From 5798135a0880233d4af9a548e55bb37b085b9325 Mon Sep 17 00:00:00 2001 From: Khyojae Date: Thu, 24 Sep 2026 09:47:43 +0900 Subject: [PATCH] =?UTF-8?q?docs(portfolio):=20=EB=AC=B8=EC=A0=9C=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=204=EC=9E=A5=20PDF=20=EC=9B=90=EB=B3=B8=20HTML=20?= =?UTF-8?q?=EC=9D=84=20=EC=A0=80=EC=9E=A5=EC=86=8C=EB=A1=9C=20=EC=98=AE?= =?UTF-8?q?=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 09-24 새벽에 바탕화면 PDF(ShadowFit-문제해결.pdf, A4 4쪽)로 뽑은 제출용 판의 원본. 세션 임시 폴더에만 있어 지워질 수 있었다. 사건 4개 — 쓰기 천장(fsync·가짜 천장), 주간 리포트 JSON_TABLE 조인 순서, 아웃박스 전달 보장, AI 서버 GIL·멀티프로세스. PDF 는 브라우저 인쇄(A4)로 다시 뽑는다. 내용은 옮기기만 했고 고치지 않았다. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/portfolio/problem-solving.print.html | 269 ++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 docs/portfolio/problem-solving.print.html 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 @@ + + + + +ShadowFit 문제해결 + + + + + + +
+
ShadowFit · AI 자세 교정 앱 · Spring Boot + MySQL + FastAPI(gRPC) · 백엔드·DB 담당문제해결 1 / 4
+

모든 수치는 저장소의 측정 기록(loadtest/results/, docs/decisions/)에서 그대로 옮겼다. 측정은 AWS EC2 임시 인스턴스, 판정은 같은 라운드 안의 비교만 쓴다.

+

커넥션 풀을 4배로 늘려도 쓰기 처리량이 그대로였다
— 원인은 풀이 아니라 fsync, 그리고 «가짜 천장»

+
MySQL InnoDBHikariCP그룹 커밋ghz 부하 · EC2 3~4대라틴 방격 재측정
+

«풀이 병목이니 늘리자»는 직관을 실측으로 반증하고, 원인을 fsync 로 좁힌 뒤, 그 천장조차 부하 설계가 만든 착시였음을 스스로 찾아 정정했다.

+ +
문제PROBLEM
+

운동 중 rep 마다 관절 좌표를 저장하는 gRPC 쓰기(SavePoseDataBatch)의 천장을 재던 중, pool 5~20 · 동시성 10~100 어디서도 처리량이 약 205 RPS 에서 움직이지 않았다.

+

pool=5 에서는 대기 요청이 95개까지 쌓이는데(획득 최대 0.957초), pool=20(대기 0)과 처리량이 같았다 — 풀 상태가 이만큼 갈리는데 처리량이 같다면 병목은 커넥션이 아니다.

+
+ +
원인ROOT CAUSE
+
    +
  • 부하기 커넥션 수(1→4→16), CPU 포화(MySQL 50~59%, 백엔드 27~37%)를 차례로 기각.
  • +
  • 내구성 설정 하나만 바꾸자 처리량이 따라 움직였다 — 디스크 동기화(fsync)가 천장.
  • +
+ + + +
innodb_flush_log_at_trx_commit / sync_binlogRPS
1 / 1 (기본, 가장 안전)231.6
1 / 0411.5
2 / 0803.1 (3.47배)
+

그런데 이 «231 천장»을 그대로 믿지 않고 한 라운드를 더 돌렸다. 부하 페이로드가 단일 세션이라 모든 요청이 같은 행·페이지를 두드리고 있었기 때문이다.

+ + +
페이로드 (기본 내구성)RPS완화 시 배율fsync/커밋행 락 대기
단일 핫세션 (801)220.42.43배0.508,056
다세션 (901~1000)649.41.03배0.160~2
+

진짜 기제는 «fsync 대기 → 행 락 보유 연장 → 직렬화»이고, 이는 같은 행으로 몰릴 때만 성립한다. 요청이 흩어지면 그룹 커밋이 커밋 약 6개당 fsync 1회로 비용을 나눠 천장이 2.9배 올라간다. 저장소에 이미 «가짜 천장» 경고가 있는 도구(gen_batch_multi.py)를 두 실험 모두 안 썼던 것이 원인이었다.

