Spring BootArchitectureKafkaEvent-Driven

서비스 간 결합도를 낮추기 위한 이벤트 기반 아키텍처 도입기

마이크로서비스 환경에서 서비스 간 강한 결합을 끊기 위해 Spring ApplicationEvent에서 시작해 Kafka로 확장한 실전 경험을 공유합니다.

Srue2026년 4월 14일
서비스 간 결합도를 낮추기 위한 이벤트 기반 아키텍처 도입기

주문이 완료되면 알림톡을 보내고, 포인트를 적립하고, 재고를 차감해야 했습니다. 처음에는 OrderService 안에서 이 세 가지를 순서대로 호출하면 되겠거니 생각했습니다.

OrderService.java (변경 전)
@Transactional
public void completeOrder(Long orderId) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    order.complete();
 
    notificationService.sendKakao(order.getUserId(), "주문 완료!");
    pointService.accumulatePoints(order.getUserId(), order.getAmount());
    stockService.decrease(order.getProductId(), order.getQuantity());
}

코드 자체는 틀린 게 없었습니다. 하지만 서비스가 성장하면서 문제가 하나씩 모습을 드러냈습니다. 카카오 알림톡 API가 타임아웃 나면 주문 완료 자체가 롤백되었고, 포인트 적립 로직을 수정하려면 주문 도메인 코드를 열어야 했습니다. 이전에 정리했던 멀티 모듈 아키텍처로 패키지는 분리해 두었지만, 런타임에서의 결합은 여전히 단단했습니다.

"주문 서비스는 주문이 완료되었다는 사실만 알리면 되지, 그 뒤에 누가 뭘 하는지까지 알 필요가 있을까?" 라는 의문이 들기 시작했습니다.

Spring ApplicationEvent: 같은 JVM 안에서의 첫 시도

이벤트 기반 아키텍처(Event-Driven Architecture)를 한 번에 Kafka로 도입하기엔 인프라 부담이 컸습니다. 그래서 Spring Framework에 내장된 ApplicationEvent 메커니즘부터 시작했습니다. 별도의 의존성 없이 이벤트 발행(Publish)과 구독(Subscribe)을 분리할 수 있다는 점이 매력적이었습니다.

이벤트 정의와 발행

먼저 "주문이 완료되었다"는 사실을 담는 이벤트 클래스를 만들었습니다.

OrderCompletedEvent.java
@Getter
@RequiredArgsConstructor
public class OrderCompletedEvent {
    private final Long orderId;
    private final Long userId;
    private final Long productId;
    private final int quantity;
    private final BigDecimal amount;
}

그리고 OrderService에서는 더 이상 후속 서비스를 직접 호출하지 않고, 이벤트만 발행하도록 변경했습니다.

OrderService.java (변경 후)
@Transactional
public void completeOrder(Long orderId) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    order.complete();
 
    applicationEventPublisher.publishEvent(
        new OrderCompletedEvent(
            order.getId(), order.getUserId(),
            order.getProductId(), order.getQuantity(), order.getAmount()
        )
    );
}

이벤트 리스너 분리

각 후속 처리는 독립적인 리스너로 분리했습니다.

OrderEventListener.java
@Component
@RequiredArgsConstructor
public class OrderEventListener {
 
    private final NotificationService notificationService;
    private final PointService pointService;
    private final StockService stockService;
 
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleNotification(OrderCompletedEvent event) {
        notificationService.sendKakao(event.getUserId(), "주문 완료!");
    }
 
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handlePointAccumulation(OrderCompletedEvent event) {
        pointService.accumulatePoints(event.getUserId(), event.getAmount());
    }
 
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleStockDecrease(OrderCompletedEvent event) {
        stockService.decrease(event.getProductId(), event.getQuantity());
    }
}

여기서 핵심은 @EventListener가 아니라 @TransactionalEventListener를 사용했다는 점입니다. phase = AFTER_COMMIT을 지정하면 주문 트랜잭션이 성공적으로 커밋된 이후에만 리스너가 동작합니다. 이전에 트랜잭션과 동시성 노트에서 경험했듯, 커밋 전에 후속 작업이 끼어들면 데이터 정합성이 깨질 수 있기 때문입니다.

Spring Event의 한계를 느끼다

단일 서버 환경에서는 훌륭하게 동작했습니다. OrderService는 더 이상 알림이나 포인트 로직을 모르고, 새로운 후속 처리(예: 쿠폰 발급)를 추가할 때도 리스너 하나만 등록하면 되었습니다.

