프로그래밍 PROGRAMMING/아키텍쳐

Event-Driven Architecture 적용하기

매운할라피뇽 2026. 3. 31. 08:15
반응형
이벤트 드리즌 아키텍쳐 적용하기

Event-Driven Architecture(EDA)는 시스템 컴포넌트들이 이벤트를 통해 느슨하게 결합되는 설계 패턴입니다. 서비스 간 직접 호출 대신 이벤트를 발행(Publish)하고 구독(Subscribe)하는 구조로, 높은 확장성탄력성을 확보할 수 있습니다. 이 글에서는 EDA의 핵심 개념부터 Spring Boot와 Apache Kafka를 활용한 실제 적용법까지 단계별로 살펴봅니다.


Event-Driven Architecture의 핵심 구성 요소

EDA는 세 가지 핵심 역할로 구성됩니다.

  • Producer (이벤트 발행자): 특정 도메인 행위가 완료되면 이벤트를 생성해 메시지 브로커로 전달합니다.
  • Broker (메시지 브로커): 이벤트를 수신·저장하고 Consumer에게 전달합니다. Apache Kafka, RabbitMQ, AWS SNS/SQS 등이 대표적입니다.
  • Consumer (이벤트 구독자): 관심 있는 이벤트 토픽을 구독하고 비동기로 처리합니다.

이 구조에서 Producer는 누가 이벤트를 소비하는지 알 필요가 없습니다. 이것이 기존 동기 방식의 REST 호출과 가장 큰 차이점이며, 서비스 간 의존성 제거의 핵심입니다.


동기 호출 vs. 이벤트 기반 설계 비교

변경전 — 서비스 간 직접 HTTP 호출

// OrderService.java — 직접 호출 방식 (강결합)
@Service
public class OrderService {

    private final InventoryClient inventoryClient;
    private final NotificationClient notificationClient;

    public void placeOrder(Order order) {
        // 재고 차감 — 실패 시 전체 트랜잭션 롤백 필요
        inventoryClient.deduct(order.getProductId(), order.getQuantity());
        // 알림 전송 — 응답 대기 시간이 주문 완료 속도에 직접 영향
        notificationClient.sendConfirmation(order.getUserId());
    }
}

변경후 — 이벤트 발행 방식 (느슨한 결합)

// OrderService.java — 이벤트 발행 방식
@Service
@RequiredArgsConstructor
public class OrderService {

    private final KafkaTemplate<String, OrderPlacedEvent> kafkaTemplate;

    public void placeOrder(Order order) {
        // 주문 저장 후 이벤트만 발행; Consumer는 독립적으로 처리
        orderRepository.save(order);
        OrderPlacedEvent event = new OrderPlacedEvent(
            order.getId(), order.getProductId(), order.getQuantity(), order.getUserId()
        );
        kafkaTemplate.send("order.placed", event); // 비동기 발행
    }
}

변경 후에는 InventoryServiceNotificationService가 각자의 속도로 이벤트를 소비합니다. 주문 서비스는 두 서비스의 응답을 기다리지 않아 응답 속도가 크게 향상됩니다.


Kafka Consumer 구현 및 멱등성 처리

Consumer를 구현할 때 가장 중요한 설계 원칙은 멱등성(Idempotency)입니다. 네트워크 장애나 재시도로 인해 동일 이벤트가 두 번 이상 전달될 수 있기 때문입니다.
아래는 재고 차감 Consumer의 멱등성 처리 예시입니다.

// InventoryConsumer.java
@Component
@RequiredArgsConstructor
public class InventoryConsumer {

    private final InventoryRepository inventoryRepository;
    private final ProcessedEventRepository processedEventRepository;

