포스트

ParityPay로 검증하는 결제 정합성 9 - 느린 기관 앞에서 결제 서버를 지키기: 타임아웃, 재시도, 차단기, 리미터, 벌크헤드가 각각 무엇을 막는가

엔지니어링 요약

Problem

3편은 응답이 사라진 거래 하나를 어떻게 확정하는지에 답했다. 기관이 40초 동안 느려지면 어떻게 되는지는 답하지 않았다. 외부 PG 호출에는 타임아웃과 UNKNOWN만 있었고, 진행 중 호출의 상한도 죽은 기관에 보내는 요청을 끊는 장치도 없었다.

Decision

Mock PG가 소켓을 연 채 응답을 붙잡게 해 read timeout 조건을 만들고, 다섯 가지를 같은 부하에서 쟀다. 타임아웃 없음(플랫폼 vs 가상 스레드), 타임아웃+재시도, 차단기 상태 전이, 리미터 알고리즘 3종, 벌크헤드. 실험 뒤 벌크헤드와 차단기를 기본값으로 채택하고(ADR-014), 보내지 않은 요청은 UNKNOWN이 아니라 FAILED로 확정하게 했다.

Result

플랫폼 스레드 200개는 3회 모두 23.7~24.0초에 고갈됐고 그 순간 지표 엔드포인트도 함께 죽었다. 가상 스레드는 붕괴하지 않았지만 결제 300건이 상한 없이 기다렸다. 재시도 3회는 기관 요청을 정확히 3.00배로 만들었고 지터는 봉우리를 낮추지 못했다. 차단기는 죽은 기관에 보내는 결제를 370건에서 46건으로 줄이고 거절 결제 중 기관 도달은 0이었다. 벌크헤드 50은 같은 붕괴 조건에서 무관한 API를 p95 10ms로 지켰다.

3편에서 외부 승인 응답이 사라진 거래를 UNKNOWN으로 보존해 조회로 수렴시켰다. 그 글의 장애는 거래 하나의 응답이 사라지는 것이었다. 이 글의 장애는 다르다. 기관이 40초 동안 모든 요청에 30초씩 응답을 붙잡으면, 그동안 들어오는 결제 수백 건과 결제와 무관한 잔액 조회는 어떻게 되는가.

설계 문서에는 답이 절반만 있었다. 읽기 타임아웃이 있고, 타임아웃은 UNKNOWN이 되고, 복구 작업이 확정한다. 그런데 동시에 몇 건까지 기관을 기다릴 것인가, 기관이 죽어 있는 동안에도 계속 보낼 것인가에 대한 답은 없었다. 다섯 가지 실험으로 그 빈칸이 실제로 무엇을 만드는지 봤고, 실험 뒤에 빈칸을 채웠다. 이번에는 실험 코드가 아니라 기본값이 바뀌었다.

실험의 공통 조건

장애 주입은 Toxiproxy 없이 Mock PG 자체의 HANG_BEFORE_PROCESSING·HANG_AFTER_PROCESSING으로 했다. 소켓을 연 채 응답을 붙잡는 것이 곧 TCP read timeout 조건이라 프록시가 필요 없었다. 대신 연결 거부, 패킷 손실 같은 조건은 이 실험에 없다. 실험용 설정은 experiment-resilience 프로필과 EXPERIMENT_* 환경변수로만 바뀐다. 8편과 같은 원칙이다. pay-api 1대(호스트), Mock PG 1대(대역이 먼저 마르지 않게 가상 스레드로 바꿈), HikariCP 20, Tomcat 200. 부하는 k6로 PG 결제와 결제와 무관한 잔액 조회를 동시에 건다. 무관한 API가 언제 무너지는지가 이 실험 전체의 축이다. 각 3회.

실험 1: 타임아웃이 없으면 언제 무너지는가

읽기 타임아웃 120초, 벌크헤드·차단기 끔, Mock PG 30초 지연. 결제 10rps + 조회 20rps, 75초. 40초에 jstack 1회. 이 프로젝트의 기본은 가상 스레드이므로 같은 조건을 플랫폼 스레드(전형적인 구성)와 둘로 쟀다(M-019).

