Bugs · 조사 결과 업데이트 · 2026-09-23

프로필 조회 타임아웃과 듣기 OFF
요청·CPU 재집계와 원인 범위

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

음성수업 ON 아동이 프로필을 조회하지 못하면 듣기 OFF로 초기화될 수 있습니다. 조회 실패 후 OFF로 처리하는 코드 경로는 확인했습니다. 수업 시작 피크와 CPU 사용량은 관련 증거지만, 웹 메인 스레드 포화가 원인이라는 결론은 아직 미확정입니다.

최초 작성 2026-09-22 · 갱신 2026-09-2309/09~09/21 중 제공된 12일 · 20:00~22:00 KSTPPI-1336 · 분석 + 수정 방안(§7) · 1차 묶음 PR #1135로 구현

이번 재검증의 결론 — 프로필 HTTP 요청 1,958건 중 457건이 응답 완료 전에 중단됐고, 그중 443건(96.9%)이 수업 시작 후 첫 1분에 집중됐습니다. CPU는 운영 Prometheus에서 직접 조회했습니다. 9/21 최대 1.403코어는 프로세스 합산 사용량이며, 이 숫자만으로 메인 스레드 포화·서버 과부하·타임아웃 원인을 확정할 수 없습니다.
이전 문서 정정: “CPU 1코어 초과 = 메인 스레드 포화”, “aborted = 소켓 3초 타임아웃”, “9/21 abort율 71%”를 현재 확정 사실로 사용하지 않습니다. 기준이 불명확했던 표는 아래 직접 집계 결과로 교체했습니다. 이전 소켓·ALB 분석은 별도 보존 기록으로 구분합니다.

1날짜별 프로필 요청과 abort율

제공된 ppi-web-prod-202609DD_2000-2200.log 12개를 직접 파싱했습니다. API_AUDIT / api_audit_completed / /api/internal/user-profile/[userId]만 세고, requestId 중복을 확인했습니다(0건). 시작·종료 기록 건수도 날짜별로 일치합니다.

abort율 = aborted 요청 ÷ 전체 완료·중단 요청 × 100. 재시도가 포함된 HTTP 요청 기준이며, 아동 수·입장 수·듣기 OFF 비율이 아닙니다.

날짜전체 요청성공abortedabort율
9/93102337724.84%
9/10236219177.20%
9/1114814800.00%
9/12000
9/132200.00%
9/1519018910.53%
9/1632119912238.01%
9/172531738031.62%
9/1817516784.57%
9/19000
9/20000
9/2132317115247.06%
합계1,9581,50145723.34%

457건 모두 20:00·20:30·21:00 경계 이후 74.916초 이내에 발생했습니다. 9/12·19·20은 프로필 요청 자체가 없고, 9/13은 2건 모두 성공했습니다. 9/14 로그는 제공되지 않아 조사에 포함하지 않았습니다.

요청 수가 많아서 실패했는가?

평일 8일 기준 Pearson 상관계수는 요청 수↔abort 건수 0.914, 요청 수↔abort율 0.903입니다. 그러나 실패가 재시도를 만들어 요청 수를 늘리기도 합니다. 9/9(310건·77 abort)와 9/21(323건·152 abort)은 요청 수가 비슷해도 중단은 약 2배 차이 납니다. 상관관계만으로 인과를 확정하지 않습니다. 최초 요청·재시도를 나눠 동시 도착량을 확인해야 합니다.

2운영 Prometheus에서 직접 계산한 CPU

2026-09-22 운영 Grafana의 Prometheus(PBFA97CFB590B2093)에서 job="ppi-web", env="prod", hostname="ppi-web-prod"를 조회했습니다. 각 날짜 20:00:00~22:00:00 KST(양 끝 포함), 평가 간격 15초, 하루 481개씩 총 3,848개 rate[1m] 샘플을 검증했습니다. 15초는 조회 평가 간격이며 원본 수집 주기를 뜻하지 않습니다.

