핵심 한 줄

11편의 3-config 실험은 “185는 payment→risk 동기 홉의 천장”이라 결론냈다. 이번엔 그 천장의 정체를 관측으로 규명했다 — 두 번 뒤집혔다. 반전 1: 홉이 아니라 payment의 커넥션 풀(400 VU에서 CPU는 놀고 Hikari는 10 만석 + 190 대기). 반전 2: 풀 크기도 아니었다(2코어 DB엔 풀 10이 이미 공식 상한). 진짜 정체는 취소 1건당 커밋 6개(fsync)였다. risk 홉은 무죄(21ms).

시리즈 — 부하가 드러낸 설계

1막 · 실측으로 잡고 증명하다 00 · 01 · 02 · 03 · 04 · 05 · 06 · 07 · 08 · 09 · 10 · 11 2막 · 관측을 코드로, 코드를 재측정으로 12 · 13 · 14 · 15


1. 11편이 남긴 미결

11편의 3-config 스윕은 캐시/스냅샷/HTTP-매번 셋 다 VU400까지 ~185rps에서 포화함을 보였다. 결론: 185는 daily_limit 해석층과 무관한 구성 독립 천장이고, 병목은 payment→risk 동기 홉 어딘가다.

근데 “동기 홉 어딘가”는 아직 추론이다. 185에서 실제로 어느 자원이 먼저 포화하는지 — payment CPU? risk CPU? 커넥션 풀? DB? — 본 적이 없다. 추측으로 서버를 늘리거나 풀을 키우면, 안 막힌 자원을 최적화할 수 있다.

원칙 — 측정 먼저(measure first)

벽이 어디인지 모르는 채 고치는 건 도박이다. USE 방법(자원마다 Utilization·Saturation·Errors)으로, 포화 지점에서 자원별 신호를 동시에 읽는다. 벽은 딱 하나 — 다른 건 여유 있는데 혼자 100%거나 큐가 쌓이는 놈.

2. 측정 먼저 — USE 인구조사

포화 진단 키트를 깔았다. 대부분 신호는 이미 스크레이프 중이었고, 공백 둘만 새로 계측했다.

  • Tomcat 스레드(busy/max) — opt-in env로.
  • payment→risk 홉 지연 — RestTemplate을 빌더로 만들어 http.client.requests 자동 계측.
  • 나머지(CPU·HikariCP·DB·5xx)는 재사용.

그리고 stress 스윕(50→400 VU, 6분)을 돌려 rps가 평평해진 구간에서 자원별 U/S/E를 한 번에 캡처했다.

3. 400 VU에서 읽은 U/S/E

자원값
payment CPU38%여유
risk CPU14%놀고 있음
risk 홉 지연21ms🟢 빠름
payment Tomcat busy/max200 / 200🔴 만석
payment Hikari active/max10 / 10🔴 만석
payment Hikari pending190🔴 190개가 커넥션 대기
mysql threads_running11여유

숫자가 딱 맞는다. Tomcat 200 = 활성 커넥션 10 + 대기 190. 200개 스레드 전부 취소 핸들러에 들어와 있는데, DB 커넥션이 10개뿐이라 190개가 풀에서 블로킹. Tomcat이 “다 busy”인 이유도 실은 다 풀을 기다리며 멈춰 있어서다.

flowchart TB
  VU["400 VU (부하)"] --> TC["Tomcat 스레드 200<br/>전부 busy"]
  TC --> POOL["HikariCP 풀 = 10"]
  POOL -->|"활성 10"| DB["DB 커밋(fsync)"]
  POOL -->|"대기 190"| W["190개 풀 앞 블로킹 = 벽"]
  DB --> IDLE["payment CPU 38% · risk 21ms · DB 여유<br/>→ 연산도 홉도 아닌 풀 큐잉"]

4. 반전 1 — 홉이 아니라 풀이었다

CPU·DB·risk가 전부 논다. risk 홉은 21ms로 빠르다. 벽은 연산도, DB도, 홉도 아니라 payment의 커넥션 풀 큐잉이었다.

3-config가 가리킨 "동기 홉"의 정정

11편의 “구성 독립 천장”은 맞았다 — 근데 그 천장의 정체는 risk 홉의 연산이 아니라 payment 풀이었다. risk가 21ms에 답하는데 홉이 병목일 리 없다. 같은 데이터라도, 자원별로 쪼개 보기 전까진 이름을 잘못 붙인다.

그럼 답은 간단해 보였다 — 풀 10을 50으로 올리면?

5. 반전 2 — 풀도 아니었다

풀을 무작정 못 키운다. HikariCP·PostgreSQL이 공유하는 사이징 공식이 있다.

그리고 그 분석의 핵심은 작은 풀이 더 빠르다 — 코어보다 많은 커넥션을 주면 컨텍스트 스위칭·락 경합으로 오히려 느려진다.

우리 DB(mysql-payment)는 2 vCPU. 공식대로면 (2×2)+1 ≈ 5. 풀 10은 이미 2코어 DB 기준 상한이고, 그 DB가 지금 62% CPU로 갈리고 있다. 50을 주면 2코어가 50쿼리를 동시 처리하려다 스래싱 — 십중팔구 더 나빠진다.

풀이 만석인데 DB가 안 죽었다는 건

pending 190은 “커넥션이 부족하다”가 아니라 “커넥션을 너무 오래 쥔다”는 신호다. 점유 시간을 줄이면 같은 풀로 더 많이 처리한다. 그럼 — 무엇이 커넥션을 오래 쥐고 있나?

6. 진짜 정체 — 커밋 6

general_log를 켜고 신규 취소 20건의 SQL을 잡아 정규화했다. 취소 1건 = 18개 앱 쿼리, 그중 COMMIT 6개.

  • 코어 3 트랜잭션(PENDING → PROCESSING → COMPLETED, 외부 호출로 분리).
  • 이력 3 트랜잭션 — 상태전이마다 cancel_request_history를 별도 커밋으로.

커밋은 fsync 바운드라 DB에서 제일 비싼 연산이다. 그리고 인과가 닫힌다.

관측 147rps와 일치. 185(→147) 천장의 진짜 정체는 payment가 취소당 6번 fsync하는 것이었다 — 동기 홉도(risk 21ms), 풀 크기도(2코어 공식 상한) 아니라.

한 줄

3-config는 “동기 홉”이라 불렀고, 나도 “풀을 키우자”고 할 뻔했다. 측정이 둘 다 뒤집었다 — 벽은 커밋 수였다. 이제 그걸 줄인다: 14편, 감사 불변식은 지키면서 이력 3커밋을 1로.