← 전체 글로 돌아가기

웹 개발

웹훅 재전송을 대비해 요청 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를 모두 남긴다. 장애 때 어느 요청이 재시도였는지와, 어느 업무 이벤트가 실제로 처리됐는지를 분리해서 볼 수 있게 됐다.