마이크로서비스와 Apache Kafka — Spring Boot로 부서 쪼개기

2026-05-02AWS SAA-C03 스터디

Spring Boot 3 핵심 정리 시리즈 16편. 모놀리식을 작은 부서별 사업부로 쪼개는 마이크로서비스 아키텍처, 운영 12계명 12-Factor App, EIP 메시지 흐름 패턴(Splitter·Router·Aggregator), 그리고 Spring Kafka로 비동기 메시지 통신을 구현하는 Producer/Consumer/EmbeddedKafka 테스트까지 회사 부서 비유로 친절하게 풀어쓴 16편.

📚 Spring Boot 3 핵심 정리 · 16편 / 14편 — Spring Boot로 부서 쪼개기

이 글은 Spring Boot 3 핵심 정리 시리즈의 16편입니다. 15편까지 따라오셨다면 우리는 이미 한 애플리케이션을 컨테이너에 담아 어디든 옮기고 K8s로 자동 운영할 수 있는 단계에 와 있어요. 이번 16편은 한 발 더 나아갑니다 — 그 애플리케이션 자체를 어떻게 잘게 쪼개고, 서로 어떻게 연결할 것인가.

핵심은 두 가지예요. 하나는 마이크로서비스 아키텍처(MSA), 다른 하나는 그 사이를 잇는 Apache Kafka. MSA가 "회사를 부서별로 쪼개는 결정"이라면, Kafka는 "부서 사이에 흐르는 사내 메시지 시스템"입니다.

왜 마이크로서비스가 처음엔 어렵게 느껴질까요

이유는 세 가지예요.

첫째, 모놀리식이 멀쩡히 돌아가고 있는데 왜 굳이 쪼개야 하나 싶습니다. 작은 회사에서는 모든 기능이 한 코드베이스에 있는 게 오히려 더 빠르고 단순해요. MSA의 진짜 가치는 회사가 커진 뒤에 드러납니다.

둘째, 새로운 단어가 너무 많아요. 12-Factor App, EIP, Saga, Choreography, Producer, Consumer Group, Partition, Offset — 한 문단 안에 이 용어들이 다 등장하면 머리가 어지러워집니다.

셋째, 분산 시스템의 기본 가정이 바뀌어요. 같은 프로세스 안에서는 메서드 호출이 거의 무조건 성공하지만, 네트워크 너머의 서비스 호출은 언제든 실패할 수 있어요. 트랜잭션도 한 DB 안에서는 자동인데, 여러 서비스에 걸치면 보상 처리를 직접 설계해야 합니다.

해결법은 한 가지 비유예요. 마이크로서비스는 "회사를 부서별로 작게 쪼갠 독립 사업부" 입니다. 영업부·재무부·인사부가 각자 자기 시스템을 가지고 자기 결정을 내리는 거예요. 부서끼리는 사내 메일과 회의로 협업하고, 그 사내 메일이 바로 Kafka 같은 메시지 브로커입니다. 이 비유 한 가지만 잡고 가면 흐름이 자연스럽게 따라옵니다.

모놀리식의 한계 — 왜 부서를 쪼개야 하나요

전통적인 모놀리식(Monolithic) 애플리케이션은 모든 기능이 하나의 코드베이스와 배포 단위 안에 있습니다. 초기에는 단순하지만 애플리케이션이 성장하면서 다음 문제가 나타나요.

  • 배포 위험 — 작은 기능 하나만 바꿔도 전체 앱을 재배포해야 합니다
  • 확장 비효율 — 특정 기능에만 부하가 몰려도 전체 앱을 스케일업해야 해요
  • 기술 스택 고착 — 일부를 새로운 기술로 교체하기 매우 어렵습니다
  • 개발 속도 저하 — 팀이 커질수록 코드 충돌과 협업 비용이 폭증해요

회사가 커지면 한 부서가 모든 일을 다 처리하는 게 비효율이 되는 것과 같아요. 영업·재무·인사·총무를 분리하고, 각 부서가 자기 일을 책임지게 하는 게 합리적입니다.

마이크로서비스 아키텍처란 — 독립 사업부 비유

마이크로서비스(Microservices)는 작고 독립적인 서비스들이 서로 협력하여 전체 시스템을 구성하는 아키텍처 스타일이에요. 각 서비스는 특정 비즈니스 기능(예: 주문, 사용자, 결제)에 집중하며, 독립적으로 개발·테스트·배포할 수 있습니다.