스레드조회 p95조회 실패무관 API 붕괴 시각Tomcat busy 최대Hikari 대기
플랫폼(200), 3회30,001~30,003ms184~19523.7~24.0초197~199 / 2000
가상(기본), 3회7.0~8.1ms0없음0

플랫폼 스레드 run 1의 시간 축은 산술 그대로였다. 초당 10건이 각 30초를 붙잡으니 busy가 초당 10씩 올라(4초 6 → 8초 48 → 16초 121 → 23초 195) 23~24초에 200을 채웠고, 그 순간부터 결제와 무관한 잔액 조회가 한 건도 응답하지 못했다. 40초의 jstack에서 스레드 234개 중 http-nio-exec-* 202개 전부가 NioSocketImpl.timedRead에서 기관 응답을 기다리고 있었다. Hikari 활성 커넥션은 0~2. DB 풀은 마르지 않았다. 외부 호출이 트랜잭션 밖에 있기 때문이고, 6편에서 대사의 트랜잭션 경계를 옮긴 것과 같은 규칙이 여기서 값을 했다.

하나 더 죽은 것이 있다. /actuator/prometheus가 같은 Tomcat 스레드를 쓰므로 붕괴 순간부터 스크레이프가 실패해 24~108초 구간의 지표가 없다. 표에 그 시간이 비어 있는 것은 하니스 실수가 아니라 그 시간에 아무도 이 서버의 상태를 볼 수 없었다는 기록이다. 경보가 “지표 없음”을 이상으로 다루지 않으면 붕괴는 조용하다. 4편 결함 G에서 캐시가 늙는 것을 refresh_age_seconds로 잡았던 것과 같은 자리의 문제다.

가상 스레드에서는 무관한 API가 무너지지 않았다. 조회 p95 7~8ms, 실패 0. 그러나 진행 중인 결제는 상한 없이 쌓였다. 30초 × 10rps = 300건이 늘 기관을 기다리고 있었다. 가상 스레드는 붕괴를 막지만 “느린 기관을 얼마나 기다릴 것인가”에는 답하지 않는다. 그리고 jstack에는 가상 스레드가 보이지 않는다(41~43개, 소켓 읽기 0). 가상 스레드 구성에서 “무엇이 기관을 기다리는가”는 스레드 덤프가 아니라 진행 중 호출 수 지표로 봐야 한다.

실험 2: 재시도는 기관에 무엇을 하는가

타임아웃 3초를 두고 재시도를 붙이면 기관이 받는 요청이 몇 배가 되는지, 지터가 무엇을 바꾸는지(M-020). Mock PG는 승인한 뒤 6초 응답을 붙잡으므로 모든 요청이 타임아웃되고 재시도는 같은 외부 키로 다시 온다. 이 프로젝트는 승인 재시도를 금지하므로(ADR-007) 재시도는 실험 프로필에서만 켜진다.

재시도기관이 받은 요청 / 클라이언트 요청배수기관 승인 기록기관 초당 최대결제 p95
없음 (기본), 3회300~301 / 300~3011.00×300~301113,034~3,036ms
3회 즉시, 3회900~903 / 300~3013.00×300~301339,047~9,050ms
3회 지수+지터, 3회900~903 / 300~3013.00×300~30138~4110,279~10,370ms

재시도 3회는 기관 요청을 9회 전부 정확히 3.00배로 만들었다. 기관 입장에서 초당 11건이던 부하가 33건이 됐고, 그 기관은 이미 느려서 타임아웃이 나던 중이다. 재시도는 아픈 기관에 세 배의 부하를 얹는다.

이중 청구가 안 난 것은 우리 덕이 아니라 기관 덕이다. 승인 기록이 요청 수와 같은 것은 Mock PG가 외부 키로 멱등하기 때문이다. 멱등하지 않은 기관이면 승인 기록 903, 한 결제에 세 번 청구다. 우리 쪽에서 그것을 막을 방법은 재시도하지 않는 것뿐이고, 그래서 설계는 재시도 대신 UNKNOWN과 조회다.

