- Published on
PG가 5초 늦거나 답이 없을 때, 결제 승인은 어떻게 되나
- Authors

- Name
- 김민석
결제 사고는 대부분 PG에서 난다
결제 사고 사례를 찾아보면 대부분이 같은 유형이다. 결제 서버 자체가 아니라 PG나 카드사가 느려지거나 멈추고, 그 여파로 결제 실패율이 치솟는다. 그때 결제 서버가 같이 멈추면 결제 한 건의 문제가 서비스 전체의 문제가 된다.
그래서 PG가 5초씩 늦을 때, 아예 답을 안 할 때 우리 승인 API가 어떻게 되는지 숫자로 확인했다. 코드를 읽어서 "괜찮을 것 같다"까지는 알 수 있지만, 동시 50건에서 몇 건이 살아남는지는 직접 재야 나온다.
PG 응답을 늦추거나 끊을 수 있는 테스트용 PG를 붙여, 고치기 전에 먼저 재고 고친 다음 같은 부하로 다시 잰다.
PG 응답이 5초 늦을 때 동시 결제 50건이 들어오면
PG가 5초씩 늦게 답하는 상황에서 50명이 동시에 결제하면, 승인 요청 대부분이 30초를 기다리다 실패했다. 원인은 PG가 아니라 우리 서버의 DB 커넥션이었다.
DB 커넥션 풀 10개, 톰캣 스레드 200인 서버에 PG 응답을 5초 늦추고 동시 50건, 90초 뒤 동시 100건의 승인을 넣었다. 총 150건. p95는 100건 중 95번째로 느린 요청이 걸린 시간이다.
트랜잭션 안에서 PG를 부르던 예전 구조는 150건 중 20건만 살아남았다
지금 코드는 토스 호출을 트랜잭션 밖으로 뺀 상태다. 승인 전체를 트랜잭션 하나로 감싸던 예전 구조로 먼저 돌렸다.
| 항목 | 결과 |
|---|---|
| 승인 성공 | 20 / 150 (13%) |
| 승인 소요 | 가장 빠른 건 30.05초, 중앙값 36초, p95 100초 |
| PG에 도달한 요청 | 20 |
| 커넥션 대기 타임아웃 로그 | 172건 |
| 풀 대기(pending) | 50 → 100 |
150건 중 20건만 PG에 닿았다. 나머지 130건은 PG를 부르기도 전에 죽었다. 타임아웃 로그에 찍힌 우리 코드의 첫 줄이 전부 트랜잭션 시작이다. 예전 구조는 트랜잭션을 열면서 DB 커넥션을 먼저 잡고, 그 커넥션을 쥔 채 PG 응답 5초를 기다린다. 커넥션이 10개니 50명 중 10명만 받고, 40명은 줄을 서서 30초를 기다리다 죽는다.

- 커넥션 풀 패널에서 대기(
pending)가50에서100으로 선다. 최대는10에 고정이다. - 커넥션 대기 타임아웃이
분당 120건가까이 쌓인다. 승인 성공은 초당0.4건이 전부다.

HTTP 500이 초당3건씩 나가는 동안 CPU는20%를 넘지 않는다. 서버가 바쁜 게 아니라 커넥션을 기다리고 있었다.- 스레드가
32에서140으로 늘어난 건 요청 스레드가 대기열에 묶여 있어서다.
트랜잭션을 분리한 현재 코드도 150건 중 27건만 살아남았다
현재 코드로 돌아와 같은 부하를 넣었다. 예상은 "다 살아남는다"였다.
| 항목 | 결과 |
|---|---|
| 승인 성공 | 27 / 150 (18%) |
| 승인 소요 | 가장 빠른 건 11.2초, 중앙값 71초, p95 95초 |
| PG에 도달한 요청 | 150 |
| 승인 뒤 보상 취소 | 123 |
| 커넥션 대기 타임아웃 로그 | 606건 |
| 주문 끝 상태 | PAID 27, FAILED 83, 결제 진행중 잔류 40 |
| 알림 | 성공 알림 8/27, 영수증 23/27 |
트랜잭션 분리는 제 역할을 했다. 150건 전부 PG에 닿았다. 그런데 승인은 82%가 실패했고 123건은 승인 뒤 보상 취소로 돌아갔다. 고객 관점에선 카드 승인 문자와 취소 문자가 연달아 온다.

- 대기(
pending)가50,50,100으로 세 번 선다.50건단계에서 두 번,100건단계에서 한 번이다. - 승인 결과 패널의 라벨이
db_failed다. PG는 승인했고 우리 DB 저장에서 죽었다는 뜻이다. 실패 지점이 PG에서 DB로 옮겨 왔다.

