입장 몰림 시 프로필 조회 실패 → 듣기 OFF 완화 + 리소스 목록 캐시

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

b28aa399 charles-na · 2026-09-23 Fix 15 files +1372 −64

PR #1135 · PPI-1336

수업 시작 정각에 socket→web 프로필 조회(시도당 3초, 1회 재시도)가 실패하면 음성 아동이 듣기 OFF로 시작하고, 나중에 조회가 성공해도 그대로 남던 문제를 완화합니다. socket은 지난 프로필(last-known)로 폴백하고, 뒤에서 다시 조회해 최신 프로필로 맞춥니다. web은 아동 입장 경로의 리소스 목록 전체 Scan을 60초 캐시로 줄입니다. 리뷰할 때는 applyProfileListeningIntent의 revision 0 CAS와 같은 commandId replay를 가장 먼저 보세요.

그림으로 보기 — 쉽게 이해하기

socket 서버를 접수 창구, web 서버를 본사 전화, 아동 프로필을 "이 아이는 말로 수업하나요?"라는 질문으로 생각하면 쉽습니다.

문제 본사(web) 3초 무응답 → 듣기 OFF

정각에 전화가 몰림

아이들이 한꺼번에 들어오면 본사가 제때 답을 못 줍니다. 그러면 말로 수업하는 아이도 듣기 OFF로 시작하고, 나중에 답이 와도 그대로 남았습니다.

해결 1 음성 ✓ 수첩(Redis)에 14일 보관

지난번 답을 수첩에서 꺼냄

전화가 안 되면 지난 수업 때 적어 둔 답을 봅니다. 수첩에 "음성"이라고 적혀 있으면 ON으로 시작합니다.

해결 2 5초15초30초 통화 성공 → 맞춤 수첩이 없거나 낡으면 재전화

잠시 뒤 다시 전화해서 바로잡음

본사가 한가해지면 최신 답을 받아 듣기 상태를 맞춥니다. 중간에 전달이 끊겨도 같은 접수번호(commandId)로 이어서 보냅니다.

안전장치 0 1+ 자동으로 바꿈손대지 않음 번호표 = revision

진행자가 손댄 방은 건드리지 않음

진행자가 듣기를 한 번이라도 바꾸면 번호표가 올라갑니다. 자동 교정은 번호표가 0일 때만 합니다. 진행자가 바꾼 VAD(말 끝 인식 시간)도 그대로 둡니다.

해결 3 · web 리소스 창고 1분에 1번 메모장(60초) Scan 290번 → 1~3번 (추정)

창고를 매번 뒤지지 않음

아이마다 리소스 창고 전체를 뒤지던 것을, 1분에 한 번만 뒤져 메모장에 적어 두고 함께 씁니다. 그만큼 본사가 한가해져 전화도 잘 받게 됩니다(효과는 배포 후 측정).

대가 새 파일은 최대 60초 늦게

새로 올린 건 잠깐 모름

메모장이 1분짜리라 방금 올린 파일은 최대 1분 뒤에 보입니다. 수업 리소스는 수업 전에만 바뀌어서 괜찮다고 합의했습니다. 관리자 화면은 메모장을 쓰지 않습니다.

무엇이, 왜 바뀌었나

원인분석 문서 §7의 1차 묶음(1·2·3①②·4)을 구현했습니다. 웹 메인 스레드 포화가 원인이라는 결론은 아직 미확정이므로, 원인과 관계없이 효과가 있는 조치만 넣었습니다. 타임아웃은 늘리지 않았습니다.

흐름 — 아동 입장 시 프로필 결정과 교정

1

web 조회 (5분 캐시 · 동시 조회 합침)

apps/socket/src/utils/profile-client.ts

성공하면 Redis profile:last-known:{userId}에 14일 보관합니다.

2

실패 시 last-known, 없으면 OFF

room-handlers.ts resolveEntryProfile

지표의 source 라벨은 profile / last_known / fallback입니다.

3

5·15·30초 재조회 → 최신 프로필로 맞춤

room-handlers.ts scheduleProfileListeningSync

web 조회에 성공하지 못한 입장만 대상입니다. 복구가 busy·unavailable·예외면 남은 횟수 안에서 재시도하고, 소켓이 방을 떠나면 중단합니다.

4

revision 0 CAS + 전달

avatar-handlers.ts applyProfileListeningIntent

ON/OFF 양방향으로 맞추고, 전달은 아동과 모니터 모두에게 보냅니다.

코드로 보는 핵심 지점

① revision 0 CAS와 같은 commandId replay

CAS Lua는 revision을 비교하기 전에 commandId 기록부터 확인합니다. 그래서 커밋은 됐는데 전달이 실패한 경우, 같은 id로 다시 호출하면 커밋된 결과를 되돌려받고 전달만 이어서 할 수 있습니다. replay 뒤 진행자가 더 새 revision을 만들었다면 예전 상태를 다시 퍼뜨리지 않습니다.

apps/socket/src/sfu-socket/handlers/avatar-handlers.ts if (snapshot.revision === 0 && snapshot.listeningIntent === listeningIntent) { return "unchanged"; } const result = await compareAndSetVoiceControl({ roomId, expectedRevision: 0, commandId, listeningIntent, }); if (result.status === "conflict") return "conflict"; if (result.replayed) { const current = await getVoiceControlSnapshot(roomId); if (current.revision !== result.snapshot.revision) return "conflict"; } await publishCommittedVoiceControlCommand({ ... });

