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

PG가 느려지면 웹훅까지 줄을 선다: 동시 호출 상한의 비용은 식으로 나온다

목차

PG 응답이 5초로 느려지자 PG를 부르지도 않는 웹훅 수신 API의 p95가 13초가 됐고 요청의 26.1%가 토스의 10초 규약을 넘겼습니다. 결제 승인과 웹훅이 같은 톰캣 워커 100개를 나눠 쓰기 때문입니다. PG 동시 호출 상한을 40으로 걸면 같은 조건에서 웹훅 p95는 9ms로 돌아오고 대신 결제의 84%를 PG에 보내기 전에 거절합니다. 이 84%는 실험 전에 계산할 수 있는 값이었고 실측도 84.0%였습니다.

결론부터

  • 느린 PG는 PG를 호출하지 않는 웹훅과 조회까지 같은 워커·DB 풀을 통해 늦출 수 있습니다.
  • PG 동시 호출 상한 40은 웹훅 p95를 9ms로 되돌렸지만 결제의 84%를 즉시 거절했습니다.
  • 이 거절률은 실험 뒤에 우연히 나온 값이 아니라 도착률 × PG 지연 - 허용 동시성으로 미리 계산할 수 있었습니다.

상한은 장애를 없애는 버튼이 아니라, 어떤 요청을 살리고 어떤 요청을 포기할지 정하는 자원 예산입니다.

무엇을 보호해야 했나

이 글은 PG 응답이 느려지는 상황(브라운아웃)에서 무엇이 먼저 마르고 동시 호출 상한이 그것을 어떤 비용으로 막는지를 다룹니다. 상한값 하나를 고르려면 “상한이 좋다”는 한 점의 측정으로는 부족했습니다. 지연을 축으로 쓸어 보니 거절률이 식 하나로 맞아떨어졌고 그 식이 상한값을 고르는 근거가 됐습니다.

모든 수치는 가짜 PG에 지연을 주입해 한 대의 맥에서 잰 값입니다. 실제 PG의 응답시간 분포는 없고 실제 토스 재전송도 받아 보지 않았습니다. 절대값보다 정책 사이의 비교로 읽어야 합니다.

느린 PG 앞에서 톰캣 워커 100개를 결제 승인, 상품 조회, 웹훅 수신이 나눠 쓴다. 상한이 없으면 조회 p95 14,080ms와 웹훅 p95 13,056ms, 상한 40이면 결제 84%를 거절하고 조회 7ms와 웹훅 9ms

커넥션은 평평하고 워커만 늘었다

체크아웃은 PG 호출을 DB 트랜잭션 밖으로 빼 둔 구조입니다. 주문을 먼저 저장하고 커밋한 뒤 PG를 부르므로 PG가 느려도 DB 커넥션을 붙잡고 있지 않습니다. 그래서 느린 PG가 커넥션 풀(Hikari 20개)을 마르게 하는 경로는 끊겨 있습니다.

그런데 요청 하나를 처리하는 톰캣 워커 스레드는 여전히 PG 응답이 올 때까지 묶여 있습니다. 도착률 30건/초에서 PG 지연만 바꿔 가며 쟀습니다.

PG 지연커넥션 활성(평균)워커 사용(평균/최대)결제 p95처리량
05.93.6 / 999.5ms29.9/s
1s5.432.6 / 371.08s29.3/s
3s5.086.6 / 963.07s28.1/s
5s9.679.0 / 10014.26s17.6/s

PG 지연이 늘어나는 동안 커넥션 활성 수는 5~10개에 머물렀고 워커만 늘었습니다. 워커 점유 수는 리틀의 법칙(Little’s Law) 그대로입니다. 시스템 안에 머무는 요청 수는 도착률에 머무는 시간을 곱한 값이라는 관계입니다. 30건/초 × 3초 = 90개로 실측 86.6개와 맞습니다. 워커 100개가 버틸 수 있는 한계는 도착률 30건/초에서 PG 지연 약 3.3초입니다.

이 표를 만들려다 먼저 걸린 것이 있습니다. 워커 사용 수(tomcat_threads_busy_threads)가 Prometheus에 아예 나가지 않고 있었습니다. Spring Boot는 톰캣의 MBean 레지스트리를 기본으로 꺼 두는데 그러면 커넥션은 보이고 워커는 안 보입니다. 느린 PG 앞에서 마르는 자원이 바로 그 워커였습니다. server.tomcat.mbeanregistry.enabled를 켜고 나서야 위 표를 채울 수 있었습니다.

선택지는 셋이었고 각자 다른 것을 잃는다

워커가 마르면 결제와 무관한 요청까지 줄을 섭니다. 이것을 막는 방법으로 셋을 놓고 비교했습니다.

선택지얻는 것잃는 것
그대로 둔다결제를 하나도 거절하지 않는다결제와 무관한 조회까지 초 단위로 느려진다
PG 읽기 타임아웃을 조인다결제 응답이 빨리 끝난다승인 여부를 모르는 미확정 결제가 쌓인다
PG 동시 호출에 상한을 둔다상한을 넘는 결제만 PG에 보내기 전에 거절하고 나머지를 지킨다그 결제는 실패한다

