프로젝트
약 6분 분량 학습 프로젝트/결제 시스템

실제 PG를 붙이자 웹훅이 결제보다 먼저 도착했습니다

Payment웹훅토스페이먼츠멱등성결제 시스템
목차

개요

결제 승인 응답을 받아 DB에 저장하기도 전에 결제사 웹훅이 먼저 도착했습니다. 그때 결제 행이 없으니
조회가 실패하고, 실패를 예외로 던지던 코드가 그 예외로 트랜잭션을 오염시켜 자기가 남기려던
보류 상태까지 같이 날리고
있었습니다. 웹훅이 통째로 사라지는 경로였습니다.

자체 목 PG로 개발하는 동안에는 이 순서가 한 번도 뒤집히지 않았습니다. 목 PG는 승인 응답을
돌려주고 나서 웹훅을 보내니까 늘 순서가 지켜집니다. 토스페이먼츠 테스트 상점에 실제로 붙이고
나서야 드러났습니다.

고친 방향은 웹훅이 먼저 오는 것을 정상 순서 중 하나로 다루는 것입니다. 결제 행이 없으면 예외를
던지지 않고 PENDING_PAYMENT로 보류한 다음, 5초 간격으로 다시 봅니다. 상한 12회까지 짝이
맞지 않으면 그때 실패로 확정하고 사람이 보게 남깁니다.

이 글에는 실제로 붙여 보고 나서야 나온 것 셋이 있습니다. 결제사가 멱등 키를 안 준다는 것,
결제 웹훅에는 서명이 안 붙는다는 것, 그리고 예외 처리 방식 하나가 상태 저장을 통째로
무효로 만든다는 것입니다.


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를 못 받으면 같은 이벤트를
다시 보내는데, 그 재전송이 같은 키로 접혀야 하기 때문입니다.

정규화 어댑터는 토스가 보낸 필드를 하나도 지우지 않고 eventIdeventType만 얹습니다.
원본이 그대로 남아 있어야 나중에 무슨 일이 있었는지 되짚을 수 있습니다.


3. 결제 웹훅에는 서명이 없습니다

자체 목 PG는 HMAC 서명을 붙여 보냈고, 수신부는 서명을 검증하도록 만들어 뒀습니다. 실제 웹훅은
그 문으로 들어오지 못했습니다.

처음에는 “토스가 서명 스펙을 공개하지 않는다”고 코드 주석과 문서에 적었습니다. 그건 틀렸습니다.
토스에도 서명이 있고 tosspayments-webhook-signature 헤더로 옵니다. 다만 붙는 이벤트가
payout.changedseller.changed 둘뿐이고, 우리가 받는 결제 상태 웹훅에는 붙지 않습니다.
문서 네 군데를 고쳤습니다.

그래서 서명 없는 웹훅을 받는 두 번째 진입점을 뒀습니다. 메서드 이름을 handleUnsigned로 지은
것은 의도한 것입니다. 이 문으로 들어온 요청은 보낸 쪽이 누구인지 확인되지 않았습니다. 이름이
그 사실을 계속 말해 주도록 했습니다.

그러면 무엇이 위조를 막나

문이 아니라 페이로드를 믿지 않는 설계입니다. 웹훅 처리는 페이로드에 적힌 상태를 쓰지 않고
조회 API로 실제 상태를 다시 물어봅니다.

// 페이로드가 아니라 조회 API로 실상태 재검증
paymentRecoveryService.resolveByPaymentKey(paymentKey);

위조 요청이 할 수 있는 최대치는 webhook_events 행을 하나 만들고 쓸모없는 조회를 한 번
유발하는 것입니다. 장부는 조회 결과로만 움직입니다.

남는 위험은 그 쓸모없는 조회와 보류 행이 쌓이는 것 자체가 부하이자 알림 소음이라는 점입니다.
발신 IP 허용 목록으로 좁힐 수 있게 해뒀지만 기본값은 꺼둡니다. 앱이 프록시 뒤에 있으면 보이는
IP가 프록시라서 앱단 검사로는 부족합니다. 앞단이 있으면 거기서 막는 게 맞습니다.


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

무한히 기다리지 않는 이유는, 결제 행이 끝내 안 생기는 경우가 순서 문제가 아니라 다른 고장이기
때문입니다. 승인 요청 자체가 우리 쪽에 도달하지 못했거나, 다른 상점의 웹훅이 잘못 온 것이거나,
위조 요청일 수 있습니다. 어느 쪽이든 자동으로 해결되지 않으니 사람이 봐야 합니다.

실패로 넘길 때 사유를 함께 적습니다. “결제 행이 끝내 생기지 않음”과 재시도 횟수가 같이 남아서,
나중에 이 건을 열어 본 사람이 무엇을 얼마나 기다렸는지 알 수 있습니다.

재시도 고리를 왜 따로 뒀나

이 시스템은 비동기 처리 재시도를 아웃박스로 돌립니다. 그런데 이 문제는 아웃박스로 안 됩니다.
아웃박스 재시도는 처리 중 예외가 나야 걸리는데, 여기서는 예외를 만들지 않기로 했기 때문입니다.
정상 종료한 처리는 재시도 대상이 아닙니다.

그래서 보류 상태를 주기적으로 훑는 별도 스케줄러를 뒀습니다. 상태 이름을 PENDING_PAYMENT
지은 것도 이 고리가 무엇을 기다리는지 상태 자체가 말하게 하기 위해서입니다.


남는 것

  • 보류 행이 쌓이는 것 자체가 부하입니다. 지금은 상한과 만료로만 관리하고 있습니다.
  • IP 허용 목록은 만들어 뒀지만 기본값이 꺼져 있습니다. 앞단 프록시에서 막는 것이 맞다고 봐서
    앱단은 보조 수단으로만 뒀습니다.
  • 실제로 확인한 이벤트는 결제 상태 변경과 입금 콜백 둘입니다. 다른 이벤트 유형은 아직 받아
    보지 못했습니다.

목 PG로 개발하면 순서가 늘 지켜집니다. 지켜지는 순서만 보고 만든 코드는 그 순서를 전제로
굳습니다. 실제로 붙여 보기 전까지는 그 전제가 코드 안 어디에 박혀 있는지 알 수 없었습니다.


참고

프로필 사진
작성자 @범수

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

댓글

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