Software Architecture/Architectural Styles

Architectural Styles - 07. 이벤트 기반 아키텍처

이벤트 기반 아키텍처 — 비동기가 만드는 새로운 일관성

결제 서비스가 3초 걸린다고 하자. 모놀리스에서 동기 호출로 묶여 있으면, 주문은 결제가 끝날 때까지 3초 대기한다. 요청이 몰리면 스레드 풀이 고갈되고 전체 장애로 번진다. 이 장면을 이벤트 기반으로 바꾸면 — 주문은 접수만 하고 'OrderPlaced' 이벤트를 발행한다. 결제 서비스는 그 이벤트를 받아 자기 속도로 처리한다. 주문은 결제가 3초 걸리든 30초 걸리든 영향받지 않는다. 이게 이벤트 기반 아키텍처(event-driven architecture)의 약속이다.

이 글이 다루는 질문: 비동기 이벤트가 푸는 문제는 무엇이고, 그 대가로 요구하는 것은 무엇인가. 푸는 문제는 '결합과 장애 전파'고, 대가는 '강한 일관성과 디버깅 단순함'이다.

비유로 감 잡기 — 사서와 게시판

도서관을 상상하자. 새 책이 들어왔을 때 사서가 모든 관심 있는 사람에게 일일이 전화하는 모델이 동기 호출이다. 사서는 누가 관심 있는지 다 알아야 하고 (강결합), 한 명이 안 받으면 다음 사람 전화가 밀린다 (블로킹).

반면, 사서가 게시판에 "새 책 들어옴" 쪽지를 붙이는 모델이 이벤트 기반이다. 사서는 누가 읽을지 모른다 (느슨한 결합). 쪽지를 붙이고 바로 다른 일을 한다 (비동기). 관심 있는 사람들은 게시판을 보고 각자 반응한다. 한 명이 쪽지를 못 봐도 사서는 영향받지 않는다 (장애 격리).

이벤트 기반 아키텍처의 본질이 이 비유에 있다. 이벤트를 발행하는 쪽(publisher)은 누가 소비하는지(consumer) 모른다. 소비하는 쪽은 자기 속도로 반응한다. 발행자와 소비자의 결합이 느슨해지고, 한쪽의 문제가 다른 쪽으로 전파되지 않는다.

비유의 한계 — 도서관 게시판 비유는 '단순 알림'을 강조하지만, 실제 이벤트는 상태 변경의 '사실(fact)'을 담는다. 또한 게시판은 사라질 수 있지만, 메시지 브로커는 보통 영속(persistent)한다 — 발행 시점에 소비자가 없어도 나중에 읽을 수 있다. 비유는 '영속성'과 '이벤트의 의미(semantics)'에서 무너진다.

이벤트 기반의 두 토폴로지

이벤트 기반 아키텍처는 주로 두 형태로 나타난다.

1. Broker 토폴로지

발행자가 이벤트를 브로커(Kafka, RabbitMQ, SNS)에 보내고, 소비자가 브로커에서 읽는 형태. 중앙조정자가 없다. 각 서비스는 자기가 관심 있는 이벤트를 구독하고 반응한다.

flowchart LR
    P1[Order Service] -->|OrderPlaced| B[(Message Broker<br/>Kafka/RabbitMQ)]
    P2[Payment Service] -->|PaymentSucceeded| B
    P3[Inventory Service] -->|StockDecreased| B
    B --> S1[Shipping Service<br/>구독: OrderPlaced, PaymentSucceeded]
    B --> S2[Notification Service<br/>구독: PaymentSucceeded]
    B --> S3[Analytics Service<br/>구독: 모든 이벤트]

장점 — 느슨한 결합, 쉬운 소비자 추가(새 서비스 구독만 추가), 확장성.
단점 — 흐름 파악 어려움, 순서 보장 어려움, 중복 처리 부담.

2. Mediator 토폴로지

중앙조정자(mediator)가 이벤트 흐름을 조율하는 형태. 각 단계를 조정자가 호출하고, 그 결과에 따라 다음 단계를 결정한다. Orchestration이라고도 한다.

flowchart TD
    M[Order Mediator] -->|1. placeOrder| O[Order Service]
    M -->|2. charge| P[Payment Service]
    M -->|3. reserveStock| I[Inventory Service]
    M -->|4. scheduleShipping| S[Shipping Service]
    M -. 결제 실패 시 보상 .-> O

