이 글은 “믿을 수 있는 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 폴링으로 시작하는 이유는, 패턴의 본질(원자적 발행 + 별도 전달)을 먼저 또렷하게 보기 위해서입니다. 확장은 문제가 실제로 보일 때 하나씩 도입하는 편이 배우기에도 좋습니다.
시리즈 로드맵
- (이번 편) 설계와 Outbox 패턴 — 왜 단순 호출이 안 되는가
- 아웃박스 저장 + 폴링 워커 구현, 첫 전송 성공시키기
- 재시도와 지수 백오프, Dead Letter 격리
- 여러 워커 인스턴스의 경쟁 조건 — 잠금/클레임 전략
- 멱등성 키로 수신 측 중복 제거하기
- 관측: 시도 횟수·지연·실패율 들여다보기
마무리
Webhook은 “보내면 끝”이 아니라 “안 받아줄 수도 있는 상대에게, 유실 없이, 중복 없이, 내 트랜잭션을 망치지 않으면서 전달하기”라는 문제입니다. 다음 편에서는 위 아웃박스 테이블을 실제로 만들고, 가장 단순한 폴링 워커로 첫 이벤트를 전송시켜 보겠습니다. 그 과정에서 처음 마주친 문제들을 가감 없이 기록할 예정입니다.