각 서비스는 한 부서처럼 자기만의 데이터베이스를 갖고, 자기만의 배포 주기를 가지며, 자기만의 기술 스택까지 고를 수 있어요. 부서 A는 Spring Boot로, 부서 B는 Node.js로 만들어도 사내 메일(Kafka 같은 메시지 브로커)만 통하면 협업이 가능합니다.

다만 이 자유가 공짜는 아니에요. 모놀리식보다 운영 복잡도가 훨씬 높아지고, 분산 환경 특유의 함정(네트워크 실패, 분산 트랜잭션 등)이 새로 등장합니다. 그래서 마이크로서비스는 "회사가 충분히 큰 뒤에" 도입하는 패턴이에요.

12-Factor App — 마이크로서비스 운영 12계명

마이크로서비스를 제대로 운영하려면 운영 원칙이 먼저 잡혀야 해요. 그 원칙을 한 묶음으로 정리한 게 12-Factor App 방법론입니다. 헤로쿠(Heroku) 개발자들이 대규모 SaaS 운영 경험을 바탕으로 정립한 12가지 원칙이에요. 자세한 내용은 12factor.net에서 확인할 수 있습니다.

Factor원칙Spring Boot 적용
1. Codebase단일 코드베이스, 다중 배포Git 단일 레포
2. Dependencies의존성 명시적 선언Maven/Gradle pom.xml
3. Config환경 변수로 설정 관리application.properties, 환경 변수
4. Backing Services연결 가능한 리소스로 취급DataSource, Kafka 설정 분리
5. Build, Release, Run빌드/릴리스/실행 단계 분리Docker 이미지 빌드 → 배포
6. Processes무상태(Stateless) 프로세스SessionState 외부화
7. Port Binding포트 바인딩으로 서비스 노출Spring Boot 내장 톰캣
8. Concurrency프로세스 모델로 스케일아웃Kubernetes Deployment replicas
9. Disposability빠른 시작과 그레이스풀 종료Spring Boot graceful shutdown
10. Dev/Prod Parity환경 일치Docker로 환경 표준화
11. Logs로그를 이벤트 스트림으로 처리중앙 로그 집계 (ELK)
12. Admin Processes관리 프로세스를 일회성 작업으로Spring Batch, actuator

12개를 다 외울 필요는 없지만 3번(설정 외부화)과 6번(무상태) 만큼은 마이크로서비스의 핵심이에요. 이 둘을 어기면 컨테이너로 자유롭게 스케일아웃하기가 어려워집니다.

# Factor 3 — 설정을 환경 변수로 관리 (application.properties)
spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/orderdb}
spring.datasource.username=${SPRING_DATASOURCE_USERNAME:orderadmin}
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:password}
spring.kafka.bootstrap-servers=${SPRING_KAFKA_BOOTSTRAP_SERVERS:localhost:9092}

같은 이미지를 dev·staging·prod 어느 환경에 띄워도 환경 변수만 다르게 주입하면 동작이 바뀝니다. 15편에서 다룬 K8s ConfigMap·Secret이 이 원칙을 그대로 구현한 거예요.

리액티브 선언문 — 분산 시스템의 4가지 속성

리액티브 선언문(Reactive Manifesto)은 현대적인 분산 시스템이 갖춰야 할 4가지 핵심 속성을 정의합니다.

Reactive System
    │
    ├── 응답성 (Responsive)
    │       시스템은 가능한 한 빠르게, 일관되게 응답해야 함
    │       사용자 경험의 근본이자 모든 속성의 전제 조건
    │
    ├── 회복성 (Resilient)
    │       장애가 발생해도 시스템은 계속 응답 가능한 상태 유지
    │       복제(replication), 격리(isolation)를 통해 구현
    │
    ├── 신축성 (Elastic)
    │       부하 변화에 따라 자원을 동적으로 확장/축소
    │       Kubernetes HPA, AWS Auto Scaling 등으로 구현
    │
    └── 메시지 기반 (Message-Driven)
            비동기 메시지 전달로 컴포넌트 간 결합도 낮춤
            Apache Kafka, RabbitMQ 등으로 구현

이 네 속성은 서로 떠받쳐 줍니다. 메시지 기반이면 결합도가 낮아 회복성이 좋아지고, 회복성이 좋으면 응답성이 일관되고, 신축성으로 부하에 따라 응답성을 유지할 수 있어요.

EIP — 메시지 흐름의 표준 패턴

마이크로서비스를 비동기 메시지로 잇기 시작하면 새로운 설계 문제가 등장해요. "큰 주문을 어떻게 여러 작은 주문으로 쪼갤까", "처리 결과를 어떻게 다시 모을까", "실패한 메시지는 어디로 보낼까" 같은 문제들이에요.

