콘텐츠 대표 이미지 - ☕ 자바 메시징과 Apache Kafka, RabbitMQ를 활용한 실전 이벤트 처리 완벽 가이드

☕ 자바 메시징과 Apache Kafka, RabbitMQ를 활용한 실전 이벤트 처리 완벽 가이드

분산 시스템의 핵심, 메시지 큐를 친구처럼 쉽게 알아보자! 🚀

안녕! 👋 오늘은 현대 애플리케이션 개발에서 빼놓을 수 없는 메시징 시스템에 대해 이야기해볼 거야. 특히 자바 환경에서 Apache Kafka와 RabbitMQ를 어떻게 활용하는지, 실전 경험을 바탕으로 친구에게 설명하듯 풀어볼게! 😊

요즘 마이크로서비스 아키텍처가 대세잖아? 그런데 서비스들이 서로 어떻게 대화를 나눠야 할까? 바로 여기서 메시징 시스템이 등장하는 거야. 마치 우체국처럼 메시지를 안전하게 전달해주는 역할을 하지! 📬

메시징 시스템 아키텍처 프로듀서 (메시지 발행) 📤 메시지 브로커 Kafka / RabbitMQ 🔄 큐 관리 및 라우팅 컨슈머 (메시지 소비) 📥 Kafka 특징 ✓ 고성능 분산 스트리밍 ✓ 대용량 데이터 처리 ✓ 로그 기반 저장 ✓ 파티션 병렬 처리 RabbitMQ 특징 ✓ 유연한 라우팅 ✓ 다양한 프로토콜 지원 ✓ 메시지 우선순위 ✓ 관리 UI 제공

🎯 메시징 시스템, 왜 필요할까?

본격적으로 들어가기 전에, 메시징 시스템이 왜 필요한지부터 이해해보자! 🤔

상상해봐. 너가 온라인 쇼핑몰을 운영하는데, 주문이 들어오면 여러 가지 일이 동시에 일어나야 해. 재고 확인, 결제 처리, 배송 준비, 고객 알림 발송... 이 모든 걸 하나의 시스템에서 순차적으로 처리하면 어떻게 될까? 🛒

맞아, 엄청 느려질 거야! 그리고 하나라도 문제가 생기면 전체가 멈춰버리지. 이게 바로 강결합(Tight Coupling) 문제야.

💡 메시징 시스템의 핵심 장점

1. 비동기 처리 (Asynchronous Processing) ⚡
요청을 보내고 응답을 기다리지 않아도 돼. 메시지를 큐에 넣어두면 나중에 처리할 수 있지!

2. 느슨한 결합 (Loose Coupling) 🔗
서비스들이 서로 직접 알 필요가 없어. 메시지 브로커를 통해 간접적으로 통신하니까 독립적으로 개발하고 배포할 수 있어.

3. 확장성 (Scalability) 📈
트래픽이 늘어나면 컨슈머를 더 추가하면 돼. 시스템 전체를 건드릴 필요가 없지!

4. 신뢰성 (Reliability) 🛡️
메시지가 큐에 저장되니까 시스템이 잠깐 다운되어도 메시지가 사라지지 않아. 복구되면 이어서 처리할 수 있어.

💬 실무 팁: 재능넷 같은 플랫폼에서도 메시징 시스템은 필수야! 사용자가 재능을 등록하면 검색 인덱싱, 알림 발송, 통계 업데이트 등 여러 작업이 비동기로 처리되거든. 이렇게 하면 사용자는 빠르게 응답을 받고, 백그라운드에서 나머지 작업이 안전하게 처리되는 거지! 🎨

🐰 RabbitMQ 완전 정복하기

먼저 RabbitMQ부터 알아볼까? RabbitMQ는 AMQP(Advanced Message Queuing Protocol)를 구현한 오픈소스 메시지 브로커야. 2007년에 처음 나왔으니 꽤 역사가 있지! 🕰️

RabbitMQ의 가장 큰 특징은 유연한 라우팅이야. 메시지를 어디로 보낼지 다양한 방식으로 결정할 수 있거든!

📚 RabbitMQ 핵심 개념

개념 설명 비유
Producer 메시지를 생성하고 보내는 애플리케이션 편지를 쓰는 사람 ✍️
Exchange 메시지를 받아서 적절한 큐로 라우팅 우체국 분류 센터 🏤
Queue 메시지가 저장되는 버퍼 우편함 📮
Binding Exchange와 Queue를 연결하는 규칙 배송 규칙 📋
Consumer 메시지를 받아서 처리하는 애플리케이션 편지를 받는 사람 📬

🔀 Exchange 타입 완벽 이해하기

RabbitMQ의 진짜 파워는 Exchange에 있어! 4가지 타입이 있는데, 각각 언제 쓰는지 알아보자. 🎯

Direct Exchange 📍

라우팅 키가 정확히 일치하는 큐로만 메시지를 보내. 가장 단순하고 명확해!

사용 예시:
로그 레벨별로 다른 큐에 저장할 때. "error" 키를 가진 메시지는 에러 큐로, "info" 키를 가진 메시지는 정보 큐로!

Fanout Exchange 📢

모든 바인딩된 큐에 메시지를 브로드캐스트해. 라우팅 키는 무시돼!

사용 예시:
실시간 알림을 여러 서비스에 동시에 보낼 때. 새 주문이 들어오면 재고 관리, 배송 시스템, 알림 서비스 모두에게!

Topic Exchange 🏷️