② 입장 시 프로필 결정 — 지난 프로필 폴백

apps/socket/src/sfu-socket/handlers/room-handlers.ts let isVoiceUser = false; try { const profile = await getUserProfile(userId); ... } catch { /* isVoiceUser=false */ } const { profile, source: initSource } = await resolveEntryProfile(roomId, userId); ... if (initSource !== "profile") { scheduleProfileListeningSync(target); } else if (voiceControlSnapshot.revision === 0 && voiceControlSnapshot.listeningIntent !== isVoiceUser) { // 이전 입장의 폴백·이전 프로필 snapshot이 남아 있으면 즉시 맞춘다.

③ VAD — 프로필로 넣은 값만 갱신

입장할 때 프로필로 넣은 VAD를 기억해 둡니다. 방의 현재 VAD가 그 값과 같을 때만 최신 프로필 값으로 바꾸고, 다르면 진행자가 바꾼 것으로 보고 그대로 둡니다.

apps/socket/src/sfu-socket/handlers/room-handlers.ts const current = routerManager.getVadSettings(target.roomId); if (!sameVadSettings(current, target.seededVad)) { logger.info("Profile VAD sync skipped, room VAD changed after entry", ...); return; }

④ web 리소스 목록 캐시 — Scan 횟수 감소

apps/web/lib/api/services/resource.service.ts const allResources = await getResources(); const matchingResources = allResources.filter((r) => r.fileName.replace(/\.[^/.]+$/, "") === resName); const { byBaseName } = await getResourceCatalog(); for (const resource of byBaseName.get(resName) ?? []) { apps/web/lib/resource-catalog.ts (신규) const CATALOG_TTL_MS = 60_000; // 동시 요청은 pending Promise 하나를 공유, 실패는 캐시 안 함 const state = (globalStore.__ppiResourceCatalog ??= { ... }); // 라우트 번들이 달라도 한 벌

레이어별 변경 요약

레이어파일핵심 변경
socketapps/socket/src/utils/profile-client.ts캐시를 30초에서 5분으로 늘림, 동시 조회 합침, last-known 저장(14일)과 getLastKnownUserProfile 추가. 타임아웃과 재시도는 그대로
socketapps/socket/src/redis/key-prefix.tsuserProfileLastKnownKey 추가
socketapps/socket/src/sfu-socket/handlers/room-handlers.ts입장 프로필 결정, 재조회 예약(같은 commandId), 즉시 교정, VAD 동기화
socketapps/socket/src/sfu-socket/handlers/avatar-handlers.tsapplyProfileListeningIntent: revision 0 CAS, replay 재개, 전환 중이면 busy
webapps/web/lib/resource-catalog.ts60초 캐시, 동시 요청은 Scan 1회 공유, 파일명·확장자 제거 이름 색인
webapps/web/lib/api/services/resource.service.ts스텝 리소스 매칭과 소음 알림 이미지 수집에 캐시 사용. guest-data 1회당 Scan이 2회에서 0~1회로 줄어듦
webapps/web/app/api/resources/urls/route.ts파일명 경로만 캐시 사용, id 경로는 DB 조회 유지
webapps/web/app/api/resources/[id]/route.ts삭제 시 invalidateResourceCatalog()
testsocket 테스트 4개, web 테스트 3개socket 134건, web 11건 통과. 수정 전 동작을 고정하는 특성 테스트 6건 포함

리뷰 관전 포인트

동작 확인

실제 환경 검증 없음. 모든 검증은 목(mock) 기반 테스트입니다. staging에서 web 프로필 API를 지연시키거나 막은 상태로 음성 아동을 입장시켜 확인해야 합니다. last-known이 있으면 ON, 없으면 OFF로 시작했다가 수 초 뒤 자동으로 ON이 되어야 하고, Profile listening sync finished 로그가 남아야 합니다.

구조

replay 뒤 revision을 확인하는 순간과 전달 사이에 진행자가 조작하면, 아주 짧은 경합 구간이 남습니다. 방이 다른 socket 인스턴스에 있으면 이 서버에서 VAD를 읽을 수 없어서 보수적으로 갱신하지 않습니다. 또 room-handlers가 크기가 큰 avatar-handlers 모듈을 가져오게 됐습니다(순환 참조는 없음).

운영 영향

모니터링 지표 init_room_defaults_from_profile_total의 source 라벨에 last_known 값이 새로 생깁니다. fallback만 세는 대시보드는 쿼리를 확인해야 합니다. 14일 사이 수업 유형이 바뀐 아동은 첫 조회가 실패하면 잠깐 지난 값으로 시작하지만, 재조회가 성공하면 교정됩니다.

영향 범위

관리자 화면은 캐시하지 않아 동작이 그대로입니다. 캐시 지연 60초는 수업 전 리소스 변경 기준으로 합의했습니다. 효과는 배포 후 같은 요일·시간대의 프로필 aborted 건수, guest-data p50, process_cpu_seconds_total rate로 측정합니다.

관련 문서

원인 분석: 수업 시작 프로필 조회 타임아웃 → 듣기 OFF — 요청·CPU 재집계와 원인 범위 — 이 PR은 이 문서 §7의 1차 묶음을 구현한 것입니다.

재사용한 패턴: 텍스팅 수업 진행자 부재 시 AI 듣기 OFF — presence fallback 설계 — 기존 자동 복구의 CAS·전달 경로를 그대로 사용했습니다.