웹 개발
웹훅 재전송을 대비해 요청 ID와 멱등 키를 나눈 비교 글
결제 알림이 두 번 들어왔을 때 요청 ID와 멱등 키를 같은 것으로 보지 않고 처리 기준을 세운 기록이다.
같은 이벤트가 두 번 온 날 기준을 다시 세웠다
외부 서비스의 결제 완료 웹훅이 짧은 간격으로 두 번 도착했고, 주문 완료 이메일도 두 통 발송됐다. 처음에는 HTTP 요청마다 찍는 request ID로 중복을 막으려 했다. 하지만 재전송은 새 HTTP 요청이라 request ID가 달랐다. 전송 단위 식별자와 업무상 한 번만 처리해야 하는 키는 목적이 다르다.
두 ID를 구분해서 저장한다
| 값 | 쓰는 이유 | 중복 판단에 쓰는가 |
|---|---|---|
| request ID | 로그 추적과 장애 조사 | 아니다 |
| provider event ID | 같은 외부 이벤트 재전송 감지 | 보통 그렇다 |
| idempotency key | 우리 API의 같은 의도 재시도 감지 | 그렇다 |
웹훅 제공자가 event ID를 준다면 이 값을 고유 제약 조건으로 저장하는 방식이 가장 단순했다. 제공하지 않는 경우에는 주문 ID, 이벤트 종류, 공급자 거래 ID처럼 변하지 않는 필드를 조합하되, 금액이나 수신 시각처럼 달라질 수 있는 값은 키에서 뺐다.
처리 순서는 트랜잭션으로 묶는다
서명 검증을 통과한 payload만 처리하고, 이벤트 기록 삽입과 주문 상태 변경을 같은 트랜잭션에 넣었다.
create table webhook_events (
provider_event_id text primary key,
received_at timestamptz not null default now()
);
await db.transaction(async (tx) => {
const inserted = await tx.webhookEvent.createMany({
data: [{ providerEventId: event.id }],
skipDuplicates: true,
})
if (inserted.count === 0) return
await tx.order.update({
where: { paymentId: event.paymentId },
data: { status: 'paid' },
})
})
skipDuplicates만으로 이메일 발송까지 안전해지는 것은 아니다. 트랜잭션 커밋 뒤 메시지 발송이 실패할 수 있다. 규모가 커지면 outbox 테이블에 발송할 작업을 기록하고 별도 워커가 처리하는 방식이 재시도와 추적에 유리하다.
재전송 테스트로 마무리한다
- 같은 payload를 연속 두 번 보낸다.
- 첫 처리 중 응답을 끊은 경우도 재현한다.
- event ID는 같고 request ID는 다른 로그를 확인한다.
- 중복 이벤트가 와도 주문 상태와 이메일 수가 한 번인지 확인한다.
이후 로그에는 두 ID를 모두 남긴다. 장애 때 어느 요청이 재시도였는지와, 어느 업무 이벤트가 실제로 처리됐는지를 분리해서 볼 수 있게 됐다.