이런 공통 문제를 검증된 패턴으로 정리한 게 EIP(Enterprise Integration Patterns) 입니다. 메시지 기반 분산 시스템 설계의 고전이에요.

주요 EIP 패턴
├── Message Channel: 메시지가 이동하는 통로 (Kafka Topic, Queue)
├── Message: 데이터를 운반하는 객체 (Header + Payload)
├── Message Router: 조건에 따라 다른 채널로 메시지 전달
├── Message Transformer: 메시지 형식 변환 (JSON↔POJO 등)
├── Message Filter: 조건을 충족하는 메시지만 통과
├── Splitter: 하나의 메시지를 여러 개로 분할
├── Aggregator: 여러 메시지를 하나로 합산
├── Correlation Identifier: 요청과 응답을 연결하는 식별자
└── Dead Letter Channel: 처리 실패한 메시지를 보관

여기서 추상도가 한 단계 올라가니, 친숙한 비유로 풀어 봅시다. 카페 주문 처리 흐름을 예로 들면 EIP가 한 번에 잡혀요.

고객 주문 (Producer)
    │
    ▼ [Message]
[ 주문 줄 ] ← Message Channel (주문 큐)
    │
    ▼ [Splitter] 큰 주문 → 개별 음료로 분할
[ 아메리카노 ] [ 카페라떼 ] [ 에스프레소 ]
    │             │            │
    ▼ [Router]   ▼            ▼
[에스프레소 바리스타] [밀크 바리스타] [에스프레소 바리스타]
(경쟁 소비자 패턴)
    │             │            │
    ▼ [Aggregator]
[ 완성된 주문 묶음 ] ← 고객 이름(Correlation Identifier)으로 매핑
    │
    ▼
고객 호명 → 음료 전달

큰 주문을 받아 음료별로 쪼개고(Splitter), 음료 종류에 따라 적절한 바리스타에게 보내고(Router), 완성된 음료들을 다시 한 손님 단위로 묶어(Aggregator), 고객 이름(Correlation Identifier)으로 매칭해 전달하는 흐름이에요. 이 패턴들이 Kafka 위에서 그대로 구현됩니다.

Apache Kafka — 사내 메시지 흐름의 핵심

Apache Kafka는 대용량 실시간 데이터 파이프라인과 스트리밍 애플리케이션을 위한 분산 이벤트 스트리밍 플랫폼이에요. LinkedIn이 개발해 2011년 오픈소스로 공개했고, 현재 전 세계 수천 개 기업이 사용합니다. 자세한 사양은 Apache Kafka 공식 문서에서 확인할 수 있어요.

핵심 특징을 한 줄씩 정리하면:

  • 초당 수백만 메시지 처리 (높은 처리량)
  • 메시지를 디스크에 영속적으로 저장 (보존 기간 설정 가능)
  • 브로커 추가로 선형적 스케일아웃
  • 데이터 복제로 내결함성(Fault Tolerance) 보장
  • 10ms 미만의 낮은 지연 시간

회사 비유로 — Kafka는 "사내 게시판 + 사내 우편 보관소" 결합형이에요. 누가 공지를 게시판에 붙이면 거기 보관되고, 관심 있는 부서가 읽고 가도 공지는 그대로 남아 있어 다른 부서도 읽을 수 있습니다. 그리고 보관 기간을 설정해 일정 기간 후 자동 삭제할 수도 있어요.

Kafka 핵심 개념

Kafka Cluster
├── Broker 1 ──── Broker 2 ──── Broker 3 (복제로 고가용성)
│
├── Topic: "order-placed"
│   ├── Partition 0 ── [msg1][msg5][msg9]...
│   ├── Partition 1 ── [msg2][msg6][msg10]...
│   └── Partition 2 ── [msg3][msg7][msg11]...
│
├── Producer: 메시지 발행자 (Spring 애플리케이션)
│
└── Consumer Group: "order-processor-group"
    ├── Consumer 1 ← Partition 0
    ├── Consumer 2 ← Partition 1
    └── Consumer 3 ← Partition 2

용어가 많지만 한 번에 정리하면:

  • Topic = 메시지의 카테고리 (사내 게시판의 한 게시판 분류)
  • Partition = 토픽의 물리적 분할 단위, 병렬 처리 기반
  • Offset = 파티션 내 메시지의 순서 위치 (소비자가 어디까지 읽었는지 추적)
  • Consumer Group = 같은 토픽을 나눠서 처리하는 소비자 묶음 (한 파티션은 그룹 내 한 소비자만 담당)
  • Broker = Kafka 서버, 메시지를 저장하고 전달
  • Zookeeper / KRaft = 클러스터 메타데이터 관리 (KRaft가 최신 방식)