- 스레드
140, CPU40%. 예전 구조보다 CPU를 더 쓴 건150건전부 PG까지 갔다가 돌아왔기 때문이다.
커밋 직후 알림 처리가 두 번째 커넥션을 요구한다
용어 둘만 짚고 간다. 결제가 저장되면 "결제 완료 알림"과 "영수증 알림"을 보내는 코드가 있다. 알림 코드는 결제 커밋이 끝난 직후에 자동으로 실행되고(AFTER_COMMIT), 알림을 저장할 때 자기만의 새 트랜잭션을 연다(REQUIRES_NEW). 새 트랜잭션은 DB 커넥션을 하나 더 필요로 한다.
타임아웃 로그 606줄을 "어디서 기다리다 죽었나"로 분류했다.
| 대기하다 죽은 지점 | 건수 |
|---|---|
| 결제 저장 트랜잭션 진입 | 160 |
| 실패 처리(주문 FAILED 전환) | 40 |
| 알림 리스너 | 33 |
| JWT 회원 조회 | 2 |
순서대로 보면 이렇다.
- PG가
5초뒤50건에 동시에 답한다. 50건이 동시에결제 저장에 들어온다. - 커넥션
10개를 받은10건이 저장을 끝내고 커밋한다. - 커밋 직후 알림 처리가 시작되고, 알림을 저장하려고 커넥션을 하나 더 요청한다. 그런데 첫 커넥션은 아직 반납 전이다.
- 10건 모두 첫 커넥션을 쥔 채 두 번째 커넥션을 기다린다. 풀에 남은 커넥션은
0개. 서로가 서로를 기다린다. - 나머지
40건은 저장에 들어가지도 못하고30초를 기다리다 죽는다. - 죽은 40건은 PG에 이미 승인된 돈을 되돌리는
보상 취소를 부른다. 취소 뒤 주문을FAILED로 바꾸는 것마저 커넥션을 못 받아, 주문이PAYMENT_PENDING(결제 진행중)으로 남는다.

