마지막 업데이트 2026-09-23
음성수업 ON 아동이 프로필을 조회하지 못하면 듣기 OFF로 초기화될 수 있습니다. 조회 실패 후 OFF로 처리하는 코드 경로는 확인했습니다. 수업 시작 피크와 CPU 사용량은 관련 증거지만, 웹 메인 스레드 포화가 원인이라는 결론은 아직 미확정입니다.
제공된 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 비율이 아닙니다.
| 날짜 | 전체 요청 | 성공 | aborted | abort율 |
|---|---|---|---|---|
| 9/9 | 310 | 233 | 77 | 24.84% |
| 9/10 | 236 | 219 | 17 | 7.20% |
| 9/11 | 148 | 148 | 0 | 0.00% |
| 9/12 | 0 | 0 | 0 | — |
| 9/13 | 2 | 2 | 0 | 0.00% |
| 9/15 | 190 | 189 | 1 | 0.53% |
| 9/16 | 321 | 199 | 122 | 38.01% |
| 9/17 | 253 | 173 | 80 | 31.62% |
| 9/18 | 175 | 167 | 8 | 4.57% |
| 9/19 | 0 | 0 | 0 | — |
| 9/20 | 0 | 0 | 0 | — |
| 9/21 | 323 | 171 | 152 | 47.06% |
| 합계 | 1,958 | 1,501 | 457 | 23.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배 차이 납니다. 상관관계만으로 인과를 확정하지 않습니다. 최초 요청·재시도를 나눠 동시 도착량을 확인해야 합니다.
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/9 | 1.310 | 20:00:45 | 0.818 |
| 9/10 | 1.199 | 21:00:45 | 0.823 |
| 9/11 | 1.147 | 21:00:45 | 0.470 |
| 9/15 | 1.092 | 20:00:45 | 0.714 |
| 9/16 | 1.181 | 20:30:45 | 0.864 |
| 9/17 | 1.107 | 21:00:45 | 0.707 |
| 9/18 | 1.098 | 21:00:45 | 0.558 |
| 9/21 | 1.403 | 20:00:45 | 0.867 |
기본 웹 대시보드는 rate[5m] × 100을 퍼센트로 표시합니다. 위 1분·5분 값은 별도로 집계한 값이므로 서로 혼용하지 않습니다. 이전 이미지와 9/10·11·16·17·18의 값이 다른 이유는 원래 이미지의 정확한 조회 시간·평가 간격이 남아 있지 않아 확정할 수 없습니다.
사용자가 제공한 호스트 사양은 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 원본 시계열·조회 조건·로그 집계 JSON · 운영 웹 대시보드 · Node.js 공식 이벤트 루프 설명
apps/socket/src/utils/profile-client.ts는 시도마다 AbortSignal.timeout(3000)을 적용하고 실패 시 즉시 1회 재시도합니다. 두 번 모두 타임아웃이면 통상 약 6초 뒤 실패 경로로 진행하지만, 타이머 실행 지연 등이 있어 정확한 전체 대기 시간은 별도 실측이 필요합니다. response.json()도 같은 try 안에서 실행됩니다.
| 측정 | 구간 | 이번 자료로 알 수 있는 것 |
|---|---|---|
| 소켓 대기 시간 | fetch 시작 → 응답 수신·파싱 또는 예외 | 제한은 시도당 3초. 실제 발신·종료 시각은 제공된 웹 로그에 없음 |
| 웹 durationMs | Node HTTP 요청 콜백 진입 → 응답 완료 또는 연결 종료 | aborted 457건의 관측 범위 약 4.6~262.1ms |
| api_audit_started | Next 미들웨어에서 시작 감사 로그 기록 | 소켓 발신 시각이나 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가 빨랐다”를 증명하지 않습니다.
아동 입장 (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의 인과를 검증하려면 날짜별 최대값 비교를 넘어 동일한 수초 구간의 시각 정렬이 필요합니다.
09/22 문서는 소켓·ALB·호스트 지표까지 조회했다고 기록했습니다. 해당 원본·매칭 산출물을 이번 검증 자료로 확보하지 못했으므로 거짓으로 단정하지도, 새로 확인된 사실로 재인용하지도 않습니다. 아래 세부 내용은 이전 조사 기록이며 현재 결론은 위 §4를 따릅니다.
대표 사례 — 아동 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초)과 web api_audit_started를 1:1 매칭해 계산했습니다.
| 날짜 (20:00~20:02) | 매칭 | 진입 지연 p50 | p90 | 최대 | 3초 초과 |
|---|---|---|---|---|---|
| 09/21(월) | 88건 | 4.75초 | 7.39초 | 8.08초 | 100% |
| 09/10(목) | 18건 | 3.63초 | 4.79초 | 5.86초 | 100% |
| 09/17(목) 21:00 | 50건 | 4.49초 | 5.09초 | 5.84초 | 98% |
반면 웹 콜백 시작부터 중단까지는 19.9ms(09/17 aborted 55건 p50, 전건 1초 미만)였습니다. 이전 분석은 이를 핸들러 진입 전 대기로 해석했습니다. 다만 짧은 aborted duration은 정상 처리 소요 시간이 아니며, 발신 시각은 역산값이므로 추가 검증이 필요합니다. 계측 기산점이 이를 뒷받침합니다 — apps/web/server.js:72의 startedAt = 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초 근처로 복귀
| 후보 | 판정 | 근거 |
|---|---|---|
| 업스트림 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% |
09/21 20:00~20:02 완료 3,110건 — child 1,710 / member 972 / multiple 262 / service 162
| 라우트 | 건수 | p50 | 누적 | 아동 1명당 |
|---|---|---|---|---|
POST /api/resources/urls | 204 | 345ms | 76.1초 | 5건 |
GET /api/guest-data | 44 | 791ms | 34.6초 | 1건 |
POST /api/lesson-logs/upload-url | 283 | 32ms | 30.0초 | 7건 |
POST /api/session-logs | 406 | 18ms | 27.6초 | 10건 |
POST /api/validate-lesson | 51 | 393ms | 26.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()가 전체 테이블 ScanGET /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을 쓰고 있어 패턴은 존재합니다.
rate(nodejs_eventloop_lag_seconds[5m])는 지연 gauge 자체와 다르므로 원본 gauge·분위수와 산식을 확인합니다.EXISTS==0 조건을 단순 제거해 사용자의 조작을 덮어쓰면 안 됩니다.백로그: 수업 입장 몰림 시 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
| # | 조치 | 위치 | 기대 효과 | 의존 |
|---|---|---|---|---|
| 1 | getResources() 결과를 프로세스 메모리에 캐시(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.ts | O(스텝 × 전체 리소스) 반복과 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 조건 준수 | 무관 |
| 5 | guest-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 | 피크 구간 요청 수 감소 | 부하 가설 |
| 7 | server.js cluster 워커 2개 또는 수평 확장 | apps/web/server.js | 단일 스레드 한계 해소. 1~6번 없이 단독 적용하면 문제를 미루는 데 그침 | 포화 확정 후 |
b28aa399, PPI-1336) · 리뷰 문서: 입장 몰림 시 프로필 조회 실패 → 듣기 OFF 완화 + 리소스 목록 캐시| # | 제안 | 실제 구현 | 제안과 달라진 점 |
|---|---|---|---|
| 1 | getResources() 캐시 + 쓰기 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차 효과를 측정한 뒤 결정합니다. |
process_cpu_seconds_total rate 최대값.812a26eb4a6939414e9f8fceab840e190abd68c2. 이 SHA는 현재 코드 기준이며 사건 당시 배포 SHA로 확인된 것은 아닙니다.| 역할 | 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 |