지터는 총량을 줄이지 않았고 이 부하에서는 봉우리도 낮추지 못했다. 초당 최대가 33에서 38~41로 오히려 높다. 지터가 값을 하는 조건은 동기화된 폭주, 장애 복구 직후 수천 클라이언트가 같은 순간에 재시도하는 것인데, 이 부하는 초당 10건이 고르게 도착하는 것이라 펼칠 봉우리가 없었다. 지터는 재시도 정책의 필수 요소이지 재시도 자체를 정당화하지 않는다. 사용자가 겪는 것은 단순하다. 재시도가 없으면 3초 뒤 “확인 중”, 있으면 9~10초 뒤 같은 “확인 중”.

실험 3: 차단기가 열려 있는 동안 들어온 결제는 무엇이 되는가

차단기 파라미터는 COUNT 윈도 20·최소 10건·실패율 50%·OPEN 대기 10초·HALF_OPEN 허용 3건. 타임아웃 3초, 벌크헤드 50. Mock PG를 6초 지연으로 40초 유지한 뒤 정상으로 되돌린다. 결제 10rps + 조회 5rps, 90초. 대조군은 차단기만 끈다(M-021).

차단기201 APPROVED201 FAILED (CIRCUIT_OPEN)202 UNKNOWN거절 결제 중 기관 도달
켬 (기본), 3회489~496329~36046~830
끔 (대조), 3회530~5310369~370

run 2의 상태 전이(부하 시작 0초, 기관 복구 40.5초):

시각전이무슨 일
+7.5sCLOSED → OPEN최소 10건 전부 타임아웃, 실패율 100%
+17.5sOPEN → HALF_OPEN10초 대기 끝. 3건만 흘려보냄
+20.7sHALF_OPEN → OPEN3건 전부 타임아웃
+30.8sOPEN → HALF_OPEN 
+34.0sHALF_OPEN → OPEN아직 죽어 있음
+44.1sOPEN → HALF_OPEN기관은 40.5초에 회복됨
+44.3sHALF_OPEN → CLOSED3건이 0.2초 안에 성공. 회복 뒤 3.8초

OPEN 동안 들어온 결제는 즉시 FAILED(CIRCUIT_OPEN), HTTP 201, 기관 도달 0, 미확정 0이다. 세 번 모두 329~360건이 그렇게 끝났고 그중 기관에 승인 기록이 있는 것은 하나도 없다. 결제 ID로 Mock PG의 승인 표를 대조했다. 차단기는 돈을 움직이지 않았다.

이 결과가 나오려면 결정이 하나 필요했다. 요청이 나가지 않은 것은 UNKNOWN이 아니다. 3편의 규칙은 “모르는 동안에는 아무것도 확정하지 않는다”였는데, 차단기가 거절한 요청은 모르는 것이 아니라 보내지 않은 것이다. 그래서 PgCallRejectedException으로 구분해 알려진 실패로 확정하고 failureReason을 응답에 실었다. 이것을 UNKNOWN으로 두면 복구 작업이 존재하지 않는 기록을 기관에 묻는다.

차단기가 있어도 미확정은 생긴다. 열리기 전 10건, HALF_OPEN 프로브 3건씩. 46건이 그것이고 전부 복구 작업이 실행 중에 확정했다. 차단기는 미확정을 없애는 것이 아니라 죽은 기관에 보내는 요청 수를 윈도 크기 + 프로브 수로 묶는 장치다. 차단기가 없으면 장애 40초 동안의 결제 370건이 전부 기관으로 나가 3초씩 기다린 뒤 미확정이 됐고, 복구 작업은 370건 × “없음” 3회 확인을 떠안았다. 대신 기관 회복 뒤 정상 복귀는 즉시였다. 차단기는 회복을 몇 초 늦추는 대신 죽은 기관에 보내는 요청과 미확정을 8분의 1로 줄이는 거래다(370 → 46, 복귀 3.8~3.9초).

run 1은 달랐다. HALF_OPEN 프로브 3건이 기관 복구 순간과 겹쳐 +38.3초에 한 번 CLOSED가 됐다가, 그 직전에 나갔던 호출들의 타임아웃이 새 윈도에 들어와 +42.2초에 다시 OPEN, +52.5초에 최종 CLOSED(회복 뒤 12.6초). 이 한 번의 되열림이 run 1의 UNKNOWN이 83건인 이유다. 차단기의 복귀 시간은 상수가 아니라 대기 주기의 어디에 복구가 떨어지느냐에 달려 있다.

