PPI-1276 그룹수업 "듣기 확인 중" 고착
— join-room 거부 재시도 부재 원인 분석 및 수정 구현 완료 · 미커밋

마지막 업데이트 2026-09-12

쉬운 설명 한 장 보기 · 사전지식 없이 읽는 요약

작성: 2026-09-07대상 세션: 문세찬 1회기 (2026-09-05)브랜치: PPI-1276 (worktree, 미커밋)

① 코드 경로

이 문서가 다루는 파일·함수. 수정된 두 파일은 수정로 표시.

파일대상
apps/web/entities/socket-connection/model/use-mediasoup-socket.tsjoinRoom의 JOIN_ROOM ack 콜백 실패 분기 (구 391-404행 → 신 412-437행)수정
apps/web/entities/socket-connection/model/join-attempt-fence.tsnextJoinRejectionRetryDelay (78행), createSessionReconcileRetryController (14행, 재사용)수정
apps/web/entities/socket-connection/model/join-attempt-fence.test.ts회귀 테스트 2건 추가수정
apps/socket/src/sfu-socket/handlers/connection-handlers.tsJOIN_ROOM 핸들러 (431행) — 여러 사유로 respond({success:false, error:…})미수정 (분석 대상)
apps/socket/src/sfu-socket/handlers/room-handlers.tsguest join 시 VOICE_CONTROL_UPDATED push (257행)미수정 (분석 대상)
apps/web/entities/guest-page-session/model/use-guest-page-session.tsapplyVoiceControlSnapshot (1151행), handleReconnected (2066행)미수정 (분석 대상)
apps/web/entities/monitor-controls/model/voice-control-display-state.tsresolveVoiceControlDisplayState (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.errorreason: response?.error를 추가해, 다음 재발 시에는 바로 원인 문자열을 확인할 수 있게 했다.

⑤ 주의사항과 이슈 히스토리

확진하지 못한 부분 UNKNOWN

SFU가 왜 거부했는지는 이번 범위에서 미확정. 문세찬 세션(2026-09-05 11:03:09)의 게스트 클라이언트 로그에는 {"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 로그로 확인 필요.

이슈 히스토리

관련 문서