패턴 매칭으로 라우팅해. 와일드카드 사용 가능! (* = 단어 하나, # = 0개 이상의 단어)

사용 예시:
"order.*.created" 패턴으로 모든 주문 생성 이벤트를 받거나, "payment.#"로 결제 관련 모든 이벤트를 받을 수 있어!

Headers Exchange 📋

메시지 헤더의 속성으로 라우팅해. 라우팅 키 대신 헤더 값을 사용!

사용 예시:
복잡한 조건으로 라우팅할 때. 예를 들어 "format=pdf AND priority=high" 같은 조건!

💻 RabbitMQ 자바 실전 코드

이제 실제로 어떻게 코드를 작성하는지 볼까? Spring Boot와 Spring AMQP를 사용하면 정말 간단해! 😎

1️⃣ 의존성 추가 (Maven)

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

2️⃣ RabbitMQ 설정 클래스

@Configuration
public class RabbitMQConfig {
    
    // 큐 선언
    @Bean
    public Queue orderQueue() {
        return new Queue("order.queue", true); // durable = true
    }
    
    @Bean
    public Queue paymentQueue() {
        return new Queue("payment.queue", true);
    }
    
    // Topic Exchange 선언
    @Bean
    public TopicExchange orderExchange() {
        return new TopicExchange("order.exchange");
    }
    
    // 바인딩 설정
    @Bean
    public Binding orderBinding(Queue orderQueue, TopicExchange orderExchange) {
        return BindingBuilder
            .bind(orderQueue)
            .to(orderExchange)
            .with("order.created");
    }
    
    @Bean
    public Binding paymentBinding(Queue paymentQueue, TopicExchange orderExchange) {
        return BindingBuilder
            .bind(paymentQueue)
            .to(orderExchange)
            .with("order.payment.*");
    }
    
    // JSON 메시지 컨버터
    @Bean
    public MessageConverter jsonMessageConverter() {
        return new Jackson2JsonMessageConverter();
    }
}

3️⃣ 메시지 발행 (Producer)

@Service
public class OrderService {
    
    @Autowired
    private RabbitTemplate rabbitTemplate;
    
    public void createOrder(Order order) {
        // 주문 처리 로직...
        
        // 메시지 발행
        rabbitTemplate.convertAndSend(
            "order.exchange",           // Exchange 이름
            "order.created",            // Routing Key
            order,                      // 메시지 객체
            message -> {
                // 메시지 속성 설정
                message.getMessageProperties()
                    .setHeader("orderId", order.getId());
                message.getMessageProperties()
                    .setHeader("timestamp", System.currentTimeMillis());
                return message;
            }
        );
        
        System.out.println("주문 생성 이벤트 발행: " + order.getId());
    }
    
    public void processPayment(String orderId, PaymentInfo payment) {
        // 결제 처리...
        
        rabbitTemplate.convertAndSend(
            "order.exchange",
            "order.payment.completed",
            payment
        );
        
        System.out.println("결제 완료 이벤트 발행: " + orderId);
    }
}

4️⃣ 메시지 소비 (Consumer)

@Component
public class OrderEventListener {
    
    // 기본 리스너
    @RabbitListener(queues = "order.queue")
    public void handleOrderCreated(Order order) {
        System.out.println("주문 수신: " + order.getId());
        
        // 재고 확인 및 예약
        inventoryService.reserveStock(order);
        
        // 배송 준비
        shippingService.prepareShipment(order);
    }
    
    // 메시지와 헤더 함께 받기
    @RabbitListener(queues = "payment.queue")
    public void handlePayment(
        PaymentInfo payment,
        @Header("orderId") String orderId,
        @Header("timestamp") Long timestamp
    ) {
        System.out.println("결제 정보 수신: " + orderId);
        System.out.println("타임스탬프: " + new Date(timestamp));
        
        // 결제 검증 및 처리
        paymentService.verifyAndProcess(payment);
    }
    
    // 에러 핸들링
    @RabbitListener(queues = "order.queue")
    public void handleOrderWithErrorHandling(Order order) {
        try {
            processOrder(order);
        } catch (Exception e) {
            // 에러 로깅
            log.error("주문 처리 실패: " + order.getId(), e);
            
            // DLQ(Dead Letter Queue)로 전송
            rabbitTemplate.convertAndSend(
                "order.dlx",
                "order.failed",
                order
            );
        }
    }
}
🎓 프로 팁: RabbitMQ는 메시지 확인(Acknowledgement) 메커니즘이 중요해! 기본적으로 자동 확인 모드지만, 프로덕션에서는 수동 확인 모드를 사용하는 게 안전해. 메시지 처리가 완전히 끝난 후에 확인을 보내면 실패 시 재처리가 가능하거든! 💪

⚙️ RabbitMQ 고급 기능

🔄 메시지 재시도 (Retry) 설정

@Configuration
public class RetryConfig {
    
    @Bean
    public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(
        ConnectionFactory connectionFactory
    ) {
        SimpleRabbitListenerContainerFactory factory = 
            new SimpleRabbitListenerContainerFactory();
        factory.setConnectionFactory(connectionFactory);
        
        // 재시도 정책 설정
        factory.setAdviceChain(
            RetryInterceptorBuilder.stateless()
                .maxAttempts(3)                    // 최대 3번 시도
                .backOffOptions(1000, 2.0, 10000)  // 지수 백오프
                .recoverer(new RejectAndDontRequeueRecoverer())
                .build()
        );
        
        return factory;
    }
}

💀 Dead Letter Queue (DLQ) 설정

@Bean
public Queue orderQueue() {
    return QueueBuilder.durable("order.queue")
        .withArgument("x-dead-letter-exchange", "order.dlx")
        .withArgument("x-dead-letter-routing-key", "order.failed")
        .withArgument("x-message-ttl", 60000)  // 60초 TTL
        .build();
}

@Bean
public Queue deadLetterQueue() {
    return new Queue("order.dlq", true);
}

@Bean
public DirectExchange deadLetterExchange() {
    return new DirectExchange("order.dlx");
}

@Bean
public Binding deadLetterBinding() {
    return BindingBuilder
        .bind(deadLetterQueue())
        .to(deadLetterExchange())
        .with("order.failed");
}

⏱️ 지연 메시지 (Delayed Message)

public void sendDelayedNotification(String userId, String message, int delayMinutes) {
    rabbitTemplate.convertAndSend(
        "notification.exchange",
        "notification.delayed",
        message,
        msg -> {
            msg.getMessageProperties()
                .setDelay(delayMinutes * 60 * 1000);  // 밀리초 단위
            return msg;
        }
    );
    
    System.out.println(delayMinutes + "분 후 알림 발송 예약");
}

🚀 Apache Kafka 마스터하기

이제 Kafka를 알아볼 차례야! Kafka는 LinkedIn에서 개발해서 2011년에 오픈소스로 공개했어. 원래는 로그 수집용으로 만들어졌는데, 지금은 분산 스트리밍 플랫폼의 대명사가 됐지! 🎉

Kafka의 가장 큰 특징은 엄청난 처리량(Throughput)이야. 초당 수백만 개의 메시지를 처리할 수 있거든! 어떻게 가능할까? 🤔

🏗️ Kafka 아키텍처 핵심 개념

개념 설명 특징
Topic 메시지가 발행되는 카테고리 RabbitMQ의 Queue와 유사하지만 더 추상적 📚
Partition Topic을 나눈 물리적 단위 병렬 처리의 핵심! 파티션 수만큼 병렬화 가능 ⚡
Offset 파티션 내 메시지의 위치 순차적으로 증가하는 ID, 컨슈머가 어디까지 읽었는지 추적 📍
Producer 메시지를 발행하는 클라이언트 어느 파티션에 쓸지 결정 (키 기반 또는 라운드로빈) ✍️
Consumer 메시지를 소비하는 클라이언트 Consumer Group으로 묶여서 협력 처리 👥
Broker Kafka 서버 인스턴스 여러 브로커가 클러스터를 구성 🖥️
Zookeeper 클러스터 메타데이터 관리 최신 버전(KRaft)에서는 선택사항 🔧
Kafka 파티션과 컨슈머 그룹 프로듀서 A 📝 프로듀서 B 📝 Topic: orders Partition 0 Offset: 0 1 2 3 4... Partition 1 Offset: 0 1 2 3 4... Partition 2 Offset: 0 1 2 3 4... 병렬 처리로 고성능 달성! ⚡ Consumer Group 컨슈머 1 (Partition 0) 컨슈머 2 (Partition 1) 컨슈머 3 (Partition 2) 각 컨슈머는 할당된 파티션만 읽음 → 중복 처리 방지! 컨슈머 추가/제거 시 자동으로 파티션 재분배 (리밸런싱) 🔄

💻 Kafka 자바 실전 코드

Kafka도 Spring Boot와 찰떡궁합이야! Spring Kafka를 사용하면 정말 쉽게 구현할 수 있어. 😊

1️⃣ 의존성 추가 (Maven)

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

2️⃣ Kafka 설정 (application.yml)

spring:
  kafka:
    bootstrap-servers: localhost:9092
    
    # Producer 설정
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
      acks: all  # 모든 복제본이 확인할 때까지 대기
      retries: 3
      properties:
        linger.ms: 10  # 배치 처리를 위한 대기 시간
        batch.size: 16384
    
    # Consumer 설정
    consumer:
      group-id: order-service-group
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
      auto-offset-reset: earliest  # 처음부터 읽기
      enable-auto-commit: false  # 수동 커밋
      properties:
        spring.json.trusted.packages: "*"
        max.poll.records: 100

3️⃣ Topic 설정 클래스

@Configuration
public class KafkaTopicConfig {
    
    @Bean
    public NewTopic orderTopic() {
        return TopicBuilder.name("orders")
            .partitions(3)           // 3개 파티션
            .replicas(2)             // 2개 복제본
            .config(TopicConfig.RETENTION_MS_CONFIG, "604800000")  // 7일 보관
            .build();
    }
    
    @Bean
    public NewTopic paymentTopic() {
        return TopicBuilder.name("payments")
            .partitions(3)
            .replicas(2)
            .build();
    }
    
    @Bean
    public NewTopic notificationTopic() {
        return TopicBuilder.name("notifications")
            .partitions(5)  // 알림은 더 많은 파티션
            .replicas(2)
            .build();
    }
}

4️⃣ 메시지 발행 (Producer)

@Service
public class OrderProducerService {
    
    @Autowired
    private KafkaTemplate<String, Order> kafkaTemplate;
    
    // 기본 발행
    public void publishOrder(Order order) {
        kafkaTemplate.send("orders", order.getId(), order);
        System.out.println("주문 발행: " + order.getId());
    }
    
    // 콜백과 함께 발행
    public void publishOrderWithCallback(Order order) {
        ListenableFuture<SendResult<String, Order>> future = 
            kafkaTemplate.send("orders", order.getId(), order);
        
        future.addCallback(
            result -> {
                RecordMetadata metadata = result.getRecordMetadata();
                System.out.println("발행 성공! Topic: " + metadata.topic() 
                    + ", Partition: " + metadata.partition()
                    + ", Offset: " + metadata.offset());
            },
            ex -> {
                System.err.println("발행 실패: " + ex.getMessage());
                // 재시도 로직 또는 DLQ 전송
            }
        );
    }
    
    // 헤더와 함께 발행
    public void publishOrderWithHeaders(Order order) {
        ProducerRecord<String, Order> record = 
            new ProducerRecord<>("orders", order.getId(), order);
        
        // 헤더 추가
        record.headers().add("source", "order-service".getBytes());
        record.headers().add("timestamp", 
            String.valueOf(System.currentTimeMillis()).getBytes());
        record.headers().add("version", "1.0".getBytes());
        
        kafkaTemplate.send(record);
    }
    
    // 파티션 지정 발행
    public void publishToSpecificPartition(Order order, int partition) {
        kafkaTemplate.send("orders", partition, order.getId(), order);
        System.out.println("파티션 " + partition + "에 발행");
    }
    
    // 트랜잭션 발행
    @Transactional
    public void publishOrdersInTransaction(List<Order> orders) {
        kafkaTemplate.executeInTransaction(operations -> {
            for (Order order : orders) {
                operations.send("orders", order.getId(), order);
            }
            return true;
        });
        System.out.println(orders.size() + "개 주문 트랜잭션 발행 완료");
    }
}

5️⃣ 메시지 소비 (Consumer)

@Component
public class OrderConsumerService {
    
    // 기본 컨슈머
    @KafkaListener(topics = "orders", groupId = "order-service-group")
    public void consumeOrder(Order order) {
        System.out.println("주문 수신: " + order.getId());
        processOrder(order);
    }
    
    // 메타데이터와 함께 수신
    @KafkaListener(topics = "orders", groupId = "order-service-group")
    public void consumeOrderWithMetadata(
        @Payload Order order,
        @Header(KafkaHeaders.RECEIVED_TOPIC) String topic,
        @Header(KafkaHeaders.RECEIVED_PARTITION_ID) int partition,
        @Header(KafkaHeaders.OFFSET) long offset,
        @Header(KafkaHeaders.RECEIVED_TIMESTAMP) long timestamp
    ) {
        System.out.println("주문 수신 - Topic: " + topic 
            + ", Partition: " + partition 
            + ", Offset: " + offset
            + ", Timestamp: " + new Date(timestamp));
        
        processOrder(order);
    }
    
    // 배치 처리
    @KafkaListener(topics = "orders", groupId = "batch-processor-group")
    public void consumeOrdersBatch(List<Order> orders) {
        System.out.println("배치 수신: " + orders.size() + "개");
        
        // 배치로 한번에 처리
        processBatch(orders);
    }
    
    // 수동 커밋
    @KafkaListener(
        topics = "orders", 
        groupId = "manual-commit-group",
        containerFactory = "manualCommitKafkaListenerContainerFactory"
    )
    public void consumeOrderWithManualCommit(
        Order order,
        Acknowledgment acknowledgment
    ) {
        try {
            processOrder(order);
            
            // 처리 성공 시 커밋
            acknowledgment.acknowledge();
            System.out.println("오프셋 커밋 완료");
            
        } catch (Exception e) {
            System.err.println("처리 실패, 커밋하지 않음: " + e.getMessage());
            // 커밋하지 않으면 다음에 다시 읽음
        }
    }
    
    // 여러 토픽 동시 구독
    @KafkaListener(
        topics = {"orders", "payments", "shipments"},
        groupId = "multi-topic-group"
    )
    public void consumeMultipleTopics(
        String message,
        @Header(KafkaHeaders.RECEIVED_TOPIC) String topic
    ) {
        System.out.println("토픽 " + topic + "에서 메시지 수신");
        
        switch (topic) {
            case "orders":
                handleOrder(message);
                break;
            case "payments":
                handlePayment(message);
                break;
            case "shipments":
                handleShipment(message);
                break;
        }
    }
    
    // 에러 핸들링
    @KafkaListener(topics = "orders", groupId = "error-handling-group")
    public void consumeOrderWithErrorHandling(Order order) {
        try {
            processOrder(order);
        } catch (Exception e) {
            System.err.println("처리 중 에러 발생: " + e.getMessage());
            
            // 에러 토픽으로 전송
            kafkaTemplate.send("orders-error", order.getId(), order);
        }
    }
}

6️⃣ 고급 설정 - 수동 커밋 컨테이너

@Configuration
public class KafkaConsumerConfig {
    
    @Bean
    public ConsumerFactory<String, Order> manualCommitConsumerFactory() {
        Map<String, Object> props = new HashMap<>();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        props.put(ConsumerConfig.GROUP_ID_CONFIG, "manual-commit-group");
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, 
            StringDeserializer.class);
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, 
            JsonDeserializer.class);
        props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
        props.put(JsonDeserializer.TRUSTED_PACKAGES, "*");
        
        return new DefaultKafkaConsumerFactory<>(props);
    }
    
    @Bean
    public ConcurrentKafkaListenerContainerFactory<String, Order> 
        manualCommitKafkaListenerContainerFactory() {
        
        ConcurrentKafkaListenerContainerFactory<String, Order> factory = 
            new ConcurrentKafkaListenerContainerFactory<>();
        factory.setConsumerFactory(manualCommitConsumerFactory());
        factory.getContainerProperties()
            .setAckMode(ContainerProperties.AckMode.MANUAL);
        
        return factory;
    }
}
🎯 실무 꿀팁: Kafka에서 파티션 수는 처음 설정할 때 신중하게 결정해야 해! 나중에 늘릴 수는 있지만 줄일 수는 없거든. 일반적으로 예상 처리량과 컨슈머 수를 고려해서 결정해. 예를 들어 컨슈머가 3개면 파티션도 최소 3개는 있어야 병렬 처리가 가능하지! 🎲