실험 4: 리미터 알고리즘은 순간에서 갈린다

기관이 초당 50건 상한을 둔다고 가정하고, 부하 80rps에서 세 알고리즘의 어느 1초 구간의 최대 통과 수를 쟀다(M-022). 통과 시각은 Mock PG 승인 표의 created_at으로 읽는다. 거절은 기다리지 않고 즉시.

알고리즘통과 (20초)임의 1초 구간 최대임의 100ms 최대결제 p95거절 결제 중 기관 도달
고정 윈도, 3회1,027~1,03177~82 (예비 실행 100)14~1818.1~20.4ms0
토큰 버킷, 3회1,0478613~1417.6~18.6ms0
슬라이딩 윈도, 3회998~1,0005214~1817.8~19.8ms0

셋 다 평균은 지켰고 순간은 다르다. 20초 동안 통과한 수는 초당 50~52로 비슷하지만 어느 1초 구간의 최대는 고정 윈도 77~82, 토큰 버킷 86, 슬라이딩 윈도 52다. 기관의 상한이 “초당 50”이라면 앞의 둘은 그것을 1.5~2배 넘긴 순간이 있었다.

고정 윈도의 경계 몰림은 실제로 났다. 윈도가 1초마다 리셋되므로 앞 윈도의 끝 50건과 뒤 윈도의 첫 50건이 붙으면 어느 1초 안에 100건이 지나간다. 예비 실행이 정확히 100, 본 실행이 77~82였던 것은 부하가 윈도 경계에 얼마나 붙느냐의 차이다. 토큰 버킷은 버킷 크기만큼 초기 버스트를 허용한다. 첫 1초에 버킷 50 + 리필 36이 나가 86이고 그 뒤로는 초당 50이다. 상한이 평균이면 좋은 성질이고 순간이면 넘치는 성질이다. 슬라이딩 윈도는 어느 1초를 잘라도 52를 넘지 않았다. 통과가 정확히 1,000 = 50 × 20.

거절은 즉시이고 결제는 FAILED(RATE_LIMITED)다. p95 18~20ms. 결제를 붙잡아 두는 것은 사용자에게 타임아웃과 같으므로 기다리게 하지 않았다. 거절된 결제는 기관에 도달하지 않았고 미확정도 아니다.

기본값은 리미터 이다. Mock PG는 상한을 알려 준 적이 없고, 모르는 상한을 지키는 것은 우리가 우리를 막는 것이다. 켜야 할 때의 기본은 슬라이딩 윈도가 아니라 토큰 버킷으로 뒀다. 대부분의 기관 상한은 순간이 아니라 평균이고, 버스트를 흡수하는 편이 사용자에게 낫다. 순간 상한이 명시된 기관이면 슬라이딩 윈도로 바꾼다.

실험 5: 벌크헤드 하나로 붕괴가 사라지는가

실험 1의 플랫폼 스레드 붕괴 조건 그대로에 벌크헤드(PG 동시 호출 50, 대기 0)만 더했다(M-023).

run기관에 나간 결제BULKHEAD_FULL 거절조회 p95조회 실패붕괴Tomcat busy 최대jstack 소켓 대기
1–31506019.5~10.2ms0없음53 / 20050

같은 조건에서 붕괴가 사라졌다. 24초에 무너져 조회 p95 30초·실패 190여 건이던 것이 벌크헤드 50 하나로 조회 p95 10ms·실패 0이 됐다. Tomcat busy는 53에서 멈췄고 jstack의 소켓 대기 스레드는 정확히 50개다.

대가는 결제 601건의 즉시 거절이다. 30초 × 10rps = 300건이 늘 기관을 기다려야 하는데 자리는 50개라 나머지는 기다리지 않고 거절됐다. 거절된 결제는 기관에 도달하지 않았고 미확정도 아니다. 이것이 벌크헤드의 계약이다. 느린 기관 때문에 잃는 것을 결제 일부로 한정하고 나머지 시스템은 지킨다. 상한 없이 전부 기다리게 하면 잃는 것이 시스템 전체다. 50이 맞는 수치라는 뜻은 아니다. “상한이 있어야 한다”가 결론이고, 값은 기관의 동시 처리 능력과 인스턴스 수로 정한다.

