Event-driven · Backend

이벤트 기반 시스템의
신뢰성

메시지 브로커가 있다고 해서 이벤트가 안전하게 전달되는 건 아니다. 유실, 중복, 순서 뒤바뀜을 당연히 일어나는 일로 전제하고 설계하면, 장애가 나더라도 시스템은 일관된 상태로 돌아올 수 있다.

이벤트 기반 아키텍처는 서비스 간 결합도를 낮추고 독립적인 확장을 가능하게 한다. 하지만 요청 직후 응답이 오는 동기 호출과 달리, 이벤트는 한 번 발행되고 나면 그게 어디까지 흘러갔는지 한눈에 파악하기 어렵다. DB 저장은 성공했는데 이벤트 발행은 실패할 수도 있고, Consumer가 같은 메시지를 두 번 받을 수도 있고, 한동안 멈춰 있던 Consumer가 뒤늦게 오래된 이벤트를 처리할 수도 있다.

신뢰성은 "정확히 한 번 전달(exactly-once delivery)"이라는 하나의 약속으로 달성되는 게 아니다. 저장, 전달, 소비, 관측이라는 각 단계마다 장애를 미리 전제하고, 같은 작업을 다시 해도 안전하도록 만드는 여러 메커니즘의 조합이다. 이 글에서는 실무에서 가장 자주 마주치는 문제들을 순서대로 짚어본다.

먼저 맞춰야 할 것: 전달 보장과 처리 보장은 다르다

메시지 브로커의 at-least-once 전달이란, 메시지가 Consumer에게 최소 한 번은 도달할 때까지 재시도한다는 뜻이다. 하지만 Consumer가 처리 결과를 저장한 직후, ack를 보내기 전에 죽어버리면 브로커는 같은 메시지를 다시 보낸다. 즉 전달이 보장된다고 해서 비즈니스 연산이 정확히 한 번만 실행되는 것은 아니다.

실무에서는 at-least-once 전달을 그대로 받아들이고 Consumer를 멱등(idempotent)하게 만들어 effectively-once 처리를 목표로 삼는 편이 낫다. 멱등성이란 같은 입력을 여러 번 처리해도 한 번 처리했을 때와 동일한 결과가 나온다는 뜻이다. 결제 승인, 포인트 적립, 이메일 발송처럼 외부에 영향을 미치는 연산일수록 이 경계가 중요하다.

1. DB 저장과 이벤트 발행을 함께 처리한다

가장 단순한 코드, 즉 주문을 저장한 뒤 이벤트를 발행하는 방식에는 두 개의 실패 구간이 있다. DB 저장 후 프로세스가 죽으면 이벤트가 유실되고, 이벤트를 먼저 보낸 뒤 DB 저장이 실패하면 존재하지 않는 주문에 대한 이벤트가 전달된다. 두 시스템을 분산 트랜잭션으로 묶을 수도 있지만, 운영 복잡도와 가용성 비용이 크다.

Outbox 패턴은 도메인 데이터와 발행할 이벤트를 같은 로컬 트랜잭션 안에 저장한다. 별도의 워커가 아직 발행되지 않은 Outbox 레코드를 읽어 브로커로 보내고, 성공하면 발행 완료로 표시한다. 이 방식은 이벤트가 늦게 전달될 수는 있지만, 유실되는 구간 자체를 없앤다.

예시: Transactional Outbox

@Service
class PlaceOrderService(
    private val orderRepository: OrderRepository,
    private val outboxRepository: OutboxRepository,
) {
    @Transactional
    fun placeOrder(command: PlaceOrderCommand): Order {
        val order = Order.place(command)
        orderRepository.saveOrder(order)

        outboxRepository.append(
            OutboxEvent(
                id = UUID.randomUUID().toString(),
                topic = "orders.placed",
                key = order.id,
                payload = OrderPlacedPayload(orderId = order.id, customerId = order.customerId),
                occurredAt = LocalDateTime.now(),
            ),
        )
        return order
    }
}

@Component
class OutboxPoller(
    private val outboxRepository: OutboxRepository,
    private val broker: EventBroker,
) {
    @Scheduled(fixedDelay = 1000)
    @Transactional
    fun publishOutbox() {
        outboxRepository.findUnpublished().forEach { event ->
            broker.publish(event.topic, event.key, event.payload)
            outboxRepository.markPublished(event.id)
        }
    }
}

워커가 브로커에 전송한 뒤 완료 표시를 하기 전에 죽으면, 같은 이벤트가 다시 발행될 수 있다. 즉 Outbox만으로는 중복을 없앨 수 없다. 발행 쪽은 안정적인 이벤트 ID와 key를 제공해야 하고, 소비 쪽은 중복을 안전하게 처리해야 한다.

2. Consumer는 멱등성 키를 저장한다

가장 단순한 방법은 이벤트 ID를 처리 이력으로 기록하는 것이다. Consumer는 작업을 수행하기 전에 해당 ID가 이미 처리됐는지 확인하고, 처리 결과와 ID를 같은 트랜잭션 안에 저장한다. 데이터베이스의 unique 제약을 활용하면, 여러 Consumer 인스턴스가 동시에 같은 메시지를 받더라도 그중 하나만 성공하게 만들 수 있다.

예시: 중복 이벤트를 무시하는 Consumer