🔥 Kafka Streams - 실시간 스트림 처리

Kafka의 진짜 파워는 Kafka Streams에 있어! 실시간으로 데이터를 변환하고 집계할 수 있거든. 🌊

Kafka Streams 예제 - 주문 통계

@Configuration
@EnableKafkaStreams
public class KafkaStreamsConfig {
    
    @Bean
    public KStream<String, Order> orderStream(StreamsBuilder builder) {
        // 주문 스트림 읽기
        KStream<String, Order> orderStream = builder.stream("orders");
        
        // 1. 필터링 - 금액이 10만원 이상인 주문만
        KStream<String, Order> highValueOrders = orderStream
            .filter((key, order) -> order.getAmount() >= 100000);
        
        highValueOrders.to("high-value-orders");
        
        // 2. 변환 - 주문을 알림 메시지로 변환
        KStream<String, Notification> notifications = orderStream
            .mapValues(order -> new Notification(
                order.getUserId(),
                "주문이 접수되었습니다: " + order.getId(),
                NotificationType.ORDER_CREATED
            ));
        
        notifications.to("notifications");
        
        // 3. 그룹화 및 집계 - 사용자별 주문 수
        KTable<String, Long> orderCountByUser = orderStream
            .groupBy((key, order) -> order.getUserId())
            .count();
        
        orderCountByUser.toStream().to("order-count-by-user");
        
        // 4. 윈도우 집계 - 최근 1시간 동안의 주문 금액 합계
        KTable<Windowed<String>, Double> hourlyRevenue = orderStream
            .groupByKey()
            .windowedBy(TimeWindows.of(Duration.ofHours(1)))
            .aggregate(
                () -> 0.0,  // 초기값
                (key, order, total) -> total + order.getAmount(),
                Materialized.with(Serdes.String(), Serdes.Double())
            );
        
        // 5. 조인 - 주문과 결제 정보 조인
        KStream<String, Payment> paymentStream = builder.stream("payments");
        
        KStream<String, OrderWithPayment> enrichedOrders = orderStream
            .join(
                paymentStream,
                (order, payment) -> new OrderWithPayment(order, payment),
                JoinWindows.of(Duration.ofMinutes(5))
            );
        
        enrichedOrders.to("enriched-orders");
        
        return orderStream;
    }
}
⚠️ 주의사항: Kafka Streams는 상태 저장(Stateful) 처리를 할 때 로컬 스토어를 사용해. 애플리케이션이 재시작되면 상태를 복구해야 하니까 changelog 토픽을 활용하는 게 중요해! 그리고 리밸런싱이 일어날 때 잠깐 처리가 멈출 수 있다는 점도 알아두자. 🛠️

⚖️ Kafka vs RabbitMQ - 어떤 걸 선택할까?

자, 이제 가장 중요한 질문이야. "내 프로젝트에는 뭘 써야 하지?" 🤷‍♂️

정답은... "상황에 따라 다르다"야! 각각의 강점이 다르거든. 하나씩 비교해보자!