결함 N과 기본값 변경

다섯 실험이 드러낸 것은 결함 하나로 모인다. 외부 PG 호출에 동시성 상한도 차단기도 없었다. 기관이 느려지면 진행 중 호출이 상한 없이 쌓이고(실험 1 가상 스레드: 300건), 기관이 죽어 있는 동안에도 결제마다 타임아웃까지 기다렸다가 미확정이 됐다(실험 3 대조군: 40초에 370건). 가상 스레드와 트랜잭션 밖 호출 덕에 무관한 API는 살았지만, 그것은 “느린 기관을 얼마나 기다릴 것인가”에 대한 답이 아니었다. 상한이 없으면 격리가 아니다. 결함 목록의 14번째이고, 실험 뒤 고쳤다.

ADR-014로 외부 PG 호출 앞에 PgCallGuard를 두고 기본값을 바꿨다.

장치기본값거절 시 결제 상태
타임아웃3초 (변경 없음)UNKNOWN — 보냈는데 모름
벌크헤드동시 50, 대기 0, FAILED(BULKHEAD_FULL) — 보내지 않음
차단기윈도 20·최소 10·실패율 50%·OPEN 10초·HALF_OPEN 3, FAILED(CIRCUIT_OPEN) — 보내지 않음
리미터끔 (켜면 토큰 버킷)FAILED(RATE_LIMITED) — 보내지 않음
재시도없음 (실험 전용)

표의 마지막 열이 이 ADR의 핵심이다. 타임아웃만 UNKNOWN이고 나머지는 전부 FAILED다. 보낸 것과 보내지 않은 것을 구분하는 것이 격리 장치와 3편의 복구 규칙을 충돌 없이 붙이는 조건이었다.

장치별 역할

다섯 장치가 서로 다른 질문에 답한다는 것이 이 실험의 결론이다.

질문장치실측
한 요청을 얼마나 기다릴 것인가타임아웃없으면 24초에 스레드 200개 고갈. 있으면 3초 뒤 UNKNOWN
동시에 몇 건까지 기다릴 것인가벌크헤드50 → 무관한 API p95 10ms, 결제 601건 즉시 거절
죽은 기관에 몇 건까지 보낼 것인가차단기370건 → 46건. 복귀 3.8초 늦어짐
기관 상한을 순간까지 지킬 것인가리미터슬라이딩 윈도만 52. 고정·토큰은 77~86
스레드가 고갈되는가가상 스레드붕괴 없음. 그러나 위 네 질문에는 답하지 않음
실패한 요청을 다시 보낼 것인가재시도기관 부하 3.00×, 사용자 대기 3배, 같은 답. 하지 않음

한계

  • 연결 거부·패킷 손실·은행 쪽은 재지 않았다. 장애 주입이 Mock PG의 응답 보류뿐이라 “연결이 즉시 실패하는” 조건과 “응답만 유실되는” 조건이 없다. 후자에서는 재시도의 중복이 달라진다(8편 한계와 같은 자리).
  • 컨테이너 리소스 제한이 없다. pay-api는 호스트 JVM이다. 붕괴 시각 23.7~24.0초는 이 노트북의 값이고, 산술(10rps × 30초 / 200)이 맞았다는 것까지만 일반화된다.
  • 지터의 결론은 조건부다. 균등 도착 부하에서만 쟀다. 동기화된 폭주 조건에서는 지터가 봉우리를 낮출 것이고, 그 조건은 만들지 않았다.
  • 벌크헤드 50, 차단기 파라미터는 근거 있는 값이 아니다. 존재해야 한다는 것을 보였을 뿐 값의 산정은 기관의 처리 능력과 인스턴스 수를 알아야 한다.
  • HALF_OPEN 프로브가 복구 경계와 겹치는 되열림(run 1)은 3회 중 1회라 빈도를 말할 수 없다.

실무 경험과의 관계