여기서 시험 함정이 하나 있어요. 한 파티션은 한 Consumer Group 내에서 한 소비자만 담당합니다. 그래서 파티션 수보다 소비자 수가 많으면 일부 소비자는 놀게 돼요. 처리량을 늘리고 싶으면 파티션 수를 먼저 늘려야 합니다.

Spring Boot + Kafka 설정

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>

<!-- 테스트용 내장 Kafka -->
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka-test</artifactId>
    <scope>test</scope>
</dependency>
# application.properties — Kafka 기본 설정
spring.kafka.bootstrap-servers=localhost:9092

# Producer 설정
spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer
spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer

# Consumer 설정
spring.kafka.consumer.group-id=order-service-group
spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer
spring.kafka.consumer.value-deserializer=org.springframework.kafka.support.serializer.JsonDeserializer
spring.kafka.consumer.auto-offset-reset=earliest
spring.kafka.consumer.properties.spring.json.trusted.packages=*

여기서 정말 중요한 시험 함정 — spring.kafka.consumer.group-id를 명시하지 않으면 재시작할 때마다 처음부터 메시지를 읽을 위험이 있어요. 그룹 ID가 있어야 Kafka가 "이 그룹은 어디까지 읽었다"는 offset을 추적해 줍니다.

Kafka Topic 설정 — 상수로 관리

@Configuration
public class KafkaTopicConfig {

    // 토픽 이름은 반드시 상수로 — 오타 방지
    public static final String ORDER_PLACED_TOPIC = "order-placed";
    public static final String ORDER_ALLOCATED_TOPIC = "order-allocated";
    public static final String ORDER_REQUEST_TOPIC = "order-request";
    public static final String ORDER_RESPONSE_TOPIC = "order-response";

    @Bean
    public NewTopic orderPlacedTopic() {
        return TopicBuilder.name(ORDER_PLACED_TOPIC)
                .partitions(3)      // 3개 파티션으로 병렬 처리
                .replicas(1)        // 개발: 1, 프로덕션: 3 이상 권장
                .build();
    }

    @Bean
    public NewTopic orderRequestTopic() {
        return TopicBuilder.name(ORDER_REQUEST_TOPIC)
                .partitions(3)
                .replicas(1)
                .config(TopicConfig.RETENTION_MS_CONFIG, "3600000")  // 1시간 보존
                .build();
    }
}

토픽 이름을 문자열 리터럴로 바로 쓰는 건 위험해요. "order-placed"를 어디선가 "oder-placed"로 오타 내면 컴파일러가 잡아 주지 못합니다. 항상 상수로 관리하세요.

Kafka Producer — 메시지 발행

Producer는 사내 게시판에 공지를 붙이는 역할이에요. Spring Kafka에선 KafkaTemplate을 주입받아 send()로 발행합니다. 14편에서 다룬 Spring Application Event를 Kafka로 다시 발행하는 패턴이 자주 등장해요.

@Component
@RequiredArgsConstructor
@Slf4j
public class OrderPlacedEventListener {

    private final KafkaTemplate<String, OrderPlacedEvent> kafkaTemplate;

    /**
     * Spring Application Event를 받아 Kafka 메시지로 변환해 발행
     * ApplicationEvent → Kafka Message 변환의 좋은 예
     */
    @EventListener
    public void listenForOrderPlaced(OrderPlacedEvent event) {
        log.debug("Sending OrderPlacedEvent to Kafka: {}", event.getOrder().getId());

        // 비동기 전송 — 결과를 CompletableFuture로 받음
        CompletableFuture<SendResult<String, OrderPlacedEvent>> future =
                kafkaTemplate.send(
                        KafkaTopicConfig.ORDER_PLACED_TOPIC,        // 토픽
                        event.getOrder().getId().toString(),         // 키 (파티션 라우팅)
                        event                                        // 페이로드
                );

        // 전송 결과 처리 (선택적)
        future.whenComplete((result, ex) -> {
            if (ex == null) {
                log.debug("Message sent successfully: topic={}, offset={}",
                        result.getRecordMetadata().topic(),
                        result.getRecordMetadata().offset());
            } else {
                log.error("Failed to send message: {}", ex.getMessage());
            }
        });
    }
}

키(Key) 는 단순한 식별자가 아니에요. 같은 키를 가진 메시지는 항상 같은 파티션으로 갑니다. 이 점이 중요한 이유 — 같은 주문 ID에 대한 여러 메시지가 같은 파티션에 들어가면 처리 순서가 보장돼요. 주문 생성 → 결제 → 출고 메시지가 순서를 유지해야 한다면, 키를 주문 ID로 통일하면 됩니다.

