PPI-879 Redis(Valkey) 이관 회고

기간. 2026-04-28 ~ 2026-05-19 (3주) 커밋 수. 25 (Merge 포함) 핵심 fix. 11개 작성. 2026-05-24
한 줄 요약. 단일 인스턴스 메모리 SoT → 다중 인스턴스 Redis SoT 이관. 4/28 초기 feat 이후 3주에 걸쳐 11개의 race condition / sync 결함이 운영 중 발견되어 fix가 누적되었습니다. 분산 시스템의 "메모리는 캐시, Redis가 정답" 원칙이 코드 곳곳에서 위반되며 발생한 버그가 주를 이루었고, Lua 원자성 강화와 router-sync pub/sub 도입으로 안정화되었습니다.

목차

  1. 1. 이관 배경
  2. 2. 초기 설계 (4/28)
  3. 3. 발견된 11개의 문제 (시간순)
  4. 4. 운영/배포 관련 이슈
  5. 5. 회고 - 반복된 패턴
  6. 6. 재발 시 빠른 진단 단서
  7. 7. 전체 타임라인
  8. 8. 배운 점

🎯 1. 이관 배경

이관 전 한계

Socket 서버는 단일 인스턴스 메모리 기반 설계로 운영되었습니다.

이관 목적

  1. Room Instance Pin. 같은 room의 모든 peer를 한 인스턴스로 라우팅 (mediasoup ingest 단일화)
  2. Cross-instance Broadcast. Socket.IO Redis adapter로 서버 간 메시지 전파
  3. Zombie 자동 정리. Heartbeat + Reaper 패턴으로 죽은 인스턴스 자원 회수
  4. Redis SoT 원칙. 메모리는 부차, Redis가 항상 정답
  5. Fail-fast. REDIS_URL 미설정 시 부팅 실패로 운영 실수 방지

🏗 2. 초기 설계 (4/28, d9649fdf)

핵심 컴포넌트 5개

SoT 전환의 의미

이전. 메모리(primary, not durable) ↔ 클라이언트

이후. Redis(primary SoT) ↔ 메모리(read-through cache) ↔ 클라이언트

메모리 캐시는 성능 최적화용 부차 저장소. Redis 값이 정답. 재부팅/장애 후에도 복구 가능.

🔥 3. 발견된 11개의 문제 (시간순)

① Prod 배포 직후 부팅 실패

2026-05-06 90c9285c BLOCKER
증상

Production 배포 후 socket 서버가 REDIS_URL is not set로 부팅 실패. 컨테이너 재시작 루프.

원인

.github/actions/deploy-socket/action.ymldocker run 커맨드에 -e REDIS_URL이 없었음. dev 배포에서만 전달되고 prod는 누락.

해결

inputs.redis_urlrequired: true로 추가하고 docker run -e REDIS_URL=... 부착.

교훈. 인프라 변경(Redis 필수화)은 모든 배포 경로를 점검해야 함. Fail-fast가 좋은 설계지만, 배포 파이프라인을 먼저 검증할 것.

② 배포 후 토글이 memory로 표시됨

2026-05-06 88dadc43 CONFIG
증상

배포 직후 debug 페이지에서 SOCKETIO_ADAPTER=memory 표시. 코드 기본값은 redis인데 운영 토글이 memory.

원인

환경변수 체인의 마지막 단계(terraform/branch-deploy/variables.tf)에 기본값 memory가 박혀 있어서 코드 기본값을 덮어쓰고 있었음.

해결
variable "socketio_adapter" {
  default = "redis"   # memory → redis
}
variable "room_meta_source" {
  default = "redis"   # memory → redis
}
교훈. 환경 변수 + 코드 기본값 + Terraform의 override 체인을 명확히. 토글 기본값 변경 시 배포 파이프라인 함께 업데이트.

③ 게스트 새로고침 시 모니터 disconnect 유지

2026-05-07 08409e03 P1
증상

게스트 F5 → 호스트 화면에서 게스트 카드 사라짐. 재진입해도 복구 안 됨. 일부 케이스는 TOO_MANY_REDIRECTS로 세션 실패.

원인 1. PIN_ROOM_LUA 반환값 버그
if owner == newInstanceId then
  redis.call('EXPIRE', roomInstanceKey, ttl)
  return {1, newInstanceId}  -- 원래는 pinned=0으로 잘못 반환되던 경로
end

owner가 자기 자신인데 pinned=0으로 응답 → 클라이언트가 redirect → 자기 자신으로 → 무한 루프.

원인 2. signalingHandler의 중복 peer cleanup