Kafka vs RabbitMQ 비교 Apache Kafka 분산 스트리밍 플랫폼 ✅ 강점 • 초고속 처리량 (수백만 msg/sec) • 대용량 데이터 스트리밍 • 로그 영구 저장 (디스크 기반) • 이벤트 소싱에 최적 • 재처리 가능 (오프셋 조정) • 수평 확장 용이 ❌ 약점 • 복잡한 설정 및 운영 • 메시지 라우팅 기능 제한적 • 작은 메시지에는 오버헤드 • 우선순위 큐 미지원 🎯 적합한 경우 로그 수집, 이벤트 스트리밍, 실시간 분석, 대용량 처리 RabbitMQ 메시지 브로커 ✅ 강점 • 유연한 라우팅 (Exchange 타입) • 다양한 프로토콜 지원 • 메시지 우선순위 지원 • 관리 UI 제공 (편리함) • 설정 및 사용이 간단 • 작은 메시지 처리에 효율적 ❌ 약점 • 처리량이 Kafka보다 낮음 • 메시지 소비 후 삭제됨 • 재처리 어려움 • 대용량 스트리밍에 부적합 🎯 적합한 경우 작업 큐, 복잡한 라우팅, 마이크로서비스 통신, RPC

📊 상세 비교표

비교 항목 Apache Kafka RabbitMQ
아키텍처 분산 로그 시스템 전통적인 메시지 브로커
처리량 매우 높음 (수백만 msg/sec) 🚀 중간 (수만 msg/sec) ⚡
메시지 저장 디스크에 영구 저장 (설정 가능) 소비 후 삭제 (기본)
메시지 순서 파티션 내에서 보장 큐 내에서 보장
재처리 오프셋 조정으로 쉽게 가능 ✅ 어려움 (별도 구현 필요) ❌
라우팅 토픽 기반 (단순) Exchange 타입으로 유연함 🎯
프로토콜 자체 바이너리 프로토콜 AMQP, MQTT, STOMP 등 다양
우선순위 큐 미지원 지원 ✅
관리 UI 별도 도구 필요 (Kafka Manager 등) 기본 제공 (매우 편리) 👍
학습 곡선 가파름 📈 완만함 📉
운영 복잡도 높음 (Zookeeper 관리 등) 낮음
확장성 수평 확장 매우 용이 수직 확장 위주

🎯 선택 가이드

Kafka를 선택해야 하는 경우 🚀

1. 대용량 이벤트 스트리밍
초당 수십만 건 이상의 이벤트를 처리해야 한다면 Kafka가 답이야! 예를 들어 IoT 센서 데이터, 클릭스트림 분석, 로그 수집 같은 경우.

2. 이벤트 소싱 패턴
모든 상태 변경을 이벤트로 저장하고, 나중에 재생(replay)해야 한다면 Kafka의 로그 기반 저장소가 완벽해.

3. 실시간 데이터 파이프라인
여러 시스템 간에 실시간으로 데이터를 흘려보내야 할 때. Kafka Streams나 Kafka Connect를 활용하면 강력한 파이프라인을 구축할 수 있어!

4. 메시지 재처리가 필요한 경우
버그 수정 후 과거 데이터를 다시 처리하거나, 새로운 컨슈머가 과거 데이터부터 읽어야 하는 경우.

실제 사례: Netflix의 실시간 추천 시스템, LinkedIn의 활동 스트림, Uber의 실시간 위치 추적 🎬

RabbitMQ를 선택해야 하는 경우 🐰

1. 복잡한 라우팅이 필요한 경우
메시지를 조건에 따라 다양한 큐로 분배해야 한다면 RabbitMQ의 Exchange가 완벽해. Topic Exchange로 패턴 매칭도 가능하고!

2. 작업 큐 (Task Queue)
이메일 발송, 이미지 처리, 리포트 생성 같은 백그라운드 작업을 비동기로 처리할 때. 우선순위 큐도 지원하니까 긴급한 작업을 먼저 처리할 수 있어.

3. 마이크로서비스 간 통신
서비스들이 서로 요청-응답 패턴으로 통신해야 할 때. RabbitMQ는 RPC 패턴을 잘 지원해.

4. 빠른 프로토타이핑
설정이 간단하고 관리 UI가 있어서 빠르게 개발하고 테스트할 수 있어. 스타트업이나 작은 프로젝트에 적합!

실제 사례: 재능넷 같은 플랫폼의 알림 시스템, 이커머스의 주문 처리 워크플로우, 채팅 애플리케이션 💬

💡 프로 팁: 꼭 하나만 선택할 필요는 없어! 많은 기업들이 두 가지를 함께 사용해. 예를 들어 대용량 로그 수집은 Kafka로, 작업 큐와 알림은 RabbitMQ로 처리하는 식이지. 각자의 강점을 살리는 거야! 🎯

🔄 하이브리드 아키텍처 예시

// Kafka에서 이벤트를 받아서 RabbitMQ로 작업 분배
@Service
public class EventDistributionService {
    
    @Autowired
    private RabbitTemplate rabbitTemplate;
    
    // Kafka에서 주문 이벤트 수신
    @KafkaListener(topics = "orders", groupId = "distributor-group")
    public void handleOrderEvent(Order order) {
        System.out.println("Kafka에서 주문 이벤트 수신: " + order.getId());
        
        // RabbitMQ로 개별 작업 분배
        
        // 1. 재고 확인 작업
        rabbitTemplate.convertAndSend(
            "tasks.exchange",
            "task.inventory.check",
            new InventoryCheckTask(order.getId(), order.getItems())
        );
        
        // 2. 결제 처리 작업 (높은 우선순위)
        rabbitTemplate.convertAndSend(
            "tasks.exchange",
            "task.payment.process",
            new PaymentTask(order.getId(), order.getAmount()),
            message -> {
                message.getMessageProperties().setPriority(10);
                return message;
            }
        );
        
        // 3. 알림 발송 작업
        rabbitTemplate.convertAndSend(
            "notifications.exchange",
            "notification.order.created",
            new NotificationTask(order.getUserId(), "주문이 접수되었습니다")
        );
        
        System.out.println("작업들을 RabbitMQ로 분배 완료");
    }
}

🛠️ 실전 패턴과 베스트 프랙티스

이론은 충분히 배웠으니, 이제 실전에서 정말 유용한 패턴들을 알아보자! 이건 내가 실무에서 직접 써본 것들이야. 😊

1️⃣ 멱등성 보장 (Idempotency)

네트워크 문제로 메시지가 중복 전송될 수 있어. 같은 메시지를 여러 번 처리해도 결과가 같아야 해! 🔁

멱등성 구현 예제

@Service
public class IdempotentOrderService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @KafkaListener(topics = "orders", groupId = "idempotent-group")
    public void processOrder(Order order) {
        String idempotencyKey = "order:processed:" + order.getId();
        
        // Redis를 사용한 중복 체크
        Boolean isNew = redisTemplate.opsForValue()
            .setIfAbsent(idempotencyKey, "processing", 1, TimeUnit.HOURS);
        
        if (Boolean.FALSE.equals(isNew)) {
            System.out.println("이미 처리된 주문: " + order.getId());
            return;  // 중복 처리 방지
        }
        
        try {
            // 실제 주문 처리
            processOrderLogic(order);
            
            // 처리 완료 표시
            redisTemplate.opsForValue().set(
                idempotencyKey, 
                "completed", 
                24, 
                TimeUnit.HOURS
            );
            
            System.out.println("주문 처리 완료: " + order.getId());
            
        } catch (Exception e) {
            // 실패 시 키 삭제 (재시도 가능하도록)
            redisTemplate.delete(idempotencyKey);
            throw e;
        }
    }
}

2️⃣ Saga 패턴 - 분산 트랜잭션

마이크로서비스에서는 여러 서비스에 걸친 트랜잭션을 어떻게 처리할까? Saga 패턴을 사용하면 돼! 🎭

Choreography 기반 Saga (이벤트 기반)

// 1. 주문 서비스 - Saga 시작
@Service
public class OrderSagaService {
    
    @Autowired
    private KafkaTemplate<String, Object> kafkaTemplate;
    
    public void createOrder(Order order) {
        // 주문 생성 (Pending 상태)
        order.setStatus(OrderStatus.PENDING);
        orderRepository.save(order);
        
        // 재고 예약 이벤트 발행
        kafkaTemplate.send("inventory-events", 
            new ReserveInventoryEvent(order.getId(), order.getItems()));
        
        System.out.println("Saga 시작: 재고 예약 요청");
    }
    
    // 재고 예약 성공 이벤트 수신
    @KafkaListener(topics = "inventory-events", groupId = "order-saga-group")
    public void handleInventoryReserved(InventoryReservedEvent event) {
        System.out.println("재고 예약 성공: " + event.getOrderId());
        
        // 결제 처리 이벤트 발행
        Order order = orderRepository.findById(event.getOrderId()).get();
        kafkaTemplate.send("payment-events",
            new ProcessPaymentEvent(order.getId(), order.getAmount()));
    }
    