처음엔 가설을 버렸다. 로그를 에러마다 40줄씩 잘라 "알림 처리" 줄을 찾았는데 0건이었다. 실제 스택은 40줄보다 길어서 잘린 부분에 있었다. 60줄로 다시 자르고, 알림 실패 로그 40건의 원인이 전부 "트랜잭션을 열 커넥션을 못 받음"(Could not open JPA EntityManager for transaction)인 것을 확인한 뒤에 가설이 섰다. 측정 도구가 아니라 내가 로그를 자른 폭이 틀렸던 것이다.
풀 확대와 트랜잭션 안 알림 저장은 고르지 않았다
- 커넥션 풀을
10 → 20으로 늘리는 방법은 동시 커밋이 풀의 절반을 넘으면 같은 방식으로 다시 막힌다. 구조를 안 바꾸면 무너지는 숫자만 옮긴다. - 알림 저장을 결제 트랜잭션 안에서 같이 하는 방법은 커넥션은 안 늘지만, 알림 저장이 실패하면 결제 저장까지 함께 되돌아갈 위험이 있다. 알림 중복을 잡는 처리가 트랜잭션 전체를 취소 대상으로 표시하기 때문이다. 실제 로그에 그런 이력(
UnexpectedRollbackException)이 있었다. - 알림 처리를 다른 스레드로 넘기는 방법을 골랐다. 결제를 처리한 스레드는 커밋 직후 커넥션을 반납하고 끝낸다. 알림은 알림 전용 스레드 최대
4개가 나중에 자기 커넥션으로 처리한다. 알림이 실패해도 결제는 그대로고, 알림이 커넥션을4개넘게 쓰는 일도 없다.
알림 처리를 다른 스레드로 넘기자 150건 전부 성공했다
| 항목 | 트랜잭션 안 PG 호출 | 트랜잭션 분리 | 트랜잭션 분리 + 알림 비동기 |
|---|---|---|---|
| 승인 성공 / 150 | 20 | 27 | 150 |
| 승인 소요 p95 | 100초 | 95초 | 6.07초 (PG 지연 5초 포함) |
| 커넥션 대기 타임아웃 | 172 | 606 | 0 |
| 보상 취소 | 0 | 123 | 0 |
| 알림 성공 / 영수증 | 0/20, 10/20 | 8/27, 23/27 | 150/150, 150/150 |
| 원장 항등식 | 20 = 20+0+0 | 150 = 27+123+0 | 150 = 150+0+0 |
원장 항등식은 PG 승인 건수 = PAID 주문 + 취소 + 사고 기록. 식이 안 맞으면 돈이 어디로 샌 것이다. 세 번 모두 맞았다.
동시 100건이 6.13초 안에 전부 끝났다. PG 지연 5초를 빼면 우리 서버 몫은 1초 남짓이다. 처리량으로 쓰면 100건 ÷ 6.13초 = 16 TPS인데, PG 지연에 묶인 값이라 서버 용량은 아니다. 서버 용량은 PG가 정상 응답하는 조건에서 따로 재야 하는 숫자다.
복구 배치가 잔류 주문을 영원히 보류하던 문제도 같이 고쳤다
트랜잭션을 분리한 현재 코드로 돌렸을 때 PAYMENT_PENDING으로 남은 40건은 10분마다 도는 복구 배치가 정리해야 한다. 그런데 배치는 PG 상태가 CANCELED면 "판단 보류"로만 처리하고 있었다. 보상 취소된 주문은 PG 상태가 항상 CANCELED라 영원히 풀리지 않는다. 배치에 CANCELED → FAILED 분기를 추가했다.
PG가 아예 답을 안 할 때 요청마다 30초씩 멈춘다
PG가 아예 답을 안 하면 결제 요청 하나가 30초씩 서버를 잡고, 건마다 사고 기록과 알람이 쌓였다. 서킷브레이커를 달아 "PG가 죽었다"고 판단되면 PG를 부르지 않고 바로 실패를 돌려주게 했다.
서킷브레이커는 전기 차단기와 같은 뜻이다. 실패가 일정 비율을 넘으면 회로를 끊어(열림) 더 이상 PG를 부르지 않고, 잠시 뒤 몇 건만 흘려보내(반열림) 살아났으면 다시 붙인다(닫힘).
PG를 무응답 상태로 두고 동시 50건을 넣었다.
서킷브레이커가 없으면 요청 하나가 30초 동안 서버를 잡는다
| 항목 | 결과 |
|---|---|
| 승인 | 50 / 50 실패, 전부 30.2 ~ 30.5초 |
| PG에 매달린 요청 | 100 (승인 50 + 결과 확정 조회 50) |
| 사고 기록 | +50 |
| CRITICAL 알람 로그 | 100줄 |
PG가 답을 안 하면 승인 요청은 15초를 기다리고 포기한다. 그런데 승인이 실제로 됐는지 모르는 상태라 "이 주문 승인됐나"를 PG에 한 번 더 물어보고, 그것도 15초를 기다린다. 그래서 요청 하나가 30초 동안 서버 스레드를 잡는다. 두 번째 조회까지 실패하면 설계상 사고 기록을 남기고 운영자에게 알람을 보내게 돼 있어서, 건마다 사고와 메일이 쌓인다. 서버가 동시에 처리할 수 있는 요청이 200개니, 동시 200건이면 서버 전체가 30초씩 멈춘다.
타임아웃 단축과 재시도는 고르지 않았다
- 기다리는 시간을 줄이는 방법은 정상 승인도 가끔 느리다는 점에서 막힌다. 짧게 자르면 성공할 결제까지 실패로 만든다.
- 재시도는 죽은 PG에 부하만 더한다.
- 서킷브레이커를 골랐다. 실패가 쌓이면 PG를 부르지 않고 바로 실패를 돌려준다. 승인 호출에만 건다. 취소와 조회는 돈을 되돌리는 복구 경로라 차단하지 않는다.
설정은 이렇다. 최근 20건을 보고, 그중 절반 넘게 실패하면 열린다(판단하려면 최소 10건은 있어야 한다). 열린 뒤 30초가 지나면 3건만 시험으로 흘려보내고, 다 성공하면 닫힌다. 카드 한도 초과 같은 거절(4xx)은 PG가 정상이라는 뜻이라 실패로 세지 않는다. 열려 있는 동안은 승인 자체를 안 하니 돈이 움직이지 않고, 조회도 취소도 사고 기록도 없다.

서킷브레이커가 열린 뒤에는 0.49초 만에 실패를 돌려준다
| 항목 | 결과 |
|---|---|
| 1단계 20건 | 30.5초 실패 (실패 10건이 쌓이기 전이라 차단기가 아직 닫힘 상태) |
| 2단계 50건 | 전부 PG 호출 없이 즉시 실패, 가장 빠른 건 0.22초, 중앙값 0.49초 |
| 차단기 상태 변화 | 16:50:48 닫힘 → 열림, 16:51:18 열림 → 반열림 |
| PG에 매달린 요청 | 40 (1단계만) |
| 사고 기록 | +20 (2단계는 0) |
첫 20건은 그대로 30초를 겪는다. 차단기는 뒤따르는 요청을 보호하는 것이라 한계이지 결함은 아니다. "PG가 20분 죽었다면"으로 바꿔 말하면, 서킷브레이커가 없으면 20분 내내 요청마다 30초와 사고와 알람이고, 있으면 첫 30초만 겪고 이후 즉시 실패하며 30초마다 3건씩만 살아났는지 시험한다.

