기술

결제 웹훅에 400을 돌려주면, 대행사는 다시 보내지 않습니다

2026.09.2910분 읽기

학교 서류 접수 서비스에서 한 손님이 결제를 마쳤습니다. 결제대행사에서는 승인이 났는데, 우리 DB 에는 그 결제가 없었습니다. 화면에서도 결제가 안 된 것처럼 보였을 테니 손님은 7분 뒤에 다시 결제했습니다. 이중결제입니다.

시스템은 아무 에러도 내지 않았습니다. 서버 입장에서는 그 결제가 처음부터 없었던 것과 똑같이 보였기 때문입니다.


서버가 결제를 아는 길이 하나뿐이었습니다

구조는 이랬습니다. 손님이 결제대행사 창에서 승인을 받으면, 손님의 브라우저가 우리 서버로 돌아와 결제가 끝났다고 알려줍니다. 서버는 그 콜백을 받아 접수를 완료 처리합니다. 즉 서버가 결제 사실을 아는 경로는 정확히 하나, 손님 브라우저였습니다.

그날 밤 그 하나가 끊겼습니다. 승인은 대행사에서 정상으로 났고, 브라우저 콜백만 서버에 도달하지 않았습니다. 여기서 운영상 가장 나쁜 성질이 드러납니다. **결제가 안 된 것과 결제됐는데 우리가 모르는 것의 겉모습이 똑같습니다.** 이상함을 느낀 사람은 손님 하나뿐이었고, 손님이 할 수 있는 대응은 다시 결제하는 것뿐이었습니다.

더 나쁜 것은 그 다음이었습니다. 결제 API 에 로그가 없었습니다. 그 시각에 우리 서버가 무엇을 받았는지 되짚을 재료 자체가 없는 상태였습니다. 끊긴 것도 모르는데 끊긴 자리를 확인할 수도 없었습니다. 1차 결제는 다음 날 대행사 콘솔에서 전액 환불로 정리했습니다.


같은 길을 보강할 것인가, 길을 하나 더 낼 것인가

선택지는 둘이었습니다. 하나는 기존 콜백을 더 튼튼하게 만드는 것이고, 다른 하나는 서버가 대행사에게 직접 듣는 두 번째 길을 내는 것입니다.

선택지

비용

한계

콜백 보강 — 재시도·타임아웃·프론트 예외 처리

싸고 빠릅니다

콜백이 끊기는 원인이 우리 통제 밖입니다

대행사 웹훅 — 서버가 서버에게 듣는 길

복잡도가 늘어납니다

같은 결제를 두 번, 동시에 처리할 수 있습니다

콜백 보강을 접은 이유는 원인의 위치입니다. 손님이 창을 닫고, 터널로 들어가고, 배터리가 나갑니다. 같은 길을 두껍게 만드는 것으로는 그 길이 통째로 사라지는 경우를 막지 못합니다. 이중결제는 돈이 걸린 사고라 드물다는 말로 넘길 수 없었고, 길이 하나면 그 하나가 끊기는 날은 반드시 옵니다. 그래서 웹훅 수신 경로를 신설했습니다. 비용은 복잡도로 냈습니다.

결제 사실을 아는 경로가 브라우저 콜백 하나였다가 대행사 웹훅이 더해져 둘이 되는 구조 비교


웹훅이 준 값은 씨앗으로만 씁니다

웹훅 수신 엔드포인트는 비로그인입니다. 밖에서 누구든 두드릴 수 있는 자리라는 뜻입니다. 그래서 본문에 담겨 온 값은 그대로 쓰지 않기로 했습니다. 받은 거래 식별자만 신뢰의 씨앗으로 쓰고, 그 식별자로 대행사에 단건조회를 걸어 돌아온 값으로 판단합니다.

대조하는 것은 셋입니다.

상태가 결제완료인가

주문번호가 우리가 만든 그 접수 건인가

금액이 청구한 금액과 같은가