+
+ +
해결ACTION
+
    +
  • 내구성 완화는 채택하지 않았다 — 장애 시 커밋된 운동 기록 유실과 맞바꾸는 설정이라 진단용으로만 썼다.
  • +
  • «커밋 횟수 줄이기»(요청당 rep 묶기) 레버를 측정 후 기각: n1 / n5 / n10 = 3,915 / 2,838 / 2,665 rows/s(−27.5%, −31.9%), p99 227ms → 11,280ms. 묶을수록 fsync/커밋이 0.154 → 0.711 로 올라 그룹 커밋 이득이 사라진다. +
    초판은 «+32%»로 나왔지만 팔당 1판이라 순서 효과였고, 버림판 + 라틴 방격으로 재측정하자 부호가 뒤집혔다.
  • +
  • 이후 모든 풀 실험의 부하를 다세션 페이로드로 고정하고, 풀 크기는 15 를 유지하되 근거 주석을 «5 는 부족하다까지만 말할 수 있다»로 고쳤다.
  • +
+
+ +
결과RESULT
+

천장 해석을 «231 RPS, fsync 가 막는다»에서 «다세션 649.4 RPS, fsync 는 풀 점유 시간을 늘려 풀을 병목으로 만든다»로 바로잡았다. 같은 조건에서 pool 5 는 pool 20 대비 69.2%(449.3 RPS)로, 풀이 병목이 되는 경계를 실측으로 확인했다. 효과가 음수인 레버(커밋 묶기)를 도입 전에 걸러냈다.

+
+ +
정직하게 남긴 것 +
    +
  • 판 순서 효과(−13%)의 원인 미상 · 행 락만 봤고 래치는 안 쟀다 · 디스크는 gp3 한 종류 · 동시성 c=100 외 미측정.
  • +
  • 라운드 간 부하기·EBS 구성이 달라 라운드를 넘나드는 절대 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 주석)

+
+ + +
+
ShadowFit · 주간 리포트 API문제해결 2 / 4
+

JSON 파싱이 비쌀 거라 생각했던 쿼리
— 실제 비용은 조인 순서였고, 술어 한 줄로 8.5~11배

+
MySQL 8.0 JSON_TABLEEXPLAIN ANALYZEHandler 카운터복합 인덱스 프리픽스
+

«JSON 을 정규화 테이블로 빼야 한다»는 큰 구조 변경 대신, EXPLAIN 으로 비용의 진짜 위치를 찾아 결과가 바뀌지 않는 중복 술어 하나로 해결했다.

+ +
문제PROBLEM
+

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회 중 최소값 · 쿼리 순서 라틴 방격.

+
+ +
원인ROOT CAUSE
+

8셀 전부 옵티마이저가 session_reports 를 먼저 읽어 그 회원의 전 기간 리포트 F 행을 가져온 뒤 세션 쪽에서 날짜로 걸렀다.

+
Index lookup on r using member_id ← 회원의 리포트 전부(F행) + → PK lookup on s → Filter(start_time) ← 여기서야 7일로 줄어듦
+
    +
  • Handler 읽기 차이가 정확히 F−W (F=365, W=3 에서 362) — 읽는 양이 기록 나이에 비례.
  • +
  • 파싱은 범인이 아니었다: JSON_TABLE 은 날짜 필터 바깥 루프에 붙어 파싱 횟수는 W 번뿐. 스칼라 하나만 뽑는 Q3 도 Q2 와 비슷한 비용 → 비용은 F 행 페치.
  • +
  • 근본 원인: 인덱스 (member_id, status, start_time) 가 (member_id, start_time) 을 흡수해 둔 상태에서, 쿼리에 status 등치가 없어 start_time 범위를 못 타고 member_id 프리픽스로 F 개를 전부 훑었다.
  • +
+
+ +
해결ACTION
+

