재전송을 켰더니 느린 PG에서 결제 실패가 늘었다
목차
PG 응답이 끊긴 승인을 같은 멱등키로 한 번 더 보내게 했더니 응답 일부를 잃는 PG에서는 확정 실패가 14.29%에서 1.90%로 줄었지만 PG 동시 호출 상한이 찬 느린 PG에서는 31.56%에서 55.23%로 늘었습니다. 측정 전에는 느린 PG에서 결과가 거의 같을 거라고 적었습니다. 재전송을 끄는 대신 상한에 빈 자리가 4개 이상일 때만 다시 보내게 했습니다. 느린 PG에서는 31.40%로 재전송을 안 한 것과 같아졌고 손실에서는 2.82%로 이득이 남았습니다.
결론부터
- 재전송은 응답 손실에는 효과가 있었지만, 느린 PG에서는 같은 결제가 PG 동시 호출 자리를 한 번 더 차지해 실패를 늘렸습니다. 확정 실패가 응답 손실 조건에서는 14.29%에서 1.90%로 줄었고, 느린 조건에서는 31.56%에서 55.23%로 늘었습니다.
- 재전송을 무조건 켜는 대신 동시 호출 상한에 빈 자리가 4개 이상일 때만 허용했습니다. 그 결과 느린 조건은 31.40%, 응답 손실 조건은 2.82%가 됐습니다.
- 재시도 정책은 재시도율만 볼 수 없습니다. 같은 동시성 예산을 쓰는 원 요청과 재전송을 함께 세고, PG 상태별 최종 결과를 따로 봐야 합니다.
재전송을 넣은 이유
PG가 응답하지 않은 승인은 실패로 적지 않고 UNKNOWN으로 남깁니다. 돈이 빠져나갔는데 주문이 없는 상태를 만들지 않으려는 것입니다. UNKNOWN은 복구 배치가 PG에 다시 물어 확정합니다(확정 못 한 건이 복구를 막았다). 그 사이 고객은 결과를 기다립니다.
응답만 잃은 경우라면 그 자리에서 한 번 더 물어보면 바로 확정할 수 있습니다. 그래서 승인 타임아웃 뒤 같은 멱등키로 20~60ms 기다렸다가 한 번 다시 보내게 했습니다. 토스는 같은 Idempotency-Key로 다시 보낸 요청을 이중 결제 없이 처리합니다. 앞 요청을 아직 처리 중이면 409 IDEMPOTENT_REQUEST_PROCESSING으로 답합니다. 재전송도 타임아웃이거나 409면 원래대로 UNKNOWN으로 넘깁니다. 재전송도 승인 서킷과 PG 동시 호출 상한 40을 그대로 거칩니다(PG가 느려지면 웹훅까지 줄을 선다).
걸린 것은 재전송이 PG에 얹는 부하였습니다. 재 본 기록이 없었습니다.
측정 전에 적은 예상
PG가 나빠지는 모양을 둘로 나눠 이슈에 예상과 판정 기준을 먼저 적었습니다.
| PG 상태 | 가짜 PG에 넣은 조건 | 예상 |
|---|---|---|
| 느림 | 승인마다 5초 지연, 읽기 타임아웃 2초. 첫 시도는 항상 타임아웃 | 재전송도 똑같이 타임아웃되니 확정 실패는 거의 같다(차이 2%p 이내) |
| 응답 손실 | 승인의 15%가 PG에 기록 없이 사라짐 | 재전송이 손실을 다시 뽑으니 확정 실패가 5%p 넘게 준다 |
도착률은 초당 30건, 60초, 상한은 운영값 40입니다. 복구 배치를 켜 두고 UNKNOWN이 모두 풀릴 때까지 기다렸다가 DB의 최종 상태를 셌습니다.
느린 PG에서는 예상이 틀렸다
첫 측정에서 느린 PG의 확정 실패는 재전송을 켜자 31.3%에서 56.2%로 늘었습니다. confirm p95도 2.02초에서 4.07초가 됐습니다. 응답 손실에서는 예상대로 15.1%에서 2.4%로 줄었습니다.
느린 PG의 실패는 전부 상한 거절이었습니다. 타임아웃 난 결제는 복구 배치가 나중에 성공으로 확정했습니다. 확정 실패로 남은 것은 상한 40에 막혀 PG에 가 보지도 못한 결제였습니다. 이 조건에서 필요한 동시 호출은 초당 30건 × 타임아웃 2초 = 60으로 이미 상한 40을 넘습니다. 재전송은 같은 결제로 자리를 한 번 더 2초 쥡니다. 그만큼 뒤에 온 결제가 상한에 막혔습니다.
판정 기준 하나는 쓸 수 없었습니다. “PG 동시 호출 최대가 20% 넘게 늘면 부하가 크다”고 적었습니다. 그런데 느린 PG에서는 재전송과 상관없이 게이지가 상한 40에 붙어 있었습니다. 손실 조건에서는 지연이 없어 1초 간격 표본이 동시 호출을 거의 잡지 못했습니다. 대신 PG로 나간 승인 호출 수를 셌습니다(느림 +12.8%, 손실 +14.4%).
끄지 않고 조건을 걸었다
재전송을 끄면 응답 손실에서 얻은 약 12%p를 잃습니다. 두 상황을 가르는 조건이 필요했고 선택지는 둘이었습니다.
- 토큰 버킷: 최근 승인 대비 재전송을 10%까지만 허용합니다. 최근 승인 수를 시간 창으로 세는 상태를 따로 둬야 합니다. 그 비율은 지키려는 자원인 상한의 자리와 간접적으로만 이어집니다.
- 상한의 빈 자리: 상한이 이미 쥐고 있는 세마포어의 남은 자리를 읽습니다. 빈 자리가 기준보다 적으면 다시 보내지 않고 UNKNOWN으로 넘깁니다. 따로 둘 상태가 없고 조건이 지키려는 자원과 같습니다.
빈 자리 쪽을 골랐고 기준은 상한 40의 10%인 4로 뒀습니다. 기대는 “느린 PG에서는 재전송 없음과, 손실에서는 재전송 1회와 비슷하다”였습니다. 예산이 실제로 작동했는지 가르려고 빈 자리가 모자라 건너뛴 재전송을 따로 세는 카운터를 붙였습니다.
여섯 조건을 한 번에 다시 쟀다
앞의 네 조건과 예산을 건 두 조건을 한 실행에서 순서대로 쟀습니다.
| PG 상태 | 조건 | PG로 나간 승인 호출 | 건너뛴 재전송 | 상한 거절 | 확정 실패 |
|---|---|---|---|---|---|
| 5초 지연 | 재전송 없음 | 1,336 | 616 | 31.56% | |
| 5초 지연 | 재전송 1회 | 1,517 | 0 | 1,078 | 55.23% |
| 5초 지연 | 빈 자리 4 이상일 때만 | 1,502 | 1,176 | 613 | 31.40% |
| 응답 15% 손실 | 재전송 없음 | 1,952 | 0 | 14.29% | |
| 응답 15% 손실 | 재전송 1회 | 2,203 | 0 | 0 | 1.90% |
| 응답 15% 손실 | 빈 자리 4 이상일 때만 | 2,246 | 0 | 0 | 2.82% |
적어 둔 세 기준이 모두 맞았습니다. 느린 PG에서 31.40%는 재전송 없음(31.56%)과 0.16%p 차이입니다. 손실에서 2.82%는 재전송 1회(1.90%)와 0.92%p 차이입니다. 건너뛴 재전송이 느린 PG에서 1,176건, 손실에서 0건이라 두 조건을 가른 것이 예산이라는 것도 확인했습니다. 이 카운터가 없었다면 31.40%가 예산 덕분인지 우연인지 구분할 수 없었습니다.
다시 잰 재전송 없음 · 1회 네 조건은 첫 측정과 1%p 안에서 같게 나왔습니다. 이 실행은 같은 노트북에서 다른 부하 스크립트가 도는 동안 진행됐습니다. 그래서 지연은 첫 측정과 비교하지 않고 CPU 경합에 둔감한 건수와 비율로만 판정했습니다.
결정이 기본값에 빠져 있었다
포트폴리오에 “빈 자리가 4보다 적으면 재전송하지 않는다”를 결정으로 적은 뒤 설정 파일을 다시 열어 봤습니다. 측정 PR은 “느린 PG가 되풀이되면 켠다”며 기본값을 0(예산 없음)으로 뒀습니다. 설정을 따로 주지 않으면 예산 없이 재전송하는 상태였습니다. 글에 적은 결정과 코드의 동작이 달랐습니다.
기본값을 4로 바꿨습니다. 같은 자리에 “멱등키는 주문번호”, “재전송 부하는 아직 재지 않았다”처럼 사실과 달라진 주석도 있어서 함께 고쳤습니다. 단위 테스트는 생성자에 값을 직접 넣어 기본값의 영향을 받지 않았습니다. 통합 테스트는 상한을 작게 두지 않아 빈 자리 조건이 걸릴 일이 없었습니다. 둘 다 CI로 확인했습니다.
대가와 재지 않은 것
- 빈 자리가 모자라 건너뛴 승인은 복구 배치가 조회할 때까지 늦게 확정됩니다
- 응답 손실과 PG 포화가 겹치면 재전송으로 살릴 결제가 확정 실패로 끝날 수 있습니다. 그 조건은 재지 않았습니다
- 기준 4는 상한 40에 맞춘 값입니다. 2 · 4 · 8처럼 바꿔 가며 재지 않았습니다. 서버를 늘려 상한을 서버 수로 나누면 예산도 다시 계산해야 합니다
- 가짜 PG의 지연과 손실입니다. 실제 PG의 응답시간 분포로는 재지 않았습니다
정리
- 재전송은 PG가 응답만 잃을 때는 이득입니다. 동시 호출 상한이 이미 찬 상태에서는 다른 결제의 자리를 빼앗습니다. 같은 기능이 PG가 나빠지는 모양에 따라 반대로 작동했습니다
- 끄지 않고 상한의 빈 자리를 조건으로 걸었습니다. 지키려는 자원을 직접 읽는 조건이라 따로 둘 상태가 없습니다
- 조건이 실제로 작동했는지는 건너뛴 재전송 수로 가렸습니다. 비율이 같게 나온 것만으로는 이유를 댈 수 없었습니다
- 결정을 글에 적은 뒤 운영 기본값까지 확인해야 했습니다. 이번에는 기본값이 결정과 달랐습니다
댓글
댓글 수정/삭제는 GitHub Discussions에서 가능합니다.