같은 peerId가 이미 새 socket으로 점유된 상태에서 이전 socket이 disconnect 처리되며 owner-replaced 검사 없이 cleanup 실행. 호스트 라우터 cleanup과 PEER_LEFT 이벤트가 중복 발생.

해결
const isOwnerReplaced =
  guestPeerId && peers[guestPeerId]?.socketId !== socket.id;
if (isOwnerReplaced) {
  logger.warn("Guest already replaced, skip cleanup");
  return;
}
교훈. Lua 스크립트 return 값은 의미를 명확히 주석. 같은 peer 재진입 시 "이전 상태 overwrite vs 중복 처리"를 엄격히 구분.

④ 호스트 카드뷰 토글 미응답

2026-05-15 ba1f2ba5 P1
증상

호스트가 카드뷰 토글(끼어들기, 음소거 등) 클릭 → 화면에 즉시 반영 안 됨. 2~3초 후 갑자기 반영. 다중 인스턴스 환경에서 호스트가 비-owner 인스턴스로 라우팅되면 Redis 갱신 자체가 안 됨.

원인 1. Broadcast race

routerManager의 setter 순서를 await Redis → 메모리 갱신에서 메모리 갱신 → await Redis로 바꿨는데, 호출자 37곳이 await를 부착하지 않아 stale 메모리 시점에 broadcastRoomList()가 실행됨.

원인 2. 비-owner silent no-op
// 비-owner 인스턴스 (room이 메모리에 없음)
if (!room) return;  // ← Redis 쓰기 자체가 스킵됨!
해결
async updateGuestState(roomId: string, updates: Partial<GuestState>) {
  // 1. 메모리 먼저 갱신
  const room = this.rooms.get(roomId);
  if (room) {
    room.guestState = { ...room.guestState, ...updates };
  } else {
    // 2. 비-owner 브랜치도 Redis write 강제
    const current = await redisGetRoomMeta(roomId);
    if (current) {
      const merged = applyGuestStateUpdates(current.guestState, updates);
      await redisUpdateRoomMeta(roomId, { guestState: merged });
    }
    return;
  }
  await redisUpdateRoomMeta(roomId, { guestState });
}

적용 대상. createRouter, closeRouter, increment/decrementPeerCount, setAutoTransitionEnabled, setRealtimeModelVersion, setIsTest, setGroupId, setExternalSttEnabled, setAutoResponseEnabled, setVadSettings, updateGuestState 등 14개 메서드.

교훈. async 메서드의 order guarantee를 코드 주석으로 명시. 비-owner 코드패스도 Redis write를 보장해야 함.

⑤ 환경소음 알림 cross-instance 미동기화

2026-05-15 bd6679e0 FEATURE
증상

호스트가 환경소음 알림을 ON → 다른 인스턴스에 연결된 모니터에는 반영 안 됨.

원인

ambientNoiseEnabled 플래그를 메모리 변수로만 저장. Socket.IO broadcast는 같은 인스턴스의 클라이언트에게만 전파됨.

해결

settings-store.ts 신설. ppi:socket:{stage}:settings:ambientNoiseEnabled 키에 Redis 저장. Socket.IO Redis adapter가 broadcast를 다른 인스턴스로 자동 전파.

교훈. 단일 인스턴스 설계 후 다중 인스턴스로 전환 시 모든 서버 상태를 Redis로. Broadcast는 adapter가 알아서 함.

⑥ Stale owner 중복 등록

2026-05-17 821814a5 P1
증상

같은 room이 두 인스턴스의 instance:{id}:rooms set에 중복 등록. Reaper가 둘 다 owner로 간주하여 비결정적 cleanup.

원인

PIN_ROOM_LUA 실행 후 Lua 밖에서 추가 SADD 호출이 있었음. Lua eval 사이에 병렬 요청이 끼어들어 두 인스턴스가 동시에 SADD 호출 가능.

해결
-- 모든 명령어를 Lua 내부에서 원자 실행
if redis.call('EXISTS', hbKey) == 0 then
  local oldRoomsKey = keyPrefix .. 'instance:' .. owner .. ':rooms'
  redis.call('SREM', oldRoomsKey, roomId)
  redis.call('SET', roomInstanceKey, newInstanceId, 'EX', ttl)
  redis.call('SADD', newRoomsKey, roomId)  -- ← Lua 안에서 처리
  return {1, newInstanceId}
end

추가 개선. peer-store.removePeerFromStore를 CAS Lua로 변경(GUEST_POINTER_CAS_DEL_LUA), reaper.reapInstance도 각 room을 CAS Lua로 원자 처리.

교훈. Lua는 all-or-nothing. 스크립트 밖에서 추가 Redis 명령어 호출 금지. Race 우려는 CAS로 차단.