장점 — 흐름 한눈에 보임, 복잡한 분기·보상 처리 가능.
단점 — 중앙조정자 = 단일 장애점, 결합 다시 증가.

Choreography vs Orchestration — 두 패턴의 트레이드오프

이벤트 기반 시스템을 설계할 때 가장 중요한 결정이 이 둘 중 어느 쪽을 택할까다.

  • Choreography — 각 서비스가 이벤트에 반응해 자율적으로 동작. 중앙조정자 없음. Broker 토폴로지와 짝.
  • Orchestration — 중앙조정자가 서비스 호출 순서를 결정. Mediator 토폴로지와 짝.

Richards-Ford가 (2020)에서 정리하는 트레이드오프:

기준 Choreography Orchestration
결합도 낮음 (느슨) 높음 (중앙 의존)
흐름 파악 어려움 (이벤트 추적 필요) 쉬움 (코드 한 곳에)
확장 소비자 추가만으로 중앙조정자 수정 필요
분기 처리 각 서비스가 분기 — 흩어짐 중앙协调자가 분기 — 집중
장애 한 서비스 장애 격리 쉬움 중앙조정자 장애 = 전체 장애
적합 단순 흐름, 느슨한 결합 중시 복잡한 흐름, 보상 트랜잭션 필요

현실의 시스템은 두 패턴을 섞어 쓰는 경우가 많다 — 단순한 알림류는 choreography로, 복잡한 비즈니스 흐름(주문 처리처럼 다단계 보상이 필요한)은 orchestration으로. Fowler가 "이벤트 기반 시스템의 복잡도가 올라가면 어느 순간 orchestration이 다시 매력적이 된다"고 관찰한 것은 이 맥락이다.

이벤트가 '사실'인가 '명령'인가 — 의미적 구분

이벤트 기반 설계에서 흔히 혼동되는 구분. Martin Fowler가 "What do you mean by Event-Driven?" 글에서 정리한 구분이다.

  • 이벤트(event) — '무언가 일어났다'는 사실. 과거형. OrderPlaced, PaymentSucceeded. 발행자는 소비자가 뭘 할지 지시하지 않는다.
  • 명령(command) — '무언가 해라'는 지시. PlaceOrder, ChargePayment. 발행자가 소비자에게 할 일을 지시한다.
  • 상태(state) — 현재 상태. 보통 query 모델에서 다룬다 (08편 CQRS 참조).

이 구분이 왜 중요한가. 혼동하면 시스템이 결합된다. OrderPlaced(이벤트, 사실)를 발행하면 결제 서비스가 자율적으로 "결제해야겠다"고 판단한다. 반면 ChargePayment(명령)를 발행하면 주문 서비스가 결제 서비스의 행위를 지시하는 것이라, 주문 서비스가 결제 서비스의 존재를 알아야 한다 (결합).

// 느슨한 결합 — 이벤트 (사실)
events.publish(new OrderPlaced(order.id(), order.items()));
// 발행자는 누가 이걸 보고 뭘 할지 모름

// 강결합 — 명령 (지시)
commands.send(new ChargePaymentCommand(order.id(), order.total()));
// 발행자가 누가(결제 서비스) 뭘 해야 하는지(결제) 앎

이벤트 기반 아키텍처의 이상은 '사실'을 발행하는 쪽에 가깝다. 소비자가 자율적으로 반응하게 하는 게 느슨한 결합의 본질이기 때문이다. 다만 현실에선 비즈니스 흐름(주문 → 결제 → 배송)을 '사실'만으로 표현하면 흐름이 흩어져 보이기 어려워, orchestration 형태로 '명령'을 쓰는 경우도 흔하다. 둘의 trade-off를 아는 게 핵심이다.

최종 일관성 — 이벤트 기반의 근본적 비용

이벤트 기반의 가장 큰 비용. 동기 호출에선 "주문 완료 = 결제 완료"가 한 트랜잭션에서 보장된다. 이벤트 기반에선 "주문 완료 → (이벤트) → 결제 처리"가 분리되고, 그 사이에 시간차가 생긴다. '주문은 됐는데 결제는 안 된' 중간 상태가 외부에 보인다.

