자동화 slecs

결제 웹훅 중복 처리를 멱등성 락으로 차단

목차

결제대행사 웹훅이 중복으로 들어오는 건 어느 시점에 한 번씩은 터지는 문제다. 처음엔 "설마 실서비스에서"라고 생각하다가, 누적된 로그를 보고 나서야 구조적으로 막아야겠다 싶었다.

우리 쪽 서버가 5xx를 내거나 응답이 지연되면 결제대행사 측 시스템이 "ACK 못 받았다"고 판단하고 일정 간격으로 재시도를 때린다. 이건 결제대행사 입장에서 지극히 합리적인 동작이다. 문제는 첫 번째 요청이 이미 정상 처리됐는데, 두 번째 요청도 처리 흐름에 진입해서 잔액을 한 번 더 건드리거나 알림을 중복 발송하는 상황이 생긴다는 거다. 결제 쪽은 이런 사고 하나가 CS 비용이랑 신뢰 훼손을 동시에 데려온다.

원인을 정리하면 세 가지가 겹쳤음.

  • 응답 지연 혹은 5xx 이력 → 대행사가 재시도 트리거
  • 두 콜백이 거의 동시에 진입했을 때 둘 다 "미처리" 상태를 읽는 레이스 컨디션
  • 일부 요청에서 거래키가 빈 값으로 오는 케이스

첫 번째 항목은 인프라 쪽 안정화가 병행돼야 하지만, 두 번째·세 번째는 애플리케이션 레이어에서 확실히 막을 수 있었다.

멱등성 키 + 짧은 락으로 동시 진입 차단

단순히 "이미 처리된 건 무시"로 걸면 될 것 같지만, 동시에 두 요청이 들어올 때 둘 다 "미처리" 상태를 읽고 둘 다 처리 분기에 진입하는 걸 잡지 못한다. 상태 체크와 처리 진입 사이에 갭이 있고, 그 갭을 두 스레드가 함께 통과한다.

결국 멱등성 키 + 분산 락 패턴으로 다시 짰다.

단계 처리
1 거래키 + 이벤트 타입으로 멱등 키 생성
2 락 획득 실패 시 즉시 200 OK 응답
3 락 획득 성공 시 상태 검증 후 처리 진입
4 처리 완료 후 결과를 짧은 TTL로 캐시
key = txKey + ":" + eventType
# SETNX 로 락 획득, TTL 5분
# 실패 시 즉시 200 반환 (중복 요청으로 간주)
# 성공 시 DB 상태 재검증 후 진입

락 TTL은 처리 시간 상한을 여유 있게 잡아서 설정했다. 너무 짧으면 처리가 느린 구간에서 락이 먼저 풀리고 다음 재시도가 다시 진입할 수 있다. 반대로 너무 길면 장애 상황에서 복구가 늦어진다. 결제 처리 SLA를 기준으로 상한 × 1.5 정도로 잡는 게 무난하다.

200을 빨리 돌려주는 게 핵심이다. 외부 결제대행사 입장에서 "받았다"고 인지해야 재시도가 멈춘다. 중복 요청을 받았을 때 4xx나 5xx로 응답하면 오히려 재시도를 더 유발한다. "이미 처리했으니 됐다"는 의미의 200이 맞다.

처리 완료 결과를 짧은 TTL로 캐시해두는 이유도 비슷하다. 락이 풀린 뒤 세 번째·네 번째 요청이 들어와도 DB 조회 없이 캐시에서 "완료" 상태를 빠르게 읽고 200을 돌려줄 수 있다. 락 + 캐시 두 레이어를 같이 쓰면 타이밍 엣지케이스를 훨씬 안정적으로 커버한다.

거래키 누락 보정 — 클라이언트와 서버 양쪽에서

빈 거래키 케이스가 따로 골치였다. 파트너 응답 페이지에서 결과를 부모창으로 전달하는 JS 흐름이 있는데, 일부 케이스에서 거래키 필드가 누락된 채로 넘어왔다. 멱등 키를 만들 재료가 없으니 중복 판별 자체가 불가능해지는 상황.

클라이언트 쪽에서는 부모창 전달 스크립트가 거래키를 명시적으로 포함하도록 수정했다. 누락이 생기는 흐름을 코드 레벨에서 막는 게 1차 방어선이다.

서버 쪽에선 거래키가 없을 때 주문번호 + 승인번호 조합으로 역추적해서 보정하는 로직을 넣었다. 보정 후에도 식별이 안 되면 별도 큐로 격리해 수동 검수로 넘어가게 했다. 회원 조회 SQL에도 보정 컬럼 인덱스를 추가해서 풀스캔 위험을 미리 차단했다.

이때 보정 로직을 컨트롤러에 두지 않고 유틸 레이어로 뺀 게 나중에 효과를 봤다. 다음 스프린트에서 다른 채널 웹훅을 연동할 때 같은 보정이 필요했는데 그냥 가져다 썼다. 컨트롤러에 박아뒀으면 복붙하거나 다시 짜야 했을 거다.

이번 작업에서 굳어진 인식들

외부 콜백은 "한 번만 온다"는 가정 자체를 깔면 안 된다. 네트워크는 언제든 재시도를 만들어낸다. 내 서버가 안정적이어도 상대방 시스템이 ACK를 못 받았다고 오판할 수 있다.

ACK 응답이 빠르고 정확해야 재시도 폭주를 안 부른다. 처리 자체는 비동기로 빼더라도 "받았다"는 신호는 최대한 빨리 돌려줘야 한다. 처리 완료를 기다렸다가 응답하면 그 사이에 타임아웃이 걸리고, 대행사 측 재시도가 또 들어온다.

식별자 누락은 클라이언트와 서버 양쪽에서 막아야 안전하다. 어느 한쪽만 고쳐봤자 다른 쪽 흐름에서 같은 문제가 다시 나온다. 입력 검증과 보정 로직은 레이어를 나눠서 양쪽에 두는 게 맞다.

보정 로직은 컨트롤러 말고 별도 유틸로 빼둬야 재활용이 쉽다. 이건 원칙이라기보다 이번에 실제로 효과를 봐서 다음에도 그렇게 짤 것 같다.

댓글 0

첫 댓글 달아줘.