셋을 다 보는 이유는 하나만 보면 통과하는 것이 있기 때문입니다. 상태만 보면 다른 주문의 성공 건이 통과하고, 주문번호만 보면 금액이 다른 건이 통과합니다. 셋 중 하나라도 어긋나면 완료 처리하지 않습니다.


길이 둘이 되면 멱등과 잠금은 부록이 아니라 본체입니다

웹훅을 붙였다는 말은 한 줄이지만, 실제 작업의 절반 이상은 중복과 경합을 다루는 데 들어갔습니다. 웹훅은 여러 번 올 수 있고, 브라우저 콜백과 동시에 도착할 수도 있습니다. 같은 결제가 두 번 처리되면 애초에 막으려던 사고를 우리가 직접 만드는 셈입니다.

멱등 — 같은 거래 식별자로 여러 번 들어와도 완료 처리는 한 번만 일어납니다

행 단위 비관적 잠금 — 같은 접수 건을 동시에 만지면 조회 시점에 잠가 한 줄로 세웁니다

브라우저 콜백도 멱등화 — 웹훅이 먼저 끝내놨어도 콜백에는 성공을 돌려줍니다. 손님 화면에 에러를 띄우지 않기 위해서입니다

이번에는 멱등 키가 쉬웠습니다. 거래 식별자가 자연 키로 있었기 때문입니다. 그런 키가 없는 도메인, 예를 들어 같은 사람이 같은 금액을 연달아 내는 경우라면 중복인지 정상적인 두 번인지를 시스템이 판정할 수 없는 구간이 남습니다.

곁다리로 결제 API 에 식별자 로그를 붙였습니다. 접수 식별자와 거래 식별자를 INFO 로 남기고, 구매자 이메일은 로그에서 제외했습니다. 대행사 호출 타임아웃도 연결 10초·읽기 12초로 명시했습니다. 테스트는 26개를 붙였는데 전부 순수 단위 테스트입니다. 이 환경은 통합 테스트 컨텍스트가 운영 DB 에 붙고 스키마를 자동으로 반영하는 설정이라, 스프링 컨텍스트를 띄우는 테스트 자체를 금지해 두고 작업했습니다.

웹훅 본문을 씨앗으로만 쓰고 대행사 단건조회로 상태·주문번호·금액 셋을 대조한 뒤 완료 처리하는 흐름


가장 값진 수확은 코드가 아니라 리뷰가 잡은 한 줄이었습니다

제 계획에는 이렇게 적혀 있었습니다. 대행사 단건조회에 실패하면 웹훅에 400 을 돌려준다. 최종 리뷰에서 이 한 줄이 걸렸습니다.

400 이면 대행사가 재발송을 하지 않습니다. 클라이언트 잘못이라는 뜻이라, 상대 입장에서는 다시 보낼 이유가 없습니다.

우리가 실패를 어떻게 부르느냐가 상대의 행동을 정합니다. 4xx 는 네가 잘못했으니 다시 보내지 말라는 말이고, 5xx 는 내가 일시적으로 문제이니 다시 보내달라는 말입니다. 조회 실패는 우리 쪽 일시 장애인데 거기에 4xx 를 주면, **상대의 재시도라는 안전망을 우리 손으로 끕니다.** 그래서 500 으로 바꿨습니다.

이 지적이 무서운 이유는 따로 있습니다. 400 으로 나갔어도 화면상으로는 아무 일도 안 일어났을 겁니다. 에러 로그는 깨끗하고, 대행사 콘솔에는 전달 완료로 찍히고, 우리 DB 에만 그 결제가 없습니다. 처음 사고와 똑같은 모양으로 조용히 반복됩니다. 웹훅을 붙였다는 사실이 오히려 안심하게 만들었을 겁니다. 웹훅·큐·배치처럼 재시도를 상대가 쥔 구조에서는 상태 코드가 곧 설계입니다.

통지가 안 닿는데 아무도 모르는 구조는 이 블로그에서 한 번 더 다룬 적이 있습니다. 실패 알림에 기대고 있었는데 그 알림 자체가 오지 않던 사례입니다.