Martin Kleppmann이 (2017)에서 정리하듯, 이것은 '최종 일관성(eventual consistency)'의 한 형태다. 시스템은 결국 일관된 상태에 수렴하지만, 중간에는 불일치 상태가 존재한다. 사용자에게 "결제 완료됨"을 즉시 보여줄 수 없고, "결제 처리 중" 상태를 보여줘야 한다.

최종 일관성의 함정

함정 설명 완화
중간 상태 노출 사용자가 '처리 중' 상태를 봄 UI에서 명확한 상태 표시
순서 비보장 이벤트가 도착 순서대로 처리 안 될 수 있 순서 키(키별 파티션) 또는 시퀀스
중복 처리 같은 이벤트가 두 번 처리될 수 있 (at-least-once) 멱등성(idempotency) 설계
이벤트 유실 브로커 장애 시 이벤트 사라짐 영속 브로커 + ack + outbox 패턴
디버깅 복잡 한 흐름이 여러 서비스에 흩어짐 분산 추적(trace-id 전파)

이 표의 함정들이 이벤트 기반의 '비용'이다. 모놀리스 동기 호출에선 자연스럽던 것(강한 일관성, 호출 스택 추적)이 이벤트 기반에선 감수해야 하는 것들이 된다.

Outbox 패턴 — 이벤트 유실을 막는 패턴

이벤트 기반의 흔한 실패 — DB 트랜잭션은 커밋됐는데 이벤트 발행이 실패하면, 시스템이 불일치 상태에 빠진다. 주문은 저장됐는데 OrderPlaced 이벤트가 안 나가면, 결제가 영영 일어나지 않는다.

이 문제를 푸는 패턴이 outbox다. 비즈니스 데이터와 이벤트를 같은 트랜잭션에 쓰고, 별도 프로세스가 이벤트를 브로커로 발행한다.

@Transactional
public OrderId placeOrder(OrderRequest req) {
    Order order = new Order(req);
    orders.save(order);                              // 1. 비즈니스 데이터 저장

    // 2. 같은 트랜잭션에 outbox 테이블에 이벤트 저장
    outbox.save(new OutboxEvent("OrderPlaced", serialize(order)));

    return order.id();
    // 트랜잭션 커밋 시 order와 outbox_event가 원자적으로 저장
}

// 별도 프로세스가 outbox를 읽어 브로커로 발행
class OutboxPublisher {
    @Scheduled(every = "100ms")
    void publishPending() {
        List<OutboxEvent> pending = outbox.findUnpublished();
        for (OutboxEvent e : pending) {
            kafka.send(e.topic(), e.payload());
            outbox.markPublished(e.id());  // 발행 완료 표시
        }
    }
}

이 패턴의 핵심 — 비즈니스 상태 변경과 이벤트 발행이 같은 DB 트랜잭션에 묶인다. 둘 다 성공하거나 둘 다 실패한다. 발행이 실패해도 outbox에 남아있으므로 재시도할 수 있다. 이것이 없으면 "DB엔 있는데 브로커엔 없는" 불일치가 발생한다.

설계 사례 — 동기 모놀리스에서 이벤트 기반으로

1단계 — 동기 모놀리스 (장애 전파 위험)

class OrderService {
    void placeOrder(OrderRequest req) {
        Order order = orders.save(new Order(req));
        payment.charge(order);        // 동기 — 3초 대기
        inventory.decrement(req.items());
        shipping.reserve(order);
    }
}

결제가 3초 걸리면 주문 전체가 3초 블록된다.

2단계 — 이벤트 기반 (choreography)

// Order Service — 주문만 저장하고 이벤트 발행
class OrderService {
    public OrderId placeOrder(OrderRequest req) {
        Order order = new Order(req);
        order.markPendingPayment();
        orders.save(order);
        events.publish(new OrderPlacedEvent(order.id(), order.total(), req.items()));
        return order.id();
    }
}

// Payment Service — 이벤트 구독, 자율 처리
class PaymentService {
    void on(OrderPlacedEvent e) {
        try {
            charge(e.orderId(), e.total());
            events.publish(new PaymentSucceededEvent(e.orderId()));
        } catch (Exception ex) {
            events.publish(new PaymentFailedEvent(e.orderId(), ex.getMessage()));
        }
    }
}

// Inventory Service — 독립적으로 반응
class InventoryService {
    void on(OrderPlacedEvent e) {
        decrement(e.items());
        events.publish(new StockReservedEvent(e.orderId()));
    }
}

