BE-commerce 프로젝트
약 7분 분량 결제·정산성능·용량

짧은 PG 타임아웃은 우리 쪽 대기만 줄였다

목차

PG 읽기 타임아웃 5초를 토스 공식 권장인 60초와 나란히 쟀더니 평소에는 5초가 결제의 10.2%를 결과 모름으로 만들었고 PG가 전부 느린 브라운아웃에서는 1.5배 더 팔았지만 PG 승인 호출이 16배였습니다. 5초가 더 판 결제는 모두 결과 모름을 거쳐 약 2분 뒤에 확정됐습니다. 우리가 연결을 끊어도 PG는 그 호출을 끝까지 처리하기 때문입니다. 짧은 타임아웃은 우리 쪽 대기만 줄이고 PG의 일은 줄이지 못했습니다. 측정 전에 적은 기준을 넘어 60초로 바꿨습니다.

결론부터

  • 5초 타임아웃은 우리 서버의 대기를 줄였지만 PG의 처리를 줄이지 못했습니다. 평소 느린 꼬리에서 결제의 10.2%가 UNKNOWN이 됐고, 브라운아웃에서는 60초보다 1.5배 더 접수했지만 승인 호출은 16배였습니다.
  • 연결을 먼저 끊어도 PG가 요청을 끝까지 처리한다면, 짧은 타임아웃은 부하 차단이 아니라 결과를 모르는 결제를 더 빨리 만드는 정책입니다. 이후 복구 배치가 같은 멱등키로 결과를 확인해야 합니다.
  • 그래서 읽기 타임아웃은 60초로 바꾸고, 결제 화면도 그 시간까지 결과를 기다릴 수 있게 조정했습니다. 이 값은 PG 계약, 동시 호출 상한, 재전송 정책을 함께 놓고 운영 환경에서 다시 검증해야 합니다.

짧은 PG 타임아웃은 우리 쪽 대기만 줄였다

5초는 토스 권장의 12분의 1이었다

토스페이먼츠 개발자센터는 타임아웃을 이렇게 안내합니다.

결제 처리와 관련된 API 의 Read Timeout 은 60초로 설정하면 돼요.

Stripe SDK의 기본 타임아웃도 80초입니다. 우리 설정은 5초였습니다. PG가 느려질 때 톰캣 워커를 빨리 풀려고 고른 값이었고(PG가 느려지면 웹훅까지 줄을 선다) 설정 파일 주석에는 “어느 쪽이 싼지는 아직 안 쟀다”가 남아 있었습니다.

그대로 둘 수 없었던 이유는 이 값 위에 서 있는 것이 많아서입니다. PG 동시 호출 상한 40, 상한이 거절할 양을 미리 계산하는 식, 재전송 예산(재전송을 켰더니 느린 PG에서 결제 실패가 늘었다), 결제 화면이 응답을 기다리는 15초가 모두 5초를 전제로 했습니다.

가짜 PG가 5초 편을 들고 있었다

재기 전에 가짜 PG부터 봤습니다. 지금까지 가짜 PG는 우리가 읽기 타임아웃으로 끊으면 그 호출의 처리도 같이 끝났습니다. 실제 PG는 끊긴 호출도 끝까지 처리합니다. 이대로 재면 5초가 PG의 부하까지 줄이는 것처럼 나옵니다.

가짜 PG에 스위치 셋을 더했습니다. 기본은 모두 꺼져 있어 기존 실험과 테스트는 그대로입니다.

  • PG 쪽 처리: 우리가 끊어도 PG는 지연만큼 처리를 계속합니다. 그동안 같은 멱등키로 다시 오면 토스처럼 409 IDEMPOTENT_REQUEST_PROCESSING을 돌려주고 우리 쪽에서는 결과 모름이 됩니다. 처리가 끝난 뒤 같은 키로 오면 처음 결과를 그대로 돌려줍니다
  • PG 계약 한도: PG가 한 가맹점에 대해 동시에 처리하는 수의 한도입니다. 우리가 끊은 호출도 PG에서 처리 중이면 셉니다. 토스는 이 값을 공개하지 않아 60으로 가정했습니다
  • 느린 꼬리: 승인의 일부 비율만 느리게 합니다. 카드사 쪽이 가끔 느린 평소 상태입니다