⑦ Router 종료 race + pin 폴백 회귀

2026-05-18 80fc3beb P1
증상

게스트 재접속 시 영상/음성이 2~3초 조기 종료. 일부 케이스는 TOO_MANY_REDIRECTS로 세션 시작 실패.

원인 1. decrementPeerCount race
// 문제 있는 코드
room.peerCount--;
const shouldClose = room.peerCount <= 0;  // ← await 전에 캡처
await redisDecrementPeerCount(roomId);    // ← 이 동안 peer B 진입 가능!
if (shouldClose) closeRouter(roomId);     // ← 살아있는 peer의 router 닫음
원인 2. tryPinRoom 폴백
catch (err) {
  return { pinned: true, ownerInstanceId: "" };  // ← 빈 instanceId!
}
// 클라이언트가 ?instance= 로 redirect → 무한 루프
해결
// Redis 갱신 후 peerCount 재확인
await redisDecrementPeerCount(roomId);
const stillEmpty = this.rooms.get(roomId)?.peerCount ?? 0;
if (stillEmpty <= 0) {
  await closeRouter(roomId);
}

// tryPinRoom 폴백을 자기 자신으로
catch (err) {
  return { pinned: true, ownerInstanceId: instanceId };
  // 정합성 회복은 reaper/heartbeat에 위임 (graceful degradation)
}
교훈. Async 중간값은 await 후 재확인. Redis 실패 시 최악(빈 값)보다 차악(로컬 폴백)을 택하고 background job으로 정합성 회복.

⑧ upsertPeer가 stale 인덱스를 정리하지 않음

2026-05-19 98b911a2 P1
증상

같은 peerId가 다른 roomId로 이동해도 이전 room 인덱스에 잔존. 모니터링 인덱스에 stale 누적. Reaper가 실재하지 않는 peer를 cleanup 시도.

원인

이전 코드가 새 인덱스에 SADD만 하고 이전 인덱스 SREM을 안 함. 또한 partial HSET으로 optional 필드(displayName, monitoringRoomId)가 stale 잔존.

해결
// 1. 기존 peer 데이터 읽고 비교
const prev = await redis.hgetall(peerKey(info.peerId));
const pipeline = redis.multi();

// 2. 변경된 인덱스 SREM (socket, room, role, monitoring 4종)
if (prevSocketId !== info.socketId) {
  pipeline.srem(socketPeersKey(prevSocketId), info.peerId);
}
if (prevRoomId !== newRoomId || prevRole !== newRole) {
  pipeline.srem(roomPeersKey(prevRoomId), info.peerId);
  if (prevRole === "guest") {
    pipeline.eval(GUEST_POINTER_CAS_DEL_LUA, 1, ...);  // CAS
  }
}

// 3. DEL + HSET atomic (optional 필드 stale 방지)
pipeline.del(peerKey(info.peerId));
pipeline.hset(peerKey(info.peerId), serializePeer(info));

// 4. 새 인덱스 추가
pipeline.sadd(...);
await pipeline.exec();
교훈. 역인덱싱은 이전 모든 인덱스 SREM 후 새 인덱스 SADD. Hash 부분 업데이트는 stale 필드 누적 → DEL + HSET으로 full replace.

⑨ ambient-noise-alert 발신자 검증 미흡

2026-05-19 ce131b82 SECURITY
증상 & 원인

레거시 핸들러가 roomId만 확인. 누구든 roomId만 알면 임의로 게스트에게 환경소음 알림 전송 가능.

해결
if (targetRoom.host !== socket.id) {
  logger.warn("Invalid sender for ambient-noise-alert");
  return;
}
교훈. Broadcast/emit 핸들러는 항상 발신자 검증. roomId는 공개 정보로 가정하고 추가 권한 가드 필수.

⑩ Cross-instance 변경 시 owner 메모리 stale

2026-05-19 b72a635d P1
증상

비-owner 인스턴스에서 setGroupId(), updateGuestState() 호출. Redis는 갱신되지만 owner 인스턴스 메모리는 stale 상태로 옛값을 broadcast.

해결. router-sync pub/sub 채널 신설
// 비-owner 브랜치
async setGroupId(roomId: string, groupId: string) {
  await redisUpdateRoomField(roomId, 'groupId', groupId);
  publishRoomSync(roomId);  // { roomId, by: instanceId } 발행
}