- 커넥션 대기 타임아웃
0. 무응답 테스트는 커넥션이 아니라 PG 대기가 병목이라 풀 그래프는 조용하다. 16:51에 대기(pending)가29로 한 번 튄다. 차단기가 열린 순간50건이 동시에 즉시 실패 처리되며 주문 상태를 바꾸는 DB 쓰기가 몰린 것이다.

HTTP 503이 새로 보인다. 차단기가 열려 PG 호출 없이 즉시 실패시킨 응답이다.16:48~16:49의 빈 구간은 서킷브레이커 배포로 프로세스를 재기동한 시간이다.
경보는 PG 장애를 몇 분 만에 알리나
PG 장애가 나면 사람이 알아채기까지 걸리는 시간을 재봤다. 경보 규칙을 하나 두었더니 2분 3초 만에 울렸다.
경보가 없던 때는 PG 장애를 누군가 결제가 안 된다고 말해 줄 때까지 모른다. 규칙을 하나 두었다. 최근 5분 동안 승인 실패율이 20%를 넘고 실패가 5건 이상이면, 그 상태가 1분 이어질 때 경보를 보낸다.
서킷브레이커를 단 뒤 무응답을 다시 넣은 실행에서 재봤다. 무응답 주입 07:50:12, 첫 실패 07:50:48, 경보 pending 07:51:15, firing 07:52:15, 해소 07:53. 장애 주입 기준 2분 3초, 첫 실패 기준 1분 27초 만에 감지했다.
배운 것 하나. 차단기가 바로 거절한 50건은 pg_unavailable(PG 사용 불가)이라는 새 종류의 실패로 집계되는데, 그 종류가 그 순간 처음 생겨서 Prometheus가 "0에서 50으로 늘었다"를 계산하지 못했다. 처음 나타난 값의 증가분은 모르기 때문이다. 경보는 그 앞의 일반 실패 20건만으로 울렸다. 서버가 켜질 때 실패 종류마다 0을 미리 등록해 두면 해결된다.
동시 50건과 100건으로 잡은 이유
- 병목 후보였던 커넥션 풀
10개의5배와10배. 풀보다 조금 큰 부하면 "가끔 느려짐"으로 끝나 원인이 안 갈리는데, 5배면 풀 고갈이면 확실히 무너진다. 실제로82%가 실패했다. - 실서비스 순간 피크보다 몇 배 센 조건이다. 승인 한 경로에 동시 50 ~ 100건은 실서비스 전체 API 합보다 크다.
- 테스트 환경 한도. 승인 API는 회원당
분당 10건제한이라 테스트 계정30개로 한 번에 넣을 수 있는 승인이 최대300건이고, 그 안에서50 → 100두 단계로 잡았다.
남은 것
- PG 정상 응답(
300ms) 조건에서50 → 100 → 200 → 300으로 올리는 용량 측정.p95가 꺾이는 지점과TPS - 승인 결과 지표를
CloudWatch로 보내는 운영 경보 - 실패 종류별 카운터를 기동 시
0으로 등록하는 사전 등록 - 새벽 구독 갱신 배치. 순차 처리라 건수에 정비례하고, 자정을 넘기면 다음 날 배치와 겹쳐 이중 결제가 나는지
정리
| 문제 | 고친 것 | 고치기 전 → 고친 뒤 |
|---|---|---|
| PG 5초 지연에 동시 50건이면 승인이 30초씩 멈춤 | 알림 리스너 비동기 분리 | 승인 성공 27/150 → 150/150, p95 95초 → 6.07초, 알림 유실 23건 → 0 |
| PG 무응답이면 요청마다 30초 점유, 사고 기록 폭주 | 승인 호출에 서킷브레이커 | 실패 응답 중앙값 30.4초 → 0.49초, 사고 기록 50 → 0 |
| PG 장애를 사람이 알 때까지 모름 | 실패율 경보 | 장애 주입 2분 3초 만에 감지 |
| 보상 취소된 주문이 결제 진행중으로 영원히 남음 | 복구 배치에 CANCELED 처리 | 잔류 40건 → 배치가 회수 |
트랜잭션 분리는 PG 도달까지만 보장했다. 그 뒤에 숨어 있던 두 번째 커넥션 요구는 부하를 실제로 걸어 보기 전엔 보이지 않았다. 코드를 읽어서 아는 것과 재서 아는 것은 다르다.