측정 전에 적은 예상과 기준

상한 40, 결제 화면과 똑같이 움직이는 고객, 복구 배치를 켠 상태에서 세 조건을 60초씩 쟀습니다. 끝난 뒤 결과 모름이 0이 될 때까지 기다린 다음 DB의 최종 상태를 셌습니다.

조건부하예상
평소 꼬리초당 15건, 90%는 1초 · 10%는 10초5초는 느린 10%가 결과 모름, 60초는 0
브라운아웃초당 50건, 모든 승인 20초, 계약 한도 605초는 PG가 받는 승인이 초당 약 3건(60 ÷ 20초)으로 60초(초당 2건)의 1.5배
계약 한도 없음브라운아웃과 같되 한도 없음5초가 60초의 4배, PG 쪽 동시 처리 약 160

판정 기준도 먼저 적었습니다. 브라운아웃에서 5초의 최종 판매가 60초의 2배를 넘지 못하고 평소 꼬리에서 5초가 결제의 5% 이상을 결과 모름으로 만들면 60초로 바꾼다. 둘 중 하나라도 어긋나면 5초를 유지하고 이유를 적기로 했습니다.

결과

조건읽기 타임아웃첫 응답이 결과 모름최종 판매확정까지 p50 / p95PG 승인 호출PG 한도 거절PG 쪽 동시 처리 최대
평소 꼬리5초89 / 876 (10.2%)1,0241.0 / 92.7초1,121038
평소 꼬리60초0 / 8871,0311.0 / 10.0초1,031038
브라운아웃5초180 / 2,941240109.9 / 117.6초2,6342,21660
브라운아웃60초0 / 2,93916020.0 / 20.0초160040
계약 한도 없음5초480 / 2,937600102.0 / 121.5초802160
계약 한도 없음60초0 / 2,91316020.0 / 20.0초16040

최종 판매와 서버 쪽 수치는 워밍업(초당 10건, 15초)을 포함합니다. 같은 노트북에서 다른 부하 스크립트가 도는 중이라 판정은 건수로 했습니다. 시간은 가짜 PG의 지연과 복구 배치 주기(60초)가 정하는 값이라 CPU 경합의 영향을 거의 받지 않습니다.

예상이 모두 맞았고 판정 기준 둘을 모두 넘었습니다.

5초가 더 판 1.5배

브라운아웃에서 5초는 240건, 60초는 160건을 팔았습니다. 5초가 이긴 것처럼 보이지만 5초의 240건은 전부 결과 모름을 거쳤습니다. 고객은 “확인 중”을 보다가 “주문내역에서 확인해 주세요”를 받았고 복구 배치가 PG에 다시 물어 확정하기까지 약 2분이 걸렸습니다. 60초의 160건은 모두 20초 무렵에 바로 확정됐습니다.

그 1.5배를 위해 PG 승인 호출은 2,634건으로 60초(160건)의 16배였고 2,216건이 PG 계약 한도에 막혔습니다. 5초로 끊은 호출이 PG 안에서는 20초 동안 계속 처리되면서 계약 한도 60을 채웠고 우리는 비운 자리에 새 호출을 계속 보냈습니다.

계약 한도를 빼면 5초가 4배(600 대 160)를 파는 것처럼 보였습니다. 지금까지의 가짜 PG가 이 가정이었습니다. 이때 PG 쪽 동시 처리는 160으로, 가정한 계약 한도 60의 2.7배였습니다. 실제 PG라면 그 앞에서 거절했을 호출입니다.

60초로 바꾸고 화면도 맞췄다

payment.toss.read-timeout과 payment.kakaopay.read-timeout을 60초로 바꿨습니다. 카카오페이는 공식 권장값을 찾지 못해 PG가 끊긴 호출을 계속 처리한다는 같은 이유로 맞췄습니다.