@Component
class PaymentApprovedEventHandler(
    private val inboxRepository: InboxRepository,
    private val orderRepository: OrderRepository,
) {
    @Transactional
    fun handle(event: PaymentApprovedEvent) {
        if (inboxRepository.exists(event.id)) return

        val order = orderRepository.findOrder(event.orderId)
        order.markPaid(event.approvedAt)
        orderRepository.saveOrder(order)

        // Put a unique index on event_id.
        inboxRepository.record(event.id, "payment-service")
    }
}

여기서 Inbox 레코드와 주문 상태 변경은 반드시 같은 트랜잭션 안에 있어야 한다. 둘 중 하나만 성공하면 다음 재시도에서 불일치가 생긴다. 외부 API 호출처럼 같은 트랜잭션에 묶을 수 없는 작업이라면, 제공자가 지원하는 멱등성 키를 전달하거나 호출 의도 자체를 Outbox 항목으로 기록해 재시도 가능한 연산으로 바꿔야 한다.

3. 재시도는 정책이지, 무한 반복이 아니다

네트워크 오류나 일시적인 데이터베이스 과부하는 재시도로 복구할 수 있다. 하지만 영구적인 실패도 있다 — 스키마가 맞지 않는 이벤트, 이미 삭제된 상품을 참조하는 이벤트 같은 것들이다. 모든 실패를 즉시 재시도하면 Consumer는 같은 메시지에 발이 묶여 뒤에 대기 중인 정상 메시지조차 처리하지 못하게 된다.

일시적 오류에는 exponential backoff와 jitter를 적용하고, 정해진 횟수를 넘기면 메시지를 Dead Letter Queue로 옮기는 정책이 필요하다. DLQ는 실패를 숨기기 위한 쓰레기통이 아니라, 운영자가 원인을 파악하고 고친 뒤 재처리하기 위해 쓰는 큐다. 진단을 위해서는 원본 이벤트, 실패 횟수, 예외 메시지, 최초 수신 시각을 함께 보관해야 한다.

재시도 설계 팁

재시도 횟수만 정하지 말고, 어떤 오류가 재시도 대상인지, 최대 지연 시간은 얼마인지, DLQ 재처리는 누구 책임인지도 함께 정의하라. 알림이 없는 DLQ는 결국 유실된 이벤트와 다를 게 없다.

4. 순서가 중요하다면 그 범위를 제한하라

분산 환경에서 모든 이벤트의 전역 순서를 보장하는 건 대개 비용이 크고 불필요하다. 보통 필요한 건 "같은 주문에 대한 이벤트는 순서대로 처리된다"에 가까운 수준이다. 주문 ID를 파티션 키로 사용하면 같은 주문의 이벤트가 같은 파티션으로 전달되어, Consumer는 그 범위 안에서 순서를 보존할 수 있다.

그럼에도 지연된 이벤트는 여전히 도착할 수 있다. 상태 전이를 검증하고 버전을 포함시키는 방식으로 이를 방어할 수 있다. 예를 들어 이미 취소된 주문에 뒤늦게 결제완료 이벤트가 도착하면, 변경을 무조건 적용하는 대신 현재 상태와 이벤트의 버전을 비교해야 한다. "오래된 메시지는 무시한다"는 것도 암묵적인 처리가 아니라 명시적인 비즈니스 결정이어야 한다.

5. 관측 가능성이 복구 시간을 줄인다

이벤트 시스템의 장애는 대개 조용히 시작된다. 발행 지연이 쌓이고, 어느 한 Consumer의 재시도가 늘어나고, DLQ가 서서히 커진다. 로그, 트레이스, 메트릭 모두에 이벤트 ID와 correlation ID를 남겨두면, 하나의 요청이 만들어낸 이벤트들이 어디서 멈췄는지 추적할 수 있다.

최소한 대시보드에서는 다음을 지켜봐야 한다: 미발행 Outbox 항목 수, 발행 지연 시간, Consumer lag, 재시도 횟수, DLQ 적재량. 이 운영 지표들은 성능 지표이기도 하지만, 동시에 일관성 지표이기도 하다 — 비즈니스 사실이 실제로 언제 반영되는지를 보여주기 때문이다.

신뢰성 체크리스트

  • 도메인 변경과 이벤트 기록이 하나의 로컬 트랜잭션 안에 저장되는가?
  • 모든 이벤트가 고유 ID, 발생 시각, 추적 가능한 correlation ID를 갖는가?
  • Consumer가 중복을 안전하게 무시하거나 동일한 결과를 재현하는가?
  • 일시적 실패와 영구적 실패를 구분하고, DLQ를 모니터링하는가?
  • 순서가 중요한 범위와, 지연된 이벤트를 처리하는 정책이 명확히 정의되어 있는가?

신뢰할 수 있는 이벤트 시스템이란 절대 실패하지 않는 시스템이 아니라, 실패를 미리 전제하고 안전하게 재시도할 수 있는 시스템이다. 전달 보장, 멱등성, 재시도 정책, 관측 가능성을 함께 설계하면 서비스가 늘어나도 데이터 흐름을 계속 유지할 수 있다.

저장소 내 추가 자료

docs/architecture/domain-events.md — 전체 Outbox 패턴, Integration Event, 멱등성 컨벤션