구동 테이블을 세션으로 바꾸고, 리포트는 완료 세션에만 존재하므로 결과가 바뀌지 않는 status 술어를 추가했다(PR #760, 두 쿼리 동일).

+
- WHERE r.member_id = :memberId AND s.start_time ... ++ WHERE s.member_id = :memberId ++ AND s.status = 'COMPLETED' AND s.start_time ...
+

계획이 Covering index range scan on s using idx_session_member_status_start (rows=3) → 리포트 단건 조회 W 번으로 바뀌었다. 구조 변경 후보는 실측으로 정리 — 생성 컬럼(차이 ≤8%, 여전히 F 를 읽음)·사전집계(«비싸다»가 안 나옴)는 탈락, 정규화 테이블은 실사용 R 분포를 본 뒤로 보류.

+
+ +
결과RESULT
+ + + +
F=365 (1년치 사용자)Handler 읽기 (전 → 후)시간 배율
Q2 · W=3945 → 22111.3배
Q2 · W=71,189 → 4738.5배
Q3 · W=3744 → 2017배
+

읽는 양이 F 와 무관해졌다 — W=3 이면 221/20, W=7 이면 473/44 로 F=7·50·365 에서 같은 값. 즉 오래 쓴 사용자일수록 느려지는 성질을 없앴다. 실 MySQL(Testcontainers) race 테스트·H2 테스트 모두 통과.

+
+ +
정직하게 남긴 것 +
    +
  • 로컬 2코어 박스·합성 데이터 측정이라 절대 ms 는 인용하지 않고 같은 실행 안의 배율·Handler 수만 쓴다(1·2차 절대값이 2배 달랐다).
  • +
  • F=7·W=7 한 셀은 옵티마이저가 다른 인덱스를 골라 0.6배(읽는 양은 같음). 옵티마이저 선택이 «그 주 전 회원 행수»에 따라 갈린다(13명 vs 273명에서 반대) · 합성 데이터라 값 분포가 균일해 선택도는 미측정.
  • +
+

근거: docs/decisions/weekly-json-table-query-tuning.md · loadtest/results/weekly-json-table-2026-09-15-n3.txt · backend/.../report/WeeklySummaryQueryRepositoryImpl.java

+
+ + +
+
ShadowFit · Spring ↔ AI 서버 연동문제해결 3 / 4
+

DB 커밋과 gRPC 호출이 따로 놀아 운동 기록이 사라질 수 있었다
— 트랜잭셔널 아웃박스로 전달을 보장하고, 무한 재시도 구멍까지 막다

+
Transactional OutboxFOR UPDATE SKIP LOCKEDlease · fencing지수 백오프장애 주입 테스트
+

두 시스템에 걸친 쓰기를 원자적으로 만들 수 없다는 사실에서 출발해, 2PC·브로커 대신 같은 트랜잭션 안의 아웃박스 행으로 «알림 유실»을 «지연»으로 바꿨다.

+ +
문제PROBLEM
+

운동 종료(endSession)는 ① MySQL 커밋 ② AI 서버로 gRPC StopAnalysis 두 곳에 쓴다. ②는 afterCommit 에서 한 번만 보냈고, 실패하면 로그만 남겼다. 서킷이 OPEN 이면 아예 호출을 건너뛰었다 — AI 가 아플 때 정확히 유실되는 구조.

+

StopAnalysis 가 AI 의 완료 콜백을 부르는 유일한 트리거라, 하나를 잃으면 연쇄된다: 세션이 진행 중으로 남음 → 타임아웃 스케줄러가 FAILED 처리 → 사용자의 rep·싱크로율 결과 영구 유실 + 실패로 오분류.

+

※ 운영 사고로 발견한 것이 아니라 코드 분석으로 찾았고, 수정 후 장애 주입 실험으로 재현·검증했다.

+
+ +
원인ROOT CAUSE
+

MySQL 과 FastAPI 는 다른 시스템이라 두 쓰기를 한 트랜잭션으로 묶을 수 없다. afterCommit 은 순서만 맞췄을 뿐 두 번째 쓰기의 실패를 복구할 수단이 없었다(at-most-once).

+
    +
  • 2PC/XA — FastAPI 가 XA 참여자가 아니고, 블로킹·코디네이터 단일 장애점 → 기각
  • +
  • 메시지 브로커 — DB 커밋과 브로커 발행 사이에 같은 dual-write 가 남는다 → 문제를 옮길 뿐
  • +
  • 동기 재시도 — 인스턴스가 죽으면 메모리의 대기분이 사라지고, 서킷 OPEN 동안 못 뚫는다 → 기각
  • +
+
+ +
해결ACTION
+
endSession @Transactional ─ 세션 종료 + outbox_event(STOP_ANALYSIS) 한 커밋 ← 요청 경로에 gRPC 없음 +Publisher claim: FOR UPDATE SKIP LOCKED → PROCESSING, lock_expires_at = now + 60s (lease) + send : 트랜잭션 밖에서 gRPC → SENT / RETRY(1s→2s→4s…, 최대 10회) / FAILED + fence: UPDATE ... AND locked_by = :me (0행이면 lease 를 뺏긴 것 — 덮어쓰지 않음) + 회수 : lease 만료 행은 possiblyRedelivered=true 로 재전송 → 수신 측 멱등 처리
+
    +
  • 서킷 OPEN 은 이제 «버림»이 아니라 RETRY — 복구되면 자동 전달.
  • +
  • 재전송 중복은 수신 측에서 흡수: AI 는 끝난 세션 id 를 기억하고, Spring 은 결과를 first-write-wins 로 반영.
  • +
  • LLM 주간 리포트처럼 느린 작업(p95 1.7초, 최대 39초)은 별도 차선으로 분리해 알림 전달을 막지 않게 했다.
  • +
  • 후속 보강(#759): 송신 코드가 예외를 던지면 재시도 횟수가 안 올라 영구 예외(NPE·잘못된 페이로드)가 lease 주기마다 영원히 재처리되던 구멍을 발견 → 예외도 RETRY 로 세어 한도 초과 시 FAILED + 포기 처리(주간 리포트는 템플릿 폴백). 결과 기록 단계 예외는 기존대로 회수 경로 유지.
  • +
+
+ +
결과RESULT
+
+ 장애 주입 E2E +
    +
  • AI 일시정지 → 25초간 PENDING 유지 → 복구 후 SENT · 세션 COMPLETED
  • +
  • 서킷 OPEN → 행 보존 → 복구 후 전달
  • +
  • 백오프 8s → 16s → 32s 정확히 2배
  • +
  • 중복 재전송에도 리포트 1 → 1
  • +
+ AWS 지연 (N=40) +
    +
  • 종료 → AI 수신 median 0.70s · p95 1.03s · p99 1.05s
  • +
  • p99 의 지배 요인은 AI 처리가 아니라 폴링 주기(1s)
  • +
  • 장애 주입 테스트로 계약 고정 — lease 회수·fencing·재시도 한도·예외 경로
  • +
+
+ +
정직하게 남긴 것 +
    +
  • 아웃박스는 Spring→AI 방향만 보장한다. AI→Spring 완료 콜백은 AI 쪽 3회 재시도가 전부이고 소진되면 타임아웃이 FAILED 로 정리한다.
  • +
  • 재시도가 무한이 아니므로 «반드시 전달»은 아니며, FAILED 행을 사람이 재처리하는 절차는 아직 없다.
  • +
  • 발행기가 20건 순차 배치라 처리량 천장이 있다(#573) — 안정 RATE 는 아직 못 찾았다. 가정한 DAU 1,000 의 부하(0.075건/초)보다는 훨씬 위. #759 는 단위·통합 테스트로만 검증.
  • +
+

근거: docs/decisions/outbox-reliable-messaging.md · backend/.../exercise/AbstractOutboxPublisher.java · OutboxPublisherFailureInjectionTest · loadtest/results/outbox-duplicate-latency-2026-08-26/

+
+ + +
+
ShadowFit · AI 분석 서버(FastAPI + MediaPipe)문제해결 4 / 4
+

16코어 박스에서 AI 서버가 9.5코어만 썼다
— 원인은 GIL, 해결은 스레드가 아니라 프로세스

+
Python GIL멀티프로세스 + sticky 라우팅nginx map앱 내 계측 프로브EC2 스윕
+

후보 일곱을 하나씩 지우다 처음에 기각했던 GIL 을 계측으로 되살려 원인을 확정하고, 세션 상태를 깨지 않는 멀티프로세스 구조로 처리량을 올렸다.

+ +
문제PROBLEM
+

c7i.4xlarge(16 vCPU)에서 동시 세션 부하를 걸면 AI 서버가 9.5 vCPU 에서 멈추고 처리량이 더 오르지 않았다. 박스를 키워도 소용없는 천장이라면 비용 문제로 직결된다.

+
+ +
원인ROOT CAUSE
+
    +
  • 하드웨어(순수 추론은 15.2 vCPU 까지 씀)·검출기 풀 상한·부하기 CPU·anyio 한도(40↔128: −0.6%)·부하기 프로세스 수·프로세스 내부 락·GIL(초기 실험: 프로세스/스레드 0.96~1.06배)까지 일곱 후보를 차례로 기각.
  • +
  • 그런데 프로세스를 둘로 쪼개자 CPU 9.5 → 14.5 vCPU, 처리량 1.30배 — «한 프로세스라서»가 답이었다. GIL 스위치 간격 100배 조정(−0.8%)·gRPC 핸들러 수 5/10/20(팔간 2.3%)은 효과 없음.
  • +
  • py-spy 가 3회 중 2회 무효라, 앱 안에 널 핸들러와 GIL 프로브를 직접 심어(오버헤드 −0.86%) 계측했다:
  • +
+ + + +
계측값
GIL 총 점유율 (하한)≥ 93.9%
순수 파이썬 경로(널 핸들러) 천장정확히 1.04 vCPU
GIL 획득 대기 p99 (부하 중)5.62 ms = GIL 스위치 간격 1회분
+

→ 파이썬 ~1코어 + MediaPipe(네이티브) ~8.5코어 = 9.5/16. 파이썬 구간이 GIL 로 직렬화되어 나머지 코어에 일을 못 넘긴다.

+
+ +
해결ACTION
+

GIL 은 프로세스 단위이므로 워커 프로세스를 N 개 띄운다. 단 세션 상태가 프로세스 메모리에 있어, 같은 세션의 프레임은 항상 같은 워커로 가야 한다.

+
Spring 세션 시작 응답에 워커 번호 = floorMod(sessionId, N) +Front 프레임 요청마다 헤더 X-AI-Worker: k +nginx map $http_x_ai_worker → shadowfit-ai:800k (계산은 Spring 한 곳에서만 — 로직 이원화 방지) +AI entrypoint 가 워커 k 를 HTTP 8000+k / gRPC 8585+k 로 기동, 하나가 죽으면 컨테이너째 재시작
+
    +
  • 포트 공유(SO_REUSEPORT)를 먼저 시도했으나 세션을 안 가진 워커로 프레임이 가 6건 중 4건(67%) 거절 → sticky 라우팅으로 전환.
  • +
  • 워커마다 검출기 풀이 따로 생기므로, 풀 크기를 컨테이너 메모리 한도 ÷ 워커 수에서 유도(세션당 106.74 MiB) — 근거 없는 상수는 코드에 넣지 않는다.
  • +
+
+ +
결과RESULT
+ + + + +
N합계 rps판간 산포박스 CPUp50 지연
1335.62.95%61%469.9 ms
2429.41.75%90%346.5 ms
3451.23.04%98%306.5 ms
4456.83.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배)까지 붕괴 없이 확장되는 것도 확인했다.

+
+ +
정직하게 남긴 것 +
    +
  • 같은 기종도 인스턴스마다 성능이 다르다(추론 보정값 +32.2%) — 절대 rps 는 이 라운드의 CPU 보정값(18,977,927 iter/s)과 함께만 인용하고, 판정은 같은 박스 안 비율로 한다.
  • +
  • «N=3» 은 SMT 가 켜진 16 vCPU 박스의 값(SMT 를 끄면 N=2 에서 포화) · 워커당 RAM 고정비(#591)와 sticky 라우팅 비용은 미측정.
  • +
+

근거: 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

+
+ + +