Kafka Consumer — @KafkaListener

Consumer는 사내 게시판을 구독해 자기 부서의 일을 가져가는 역할이에요. @KafkaListener 어노테이션 한 줄로 끝납니다.

@Component
@RequiredArgsConstructor
@Slf4j
public class OrderAllocationKafkaListener {

    private final OrderAllocationService allocationService;
    private final KafkaTemplate<String, OrderDTO> kafkaTemplate;

    /**
     * Kafka 메시지 소비 — @KafkaListener
     * topics: 구독할 토픽 목록
     * groupId: 소비자 그룹 ID
     */
    @KafkaListener(
            topics = KafkaTopicConfig.ORDER_REQUEST_TOPIC,
            groupId = "order-allocation-processor"
    )
    public void listenForOrderRequest(OrderDTO orderDTO) {
        log.debug("Processing order request: {}", orderDTO.getId());

        try {
            // 주문 처리
            OrderDTO processedOrder = allocationService.allocateOrder(orderDTO);

            // 처리 결과를 응답 토픽으로 발행
            kafkaTemplate.send(
                    KafkaTopicConfig.ORDER_RESPONSE_TOPIC,
                    processedOrder.getId().toString(),
                    processedOrder
            );
        } catch (Exception e) {
            log.error("Failed to process order: {}", orderDTO.getId(), e);
            // Dead Letter Topic으로 보내거나 재처리 로직
        }
    }

    /**
     * 복수 메시지를 배치(batch)로 처리
     */
    @KafkaListener(
            topics = "order-batch",
            groupId = "batch-processor",
            containerFactory = "batchFactory"
    )
    public void listenBatch(List<OrderDTO> orders) {
        log.info("Processing batch of {} orders", orders.size());
        orders.forEach(order -> processOrder(order));
    }
}

고급 소비자 설정 — 에러·재시도·DLQ

프로덕션에선 메시지 처리가 실패할 때를 대비해 재시도 + Dead Letter Queue(DLQ) 를 설정해야 해요.

@Configuration
public class KafkaConsumerConfig {

    @Bean
    public ConcurrentKafkaListenerContainerFactory<String, OrderDTO>
    kafkaListenerContainerFactory(ConsumerFactory<String, OrderDTO> consumerFactory,
                                  KafkaTemplate<String, OrderDTO> kafkaTemplate) {

        ConcurrentKafkaListenerContainerFactory<String, OrderDTO> factory =
                new ConcurrentKafkaListenerContainerFactory<>();

        factory.setConsumerFactory(consumerFactory);
        factory.setConcurrency(3);  // 3개의 스레드로 병렬 소비

        // 에러 핸들러 — 재시도 + DLQ
        factory.setCommonErrorHandler(new DefaultErrorHandler(
                new DeadLetterPublishingRecoverer(kafkaTemplate),  // DLQ로 전송
                new FixedBackOff(1000L, 3)  // 1초 간격으로 3번 재시도
        ));

        return factory;
    }
}

EIP 패턴 구현 — Splitter / Router / Aggregator

EIP를 Kafka로 구현하면 마이크로서비스 사이의 메시지 흐름을 깔끔하게 설계할 수 있어요. 카페 주문 비유에서 풀었던 흐름을 코드로 옮겨 봅시다.

Splitter 패턴 — 큰 주문을 음료별로 쪼개기

@Service
@RequiredArgsConstructor
@Slf4j
public class OrderSplitter {

    private final KafkaTemplate<String, OrderLineWithOrderDTO> kafkaTemplate;

    /**
     * 주문을 받아 각 음료 라인별로 Kafka 메시지 발행
     */
    @KafkaListener(topics = KafkaTopicConfig.ORDER_REQUEST_TOPIC)
    public void splitOrder(OrderDTO order) {
        log.debug("Splitting order: {} into {} lines",
                order.getId(), order.getOrderLines().size());

        order.getOrderLines().forEach(line -> {
            // 각 음료 라인을 별도 메시지로 발행
            OrderLineWithOrderDTO lineWithOrder = OrderLineWithOrderDTO.builder()
                    .orderId(order.getId())
                    .customerRef(order.getCustomerRef())
                    .orderLine(line)
                    .build();

            // 음료 종류에 따라 다른 토픽으로 라우팅 (Router 패턴 결합)
            String targetTopic = getTopicByCategory(line.getCategory());
            kafkaTemplate.send(targetTopic, order.getId().toString(), lineWithOrder);
        });
    }

