결제 실패: 카드에서는 빠졌는데 우리는 실패로 알았다
목차
개요
카드에서는 돈이 빠져나갔는데 우리 서버는 그 결제를 실패로 처리하고 있었습니다. PG(결제대행사) 응답이 안 오면 결제가 된 건지 안 된 건지 알 수 없고, 이미 승인이 나간 뒤에는 DB 롤백으로 되돌릴 수도 없습니다.
이 글은 실패를 네 종류로 나눠 각각 다르게 다룬 기록입니다. 응답이 안 와서 결과를 모르는 실패, 승인은 났는데 우리 쪽 재고가 없는 실패, PG 자체가 죽어서 다른 곳으로 보내야 하는 실패, 완료된 줄 알았는데 뒤집히는 실패. 나누는 기준은 하나입니다. 무엇이 확정됐고 무엇이 미상인가.
| 상황 | 다룬 것 |
|---|---|
| 상황 1 | 타임아웃을 실패로 단정하지 않고 UNKNOWN으로 남겼다 |
| 상황 2 | 승인은 났는데 재고가 모자라면 방금 낸 승인을 되돌린다 |
| 상황 3 | 멀티 PG 라우팅. 언제 넘기면 안 되는가 |
| 상황 4 | 가상계좌는 완료가 최종 상태가 아니었다 |
개인 프로젝트로 만든 결제 시스템 pay의 개발 기록입니다. 실무 운영 경험이 아닙니다.
결제가 실패했을 때: 타임아웃을 실패로 단정하지 않는다
PG 응답이 안 오면 돈이 나갔는지 안 나갔는지 우리는 모릅니다. 그걸 실패로 단정하지 않고 UNKNOWN으로 남긴 뒤, 멱등키와 복구와 망취소 셋으로 나중에 확정했습니다. 망취소는 승인이 난 뒤에 그 승인을 되돌리는 취소를 말합니다.
0. 왜 성공이 아니라 실패부터 설계했나
PG를 연동해서 “결제 성공”까지 가는 건 흔합니다. 차별화는 PG가 책임지지 않는 영역에 있고, 그게 바로 이번 이야기입니다.
앞 절에서 타임아웃을 UNKNOWN 상태로 보존만 했습니다. 이번엔 그 미확정을 실제로 확정합니다. 만든 것은 셋.
멱등키 "따닥" 중복결제 + 타임아웃 후 안전 재시도UNKNOWN 복구 조회 API로 확정 / 안 되면 망취소서킷브레이커 PG 장애가 우리 전체로 번지지 않게 (단, 승인은 재시도 금지)1. 따닥: 멱등키, 그리고 “INSERT가 곧 잠금”
사용자가 결제 버튼을 두 번 누르면(따닥), 승인 요청이 두 번 갑니다. 프론트에서 버튼 비활성화? UX일 뿐입니다. 방어는 서버에 있어야 합니다.
멱등키는 이렇게 설계했습니다. 같은 Idempotency-Key로 온 요청은 딱 한 번만 실제 실행되고, 재요청엔 첫 응답을 그대로 재반환합니다.
핵심은 동시성 처리입니다. 따닥은 거의 동시에 도착하는데 어떻게 하나만 통과시키나. DB 유니크 제약입니다.
// (멱등키 + 경로 + 메서드) 유니크try { record = repository.saveAndFlush(IdempotencyRecord.start(key, path, method, requestHash));} catch (DataIntegrityViolationException race) { // 다른 요청이 같은 순간 먼저 INSERT 함 → 그 결과로 판정 return handleExisting(reload(key), requestHash, responseType);}INSERT에 성공했다는 것 자체가 “처리권을 획득했다”는 뜻입니다. 동시에 온 두 요청 중 하나만 INSERT에 성공하고, 나머지는 유니크 위반으로 튕깁니다. 별도의 분산락이 필요 없습니다. DB가 이미 락 역할을 합니다.
상태에 따라 응답도 나눕니다. 토스페이먼츠와 같은 시맨틱입니다.
| 상황 | 응답 |
|---|---|
| 처리 완료된 같은 키 | 저장된 첫 응답 재반환 (재실행 없음) |
| 처리 중인 같은 키 | 409: 잠시 후 같은 키로 재시도 |
| 같은 키인데 본문이 다름 | 422: 위험한 재사용 |
| 키 없음/형식 오류 | 400 |
“같은 키 다른 본문 = 422”가 중요합니다. 멱등키는 “이 요청을 한 번만”이라는 약속인데, 본문이 다르면 약속이 깨진 것입니다.
2. 타임아웃을 실패로 단정하지 않고 UNKNOWN을 확정하기
여기서 제일 하고 싶은 이야기입니다.
PG 승인 API를 부르다 타임아웃이 나면, 결제가 된 건지 안 된 건지 우리는 모릅니다. 흔한 실수가 “타임아웃 = 실패” 처리입니다. 그러면 카드에서는 돈이 빠졌는데 우리는 실패로 알고 주문을 취소해버립니다. 최악의 CS입니다.
그래서 카카오페이의 3-상태 모델을 따랐습니다. 성공, 실패, Unknown.
return switch (result.outcome()) { case SUCCESS -> { payment.approve(...); /* 완료 이벤트 발행 */ } case FAILED -> { payment.abort(...); } // 명시적 거절만 실패 case TIMEOUT -> { payment.markUnknown(reason); } // 미확정 — 보존합니다};복구 배치가 UNKNOWN을 확정합니다. 주기적으로 UNKNOWN 결제를 스캔해서, PG에 조회 API로 실제 상태를 물어봅니다.
PgQueryResult pg = pgClient.query(payment.getPaymentKey());switch (pg.status()) { case APPROVED -> payment.confirmByRecovery(pg.method()); // 실제론 됐네 → 전진 복구(DONE) case NOT_FOUND -> payment.abortByRecovery("PG에 결제 없음"); // 진짜 안 됐네 → ABORTED case CANCELED -> payment.networkCancel("이미 취소됨"); // 망취소}여기서 선택이 하나 갈립니다. PG에 조회했더니 실제로는 승인돼 있으면, 나는 취소하지 않고 전진 복구(주문을 완료)를 택했습니다. 사용자가 결제하려던 거였으니 완성시켜주는 게 맞습니다. 반대 정책(타임아웃이면 무조건 취소, 곧 망취소)도 유효해서 networkCancel도 만들어 뒀습니다. 둘 다 상태머신에 허용 전이로 넣었습니다(UNKNOWN → DONE, UNKNOWN → CANCELED).
복구 배치는 건별로 실패를 격리합니다. 한 결제 복구가 터져도 배치 전체가 멈추지 않고, 다음 주기에 다시 시도됩니다. 이런 결제는 조용히 사라지면 안 되니까.
이걸 테스트로 못 박았습니다. FakePgClient를 상태 기반으로 만들어서 “우리는 타임아웃이었지만 PG엔 승인으로 남은” 상황을 재현했습니다.
@Test@DisplayName("PG에 승인돼 있으면 전진 복구(DONE) + 완료 이벤트 발행")void recoverForwardWhenPgApproved() { Payment p = unknownPayment("pk-1"); when(pg.query("pk-1")).thenReturn(new PgQueryResult(APPROVED, "CARD")); service.recoverUnknownPayments(); assertThat(p.getStatus()).isEqualTo(DONE);}3. 서킷브레이커: 승인은 재시도하면 안 된다
국내 상위 PG사도 실제로 한 시간씩 장애가 납니다. 그때 우리 서버가 모든 요청을 10초씩 기다리면? 스레드가 고갈되고, PG 장애가 우리 전체 장애로 번집니다.
그래서 Resilience4j 서킷브레이커로 PG 호출을 감쌌습니다. 실패율이 임계치를 넘으면 회로가 OPEN되고, 그 뒤엔 PG를 아예 호출하지 않고 즉시 폴백합니다.
그런데 결제 도메인만의 함정이 있습니다. 보통 “장애엔 재시도”가 정석인데,
승인(approve)은 재시도하면 안 됩니다. 멱등키 없이 승인을 재시도하면, 첫 요청이 사실 성공했을 경우 이중결제가 납니다. 재시도가 오히려 사고를 만듭니다.
그래서 이렇게 나눴습니다.
| PG 호출 | 재시도 | 이유 |
|---|---|---|
| 승인 (approve) | 안 함 | 멱등키 없는 재시도 = 이중결제. 실패/서킷오픈 시 UNKNOWN으로 돌려 복구 배치에 맡김 |
| 조회 (query) | 함 (지수 백오프+지터) | 읽기라 몇 번을 불러도 안전 |
| 취소 (cancel) | 안 함 (서킷만) | 호출부가 실패를 처리 |
public PgApproveResult approve(PgApproveCommand command) { try { return circuitBreaker.executeSupplier(() -> delegate.approve(command)); } catch (CallNotPermittedException open) { return PgApproveResult.timeout("서킷 오픈: PG 장애로 승인 미확정"); // 재시도 아님 — UNKNOWN } catch (RuntimeException ex) { return PgApproveResult.timeout("PG 오류로 승인 미확정: " + ex.getMessage()); }}승인 예외를 TIMEOUT(=UNKNOWN)으로 돌리는 게 핵심입니다. PG가 예외를 던져도 실제로 처리됐을 수 있으니 실패로 단정하지 않고, 앞의 복구 배치로 흘려보냅니다. 실패 처리의 모든 길이 UNKNOWN → 복구로 모이게 설계했습니다.
이것도 테스트로 박았습니다.
@Test@DisplayName("승인은 재시도하지 않는다 — 멱등키 없는 재시도는 이중결제 위험")void approveIsNotRetried() { flaky.approveError = new RuntimeException("PG 오류"); client.approve(new PgApproveCommand("pk", "order-1", 10_000)); assertThat(flaky.approveCalls.get()).isEqualTo(1); // 딱 한 번}4. 다층 방어로 겹쳐 보면
이번에 만든 걸 겹쳐 보면 실패 처리가 여러 겹입니다.
1차 멱등키 + 유니크 제약 중복(따닥)을 아예 차단2차 3-상태 모델 + UNKNOWN 타임아웃을 실패로 단정하지 않고 보존3차 서킷브레이커 PG 장애 전파 차단4차 복구 배치 미확정을 조회로 확정 / 망취소모든 실패 경로가 한 곳(UNKNOWN → 복구)으로 수렴하게 만든 게 이번 설계의 핵심입니다. 타임아웃이든 PG 예외든 서킷 오픈이든 전부 “미확정”으로 보존됐다가 복구 배치가 조회로 확정합니다. 실패의 종류마다 다른 특수 처리를 흩뿌리지 않았습니다.
테스트는 전부 통과합니다. 아직 실제 PG(토스페이먼츠) 대신 상태 기반 FakePgClient로 이 모든 시나리오를 재현하고 있습니다. 덕분에 네트워크·키 없이도 타임아웃·장애·복구를 결정적으로 테스트할 수 있습니다.
5. 후처리 여섯 가지 중 셋을 뺐다
같은 문제를 다룬 카카오페이의 MSA 결제 트랜잭션 관리 글이 미확정 건의 후처리를 여섯 가지로 나열합니다. 그 목록에 우리 선택을 대 봤습니다.
| 후처리 | 이 시스템 |
|---|---|
| 즉시 재요청 | 안 합니다 |
| 일정 시간 뒤 재시도 | 안 합니다 |
| 성공했는지 확인 후 재시도 | 확인만 합니다. 조회 결과가 곧 확정이고 재시도는 없습니다 |
| 결제 취소 요청(보상) | 합니다. 망취소를 적재하고 배치가 재시도합니다 |
| 무조건 성공 후 뒤처리 | 안 합니다 |
| 수기 처리 | 합니다. 자동으로 못 닫힌 건의 마지막 자리입니다 |
빠진 셋에 공통점이 있습니다. 승인을 한 번 더 보내는 경로를 전부 뺐습니다.
// 승인(approve)은 재시도하지 않는다. 멱등키 없이 승인을 재시도하면 이중결제가 난다.// 조회(query)는 읽기라 재시도가 안전하다. 지수 백오프 + 지터로 일시 장애를 흡수한다.미확정은 모르는 상태지 실패한 상태가 아닙니다. 모르는 상태에서 같은 승인을 다시 보내면, 앞의 것이 실제로는 성공해 있었을 때 결제가 두 번 됩니다. 그래서 되돌리는 대신 물어봅니다. 재시도를 붙인 곳은 조회 하나뿐입니다.
카드사가 멱등키를 보장하면 승인 재시도도 안전해집니다. 토스페이먼츠는 지원하고, 우리 어댑터도 이미 승인에 키를 싣고 있습니다.
.header("Idempotency-Key", command.orderNo()) // 주문번호로 PG 멱등 보장그러니 재시도를 못 켜는 게 아니라 안 켠 것입니다. 이유는 멀티 PG 절에 적은 것과 같습니다. 결제 코어가 특정 카드사의 보장에 기대면 카드사를 갈아 끼울 때 그 전제가 따라오지 않습니다. 켜려면 재시도를 전역 정책이 아니라 PG 경로별 능력으로 선언해야 합니다.
그리고 켜도 배치는 남습니다. 재시도가 또 타임아웃 나면 그것도 모르는 상태입니다. 재시도는 미확정을 줄이지 없애지 못합니다.
보상에도 끝이 있어야 합니다. 위 글이 짚는 대로 망취소 자체도 외부 호출이라 실패할 수 있고, 그 보상의 보상을 무한히 만들 수는 없습니다. 그래서 재시도에 상한을 뒀습니다.
long backoff = Math.min(MAX_BACKOFF_SECONDS, (long) Math.pow(2, task.getRetryCount() + 1));// 재시도를 소진하면 FAILED 로 두고 compensation.exhausted 카운터를 올린다지수 백오프로 최대 5분 간격까지 벌리고, 소진하면 FAILED로 두고 compensation.exhausted를 올립니다. 자동으로 될 만큼 해보고 안 되면 사람을 부른다는 경계가 여기입니다.
6. 미확정을 누가 다시 여는가
위 글은 꼬리를 무는 후처리를 이렇게 끊습니다. 첫 요청의 예외까지는 고객 응답이 나가기 전에 후처리를 해보고, 그것마저 실패하면 미확정으로 저장한 뒤 사용자에게 재시도 안내를 내보냅니다. 그리고 고객이 다시 시도하면 그 건이 미확정인지 확인해 후처리를 다시 진행합니다.
우리는 여기서 갈립니다.
case TIMEOUT -> { // 미확정: 실패로 단정하지 않는다. 복구 배치가 확정한다. payment.markUnknown(outcome.failReason()); yield new ConfirmResult(payment.getId(), payment.getStatus(), null, "결제 결과를 확인하고 있습니다. 잠시 후 다시 확인해 주세요.");}응답 전에 후처리를 시도하지 않습니다. 바로 미확정으로 보존하고 응답합니다. 응답 코드로 세 결과를 구분합니다.
| 결과 | 상태 코드 | 앱이 할 일 |
|---|---|---|
| 승인 | 200 | 완료로 표시합니다 |
| 거절 | 400 | 실패로 표시합니다 |
| 미확정 | 202 | 완료로 표시하지 않고 조회로 다시 물어봅니다 |
202는 “받았지만 아직 안 끝났다”입니다. 그리고 이 응답은 멱등 레코드에 저장되므로, 고객이 같은 키로 다시 눌러도 저장된 202를 그대로 돌려받습니다. 후처리가 다시 돌지 않습니다.
그럼 누가 여느냐면 배치입니다. PaymentRecoveryScheduler가 60초마다 미확정 결제를 PG에 다시 물어 확정하고, 체크아웃이 중간에 멈춘 건은 resolveStuckPayment가 같은 방식으로 엽니다.
고객이 다시 눌러야 닫히는 구조를 안 만들었습니다. 고객이 앱을 끄고 돌아오지 않아도 배치가 60초 안에 닫아야 한다고 봤습니다.
그것도 못 닫으면 미확정 나이가 계속 올라가고, 10분을 넘으면 알림이 울려 사람이 화면에서 닫습니다. 마지막 그물은 대사입니다.
7. 그런데 고객이 다른 카드로 다시 누르면
여기까지 쓰고 나서 빠진 자리를 하나 알았습니다. 고객은 결제 완료 알림을 못 받았으니 카드를 바꿔 다시 누릅니다. 그때 무슨 일이 일어나는지 안 봤습니다.
봤더니 이렇게 됩니다. 미확정일 때 주문은 PAYMENT_IN_PROGRESS에 머무는데, 전이표에 그 상태에서 자기 자신으로 가는 길이 없습니다. 그래서 불법 전이로 막힙니다.
이중결제는 안 납니다. 그런데 막는 자리가 멱등키가 아닙니다. 카드 A와 카드 B는 다른 요청이라 새 멱등키를 받습니다. 막는 건 주문 상태머신입니다. 방어가 두 층인 셈이고, 하나만 있으면 뚫립니다.
문제는 대가였습니다. 고객은 배치가 돌 때까지 아무 수단으로도 결제를 못 합니다. 이미 화면 앞에 있는데 60초를 기다립니다. 그리고 왜 안 되는지도 안 알려줍니다. 「허용되지 않은 상태 전이입니다」라는 도메인 내부 문장이 그대로 나갔습니다.
찾아보니 Stripe의 PaymentIntent가 주문 하나에 의도 하나, 그 아래 시도 여럿입니다. 카드를 바꿔 재시도하는 것은 같은 의도를 다시 confirm 하는 것이고, 문서는 새 의도를 만들지 말고 재사용하라고 씁니다. 멱등 범위도 요청이 아니라 주문 번호입니다.
그래서 고객의 재시도를 미확정 해소 트리거로 썼습니다. 조회 한 번이면 갈립니다.
| 조회 결과 | 응답 | 뜻 |
|---|---|---|
| 앞 결제가 승인됨 | 409 ORDER_ALREADY_PAID | 이미 결제 완료됐습니다 |
| 아직 모름 | 409 PAYMENT_RESULT_PENDING | 확인 중입니다. 잠시 후 다시 |
| 승인 아님 | 그대로 진행 | 카드 B로 결제됩니다 |
막지도 않고 이중결제도 안 납니다. 60초가 사라졌습니다.
상태머신은 안 풀었습니다. 자기 전이를 허용하면 미확정을 확인하지 않은 채 두 번째 승인이 나갈 수 있습니다. 막는 층은 그대로 두고 막히기 전에 해소하는 쪽으로 갔습니다.
해소가 실패하면 삼키고 진행합니다. 그러면 주문이 막힌 채 배치로 넘어가서, 고치기 전 동작으로 떨어질 뿐 이중결제로 가지 않습니다. 그 경로를 테스트로 고정했습니다.
남는 창이 하나 있습니다. 조회가 「없다」고 답해 앞 결제를 버렸는데 그 승인이 PG에서 아직 진행 중이었으면 둘 다 승인될 수 있습니다. 조회로는 못 막고 대사가 잡습니다. 버린 결제는 완료 이벤트가 없어 내부 기록이 없는데 PG 파일엔 있으니, 대사에서 「PG엔 있고 우리 장부엔 없음」으로 걸립니다.
돈은 나갔는데 주문이 사라지면: 승인 후 재고 부족과 자동 망취소
앞 절이 “결과를 모르는 실패”를 다뤘다면 이번엔 “결과가 확정된 뒤의 실패”입니다. 카드 승인은 성공했는데 재고 차감이 실패하면 DB 롤백으로는 되돌릴 수 없습니다. PG에서 이미 일어난 승인은 롤백되지 않기 때문입니다. 이 틈을 자동 망취소, 즉 성공할 때까지 재시도하는 보상 트랜잭션으로 메웠습니다.
- 대안 비교 : 카드 승인이 성공한 뒤 재고 차감이 실패했을 때 무엇으로 되돌리느냐입니다.
- ①
@Transactional롤백 → 기각. 코드는 가장 단순합니다. 그런데 롤백되는 것은 DB뿐이고 PG의 승인은 그대로 남습니다. 고객 카드에서는 돈이 빠졌는데 우리 DB엔 주문이 없습니다 - ② 지금 한 번 망취소 → 기각. 이미 난 승인을 취소해 되돌립니다. 그런데 망취소도 PG 호출이라 네트워크가 끊기면 같이 실패하고, 그러면 되돌릴 방법 자체가 사라집니다
- ③ 망취소를 적재하고 재시도 → 채택. 되돌리라는 지시를 DB에 먼저 적고 배치가 성공할 때까지 재시도합니다. 대신 보상 테이블과 배치를 관리하는 일이 늘어납니다
- ①
- 핵심 : 우리 DB와 PG는 한 트랜잭션으로 묶을 수 없습니다. 둘 중 하나가 이미 커밋됐는데 다른 하나가 실패하는 순간은 롤백이 아니라 보상으로 풀어야 합니다
→ ③을 골랐습니다. 지시가 DB에 있어 서버가 중간에 죽어도 사라지지 않습니다.
0. 남겨둔 한 줄
승인 흐름을 만들 때 CheckoutService엔 이런 주석이 있었습니다.
// 차감 실패(품절 경합)는 예외 → 이후 망취소/보상 트랜잭션으로 승격.미뤄둔 데는 이유가 있습니다. 이게 결제 시스템에서 가장 위험한 순간 중 하나이기 때문입니다.
1. 위험한 순간: 승인 성공 → 재고 부족
주문 승인의 순서를 다시 보자.
- 포인트 선점
- 카드 승인 (외부 PG 호출)
- 재고 차감
- 주문 PAID
문제는 2와 3 사입니다. 카드는 이미 승인됐는데(2), 재고 차감(3)이 품절 경합으로 실패하면 어떻게 될까?
원래 코드는 재고 차감 실패 시 예외를 던졌고, @Transactional이 전부 롤백했습니다. 그런데 여기에 무서운 구멍이 있습니다.
@Transactional이 롤백하는 건 DB뿐입니다. 하지만 2번의 카드 승인은 PG(외부 시스템)에서 이미 일어났습니다. DB를 롤백해도 PG의 승인은 되돌아오지 않습니다. 결과는 고객 카드에서는 돈이 빠져나갔는데 우리 DB엔 주문이 없는 상태. 고객 입장에선 “결제했는데 아무것도 못 받은” 최악의 경험입니다.
이게 분산 트랜잭션의 본질적 문제입니다. 우리 DB와 PG는 한 트랜잭션으로 묶을 수 없습니다. 그래서 “둘 중 하나가 이미 커밋됐는데 다른 하나가 실패”하는 순간은 보상(compensation)으로 풀어야 합니다. 롤백으로는 풀 수 없습니다.
2. 해법: 롤백 대신 자동 망취소
방향을 바꿨습니다. 재고 차감이 실패하면 롤백하지 않습니다. 대신 이미 승인된 카드를 취소(망취소)하는 걸로 되돌립니다.
그런데 망취소도 PG 호출이라 또 실패할 수 있습니다. 네트워크가 끊길 수도, PG가 잠깐 죽었을 수도 있습니다. 그래서 망취소는 지금 한 번 시도하고 끝내는 대신, durable하게 적재해두고 성공할 때까지 재시도하는 구조로 만들었습니다. 이게 compensation_tasks 테이블입니다. outbox 패턴의 사촌입니다.
흐름은 이렇습니다.
카드 승인 성공 → 재고 차감 시도 → 실패(품절) ├─ 이미 차감된 재고가 있으면 원복 ├─ 선점한 포인트 복원 (내부 자원 — 즉시·확실) ├─ compensation_tasks에 "이 카드 망취소해" 태스크 적재 ├─ 주문 FAILED └─ 트랜잭션 커밋 (승인된 결제 + 보상 태스크가 함께 남는다)
[스케줄러] PENDING 태스크를 주기적으로 집어 → PG 망취소 호출 ├─ 성공 → DONE └─ 실패 → 재시도 카운트++ , 지수 백오프로 다음 시도 예약 └─ 재시도 소진 → FAILED (운영 개입 알림)핵심은 “내부적이고 확실한 것”과 “외부적이고 불확실한 것”을 나눈 것입니다. 포인트 복원은 우리 DB 안이라 즉시 확실하게 처리하고, PG 망취소만 durable 재시도 큐로 뺐습니다. 불확실한 것만 재시도 인프라에 태우는 구조입니다.
3. 진짜 함정: 잡은 예외가 트랜잭션을 오염시킨다
여기서 예상 못 한 벽에 부딪혔습니다. 처음엔 이렇게 짰습니다.
try { stockDeductionService.deductConditional(productId, qty); // 실패 시 OrderException 던짐} catch (OrderException e) { // 보상 처리... compensationService.enqueueNetworkCancel(...); order.markFailed(); // 예외를 안 던지고 정상 리턴 → 커밋되겠지?}“예외를 잡아서 삼켰으니 트랜잭션은 커밋되겠지” 했는데 안 됐습니다. 최종 커밋에서 UnexpectedRollbackException이 터지고, 보상 태스크 적재까지 다 롤백됐습니다.
원인은 Spring 트랜잭션의 미묘한 규칙입니다.
deductConditional은@Transactional메서드고, 바깥 트랜잭션에 참여(join)합니다. 이게 예외를 던지는 순간, Spring은 공유 트랜잭션을 rollback-only로 표시합니다. 바깥에서 그 예외를 잡아도 트랜잭션은 이미 “이건 무조건 롤백”으로 낙인이 찍힌 상태입니다. 그래서 커밋 시도가UnexpectedRollbackException으로 실패합니다.
“잡았다”고 없던 일이 되는 게 아닙니다. 참여 트랜잭션 안에서 던져진 예외는 잡아도 전체를 오염시킵니다.
해법은 애초에 예외를 던지지 않는 것이었습니다. 조건부 차감을 boolean으로 바꾼 tryDeduct를 새로 만들었습니다.
/** 예외 없는 조건부 차감 — 성공 true, 재고부족 false. */@Transactionalpublic boolean tryDeduct(long productId, int qty) { return stockRepository.deductConditionally(productId, qty) > 0;}이제 체크아웃 경로는 예외 대신 boolean 분기로만 흐릅니다. 트랜잭션을 오염시키는 예외가 없으니 보상 상태(승인된 결제 + 보상 태스크)가 온전히 함께 커밋됩니다. 기존 deductConditional(예외 버전)은 다른 호출부를 위해 그대로 뒀습니다.
“예외를 잡으면 안전하다”는 직관이 트랜잭션 경계 안에서는 틀립니다. 문서로 아는 것과 커밋이 터지는 걸 눈으로 보는 건 달랐습니다.
4. 무한 재시도를 막는 멱등
재시도 구조엔 함정이 하나 더 있습니다. 망취소를 재시도하다가, 이미 다른 경로로 취소된 결제를 또 취소하려 하면? 이 프로젝트의 취소는 “성공한 결제가 없으면 PAYMENT_NOT_FOUND”를 던집니다. 이걸 실패로 처리하면 영원히 재시도하게 됩니다. 이미 취소된 걸 계속 취소하려고.
그래서 이렇게 처리했습니다.
} catch (PaymentException e) { if ("PAYMENT_NOT_FOUND".equals(e.code())) { task.markDone(); // 취소할 게 없다 = 이미 보상됨 → 완료로 간주(멱등) } else { throw e; // 그 외 예외만 재시도 대상 }}“취소할 결제가 없다”는 건 이미 목적이 달성된 상태입니다. 실패로 볼 이유가 없습니다. 보상 작업에서 멱등성은 이렇게 “재시도해도 같은 결과”를 보장하는 안전장치입니다.
5. 소진하면 멈추고 알린다
지수 백오프로 재시도하되, maxRetries(5회)를 넘으면 태스크를 FAILED로 두고 더는 자동 재시도하지 않습니다. 대신 compensation.exhausted 카운터를 올립니다.
if (task.isExhausted()) { meterRegistry.counter("compensation.exhausted").increment(); // 알림 룰의 소스}무한 재시도는 그 자체가 장애를 키웁니다(죽은 PG를 계속 때리기). 그래서 “자동으로 될 만큼 해보고, 안 되면 사람을 부른다”로 경계를 그었습니다. 이 카운터가 0보다 크면 운영이 개입해야 한다는 신호입니다. 운영 자동화 편에서 만든 메트릭 기반 알림의 연장선입니다.
스케줄러 자체는 app.compensation.enabled 프로퍼티로 켜고 끕니다. 기본은 꺼둬서 테스트·로컬 부팅에 부작용이 없고, 운영에서만 환경변수로 켭니다. 복구 배치와 같은 방식입니다. 배치 로직은 순수 메서드로 두고 테스트는 직접 호출합니다.
전체 코드는 Spring Modulith 기반 결제 시스템에 있고, 보상 흐름은 14개의 단위 테스트로 검증했습니다.
멀티 PG 라우팅: 규칙을 정하고, 한참 뒤에 배선했다
국내 상위 PG사도 한 시간씩 장애가 납니다. PG를 여럿 두고 하나가 죽으면 넘기되, 요청이 그 PG에 닿지도 못한 게 확실할 때만 넘깁니다. 아무 때나 넘기면 이중결제가 됩니다.
이 절은 뒤로 가면서 성격이 한 번 바뀝니다. 앞은 “언제 넘기면 안 되는가”라는 실패 설계이고, 뒤는 그렇게 만들어둔 라우터가 실제로는 배선이 안 돼 테스트에서만 살아 있었다는 것을 감사로 발견해 고친 이야기입니다.
0. PG 하나에 매출을 걸지 않는다
확장의 첫 대상은 PG 자체의 이중화입니다. 국내 상위 PG사도 실제로 한 시간씩 장애가 납니다. 단일 PG만 연동했으면 그 한 시간 동안 매출이 통째로 멈춥니다. 그래서 여러 PG를 두고, 하나가 죽으면 다른 PG로 넘기는(failover) 게 필요합니다.
결제 코어 편에서 PG를 PgClient로 추상화하고 서킷브레이커를 붙여둔 덕에, 이건 그 위에 라우터 하나를 얹는 일입니다.
RoutingPgClient(List.of( PgRoute.of("TOSS", tossAdapter, 10), // 가중치 높은 PG 먼저 PgRoute.of("NICE", niceAdapter, 5)));가중치로 우선순위를 줍니다. 수수료 낮은 PG를 높게 두면 비용 최적화도 됩니다. 각 PG는 자체 서킷브레이커로 보호돼서, 계속 실패하는 PG는 아예 건너뜁니다.
1. 진짜 어려운 건 “언제 failover하면 안 되는가”
여기가 이 기능의 핵심입니다. “장애면 다음 PG로”는 쉽습니다. 문제는 아무 때나 넘기면 사고가 난다는 것. 결과를 네 가지로 나눠서 각각 다르게 처리합니다. 승인 성공, 카드 거절, 응답 없음, 그리고 요청이 PG에 닿지도 못한 경우입니다.
try { PgApproveResult result = route.circuitBreaker() .executeSupplier(() -> route.adapter().approve(command)); return result; // ← SUCCESS/FAILED/TIMEOUT 모두 여기서 반환 (failover 안 함)} catch (RuntimeException e) { // 요청이 PG에 <닿지도 못했다>(연결 실패·서킷 오픈) → 그 PG에선 아무 일도 없었으므로 안전 // 그 밖의 RuntimeException 은 넘기지 않는다 — 응답이 온 실패라 원 PG에서 승인이 났을 수 있습니다}| PG 응답 | failover | 왜 |
|---|---|---|
| SUCCESS | 넘기지 않음 | 됐으니까 |
| FAILED (카드 거절) | 넘기지 않음 | 다른 PG도 거절한다. 잔액 부족은 어느 PG로 가도 부족하다 |
| TIMEOUT (미확정) | 절대 넘기지 않음 | 이중결제 위험. 원 PG에서 이미 처리됐을 수 있는데 다른 PG로 또 쏘면 두 번 결제된다 |
| 요청 미도달 (연결 실패·서킷 오픈) | 다음 PG로 넘김 | PG가 요청을 아예 못 받았다 |
| 그 밖의 예외 | 넘기지 않음 | 요청은 나갔고 응답이 불확실하다. 넘기면 이중결제 |
앞서 다룬 “타임아웃은 실패가 아니다”와 같은 논리입니다. 타임아웃은 “PG가 처리했는지 모르는” 상태라, 다른 PG로 재시도하는 순간 이중결제 리스크가 생깁니다. 그래서 failover는 “PG가 요청을 못 받은 게 확실할 때”(연결 실패·서킷 오픈)만 합니다. 모든 PG가 안 되면? UNKNOWN으로 돌려서 복구 배치에 맡깁니다.
2. 테스트로 못 박기
failover 규칙을 시나리오별로 테스트했습니다. 특히 “failover하면 안 되는” 케이스를 집중적으로.
@Test@DisplayName("타임아웃(미확정)은 failover하지 않는다 — 이중결제 방지")void noFailoverOnTimeout() { StubPg toss = new StubPg("TOSS").returns(PgApproveResult.timeout("응답 없음")); StubPg nice = new StubPg("NICE").returns(PgApproveResult.success("CARD")); RoutingPgClient router = new RoutingPgClient(List.of( PgRoute.of("TOSS", toss, 10), PgRoute.of("NICE", nice, 5)));
PgApproveResult r = router.approve(cmd);
assertThat(r.outcome()).isEqualTo(PgOutcome.TIMEOUT); assertThat(nice.approveCalls.get()).isZero(); // NICE로 안 넘어감}- 주 PG 성공 → 그것만 씀 (보조 PG 호출 0)
- 주 PG 장애(예외) → 보조 PG로 failover → 성공
- 주 PG 카드 거절 → 그대로 반환, failover 안 함
- 주 PG 타임아웃 → 그대로 반환, failover 안 함 (이중결제 방지)
- 모든 PG 장애 → UNKNOWN
- 주 PG 서킷 오픈 → 건너뛰고 보조 PG
3. 그런데 이 라우터가 테스트에서만 살아 있었다
여기까지가 설계입니다. 한참 뒤 전수 감사에서, 이 라우터가 어디에도 배선되지 않았다는 게 나왔습니다. 금고를 만들고 안 채우고 배치를 만들고 안 부르던 그 패턴이(4편) PG에도 있었습니다.
RoutingPgClient. 여러 PG를 가중치 순으로 시도하고 장애 시 다음 PG로 넘기는(failover) 라우터를 꽤 정성껏 만들어놨습니다. 서킷브레이커도 PG별로 붙였고 단위 테스트도 촘촘했습니다. 그런데.
grep해보니 이 라우터를 참조하는 건 자기 테스트뿐이었습니다. 어느@Configuration에서도 빈으로 등록되지 않았고, 실제 결제는 여전히 단일 PG(ResilientPgClient가 감싼 하나)로만 흘렀습니다. failover가 실제 결제 경로엔 없고 테스트 안에서만 돌고 있었습니다.
이번에 배선했습니다. 다만 “빈으로 등록만 하면 되겠지”로 끝나는 일이 아니었습니다.
4. 진짜 문제는 @Primary였다
결제는 PgClient 인터페이스로 PG를 부르고, 그 구현으로 ResilientPgClient가 @Primary로 주입됩니다(서킷브레이커·재시도를 입힌 데코레이터). 여기에 라우터를 넣으려니 문제가 걸렸습니다.
RoutingPgClient를 또@Primary로 두면@Primary가 둘이 되어 스프링이 어느 걸 주입할지 못 정합니다. 그렇다고ResilientPgClient의@Primary를 떼면 그게 주던 재시도·외곽 서킷을 잃습니다.
답은 이미 있던 seam에 있었습니다. ResilientPgClient는 자기가 감쌀 대상을 이렇게 주입받고 있었습니다.
public ResilientPgClient(@Qualifier("pgDelegate") PgClient delegate) { ... }pgDelegate라는 이름표(qualifier)가 붙은 PG를 감쌉니다. 원래는 FakePgClient(개발)나 TossPgClient(운영)가 프로파일로 그 자리에 들어갔습니다. 그렇다면 라우터를 바로 그 pgDelegate 자리에 끼우면 됩니다.
@Configuration@ConditionalOnProperty(name = "app.pg.routing.enabled", havingValue = "true")class PgRoutingConfig { @Bean @Qualifier("pgDelegate") PgClient routingPgDelegate() { return new RoutingPgClient(List.of( PgRoute.of("primary-fake", new FakePgClient(), 10), PgRoute.of("secondary-fake", new FakePgClient(), 5))); }}그러면 계층이 자연스럽게 합성됩니다.
PaymentService → ResilientPgClient(@Primary, 외곽 서킷·query 재시도) → RoutingPgClient(pgDelegate, PG별 서킷·failover) → [primary PG, secondary PG]@Primary는 ResilientPgClient 하나로 그대로 두고, 그 아래 pgDelegate만 단일 PG에서 라우터로 바뀝니다. 데코레이터 패턴의 힘이 여기서 나옵니다. 바깥 껍질은 안쪽이 하나든 라우터든 모릅니다.
5. qualifier가 둘이 되는 함정
한 가지가 더 걸렸습니다. FakePgClient는 항상 @Qualifier("pgDelegate")였습니다. 라우터도 pgDelegate로 등록하면 같은 이름표가 둘이 되어 다시 주입이 모호해집니다.
그래서
FakePgClient의pgDelegate역할을 라우팅이 꺼졌을 때만으로 조건화했습니다.@ConditionalOnProperty(name="app.pg.routing.enabled", havingValue="false", matchIfMissing=true). 라우팅을 켜면 이 빈은 아예 등록되지 않고 라우터가 유일한pgDelegate가 됩니다. 라우터 내부 경로는 자체new FakePgClient()로 만듭니다.
토글 하나로 FakePgClient의 등록과 PgRoutingConfig의 등록이 함께 뒤집힙니다. 언제나 하나만 pgDelegate가 되는 구조입니다.
*이 배선은 FakePgClient 둘로 라우팅 정책과 failover 금지 조건을 검증한 것입니다. 결제창에서 발급된 paymentKey는 PG마다 달라서 승인 단계에서는 넘길 수 없고, 그래서 뒤에서 고르는 시점을 결제창 앞으로 옮깁니다.
실기동으로 확인했습니다.
APP_PG_ROUTING_ENABLED=true ./gradlew bootRun→ PgRoutingConfig : 멀티 PG 라우팅 활성화 — 경로 2개 (가중치 순 시도, 장애 시 failover)→ 결제 승인 → order PAID / payment DONE (라우터의 primary 경로로 승인)6. 취소·조회를 원 PG로 되돌렸다
하나가 걸렸습니다. 취소·조회는 원래 결제를 처리한 그 PG로 가야 맞습니다(A PG로 승인했으면 A PG로 취소). Payment.pgProvider에 어느 PG였는지 기록은 돼 있는데, 정작 PgClient.cancel(paymentKey, ...) 인터페이스가 provider를 안 받습니다. 그래서 지금은 “가용한 첫 PG”로 시도합니다.
처음엔 “인터페이스를 건드리는 일이라 후속 과제”로 적었습니다. 그건 후속 과제로 둘 수 있는 게 아니었습니다.
Toss로 승인된 결제를 다른 PG에 조회하면 없다고 나옵니다. 그러면 복구 배치가 살아 있는 결제를 실패로 확정하고, 망취소는 하지도 않은 취소를 완료로 끝냅니다. 앞에서 만든 UNKNOWN 복구와 보상을 통째로 깨는 자리입니다. 기능 개선이 아니라 정합성의 전제였습니다.
고쳤습니다. 취소·조회는
Payment.pgProvider로 원 PG를 찾아 보냅니다. provider가 있는데 경로에 없으면 아무 데도 안 보내고 예외를 던집니다. provider를 모르는 옛 결제만 순회하는데, 그때도 조회는NOT_FOUND가 아니라IN_PROGRESS를 반환합니다. 모르는 것을 확정하지 않습니다.
전체 코드는 Spring Modulith 기반 결제 시스템에 있습니다. app.pg.routing.enabled=true로 라우터가 pgDelegate로 배선되어 결제가 라우팅 경로로 승인되는 것을 실기동으로 확인했습니다.
7. 그래서 켤 수 있게 됐나: 고르는 시점을 옮겼다
여기까지 만들고도 기본값은 꺼둔 채였습니다. 그 이유가 오래 애매했는데, 다시 들여다보니 제가 적어둔 이유가 틀려 있었습니다.
「인터페이스가 원 결제사 정보를 안 받는다」고 적었는데 그건 방금 고친 것입니다. 진짜 막고 있던 건 둘이었습니다.
하나, 서버에 PG를 고를 자리가 없었습니다.
CheckoutController @PostMapping("/confirm") record ConfirmRequest(String paymentKey, ...) // 이미 받은 뒤다paymentKey가 들어온 시점은 고를 시점이 이미 지난 뒤입니다. 결제창은 클라이언트가 띄우고 서버는 승인만 받습니다.
둘, 결제창 방식에서는 승인 단계 failover가 애초에 성립하지 않습니다.
승인에 쓰는 키는 고객이 그 PG의 결제창에서 인증을 마쳐야 발급됩니다. 업계 용어로 PSP 토큰입니다. 그 토큰을 만든 서비스만 쓸 수 있어서, 다른 PG에 보내면 모르는 거래라고 답합니다. 넘겨도 성공률이 0입니다.
현업은 고르는 시점을 앞으로 옮긴다
찾아보니 이게 표준이었습니다.
- 포트원 스마트 라우팅은 개별 채널을 지정하는
channelKey대신channelGroupId를 받습니다. 그러면 결제 요청 시점에 채널 비율로 PG를 고릅니다. 단건 결제도 빌링키 발급도 같습니다 - 결제 오케스트레이션 일반도 “결제 요청이 시작될 때” 가용 공급자를 평가하고, 그 평가는 “실제 거래가 제출되기 전에” 일어납니다. 판단 재료에 현재 acquirer 상태와 가동률이 들어갑니다
그리고 이 조사에서 제일 값어치 있던 문장이 이겁니다.
캐스케이딩(한 거래를 여러 PG에 차례로 재시도)은 기술적으로 가능하지만 소비자 체크아웃에서는 거의 쓰이지 않습니다. 책임과 네트워크 컴플라이언스 리스크가 승인률 이득보다 큽니다.
업계도 승인 단계에서는 안 넘깁니다. 「타임아웃은 넘기지 않는다」는 이 시스템의 판단이 현업과 같았습니다. 못 켠 게 아니라 그 자리에서는 안 켜는 게 맞았습니다.
그래서 만든 것
POST /api/v1/payments/init ← 결제창을 띄우기 전에 부른다 차단기가 열린 PG는 후보에서 뺀다 남은 것 중 가중치에 비례해 고른다 ↓ 프론트가 그 PG의 결제창을 띄운다 ↓POST /api/v1/payments/confirm ← Payment.pgProvider 로 그 PG에 승인진행 중인 거래를 넘기는 게 아닙니다. 아픈 PG로 고객을 애초에 안 보내는 장치입니다. 이미 시작된 결제는 끝까지 그 PG로 가고, 응답을 못 받은 요청은 여전히 다른 곳에 다시 보내지 않습니다.
판단이 하나 있었습니다. 전부 아파도 하나는 골라 줍니다.
if (healthy.isEmpty()) { // 전부 아프다. 그래도 하나는 준다 — 여기서 막으면 결제가 전면 중단된다. return Optional.of(routes.getFirst().name());}여기서 빈 값을 주면 PG 둘이 동시에 흔들릴 때 결제가 통째로 멈춥니다. 어차피 승인 단계에서 다시 판정하므로 고르기는 합니다. 이걸 테스트로 못 박았습니다.
모듈 경계가 자리를 정해줬다
처음엔 CheckoutController에 붙였는데 검증이 막았습니다.
Module 'order' depends on module 'payment' via CheckoutController -> payment.pg.PgSelectororder의 허용 의존에 payment는 있지만 그 안의 pg는 내부 패키지라 밖에서 볼 수 없습니다. PG를 고르는 건 payment의 관심사이므로 창구를 그쪽에 뒀습니다. 경계 검증이 설계를 대신 정해준 셈입니다.
8. 개선의 교훈
포트원 같은 결제 대행사도 멀티 PG를 세일즈 포인트로 삼는데(“장애 대응 1시간 → 10초”), 보통은 콘솔 수동 전환입니다. 여기서는 자동 failover를 만들었고, “언제 failover하면 안 되는지”를 분명히 했습니다.
“PG 장애 나면요?”에 “다른 PG로 넘겨요”는 절반의 답입니다. 나머지 절반이 “단, 타임아웃과 카드 거절엔 안 넘긴다. 이중결제와 무의미한 재시도니까”입니다. 이 구분이 빠지면 failover 자체가 이중결제 경로가 됩니다.
가상계좌: 완료가 최종 상태가 아니었다
가상계좌는 우리가 부른 요청이 아니라 상대가 언제 움직일지 모르는 결제입니다. 만료에는 웹훅이 아예 안 오고, 완료도 최종 상태가 아니었습니다.
0. 입금을 기다리는 결제
앞의 셋은 우리가 부른 요청의 실패였습니다. 가상계좌는 반대로 상대가 언제 움직일지 모르는 결제입니다. 계좌번호를 발급하고 입금을 기다립니다. 카드처럼 즉시 승인이 떨어지지 않고, 사용자가 나중에 그 계좌로 돈을 넣어야 완료됩니다. 상태 흐름은 발급 → 입금대기 → 입금완료.
기본은 웹훅으로 처리합니다. 입금되면 PG가 웹훅을 보내고, 우리는 “믿지 말고 조회로 재검증”해서 완료 처리합니다. 여기까진 쉽습니다. 토스페이먼츠 문서를 파고들면 함정 둘이 나옵니다.
1. 함정 ①: 만료엔 웹훅이 안 온다
입금기한(예: 7일)이 지나면 가상계좌가 만료됩니다. 여기에 함정이 있습니다.
EXPIRED 상태로 바뀔 때는 웹훅이 전송되지 않습니다. (토스페이먼츠 문서에 명시돼 있습니다.)
이걸 모르면 “웹훅으로 다 처리하니까 만료도 웹훅 오겠지” 하고 방치하게 됩니다. 그러면 만료된 가상계좌가 영원히 “입금대기”로 남아 재고나 쿠폰을 물고 있습니다. 그래서 자체 만료 배치가 필요합니다.
public int expireOverdue(Instant now) { // EXPIRED 웹훅이 안 오므로 직접 스캔합니다 List<VirtualAccount> overdue = repository.findByStatusAndDueDateBefore(WAITING_FOR_DEPOSIT, now); for (VirtualAccount va : overdue) { // 만료시키기 전에 PG에 조회 — 입금이 늦게 도착했을 수도 있으니까 if (pgClient.query(va.getPaymentKey()).isApproved()) { va.confirmDeposit(); // 늦은 입금 → 완료 (만료 안 함) } else { va.expire(); } } return overdue.size();}2. 만료-입금 레이스
위 코드에서 눈여겨볼 게 있습니다. 만료 배치가 도는 바로 그 순간에 입금이 도착할 수 있습니다. 만료시켜야 하나, 완료시켜야 하나?
답은 앞서 세운 원칙 그대로입니다. 믿지 말고 PG에 조회. 만료 대상이라도 조회해서 실제로 입금됐으면(APPROVED) 만료시키지 않고 완료 처리합니다. dueDate만 보고 기계적으로 만료시키면 방금 입금한 사용자의 돈이 붕 뜹니다.
“가상계좌 만료 어떻게 처리하세요?”에 대한 답이 이것입니다. “EXPIRED 웹훅이 없어서 배치로 감지하고, 만료 직전에 조회로 재확인해서 늦은 입금과의 레이스를 해소합니다.” 문서를 대충 읽으면 절대 안 나오는 디테일입니다.
3. 함정 ②: DONE에서 되돌아온다
두 번째 함정은 더 미묘합니다.
일부 은행(신한 등)은 입금 실패인데 DONE을 먼저 보낸 뒤, 최대 2분 후 되돌리는 통보를 합니다.
DONE → 입금대기로 상태가 역전이되는 것입니다.
보통 상태머신은 “완료(DONE)는 최종 상태”라고 가정합니다. 가상계좌는 아닙니다. 그래서 상태머신에 역전이를 허용 전이로 넣었습니다.
DONE → { WAITING_FOR_DEPOSIT, CANCELED } // 은행 지연 통보로 인한 역전이역전이가 오면 이미 보낸 “결제 완료” 후속 처리(알림, 포인트 적립 등)를 되돌려야 합니다. 상태 전이 이력과 보상 트랜잭션 발상이 여기서도 쓰입니다.
4. 정리
가상계좌는 “입금 기다리기”라는 단순해 보이는 기능에 실무 함정이 숨어 있습니다.
| 함정 | 대응 |
|---|---|
| EXPIRED엔 웹훅 없음 | 자체 만료 배치(dueDate 스캔) |
| 만료-입금 레이스 | 만료 직전 조회로 재확인 |
| DONE→입금대기 역전이 | 상태머신에 역전이 허용 + 후속 처리 보상 |
세 가지 모두 문서 구석에서 건진 것들입니다. “가상계좌 붙였어요”에서 멈추면 이 셋은 만나지 않습니다. 이 시스템의 원칙, “웹훅을 믿지 말고 조회로 확정한다”·“실패/역전이를 상태머신에 새긴다”가 그대로 재사용됐습니다.
웹훅이 승인 응답보다 먼저 오면
현업에 계신 분에게 질문을 하나 받았습니다.
동기 요청이 타임아웃으로 늘어지는데 웹훅(비동기)이 먼저 떨어지면? 이거 해결해보는 거는 좋은 부분이에요. 실무에서도 왕왕 있구요
코드를 열어봤습니다. 늦게 온 웹훅은 막고 있었습니다.
if (payment.getStatus() != PaymentStatus.UNKNOWN && payment.getStatus() != PaymentStatus.IN_PROGRESS) { return; // 이미 확정됨 — 웹훅이 늦게/중복 도착해도 안전(멱등)}그런데 먼저 온 웹훅은 예외로 떨어지고 있었습니다. 결제 행을 못 찾으면 PAYMENT_NOT_FOUND를 던집니다.
두 안전망이 서로를 무력화하고 있었다
각각은 맞는 설계인데, 겹쳐 놓으니 그 사이에 구멍이 생겼습니다.
하나. 웹훅 수신은 서명 검증과 저장만 하고 즉시 200을 돌려줍니다. PG가 10초 안에 2xx를 요구하는데, 조회 API 왕복을 수신 스레드에서 하면 그 시간을 넘길 수 있어서 그렇게 했습니다. 맞는 판단입니다. 그런데 그 바람에 PG 재전송이라는 두 번째 그물이 사라집니다. 이미 성공으로 알렸으니까요.
둘. 남은 재시도 경로는 아웃박스입니다. 코드 주석에도 그렇게 적혀 있었습니다.
여기서 예외를 던지면 Modulith가 발행을 미완료로 남겨 재시도한다(at-least-once)
그런데 설정을 보면 이렇습니다.
republish-outstanding-events-on-restart: true # 미완료 이벤트 재시도앱을 재기동해야 돕니다. 주석이 틀린 건 아닌데, 재기동 전제라는 것이 안 적혀 있었습니다.
실패가 아니라 순서 문제로 다뤘다
결제 행이 없다는 것은 승인이 실패했다는 뜻이 아닙니다. 아직 커밋되지 않았다는 뜻입니다. 그래서 상태를 하나 더 뒀습니다.
public void markPendingPayment(Instant nextRetryAt) { this.status = WebhookEventStatus.PENDING_PAYMENT; ... this.failReason = "결제 행 없음 — 웹훅이 승인 응답보다 먼저 도착";}이름을 PENDING_PAYMENT로 한 이유가 있습니다. FAILED로 두고 재시도 고리에 얹으면 “실패해서 다시” 가 되는데, 실제로는 “결제 행을 기다리는 중” 입니다. 로그를 보는 사람이 그 둘을 가릴 수 있어야 합니다.
PAYMENT_NOT_FOUND만 골라 보류로 바꾸고, 다른 실패는 그대로 던집니다. 조회 API가 죽은 것과 순서가 뒤집힌 것은 대응이 달라야 하니까요.
전용 스케줄러가 5초마다 다시 보고, 12회를 넘기면 승인이 끝내 실패한 것으로 보고 FAILED로 넘겨 사람이 봅니다.
규약을 어겨 테스트가 잡았다
스케줄러를 게이트 없이 만들었더니 테스트가 바로 잡았습니다.
SchedulerGatePairingTest > 스케줄러는 모두 프로퍼티 게이트를 가진다 FAILED이 프로젝트에는 게이트 없이 항상 도는 배치가 없어야 한다는 규칙이 테스트로 박혀 있습니다. 게이트가 없으면 테스트나 로컬 부팅에서도 돌아 부작용을 냅니다. 제가 그 규약을 잊고 만들었고, 예전의 제가 짜둔 테스트가 지금의 저를 막았습니다.
다른 배치와 같게 프로퍼티 게이트와 짝이 되는 스케줄링 설정을 붙였습니다. 기본은 꺼둡니다.
실 MySQL로 재현했더니 버그가 셋 나왔다
처음에는 PaymentRecoveryService를 목으로 두고 PAYMENT_NOT_FOUND를 던지게 만들어 경로만 고정했습니다. 그런데 실제로 문제가 되는 건 “결제 행이 아직 커밋되지 않아 다른 트랜잭션에서 안 보이는” 상태인데, 그건 목으로 만들 수 없습니다.
실 MySQL 컨테이너에 붙여 결제 행이 정말 없는 상태로 웹훅을 넣었습니다. 셋이 나왔습니다.
1. 로그는 “보류”인데 DB는 그대로였다
INFO 웹훅이 결제보다 먼저 도착 — 보류 paymentKey=pk-race…ERROR Unexpected exception occurred invoking async method UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-onlyresolveByPaymentKey가 @Transactional입니다. 거기서 난 예외가 이미 그 트랜잭션을 rollback-only로 만듭니다. 제가 그 예외를 잡아 PENDING_PAYMENT를 저장해도 커밋 자체가 거부됩니다.
그런데 이 함정은 기존 코드 주석이 정확히 경고하고 있었습니다.
예외를 catch해 같은 트랜잭션에 FAILED를 쓰려 하면, PG 조회 예외가 이미 그 트랜잭션을 rollback-only로 오염시켜 그 write마저 커밋되지 않는다
읽고도 그대로 밟았습니다. 예외를 받지 않고 먼저 물어보는 쪽으로 고쳤습니다.
if (!paymentRecoveryService.exists(paymentKey)) { event.markPendingPayment(...); return;}2. 이번엔 저장이 flush되지 않았다
고치려고 exists()를 @Transactional(readOnly = true)로 뒀습니다. 그랬더니 상태가 여전히 RECEIVED였습니다.
readOnly로 바깥 트랜잭션에 합류하면 Hibernate FlushMode가 MANUAL이 됩니다. 그래서 이어지는 save가 flush되지 않고 사라집니다.
실기동 편에서 saveAndFlush가 필요했던 이유와 같은 함정입니다. 같은 교훈이 새 자리에서 또 나왔습니다.
3. 상태 컬럼이 ENUM이었다
JpaSystemException: Data truncated for column 'status' at row 1webhook_events.status가 VARCHAR가 아니라 ENUM('FAILED','PROCESSED','RECEIVED','SKIPPED')였습니다. 새 값이 잘려 들어갔습니다.
H2는 이걸 문자열로 받아 통과시킵니다. 마이그레이션에 ENUM 값 목록 갱신을 더했습니다.
셋 다 목으로는 안 나온다
목이 트랜잭션 경계도, FlushMode도, 컬럼 타입도 흉내내지 못합니다. “경로만 고정했다”가 정확히 그 한계였습니다.
고친 뒤 로그는 이렇게 나옵니다.
[task-1] 웹훅이 결제보다 먼저 도착 — 보류 ← 수신 스레드[scheduling-1] 웹훅이 결제보다 먼저 도착 — 보류 ← 재시도 스케줄러가 다시 집었다실 PG로 쏴 봤더니, 애초에 못 받고 있었습니다
발신자까지 토스로 바꿔 봤습니다. 그랬더니 웹훅이 한 건도 안 들어왔습니다.
카드 정보 없이 실 결제 건을 만들 수 있습니다. 가상계좌는 시크릿 키만으로 서버에서 발급되고(POST /v1/virtual-accounts), 입금은 개발자센터에서 시뮬레이션합니다. 발급하면 토스가 진짜 paymentKey를 돌려주는데, 우리 DB엔 그 결제 행이 없습니다. 순서 역전의 조건이 그대로 만들어집니다.
그런데 입금을 처리해도 우리 쪽엔 아무것도 안 남았습니다. 이유는 둘이었습니다.
- 수신 엔드포인트가
X-Signature: t=…,v1=…를 필수 헤더로 받습니다. 토스는 그 헤더를 보내지 않습니다. 컨트롤러에 닿기도 전에 400입니다 - 수신 서비스가 멱등 키로
eventId를 요구합니다. 토스는 그 필드를 주지 않습니다
서명 검증기 주석에는 이렇게 적혀 있었습니다.
토스페이먼츠에도 서명이 있지만
payout.changed·seller.changed두 이벤트에만 붙는다. 우리가 받는PAYMENT_STATUS_CHANGED·DEPOSIT_CALLBACK에는 없다. 그래서 이 검증기는 자체 Mock PG 기준으로 둔다.
전제는 정확히 적어 뒀습니다. 그런데 그 전제가 실 PG를 못 받게 만든다는 데까지는 안 적혀 있었습니다. Mock PG로만 테스트하면 끝까지 안 보이는 종류입니다.
경로를 나눴습니다
기존 경로에서 서명 헤더를 선택으로 바꾸는 방법도 있었습니다. 그러면 헤더를 빼고 보내는 것만으로 Mock PG 쪽 검증까지 우회됩니다. 서명을 요구하는 문과 요구하지 않는 문을 한 경로에 섞으면 안 됩니다.
그래서 /api/v1/webhooks/toss를 따로 뒀습니다. 어댑터가 토스 페이로드를 정규 봉투로 옮기되, 토스가 보낸 필드는 하나도 지우지 않고 eventId만 얹습니다.
멱등 키는 내용에서 만듭니다. 토스는 2xx를 못 받으면 같은 이벤트를 다시 보내는데, 수신 시각으로 키를 만들면 재전송마다 새 행이 생깁니다.
// 재전송이 같은 값으로 접히도록 내용에서 만든다return "toss:" + paymentKey + ":" + status;다시 쐈더니
개발자센터에서 입금처리를 누른 뒤 우리 로그입니다.
08:29:15 웹훅이 결제보다 먼저 도착 — 보류 paymentKey=tviva20260905075658KhxD3 retry=108:29:24 웹훅이 결제보다 먼저 도착 — 보류 … retry=208:29:29 웹훅이 결제보다 먼저 도착 — 보류 … retry=308:30:19 웹훅 보류 재시도 소진 webhookEventId=3 ← 상한 12회, 사람에게 넘어감토스 쪽 전송 기록은 성공 2건, 실패 0이었습니다. 페이로드에는 가상계좌 발급 때 받은 secret이 그대로 들어 있었습니다.
DEPOSIT_CALLBACK은 orderId만 담고 paymentKey가 없어 설계대로 SKIPPED로 끝났습니다.
남는 구멍
결제 웹훅에는 서명이 없어 이 문에는 위조 요청도 들어옵니다. 막는 것은 페이로드를 믿지 않는 설계입니다. 해석이 조회 API로 실상태를 다시 물으므로, 위조가 할 수 있는 최대치는 쓸모없는 조회를 부르는 것이고 장부는 바뀌지 않습니다. 발신 IP 제한은 아직 걸지 않았습니다.
남는 생각
셋 다 같은 질문으로 갈렸습니다. 무엇이 확정됐고 무엇이 미상인가.
- 타임아웃은 결과를 모르는 실패입니다. 그래서 재시도하면 이중결제고,
UNKNOWN으로 보존해 조회가 확정합니다 - 승인 후 재고 부족은 결과가 확정된 실패입니다. 다만 외부에서 일어나 롤백이 안 되니 보상으로 되돌립니다
- failover는 PG가 요청을 못 받은 게 확실할 때만 합니다. 타임아웃과 카드 거절에는 넘기지 않습니다
- 가상계좌 만료는 PG가 알려주지 않는 상태입니다. 배치로 감지하되 만료 직전에 조회로 다시 묻습니다
“재시도 가능/불가”라는 이분법으로는 이 셋을 못 가릅니다. 뒤에서 데드락을 만났을 때
바로 이 기준으로 “재시도해도 되는 실패”라고 판정할 수 있었습니다(동시성 편).
그리고 모든 실패 경로가 한 곳(UNKNOWN → 복구)으로 수렴하게 만든 게 이 설계의 요점입니다.
실패의 종류마다 다른 특수 처리를 흩뿌리지 않았습니다.
참고
- 결제 트랜잭션의 예외 상황과 3상태 모델: 카카오페이 기술블로그
- 멱등 요청 가이드(같은 키에 다른 본문일 때의 처리 포함): Stripe Idempotent requests, Adyen API idempotency
- 멱등키 보관 기간과 헤더 계약: 토스페이먼츠 개발자센터
댓글
댓글 수정/삭제는 GitHub Discussions에서 가능합니다.