Redis 인프라 (heartbeat·leader election·reaper) — 코드레벨 동작 흐름 P1코드레벨

마지막 업데이트 2026-07-22

작성일: 2026-06-14 대상: 개발자 — 다중 인스턴스 생존 감지/정리 파악 핵심 파일: apps/socket/src/redis/{client,reaper,reaper-scheduler,peer-store,key-prefix}.ts

개요 · 범위

다중 인스턴스 SFU에서 인스턴스의 생존(heartbeat)을 추적하고, 죽은 인스턴스가 Redis에 남긴 room/peer 메타를 한 명의 leader가 안전하게 정리(reaper)하는 인프라. room 소유권·setter 모델은 #5 Router 매니저, 본 문서는 그 짝인 생존/정리 계층.

AI-driven 영역(@handover-redis): 설계 의도 원본은 docs/handover/jacob/redis-infra.md(PR #634). 의도는 코드·커밋이 진실(추측 금지).

Heartbeat — 생존 신호 client.ts:160–182

각 인스턴스가 heartbeatIntervalMs마다 자기 생존을 Redis에 기록한다.

tick():
  multi()
    .set(instanceHeartbeat:{id}, now, EX ttl)   // 개별 키(TTL)
    .zadd(heartbeatZset, now, id)               // ZSET score=마지막 갱신 시각
  + room pin TTL refresh (자기 소유 room, ttl×3)  // eval은 multi 밖
  • ZSET이 핵심: heartbeatZset의 score가 각 인스턴스의 마지막 heartbeat 시각. reaper가 이걸로 stale을 판정.
  • heartbeat과 함께 자기 소유 room의 pin TTL(ttl×3=90s)도 갱신 → 살아있는 동안 소유권 유지(#5 PIN_ROOM_LUA의 stale takeover와 짝).

Stale 판정 reaper.ts: findStaleInstances L45

cutoff = now - staleThresholdMs()        // staleThreshold = heartbeatTtl × 1.5
zrangebyscore(heartbeatZset, -inf, cutoff)  →  stale 후보 인스턴스 ID

score(마지막 heartbeat)가 cutoff보다 오래된 인스턴스가 후보. 단 후보 ≠ 즉시 정리 — two-phase 재확인을 거친다(아래).

Leader Election — 한 명만 정리 reaper-scheduler.ts

모든 인스턴스가 reaper를 돌리면 충돌·중복 정리가 생긴다. 그래서 leader lease를 잡은 인스턴스만 reaper를 실행한다.

동작구현
획득(tryAcquireLeader)reaper:leader 키를 SET NX(lease 90s TTL)
갱신(refreshLease, 30s)CAS: 자기 instanceId가 적힌 lease만 EXPIRE 연장(남의 lease 실수 연장 방지)
해제(releaseLease)shutdown 시 자기 lease만 CAS DEL → 다른 인스턴스가 빠르게 인계

인스턴스가 죽으면 lease(90s TTL)가 만료되어 60초+α 내에 다른 인스턴스가 leader 인계. refreshLease 실패 시 leaderHeld=false로 자진 강등.

정리 루프 (two-phase) runOnce L96–121

  1. 1leader 아니면 acquire 시도 → 실패면 return(아무것도 안 함).
  2. 2findStaleInstances → 후보 없으면 return.
  3. 360초 대기(TWO_PHASE_DELAY) 후 각 후보를 isReallyDeadheartbeat 재확인.
  4. 4진짜 죽은 것만 reapInstance. (자기 자신은 skip)
왜 two-phase인가: 일시적 네트워크 spike로 heartbeat이 한 번 누락된 정상 인스턴스를 reap하면, 살아있는 세션의 room 메타가 지워진다. 60초 대기 후 재확인으로 일시 누락을 걸러낸다.

인스턴스 정리 (owner CAS) reapInstance L67 / REAP_ROOM_LUA L28

-- 각 room을 owner CAS로 원자 삭제
local owner = GET roomInstance:{roomId}
if owner ~= deadInstanceId then return 0 end   -- 이미 takeover됨 → skip
DEL room, roomInstance, guestState, ... (7키)
SREM instance:dead:rooms, roomId
return 1
  1. instance:{dead}:rooms / :peers SMEMBERS로 죽은 인스턴스 소유 목록 수집.
  2. 각 room을 REAP_ROOM_LUA로 원자 삭제 — owner가 여전히 dead일 때만(다른 인스턴스가 이미 pin takeover한 room은 건드리지 않음).
  3. peer 매핑 정리 + heartbeat 키/ZSET 엔트리 제거.
mediasoup Router는 복구 불가: reaper는 Redis 메타만 비운다. 실제 미디어는 죽은 인스턴스와 함께 사라졌으므로, 클라이언트는 재접속하면서 room pin으로 살아있는 인스턴스에 새로 붙는다(#5).

peer-store stale 매핑 방지 peer-store.ts: upsertPeer L51

게스트가 빠르게 재접속하면 새 upsertPeerroomGuest 포인터를 새 peerId로 갱신한다. 이때 변경된 인덱스 키마다 이전 값에서 명시적으로 SREM하지 않으면 이전 매핑이 영구 stale로 남는다(id-032). upsert는 "이전 인덱스 정리 후 새 매핑" 순서를 지킨다.

키/채널 prefix 격리 key-prefix.ts

모든 Redis 키·pub/sub 채널은 STAGE/브랜치별 prefix로 격리된다(#640, id-009). 같은 Redis 인스턴스를 dev/prod 또는 dev 브랜치 간 공유해도 이벤트·키가 충돌하지 않는다. 새 키/채널 추가 시 반드시 key-prefix.ts 정책을 거칠 것.

함정 · 주의 출처: redis-infra.md#failure-modes

  • two-phase 제거 금지: 60초 재확인을 빼면 일시 네트워크 spike에 정상 인스턴스가 reap되어 라이브 세션 메타가 날아간다.
  • owner CAS 필수: REAP_ROOM_LUA의 owner==dead 검사를 빼면, 이미 다른 인스턴스가 takeover한 room을 지워버린다.
  • lease CAS: refresh/release는 자기 lease만 다뤄야 한다(남의 lease 연장/삭제 시 leader 중복).
  • self-reap 방지: reapInstance/runOnce는 자기 instanceId를 건너뛴다.
  • upsertPeer 인덱스 정리: 변경된 인덱스 키 SREM 누락 시 stale 매핑 누적(id-032).
  • 채널/키 prefix: 미적용 시 dev/prod·브랜치 간 누설(#640).
  • memory-only 모드: REDIS_URL 비면 heartbeat/reaper 모두 no-op(단일 인스턴스 가정). 다중 인스턴스 운영엔 Redis 필수.

파일 · 라인 레퍼런스

파일/심볼역할
redis/client.ts (tick L160)heartbeat 발행 + room pin TTL refresh
redis/reaper.ts (findStaleInstances L45, reapInstance L67, REAP_ROOM_LUA L28)stale 판정·인스턴스 정리
redis/reaper-scheduler.ts (runOnce L96)leader election·two-phase 루프
redis/peer-store.ts (upsertPeer L51)peer 매핑·stale 정리
redis/key-prefix.tsSTAGE/브랜치 prefix 정책
docs/handover/jacob/redis-infra.md설계 의도 원본