    // 결제 성공 이벤트 수신
    @KafkaListener(topics = "payment-events", groupId = "order-saga-group")
    public void handlePaymentProcessed(PaymentProcessedEvent event) {
        System.out.println("결제 성공: " + event.getOrderId());
        
        // 주문 확정
        Order order = orderRepository.findById(event.getOrderId()).get();
        order.setStatus(OrderStatus.CONFIRMED);
        orderRepository.save(order);
        
        // 배송 시작 이벤트 발행
        kafkaTemplate.send("shipping-events",
            new StartShippingEvent(order.getId(), order.getShippingAddress()));
        
        System.out.println("Saga 완료: 주문 확정");
    }
    
    // 보상 트랜잭션 - 재고 예약 실패
    @KafkaListener(topics = "inventory-events", groupId = "order-saga-group")
    public void handleInventoryReservationFailed(InventoryReservationFailedEvent event) {
        System.out.println("재고 예약 실패: " + event.getOrderId());
        
        // 주문 취소
        Order order = orderRepository.findById(event.getOrderId()).get();
        order.setStatus(OrderStatus.CANCELLED);
        order.setCancelReason("재고 부족");
        orderRepository.save(order);
        
        System.out.println("Saga 롤백: 주문 취소");
    }
    
    // 보상 트랜잭션 - 결제 실패
    @KafkaListener(topics = "payment-events", groupId = "order-saga-group")
    public void handlePaymentFailed(PaymentFailedEvent event) {
        System.out.println("결제 실패: " + event.getOrderId());
        
        // 재고 예약 취소 이벤트 발행
        kafkaTemplate.send("inventory-events",
            new CancelInventoryReservationEvent(event.getOrderId()));
        
        // 주문 취소
        Order order = orderRepository.findById(event.getOrderId()).get();
        order.setStatus(OrderStatus.CANCELLED);
        order.setCancelReason("결제 실패");
        orderRepository.save(order);
        
        System.out.println("Saga 롤백: 재고 예약 취소 및 주문 취소");
    }
}

3️⃣ CQRS 패턴 (Command Query Responsibility Segregation)

읽기와 쓰기를 분리해서 각각 최적화하는 패턴이야. 메시징 시스템과 찰떡궁합! 🍰

CQRS 구현 예제

// Command Side - 쓰기 모델
@Service
public class OrderCommandService {
    
    @Autowired
    private OrderRepository orderRepository;
    
    @Autowired
    private KafkaTemplate<String, Object> kafkaTemplate;
    
    @Transactional
    public void createOrder(CreateOrderCommand command) {
        // 주문 생성
        Order order = new Order();
        order.setUserId(command.getUserId());
        order.setItems(command.getItems());
        order.setAmount(calculateAmount(command.getItems()));
        order.setCreatedAt(LocalDateTime.now());
        
        orderRepository.save(order);
        
        // 이벤트 발행 (읽기 모델 업데이트용)
        kafkaTemplate.send("order-events", 
            new OrderCreatedEvent(
                order.getId(),
                order.getUserId(),
                order.getAmount(),
                order.getCreatedAt()
            ));
        
        System.out.println("Command 처리: 주문 생성 완료");
    }
}

// Query Side - 읽기 모델
@Service
public class OrderQueryService {
    
    @Autowired
    private OrderReadModelRepository readModelRepository;
    
    // 이벤트를 받아서 읽기 모델 업데이트
    @KafkaListener(topics = "order-events", groupId = "query-side-group")
    public void handleOrderCreated(OrderCreatedEvent event) {
        // 읽기에 최적화된 모델로 변환
        OrderReadModel readModel = new OrderReadModel();
        readModel.setOrderId(event.getOrderId());
        readModel.setUserId(event.getUserId());
        readModel.setAmount(event.getAmount());
        readModel.setCreatedAt(event.getCreatedAt());
        readModel.setStatus("CREATED");
        
        // 추가 정보 조인 (비정규화)
        User user = userService.getUser(event.getUserId());
        readModel.setUserName(user.getName());
        readModel.setUserEmail(user.getEmail());
        
        readModelRepository.save(readModel);
        
        System.out.println("Query 모델 업데이트: " + event.getOrderId());
    }
    
    // 빠른 조회 (읽기 전용 모델 사용)
    public List<OrderReadModel> getUserOrders(String userId) {
        return readModelRepository.findByUserId(userId);
    }
    
    public OrderStatistics getOrderStatistics(String userId) {
        // 집계된 통계 정보 조회 (미리 계산되어 있음)
        return readModelRepository.getStatistics(userId);
    }
}

4️⃣ 메시지 필터링과 라우팅

조건부 메시지 처리

@Component
public class SmartOrderConsumer {
    
    // 특정 조건의 메시지만 처리
    @KafkaListener(topics = "orders", groupId = "vip-processor-group")
    public void processVipOrders(
        Order order,
        @Header(KafkaHeaders.RECEIVED_TOPIC) String topic
    ) {
        // VIP 고객의 주문만 처리
        if (order.getCustomerTier().equals("VIP")) {
            System.out.println("VIP 주문 우선 처리: " + order.getId());
            processWithHighPriority(order);
        }
    }
    
    // 금액별 다른 처리
    @KafkaListener(topics = "orders", groupId = "amount-based-group")
    public void processOrdersByAmount(Order order) {
        if (order.getAmount() >= 1000000) {
            // 고액 주문 - 추가 검증
            System.out.println("고액 주문 검증: " + order.getId());
            performAdditionalVerification(order);
            notifyManager(order);
        } else if (order.getAmount() >= 100000) {
            // 중간 금액 - 일반 처리
            processNormalOrder(order);
        } else {
            // 소액 주문 - 빠른 처리
            processFastTrack(order);
        }
    }
    
    // 지역별 라우팅
    @RabbitListener(queues = "orders.seoul")
    public void processSeoulOrders(Order order) {
        System.out.println("서울 지역 주문 처리: " + order.getId());
        assignToSeoulWarehouse(order);
    }
    
    @RabbitListener(queues = "orders.busan")
    public void processBusanOrders(Order order) {
        System.out.println("부산 지역 주문 처리: " + order.getId());
        assignToBusanWarehouse(order);
    }
}

5️⃣ 모니터링과 알림

메시지 처리 모니터링

@Component
@Slf4j
public class MessageMonitoringAspect {
    
    @Autowired
    private MeterRegistry meterRegistry;
    
    @Around("@annotation(org.springframework.kafka.annotation.KafkaListener)")
    public Object monitorKafkaListener(ProceedingJoinPoint joinPoint) throws Throwable {
        String methodName = joinPoint.getSignature().getName();
        Timer.Sample sample = Timer.start(meterRegistry);
        
        try {
            Object result = joinPoint.proceed();
            
            // 성공 메트릭
            meterRegistry.counter("kafka.message.processed", 
                "method", methodName,
                "status", "success"
            ).increment();
            
            sample.stop(Timer.builder("kafka.message.processing.time")
                .tag("method", methodName)
                .register(meterRegistry));
            
            return result;
            
        } catch (Exception e) {
            // 실패 메트릭
            meterRegistry.counter("kafka.message.processed",
                "method", methodName,
                "status", "failure",
                "error", e.getClass().getSimpleName()
            ).increment();
            
            // 알림 발송
            if (isHighPriorityError(e)) {
                sendAlert("Kafka 메시지 처리 실패: " + methodName, e);
            }
            
            throw e;
        }
    }
    
    // 지연 감지
    @Scheduled(fixedRate = 60000)  // 1분마다
    public void checkConsumerLag() {
        // Kafka Consumer Lag 체크
        Map<TopicPartition, Long> lag = getConsumerLag();
        
        for (Map.Entry<TopicPartition, Long> entry : lag.entrySet()) {
            if (entry.getValue() > 10000) {  // 10,000개 이상 밀렸을 때
                log.warn("높은 Consumer Lag 감지: {} - {}", 
                    entry.getKey(), entry.getValue());
                
                sendAlert("Consumer Lag 경고", 
                    "Topic: " + entry.getKey().topic() + 
                    ", Partition: " + entry.getKey().partition() +
                    ", Lag: " + entry.getValue());
            }
        }
    }
}
🎓 실무 꿀팁: 프로덕션 환경에서는 반드시 모니터링을 설정해야 해! Kafka는 JMX 메트릭을, RabbitMQ는 Management API를 제공하니까 Prometheus + Grafana 같은 도구로 시각화하면 좋아. Consumer Lag, 처리 속도, 에러율 등을 실시간으로 확인할 수 있거든! 📊