rate(process_cpu_seconds_total{
  job="ppi-web", env="prod", hostname="ppi-web-prod"
}[1m])
# query_range: start=해당 날짜 11:00:00Z, end=13:00:00Z, step=15s
# 날짜별 유효 샘플의 최댓값. 단위는 사용 코어 수.
날짜1분 기준 최대(코어)발생 시각(KST)5분 기준 최대(코어)
9/91.31020:00:450.818
9/101.19921:00:450.823
9/111.14721:00:450.470
9/151.09220:00:450.714
9/161.18120:30:450.864
9/171.10721:00:450.707
9/181.09821:00:450.558
9/211.40320:00:450.867

기본 웹 대시보드는 rate[5m] × 100을 퍼센트로 표시합니다. 위 1분·5분 값은 별도로 집계한 값이므로 서로 혼용하지 않습니다. 이전 이미지와 9/10·11·16·17·18의 값이 다른 이유는 원래 이미지의 정확한 조회 시간·평가 간격이 남아 있지 않아 확정할 수 없습니다.

2 vCPU 서버에서 1.403코어면 부하인가?

사용자가 제공한 호스트 사양은 ppi-web-prod 2 vCPU / 1.8 GiB입니다. 1.403코어는 전체 명목 CPU 용량의 약 70.2%에 해당하는 웹 프로세스 사용량이며, 다른 프로세스를 포함한 호스트 전체 사용률이 아닙니다.

Node.js의 주요 JavaScript 콜백은 하나의 이벤트 루프에서 실행되므로 전체 CPU가 100%에 이르기 전에도 요청 처리가 밀릴 수 있습니다. 반대로 process_cpu_seconds_total은 스레드별 분해가 없어 1.403코어 중 메인 스레드가 얼마를 사용했는지 알 수 없습니다. 메인 1.0 + 보조 0.4코어는 가능한 예시일 뿐 실측 구성이 아닙니다.

CPU 1.403코어만으로 “여유롭다” 또는 “메인 스레드가 포화됐다”를 확정하지 않습니다. 1분 구간 rate는 순간 부하를 그대로 보여주지 않습니다. 같은 시각의 이벤트 루프 지연·이용률, 스레드별 CPU, 호스트 CPU와 제한·throttling을 함께 확인해야 합니다.

CPU 원본 시계열·조회 조건·로그 집계 JSON · 운영 웹 대시보드 · Node.js 공식 이벤트 루프 설명

33초 타임아웃과 웹 duration은 다른 시간

apps/socket/src/utils/profile-client.ts는 시도마다 AbortSignal.timeout(3000)을 적용하고 실패 시 즉시 1회 재시도합니다. 두 번 모두 타임아웃이면 통상 약 6초 뒤 실패 경로로 진행하지만, 타이머 실행 지연 등이 있어 정확한 전체 대기 시간은 별도 실측이 필요합니다. response.json()도 같은 try 안에서 실행됩니다.

측정구간이번 자료로 알 수 있는 것
소켓 대기 시간fetch 시작 → 응답 수신·파싱 또는 예외제한은 시도당 3초. 실제 발신·종료 시각은 제공된 웹 로그에 없음
웹 durationMsNode HTTP 요청 콜백 진입 → 응답 완료 또는 연결 종료aborted 457건의 관측 범위 약 4.6~262.1ms
api_audit_startedNext 미들웨어에서 시작 감사 로그 기록소켓 발신 시각이나 Node 콜백 진입 시각 자체가 아님

apps/web/server.js는 요청 콜백 안에서 타이머를 시작하고, apps/web/api-audit.js는 응답 완료 전 close를 aborted로 분류합니다. aborted는 3초 타임아웃의 동의어가 아닙니다. 연결 종료 원인은 소켓 로그와 매칭해야 합니다.

9/21 사용자 식별자 끝자리 …b744ee는 20:00:13.399에 11.948ms 후 중단, 20:00:17.558에 120.519ms 후 중단됐으며 20:01:48.234에는 6.408ms로 성공했습니다. 파일의 426·622·5161행입니다. 짧은 웹 duration은 “총 12ms만 기다렸다” 또는 “DB가 빨랐다”를 증명하지 않습니다.

