PresenceTracker.cs 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. using Application.Abstractions.Cache;
  2. using Application.Abstractions.Hub;
  3. using StackExchange.Redis;
  4. namespace Infrastructure.Hubs;
  5. /// <summary>
  6. /// SignalR 멤버 접속 추적 (Redis 기반).
  7. /// `member:connections:{memberID}` set에 connectionId 저장 → refcount 패턴.
  8. /// 첫 connection 진입 시 `member:online` set에 추가, 마지막 disconnect 시 제거.
  9. /// </summary>
  10. internal sealed class PresenceTracker(IConnectionMultiplexer redis) : IPresenceTracker
  11. {
  12. public async Task<bool> AddConnectionAsync(int memberID, string connectionId)
  13. {
  14. var db = redis.GetDatabase();
  15. var key = CacheKeys.MemberConnections(memberID);
  16. var added = await db.SetAddAsync(key, connectionId);
  17. if (!added)
  18. {
  19. // 동일 connectionId 중복 등록 — 변화 없음
  20. return false;
  21. }
  22. // 항상 MemberOnlineSet 반영. SADD 의 set semantics 로 idempotent.
  23. // 이전 버전은 totalConnections==1 가드를 두었으나, Web.Api 비정상 종료로 남은
  24. // 고아 connectionId 가 set 에 있을 때 신규 connection 이 와도 online set 에 반영되지 않는 버그가 있었음.
  25. var wasNewlyOnline = await db.SetAddAsync(CacheKeys.MemberOnlineSet, memberID);
  26. return wasNewlyOnline; // true = 0→1 전환 (online set 에 새로 추가됨)
  27. }
  28. public async Task ClearAllAsync()
  29. {
  30. // Web.Api 재시작 시 stale presence 데이터 정리. 모든 client 는 reconnect 시 OnConnected 로 재등록됨.
  31. var db = redis.GetDatabase();
  32. var endpoints = redis.GetEndPoints();
  33. foreach (var endpoint in endpoints)
  34. {
  35. var server = redis.GetServer(endpoint);
  36. if (!server.IsConnected || server.IsReplica)
  37. {
  38. continue;
  39. }
  40. // member:connections:* 패턴 일괄 삭제
  41. await foreach (var key in server.KeysAsync(database: db.Database, pattern: "member:connections:*"))
  42. {
  43. await db.KeyDeleteAsync(key);
  44. }
  45. }
  46. // member:online set 전체 삭제
  47. await db.KeyDeleteAsync(CacheKeys.MemberOnlineSet);
  48. }
  49. public async Task<bool> RemoveConnectionAsync(int memberID, string connectionId)
  50. {
  51. var db = redis.GetDatabase();
  52. var key = CacheKeys.MemberConnections(memberID);
  53. var removed = await db.SetRemoveAsync(key, connectionId);
  54. if (!removed)
  55. {
  56. return false;
  57. }
  58. // 마지막 connection이면 online set에서 제거 + 빈 set 삭제
  59. var remaining = await db.SetLengthAsync(key);
  60. if (remaining == 0)
  61. {
  62. await db.KeyDeleteAsync(key);
  63. await db.SetRemoveAsync(CacheKeys.MemberOnlineSet, memberID);
  64. return true; // 1→0 전환
  65. }
  66. return false;
  67. }
  68. public async Task<bool> IsOnlineAsync(int memberID)
  69. {
  70. var db = redis.GetDatabase();
  71. return await db.SetContainsAsync(CacheKeys.MemberOnlineSet, memberID);
  72. }
  73. public async Task<HashSet<int>> GetOnlineMembersAsync(IEnumerable<int> memberIDs)
  74. {
  75. var ids = memberIDs.Distinct().ToArray();
  76. if (ids.Length == 0)
  77. {
  78. return [];
  79. }
  80. var db = redis.GetDatabase();
  81. var values = ids.Select(id => (RedisValue)id).ToArray();
  82. var results = await db.SetContainsAsync(CacheKeys.MemberOnlineSet, values);
  83. var online = new HashSet<int>();
  84. for (int i = 0; i < ids.Length; i++)
  85. {
  86. if (results[i])
  87. {
  88. online.Add(ids[i]);
  89. }
  90. }
  91. return online;
  92. }
  93. }