🚨 트러블슈팅과 성능 최적화

실전에서 만날 수 있는 문제들과 해결 방법을 알아보자! 이건 정말 중요해. 🔧

⚡ Kafka 성능 최적화

1. Producer 최적화

@Configuration
public class KafkaProducerOptimization {
    
    @Bean
    public ProducerFactory<String, Object> optimizedProducerFactory() {
        Map<String, Object> props = new HashMap<>();
        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        
        // 배치 처리 최적화
        props.put(ProducerConfig.BATCH_SIZE_CONFIG, 32768);  // 32KB
        props.put(ProducerConfig.LINGER_MS_CONFIG, 10);  // 10ms 대기
        
        // 압축 활성화 (네트워크 대역폭 절약)
        props.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "snappy");
        
        // 버퍼 크기 증가
        props.put(ProducerConfig.BUFFER_MEMORY_CONFIG, 67108864);  // 64MB
        
        // 재시도 설정
        props.put(ProducerConfig.RETRIES_CONFIG, 3);
        props.put(ProducerConfig.RETRY_BACKOFF_MS_CONFIG, 100);
        
        // Idempotence 활성화 (중복 방지)
        props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
        
        // Acks 설정 (신뢰성 vs 성능)
        // acks=0: 응답 안 기다림 (가장 빠름, 메시지 손실 가능)
        // acks=1: 리더만 확인 (균형)
        // acks=all: 모든 복제본 확인 (가장 안전, 느림)
        props.put(ProducerConfig.ACKS_CONFIG, "1");
        
        return new DefaultKafkaProducerFactory<>(props);
    }
}

2. Consumer 최적화

@Configuration
public class KafkaConsumerOptimization {
    
    @Bean
    public ConsumerFactory<String, Object> optimizedConsumerFactory() {
        Map<String, Object> props = new HashMap<>();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        
        // 한 번에 가져올 메시지 수
        props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 500);
        
        // 한 번에 가져올 데이터 크기
        props.put(ConsumerConfig.FETCH_MIN_BYTES_CONFIG, 1024);  // 1KB
        props.put(ConsumerConfig.FETCH_MAX_WAIT_MS_CONFIG, 500);  // 최대 0.5초 대기
        
        // 세션 타임아웃 (리밸런싱 감지)
        props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, 30000);  // 30초
        props.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, 3000);  // 3초
        
        // Poll 간격 (처리 시간 고려)
        props.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, 300000);  // 5분
        
        return new DefaultKafkaConsumerFactory<>(props);
    }
    
    @Bean
    public ConcurrentKafkaListenerContainerFactory<String, Object> 
        optimizedKafkaListenerContainerFactory() {
        
        ConcurrentKafkaListenerContainerFactory<String, Object> factory = 
            new ConcurrentKafkaListenerContainerFactory<>();
        factory.setConsumerFactory(optimizedConsumerFactory());
        
        // 동시 처리 스레드 수 (파티션 수와 맞춤)
        factory.setConcurrency(3);
        
        // 배치 리스너 활성화
        factory.setBatchListener(true);
        
        return factory;
    }
}

🐰 RabbitMQ 성능 최적화

1. Prefetch Count 설정

@Configuration
public class RabbitMQOptimization {
    
    @Bean
    public SimpleRabbitListenerContainerFactory optimizedRabbitListenerContainerFactory(
        ConnectionFactory connectionFactory
    ) {
        SimpleRabbitListenerContainerFactory factory = 
            new SimpleRabbitListenerContainerFactory();
        factory.setConnectionFactory(connectionFactory);
        
        // Prefetch Count: 한 번에 가져올 메시지 수
        // 너무 크면 메모리 부담, 너무 작으면 네트워크 오버헤드
        factory.setPrefetchCount(50);
        
        // 동시 컨슈머 수
        factory.setConcurrentConsumers(3);
        factory.setMaxConcurrentConsumers(10);
        
        // 배치 처리
        factory.setBatchSize(10);
        factory.setConsumerBatchEnabled(true);
        
        return factory;
    }
}

🔍 일반적인 문제와 해결책

문제 1: Consumer Lag 증가 📈 증상: 메시지가 쌓이고 처리가 밀림

원인:
• 컨슈머 처리 속도가 프로듀서 생산 속도보다 느림
• 컨슈머 수가 부족함
• 처리 로직이 무거움 (DB 쿼리, 외부 API 호출 등)

해결책:
• 컨슈머 인스턴스 추가 (수평 확장)
• 파티션 수 증가 (Kafka)
• 배치 처리 활용
• 무거운 작업은 별도 스레드 풀로 비동기 처리
• 캐싱 활용으로 DB 부하 감소

Consumer Lag 해결 예제

@Service
public class OptimizedOrderConsumer {
    
    @Autowired
    private ExecutorService asyncExecutor;
    
    @Autowired
    private OrderCache orderCache;
    
    // 배치 처리로 효율성 향상
    @KafkaListener(
        topics = "orders",
        groupId = "optimized-group",
        containerFactory = "batchListenerContainerFactory"
    )
    public void processBatch(List<Order> orders) {
        System.out.println("배치 처리 시작: " + orders.size() + "개");
        
        // 병렬 처리
        List<CompletableFuture<Void>> futures = orders.stream()
            .map(order -> CompletableFuture.runAsync(() -> {
                processOrderOptimized(order);
            }, asyncExecutor))
            .collect(Collectors.toList());
        
        // 모든 처리 완료 대기
        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
            .join();
        
        System.out.println("배치 처리 완료");
    }
    
    private void processOrderOptimized(Order order) {
        // 캐시 활용
        User user = orderCache.getUser(order.getUserId());
        
        // 무거운 작업은 비동기로
        asyncExecutor.submit(() -> {
            sendEmailNotification(order, user);
        });
        
        // 필수 작업만 동기로
        updateInventory(order);
    }
}
문제 2: 메시지 순서 보장 실패 🔀 증상: 메시지가 순서대로 처리되지 않음

원인:
• 여러 파티션에 메시지가 분산됨 (Kafka)
• 여러 컨슈머가 동시에 처리함
• 재시도 로직이 순서를 깸

해결책:
• 같은 키를 가진 메시지는 같은 파티션으로 (Kafka)
• 순서가 중요한 메시지는 단일 파티션 사용
• 컨슈머를 1개로 제한 (성능 희생)
• 애플리케이션 레벨에서 순서 보장 로직 구현

순서 보장 예제

@Service
public class OrderedMessageProducer {
    
    @Autowired
    private KafkaTemplate<String, Order> kafkaTemplate;
    
    // 같은 사용자의 주문은 같은 파티션으로
    public void publishOrderWithOrdering(Order order) {
        // 사용자 ID를 키로 사용 → 같은 파티션으로 라우팅
        kafkaTemplate.send("orders", order.getUserId(), order);
        
        System.out.println("사용자 " + order.getUserId() + 
            "의 주문을 순서 보장하여 발행");
    }
}

@Component
public class OrderedMessageConsumer {
    
    private final Map<String, Queue<Order>> userOrderQueues = 
        new ConcurrentHashMap<>();
    
    @KafkaListener(
        topics = "orders",
        groupId = "ordered-group",
        concurrency = "1"  // 단일 컨슈머로 순서 보장
    )
    public void consumeOrdered(Order order) {
        String userId = order.getUserId();
        
        // 사용자별 큐에 추가
        userOrderQueues.computeIfAbsent(userId, k -> new LinkedList<>())
            .offer(order);
        
        // 순차 처리
        processUserOrders(userId);
    }
    
    private synchronized void processUserOrders(String userId) {
        Queue<Order> queue = userOrderQueues.get(userId);
        
        while (!queue.isEmpty()) {
            Order order = queue.poll();
            processOrder(order);
        }
    }
}
문제 3: 메시지 손실 💔 증상: 발행한 메시지가 처리되지 않음

원인:
• 브로커 다운 시 메모리에만 있던 메시지 손실
• 컨슈머가 메시지를 받았지만 처리 전에 크래시
• 네트워크 문제로 전송 실패

해결책:
• Producer: acks=all 설정, 재시도 활성화
• Broker: 복제본(replica) 설정, 영구 저장
• Consumer: 수동 커밋 사용, 처리 완료 후 커밋
• 트랜잭션 사용 (Kafka Transactions)

메시지 손실 방지 예제

// Producer - 안전한 발행
@Service
public class ReliableProducer {
    
    @Autowired
    private KafkaTemplate<String, Order> kafkaTemplate;
    