가능한 예시: 웹 콜백 진입 전에 2,988ms 대기하고 웹에서 12ms 뒤 연결이 닫히면, 소켓에서는 총 3초를 기다린 것입니다. 실제 사전 대기 시간이 이렇게 측정됐다는 뜻은 아닙니다. 이미 중단된 연결은 더 기다려도 응답을 받을 수 없고, 제한 시간을 늘린 새 요청이 성공할지도 현재 자료만으로 알 수 없습니다.

4확인된 경로와 남은 원인 범위

아동 입장 (GUEST_REQUEST_ENTRY)
  → getUserProfile: 시도당 3초 제한 + 실패 시 1회 재시도
  → 최종 조회 실패: isVoiceUser=false 유지
  → 일반 수업의 초기 listeningIntent=false로 초기화 시도
  → 듣기 OFF로 시작할 수 있음
  ※ 테스트 모드와 이미 존재하는 durable snapshot은 별도 상태 조건
판정내용
확인최종 프로필 조회 실패를 음성수업 여부 false로 취급하는 코드 경로. 요청 중단의 수업 시작 직후 집중. 날짜별 요청·CPU 재계산.
가설접속 집중 시 CPU 부하·요청 대기가 프로필 호출 지연에 관여했을 가능성.
미확정이번 각 aborted의 실제 종료 원인, 정확한 소켓 전체 대기 시간, 웹 메인 스레드 포화 여부, 통신·소켓·웹 중 구체적인 대기 구간.
미집계음성수업 ON 아동 중 실제 듣기 OFF 발생 비율. 재시도 성공·기존 snapshot·테스트 모드·진행자 조작을 함께 대조해야 함.

제공된 웹 로그에는 프로필 API의 4xx·5xx 및 사용자 조회 오류가 관측되지 않았습니다. 이는 DB 지연을 기각하는 증거가 아닙니다. CPU와 abort의 인과를 검증하려면 날짜별 최대값 비교를 넘어 동일한 수초 구간의 시각 정렬이 필요합니다.

5이전 조사 보존 — 이번에 재검증하지 않은 근거

09/22 문서는 소켓·ALB·호스트 지표까지 조회했다고 기록했습니다. 해당 원본·매칭 산출물을 이번 검증 자료로 확보하지 못했으므로 거짓으로 단정하지도, 새로 확인된 사실로 재인용하지도 않습니다. 아래 세부 내용은 이전 조사 기록이며 현재 결론은 위 §4를 따릅니다.

이전 타임라인·진입 지연 추정·대안 가설·리소스 부하 기록 펼치기
이하 기록은 2026-09-22 분석에서 보존했습니다. 표의 “이전 조사 판단”과 인과 설명은 미재검증이며, 현재 검증 결과와 구분해서 읽어야 합니다.

1시간순 인과

대표 사례 — 아동 A 2회기, 2026-09-17 (childId …미표기, roomHash …8c0b)

수업 시작 정각 버스트 (아동·진행자 동시 입장)
  → ppi-web 프로세스 CPU 1.0코어 초과 → 메인 스레드 포화 가설 (미확정)
  → 요청이 Node 'request' 이벤트까지 도달하는 데 4~8초 대기 (핸들러 실행은 20ms)
      │
21:00:19.084  소켓 → web  프로필 조회 1차 발신
21:00:22.084  3초 타임아웃      (web 진입은 21:00:23.654 = +4.57초, 이미 끊긴 뒤)
21:00:22.084  2차 발신
21:00:25.084  2차도 타임아웃  → isVoiceUser=false 폴백
21:00:25.088  Room defaults reset   isMicrophoneEnabled=false
21:00:25.093  스냅샷 하이드레이트   revision=0  listeningIntent=false
21:00:25.099  아동 브라우저: Agent listening disabled (voice-control-startup-pending)
      │ 고착
21:01:51.030  프로필 재조회 성공  isVoiceUser=true   ← 실제로는 음성 아동
21:01:51.035  그러나 스냅샷은 여전히 revision=0 / listeningIntent=false
              (initializeVoiceControlSnapshot 이 EXISTS==0 일 때만 쓰기 때문)