그러나 서버가 2대 이상으로 늘어나면서 문제가 생겼습니다.

  1. 이벤트 유실: ApplicationEvent는 같은 JVM 안에서만 전파됩니다. 이벤트를 발행한 서버 인스턴스가 리스너 실행 도중 죽으면 이벤트는 영영 사라집니다.
  2. 서비스 분리 불가: 알림 서비스를 별도 마이크로서비스로 떼어내고 싶었지만, 같은 JVM이 아니면 이벤트를 수신할 방법이 없었습니다.
  3. 재처리 불가: 포인트 적립이 실패해도 "어떤 이벤트가 실패했는지" 추적하고 재시도할 메커니즘이 없었습니다.

결국, 서비스 간 경계를 넘나드는 이벤트 버스가 필요했습니다.

Kafka 도입: 프로세스 경계를 넘는 이벤트

메시지 브로커(Message Broker) 후보로 RabbitMQ와 Apache Kafka를 비교했습니다.

항목RabbitMQKafka
메시지 보존소비 후 삭제디스크에 보존 (기간 설정)
처리량중간초당 수십만 건 이상
재처리어려움 (DLQ 활용)Offset 기반으로 자유로움
순서 보장큐 단위파티션 단위
러닝 커브낮음중간~높음

저희 시스템은 주문, 결제 등 하루 수만 건의 이벤트가 발생했고, 이벤트 유실 시 재처리가 반드시 가능해야 했습니다. Kafka의 "로그 기반 메시지 보존"과 "Offset 리플레이"가 결정적이었습니다.

Producer 구성

Spring Kafka를 활용해 기존 ApplicationEvent 발행 코드를 최소한으로 수정했습니다. 핵심은 도메인 이벤트 발행 로직을 건드리지 않고, 이벤트 리스너가 Kafka로 중계하는 구조를 택한 것입니다.

KafkaOrderEventRelay.java
@Component
@RequiredArgsConstructor
public class KafkaOrderEventRelay {
 
    private final KafkaTemplate<String, Object> kafkaTemplate;
 
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void relay(OrderCompletedEvent event) {
        kafkaTemplate.send(
            "order.completed",
            String.valueOf(event.getOrderId()),
            toPayload(event)
        );
    }
 
    private OrderCompletedPayload toPayload(OrderCompletedEvent event) {
        return new OrderCompletedPayload(
            event.getOrderId(), event.getUserId(),
            event.getProductId(), event.getQuantity(),
            event.getAmount(), Instant.now()
        );
    }
}

ApplicationEvent는 JVM 내부에서 "이벤트가 발생했다"는 신호 역할을 유지하고, Kafka Producer가 이를 받아 외부로 전파하는 이중 구조입니다. 이렇게 하면 나중에 Kafka를 걷어내더라도 도메인 코드는 전혀 영향을 받지 않습니다.

Consumer 구성

별도 마이크로서비스(알림 서비스)에서 Kafka 메시지를 수신하도록 Consumer를 구성했습니다.

NotificationKafkaConsumer.java
@Component
@RequiredArgsConstructor
@Slf4j
public class NotificationKafkaConsumer {
 
    private final NotificationService notificationService;
 
    @KafkaListener(
        topics = "order.completed",
        groupId = "notification-service",
        containerFactory = "kafkaListenerContainerFactory"
    )
    public void consume(OrderCompletedPayload payload) {
        log.info("주문 완료 이벤트 수신: orderId={}", payload.getOrderId());
        notificationService.sendKakao(payload.getUserId(), "주문 완료!");
    }
}

포인트 서비스, 재고 서비스도 각각의 groupId로 같은 토픽을 구독합니다. Kafka의 Consumer Group 덕분에 각 서비스가 독립적으로 메시지를 소비하면서도 중복 처리 없이 정확히 한 번씩 받을 수 있었습니다.

실패 처리와 재시도 전략

Kafka 도입 후 가장 신경 쓴 부분은 "Consumer가 실패하면 어떻게 할 것인가"였습니다. 알림톡 API가 일시적으로 불안정한 상황에서 메시지를 그냥 버릴 수는 없었습니다.

KafkaConsumerConfig.java
@Bean
public ConcurrentKafkaListenerContainerFactory<String, Object> kafkaListenerContainerFactory() {
    ConcurrentKafkaListenerContainerFactory<String, Object> factory =
        new ConcurrentKafkaListenerContainerFactory<>();
    factory.setConsumerFactory(consumerFactory());
 
    // 최대 3회 재시도, 1초 간격
    factory.setCommonErrorHandler(
        new DefaultErrorHandler(
            new DeadLetterPublishingRecoverer(kafkaTemplate),
            new FixedBackOff(1000L, 3)
        )
    );
 
    return factory;
}