// owner 브랜치
redis.subscribe(redisKey('router-sync'));
redis.on('message', async (channel, message) => {
  const { roomId, by } = JSON.parse(message);
  if (by === instanceId) return;        // echo 방지
  if (!routerManager.hasRoom(roomId)) return;  // owner만 처리
  await refreshFromRedis(roomId);       // 메모리 동기화
  broadcastFn();                         // 변경사항 전파
});
교훈. 다중 인스턴스의 cache invalidation은 자동이 아님. Redis 갱신 후 pub/sub으로 owner에게 신호. Echo 방지를 위해 발신자 ID로 self skip.

⑪ 비-owner 초기화 race — partial HSET

2026-05-19 5f4d5703 P1
증상

비-owner 인스턴스에서 setAutoTransitionEnabled() 호출 → Redis room hash가 아직 없는 초기 상태이면 partial HSET이 hash를 생성. 직후 owner의 createRouter()에서 setRoomMeta() 전체 덮어쓰기 → 사용자 설정 유실.

해결. room 존재 가드
async setAutoTransitionEnabled(roomId: string, enabled: boolean) {
  const exists = await redis.exists(roomKey(roomId));
  if (!exists) {
    logger.warn("Room meta not initialized, skip partial update");
    return;  // ← owner가 full setRoomMeta 할 때까지 대기
  }
  await redisUpdateRoomField(roomId, 'autoTransitionEnabled', enabled);
}

적용. setAutoTransitionEnabled, setRealtimeModelVersion, setIsTest, setGroupId, setExternalSttEnabled, setAutoResponseEnabled, setVadSettings 7개 setter.

교훈. 초기화 전 partial HSET은 위험. Owner-first 원칙 - 비-owner는 room 생성을 initiate하지 않고 owner가 createRouter 할 때까지 대기.

📦 4. 운영/배포 관련 이슈

환경변수 체인의 함정

코드 기본값 (sfu-config.ts: redis)
   ↓
환경변수 (process.env.SOCKETIO_ADAPTER)
   ↓
Terraform 변수 (variables.tf: memory ← 함정!)
   ↓
GitHub Actions (dev.yml: TF_VAR_*)
   ↓
실제 배포

각 레이어가 독립적으로 설정되어 의도하지 않은 override가 발생. 5/6에 Terraform과 GitHub Actions의 기본값을 모두 redis로 통일.

STAGE 네임스페이싱

같은 Redis 클러스터를 여러 환경이 공유하므로 STAGE 환경변수로 키 격리.

ppi:socket:dev:*
ppi:socket:staging:*
ppi:socket:prod:*

STAGE는 환경변수 강제(코드 상수 금지). REDIS_URL이 설정되어 있으면 STAGE 미지정 시 부팅 실패.

Channel prefix 충돌 (별도 fix들)

Socket.IO Redis adapter 채널 prefix가 dev/prod 환경 간 충돌해서 같은 Redis 인스턴스 공유 시 메시지 누수 발생. 5/20에 7c432325 → revert(f92e2999) → 24e41e5f로 두 차례 시도 후 dev 브랜치별 분리로 해결.

🔄 5. 회고 - 반복된 패턴

패턴 A. "Redis = SoT" 원칙의 위반 케이스

위반 사례문제해결
메모리 → Redis 순서 (await 미부착) stale broadcast 호출자 14곳에 await 부착
비-owner silent no-op Redis 갱신 미발생 비-owner도 Redis write 강제
owner 메모리 stale cross-instance 불일치 router-sync pub/sub
Partial HSET 사용자 설정 유실 room 존재 가드, DEL + HSET
Lua 밖 추가 명령어 race condition 전부 Lua 안으로

패턴 B. Stale 인덱스가 반복된 이유

공통점. 메모리와 Redis가 불일치한 상태에서 자신이 어느 쪽을 믿는지 명확하지 않음.

메모리. peerId가 room A 인덱스에 있음
Redis. peerId가 room B로 이동

→ "room A에서 SREM"할 때 메모리 참고? Redis 참고?
→ "room B 상태 broadcast"할 때 메모리의 old value 전파?

해결 원칙. ① Redis read → 최신 값 기준 판단 ② 메모리는 성능 캐시일 뿐 ③ 메모리 stale 가능성 항상 고려.

패턴 C. 다중 인스턴스 race는 Lua + CAS로

개별 Redis 명령어는 원자이지만 여러 명령어의 조합은 아님. 특히 "SET + SADD"처럼 소유권 설정 + 인덱싱 순서가 중요한 경우 반드시 Lua로 래핑. Race 우려는 CAS(Compare-And-Set) 패턴으로 overwrite 경합 차단.

🩺 6. 재발 시 빠른 진단 단서

증상별 매트릭스