    private String getTopicByCategory(ProductCategory category) {
        return switch (category) {
            case ESPRESSO_BASED, COFFEE -> KafkaTopicConfig.ESPRESSO_REQUESTS_TOPIC;
            case TEA, COLD_BREW -> KafkaTopicConfig.COLD_DRINK_REQUESTS_TOPIC;
            default -> KafkaTopicConfig.GENERAL_REQUESTS_TOPIC;
        };
    }
}

Aggregator 패턴 — 분산 처리 결과를 한 주문으로 모으기

@Service
@RequiredArgsConstructor
@Slf4j
public class OrderAggregator {

    private final OrderRepository orderRepository;
    private final Map<UUID, List<OrderLineDTO>> orderLineBuffer = new ConcurrentHashMap<>();

    /**
     * 처리 완료된 음료 라인을 수신해 주문 단위로 집계
     */
    @KafkaListener(topics = KafkaTopicConfig.ORDER_RESPONSE_TOPIC)
    public void aggregateOrderLine(OrderLineDTO completedLine) {
        UUID orderId = completedLine.getOrderId();

        // Correlation Identifier로 같은 주문의 라인들을 모음
        orderLineBuffer.computeIfAbsent(orderId, k -> new ArrayList<>()).add(completedLine);

        // 원래 주문의 전체 라인 수와 집계된 라인 수 비교
        Order originalOrder = orderRepository.findById(orderId).orElseThrow();
        List<OrderLineDTO> bufferedLines = orderLineBuffer.get(orderId);

        if (bufferedLines.size() == originalOrder.getOrderLines().size()) {
            // 모든 라인 처리 완료 → 주문 상태 업데이트
            log.info("All lines processed for order: {}", orderId);
            updateOrderStatus(orderId, OrderStatus.ALLOCATED, bufferedLines);
            orderLineBuffer.remove(orderId);
        }
    }
}

이게 Correlation Identifier 패턴이에요. 분산된 처리 결과를 같은 주문으로 다시 묶을 때 주문 ID가 그 식별자 역할을 합니다.

공통 라이브러리 — Shared DTO

여러 마이크로서비스가 같은 DTO를 주고받는다면, 그 DTO를 공통 라이브러리로 묶어야 해요. 매 서비스에 똑같은 DTO를 복붙해 두면 한쪽만 수정해도 호환성이 깨집니다.

<!-- order-common-api/pom.xml — 실행 불가 라이브러리 프로젝트 -->
<dependencies>
    <!-- Fat JAR 불필요 — spring-boot-starter 제거 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

<build>
    <plugins>
        <!-- spring-boot-maven-plugin 제거 — 실행 가능 JAR 불필요 -->
    </plugins>
</build>
@Data
@Builder
@NoArgsConstructor  // Jackson 역직렬화에 필수!
@AllArgsConstructor
public class OrderDTO {

    private UUID id;

    @NotNull
    private String customerRef;

    private BigDecimal paymentAmount;

    @Valid
    @NotEmpty
    private List<OrderLineDTO> orderLines;

    private OrderStatus orderStatus;
    private LocalDateTime createdDate;
    private LocalDateTime lastModifiedDate;
}

여기서 시험 함정이 하나 있어요. 공통 DTO에 @NoArgsConstructor가 빠지면 Jackson 역직렬화가 깨집니다. Jackson은 기본 생성자로 객체를 만든 뒤 필드를 채우는 방식이라, 인자 없는 생성자가 반드시 있어야 해요.

Kafka 통합 테스트 — @EmbeddedKafka

Kafka가 실제로 잘 흐르는지 테스트하려면 임베디드 Kafka를 띄워 검증할 수 있어요.

@SpringBootTest
@EmbeddedKafka(
    partitions = 1,
    brokerProperties = {"listeners=PLAINTEXT://localhost:9092", "port=9092"}
)
@TestPropertySource(properties = {
    "spring.kafka.bootstrap-servers=${spring.embedded.kafka.brokers}",
    "spring.kafka.consumer.auto-offset-reset=earliest"
})
class OrderPlacedKafkaIntegrationTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private KafkaTemplate<String, OrderDTO> kafkaTemplate;

    private AtomicInteger messageCounter = new AtomicInteger(0);

    @TestComponent
    static class TestKafkaListener {
        private final AtomicInteger counter;

        TestKafkaListener(AtomicInteger counter) {
            this.counter = counter;
        }

        @KafkaListener(topics = "order-placed", groupId = "test-group")
        public void receiveMessage(OrderDTO order) {
            counter.incrementAndGet();
        }
    }

    @Test
    void testOrderPlacedEventPublishedToKafka() throws Exception {
        // Given
        OrderDTO testOrder = createTestOrder();

        // When
        orderService.placeOrder(testOrder);

        // Then — 비동기 메시지 수신을 최대 10초 대기
        await().atMost(10, TimeUnit.SECONDS)
               .until(() -> messageCounter.get() >= 1);

        assertThat(messageCounter.get()).isGreaterThanOrEqualTo(1);
    }
}