    @Transactional
    public void publishReliably(Order order) {
        try {
            // 동기 전송으로 결과 확인
            SendResult<String, Order> result = kafkaTemplate
                .send("orders", order.getId(), order)
                .get(10, TimeUnit.SECONDS);  // 타임아웃 설정
            
            RecordMetadata metadata = result.getRecordMetadata();
            System.out.println("발행 성공 - Offset: " + metadata.offset());
            
        } catch (Exception e) {
            System.err.println("발행 실패: " + e.getMessage());
            
            // DB에 실패 로그 저장 (나중에 재시도)
            saveFailedMessage(order);
            
            throw new MessagePublishException("메시지 발행 실패", e);
        }
    }
}

// Consumer - 안전한 소비
@Component
public class ReliableConsumer {
    
    @KafkaListener(
        topics = "orders",
        groupId = "reliable-group",
        containerFactory = "manualCommitContainerFactory"
    )
    public void consumeReliably(
        Order order,
        Acknowledgment acknowledgment
    ) {
        try {
            // 트랜잭션으로 처리
            processOrderInTransaction(order);
            
            // 처리 완료 후 커밋
            acknowledgment.acknowledge();
            System.out.println("처리 및 커밋 완료: " + order.getId());
            
        } catch (Exception e) {
            System.err.println("처리 실패: " + e.getMessage());
            
            // 커밋하지 않음 → 다음에 다시 읽음
            // 또는 DLQ로 전송
            if (shouldSendToDLQ(e)) {
                sendToDeadLetterQueue(order);
                acknowledgment.acknowledge();  // DLQ 전송 후 커밋
            }
        }
    }
    
    @Transactional
    private void processOrderInTransaction(Order order) {
        // DB 업데이트와 메시지 처리를 하나의 트랜잭션으로
        orderRepository.save(order);
        inventoryService.updateStock(order);
        // 모두 성공하거나 모두 롤백
    }
}

🎓 실전 프로젝트 예제

지금까지 배운 내용을 종합해서 실제 프로젝트를 만들어보자! 재능넷 같은 플랫폼의 알림 시스템을 구현해볼게. 😊

📱 실시간 알림 시스템 구축

시스템 아키텍처

요구사항:
• 사용자가 재능을 등록하면 관심있는 사용자들에게 알림
• 채팅 메시지 도착 시 실시간 푸시 알림
• 거래 완료, 리뷰 등록 등 다양한 이벤트 알림
• 알림 우선순위 지원 (긴급, 일반, 낮음)
• 사용자별 알림 설정 존중 (이메일, 푸시, SMS)

기술 스택:
• Kafka: 이벤트 스트리밍 (고성능)
• RabbitMQ: 알림 작업 큐 (우선순위 지원)
• Redis: 사용자 온라인 상태 및 캐싱
• WebSocket: 실시간 푸시

1. 이벤트 발행 서비스

@Service
public class TalentEventPublisher {
    
    @Autowired
    private KafkaTemplate<String, Object> kafkaTemplate;
    
    // 재능 등록 이벤트
    public void publishTalentCreated(Talent talent) {
        TalentCreatedEvent event = TalentCreatedEvent.builder()
            .talentId(talent.getId())
            .sellerId(talent.getSellerId())
            .category(talent.getCategory())
            .title(talent.getTitle())
            .price(talent.getPrice())
            .createdAt(LocalDateTime.now())
            .build();
        
        kafkaTemplate.send("talent-events", event);
        System.out.println("재능 등록 이벤트 발행: " + talent.getId());
    }
    
    // 채팅 메시지 이벤트
    public void publishChatMessage(ChatMessage message) {
        ChatMessageEvent event = ChatMessageEvent.builder()
            .messageId(message.getId())
            .roomId(message.getRoomId())
            .senderId(message.getSenderId())
            .receiverId(message.getReceiverId())
            .content(message.getContent())
            .timestamp(LocalDateTime.now())
            .build();
        
        kafkaTemplate.send("chat-events", event);
    }
    
    // 거래 완료 이벤트
    public void publishTransactionCompleted(Transaction transaction) {
        TransactionCompletedEvent event = TransactionCompletedEvent.builder()
            .transactionId(transaction.getId())
            .buyerId(transaction.getBuyerId())
            .sellerId(transaction.getSellerId())
            .talentId(transaction.getTalentId())
            .amount(transaction.getAmount())
            .completedAt(LocalDateTime.now())
            .build();
        
        kafkaTemplate.send("transaction-events", event);
    }
}

2. 알림 생성 서비스 (Kafka Consumer)

@Service
public class NotificationGenerator {
    
    @Autowired
    private RabbitTemplate rabbitTemplate;
    
    @Autowired
    private UserPreferenceService preferenceService;
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    // 재능 등록 이벤트 처리
    @KafkaListener(topics = "talent-events", groupId = "notification-generator")
    public void handleTalentCreated(TalentCreatedEvent event) {
        System.out.println("재능 등록 이벤트 수신: " + event.getTalentId());
        
        // 해당 카테고리에 관심있는 사용자 조회
        List<String> interestedUsers = 
            userService.getUsersInterestedInCategory(event.getCategory());
        
        for (String userId : interestedUsers) {
            // 사용자 알림 설정 확인
            UserNotificationPreference pref = 
                preferenceService.getPreference(userId);
            
            if (pref.isNewTalentNotificationEnabled()) {
                // 알림 생성
                Notification notification = Notification.builder()
                    .userId(userId)
                    .type(NotificationType.NEW_TALENT)
                    .title("새로운 재능이 등록되었습니다!")
                    .message(event.getTitle() + " - " + 
                        formatPrice(event.getPrice()))
                    .link("/talents/" + event.getTalentId())
                    .priority(NotificationPriority.NORMAL)
                    .createdAt(LocalDateTime.now())
                    .build();
                
                // RabbitMQ로 알림 작업 전송
                sendNotificationTask(notification, pref);
            }
        }
    }
    
    // 채팅 메시지 이벤트 처리
    @KafkaListener(topics = "chat-events", groupId = "notification-generator")
    public void handleChatMessage(ChatMessageEvent event) {
        String receiverId = event.getReceiverId();
        
        // 사용자가 온라인인지 확인
        boolean isOnline = redisTemplate.hasKey("user:online:" + receiverId);
        
        if (isOnline) {
            // 온라인이면 WebSocket으로 즉시 전송
            Notification notification = Notification.builder()
                .userId(receiverId)
                .type(NotificationType.CHAT_MESSAGE)
                .title("새 메시지")
                .message(event.getContent())
                .link("/chat/" + event.getRoomId())
                .priority(NotificationPriority.HIGH)
                .build();
            
            // 실시간 푸시 큐로 (높은 우선순위)
            rabbitTemplate.convertAndSend(
                "notifications.exchange",
                "notification.realtime",
                notification,
                message -> {
                    message.getMessageProperties().setPriority(10);
                    return message;
                }
            );
        } else {
            // 오프라인이면 푸시 알림
            UserNotificationPreference pref = 
                preferenceService.getPreference(receiverId);
            
            if (pref.isPushNotificationEnabled()) {
                Notification notification = Notification.builder()
                    .userId(receiverId)
                    .type(NotificationType.CHAT_MESSAGE)
                    .title("새 메시지")
                    .message(event.getContent())
                    .priority(NotificationPriority.HIGH)
                    .build();
                
                sendNotificationTask(notification, pref);
            }
        }
    }
    
    // 거래 완료 이벤트 처리
    @KafkaListener(topics = "transaction-events", groupId = "notification-generator")
    public void handleTransactionCompleted(TransactionCompletedEvent event) {
        // 구매자에게 알림
        sendTransactionNotification(
            event.getBuyerId(),
            "거래가 완료되었습니다!",
            "리뷰를 작성해주세요.",
            "/transactions/" + event.getTransactionId()
        );
        
        // 판매자에게 알림
        sendTransactionNotification(
            event.getSellerId(),
            "재능 거래가 완료되었습니다!",
            "수익이 정산되었습니다.",
            "/transactions/" + event.getTransactionId()
        );
    }
    
