PPI-1276 그룹수업 "듣기 확인 중" 고착
— join-room 거부 재시도 부재 원인 분석 및 수정 구현 완료 · 미커밋
마지막 업데이트 2026-09-12
① 코드 경로
이 문서가 다루는 파일·함수. 수정된 두 파일은 수정로 표시.
| 파일 | 대상 | |
|---|---|---|
apps/web/entities/socket-connection/model/use-mediasoup-socket.ts | joinRoom의 JOIN_ROOM ack 콜백 실패 분기 (구 391-404행 → 신 412-437행) | 수정 |
apps/web/entities/socket-connection/model/join-attempt-fence.ts | nextJoinRejectionRetryDelay (78행), createSessionReconcileRetryController (14행, 재사용) | 수정 |
apps/web/entities/socket-connection/model/join-attempt-fence.test.ts | 회귀 테스트 2건 추가 | 수정 |
apps/socket/src/sfu-socket/handlers/connection-handlers.ts | JOIN_ROOM 핸들러 (431행) — 여러 사유로 respond({success:false, error:…}) | 미수정 (분석 대상) |
apps/socket/src/sfu-socket/handlers/room-handlers.ts | guest join 시 VOICE_CONTROL_UPDATED push (257행) | 미수정 (분석 대상) |
apps/web/entities/guest-page-session/model/use-guest-page-session.ts | applyVoiceControlSnapshot (1151행), handleReconnected (2066행) | 미수정 (분석 대상) |
apps/web/entities/monitor-controls/model/voice-control-display-state.ts | resolveVoiceControlDisplayState (11행) — hasGuestAppliedSnapshot 기준 "checking" 표시 | 미수정 (증상 발생 지점) |
② 목적 요약
그룹수업 진행자 화면의 "듣기" 버튼이 "확인 중"에 영구 고착되는 증상을 분석하고, 근본 원인인 join-room 거부 시 재시도 부재를 use-mediasoup-socket.ts에 bounded backoff 재시도를 추가해 수정한다.
③ 입출력 흐름
정상 케이스에서 "듣기 ON" 의도가 진행자 화면에 "On"으로 확정되기까지의 흐름. FACT는 코드로 확인, HYPOTHESIS는 로그 정황상 추정.
[정상 흐름] 게스트 소켓 connect → use-mediasoup-socket.ts: joinRoom() 이 JOIN_ROOM emit → apps/socket connection-handlers.ts: JOIN_ROOM 핸들러가 승인 → apps/socket room-handlers.ts:257 socket.emit(VOICE_CONTROL_UPDATED, snapshot) // 게스트 소켓에 직접 push → use-guest-page-session.ts:2607 applyVoiceControlSnapshot(nextSnapshot) → 게스트 로컬에 리스닝 상태 적용 + socket.emit(VOICE_CONTROL_APPLIED, receipt) → 서버가 delivery status를 guest_applied 로 기록 → 진행자 쪽 use-guest-controls.ts 가 10초 주기 폴링(GET_SNAPSHOT) 또는 push 로 수신 → voice-control-display-state.ts:23 hasGuestAppliedSnapshot=true → 버튼 표시: "확인 중" → "On"/"Off" [문세찬 세션에서 끊긴 지점] use-mediasoup-socket.ts (수정 전): JOIN_ROOM 거부 시 재시도 없이 setError만 하고 종료 → room-handlers.ts:257 의 push 자체가 실행되지 않음 → applyVoiceControlSnapshot 호출 안 됨 → VOICE_CONTROL_APPLIED ack 전송 안 됨 → hasGuestAppliedSnapshot 계속 false → "확인 중" 영구 고착 (오디오 자체는 다른 경로로 이미 정상 작동 중이었음 — HYPOTHESIS)
④ 함수·모듈별 세부 설명
voice-control-display-state.ts — 왜 "확인 중"이 뜨는가 FACT
resolveVoiceControlDisplayState()(11행)는 voiceControl.hasGuestAppliedSnapshot이 false인 동안 deliveryStatus가 무엇이든(pending이든 이미 실패든) 무조건 status: "checking"을 반환한다.
if (voiceControl.hasGuestAppliedSnapshot) {
// listeningIntent 값으로 on/off 확정
}
return { status: "checking", … }; // hasGuestAppliedSnapshot=false인 한 무조건
즉 이 필드가 한 번이라도 true가 되지 않으면, 세션이 끝날 때까지 "확인 중"만 표시된다.
room-handlers.ts:257 — 서버의 유일한 push 지점 FACT
게스트가 JOIN_ROOM을 성공적으로 완료할 때마다(최초 입장이든 재연결 후 rejoin이든) 서버가 VOICE_CONTROL_UPDATED를 해당 게스트 소켓에 직접 emit한다. 주석에 "재접속/푸시 유실 뒤에도 durable snapshot을 직접 hydrate한다"고 명시돼 있어, 재연결 시 재동기화는 이미 설계돼 있었다. 문제는 이 지점이 JOIN_ROOM 성공을 전제한다는 것.
use-mediasoup-socket.ts — join-room 거부 시 재시도가 없었다 FACT (수정 전 근본 원인)
구 391-404행: response.success === false이고 WRONG_INSTANCE·LESSON_SESSION_RECONCILE_RETRY가 아닌 일반 거부일 때, setError()만 하고 끝났다. 다음 joinRoom 호출은 오직 소켓이 disconnect → connect될 때(handleConnect, 442행)만 일어난다. 즉 소켓 연결은 유지된 채 join만 거부되면(문세찬 세션의 socketConnected:true 케이스), 다음 우연한 재연결 사이클이 올 때까지 아무 것도 재시도되지 않았다.
수정 — bounded backoff 재시도 FACT (이번 변경)
기존 LESSON_SESSION_RECONCILE_RETRY 처리에 이미 쓰이던 createSessionReconcileRetryController({maxRetries:3, retryDelayMs:1_000})(순수·기존 테스트 보유)를 동일한 패턴으로 재사용해 joinRejectionRetry라는 두 번째 인스턴스를 추가했다. 판단 로직은 join-attempt-fence.ts:78의 순수 함수로 분리:
// 소켓이 이미 끊긴 상태면 재시도를 예약하지 않는다 — 곧 이어질 disconnect 이벤트가 // 별도로 재연결·재입장 사이클을 시작하므로, 여기서 또 예약하면 두 경로가 중복된다. export function nextJoinRejectionRetryDelay( controller, connectionGeneration, socketConnected ) { if (!socketConnected) return null; return controller.nextDelay(connectionGeneration); }
use-mediasoup-socket.ts:412-437 실패 분기에서 이 함수로 재시도 여부를 판정하고, 재시도 가능하면 window.setTimeout으로 joinRoom(connectionGeneration)을 다시 호출한다(1s → 2s → 3s, 최대 3회). 재시도를 예약할 때는 기존의 invalidateJoinSnapshot/onJoinInvalidated/setError를 건너뛰어 진행자·게스트 화면에 불필요한 오류 상태를 노출하지 않고, 재시도가 모두 소진됐을 때만 원래 동작(에러 표시)으로 떨어진다.
타이머는 handleDisconnect·disabled 전환·언마운트 cleanup 등 기존 sessionReconcileRetry와 동일한 생명주기 지점에서 함께 invalidate되도록 배선했다(중복 예약·좀비 타이머 방지).
로그 보강 FACT
이번 조사에서 정확한 거부 사유(response.error)가 로그에 남지 않아 서버 쪽 확진이 막혔다. console.error에 reason: response?.error를 추가해, 다음 재발 시에는 바로 원인 문자열을 확인할 수 있게 했다.
⑤ 주의사항과 이슈 히스토리
확진하지 못한 부분 UNKNOWN
{"category":"join_rejected","role":"guest","socketConnected":true}만 남아 있고, 실제 response.error 문자열은 콘솔에 기록되지 않았다(상태에만 저장). 서버(Loki, ppi-socket-prod) 로그도 msg가 마스킹돼 있어 connection-handlers.ts의 다수 거부 분기(unauthorized, peer id already in use, peer id join in progress 등) 중 어느 것인지 특정하지 못했다. 같은 시간대 SFU-SessionHandlers에서 {"reason":"unauthorized"} 경고가 몰려 있었으나 roomId가 없어 이 세션 것이라고 단정할 근거는 없다.
잔여 위험 HYPOTHESIS
joinRejectionRetry는 자체 beginConnection()을 호출하면서도, nextDelay() 인자로는 sessionReconcileRetry가 반환한 connectionGeneration 정수를 그대로 재사용한다(코드 448-449행 주석 참고). 두 컨트롤러의 beginConnection()이 handleConnect 한 곳에서만 함께 호출되는 현재 구조에서는 두 generation이 항상 동기화되므로 안전하지만, 이 지점을 나중에 따로 손대면(둘 중 하나만 호출) 재시도가 조용히 비활성화될 수 있다. crash나 데이터 손상은 없고 수정 전 동작으로 되돌아갈 뿐이라 P2 수준으로 코드 리뷰에서 보고만 하고 넘어갔다.
검증 FACT
pnpm install(신규 worktree) → tsc --noEmit 대상 파일 오류 없음 → tsx --test join-attempt-fence.test.ts 8/8 통과(신규 2건 포함) → prettier --check 통과 → pnpm run build 성공.
next lint/pnpm run lint는 이 워크트리의 Next.js 16 CLI가 lint 서브커맨드를 더 이상 지원하지 않아 실행 불가(사전에 존재하는 환경 이슈, 이번 변경과 무관 — npx eslint 직접 호출도 flat-config 순환참조로 실패). 실기기·실 세션 재현 검증은 아직 못 했다 — 다음 재발 시 reason 로그로 확인 필요.
이슈 히스토리
- 2026-09-05 11:03 — 문세찬 1회기 그룹수업, 세션 시작 직후 게스트 연결 불안정(transport_connect_error·transport close·LiveKit websocket code 1006) 구간에서
join_rejected1회 관측. - 2026-09-07 — 원인분석(
probe절차) → 대화 중 join-room 재시도 부재로 좁혀짐 → brainstorming으로 bounded fix 설계 승인 →PPI-1276worktree에 구현·검증 → 이 문서 작성.
관련 문서
- PPI-1167 듣기 자동 ON revert 후 재랜딩 복원 실패와 presence race 수정 — 같은 "듣기 ON" 도메인이지만 원인은 게스트 쪽 configured intent 이중 소스 문제로 다름
- PPI-1167 파일럿 "사라진 듣기 ON" 사건 수사반장