21:03:26.780  진행자 수동 듣기 ON  → revision 0→1
              듣기 OFF 지속 3분 01초
두 단계가 겹쳐 있습니다. ① 프로필 조회가 3초를 못 버틴 것(부하) ② 나중에 성공해도 스냅샷이 고쳐지지 않는 것(로직). ②가 없었다면 21:01:51에 자동 복구됐습니다.

2대기가 어디서 생겼는지 — 측정

소켓 발신 추정 시각(타임아웃 − 3초)과 web api_audit_started를 1:1 매칭해 계산했습니다.

날짜 (20:00~20:02)매칭진입 지연 p50p90최대3초 초과
09/21(월)88건4.75초7.39초8.08초100%
09/10(목)18건3.63초4.79초5.86초100%
09/17(목) 21:0050건4.49초5.09초5.84초98%

반면 웹 콜백 시작부터 중단까지는 19.9ms(09/17 aborted 55건 p50, 전건 1초 미만)였습니다. 이전 분석은 이를 핸들러 진입 전 대기로 해석했습니다. 다만 짧은 aborted duration은 정상 처리 소요 시간이 아니며, 발신 시각은 역산값이므로 추가 검증이 필요합니다. 계측 기산점이 이를 뒷받침합니다 — apps/web/server.js:72startedAt = performance.now()는 Node HTTP 서버가 요청을 꺼내든 순간이고, api_audit_started는 이후 Next 미들웨어에서 기록됩니다. 완료·중단 로그의 duration과 미들웨어 진입 지연은 별개입니다.

09/21은 10초 구간별로 큐가 자라는 게 그대로 보입니다. 이 값만으로 특정 큐의 길이나 메인 스레드 이용률 100%를 측정했다고 볼 수는 없습니다.

20:00:10  n=11  중앙 5.63s
20:00:20  n=38  중앙 4.19s
20:00:30  n=14  중앙 7.41s   ← 최대 8.08초, 백로그 정점
20:00:50  n=11  중앙 4.41s
20:01:10  n= 4  중앙 3.12s   ← 해소되며 3초 근처로 복귀

4이전 조사에서 검토한 후보 (원본 재대조 필요)

후보판정근거
업스트림 ppi-api가 느렸다이전 조사 판단8일 전부 p95 0.099~0.157s · CPU 1.5~2.7% · DB풀 대기 5ms. abort 0%인 09/11이 71%인 09/21보다 오히려 느림
소켓 쪽이 밀렸다이전 조사 판단소켓 이벤트루프 lag 0.01초 고정 · 프로세스 CPU 0.15~0.23코어 · 1차→2차 타임아웃 간격이 24건 모두 3.000~3.004초로 정확(타이머 정상)
소켓 내부 커넥션 큐이전 조사 판단커스텀 dispatcher 없음(레포 전체 setGlobalDispatcher 0건) · abort 이후에 web이 요청을 수신했으므로 전송은 abort 전에 완료됨
ALB 대기이전 조사 판단alb_request_processing_seconds 10,928건 전건 ≤25ms(대부분 ≤1ms)
커널 accept 큐 오버플로이전 조사 판단node_netstat_TcpExt_ListenOverflows / ListenDrops 모두 0 — 대기는 했으나 버려지진 않음
두 호스트 시계 스큐이전 조사 판단성공 조회 53건 기준 socket 수신 − web 완료 = p50 18ms
콜드스타트 급증배수이전 조사 판단급증 8.2배인 09/11이 0%, 6.3배인 09/21이 71%
입장 총량약함09/10 입장 107건 → 폴백 5개 vs 09/21 입장 89건 → 폴백 41개로 역전. 09/15는 입장 76건인데 0%

6피크 부하의 실제 구성

09/21 20:00~20:02 완료 3,110건 — child 1,710 / member 972 / multiple 262 / service 162

아동측 (1,710건, 누적 요청 경과시간 330초 = 전체의 27%, 아동 41명)

라우트건수p50누적아동 1명당
POST /api/resources/urls204345ms76.1초5건
GET /api/guest-data44791ms34.6초1건
POST /api/lesson-logs/upload-url28332ms30.0초7건
POST /api/session-logs40618ms27.6초10건
POST /api/validate-lesson51393ms26.6초1건

