승인 응답보다 웹훅이 먼저 오면 결제 상태를 어떻게 보존할까
목차
결제 승인 응답을 저장하기 전에 결제사의 웹훅이 먼저 도착하면 어떻게 해야 할까요? 결제 행이 아직 없다는 이유로 웹훅을 실패 처리하면, 나중에 도착할 승인 결과와 연결할 기회 자체를 잃습니다.
테스트에서 이 순서를 뒤집어 보니 결제 행을 찾지 못한 코드가 예외를 던졌고, 그 예외가 자기가 남기려던 보류 상태까지 롤백했습니다. 웹훅을 받았지만 아무 흔적도 남지 않는 경로였습니다.
결론부터
- 먼저 온 웹훅을 비정상 입력으로 버리지 않고
PENDING_PAYMENT로 보류합니다. - 5초 간격으로 최대 12회 결제 행과 다시 연결하고, 끝까지 짝이 맞지 않으면 사람이 볼 실패로 남깁니다.
- 이 테스트가 검증한 것은 토스 형식 이벤트를 이용한 순서 역전 시나리오입니다. 토스 공개 주소에서 실제로 우리 서버로 전달되는 운영 계약까지 검증한 것은 아닙니다.
자체 목 PG로 개발하는 동안에는 목 PG가 승인 응답 뒤에 웹훅을 보내도록 되어 있어 이 순서를 놓쳤습니다. 현재 저장소에서 확인한 것은 토스 형식 이벤트를 테스트 코드가 보내 순서를 뒤집은 시나리오이며 토스 공개 주소에서 우리 서버로 직접 전달되는 실 웹훅 계약까지 검증한 것은 아닙니다. 이 차이를 분리해 적는 이유는 테스트 이벤트의 성공을 실 PG 운영 증거로 과장하지 않기 위해서입니다.
고친 방향은 웹훅이 먼저 오는 것을 정상 순서 중 하나로 다루는 것입니다. 결제 행이 없으면 예외를
던지지 않고 PENDING_PAYMENT로 보류한 다음, 5초 간격으로 다시 봅니다. 상한 12회까지 짝이
맞지 않으면 그때 실패로 확정하고 사람이 보게 남깁니다.
이 글에는 구현·문서 검증 과정에서 확인한 것 셋이 있습니다. 결제사가 일반 결제 이벤트에
우리 시스템이 기대한 멱등 키를 주지 않는다는 것, 일반 결제 웹훅에 서명이 없을 수 있다는 것,
그리고 예외 처리 방식 하나가 상태 저장을 통째로 무효로 만들 수 있다는 것입니다. 실 PG 공개 주소
전달과 재전송 정책은 아직 별도 계약 테스트로 남겨 뒀습니다.
1. 카드 없이 실제 결제사를 붙이는 방법
실 PG 연동은 사업자등록이 필요하다고 생각해서 미루고 있었습니다. 확인해 보니 카드 결제는
그렇지만 가상계좌 발급은 시크릿 키만 있으면 됩니다. 개인 개발자도 테스트 상점 키를 받아
바로 호출할 수 있습니다.
POST /v1/virtual-accounts가상계좌는 웹훅을 확인하기에 오히려 나은 수단입니다. 카드 승인은 요청과 응답이 한 호출 안에서
끝나지만 가상계좌는 계좌를 발급하고 나서 입금이 언제 들어올지 모릅니다. 결제사가 나중에 웹훅으로
알려주는 구조라 웹훅이 결제 완료를 알리는 유일한 통로입니다.
실제로 받은 이벤트는 둘입니다. 결제 상태가 바뀌었다는 PAYMENT_STATUS_CHANGED와 입금이
들어왔다는 입금 콜백입니다. 둘 다 200으로 응답했습니다.
2. 결제사가 멱등 키를 주지 않습니다
첫 웹훅은 파싱 단계에서 떨어졌습니다.
INVALID_WEBHOOK_PAYLOAD수신부가 모든 웹훅에 eventId를 요구하도록 만들어져 있었습니다. 같은 이벤트가 두 번 와도 한 번만
반영하려면 무엇으로 같은 이벤트인지 판단할 키가 있어야 하는데 그 키를 결제사가 보내 준다고
전제하고 있었습니다. 자체 목 PG는 그 키를 보내 줍니다.
토스는 그 필드를 보내지 않습니다. 그래서 키를 내용에서 만들었습니다.
return "toss:" + paymentKey + ":" + (status == null ? eventType : status);결제 상태 변경은 paymentKey와 상태를 이어 붙이고 입금 콜백은 transactionKey와 상태를 씁니다.
수신 시각처럼 재전송마다 달라지는 값은 쓰지 않습니다. 결제사는 2xx를 못 받으면 같은 이벤트를
다시 보내는데 그 재전송이 같은 키로 접혀야 하기 때문입니다.
정규화 어댑터는 토스가 보낸 필드를 하나도 지우지 않고 eventId와 eventType만 얹습니다.
원본이 그대로 남아 있어야 나중에 무슨 일이 있었는지 되짚을 수 있습니다.
3. 일반 결제 웹훅의 서명을 가정하지 않습니다
자체 목 PG·내부 테스트 진입점은 HMAC 서명을 붙여 보내고 수신부는 그 서명을 검증하도록 만들어
뒀습니다. 그러나 토스 일반 결제 웹훅은 서명 헤더가 없는 이벤트가 있으므로 그 검증을 실제 토스
일반 결제 이벤트의 보안 근거로 확대하지 않습니다.
처음에는 모든 토스 웹훅에 서명이 있을 것처럼 문서와 목 구현을 맞췄습니다. 그 가정이 틀릴 수
있다는 것을 확인했습니다. 토스도 tosspayments-webhook-signature 헤더로 서명을 주지만 붙는
이벤트는 payout.changed·seller.changed 둘뿐입니다. 우리가 받는 결제 상태 변경
(PAYMENT_STATUS_CHANGED)과 입금 콜백에는 서명이 없어서 paymentKey로 조회 API를 다시
호출해 검증해야 합니다. 그래서 문서와 진입점을 분리했습니다.
그래서 서명 없는 웹훅을 받는 두 번째 진입점을 뒀습니다. 메서드 이름을 handleUnsigned로 지은
것은 의도한 것입니다. 이 문으로 들어온 요청은 보낸 쪽이 누구인지 확인되지 않았습니다. 이름이
그 사실을 계속 말해 주도록 했습니다.
그러면 무엇이 위조를 막나
문은 보낸 쪽을 확인하지 않습니다. 대신 페이로드를 믿지 않는 설계로 막습니다. 웹훅 처리는
페이로드에 적힌 상태를 쓰지 않고 조회 API로 실제 상태를 다시 물어봅니다.
// 페이로드가 아니라 조회 API로 실상태 재검증paymentRecoveryService.resolveByPaymentKey(paymentKey);위조 요청이 할 수 있는 최대치는 webhook_events 행을 하나 만들고 쓸모없는 조회를 한 번
유발하는 것입니다. 장부는 조회 결과로만 움직입니다.
남는 위험은 그 쓸모없는 조회와 보류 행이 쌓이는 것 자체가 부하이자 알림 소음이라는 점입니다.
발신 IP 허용 목록으로 좁힐 수 있게 해뒀지만 기본값은 꺼둡니다. 앱이 프록시 뒤에 있으면 보이는
IP가 프록시라서 앱단 검사로는 부족합니다. 앞단이 있으면 거기서 막는 게 맞습니다.
그런데 왜 그 조회를 요청 스레드에서 바로 하지 않나
토스는 웹훅을 잘 받았으면 10초 안에 200을 돌려달라고 합니다. 그런데 조회 API 호출은 PG로
나가는 동기 네트워크 왕복이라 수신 스레드에서 이어서 하면 그 왕복 시간이 응답 시간에 얹힙니다.
PG 쪽 지연이나 순간적인 부하가 겹치면 10초를 넘길 위험이 있습니다.
그래서 수신(receive·handleUnsigned)과 해석(process)을 나눴습니다. 수신은 서명 검증과
멱등 저장, 이벤트 발행까지만 하고 곧바로 200을 돌려줍니다. 실제 조회는 그 발행을 받은 리스너가
커밋 이후 별도 스레드에서 합니다. 발행은 Spring Modulith 아웃박스에 수신 트랜잭션과 함께
기록되므로 해석 전에 앱이 죽어도 재기동 시 다시 발행됩니다. Stripe도 같은 이유로 비동기 큐
처리를 권합니다. 구독이 매달 한꺼번에 갱신되는 것처럼 웹훅이 몰리는 순간에 동기 처리는 서버를
그대로 밀어버릴 수 있다고 적어 뒀습니다.
4. 예외로 처리하면 보류 상태까지 같이 사라집니다
여기가 이번에 가장 오래 붙들고 있던 자리입니다.
웹훅이 먼저 도착하면 결제 행이 없습니다. 처음 코드는 조회 서비스를 그냥 호출하고 없으면 나오는
예외를 잡아서 보류 상태를 기록하려고 했습니다. 논리적으로는 맞아 보입니다.
실제로는 보류 상태가 DB에 남지 않았습니다. 예외가 발생한 시점에 이미 트랜잭션이 rollback-only로
표시되기 때문입니다. 그 뒤에 무엇을 쓰든 커밋되지 않습니다. 예외를 잡았는데도 그 예외가
트랜잭션을 죽여 놓은 상태라, 잡아서 처리한 보류 기록까지 같이 롤백됩니다.
웹훅은 사라지고 사라진 흔적도 남지 않습니다.
고친 방법은 예외를 만들지 않는 것입니다. 던지게 두고 잡는 대신, 먼저 물어보고 넘어갑니다.
// 결제 행이 아직 없다 = 웹훅이 승인 응답보다 먼저 왔다. 실패가 아니라 순서 문제다.if (!paymentRecoveryService.exists(paymentKey)) { event.markPendingPayment(Instant.now().plus(PENDING_BACKOFF)); // saveAndFlush — 이 상태가 DB에 확정되지 않으면 웹훅이 통째로 유실된다. repository.saveAndFlush(event); log.info("웹훅이 결제보다 먼저 도착 — 보류 paymentKey={} retry={}", paymentKey, event.getRetryCount()); return;}exists로 먼저 확인하면 예외가 발생하지 않고 트랜잭션도 깨끗한 상태로 남습니다. 실 MySQL을
띄운 통합 테스트로 두 방식의 차이를 고정해 뒀습니다. 예외를 잡는 쪽은 보류 행이 남지 않고
먼저 확인하는 쪽은 남습니다.
saveAndFlush를 쓴 이유도 같은 줄기입니다. 이 상태가 DB에 확정되지 않으면 재시도 스케줄러가
이 웹훅을 영영 못 찾습니다.
5. 보류를 언제까지 들고 있을 것인가
보류는 5초 뒤에 다시 봅니다. 승인 커밋은 보통 초 단위로 끝나므로 대부분 한두 번째 시도에서
짝이 맞습니다.
상한은 12회입니다. 소진되면 실패로 확정합니다.
웹훅이 결제보다 먼저 도착 — 보류 paymentKey=tviva20260905075658KhxD3 retry=6웹훅이 결제보다 먼저 도착 — 보류 paymentKey=tviva20260905075658KhxD3 retry=7...웹훅이 결제보다 먼저 도착 — 보류 paymentKey=tviva20260905075658KhxD3 retry=12웹훅 보류 재시도 소진 webhookEventId=3이 5초·12회를 훑는 스케줄러는 app.webhook.pending-retry.enabled 게이트 뒤에 있습니다.
기본값은 꺼져 있고 API를 띄우는 배포 프로파일에는 이 설정이 없습니다. 배치를 전담하는
worker 프로파일에서만 켜집니다. API만 올라간 배포에서는 웹훅이 결제보다 먼저 와도
PENDING_PAYMENT가 쌓이기만 하고 자동으로 넘어가지 않으므로 이 고리가 도는지는 배포
단위가 무엇을 같이 띄우는지에 달려 있습니다.
무한히 기다리지 않는 이유는 결제 행이 끝내 안 생기는 경우를 다른 고장의 신호로 보기 때문입니다.
승인 요청 자체가 우리 쪽에 도달하지 못했거나 다른 상점의 웹훅이 잘못 온 것이거나 위조 요청일
수 있습니다. 어느 쪽이든 자동으로 해결되지 않으니 사람이 봐야 합니다.
실패로 넘길 때 사유를 함께 적습니다. “결제 행이 끝내 생기지 않음”과 재시도 횟수가 같이 남아서
나중에 이 건을 열어 본 사람이 무엇을 얼마나 기다렸는지 알 수 있습니다.
재시도 고리를 왜 따로 뒀나
토스는 2xx를 못 받으면 최초 전송 실패 뒤 최대 7회까지 재전송합니다. 간격은 1분에서 시작해
7회차에는 4096분까지 늘어나고 전체 기간은 최초 전송으로부터 3일 19시간입니다. 그런데 우리
컨트롤러는 발신 IP가 허용 목록에 걸린 경우만 빼고 항상 200을 돌려줍니다. 결제 행이 아직
없어도 마찬가지입니다. 그래서 토스 입장에서는 이미 성공적으로 전달한 웹훅이라 이 재전송
고리가 걸리지 않습니다.
아웃박스 재시도도 이 문제에는 맞지 않습니다. process가 예외를 던지면 Modulith가 발행을
미완료로 남기지만 그 재발행은 앱을 재기동해야 돌고 그 사이 이 웹훅은 방치됩니다. 게다가 지금
코드는 결제 행이 없는 경우 예외를 던지지 않고 PENDING_PAYMENT를 정상 커밋으로 남기므로
처음부터 재시도 대상으로도 걸리지 않습니다.
토스의 재전송도 아웃박스도 이 순서 역전을 대신 돌봐주지 않으므로 보류 상태를 주기적으로 훑는
별도 스케줄러를 뒀습니다. 상태 이름을 PENDING_PAYMENT로 지은 것도 이 고리가 무엇을
기다리는지 상태 자체가 말하게 하기 위해서입니다.
남는 것
- 보류 행이 쌓이는 것 자체가 부하입니다. 지금은 상한과 만료로만 관리하고 있습니다.
retryCount
필드는 있지만 이를 게이지나 카운터로 내보내는 코드는 없습니다. 웹훅 운영 지표 전반의 공백은
결제 시스템의 정합성을 어떻게 운영 지표로 증명할 것인가에서
다시 정리했습니다. - IP 허용 목록은 만들어 뒀지만 기본값이 꺼져 있습니다. 앞단 프록시에서 막는 것이 맞다고 봐서
앱단은 보조 수단으로만 뒀습니다. - 실제로 받은 이벤트는 결제 상태 변경과 입금 콜백 둘입니다. 입금 콜백에는
paymentKey가 없어서
이 글에서 다룬 보류·재조회 경로를 타지 않고SKIPPED로 끝납니다. 입금 콜백이 가상계좌 상태
전이로 이어지는지는 별개 경로라
가상계좌에서 결제 완료를 최종 상태로 믿으면 안 되는 이유에서
다뤘습니다.
목 PG로 개발하면 순서가 늘 지켜집니다. 지켜지는 순서만 보고 만든 코드는 그 순서를 전제로
굳습니다. 실제로 붙여 보기 전까지는 그 전제가 코드 안 어디에 박혀 있는지 알 수 없었습니다.
참고
- 토스페이먼츠 웹훅 문서: Webhook 연동하기
- 가상계좌 발급: Virtual Account API
- 웹훅 수신 측 멱등성·빠른 응답 설계: Stripe: Best practices for using webhooks
댓글
댓글 수정/삭제는 GitHub Discussions에서 가능합니다.