// Shipping Service — 두 이벤트 모두 받아야 출발
class ShippingService {
    private final Set<OrderId> paid = ConcurrentHashMap.newKeySet();
    private final Set<OrderId> stocked = ConcurrentHashMap.newKeySet();

    void on(PaymentSucceededEvent e) {
        paid.add(e.orderId());
        tryShip(e.orderId());
    }
    void on(StockReservedEvent e) {
        stocked.add(e.orderId());
        tryShip(e.orderId());
    }
    private void tryShip(OrderId id) {
        if (paid.contains(id) && stocked.contains(id)) {
            ship(id);
            events.publish(new OrderShippedEvent(id));
        }
    }
}

Shipping Service가 '결제 완료'와 '재고 확보' 두 이벤트를 모두 받아야 출발한다는 로직이 흩어져 있다. 이게 choreography의 단점 — 비즈니스 흐름이 한 곳에 보이지 않는다.

3단계 — orchestration으로 흐름 집중

// Order Saga Orchestrator — 흐름을 한 곳에서 조율
class OrderSaga {
    void handle(OrderPlacedEvent e) {
        SagaInstance saga = sagaRepo.create(e.orderId());
        sendCommand(new ChargePaymentCommand(e.orderId(), e.total()));
        saga.await("PaymentResult");
    }
    void on(PaymentSucceeded e) {
        sendCommand(new ReserveStockCommand(e.orderId(), e.items()));
    }
    void on(PaymentFailed e) {
        sendCommand(new CancelOrderCommand(e.orderId()));
    }
    void on(StockReserved e) {
        sendCommand(new ScheduleShippingCommand(e.orderId()));
    }
    void on(ShippingScheduled e) {
        saga.complete();
    }
}

이제 흐름이 한 클래스에 보인다. 디버깅이 쉬워지고, 보상 로직도 한 곳에. 대신 orchestrator가 단일 장애점이 되고, 모든 서비스가 orchestrator를 알아야 한다 (결합 증가).

Choreography vs Orchestration 선택 기준

상황 추천
단순 알림(이메일 발송, 로깅) Choreography
한 서비스 내에서 완결되는 반응 Choreography
다단계 흐름 + 보상 트랜잭션 Orchestration
흐름 파악이 운영상 중요 Orchestration
서비스 수가 많고 자율성이 중요 Choreography
비즈니스 규칙이 복잡하게 엮임 Orchestration

이벤트 기반의 진짜 비용

이벤트 기반 아키텍처가 강력한 도구인 건 분명하다 — 장애 격리, 느슨한 결합, 서비스별 독립 확장. 하지만 그 대가로 '강한 일관성'을 포기해야 한다. "결제까지 완료됐나?"라는 질문에 즉시 답할 수 없게 된다. 사용자에게 "처리 중" 상태를 보여줘야 하고, 시스템은 결국 일관된 상태에 수렴한다는 보장만 할 수 있다.

이런 비용을 감당할 수 있을 때만 이벤트 기반으로 가야 한다. 금융 원장처럼 '강한 일관성'이 비즈니스 핵심인 시스템에 이벤트 기반을 끼우면, 사용자 불만과 데이터 불일치가 동시에 발생한다. 반면 소셜 피드·알림·분석처럼 '최종 일관성'이 자연스러운 영역에선 이벤트 기반이 강력한 선택이다.

다음으로 — CQRS

이벤트 기반에서 자연스럽게 등장하는 다음 질문: 읽기와 쓰기를 같은 모델로 처리하는 게 항상 최선인가. 08편(CQRS)에서 이 질문을 다룬다. 읽기 비중이 압도적으로 높은 시스템에서, 쓰기 모델과 읽기 모델을 분리해 각각 최적화하는 패턴이다. 이벤트 기반의 '최종 일관성'을 전제로 성립하는 패턴이기도 하다.


참고

  • Richards, Ford — (O'Reilly, 2020), Ch.5 (이벤트 기반 아키텍처)·Ch.16 (비동기 중재)
  • Fowler — "What do you mean by Event-Driven?" (martinfowler.com, 2017) — 접근 2026-07-20
  • Kleppmann — (O'Reilly, 2017), Ch.5 (복제)·Ch.11 (스트림 처리)
  • Newman — (2nd ed, O'Reilly, 2021), 비동기 통신 관련 장
  • Fowler — "Outbox Pattern" / Transactional Outbox 관련 글 — 접근 2026-07-20