진행자측 — 총량과 피크 기여가 다릅니다

2시간 총량 1위는 POST /api/resources/urls(5,364건 / 20.7%)지만 고르게 분산돼 피크 기여는 4.4%뿐입니다. 반대로 GET /api/lessons/[userId]/[index]/sessions는 총량 8위(1,357건)인데 23%가 피크 3분에 몰립니다(313건).

핵심 발견 — getResources()가 전체 테이블 Scan

GET /api/guest-data  (아동 입장 시 1회)
 ├ collectActivityResourceFileNames()  → getResources()  ← 전체 Scan ①  resource.service.ts:101
 └ collectAmbientNoiseImageFileNames() → getResources()  ← 전체 Scan ②  resource.service.ts:134

db-queries.ts:1838-1854
  const params = { TableName: `${prefix}-${stage}` };        // 키 조건 없음
  await paginateDynamoDBCommand<Resource>(ScanCommand, params);
  return resources.sort((a,b) => b.createdAt - a.createdAt); // 전건 JS 정렬
  → 캐시 없음 (unstable_cache / Redis / 메모이제이션 모두 0건)

같은 getResources()POST /api/resources/urls도 매 호출마다 부릅니다(app/api/resources/urls/route.ts:123). 09/21 피크 구간의 요청 수를 단순 대입하면 아동 요청만으로 guest-data 44 × 2 + resources/urls 204 ≈ 292회의 전체 Scan이라는 호출 횟수 추정이 나옵니다. 이는 직접 측정한 DB Scan 횟수나 CPU 시간은 아닙니다. 비동기 DB 대기와 결과 역직렬화·정렬 비용을 나눠 측정해야 p50 791ms의 원인을 판정할 수 있습니다.

매칭 로직도 비쌉니다 — resource.service.ts:101-113은 스텝 리소스마다 allResources.filter()를 돌며 원소마다 정규식을 실행합니다(O(스텝 수 × 전체 리소스 수)). 같은 파일 resources/urls:124는 이미 Map을 쓰고 있어 패턴은 존재합니다.

미확인 — 리소스 테이블의 실제 행 수를 확인하지 못했습니다. 비용 추정의 핵심 변수이므로 수치화 전에 먼저 확인이 필요합니다.

6다음 검증과 수정 방향

  1. 9/21 20:00~20:02의 소켓 로그에서 입장·1차 실패·최종 fallback을 사용자와 시도별로 매칭합니다. 기존 자료로 불충분하면 fetch 시작·종료 monotonic elapsed 및 상관 ID를 계측합니다.
  2. 동일 시각의 웹 이벤트 루프 지연·이용률, 스레드별 CPU, 호스트 CPU·throttling과 ALB 구간 지연을 대조합니다. 기본 대시보드의 rate(nodejs_eventloop_lag_seconds[5m])는 지연 gauge 자체와 다르므로 원본 gauge·분위수와 산식을 확인합니다.
  3. 조회 실패를 음성수업 OFF와 구분하는 상태 처리를 검토합니다. 기존 캐시·최근 정상 프로필 사용, 재확인 및 안내 여부를 제품 정책으로 결정해야 합니다.
  4. 나중에 프로필 조회가 성공했을 때 자동 복구하려면, 진행자의 명시적 OFF와 최신 revision·재접속 세대를 보존해야 합니다. EXISTS==0 조건을 단순 제거해 사용자의 조작을 덮어쓰면 안 됩니다.
  5. 리소스 Scan 캐시·중복 조회 제거·확장은 CPU 프로파일과 데이터 변경 정책을 확인한 뒤 결정합니다. 타임아웃 연장만으로 해결된다고 가정하지 않습니다.
구현 상태: 이 절의 검증 항목(1·2)은 아직 수행하지 않았습니다. 3·4번 방향과 §7 1차 묶음은 PR #1135로 구현했습니다(§7 구현 상태 참고). timeout은 변경하지 않았고, 배포 효과는 아직 측정하지 않았습니다.