    private void sendNotificationTask(
        Notification notification,
        UserNotificationPreference pref
    ) {
        // 이메일 알림
        if (pref.isEmailNotificationEnabled()) {
            EmailNotificationTask emailTask = new EmailNotificationTask(
                notification.getUserId(),
                notification.getTitle(),
                notification.getMessage()
            );
            
            rabbitTemplate.convertAndSend(
                "notifications.exchange",
                "notification.email",
                emailTask
            );
        }
        
        // 푸시 알림
        if (pref.isPushNotificationEnabled()) {
            PushNotificationTask pushTask = new PushNotificationTask(
                notification.getUserId(),
                notification.getTitle(),
                notification.getMessage()
            );
            
            rabbitTemplate.convertAndSend(
                "notifications.exchange",
                "notification.push",
                pushTask,
                message -> {
                    message.getMessageProperties()
                        .setPriority(notification.getPriority().getValue());
                    return message;
                }
            );
        }
        
        // SMS 알림 (긴급한 경우만)
        if (pref.isSmsNotificationEnabled() && 
            notification.getPriority() == NotificationPriority.URGENT) {
            
            SmsNotificationTask smsTask = new SmsNotificationTask(
                notification.getUserId(),
                notification.getMessage()
            );
            
            rabbitTemplate.convertAndSend(
                "notifications.exchange",
                "notification.sms",
                smsTask,
                message -> {
                    message.getMessageProperties().setPriority(10);
                    return message;
                }
            );
        }
    }
}

3. 알림 전송 워커 (RabbitMQ Consumer)

@Component
public class NotificationWorkers {
    
    @Autowired
    private EmailService emailService;
    
    @Autowired
    private PushNotificationService pushService;
    
    @Autowired
    private SmsService smsService;
    
    @Autowired
    private WebSocketService webSocketService;
    
    // 실시간 알림 워커 (WebSocket)
    @RabbitListener(
        queues = "notification.realtime.queue",
        concurrency = "5-10"  // 동적 스케일링
    )
    public void handleRealtimeNotification(Notification notification) {
        try {
            // WebSocket으로 즉시 전송
            webSocketService.sendToUser(
                notification.getUserId(),
                notification
            );
            
            System.out.println("실시간 알림 전송 완료: " + 
                notification.getUserId());
            
        } catch (Exception e) {
            System.err.println("실시간 알림 전송 실패: " + e.getMessage());
            // 실패 시 푸시 알림으로 폴백
            sendPushNotificationFallback(notification);
        }
    }
    
    // 이메일 알림 워커
    @RabbitListener(
        queues = "notification.email.queue",
        concurrency = "3"
    )
    public void handleEmailNotification(EmailNotificationTask task) {
        try {
            User user = userService.getUser(task.getUserId());
            
            emailService.sendEmail(
                user.getEmail(),
                task.getTitle(),
                task.getMessage()
            );
            
            System.out.println("이메일 전송 완료: " + user.getEmail());
            
        } catch (Exception e) {
            System.err.println("이메일 전송 실패: " + e.getMessage());
            // 재시도 또는 DLQ로
            throw new NotificationException("이메일 전송 실패", e);
        }
    }
    
    // 푸시 알림 워커
    @RabbitListener(
        queues = "notification.push.queue",
        concurrency = "5"
    )
    public void handlePushNotification(PushNotificationTask task) {
        try {
            // 사용자의 디바이스 토큰 조회
            List<String> deviceTokens = 
                deviceService.getUserDeviceTokens(task.getUserId());
            
            for (String token : deviceTokens) {
                pushService.sendPush(
                    token,
                    task.getTitle(),
                    task.getMessage()
                );
            }
            
            System.out.println("푸시 알림 전송 완료: " + 
                task.getUserId() + " (" + deviceTokens.size() + "개 디바이스)");
            
        } catch (Exception e) {
            System.err.println("푸시 알림 전송 실패: " + e.getMessage());
            throw new NotificationException("푸시 알림 전송 실패", e);
        }
    }
    
    // SMS 알림 워커 (긴급)
    @RabbitListener(
        queues = "notification.sms.queue",
        concurrency = "2"
    )
    public void handleSmsNotification(SmsNotificationTask task) {
        try {
            User user = userService.getUser(task.getUserId());
            
            smsService.sendSms(
                user.getPhoneNumber(),
                task.getMessage()
            );
            
            System.out.println("SMS 전송 완료: " + user.getPhoneNumber());
            
        } catch (Exception e) {
            System.err.println("SMS 전송 실패: " + e.getMessage());
            // SMS는 비용이 들어가므로 재시도 신중하게
            logSmsFailure(task, e);
        }
    }
}

4. 알림 통계 및 모니터링

@Service
public class NotificationAnalytics {
    
    @Autowired
    private KafkaTemplate<String, Object> kafkaTemplate;
    
    // 알림 전송 결과를 Kafka로 스트리밍
    public void recordNotificationSent(
        String userId,
        NotificationType type,
        boolean success
    ) {
        NotificationMetric metric = NotificationMetric.builder()
            .userId(userId)
            .type(type)
            .success(success)
            .timestamp(LocalDateTime.now())
            .build();
        
        kafkaTemplate.send("notification-metrics", metric);
    }
    
    // Kafka Streams로 실시간 통계
    @Bean
    public KStream<String, NotificationMetric> notificationMetricsStream(
        StreamsBuilder builder
    ) {
        KStream<String, NotificationMetric> stream = 
            builder.stream("notification-metrics");
        
        // 1시간 윈도우로 성공률 계산
        KTable<Windowed<String>, Double> successRate = stream
            .groupBy((key, metric) -> metric.getType().name())
            .windowedBy(TimeWindows.of(Duration.ofHours(1)))
            .aggregate(
                () -> new SuccessRateAccumulator(),
                (key, metric, accumulator) -> {
                    accumulator.incrementTotal();
                    if (metric.isSuccess()) {
                        accumulator.incrementSuccess();
                    }
                    return accumulator;
                }
            )
            .mapValues(acc -> acc.getSuccessRate());
        
        // 성공률이 낮으면 알림
        successRate.toStream()
            .filter((key, rate) -> rate < 0.95)  // 95% 미만
            .foreach((key, rate) -> {
                System.err.println("알림 성공률 저하 감지: " + 
                    key.key() + " - " + (rate * 100) + "%");
                sendAlert("알림 성공률 저하", 
                    "타입: " + key.key() + ", 성공률: " + (rate * 100) + "%");
            });
        
        return stream;
    }
}
🎯 실전 팁: 이 알림 시스템은 실제로 재능넷 같은 플랫폼에서 사용할 수 있는 구조야! Kafka로 이벤트를 스트리밍하고, RabbitMQ로 작업을 분배하는 하이브리드 아키텍처는 각각의 장점을 최대한 활용할 수 있어. 이벤트는 영구 저장되고, 작업은 우선순위에 따라 처리되지! 🚀

🎬 마무리하며

와! 여기까지 정말 긴 여정이었어! 🎉

우리는 자바 메시징 시스템의 세계를 깊이 탐험했어. Apache Kafka와 RabbitMQ의 차이점, 각각의 강점, 실전 활용법, 그리고 트러블슈팅까지 모두 다뤘지. 😊

핵심 정리:

📌 기억해야 할 핵심 포인트

1. 메시징 시스템의 가치 💎
• 비동기 처리로 성능 향상
• 느슨한 결합으로 유연한 아키텍처
• 확장성과 신뢰성 확보
• 마이크로서비스의 필수 요소

2. Kafka vs RabbitMQ ⚖️
• Kafka: 대용량 스트리밍, 이벤트 소싱, 로그 수집
• RabbitMQ: 작업 큐, 복잡한 라우팅, 마이크로서비스 통신
• 상황에 맞게 선택하거나 함께 사용

3. 실전 패턴 🎯
• 멱등성 보장으로 안전한 처리
• Saga 패턴으로 분산 트랜잭션
• CQRS로 읽기/쓰기 최적화
• 모니터링과 알림으로 안정성 확보

4. 성능 최적화 ⚡
• 배치 처리와 압축 활용
• 적절한 파티션/컨슈머 수 설정
• 캐싱과 비동기 처리
• Consumer Lag 모니터링

메시징 시스템은 처음에는 복잡해 보이지만, 한 번 익숙해지면 정말 강력한 도구가 돼. 특히 재능넷 같은 플랫폼을 개발할 때, 사용자 경험을 향상시키고 시스템을 안정적으로 운영하는 데 필수적이야! 🌟

이제 너도 메시징 시스템 전문가가 된 거야! 실제 프로젝트에 적용해보면서 더 많은 경험을 쌓아봐. 처음에는 작은 것부터 시작해서 점점 복잡한 패턴을 적용해나가면 돼. 💪

궁금한 점이 있거나 도움이 필요하면 언제든지 재능넷 커뮤니티에서 물어봐! 함께 성장하는 개발자가 되자! 🚀

행복한 코딩 되길 바라! Happy Coding! 😊✨

댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.