diff --git a/docs/portfolio/problem-solving.print.html b/docs/portfolio/problem-solving.print.html new file mode 100644 index 00000000..704c2ff5 --- /dev/null +++ b/docs/portfolio/problem-solving.print.html @@ -0,0 +1,269 @@ + + +
+ +모든 수치는 저장소의 측정 기록(loadtest/results/, docs/decisions/)에서 그대로 옮겼다. 측정은 AWS EC2 임시 인스턴스, 판정은 같은 라운드 안의 비교만 쓴다.
«풀이 병목이니 늘리자»는 직관을 실측으로 반증하고, 원인을 fsync 로 좁힌 뒤, 그 천장조차 부하 설계가 만든 착시였음을 스스로 찾아 정정했다.
+ +운동 중 rep 마다 관절 좌표를 저장하는 gRPC 쓰기(SavePoseDataBatch)의 천장을 재던 중, pool 5~20 · 동시성 10~100 어디서도 처리량이 약 205 RPS 에서 움직이지 않았다.
pool=5 에서는 대기 요청이 95개까지 쌓이는데(획득 최대 0.957초), pool=20(대기 0)과 처리량이 같았다 — 풀 상태가 이만큼 갈리는데 처리량이 같다면 병목은 커넥션이 아니다.
+innodb_flush_log_at_trx_commit / sync_binlog | RPS |
|---|---|
| 1 / 1 (기본, 가장 안전) | 231.6 |
| 1 / 0 | 411.5 |
| 2 / 0 | 803.1 (3.47배) |
그런데 이 «231 천장»을 그대로 믿지 않고 한 라운드를 더 돌렸다. 부하 페이로드가 단일 세션이라 모든 요청이 같은 행·페이지를 두드리고 있었기 때문이다.
+| 페이로드 (기본 내구성) | RPS | 완화 시 배율 | fsync/커밋 | 행 락 대기 |
|---|---|---|---|---|
| 단일 핫세션 (801) | 220.4 | 2.43배 | 0.50 | 8,056 |
| 다세션 (901~1000) | 649.4 | 1.03배 | 0.16 | 0~2 |
진짜 기제는 «fsync 대기 → 행 락 보유 연장 → 직렬화»이고, 이는 같은 행으로 몰릴 때만 성립한다. 요청이 흩어지면 그룹 커밋이 커밋 약 6개당 fsync 1회로 비용을 나눠 천장이 2.9배 올라간다. 저장소에 이미 «가짜 천장» 경고가 있는 도구(gen_batch_multi.py)를 두 실험 모두 안 썼던 것이 원인이었다.
천장 해석을 «231 RPS, fsync 가 막는다»에서 «다세션 649.4 RPS, fsync 는 풀 점유 시간을 늘려 풀을 병목으로 만든다»로 바로잡았다. 같은 조건에서 pool 5 는 pool 20 대비 69.2%(449.3 RPS)로, 풀이 병목이 되는 경계를 실측으로 확인했다. 효과가 음수인 레버(커밋 묶기)를 도입 전에 걸러냈다.
+근거: loadtest/results/ceiling-fsync-2026-08-08/ · commit-count-2026-08-09/ · docs/decisions/pool-cliff-vs-concurrency.md · backend/src/main/resources/application.yml(pool 주석)
«JSON 을 정규화 테이블로 빼야 한다»는 큰 구조 변경 대신, EXPLAIN 으로 비용의 진짜 위치를 찾아 결과가 바뀌지 않는 중복 술어 하나로 해결했다.
+ +GET /reports/weekly-summary 의 두 쿼리 — rep 별 평균 싱크로율 곡선(Q2), 최악 rep 분포(Q3) — 는 세션 리포트의 JSON 컬럼을 JSON_TABLE(... '$.repTrend[*]') 로 펼쳐 7일치를 집계한다.
가설은 «JSON 파싱과 집계가 비용»이었고, 후보 대책도 STORED 생성 컬럼·정규화 테이블·주간 사전집계 같은 구조 변경들이었다. 착수 전에 비용이 어디서 나는지부터 재기로 했다.
+격자: 회원 누적 리포트 수 F∈{7, 50, 365} × rep 수 R × 주간 세션 W∈{3, 7}, 8셀 · 셀당 10만 행 · 버림판 1 + 7회 중 최소값 · 쿼리 순서 라틴 방격.
+8셀 전부 옵티마이저가 session_reports 를 먼저 읽어 그 회원의 전 기간 리포트 F 행을 가져온 뒤 세션 쪽에서 날짜로 걸렀다.
JSON_TABLE 은 날짜 필터 바깥 루프에 붙어 파싱 횟수는 W 번뿐. 스칼라 하나만 뽑는 Q3 도 Q2 와 비슷한 비용 → 비용은 F 행 페치.(member_id, status, start_time) 가 (member_id, start_time) 을 흡수해 둔 상태에서, 쿼리에 status 등치가 없어 start_time 범위를 못 타고 member_id 프리픽스로 F 개를 전부 훑었다.구동 테이블을 세션으로 바꾸고, 리포트는 완료 세션에만 존재하므로 결과가 바뀌지 않는 status 술어를 추가했다(PR #760, 두 쿼리 동일).
+계획이 Covering index range scan on s using idx_session_member_status_start (rows=3) → 리포트 단건 조회 W 번으로 바뀌었다. 구조 변경 후보는 실측으로 정리 — 생성 컬럼(차이 ≤8%, 여전히 F 를 읽음)·사전집계(«비싸다»가 안 나옴)는 탈락, 정규화 테이블은 실사용 R 분포를 본 뒤로 보류.
| F=365 (1년치 사용자) | Handler 읽기 (전 → 후) | 시간 배율 |
|---|---|---|
| Q2 · W=3 | 945 → 221 | 11.3배 |
| Q2 · W=7 | 1,189 → 473 | 8.5배 |
| Q3 · W=3 | 744 → 20 | 17배 |
읽는 양이 F 와 무관해졌다 — W=3 이면 221/20, W=7 이면 473/44 로 F=7·50·365 에서 같은 값. 즉 오래 쓴 사용자일수록 느려지는 성질을 없앴다. 실 MySQL(Testcontainers) race 테스트·H2 테스트 모두 통과.
+근거: docs/decisions/weekly-json-table-query-tuning.md · loadtest/results/weekly-json-table-2026-09-15-n3.txt · backend/.../report/WeeklySummaryQueryRepositoryImpl.java
두 시스템에 걸친 쓰기를 원자적으로 만들 수 없다는 사실에서 출발해, 2PC·브로커 대신 같은 트랜잭션 안의 아웃박스 행으로 «알림 유실»을 «지연»으로 바꿨다.
+ +운동 종료(endSession)는 ① MySQL 커밋 ② AI 서버로 gRPC StopAnalysis 두 곳에 쓴다. ②는 afterCommit 에서 한 번만 보냈고, 실패하면 로그만 남겼다. 서킷이 OPEN 이면 아예 호출을 건너뛰었다 — AI 가 아플 때 정확히 유실되는 구조.
StopAnalysis 가 AI 의 완료 콜백을 부르는 유일한 트리거라, 하나를 잃으면 연쇄된다: 세션이 진행 중으로 남음 → 타임아웃 스케줄러가 FAILED 처리 → 사용자의 rep·싱크로율 결과 영구 유실 + 실패로 오분류.
※ 운영 사고로 발견한 것이 아니라 코드 분석으로 찾았고, 수정 후 장애 주입 실험으로 재현·검증했다.
+MySQL 과 FastAPI 는 다른 시스템이라 두 쓰기를 한 트랜잭션으로 묶을 수 없다. afterCommit 은 순서만 맞췄을 뿐 두 번째 쓰기의 실패를 복구할 수단이 없었다(at-most-once).
근거: docs/decisions/outbox-reliable-messaging.md · backend/.../exercise/AbstractOutboxPublisher.java · OutboxPublisherFailureInjectionTest · loadtest/results/outbox-duplicate-latency-2026-08-26/
후보 일곱을 하나씩 지우다 처음에 기각했던 GIL 을 계측으로 되살려 원인을 확정하고, 세션 상태를 깨지 않는 멀티프로세스 구조로 처리량을 올렸다.
+ +c7i.4xlarge(16 vCPU)에서 동시 세션 부하를 걸면 AI 서버가 9.5 vCPU 에서 멈추고 처리량이 더 오르지 않았다. 박스를 키워도 소용없는 천장이라면 비용 문제로 직결된다.
+| 계측 | 값 |
|---|---|
| GIL 총 점유율 (하한) | ≥ 93.9% |
| 순수 파이썬 경로(널 핸들러) 천장 | 정확히 1.04 vCPU |
| GIL 획득 대기 p99 (부하 중) | 5.62 ms = GIL 스위치 간격 1회분 |
→ 파이썬 ~1코어 + MediaPipe(네이티브) ~8.5코어 = 9.5/16. 파이썬 구간이 GIL 로 직렬화되어 나머지 코어에 일을 못 넘긴다.
+GIL 은 프로세스 단위이므로 워커 프로세스를 N 개 띄운다. 단 세션 상태가 프로세스 메모리에 있어, 같은 세션의 프레임은 항상 같은 워커로 가야 한다.
+SO_REUSEPORT)를 먼저 시도했으나 세션을 안 가진 워커로 프레임이 가 6건 중 4건(67%) 거절 → sticky 라우팅으로 전환.| N | 합계 rps | 판간 산포 | 박스 CPU | p50 지연 |
|---|---|---|---|---|
| 1 | 335.6 | 2.95% | 61% | 469.9 ms |
| 2 | 429.4 | 1.75% | 90% | 346.5 ms |
| 3 | 451.2 | 3.04% | 98% | 306.5 ms |
| 4 | 456.8 | 3.32% | 100% | 263.9 ms |
N=1 대비 N=3 에서 처리량 1.34배, p50 지연 −35%. 3→4 는 +1.24% 로 판간 산포 안이라 천장 — 두 번째 천장은 박스 포화(98~100%)이고, 이는 박스를 키우면 풀린다. 기본값 AI_WORKER_COUNT=3 으로 채택. 32 vCPU 박스에서는 N=6(정확히 2배)까지 붕괴 없이 확장되는 것도 확인했다.
근거: loadtest/results/proc-count-sweep-2026-08-24/ · ceiling-cause-stage2-2026-08-24/ · frame-path-r10a-2026-08-23/ · docs/decisions/ai-receive-path-scaling.md · ai-server/entrypoint.sh · nginx-ai/default.conf