외부 결제 시스템은 응답을 받지 못하면 같은 콜백을 다시 보낼 수 있다. 처음에는 콜백 API가 200을 반환하면 한 번만 올 거라고 막연히 생각했다. 실제로 네트워크 타임아웃, 공급자 재시도, 서버 재시작이 있으면 동일한 성공 알림이 여러 번 도착할 수 있었다.
중복 요청이 와도 거래 효과는 한 번만 발생하도록 만들어야 했다.
공급자 참조값과 이벤트를 식별했다
서명 검증을 통과한 뒤 내부 거래 ID, 공급자 거래 참조, 이벤트 종류를 기준으로 처리 기록을 찾았다.
idempotency key = provider + providerReference + eventType
요청 본문 전체의 hash는 보조 증거로 사용할 수 있지만 필드 순서나 재전송 시각이 달라질 수 있어 계약상 안정적인 식별자를 우선했다.
확인과 갱신을 하나의 원자 작업으로 묶었다
“처리 기록 조회 → 없으면 상태 변경 → 기록 저장”을 따로 실행하면 동시에 온 두 요청이 모두 없다고 판단할 수 있었다. 데이터베이스의 unique 제약과 트랜잭션, 조건부 갱신을 사용해 한 요청만 전이를 소유하게 했다.
UPDATE payment_example
SET status = 'completed'
WHERE id = :id AND status = 'processing';
변경 행이 0이면 이미 처리됐거나 허용되지 않은 상태이므로 현재 값을 읽어 안전하게 응답했다. 실제 테이블과 상태명은 예시로 바꿨다.
늦게 온 과거 상태를 되돌리지 않았다
성공 콜백 뒤 지연된 pending 이벤트가 도착할 수 있었다. 공급자 상태를 받은 순서대로 덮지 않고 허용된 내부 상태 전이표를 사용했다.
서명 실패는 처리 기록에 민감한 원문을 남기지 않고 거부했고, 알 수 없는 거래 참조는 정상 거래와 분리해 운영 확인 대상으로 보냈다.
응답을 보내기 직전에 서버가 종료되는 조건도 시험했다. 내부 거래는 완료됐지만 공급자는 응답을 받지 못해 다시 콜백을 보낼 수 있다. 두 번째 요청이 현재 상태를 읽고 같은 성공 응답을 안전하게 돌려주는지 확인했다.
멱등성은 응답 문구가 아니었다
중복 콜백에 “이미 처리됨”을 반환하는 것만으로는 부족했다. 잔액 반영, 알림 발송, 운영 기록 같은 부수 효과도 한 번이어야 했다. 트랜잭션 뒤 이벤트를 발행한다면 outbox 같은 전달 보장 구조도 검토했다.
이 작업 뒤 외부 시스템 요청은 한 번만 온다고 가정하지 않는다. 중복, 순서 변경, 부분 실패를 정상 조건으로 두고 상태와 부수 효과를 설계한다. 멱등성은 방어 코드 한 줄이 아니라 거래 경계 전체의 성질이었다.