    @KafkaListener(topics = "order.placed", groupId = "inventory-service")
    public void handleOrderPlaced(OrderPlacedEvent event,
                                   @Header(KafkaHeaders.RECEIVED_TOPIC) String topic) {
        // 이미 처리된 이벤트인지 확인 (멱등성 보장)
        if (processedEventRepository.existsByEventId(event.getOrderId())) {
            log.info("중복 이벤트 무시: orderId={}", event.getOrderId());
            return;
        }

        // 재고 차감 처리
        inventoryRepository.deduct(event.getProductId(), event.getQuantity());

        // 처리 완료 기록
        processedEventRepository.save(new ProcessedEvent(event.getOrderId(), topic));
        log.info("재고 차감 완료: productId={}, qty={}", event.getProductId(), event.getQuantity());
    }
}
// 콘솔 출력 예시
재고 차감 완료: productId=PROD-001, qty=2
중복 이벤트 무시: orderId=ORD-9921   ← 재시도 이벤트 안전하게 처리

processedEventRepository는 Redis나 RDB를 활용할 수 있습니다. Redis를 사용하면 TTL을 설정해 오래된 이벤트 ID를 자동으로 만료시킬 수 있어 메모리 효율도 확보됩니다.


Dead Letter Queue로 장애 복원력 확보

EDA에서 Consumer가 이벤트 처리에 실패할 경우 이벤트를 유실하지 않도록 Dead Letter Queue(DLQ) 패턴을 적용합니다. Spring Kafka에서는 @RetryableTopic 어노테이션으로 재시도와 DLQ를 선언적으로 설정할 수 있습니다.

// NotificationConsumer.java — 재시도 및 DLQ 설정
@Component
public class NotificationConsumer {

    @RetryableTopic(
        attempts = "3",                          // 최대 3회 재시도
        backoff = @Backoff(delay = 1000, multiplier = 2.0), // 지수 백오프
        dltTopicSuffix = ".dlt"                 // 실패 시 order.placed.dlt 토픽으로 이동
    )
    @KafkaListener(topics = "order.placed", groupId = "notification-service")
    public void handleOrderPlaced(OrderPlacedEvent event) {
        notificationService.sendEmail(event.getUserId(), event.getOrderId());
    }

    // DLQ 전용 Consumer — 알림, 수동 재처리 등 후속 조치
    @DltHandler
    public void handleDlt(OrderPlacedEvent event) {
        log.error("이벤트 최종 처리 실패, DLQ 기록: orderId={}", event.getOrderId());
        alertOpsTeam(event); // 운영팀 알림
    }
}

DLQ 패턴을 적용하면 일시적인 외부 서비스 장애가 전체 이벤트 파이프라인을 멈추지 않습니다. 장애 복원력을 높이는 핵심 아키텍처 패턴입니다.


맺음말

Event-Driven Architecture는 서비스 간 직접 의존성을 제거하고 각 컴포넌트가 독립적으로 확장될 수 있는 구조를 만들어 줍니다. 핵심 설계 원칙을 정리하면 다음과 같습니다.

  1. 이벤트는 과거형으로 명명하세요: OrderPlaced, PaymentCompleted처럼 이미 발생한 사실을 표현해야 Consumer가 의미를 명확히 이해합니다.
  2. 멱등성은 선택이 아닌 필수입니다: 네트워크 재시도는 언제든 발생하며, Consumer는 항상 중복 이벤트를 처리할 준비가 되어 있어야 합니다.
  3. DLQ로 이벤트 유실을 방지하세요: 단순 로그로 끝내지 않고 반드시 재처리 경로를 설계해야 합니다.

EDA가 적합한 상황은 서비스 간 결합도를 낮춰야 할 때, 트래픽 급증에 대비한 확장성이 필요할 때, 또는 여러 서비스가 동일 이벤트에 반응해야 할 때입니다. 반면 트랜잭션 일관성이 엄격하게 요구되는 소규모 모놀리식 환경에서는 오히려 복잡도가 증가할 수 있습니다.
관련 심화 주제로 Saga 패턴을 통한 분산 트랜잭션 처리, Event Sourcing을 통한 상태 이력 관리를 함께 학습하면 EDA 기반 아키텍처를 더욱 견고하게 설계할 수 있습니다.
참고 자료

반응형