monticker의 Circuit Breaker 글은 Resilience4j 설정과 폴백 체인을 다뤘다. 상태 머신을 그렸지만 전이 시각을 재지는 않았고, OPEN 동안 들어온 요청이 무엇이 되는지는 “폴백 소스로 간다”로 끝났다. 시세 조회는 그래도 된다. 결제는 안 된다. 폴백이 없는 도메인에서 OPEN 중 요청의 운명은 “실패”인데, 그 실패가 UNKNOWN인지 FAILED인지가 복구 작업의 부하와 사용자가 다시 시도할 수 있는지를 가른다. 그 구분이 이 글에서 새로 나온 것이다.

2021년의 결제대행 연동에는 “타임아웃이면 어느 쪽인가”라는 질문 자체가 없었다고 썼다. 이 글은 그 다음 질문들이다. 타임아웃이 있다고 치고, 동시에 몇 건까지, 죽은 기관에는 몇 건까지, 다시 보낼 것인가. 2021년에는 스케줄러가 한 대라 이 질문이 문제가 되지 않았을 뿐이고, 실험 1의 산술은 한 대에서도 24초면 충분하다는 것을 보여준다.

정리

  • 타임아웃이 없으면 플랫폼 스레드 200개는 산술대로 24초에 고갈되고, 그 순간 지표 엔드포인트도 함께 죽는다. 가상 스레드는 붕괴를 막지만 진행 중 호출을 상한 없이 쌓는다.
  • 재시도 3회는 아픈 기관에 정확히 3배의 부하를 얹고 사용자를 3배 기다리게 해 같은 답을 준다. 이중 청구가 안 난 것은 기관의 멱등성 덕이다.
  • 차단기는 죽은 기관에 보내는 요청을 윈도 + 프로브로 묶는다. 370건이 46건이 됐고, 거절 결제 중 기관 도달은 0이었다. 대가는 복귀 3.8초.
  • 리미터는 평균에서는 같고 순간에서 갈린다. 순간 상한이 명시된 기관이 아니면 토큰 버킷.
  • 벌크헤드는 잃는 것을 결제 일부로 한정한다. 같은 붕괴 조건에서 무관한 API p95 10ms.
  • 보낸 요청과 보내지 않은 요청은 다르다. 타임아웃만 UNKNOWN이고 거절은 FAILED다. 이 구분이 없으면 격리 장치가 복구 작업의 부하가 된다.
  1. 1 ParityPay로 검증하는 결제 정합성 1 - 장애가 나도 지켜야 할 금융 불변조건 여섯 가지
  2. 2 ParityPay로 검증하는 결제 정합성 2 - 잠금을 필요 이상으로 오래 쥐고 있었다: 추론을 측정으로 바꾼 기록
  3. 3 ParityPay로 검증하는 결제 정합성 3 - 외부 승인 응답이 사라졌을 때: 타임아웃은 실패가 아니다
  4. 4 ParityPay로 검증하는 결제 정합성 4 - 실험 27종이 찾아낸 결함 12건: 문서와 코드를 읽어서 나온 것은 하나도 없었다
  5. 5 ParityPay로 검증하는 결제 정합성 5 - DB 커밋과 Kafka 발행 사이: Transactional Outbox와 at-least-once 소비자
  6. 6 ParityPay로 검증하는 결제 정합성 6 - 대사: "기관에 물어보지 못했다"와 "기관에 기록이 없다"는 다른 상태다
  7. 7 ParityPay로 검증하는 결제 정합성 7 - 애플리케이션 코드를 믿지 않는 원장: 이중부기를 계정 체계, DB 제약, 속성 테스트로 강제하기
  8. 8 ParityPay로 검증하는 결제 정합성 8 - Kafka에서 중복·유실·순서 역전을 직접 만들어 보기: 멱등 소비자와 Outbox가 막는 것과 못 막는 것
  9. 9 ParityPay로 검증하는 결제 정합성 9 - 느린 기관 앞에서 결제 서버를 지키기: 타임아웃, 재시도, 차단기, 리미터, 벌크헤드가 각각 무엇을 막는가
  10. 10 ParityPay로 검증하는 결제 정합성 10 - 락 lease가 트랜잭션보다 먼저 끝나면 정말 정합성이 깨지는가: SETNX 락, Watchdog, fencing token, 그리고 락 없는 구조의 실측
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다