Socket 서버는 단일 인스턴스 메모리 기반 설계로 운영되었습니다.
routerManager) 한 곳뿐REDIS_URL 미설정 시 부팅 실패로 운영 실수 방지d9649fdf)WRONG_INSTANCE redirect (최대 3회)SET NX EX 90s로 leader election, 30초마다 refresh, 60초 주기 실행, two-phase detectioncreateRouter/closeRouter가 await Redis 후 메모리 갱신ROOM_META_SOURCE=redis, SOCKETIO_ADAPTER=redis, REAPER_ENABLED=true이전. 메모리(primary, not durable) ↔ 클라이언트
이후. Redis(primary SoT) ↔ 메모리(read-through cache) ↔ 클라이언트
메모리 캐시는 성능 최적화용 부차 저장소. Redis 값이 정답. 재부팅/장애 후에도 복구 가능.
Production 배포 후 socket 서버가 REDIS_URL is not set로 부팅 실패. 컨테이너 재시작 루프.
.github/actions/deploy-socket/action.yml의 docker run 커맨드에 -e REDIS_URL이 없었음. dev 배포에서만 전달되고 prod는 누락.
inputs.redis_url을 required: true로 추가하고 docker run -e REDIS_URL=... 부착.
배포 직후 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
}
게스트 F5 → 호스트 화면에서 게스트 카드 사라짐. 재진입해도 복구 안 됨. 일부 케이스는 TOO_MANY_REDIRECTS로 세션 실패.
if owner == newInstanceId then
redis.call('EXPIRE', roomInstanceKey, ttl)
return {1, newInstanceId} -- 원래는 pinned=0으로 잘못 반환되던 경로
end
owner가 자기 자신인데 pinned=0으로 응답 → 클라이언트가 redirect → 자기 자신으로 → 무한 루프.
같은 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;
}
호스트가 카드뷰 토글(끼어들기, 음소거 등) 클릭 → 화면에 즉시 반영 안 됨. 2~3초 후 갑자기 반영. 다중 인스턴스 환경에서 호스트가 비-owner 인스턴스로 라우팅되면 Redis 갱신 자체가 안 됨.
routerManager의 setter 순서를 await Redis → 메모리 갱신에서 메모리 갱신 → await Redis로 바꿨는데, 호출자 37곳이 await를 부착하지 않아 stale 메모리 시점에 broadcastRoomList()가 실행됨.
// 비-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개 메서드.
호스트가 환경소음 알림을 ON → 다른 인스턴스에 연결된 모니터에는 반영 안 됨.
ambientNoiseEnabled 플래그를 메모리 변수로만 저장. Socket.IO broadcast는 같은 인스턴스의 클라이언트에게만 전파됨.
settings-store.ts 신설. ppi:socket:{stage}:settings:ambientNoiseEnabled 키에 Redis 저장. Socket.IO Redis adapter가 broadcast를 다른 인스턴스로 자동 전파.
같은 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로 원자 처리.
게스트 재접속 시 영상/음성이 2~3초 조기 종료. 일부 케이스는 TOO_MANY_REDIRECTS로 세션 시작 실패.
// 문제 있는 코드
room.peerCount--;
const shouldClose = room.peerCount <= 0; // ← await 전에 캡처
await redisDecrementPeerCount(roomId); // ← 이 동안 peer B 진입 가능!
if (shouldClose) closeRouter(roomId); // ← 살아있는 peer의 router 닫음
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)
}
같은 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();
레거시 핸들러가 roomId만 확인. 누구든 roomId만 알면 임의로 게스트에게 환경소음 알림 전송 가능.
if (targetRoom.host !== socket.id) {
logger.warn("Invalid sender for ambient-noise-alert");
return;
}
비-owner 인스턴스에서 setGroupId(), updateGuestState() 호출. Redis는 갱신되지만 owner 인스턴스 메모리는 stale 상태로 옛값을 broadcast.
// 비-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(); // 변경사항 전파
});
비-owner 인스턴스에서 setAutoTransitionEnabled() 호출 → Redis room hash가 아직 없는 초기 상태이면 partial HSET이 hash를 생성. 직후 owner의 createRouter()에서 setRoomMeta() 전체 덮어쓰기 → 사용자 설정 유실.
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.
코드 기본값 (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로 통일.
같은 Redis 클러스터를 여러 환경이 공유하므로 STAGE 환경변수로 키 격리.
ppi:socket:dev:*
ppi:socket:staging:*
ppi:socket:prod:*
STAGE는 환경변수 강제(코드 상수 금지). REDIS_URL이 설정되어 있으면 STAGE 미지정 시 부팅 실패.
Socket.IO Redis adapter 채널 prefix가 dev/prod 환경 간 충돌해서 같은 Redis 인스턴스 공유 시 메시지 누수 발생. 5/20에 7c432325 → revert(f92e2999) → 24e41e5f로 두 차례 시도 후 dev 브랜치별 분리로 해결.
| 위반 사례 | 문제 | 해결 |
|---|---|---|
| 메모리 → 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 안으로 |
공통점. 메모리와 Redis가 불일치한 상태에서 자신이 어느 쪽을 믿는지 명확하지 않음.
메모리. peerId가 room A 인덱스에 있음
Redis. peerId가 room B로 이동
→ "room A에서 SREM"할 때 메모리 참고? Redis 참고?
→ "room B 상태 broadcast"할 때 메모리의 old value 전파?
해결 원칙. ① Redis read → 최신 값 기준 판단 ② 메모리는 성능 캐시일 뿐 ③ 메모리 stale 가능성 항상 고려.
개별 Redis 명령어는 원자이지만 여러 명령어의 조합은 아님. 특히 "SET + SADD"처럼 소유권 설정 + 인덱싱 순서가 중요한 경우 반드시 Lua로 래핑. Race 우려는 CAS(Compare-And-Set) 패턴으로 overwrite 경합 차단.
| 증상 | 관련 커밋 | 로그/메트릭 |
|---|---|---|
| 호스트 카드뷰 토글 미응답 | ba1f2ba5 | guestState broadcast lag, redis_op_duration > 300ms |
| 게스트 새로고침 후 호스트 사라짐 | 08409e03 | "owner-replaced" warn 로그 |
| TOO_MANY_REDIRECTS | 80fc3beb, 08409e03 | room_pin_redirect_attempts 급증 |
| 영상 조기 종료 (2~3초) | 80fc3beb | "router close while peer active" |
| Stale peer 누적 | 98b911a2 | monitoring indices mismatch |
| Cross-instance 불일치 | b72a635d | ppi_socket_room_meta_mismatch_total 증가 |
| 사용자 설정 유실 | 5f4d5703 | "Room meta not initialized, skip" |
| 로그 | 해석 |
|---|---|
tryPinRoom failed | Redis 일시 실패 (자기 자신으로 폴백) |
Lost reaper leader lease | 다른 인스턴스가 leader 가져감 (정상) |
Failed to refresh from redis | owner 메모리 sync 실패 (pub/sub 문제?) |
Room meta not initialized, skip | 비-owner 초기화 race 감지 (정상 가드 동작) |
Guest already replaced, skip cleanup | 게스트 재진입 race 감지 (정상) |
# 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 트러블슈팅 가이드 참고.
| 날짜 | 커밋 | 내용 | 등급 |
|---|---|---|---|
| 4/28 | d9649fdf | Phase 1. 초기 Redis 이관 + room pin + reaper | MAJOR |
| 5/6 | 90c9285c | Prod 배포 REDIS_URL 누락 | BLOCKER |
| 5/6 | 88dadc43 | Terraform 토글 default redis로 | CONFIG |
| 5/7 | 08409e03 | 게스트 새로고침 disconnect race | P1 |
| 5/15 | ba1f2ba5 | Broadcast race + 비-owner no-op | P1 |
| 5/15 | bd6679e0 | Ambient noise cross-instance sync | FEATURE |
| 5/17 | 821814a5 | Stale owner 중복 + Lua 강화 | P1 |
| 5/18 | 80fc3beb | Router 종료 race + pin 폴백 | P1 |
| 5/19 | 98b911a2 | upsertPeer stale 인덱스 정리 | P1 |
| 5/19 | ce131b82 | Ambient alert 발신자 검증 | SECURITY |
| 5/19 | b72a635d | router-sync pub/sub 채널 | P1 |
| 5/19 | 5f4d5703 | 비-owner setter 가드 + stale field 정리 | P1 |
| 5/19 | 5f61cfd6 | Phase 1 최종 머지 (#634) | RELEASE |
redis_op_duration, redirect_attempts, mismatch_total로 상태 가시화3주 11개 fix를 거쳐 안정적 다중 인스턴스 운영 기반이 마련되었고, 이 회고가 다음 분산 시스템 작업의 reference가 되기를 바랍니다.