증상관련 커밋로그/메트릭
호스트 카드뷰 토글 미응답ba1f2ba5guestState broadcast lag, redis_op_duration > 300ms
게스트 새로고침 후 호스트 사라짐08409e03"owner-replaced" warn 로그
TOO_MANY_REDIRECTS80fc3beb, 08409e03room_pin_redirect_attempts 급증
영상 조기 종료 (2~3초)80fc3beb"router close while peer active"
Stale peer 누적98b911a2monitoring indices mismatch
Cross-instance 불일치b72a635dppi_socket_room_meta_mismatch_total 증가
사용자 설정 유실5f4d5703"Room meta not initialized, skip"

코드 로그 패턴 키워드

로그해석
tryPinRoom failedRedis 일시 실패 (자기 자신으로 폴백)
Lost reaper leader lease다른 인스턴스가 leader 가져감 (정상)
Failed to refresh from redisowner 메모리 sync 실패 (pub/sub 문제?)
Room meta not initialized, skip비-owner 초기화 race 감지 (정상 가드 동작)
Guest already replaced, skip cleanup게스트 재진입 race 감지 (정상)

Redis 상태 점검 명령

# Zombie instance 감지 (heartbeat 없지만 rooms set 있음)
redis-cli --scan --pattern 'ppi:socket:prod:instance:*:rooms' | while read key; do
  instanceId=$(echo "$key" | sed 's/.*:instance:\(.*\):rooms/\1/')
  hb=$(redis-cli EXISTS "ppi:socket:prod:instance:$instanceId:heartbeat")
  [ "$hb" -eq 0 ] && echo "ZOMBIE: $instanceId"
done

# Duplicate owner (같은 room이 두 인스턴스 set에 동시 등록)
redis-cli --scan --pattern 'ppi:socket:prod:instance:*:rooms' \
  | xargs redis-cli SINTER | sort | uniq -c | awk '$1 > 1 {print "CONFLICT: " $2}'

# Stale peer 감지 (room 인덱스 vs peer hash 불일치)
redis-cli HGET "ppi:socket:prod:room:{roomId}" peerCount
redis-cli SCARD "ppi:socket:prod:room:{roomId}:peers"

자세한 키 구조와 운영 명령은 Redis 트러블슈팅 가이드 참고.

📅 7. 전체 타임라인

날짜커밋내용등급
4/28d9649fdfPhase 1. 초기 Redis 이관 + room pin + reaperMAJOR
5/690c9285cProd 배포 REDIS_URL 누락BLOCKER
5/688dadc43Terraform 토글 default redis로CONFIG
5/708409e03게스트 새로고침 disconnect raceP1
5/15ba1f2ba5Broadcast race + 비-owner no-opP1
5/15bd6679e0Ambient noise cross-instance syncFEATURE
5/17821814a5Stale owner 중복 + Lua 강화P1
5/1880fc3bebRouter 종료 race + pin 폴백P1
5/1998b911a2upsertPeer stale 인덱스 정리P1
5/19ce131b82Ambient alert 발신자 검증SECURITY
5/19b72a635drouter-sync pub/sub 채널P1
5/195f4d5703비-owner setter 가드 + stale field 정리P1
5/195f61cfd6Phase 1 최종 머지 (#634)RELEASE

💡 8. 배운 점

분산 시스템 설계

  1. SoT 명확화. 각 데이터마다 "진짜 값은 어디인가" 문서화
  2. Lua로 boundary 그리기. 여러 Redis 명령어 조합은 무조건 Lua로 원자화
  3. CAS 적용. Overwrite race는 Lua 조건문으로 차단
  4. 두 타임스케일 운영. 즉시(heartbeat, room pin) vs 결국 일관성(reaper, stale 정리)

코드 구조

  1. 비-owner 코드패스 강제. 모든 메서드는 local room 없이도 작동
  2. Broadcast 순서 명확. 메모리 갱신 → broadcast → Redis update
  3. Guard clause 우선. "조건 없으면 skip"이 "조건 있으면 작동"보다 safe
  4. Stale field cleanup. upsert 시 이전 모든 인덱스 SREM

배포/운영

  1. 환경변수 체인 문서화. code → env → terraform → actions 일관성 검증
  2. Fail-fast 정책. 설정 누락은 침묵보다 즉시 실패가 안전
  3. 모든 배포 경로 점검. prod, branch-deploy, dev 모두 일관성
  4. 메트릭 투명성. redis_op_duration, redirect_attempts, mismatch_total로 상태 가시화

최종 아키텍처

3주 11개 fix를 거쳐 안정적 다중 인스턴스 운영 기반이 마련되었고, 이 회고가 다음 분산 시스템 작업의 reference가 되기를 바랍니다.