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