타임아웃을 조이는 쪽도 재 봤습니다. PG 지연 5초에서 타임아웃을 5초에서 2초로 줄이자 결제 p95는 14.26초에서 2.13초로 줄었지만 그 구간 결제 1,351건이 전부 미확정이 됐습니다. PG가 실제로 승인했는지 모르는 결제를 1,351건 만든 것입니다. 그 결제들은 복구 배치가 PG에 다시 물어 확정해야 합니다. 가용성을 정합성 부채로 바꾼 셈이라 이 글의 주 선택지로 두지 않았습니다.

상한을 거는 쪽은 PG에 닿기 전에 끊습니다. PG에 닿지 않았다는 것이 보장되므로 실패로 확정해도 이중 결제나 미확정이 생기지 않습니다. 대신 그 결제를 잃습니다. 남은 질문은 얼마나 잃느냐였습니다.

한 점으로는 좋다와 나쁘다밖에 말하지 못한다

처음에는 PG 지연 3초 한 점에서만 쟀습니다. 상한 없음, 40, 20을 비교하니 상한 20이 조회 p95를 7.31초에서 18.6ms로 지키는 대신 결제 1,951건을 거절했습니다. 이 결과로 말할 수 있는 것은 “상한은 조회를 지키고 결제를 잃는다”까지입니다. 상한값을 몇으로 둘지는 이 한 점이 답하지 않습니다.

그래서 지연을 축으로 쓸었습니다. 결제 도착률 50건/초에 가벼운 조회를 함께 흘리고 PG 지연 여섯 단계와 상한 두 가지(없음, 40)를 조합해 12회 돌렸습니다. 표는 원자료에서 스크립트가 직접 만들었고 손으로 옮기지 않았습니다.

PG 지연상한결제 p95조회 p95거절률워커 최대
0ms없음69ms9ms0.0%7
0ms4069ms10ms0.0%6
500ms없음567ms9ms0.0%33
500ms40603ms12ms0.0%34
1,000ms없음1,080ms10ms0.0%62
1,000ms401,060ms9ms21.6%45
2,000ms없음3,050ms1,030ms0.0%100
2,000ms402,040ms8ms59.9%45
3,000ms없음10,510ms7,720ms0.0%100
3,000ms403,050ms8ms73.3%44
5,000ms없음15,770ms14,080ms0.0%100
5,000ms405,040ms7ms84.0%44

지연 0ms와 500ms에서 상한 40은 거절을 한 건도 내지 않았습니다. 상한은 PG가 이미 느려졌을 때만 발동하므로 평소에 켜 둬도 비용이 없습니다. 상한이 없으면 지연 2초부터 워커가 최대치 100에 붙고 조회가 1초, 7초, 14초로 무너집니다. 상한 40이면 같은 구간에서 조회가 7~8ms에 머뭅니다.

거절률은 실험 전에 계산된다

상한을 건 쪽의 거절률은 리틀의 법칙으로 예측됩니다. 동시에 PG를 부를 수 있는 수가 상한으로 묶이면 초당 처리 가능한 결제는 상한 ÷ 지연이고 나머지가 거절됩니다.

거절률 = 1 − 상한 ÷ (도착률 × 지연)
PG 지연예측 거절률실측차이
1s20.0%21.6%+1.6%p
2s60.0%59.9%−0.1%p
3s73.3%73.3%0.0%p
5s84.0%84.0%0.0%p

3초 이상에서는 소수점 첫째 자리까지 맞았습니다. 거절이 시작되는 지점도 같은 식에서 나옵니다. 상한 ÷ 도착률 = 40 ÷ 50 = 0.8초이고 실측도 500ms에서 거절 0건, 1,000ms에서 거절 시작이었습니다.

식이 맞는다는 것은 상한값을 다시 재지 않고 고를 수 있다는 뜻입니다. “피크 도착률 R에서 PG 지연 L까지는 거절 없이 버틴다”를 목표로 삼으면 상한은 R × L입니다.

기본값 40도 이 관계로 골랐습니다. 피크 도착률 30건/초, 정상 구간 PG 승인 p99 1초를 가정하면 평소 동시 점유는 30입니다. 상한이 30보다 작으면 장애가 없는데도 결제를 거절하므로 30이 하한입니다. 반대로 워커 100개 중 과반을 결제 밖의 요청에 남기려면 상한은 60을 넘으면 안 됩니다. 30~60 사이에서 하한에 33% 여유를 둔 40을 골랐습니다. 이번 측정은 그 유도에 쓴 도착률 × 지연 관계가 실제로 성립한다는 것을 확인했습니다. 가정한 도착률과 p99는 여전히 가정입니다. 실제 PG 분포가 생기면 같은 식에 넣어 다시 고르면 됩니다.