웹훅 실패에 400을 주면 재발송이 멈추고 500을 주면 대행사가 다시 보내는 차이 비교


고쳤다는 말과 맞다는 증거는 다릅니다

배포는 실결제가 흐르지 않는 시간대에 했습니다. 모집 마감 뒤라 결제 경로를 건드려도 손님을 태우지 않는 구간이었습니다. 타이밍은 코드 품질과 무관하게 사고 규모를 몇 배로 가릅니다.

배포 검증은 응답 내용이 아니라 엔드포인트의 상태 코드가 403 에서 200 으로 바뀌는지로 했습니다. 데이터가 없어도 갈리는 신호를 고른 것입니다. 이어서 대행사 콘솔에 수신 주소를 등록하고, 콘솔의 호출 테스트가 0.6초 뒤 서버 로그에 찍히는 것까지 확인했습니다.

그리고 전건 대사로 닫았습니다. 대행사 거래 내보내기와 우리 DB 결제 테이블을 17일치 전부 대조했고, 승인 건도 취소 건도 양쪽이 전량 일치했습니다. 차이는 그 사고 1건뿐이었습니다. 사실 대사를 돌리기 전까지는 사고가 그 1건뿐인지도 몰랐습니다. 이제 안 생긴다는 주장과 지금까지 쌓인 것이 맞다는 주장은 다른 말이고, 뒤쪽은 세어봐야만 할 수 있습니다.


남은 숙제도 같이 적어둡니다

웹훅도 끊깁니다. 두 번째 길 역시 길입니다. 진짜 답은 주기적으로 도는 대사인데, 이번에는 배포 직후 1회성으로만 돌렸습니다

500 이 만능은 아닙니다. 일시 장애와 영구 실패를 갈라서 앞은 500, 뒤는 200 으로 주는 것이 정석입니다. 이번에는 조회 실패를 전부 일시 장애로 본 셈이라 더 나눌 여지가 남았습니다

전건 대사는 규모가 커지면 그대로 못 돌립니다. 17일치라 통째로 대조할 수 있었고, 하루 수만 건이면 샘플링·집계 대사·차액 감시로 전략이 바뀝니다

모든 이벤트에 두 번째 길이 필요하지는 않습니다. 실패해도 사용자가 다시 누르면 그만인 것에는 과잉입니다. 갈림선은 하나입니다. 실패를 우리가 모른 채 지나가면 돈이나 신뢰가 새는가입니다.


같은 자리를 찾는 점검 항목

상대가 한 일을 우리가 아는 경로가 하나뿐인 자리가 어디인가

그 경로에 사용자 기기가 끼어 있는가. 브라우저는 신호이지 사실의 원천이 아니다

밖에서 들어온 본문의 값을 그대로 저장하는 곳이 있는가. 상태·식별자·금액을 되물어 확인하는가

같은 이벤트가 두 번 들어오면 어떻게 되는가. 동시에 들어오면 어떻게 되는가

실패할 때 상대에게 돌려주는 상태 코드가 상대의 재시도 정책과 맞는가

사고가 나면 되짚을 로그가 지금 남고 있는가

고친 뒤 맞다는 것을 무엇으로 세어 보일 것인가

경로가 둘인데 가드가 한쪽에만 있던 사례도 따로 정리해 둔 적이 있습니다. 길을 늘릴 때마다 검사도 같이 늘려야 하는 이유가 거기 있습니다.


문서에 남긴 것

이번 작업에서 문서로 남긴 것은 구조와 콘솔 설정, 배포 후 확인 절차, 사고 경위, 그리고 계획이 틀렸던 자리입니다. 마지막 항목이 제일 중요하다고 봅니다. 결론만 적으면 다음에 같은 추론이 또 들어오기 때문입니다. 400 을 주자는 판단은 그 자리에서 보면 합리적으로 보였고, 리뷰가 아니었으면 그대로 배포됐을 겁니다.

#결제웹훅#멱등성#행잠금#이중결제#대사#재시도정책