걱정했던 톰캣 워커는 마르지 않았습니다. PG를 붙잡는 워커는 동시 호출 상한 40이 묶기 때문입니다. 60초 조건에서 워커 최대는 48과 68로 100에 닿지 않았습니다. 5초가 워커를 지키던 일은 이미 상한이 하고 있었습니다.

결제 화면도 바꿨습니다. 서버가 결과를 낼 수 있는 가장 긴 시간이 60초가 됐으니 화면이 그보다 먼저 포기하면 “승인됐는데 화면은 모름”이 생깁니다. 토스가 레거시 결제 시스템을 개편하며 겪은 문제가 같은 모양이었습니다.

승인 서버의 최대 처리 시간을 계산하고, 모든 상위 서버의 타임아웃을 이에 맞추어 재설정하였고, 결제 요청 체인 전체의 타임아웃을 일관되게 맞추면서 가맹점과의 불일치를 해소 하였습니다.

화면은 응답을 15초만 붙잡고 기다린 뒤 “확인 중”을 띄우고 2초 간격으로 25번 주문을 조회합니다. 조회 창이 65초까지 가서 서버의 최대 처리 시간을 덮습니다. 가짜 PG 지연을 40초로 두고 브라우저에서 결제하자 15.5초에 “확인 중”으로 바뀌었고 41.6초에 조회로 결제 확정을 받았습니다. 예전 화면이었다면 27초에 “주문내역에서 확인해 주세요”로 끝났습니다.

같이 고친 것

요청 제한 오류의 분류. 토스 기술 블로그는 비정상적으로 많은 요청에 429를 돌려준다고 적고 에러 코드 문서에는 403 FORBIDDEN_CONSECUTIVE_REQUEST(“반복적인 요청은 허용되지 않습니다”)가 있습니다. 우리 코드는 둘 다 모르는 코드로 보고 결과 모름으로 넘겼습니다. PG가 처리하기 전에 막은 요청이라 결제가 났을 수 없는데도 복구 배치가 없는 결제를 헛조회하게 됩니다. 승인에서는 확정 실패로 돌리게 고쳤습니다. 취소는 결국 나가야 하는 요청이라 지금처럼 재시도로 뒀습니다.

측정 스크립트. 계약 한도 없는 5초 조건의 첫 실행이 복구를 기다리던 중에 끝나 결과 모름이 242건 남았습니다. 대기 루프의 awk '... {print $NF; exit}'가 첫 줄에서 끝나면서 curl이 SIGPIPE로 종료됐고 pipefail과 set -e가 스크립트를 멈춘 것이었습니다. awk가 끝까지 읽게 고치고 그 조건만 다시 쟀습니다.

대가와 재지 않은 것

  • PG가 60초를 넘겨 멈추는 경우는 재지 않았습니다. 60초는 상한의 자리를 60초씩 쥐므로 식으로는 초당 50건에서 거절이 1 − 40 ÷ (50 × 60) = 98.7%입니다
  • 계약 한도를 동시 처리 수로 모델링했습니다. 실제 PG는 한도 값을 공개하지 않고 요청 비율로 거는 것으로 보입니다
  • 가짜 PG의 지연 분포입니다. 실제 카드사 응답 시간 분포로는 재지 않았습니다

정리

  • 짧은 타임아웃은 우리 쪽 대기만 줄였습니다. PG는 끊긴 호출도 끝까지 처리해서 5초는 느린 PG에 호출을 16배 보냈고 더 판 결제는 모두 결과 모름을 거쳤습니다
  • 가짜 PG가 “끊으면 PG도 멈춘다”고 가정하고 있어 5초가 유리하게 나올 뻔했습니다. 실험 도구의 가정이 실제 대상과 다른 곳부터 확인해야 했습니다
  • 앞단은 뒷단의 최대 처리 시간 이상 기다려야 합니다. 서버 타임아웃을 늘리면 화면의 조회 창도 함께 늘려야 했습니다

참고

프로필 사진
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.