Webhook 연동 시 중복 요청 문제를 해결하는 멱등성 키 설계 방식
Webhook을 실제 프로덕션 환경에 연동해 본 경험이 있다면, 같은 이벤트가 두 번 이상 수신되는 상황을 반드시 마주치게 된다. 결제 완료 이벤트가 두 번 처리되어 재고가 두 번 차감되거나, 이메일 발송이 중복으로 트리거되는 경우가 대표적이다. 이 문제는 네트워크 불안정, 발신 서버의 재시도 정책, 혹은 수신 서버의 응답 지연 때문에 발생하며, Webhook 아키텍처 자체가 갖는 구조적 특성이기도 하다.
이를 해결하는 핵심 개념이 멱등성(Idempotency)이다. 동일한 요청을 여러 번 처리해도 결과가 한 번 처리한 것과 동일하게 유지되는 성질을 의미한다. 그리고 이 멱등성을 구현하는 실질적인 도구가 멱등성 키(Idempotency Key)다.
Webhook에서 중복 요청이 발생하는 이유
Webhook 발신 측(예: Stripe, GitHub, Slack 등)은 수신 서버가 200 OK를 반환하지 않으면 요청을 재시도한다. 수신 서버가 이벤트를 정상적으로 처리했지만 응답을 보내기 전에 타임아웃이 발생했다면, 발신 측은 실패로 판단하고 같은 이벤트를 다시 전송한다.
재시도 간격은 서비스마다 다르지만, 일반적으로 지수 백오프(exponential backoff) 방식을 사용한다. 처음에는 수 초 뒤에 재시도하고, 이후에는 수 분, 수십 분 간격으로 점점 늘어나는 방식이다. 이 과정에서 동일한 Webhook 이벤트가 수 회에서 수십 회까지 전달될 수 있다.
중복 처리 문제는 단순히 번거로운 것에서 그치지 않는다. 결제 처리, 재고 조정, 사용자 알림처럼 부작용(side effect)이 따르는 작업에서는 데이터 정합성 문제나 사용자 경험 저하로 직결된다.
멱등성 키의 기본 개념
멱등성 키는 각 Webhook 이벤트를 고유하게 식별하는 값이다. 발신 서버는 이벤트를 처음 생성할 때 고유 식별자를 함께 전송하고, 수신 서버는 이 키를 기준으로 이미 처리한 요청인지 여부를 판단한다.
대부분의 Webhook 발신 서비스는 이벤트 고유 ID를 페이로드나 헤더에 포함한다. Stripe는 id 필드를 이벤트 객체에 포함하고, GitHub는 X-GitHub-Delivery 헤더에 UUID 형식의 식별자를 전달한다. 이 값들이 사실상 멱등성 키 역할을 한다.
수신 서버는 이 키를 영속성 저장소에 기록하고, 이후 동일한 키가 들어오면 재처리 없이 이전 결과를 그대로 반환하거나 단순히 무시하는 방식으로 동작한다.
멱등성 키 설계 단계
1단계: 키 추출과 정규화
수신한 Webhook에서 멱등성 키를 어디서 가져올지 결정해야 한다. 가능하면 발신 서버가 제공하는 이벤트 ID를 그대로 사용하는 것이 안전하다. 이 값은 이미 발신 서버가 보장하는 고유 식별자이기 때문이다.
만약 발신 서버가 별도의 이벤트 ID를 제공하지 않는다면, 이벤트 타입과 핵심 페이로드 필드를 조합해 키를 생성할 수 있다. 예를 들어 event_type + order_id + timestamp를 해시한 값을 사용하는 방식이다. 단, 이 방법은 페이로드가 완전히 동일한 경우에만 같은 키가 생성된다는 전제가 필요하므로, 발신 측이 재시도 시 페이로드를 변경하지 않는다는 확인이 필요하다.
2단계: 저장소 선택과 키 저장 전략
멱등성 키는 빠른 읽기/쓰기가 가능한 저장소에 보관해야 한다. 일반적으로 사용하는 옵션은 다음과 같다.
- Redis: TTL(Time To Live) 설정이 쉽고 원자적 연산을 지원해 가장 많이 선택된다.
SET key value NX EX ttl명령으로 키가 없을 때만 저장하는 원자적 연산을 활용할 수 있다. - 관계형 데이터베이스:
UNIQUE제약 조건이 있는 별도 테이블을 만들어 관리한다. 트랜잭션과 함께 사용할 수 있어 데이터 정합성 측면에서 유리하다. - NoSQL(DynamoDB 등): 조건부 쓰기(conditional write) 기능을 활용해 원자적으로 키 존재 여부를 확인하고 저장할 수 있다.
저장소를 선택할 때는 단순히 성능만 고려할 것이 아니라, 저장소 장애 시 시스템이 어떻게 동작해야 하는지도 설계에 포함해야 한다.
3단계: 처리 상태 관리
멱등성 키만 저장하는 것으로는 부족하다. 키에 처리 상태를 함께 기록해야 한다. 처리 중(processing), 완료(completed), 실패(failed) 상태를 구분하는 이유는 다음 시나리오 때문이다.
첫 번째 요청이 처리 중인 상태에서 두 번째 동일 요청이 들어왔을 때, 키가 존재하지만 처리가 끝나지 않은 상황이다. 이 경우 두 번째 요청을 즉시 성공으로 응답하면 발신 서버는 처리가 완료된 것으로 오해할 수 있다. 상태를 구분해 두면 처리 중 상태에서는 202 Accepted 또는 409 Conflict를 반환하고 발신 서버가 나중에 다시 확인하도록 안내할 수 있다.
[키 존재 여부 확인]
↓ 없음: 처리 시작, 상태 = processing 저장
↓ 있음 + processing: 409 또는 202 반환
↓ 있음 + completed: 이전 결과 반환 (200)
↓ 있음 + failed: 정책에 따라 재처리 또는 오류 반환
4단계: 원자적 연산으로 경쟁 조건 방지
멱등성 키를 확인하고 저장하는 과정이 두 단계로 분리되어 있으면 경쟁 조건(race condition)이 발생할 수 있다. 거의 동시에 들어온 두 요청이 모두 ‘키 없음’을 확인하고 동시에 처리를 시작하는 경우다.
Redis의 SET NX(Not eXists) 명령은 키가 존재하지 않을 때만 저장하는 원자적 연산이다. 두 요청이 동시에 시도해도 하나만 성공하고 나머지는 키 이미 존재로 처리되므로 경쟁 조건을 방지할 수 있다.
관계형 데이터베이스를 사용한다면 INSERT ... ON CONFLICT DO NOTHING 또는 유니크 제약 조건 위반을 캐치해 중복 처리를 막을 수 있다.
5단계: TTL과 키 보존 기간 설정
멱등성 키를 영구적으로 보관하면 저장소가 계속 증가한다. 발신 서버의 재시도 기간보다 충분히 긴 TTL을 설정해야 한다. 대부분의 Webhook 서비스가 최대 72시간에서 7일 사이에 재시도를 종료하므로, 키 보존 기간은 최소 7일 이상으로 설정하는 것이 일반적이다.
완료된 처리 결과를 응답으로 다시 돌려줘야 한다면, 결과 자체도 키와 함께 저장해 두어야 한다. 저장 공간이 부담된다면 결과의 핵심 필드만 직렬화해 저장하는 방식을 택할 수 있다.
실제 구현 시 주의할 점
네트워크 응답과 저장 순서
처리를 완료한 뒤 응답을 보내기 전에 키를 저장하는 순서가 중요하다. 처리 완료 후 응답 전송 직전에 저장소 저장이 실패하면, 발신 서버는 재시도하고 수신 서버는 키가 없어 다시 처리한다. 반대로 키를 먼저 저장하고 처리를 시작한다면, 처리 실패 시 키는 남아 있어 재시도가 차단될 수 있다.
이상적인 순서는 다음과 같다.
- 키를
processing상태로 원자적으로 저장 - 비즈니스 로직 실행
- 키 상태를
completed로 업데이트하고 결과 저장 - 200 OK 응답 반환
처리 실패 시에는 키 상태를 failed로 업데이트하고 5xx 응답을 반환해 발신 서버의 재시도를 유도한다.
발신 서버별 이벤트 ID 위치 확인
서비스마다 이벤트 ID를 제공하는 방식이 다르다. 연동 전에 공식 문서에서 확인하는 것이 필수다.
| 서비스 | 이벤트 ID 위치 | 형식 |
|---|---|---|
| Stripe | 페이로드 id 필드 | evt_ 접두어 |
| GitHub | X-GitHub-Delivery 헤더 | UUID |
| Shopify | X-Shopify-Webhook-Id 헤더 | UUID |
| SendGrid | 페이로드 각 이벤트 sg_event_id | UUID |
발신 서버가 이벤트 ID를 제공하지 않거나 재시도 시 ID가 바뀌는 경우라면, 페이로드의 핵심 비즈니스 식별자(주문 ID, 사용자 ID 등)와 이벤트 타입을 조합해 키를 직접 구성해야 한다.
분산 환경에서의 고려사항
여러 서버 인스턴스가 동시에 Webhook을 수신하는 환경이라면 중앙화된 저장소를 반드시 사용해야 한다. 각 인스턴스의 로컬 메모리에 키를 저장하면 서버 간 공유가 되지 않아 멱등성 보장이 깨진다.
Redis Cluster나 분산 데이터베이스를 사용할 경우 네트워크 파티션 상황에서 저장소에 접근하지 못하는 경우를 대비한 폴백 전략도 필요하다. 이때는 보수적으로 처리를 거부하고 발신 서버의 재시도를 기다리는 방식이 일반적으로 더 안전하다.
멱등성 키 설계 체크리스트
실제 구현 전 다음 항목을 점검하면 빠뜨리는 부분을 줄일 수 있다.
- 발신 서버가 제공하는 이벤트 고유 ID의 위치와 형식을 파악했는가
- 멱등성 키 저장소를 선택하고 원자적 연산 방식을 확정했는가
- 처리 상태(processing / completed / failed)를 키와 함께 관리하는가
- 경쟁 조건을 방지하는 원자적 키 저장 로직이 있는가
- 키의 TTL이 발신 서버 재시도 기간보다 충분히 긴가
- 처리 실패 시 키 상태를 갱신하고 재시도를 유도하는 로직이 있는가
- 분산 환경에서 중앙화된 저장소를 사용하고 있는가
- 저장소 장애 시 폴백 동작이 정의되어 있는가
테스트 방법
멱등성 구현은 반드시 의도적인 중복 요청 테스트가 필요하다. 단순히 코드 리뷰만으로는 경쟁 조건이나 상태 전이 오류를 발견하기 어렵다.
동일한 페이로드와 키로 빠르게 연속 요청을 보내 중복 처리가 발생하지 않는지 확인한다. 처리 중 상태에서 두 번째 요청이 올 때 올바른 응답 코드를 반환하는지도 검증한다. 처리 실패 상황을 강제로 만들어 키 상태가 failed로 전환되고 재시도 시 처리가 다시 이루어지는지도 테스트 항목에 포함해야 한다.
부하 테스트 환경에서 동일 키로 수십 개의 동시 요청을 보내 저장소 레벨에서 중복이 한 건도 발생하지 않는지를 확인하는 것이 가장 신뢰도 높은 검증 방법이다.