3회 재시도 후에도 실패하면 DLT(Dead Letter Topic, order.completed.DLT)로 메시지가 이동합니다. 운영팀은 DLT를 모니터링하다가 원인을 파악한 뒤 수동 또는 자동으로 재처리할 수 있습니다. 이 부분은 Sentry 도입기에서 구축했던 알림 채널과 연동해서 DLT 적재 시 즉시 슬랙 알림이 오도록 구성했습니다.

Before & After 비교

항목변경 전 (직접 호출)변경 후 (이벤트 기반)
서비스 결합도OrderService가 5개 서비스 직접 참조OrderService는 이벤트만 발행
장애 전파알림 API 타임아웃 시 주문 롤백후속 처리 실패가 주문에 영향 없음
신규 후속 처리 추가OrderService 코드 수정 필요리스너(Consumer) 추가만으로 완료
이벤트 유실서버 다운 시 유실Kafka 디스크 보존 + DLT 재처리
독립 배포불가 (같은 JVM)서비스별 독립 배포 가능
디버깅동기 호출이라 스택트레이스 명확비동기라 추적 복잡 (Trace ID 필수)

마지막 항목이 중요합니다. 이벤트 기반으로 전환하면 디버깅이 어려워지는 건 사실입니다. 그래서 모든 Kafka 메시지 헤더에 traceId를 심어 운영 로그 구조에서 정리했던 방식대로 요청 흐름을 추적할 수 있게 했습니다.

도입 과정에서 만난 함정들

이벤트 순서 보장

주문 완료 후 취소가 빠르게 연달아 발생하면, Consumer 쪽에서 취소 이벤트가 완료 이벤트보다 먼저 처리되는 상황이 생겼습니다. Kafka는 같은 파티션 내에서만 순서를 보장하므로, 주문 ID를 메시지 키로 사용해 같은 주문의 이벤트가 반드시 같은 파티션으로 들어가도록 했습니다.

멱등성(Idempotency) 확보

네트워크 이슈로 같은 메시지가 두 번 전달되는 상황(At-Least-Once)에 대비해, Consumer 측에서 eventId를 기반으로 중복 처리를 차단했습니다. Redis에 처리 완료된 eventId를 짧은 TTL로 캐싱하는 단순한 방식이지만, 포인트가 이중 적립되는 사고를 막아주었습니다.

트랜잭션 아웃박스 패턴 검토

DB 커밋은 되었는데 Kafka 발행이 실패하는 시나리오도 고려해야 했습니다. 이를 위해 Transactional Outbox 패턴을 검토했습니다. 이벤트를 Kafka로 바로 보내는 대신, 같은 트랜잭션 안에서 outbox 테이블에 이벤트를 저장하고, 별도 폴링(Polling) 배치가 이를 읽어 Kafka로 발행하는 구조입니다. 완벽한 정합성이 필요한 결제 도메인에 한정해서 적용했고, 나머지는 At-Least-Once + 멱등성 조합으로 충분했습니다.

마무리

처음에는 OrderService 안에 세 줄의 서비스 호출이 전부였던 코드가, 이벤트 정의, Producer, Consumer, DLT, 멱등성 처리까지 꽤 많은 코드로 늘어났습니다. 단순히 코드량만 보면 "이게 더 복잡해진 거 아닌가?"라는 생각이 들 수 있습니다.

하지만 6개월간 운영하면서 체감한 건, 복잡해진 건 인프라이고 단순해진 건 각 서비스의 책임이라는 점이었습니다. 주문 서비스 개발자는 주문만 생각하면 되고, 알림 서비스 개발자는 어떤 이벤트를 수신해서 어떤 메시지를 보낼지만 고민하면 됩니다. 서로의 코드를 열어볼 일이 사라졌습니다.

한 가지 분명히 배운 교훈이 있습니다. 이벤트 기반 아키텍처는 "도입하면 좋은 기술"이 아니라, "서비스 간 결합이 실제로 고통을 주는 시점"에 도입해야 의미가 있다는 것입니다. Spring ApplicationEvent로 시작해 충분히 패턴에 익숙해진 뒤 Kafka로 확장한 순서가, 돌아가는 것 같았지만 결과적으로 가장 안전한 길이었습니다.