신뢰성 있는 Webhook 전송은 어떻게 설계할까? — Transactional Outbox 패턴 (1편)

이 글은 “믿을 수 있는 Webhook 전달 서비스 만들기” 시리즈의 첫 편입니다. 외부로 이벤트를 전달하는 Webhook은 “그냥 HTTP 한 번 쏘면 되는 것” 처럼 보이지만, 실제로 신뢰성 있게 만들려고 하면 분산 시스템의 어려운 문제들을 정면으로 만나게 됩니다.

이번 편에서는 코드를 바로 짜기 전에 무엇이 어려운지왜 Transactional Outbox 패턴을 선택하는지를 먼저 정리합니다. 다음 편부터 실제로 구현하면서 마주치는 문제와 해결 과정을 다룹니다.

순진한 구현과 그 함정

주문이 생성되면 외부 시스템에 알림(Webhook)을 보내야 한다고 해봅시다. 가장 먼저 떠오르는 코드는 이렇습니다.

@Transactional
public void createOrder(OrderRequest req) {
    Order order = orderRepository.save(new Order(req));
    // 주문 저장 직후 바로 외부로 전송
    httpClient.post("https://partner.example.com/webhook", toPayload(order));
}

잘 동작하는 것처럼 보이지만, 여기에는 최소 네 가지 문제가 숨어 있습니다.

  • ① 트랜잭션과 전송의 불일치. httpClient.post()는 성공했는데 그 뒤 트랜잭션이 롤백되면, 존재하지 않는 주문에 대한 Webhook이 이미 나가버립니다. 반대로 전송이 트랜잭션 안에서 실패하면 주문 저장까지 통째로 롤백됩니다.
  • ② 수신자 장애에 취약. 상대 서버가 잠깐 죽어 있거나 타임아웃이 나면 이벤트는 그냥 유실됩니다. 재시도 장치가 없습니다.
  • ③ 응답 지연 전파. 외부 호출이 느리면 그 시간만큼 주문 트랜잭션이 DB 커넥션을 붙잡고 늘어집니다. 트래픽이 몰리면 커넥션 풀이 고갈됩니다.
  • ④ 중복 처리 무방비. 재시도를 붙이는 순간, 같은 이벤트가 두 번 도착할 수 있습니다. 수신자가 이를 구분하지 못하면 중복 결제 같은 사고로 이어집니다.

우리가 보장하고 싶은 것

그래서 이 시리즈에서 만들 서비스의 목표를 다음과 같이 잡았습니다.

  • At-least-once 전달 — 한 번 발생한 이벤트는 (수신 확인될 때까지) 반드시 전달을 시도한다. 유실 0.
  • 비즈니스 트랜잭션과의 원자성 — 주문이 실제로 커밋된 경우에만 이벤트가 발행된다.
  • 재시도와 백오프 — 실패 시 지수 백오프로 다시 시도하고, 일정 횟수 초과분은 별도(Dead Letter)로 격리한다.
  • 멱등성 — 같은 이벤트가 두 번 도착해도 수신자가 안전하게 한 번만 처리할 수 있도록 고유 키를 부여한다.
  • 관측 가능성 — 어떤 이벤트가 몇 번 시도되어 성공/실패했는지 추적할 수 있다.

핵심 결정: Transactional Outbox 패턴

위 ①과 ②를 동시에 푸는 정석이 Transactional Outbox 패턴입니다. 아이디어는 단순합니다. “외부로 보내야 할 이벤트를, 비즈니스 데이터와 같은 DB 트랜잭션 안에서 ‘아웃박스’ 테이블에 함께 저장한다.” 전송 자체는 그 트랜잭션 밖에서, 별도의 워커가 아웃박스를 읽어 처리합니다.

주문 트랜잭션 ─┬─ orders 테이블 INSERT
              └─ outbox 테이블 INSERT (PENDING)   ← 같은 커밋

[별도 워커] outbox에서 PENDING 조회 → HTTP 전송 → 성공 시 SENT 로 표시 / 실패 시 재시도

이렇게 하면 주문과 이벤트는 같이 커밋되거나 같이 롤백되므로 ①이 사라지고, 전송이 트랜잭션 밖에서 일어나므로 ③의 커넥션 점유 문제도 사라집니다. 전송 실패는 워커가 재시도하므로 ②도 해결됩니다.

아웃박스 엔티티의 출발점은 대략 이런 모습입니다.

@Entity
@Table(name = "outbox_event")
public class OutboxEvent {

    @Id @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;            // 이 값이 곧 멱등성 키가 된다

    private String eventType;   // 예: "ORDER_CREATED"
    private String destination; // 수신 URL

    @Column(columnDefinition = "TEXT")
    private String payload;     // 직렬화된 JSON

    @Enumerated(EnumType.STRING)
    private Status status;      // PENDING, SENT, FAILED

    private int attempts;
    private Instant nextAttemptAt;
    private Instant createdAt;
}

여기서 id(UUID)를 그대로 Webhook 요청 헤더(예: X-Event-Id)에 실어 보내면, 수신자는 이 키로 중복을 걸러낼 수 있습니다. ④(멱등성)의 토대가 여기서 마련됩니다.

기술 스택

  • Spring Boot 3 / Java 21 — 애플리케이션 기반
  • Spring Data JPA + PostgreSQL — 비즈니스 데이터와 아웃박스 저장
  • 스케줄러 기반 워커 — 먼저 단순한 폴링 워커로 시작하고, 이후 편에서 다중 인스턴스 환경의 경쟁 조건과 잠금 전략으로 확장

처음부터 메시지 브로커(Kafka 등)를 끌어오지 않고 DB 폴링으로 시작하는 이유는, 패턴의 본질(원자적 발행 + 별도 전달)을 먼저 또렷하게 보기 위해서입니다. 확장은 문제가 실제로 보일 때 하나씩 도입하는 편이 배우기에도 좋습니다.

시리즈 로드맵

  1. (이번 편) 설계와 Outbox 패턴 — 왜 단순 호출이 안 되는가
  2. 아웃박스 저장 + 폴링 워커 구현, 첫 전송 성공시키기
  3. 재시도와 지수 백오프, Dead Letter 격리
  4. 여러 워커 인스턴스의 경쟁 조건 — 잠금/클레임 전략
  5. 멱등성 키로 수신 측 중복 제거하기
  6. 관측: 시도 횟수·지연·실패율 들여다보기

마무리

Webhook은 “보내면 끝”이 아니라 “안 받아줄 수도 있는 상대에게, 유실 없이, 중복 없이, 내 트랜잭션을 망치지 않으면서 전달하기”라는 문제입니다. 다음 편에서는 위 아웃박스 테이블을 실제로 만들고, 가장 단순한 폴링 워커로 첫 이벤트를 전송시켜 보겠습니다. 그 과정에서 처음 마주친 문제들을 가감 없이 기록할 예정입니다.