같은 지점을 한 번 더 돌렸을 때 차이도 결과였습니다. 상한을 건 쪽은 거절이 486건에서 488건, 1,651건에서 1,650건으로 사실상 같았습니다. 상한이 없는 쪽은 조회 p95가 7,720ms에서 7,850ms로, 결제 p95가 10,510ms에서 9,710ms로 흔들렸습니다. 포화 상태의 동작은 재현이 잘 되지 않습니다. 상한은 과부하에서의 동작을 예측 가능한 쪽으로 옮깁니다.

PG를 부르지 않는 웹훅도 같은 줄에 선다

토스 웹훅은 10초 안에 2xx를 받지 못하면 재전송합니다. 우리 웹훅 컨트롤러는 PG를 부르지 않습니다. 원본 이벤트를 저장하고 바로 200을 돌려주므로 구조상 느릴 이유가 없어 보였습니다. 하지만 이 컨트롤러도 결제 승인과 같은 톰캣 워커에서 돕니다.

웹훅만 따로 부하를 주면 이 문제가 보이지 않습니다. 결제 50건/초와 웹훅 10건/초를 같이 흘려 쟀습니다.

PG 지연상한웹훅 p95웹훅 p9910초 초과결제 p95저장된 웹훅
0ms없음13ms20ms0.00%62ms527
0ms4031ms92ms0.00%199ms526
3,000ms없음7,246ms7,974ms0.00%9,695ms493
3,000ms4013ms20ms0.00%3,046ms527
5,000ms없음13,056ms13,923ms26.11%15,865ms459
5,000ms409ms12ms0.00%5,042ms527

PG 지연 5초에서 웹훅의 26.11%가 10초를 넘었습니다. 그 건들은 재전송 대상이 되고 재전송은 다시 워커를 차지합니다. PG 장애가 웹훅 폭주로 번지는 되먹임 경로입니다. 저장 건수도 527건에서 459건으로 떨어졌습니다. 응답 코드가 아니라 webhook_events 테이블의 행 수로 확인했고 68건은 저장조차 되지 않았습니다. 같은 조건을 다시 돌렸을 때 초과 비율은 26.77%였고 상한 40은 두 번 모두 0.00%였습니다.

상한 40을 걸면 같은 조건에서 웹훅 p95가 9ms입니다. 앞에서 상한이 결제를 깎아 조회를 지킨다고 적었는데 지키는 대상에 웹훅도 들어 있었습니다.

첫 회차는 아무것도 재지 못했다

웹훅 측정의 첫 회차 표는 버렸습니다. 부하 스크립트가 주문 생성 API만 부르고 결제 승인(/payments/confirm)은 부르지 않았습니다. PG 지연이 5초인데 결제 p95가 13ms로 나와서 알았습니다. PG를 불렀다면 5초 밑으로 나올 수 없는 값입니다. 그 회차는 PG를 한 번도 부르지 않았습니다. “PG가 느려도 웹훅은 괜찮다”는 결론을 낼 수 있는 측정이 처음부터 아니었습니다.

두 번째 회차에서는 리포트 스크립트가 틀렸습니다. 웹훅 p95가 13초인데 10초 초과 비율이 전부 0%로 찍혔습니다. p95가 13초면 적어도 5%는 10초를 넘어야 하므로 둘 중 하나가 틀린 값이었습니다. k6의 Rate 지표를 rate 키로 읽고 있었는데 이 버전의 k6는 그 값을 value 키로 내보냅니다. 키를 고치자 26.11%가 나왔습니다.

정한 것과 남은 질문

PG 동시 호출 상한은 기본값 40으로 켜 둡니다. 정상 구간에서 거절이 0건이라 켜 두는 비용이 없습니다. PG가 느려지면 거절률이 식대로 나오므로 그 비용을 미리 말할 수 있습니다.

남은 질문은 셰딩 우선순위입니다. 지금 과부하 게이트는 등급이 하나라 결제와 웹훅이 같은 워커를 두고 똑같이 경쟁합니다. 그런데 둘은 늦었을 때 치르는 비용이 다릅니다. 결제가 늦으면 미확정 결제가 쌓이고 웹훅이 늦으면 재전송이 몰려옵니다. 등급을 나눌지는 이 측정이 연 다음 질문으로 남겨 뒀습니다.

이 측정이 말하지 않는 것

  • 실제 토스 재전송을 받아 본 것이 아닙니다. 우리 응답 시간을 쟀고 재전송은 그 결과로 일어날 수 있는 일입니다. 공개 URL로 웹훅을 받는 검증은 아직 하지 않았습니다.
  • 실제 PG의 응답시간 분포가 없습니다. 이 측정으로 상한값은 바꾸지 않았습니다. 상한이 언제 의미 있는지에 대한 근거가 생겼습니다.
  • 지연 0ms, 상한 40에서 웹훅 31ms와 결제 199ms로 다른 조합보다 약간 높았습니다. 이 구간에서 거절은 0건이라 상한이 발동한 것은 아닙니다. 워밍업이 덜 끝난 영향으로 보이지만 원인을 확정하지 못했습니다.

이어서 읽기

이 글에서 이어진 작업은 따로 적었습니다.

참고

프로필 사진
작성자 @범수

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

댓글

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