7수정 방안 — 입장 몰림 완화

백로그: 수업 입장 몰림 시 ppi-web CPU 포화 완화 · 코드 기준 develop 64c71f7b · 2026-09-23 제안 · 1차 묶음 구현 완료(PR #1135, b28aa399, 배포 전)

원인이 메인 스레드 포화로 확정되지 않았으므로, 조치를 원인과 무관하게 유효한 것부하 가설에 의존하는 것으로 나눕니다. 프로필 요청만 몰리는 것이 아니라, 같은 시점에 guest-data와 리소스 URL 발급 요청도 몰립니다.

정각 입장
  ├ GET /api/guest-data ─┬ collectActivityResourceFileNames → getResources()   전체 Scan + 정렬
  │                     └ collectAmbientNoiseImageFileNames → getResources()   전체 Scan 한 번 더
  │                        + 스텝 리소스마다 allResources.filter(정규식)
  ├ useResourceCache → POST /api/resources/urls → getResources()  또 Scan
  │     ※ 캐시에 있는 파일도 updatedAt 비교용으로 매번 전체 목록 URL을 다시 발급받음
  ├ session-logs · lesson-logs/upload-url  (건당 가볍지만 요청 수 많음)
  └ socket → web /api/internal/user-profile  (시도당 3초, 캐시 TTL 30초)
        → 같은 web 프로세스에서 위 요청들과 경합 → 실패하면 isVoiceUser=false
#조치위치기대 효과의존
1getResources() 결과를 프로세스 메모리에 캐시(TTL 60초, 동시 요청은 한 번만 조회). 리소스 쓰기 API에서 무효화apps/web/lib/db-queries.ts getResources피크 3분 약 290회로 추정한 Scan 호출이 1~3회로 줄어듦무관
2캐시 시 fileName과 확장자를 뗀 이름 기준 Map을 함께 만들어 filter+정규식 반복을 없애고, ambient 목록도 이 캐시로 계산apps/web/lib/api/services/resource.service.tsO(스텝 × 전체 리소스) 반복과 guest-data 1회당 중복 Scan 제거무관
3프로필 조회를 web 부하에서 분리: ① TTL을 30초에서 수 분으로 연장 ② 실패 시 이전 캐시값 사용(stale-on-error) ③ 가능하면 socket이 web을 거치지 않고 직접 조회apps/socket/src/utils/profile-client.ts지연 원인이 무엇이든 조회 실패를 음성수업 OFF로 오판할 확률이 줄어듦무관
4나중에 프로필 조회가 성공하면 초기 OFF를 복구. 단 진행자의 명시적 OFF와 최신 revision은 덮어쓰지 않음(revision==0이고 폴백으로 만든 스냅샷일 때만 갱신)apps/socket/src/redis/voice-control-store.ts폴백돼도 자동 복구. §6-4 조건 준수무관
5guest-data 응답에 파일별 updatedAt을 포함하고, 클라이언트는 캐시에 없거나 오래된 파일만 /resources/urls로 요청apps/web/hooks/use-resource-cache.ts · guest-data 서비스resources/urls 요청과 presign 비용이 부족분만큼으로 줄어듦(대기실 프리페치 PPI-1299와 맞물림)부하 가설
6입장 직후 급하지 않은 요청 분산: 첫 lesson-log flush에 0~20초 무작위 지연(jitter), session-logs 묶어 보내기(batch)apps/web/hooks/use-lesson-log-uploader.ts · apps/web/lib/session-log-api.ts피크 구간 요청 수 감소부하 가설
7server.js cluster 워커 2개 또는 수평 확장apps/web/server.js단일 스레드 한계 해소. 1~6번 없이 단독 적용하면 문제를 미루는 데 그침포화 확정 후
1차 묶음 추천: 1 + 2 + 3의 ①② + 4. 모두 한 파일 단위의 작은 변경이고 클라이언트 동작이 바뀌지 않으며, 원인이 확정되기 전에도 손해가 없습니다. 5·6·7은 §6의 시각 정렬 검증과 1차 효과를 측정한 뒤 결정합니다.
구현 상태 — 2026-09-23 · PR #1135 (b28aa399, PPI-1336) · 리뷰 문서: 입장 몰림 시 프로필 조회 실패 → 듣기 OFF 완화 + 리소스 목록 캐시
로컬 단위 테스트만 통과했습니다(socket 134건, web 11건). 배포 효과와 실제 Redis 동작은 아직 검증하지 않았습니다.
#제안실제 구현제안과 달라진 점
1getResources() 캐시 + 쓰기 API 무효화새 모듈 apps/web/lib/resource-catalog.ts: 60초 캐시, 동시 요청은 Scan 1회 공유, 실패는 캐시하지 않음, globalThis에 한 벌아동 입장 경로(guest-data, resources/urls 파일명 조회)에만 적용했습니다. 관리자 목록은 업로드 직후 보여야 해서 캐시하지 않습니다. web이 쓰는 경로는 삭제뿐이라 삭제 시에만 무효화합니다. 업로드와 변환 완료는 web 밖에서 기록되는 것으로 보여 최대 60초 늦게 반영됩니다(합의).
2파일명·확장자 제거 이름 Map, ambient도 캐시 사용resource.service.ts를 색인 조회로 바꾸고, ambient 이미지 수집도 캐시 사용제안과 같습니다. 수정 전 동작을 고정하는 특성 테스트로 매칭 결과가 같은지 확인했습니다.
3① TTL 연장 ② stale-on-error ③ socket 직접 조회① 30초에서 5분으로 늘리고 동시 조회를 합침 ② Redis profile:last-known:{userId}에 14일 보관, 조회 실패 시 사용②는 메모리 캐시 대신 Redis에 보관합니다. 메모리 캐시는 socket 재시작 때 사라지고, 주 1회 수업 아동에게는 소용이 없기 때문입니다. ③은 새 권한·의존성이 필요해 제외했습니다.
4조회 성공 후 초기 OFF 복구 (revision==0 + 폴백 스냅샷만)applyProfileListeningIntent(avatar-handlers)와 5·15·30초 재조회(room-handlers). revision 0 CAS로 ON/OFF 양방향을 맞추고, 같은 commandId replay로 커밋 후 전달 실패를 재개합니다.폴백 표시 필드를 두지 않고 revision 0 조건만 씁니다(Redis 스키마 변경 없음). last_known으로 입장한 경우도 다시 조회합니다. 프로필로 넣은 VAD만 최신 값으로 갱신하고 진행자가 바꾼 VAD는 보존합니다. 수정 위치는 voice-control-store.ts가 아니라 기존 CAS·전달 경로입니다.
5·6·7부하 가설 의존미구현1차 효과를 측정한 뒤 결정합니다.
주의
· 1번 캐시: web이 단일 프로세스여서 쓰기 API에서 무효화하면 즉시 반영됩니다. 7번(cluster)을 도입하면 워커끼리 무효화가 공유되지 않아 최대 TTL만큼 늦게 반영됩니다.
· 효과 크기는 추정입니다. 리소스 테이블 행 수를 아직 모르므로, 착수 전에 행 수와 guest-data 구간 CPU 프로파일로 Scan 비중을 먼저 확인합니다.
· 타임아웃 연장은 권하지 않습니다. 이미 끊긴 연결은 되살릴 수 없고, 제한 시간을 늘리면 입장만 느려집니다.
· 효과 측정 지표: 같은 요일·시간대의 프로필 aborted 건수, guest-data p50, web process_cpu_seconds_total rate 최대값.

8검증 방법과 관련 문서

역할PPI 저장소 상대 경로
3초 제한·재시도apps/socket/src/utils/profile-client.ts
프로필 실패 후 입장 초기값apps/socket/src/sfu-socket/handlers/room-handlers.ts
웹 계측 시작·aborted 판정apps/web/server.js · apps/web/api-audit.js
미들웨어 시작 로그apps/web/proxy.ts
프로필 API·DB 조회apps/web/app/api/internal/user-profile/[userId]/route.ts · apps/web/lib/db-queries.ts
기존 음성 제어 상태apps/socket/src/redis/voice-control-store.ts