비동기 메시지 검증의 정석은 Awaitility예요. 단순 Thread.sleep()보다 훨씬 안정적이고 가독성도 좋습니다.

분산 트랜잭션 — Saga 패턴

마이크로서비스 환경에서 가장 큰 함정 중 하나가 분산 트랜잭션이에요. 전통적인 2단계 커밋(2PC)은 마이크로서비스에서 성능과 가용성을 심각하게 저하시키니, 대신 Saga 패턴을 씁니다.

Saga는 한 줄로 — "여러 서비스에 걸친 트랜잭션을 작은 로컬 트랜잭션 + 보상 트랜잭션으로 쪼개는 패턴" 이에요. 한 단계가 실패하면 앞 단계들을 되돌리는 보상 트랜잭션을 발행해 정합성을 맞춥니다.

// Choreography-based Saga 예시 (각 서비스가 이벤트로 협업)

// OrderService: 주문 생성
kafkaTemplate.send("order-created", new OrderCreatedEvent(order));

// InventoryService: 재고 차감 시도
@KafkaListener(topics = "order-created")
public void onOrderCreated(OrderCreatedEvent event) {
    try {
        reserveInventory(event);
        kafkaTemplate.send("inventory-reserved",
                new InventoryReservedEvent(event.getOrderId()));
    } catch (InsufficientInventoryException e) {
        // 보상 트랜잭션 — 주문 취소 이벤트 발행
        kafkaTemplate.send("order-cancelled",
                new OrderCancelledEvent(event.getOrderId(), "재고 부족"));
    }
}

여기서 정말 중요한 시험 함정 — 2PC는 마이크로서비스에서 피해야 합니다. 한 노드가 멈추면 다른 노드들도 모두 대기 상태에 빠져 시스템 전체가 멈춰요. Saga가 정답이고, 그것도 단순한 메시지 기반 협업(Choreography) 또는 중앙 코디네이터(Orchestration) 두 갈래로 나뉩니다.

동기 vs 비동기 통신 비교

항목REST (동기)Kafka (비동기)
결합도높음 (URL 알아야 함)낮음 (토픽만 알면 됨)
가용성수신자 다운 시 발행자도 실패수신자 다운 시 메시지 큐에 쌓임
응답 처리즉시 응답 가능별도 응답 채널 필요
처리량수신자 처리 속도에 제한소비자 수 늘려 스케일아웃
복잡도단순직렬화·Consumer Group 등 설정 필요
장애 내성낮음높음 (메시지 보존, 재처리)
적합한 경우즉각 응답·단순 CRUD높은 처리량·느슨한 결합

Kafka vs RabbitMQ 비교

항목Apache KafkaRabbitMQ
메시지 보존디스크에 영속 저장소비 후 삭제 (기본)
처리량매우 높음 (수백만/초)높음 (수만~수십만/초)
메시지 순서파티션 내 순서 보장큐 내 순서 보장
재처리offset 이동으로 재처리DLQ를 통한 재처리
소비 모델Pull 기반Push 기반
프로토콜Kafka 자체 프로토콜AMQP
적합한 경우이벤트 소싱·로그 집계·스트리밍작업 큐·RPC·단기 메시지

판단 기준 — 이벤트 스트림·로그 분석·고처리량 = Kafka, 작업 큐·즉시 알림 = RabbitMQ.

자주 만나는 함정 5가지

1. 너무 세분화된 서비스 (Nano-Services)

마이크로서비스라고 해서 무조건 작게 쪼개면 운영 복잡도만 폭증해요.

# 잘못된 예 — 하나의 기능도 안 되는 서비스
UserNameService (이름만 관리)
UserEmailService (이메일만 관리)
UserPhoneService (전화번호만 관리)

# 올바른 예 — 비즈니스 도메인 기준
UserService (사용자 관련 모든 정보)
OrderService (주문 처리)
InventoryService (재고 관리)
NotificationService (알림 전송)

판단 기준은 "한 비즈니스 기능을 책임지는 부서 단위" 가 적절한 크기예요.

2. 서비스 간 데이터베이스 공유

