오픈하지 않은 기능의 호출이 트래픽의 97%를 차지해 결제가 실패했다
커넥션 풀 고갈의 원인이 풀 크기가 아닐 때가 있습니다. writer 는 1.8ms 로 정상이었고 reader CPU 가 99%였습니다
프로모션 트래픽이 집중된 날 오후, "결제는 됐는데 예약이 안 된다" 는 고객 문의가 접수되기 시작했습니다. 결제 서비스 로그에는 아래 에러가 4시간 동안 약 9,400건 기록돼 있었습니다.
CannotCreateTransactionException:
... Connection is not available, request timed out after 30000ms
HikariCP 풀 상태는 total=5, active=5, idle=0, waiting=95~197 이 45분 넘게 지속됐습니다. 전형적인 커넥션 풀 고갈입니다. 여기서 먼저 떠오르는 대응이 "풀 크기를 늘리자" 인데, 그 전에 지표를 몇 개 더 확인했습니다.
풀 고갈 ≠ DB 병목
| 지표 (장애 구간) | 값 | 해석 |
|---|---|---|
| Aurora writer WriteLatency | 1.8ms 평탄 | 쓰기는 안 느림 |
| writer DatabaseConnections | 45→54 | 커넥션 여유 |
| writer Deadlocks | 0 | 락 경합 없음 |
| Aurora reader CPU | 39% → 99% 고정 | 실제 병목 |
DB 쓰기는 정상이었습니다. 문제는 reader 였고, 풀이 고갈된 건 결과이지 원인이 아니었습니다. 이 상태에서 풀 크기만 확대했다면 포화된 reader 에 동시 쿼리를 더 전송해 오히려 악화됐을 겁니다.
트래픽의 97%가 오픈하지 않은 기능의 호출이었습니다
reader CPU 를 사용하는 요청이 무엇인지 요청 로그를 집계해 보니, 48분 동안 결제 서비스 호출 11,315건 중 97% 가 포인트 조회였습니다. 포인트 기능은 아직 오픈 전이었습니다.
원인은 프론트였습니다. 네비게이션 헤더가 잔액 표시용으로 포인트 조회 API 를 호출하고 있었고, 헤더는 모든 페이지에 있으니 페이지에 접속할 때마다 호출됐습니다. 기능은 오픈하지 않았어도 API 는 호출 가능한 상태였습니다. "미오픈 기능이니 트래픽이 없겠지" 는 코드가 아니라 요청 로그로 확인해야 하는 가정이었습니다.
그리고 이 API 의 내역 쿼리가 무거웠습니다. 행마다 누적 합계를 다시 계산하는 상관 서브쿼리(O(n²))에, 회원과 무관하게 예약 테이블 전체를 materialize 하는 파생테이블이 있었습니다. 콜당 고정 비용이 큰 쿼리가 전 페이지에서 호출됐고, 모두 단일 reader 로 전송됐습니다.
인과 관계
- 트래픽 급증 → 포인트 조회가 reader CPU 를 99% 로 고정
- 쿼리가 평소 0.5초 → p50 30초 (HikariCP 타임아웃 30초와 일치하는 값)
- 조회 하나가 풀 커넥션을 30초씩 점유 → 5개짜리 풀이 순식간에 고갈
- 결제 확정이 같은 풀을 공유 → 커넥션을 획득하지 못해 타임아웃
- 결제 확정이 단일 트랜잭션이라 PG 승인 성공 후 DB 저장이 실패 → catch 의 자동취소마저 같은 고갈 풀을 사용해서 실패 → "과금됐는데 예약도 결제 기록도 없음"
- 느려지니 사용자가 재접속·새로고침 → 포인트 조회가 평소의 10배로 증폭 → 악순환
돌아보면 5번의 피해가 가장 컸습니다. 조회 부하로 결제가 실패한 건 둘이 같은 5개짜리 풀을 공유했기 때문이고, 외부 과금과 DB 저장이 한 트랜잭션에 결합돼 있어 부분 실패가 최악의 형태(과금만 성공)로 남았습니다.
조치
- 즉시: 포인트 조회를 DB 를 조회하지 않는 더미 응답으로 핫픽스했습니다. 기능 미오픈이라 프론트가 실값을 사용하지 않아 무해했고, reader 부하가 해소되며 결제 풀이 즉시 회복됐습니다. 프론트의 전 페이지 호출도 제거했습니다.
- 근본: 슬로우 쿼리들을 재작성(별도 글에서 다뤘습니다), API 를 잔액/내역으로 분리해 헤더가 무거운 내역 쿼리를 호출하지 않게 했습니다.
- 남은 것: 결제 경로와 조회가 풀을 공유하는 구조(격벽 분리)와, PG 호출을 트랜잭션 안에 포함한 단일 트랜잭션 구조입니다. 쿼리가 빨라져 완화됐지만 구조는 그대로라 별도 과제로 남겼습니다.
진단 시그니처
- 커넥션 풀 고갈 알람 → 풀 크기를 조정하기 전에 writer/reader 지표를 분리해서 확인합니다. 풀 고갈은 증상이고, 커넥션을 오래 점유하게 만드는 원인(느린 쿼리, 포화된 reader, 느린 외부 호출)이 따로 있는 경우가 많습니다
- 쿼리 p50 이 풀 타임아웃 값(30초)에 수렴 → 쿼리 자체가 느린 것이 아니라 어딘가 포화됐다는 신호입니다
- "미오픈 기능" 의 실제 트래픽은 요청 로그로 확인합니다. 프론트의 부수 호출(헤더, 프리페치)이 실트래픽 1위일 수 있습니다