# 절대 금지 — DB 공유는 마이크로서비스 원칙 위반
OrderService ─────→ [공유 DB] ←───── InventoryService
                               ←───── UserService

# 올바른 방식 — 각 서비스가 자체 DB 소유
OrderService → [Order DB]
InventoryService → [Inventory DB]
UserService → [User DB]
# 데이터 공유는 API 또는 이벤트로

DB를 공유하면 모든 서비스가 한 스키마에 묶이고, 결국 모놀리식의 단점이 그대로 돌아옵니다.

3. Consumer Group ID 미설정

# 반드시 명시
spring.kafka.consumer.group-id=order-service-group

없으면 재시작할 때마다 메시지를 처음부터 다시 읽거나 일부를 놓칠 수 있어요.

4. auto-offset-reset 의 의미 혼동

# latest (기본값) — 처음 연결 시 최신 메시지부터
# → 소비자 없는 동안 발행된 메시지는 놓침
spring.kafka.consumer.auto-offset-reset=latest

# earliest — 처음 연결 시 가장 오래된 메시지부터
# → 과거 메시지 포함 모두 처리 (테스트 환경에 적합)
spring.kafka.consumer.auto-offset-reset=earliest

5. Jackson 역직렬화 오류

// @NoArgsConstructor 누락 시 JsonMappingException 발생
@Data
@Builder
@NoArgsConstructor  // 필수!
@AllArgsConstructor
public class OrderDTO {
    private UUID id;
    // ...
}
# 신뢰할 수 있는 패키지 설정
spring.kafka.consumer.properties.spring.json.trusted.packages=com.example.*

추가 — 토픽 이름 하드코딩

// 잘못된 예 — 오타 발견 어려움
kafkaTemplate.send("oder-placed", dto);  // "order"를 "oder"로 오타

// 올바른 예 — 상수 사용
kafkaTemplate.send(KafkaTopics.ORDER_PLACED, dto);

시험 직전 한 번 더 — 자주 헷갈리는 함정 모음

여기까지가 16편 마이크로서비스·Kafka의 핵심이에요. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.

  • 마이크로서비스 = 회사를 부서별로 쪼갠 독립 사업부 — 부서별 자체 DB·자체 배포·자체 기술 스택
  • 모놀리식의 한계 4가지 — 배포 위험·확장 비효율·기술 고착·개발 속도 저하
  • 12-Factor App = MSA 운영 12계명 — 핵심은 3번(설정 외부화)·6번(무상태)
  • 리액티브 4속성 — 응답성·회복성·신축성·메시지 기반
  • EIP = 메시지 흐름 표준 패턴 — Channel·Router·Splitter·Aggregator·Correlation Identifier·DLQ
  • Kafka = 사내 게시판 + 우편 보관소 결합형 — 발행 후 보관, 여러 부서가 독립적으로 읽기
  • Kafka 핵심 명사 — Topic / Partition / Offset / Consumer Group / Broker
  • 한 파티션은 한 Consumer Group 내에서 한 소비자만 담당 — 처리량 늘리려면 파티션 수↑
  • 메시지 키 = 같은 키는 같은 파티션 → 순서 보장
  • KafkaTemplate.send(topic, key, payload) 발행, @KafkaListener로 소비
  • group-id 명시 필수 — 없으면 offset 추적 불가
  • auto-offset-resetlatest(최신부터·기본값) vs earliest(처음부터·테스트 환경)
  • 공통 DTO = 별도 라이브러리 + @NoArgsConstructor 필수 (Jackson 역직렬화)
  • spring.kafka.consumer.properties.spring.json.trusted.packages — 신뢰 패키지 설정
  • 토픽 이름은 상수로 관리 — 하드코딩 시 오타 발견 어려움
  • 에러 처리 — DefaultErrorHandler + DeadLetterPublishingRecoverer + FixedBackOff 재시도
  • @EmbeddedKafka + Awaitility로 통합 테스트
  • 분산 트랜잭션은 Saga 패턴 — 2PC는 MSA에서 피하기
  • Saga 두 갈래 — Choreography(이벤트 협업) vs Orchestration(중앙 코디네이터)
  • Nano-Service 함정 — 너무 세분화하면 운영 복잡도 폭증, "비즈니스 도메인 단위"가 적절
  • DB 공유 금지 — 각 서비스 자체 DB, 공유는 API·이벤트로
  • 동기 vs 비동기 — 즉각 응답·단순 CRUD = REST, 높은 처리량·느슨한 결합 = Kafka
  • Kafka vs RabbitMQ — 이벤트 스트림·로그 = Kafka, 작업 큐·즉시 알림 = RabbitMQ

시리즈 다른 편

같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!