본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 12. 21. · 2 Views
메시지 기반 마이크로서비스 완벽 가이드
Spring Cloud Stream을 활용하여 메시지 기반 마이크로서비스를 구축하는 방법을 실무 관점에서 알아봅니다. 바인더, Supplier/Consumer/Function, 파티셔닝, 에러 처리까지 모든 핵심 개념을 다룹니다.
목차
1. Spring Cloud Stream 소개
어느 날 김개발 씨가 주문 서비스를 개발하던 중, 팀장님께서 말씀하셨습니다. "주문이 완료되면 결제 서비스, 배송 서비스, 알림 서비스에 모두 전달해야 합니다." 김개발 씨는 고민에 빠졌습니다.
각 서비스를 직접 호출해야 할까요?
Spring Cloud Stream은 메시지 기반 마이크로서비스를 쉽게 구축할 수 있게 해주는 프레임워크입니다. 마치 우체국이 편지를 배달하듯이, 서비스 간 데이터를 메시지로 주고받을 수 있습니다.
Kafka, RabbitMQ 같은 메시지 브로커를 추상화하여 일관된 방식으로 사용할 수 있습니다.
다음 코드를 살펴봅시다.
@SpringBootApplication
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication.run(OrderServiceApplication.class, args);
}
// 주문 완료 메시지를 발행하는 간단한 예제
@Bean
public Supplier<OrderEvent> orderSupplier() {
return () -> new OrderEvent("ORDER-001", "주문 완료");
}
}
김개발 씨는 입사 6개월 차 백엔드 개발자입니다. 쇼핑몰 서비스를 개발하던 중, 주문 서비스를 담당하게 되었습니다.
주문이 완료되면 결제 서비스, 배송 서비스, 알림 서비스에 모두 정보를 전달해야 하는 상황입니다. 처음에는 각 서비스의 REST API를 직접 호출하는 방식을 생각했습니다.
하지만 선배 개발자 박시니어 씨가 조언합니다. "직접 호출하면 어느 서비스 하나라도 장애가 나면 전체가 멈춰요.
메시지 기반으로 설계해보는 건 어떨까요?" 그렇다면 메시지 기반 아키텍처란 정확히 무엇일까요? 쉽게 비유하자면, 메시지 기반 통신은 마치 우체국과 같습니다.
편지를 보낼 때 받는 사람이 집에 있는지 확인하지 않습니다. 우체통에 넣으면 우체국이 알아서 배달합니다.
받는 사람은 편한 시간에 편지를 확인합니다. 서비스 간 통신도 이와 같은 방식으로 동작할 수 있습니다.
메시지 기반 통신이 없던 시절에는 어땠을까요? 개발자들은 서비스 간 통신을 위해 REST API를 직접 호출했습니다.
A 서비스가 B, C, D 서비스를 모두 호출해야 했습니다. 문제는 C 서비스가 느려지면 A 서비스도 같이 느려진다는 점이었습니다.
더 큰 문제는 D 서비스가 장애가 나면 전체 트랜잭션이 실패한다는 것이었습니다. 또한 새로운 서비스가 추가될 때마다 기존 코드를 수정해야 했습니다.
E 서비스가 추가되면 A 서비스 코드를 열어서 E 서비스 호출 로직을 추가해야 했습니다. 결합도가 높아질수록 유지보수는 악몽이 되었습니다.
바로 이런 문제를 해결하기 위해 메시지 기반 아키텍처가 등장했습니다. 메시지 브로커를 중간에 두고 서비스들이 메시지를 주고받습니다.
A 서비스는 메시지만 발행하면 됩니다. B, C, D 서비스가 살아있는지, 응답하는지 신경 쓸 필요가 없습니다.
각 서비스는 독립적으로 메시지를 구독하고 처리합니다. 하지만 여기서 또 다른 고민이 생깁니다.
Kafka를 사용할지, RabbitMQ를 사용할지에 따라 코드가 완전히 달라집니다. 나중에 메시지 브로커를 변경하면 전체 코드를 다시 작성해야 할까요?
바로 이 지점에서 Spring Cloud Stream이 빛을 발합니다. Spring Cloud Stream은 메시지 브로커를 추상화합니다.
개발자는 Supplier, Consumer, Function 같은 간단한 인터페이스만 구현하면 됩니다. 어떤 메시지 브로커를 사용할지는 설정 파일에서 결정합니다.
코드는 변경하지 않고 설정만 바꾸면 Kafka에서 RabbitMQ로 전환할 수 있습니다. 위의 코드를 살펴보겠습니다.
Spring Boot 애플리케이션에 @Bean으로 Supplier<OrderEvent>를 등록했습니다. 이것만으로도 주문 이벤트를 발행하는 메시지 프로듀서가 만들어집니다.
Spring Cloud Stream이 자동으로 이 Supplier를 감지하고 메시지 브로커와 연결합니다. 실제 현업에서는 어떻게 활용할까요?
대형 이커머스 플랫폼을 생각해봅시다. 주문이 들어오면 재고 차감, 결제 처리, 배송 준비, 포인트 적립, SMS 발송 등 수많은 작업이 필요합니다.
이 모든 작업을 동기적으로 처리하면 사용자는 주문 완료까지 오래 기다려야 합니다. Spring Cloud Stream을 활용하면 주문 서비스는 주문 정보만 데이터베이스에 저장하고 메시지를 발행합니다.
그러면 각 서비스가 비동기로 메시지를 받아서 처리합니다. 사용자는 빠르게 주문 완료 화면을 보고, 나머지 작업은 백그라운드에서 진행됩니다.
Netflix, Amazon, Uber 같은 글로벌 기업들이 모두 이런 메시지 기반 아키텍처를 사용합니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 모든 통신을 메시지 기반으로 바꾸려는 것입니다. 동기적 응답이 필요한 경우까지 메시지로 처리하면 오히려 복잡도가 높아집니다.
예를 들어 로그인 API는 즉시 성공/실패를 응답해야 하므로 REST API가 적합합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 설명을 들은 김개발 씨는 눈이 반짝였습니다. "아, 그래서 대규모 시스템에서는 메시지 기반으로 설계하는군요!" Spring Cloud Stream을 제대로 이해하면 확장 가능하고 탄력적인 마이크로서비스를 구축할 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 동기 응답이 필요한 경우는 REST API, 비동기 처리가 가능한 경우는 메시지 기반으로 설계합니다
- Spring Cloud Stream을 사용하면 메시지 브로커 변경이 쉬워집니다
- 메시지 기반 통신은 서비스 간 결합도를 낮춰줍니다
2. 바인더 개념
김개발 씨가 Spring Cloud Stream을 적용하려고 dependency를 추가하던 중, 여러 개의 바인더 라이브러리를 발견했습니다. kafka 바인더, rabbitmq 바인더, kinesis 바인더까지.
"이것들은 무엇이고, 왜 필요한 걸까요?" 박시니어 씨에게 물어봤습니다.
**바인더(Binder)**는 Spring Cloud Stream과 실제 메시지 브로커를 연결하는 어댑터입니다. 마치 전원 플러그의 어댑터처럼, 같은 인터페이스로 다양한 메시지 브로커를 사용할 수 있게 해줍니다.
애플리케이션 코드는 바인더를 통해 메시지를 주고받으므로, 브로커가 바뀌어도 코드 변경이 필요 없습니다.
다음 코드를 살펴봅시다.
// pom.xml 또는 build.gradle에서 원하는 바인더를 선택
// Kafka 사용 시
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream-binder-kafka</artifactId>
</dependency>
// RabbitMQ 사용 시
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream-binder-rabbit</artifactId>
</dependency>
// 애플리케이션 코드는 동일하게 유지됩니다
김개발 씨는 Spring Cloud Stream을 프로젝트에 도입하기로 결정했습니다. Maven 의존성을 추가하려고 공식 문서를 보는데, kafka 바인더, rabbitmq 바인더, kinesis 바인더 등 여러 종류가 나열되어 있습니다.
"이것들은 뭐가 다른 거죠?" 김개발 씨가 박시니어 씨에게 물었습니다. 박시니어 씨는 웃으며 대답합니다.
"바인더는 Spring Cloud Stream의 핵심 개념이에요. 이걸 이해하면 왜 Spring Cloud Stream이 강력한지 알 수 있어요." 그렇다면 바인더란 정확히 무엇일까요?
쉽게 비유하자면, 바인더는 마치 여행용 전원 어댑터와 같습니다. 한국에서 사용하던 전자기기를 일본, 미국, 유럽에서도 사용하려면 플러그 모양에 맞는 어댑터가 필요합니다.
전자기기 자체는 변경하지 않고 어댑터만 바꾸면 됩니다. 바인더도 이와 같은 역할을 합니다.
바인더가 없던 시절에는 어땠을까요? 개발자들은 Kafka를 사용하려면 Kafka 클라이언트 라이브러리를 직접 사용했습니다.
Producer, Consumer, Properties 설정을 모두 직접 코딩했습니다. 나중에 회사 정책이 바뀌어서 RabbitMQ로 전환하라는 지시가 떨어지면 어땠을까요?
모든 코드를 다시 작성해야 했습니다. 더 큰 문제는 테스트였습니다.
로컬 환경에서는 RabbitMQ를 사용하고, 개발 서버에서는 Kafka를 사용하고, 운영 서버에서는 AWS Kinesis를 사용한다면? 환경마다 완전히 다른 코드를 관리해야 했습니다.
바로 이런 문제를 해결하기 위해 바인더라는 개념이 등장했습니다. Spring Cloud Stream은 애플리케이션 코드와 메시지 브로커 사이에 바인더 레이어를 둡니다.
애플리케이션은 바인더가 제공하는 표준 인터페이스만 사용합니다. 실제 메시지 브로커와의 통신은 바인더가 담당합니다.
브로커를 바꾸려면 바인더 의존성만 교체하면 됩니다. 위의 코드를 살펴보겠습니다.
Kafka를 사용하려면 spring-cloud-stream-binder-kafka 의존성을 추가합니다. RabbitMQ를 사용하려면 spring-cloud-stream-binder-rabbit을 추가합니다.
중요한 점은 애플리케이션 코드는 전혀 변경하지 않는다는 것입니다. 바인더는 어떻게 동작할까요?
애플리케이션이 메시지를 발행하면, 바인더가 이를 받아서 해당 메시지 브로커의 형식으로 변환합니다. Kafka 바인더는 KafkaProducer를 사용하고, RabbitMQ 바인더는 RabbitTemplate을 사용합니다.
개발자는 이런 세부사항을 신경 쓸 필요가 없습니다. 실제 현업에서는 어떻게 활용할까요?
스타트업에서 서비스를 시작할 때는 설치가 간단한 RabbitMQ를 선택할 수 있습니다. 서비스가 성장해서 초당 수만 건의 메시지를 처리해야 하는 상황이 되면 Kafka로 전환합니다.
이때 바인더 덕분에 비즈니스 로직 코드는 수정하지 않습니다. 또 다른 사례로, 글로벌 서비스를 운영하는 경우 AWS 리전에서는 Kinesis를, Azure 리전에서는 Event Hubs를 사용할 수 있습니다.
같은 코드베이스를 유지하면서 각 클라우드의 네이티브 서비스를 활용할 수 있습니다. 바인더는 단순히 메시지 전송만 추상화하는 것이 아닙니다.
파티셔닝, 컨슈머 그룹, 재시도, 에러 처리 같은 고급 기능도 바인더가 제공합니다. 각 메시지 브로커마다 이런 기능의 구현 방식이 다르지만, 바인더가 통일된 설정 방식을 제공합니다.
하지만 주의할 점도 있습니다. 바인더가 추상화를 제공하지만, 각 메시지 브로커의 특성을 완전히 무시할 수는 없습니다.
Kafka의 파티션 개념과 RabbitMQ의 Exchange 개념은 근본적으로 다릅니다. 추상화 레이어 아래의 동작 원리를 이해하지 못하면 성능 문제나 데이터 유실 같은 이슈를 디버깅하기 어렵습니다.
또한 여러 바인더를 동시에 사용할 수도 있습니다. 하나의 애플리케이션에서 Kafka와 RabbitMQ를 동시에 사용하는 것도 가능합니다.
예를 들어 주문 이벤트는 Kafka로 발행하고, 이메일 발송은 RabbitMQ로 처리할 수 있습니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 설명을 들은 김개발 씨는 감탄했습니다. "와, 그럼 나중에 메시지 브로커를 바꿔도 코드는 안 바꿔도 되는군요!" 바인더 개념을 제대로 이해하면 유연하고 이식성 높은 마이크로서비스를 설계할 수 있습니다.
여러분도 프로젝트 초기에 바인더를 염두에 두고 설계해 보세요.
실전 팁
💡 - 로컬 개발 환경에서는 경량의 RabbitMQ, 운영 환경에서는 Kafka를 사용하는 전략이 효과적입니다
- 바인더를 교체할 때는 설정 파일(application.yml)도 함께 확인해야 합니다
- 여러 바인더를 동시에 사용하면 각 메시지의 특성에 맞는 브로커를 선택할 수 있습니다
3. Supplier, Consumer, Function
김개발 씨가 메시지를 발행하려고 코드를 작성하던 중, 박시니어 씨가 말했습니다. "Supplier를 사용해보세요." 김개발 씨는 고개를 갸우뚱했습니다.
"자바의 함수형 인터페이스 말씀이신가요? 메시지 발행과 무슨 관계가 있죠?"
Spring Cloud Stream은 Supplier, Consumer, Function이라는 자바 함수형 인터페이스를 활용합니다. Supplier는 메시지를 생성하는 프로듀서, Consumer는 메시지를 소비하는 컨슈머, Function은 메시지를 받아서 변환 후 다시 발행하는 프로세서 역할을 합니다.
함수형 프로그래밍의 간결함으로 메시지 처리 로직을 구현할 수 있습니다.
다음 코드를 살펴봅시다.
@Configuration
public class MessageHandlers {
// Supplier: 메시지 생성 (Producer)
@Bean
public Supplier<String> orderCreated() {
return () -> "새 주문이 생성되었습니다: " + System.currentTimeMillis();
}
// Consumer: 메시지 소비 (Consumer)
@Bean
public Consumer<String> logOrder() {
return message -> System.out.println("주문 수신: " + message);
}
// Function: 메시지 변환 (Processor)
@Bean
public Function<String, String> enrichOrder() {
return order -> order + " [처리완료]";
}
}
김개발 씨는 주문 서비스에서 주문 완료 이벤트를 발행하는 기능을 구현하고 있습니다. 처음에는 KafkaTemplate을 직접 사용하려고 했는데, 박시니어 씨가 더 좋은 방법을 알려줍니다.
"Spring Cloud Stream 3.0부터는 함수형 프로그래밍 모델을 사용해요. Supplier, Consumer, Function만 알면 됩니다." 김개발 씨는 대학교 때 배웠던 함수형 프로그래밍이 여기서 나온다는 것에 놀랐습니다.
그렇다면 Supplier, Consumer, Function은 정확히 무엇일까요? 쉽게 비유하자면, 이들은 공장의 생산 라인과 같습니다.
Supplier는 원자재를 공급하는 공급자입니다. 아무 입력 없이 제품을 만들어냅니다.
Consumer는 완성된 제품을 받아서 포장하거나 출하하는 담당자입니다. Function은 중간 가공을 담당합니다.
원자재를 받아서 가공한 후 다음 단계로 넘깁니다. 예전 Spring Cloud Stream 방식은 어땠을까요?
개발자들은 @EnableBinding, @StreamListener, @Output, @Input 같은 어노테이션을 사용했습니다. 설정이 복잡했고, 채널 이름을 문자열로 관리해야 했습니다.
오타가 나면 런타임에 에러가 발생했습니다. 테스트 코드 작성도 까다로웠습니다.
바로 이런 문제를 해결하기 위해 함수형 프로그래밍 모델이 도입되었습니다. Spring Cloud Stream 3.0부터는 단순히 함수를 Bean으로 등록하기만 하면 됩니다.
Spring이 자동으로 이 함수들을 감지하고 메시지 바인딩을 설정합니다. 코드가 간결해지고, 테스트하기도 쉬워졌습니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 Supplier<String> orderCreated()를 보면 아무 파라미터 없이 String을 반환합니다.
이것이 메시지 프로듀서입니다. Spring Cloud Stream은 이 함수를 주기적으로 호출하거나 외부 트리거에 의해 실행합니다.
반환된 문자열이 메시지 브로커로 발행됩니다. 다음으로 Consumer<String> logOrder()는 String을 받아서 아무것도 반환하지 않습니다.
이것이 메시지 컨슈머입니다. 메시지 브로커에서 메시지가 도착하면 이 함수가 자동으로 실행됩니다.
로깅, 데이터베이스 저장, 외부 API 호출 등의 작업을 수행합니다. 마지막으로 Function<String, String> enrichOrder()는 String을 받아서 가공한 후 다시 String을 반환합니다.
이것이 메시지 프로세서입니다. 입력 채널에서 메시지를 받아서 변환한 후 출력 채널로 발행합니다.
여러 Function을 체인으로 연결할 수도 있습니다. 실제 현업에서는 어떻게 활용할까요?
주문 처리 파이프라인을 생각해봅시다. 주문 서비스는 Supplier로 주문 이벤트를 발행합니다.
재고 서비스는 Function으로 재고를 차감하고 재고 차감 완료 이벤트를 발행합니다. 결제 서비스는 또 다른 Function으로 결제를 처리합니다.
알림 서비스는 Consumer로 최종 메시지를 받아서 사용자에게 푸시 알림을 보냅니다. 이렇게 각 서비스가 Supplier, Function, Consumer의 조합으로 구성되면 전체 시스템이 하나의 거대한 파이프라인이 됩니다.
함수 이름도 중요합니다. Spring Cloud Stream은 함수 이름을 기반으로 채널 이름을 자동 생성합니다.
orderCreated라는 Supplier는 orderCreated-out-0이라는 출력 채널을 만듭니다. logOrder라는 Consumer는 logOrder-in-0이라는 입력 채널을 만듭니다.
설정 파일에서 이 채널 이름으로 토픽이나 큐를 바인딩합니다. 여러 함수를 조합할 수도 있습니다.
application.yml에서 spring.cloud.stream.function.definition: enrichOrder|logOrder처럼 설정하면 enrichOrder의 출력이 logOrder의 입력으로 자동 연결됩니다. 파이프라인을 코드 변경 없이 설정만으로 구성할 수 있습니다.
하지만 주의할 점도 있습니다. Supplier는 기본적으로 주기적으로 실행되는 폴링 방식입니다.
실시간 이벤트를 발행하려면 StreamBridge를 함께 사용해야 합니다. 또한 Consumer에서 예외가 발생하면 메시지가 유실될 수 있으므로 에러 처리 전략을 반드시 세워야 합니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 들은 김개발 씨는 감탄했습니다.
"와, 함수만 작성하면 자동으로 메시지 처리가 되는군요!" Supplier, Consumer, Function을 제대로 활용하면 선언적이고 테스트 가능한 메시지 처리 로직을 작성할 수 있습니다. 여러분도 함수형 사고로 메시지 파이프라인을 설계해 보세요.
실전 팁
💡 - Supplier는 폴링 방식이므로 실시간 이벤트 발행은 StreamBridge를 사용합니다
- 함수 이름은 채널 이름의 기반이 되므로 명확하게 짓는 것이 중요합니다
- 여러 Function을 파이프라인으로 연결하면 복잡한 메시지 처리 흐름을 설정만으로 구성할 수 있습니다
4. 메시지 채널 설정
김개발 씨가 Supplier를 작성하고 실행했는데, 메시지가 발행되지 않습니다. 로그를 확인해보니 "destination topic not found" 에러가 보입니다.
박시니어 씨가 말합니다. "application.yml에서 채널과 토픽을 바인딩해야 해요."
메시지 채널 설정은 Spring Cloud Stream의 논리적 채널과 실제 메시지 브로커의 토픽/큐를 연결하는 작업입니다. application.yml 파일에서 바인더 타입, 목적지(destination), 컨슈머 그룹 등을 설정합니다.
마치 전화번호부처럼, 논리적 이름과 실제 주소를 매핑하는 역할을 합니다.
다음 코드를 살펴봅시다.
# application.yml
spring:
cloud:
stream:
# 사용할 함수 정의
function:
definition: orderSupplier;orderConsumer
# 바인딩 설정
bindings:
# orderSupplier 함수의 출력 채널
orderSupplier-out-0:
destination: orders-topic
content-type: application/json
# orderConsumer 함수의 입력 채널
orderConsumer-in-0:
destination: orders-topic
group: order-service-group
content-type: application/json
# Kafka 바인더 설정
kafka:
binder:
brokers: localhost:9092
김개발 씨는 Supplier 함수를 작성하고 애플리케이션을 실행했습니다. 에러는 발생하지 않았지만, Kafka UI에서 확인해보니 메시지가 발행되지 않았습니다.
로그를 자세히 살펴보니 "No destination configured" 같은 메시지가 보입니다. 박시니어 씨가 다가와 말합니다.
"코드만 작성한다고 끝이 아니에요. 설정 파일에서 채널과 토픽을 연결해줘야 합니다." 김개발 씨는 처음 듣는 이야기에 당황했습니다.
그렇다면 메시지 채널 설정이란 정확히 무엇일까요? 쉽게 비유하자면, 채널 설정은 마치 전화번호부와 같습니다.
"사장님"이라는 이름으로 전화를 걸면, 전화번호부가 실제 전화번호로 변환해줍니다. Spring Cloud Stream의 채널도 마찬가지입니다.
orderSupplier-out-0이라는 논리적 이름을 orders-topic이라는 실제 Kafka 토픽으로 변환합니다. 채널 설정이 없으면 어떻게 될까요?
Spring Cloud Stream은 함수 이름을 기반으로 채널 이름을 자동 생성합니다. 하지만 실제 메시지 브로커의 어느 토픽으로 보낼지는 모릅니다.
마치 편지를 썼지만 주소를 쓰지 않은 것과 같습니다. 우체부가 어디로 배달해야 할지 알 수 없습니다.
더 큰 문제는 환경별로 다른 토픽을 사용하고 싶을 때입니다. 개발 환경에서는 dev-orders-topic을, 운영 환경에서는 prod-orders-topic을 사용하려면 어떻게 해야 할까요?
코드를 환경별로 다르게 빌드할 수는 없습니다. 바로 이런 문제를 해결하기 위해 채널 설정을 사용합니다.
application.yml 파일에 모든 바인딩 정보를 선언합니다. 코드는 논리적 채널 이름만 사용하고, 실제 목적지는 설정 파일에서 결정합니다.
환경별로 다른 설정 파일을 사용하면 같은 코드로 여러 환경을 지원할 수 있습니다. 위의 설정을 한 줄씩 살펴보겠습니다.
먼저 function.definition에서 사용할 함수들을 세미콜론으로 구분하여 나열합니다. 여기서는 orderSupplier와 orderConsumer 두 개를 정의했습니다.
Spring Cloud Stream은 이 함수들을 활성화합니다. 다음으로 bindings 섹션에서 각 채널의 세부 설정을 정의합니다.
orderSupplier-out-0은 orderSupplier 함수의 첫 번째 출력 채널입니다. -out-은 출력을 의미하고, -0은 인덱스입니다.
여러 출력이 있으면 -1, -2로 증가합니다. destination: orders-topic은 이 채널이 연결될 실제 Kafka 토픽 이름입니다.
이 토픽이 존재하지 않으면 Kafka가 자동으로 생성할 수도 있지만, 일반적으로는 미리 생성해두는 것이 좋습니다. content-type: application/json은 메시지의 직렬화 형식을 지정합니다.
JSON으로 설정하면 객체를 자동으로 JSON 문자열로 변환합니다. Avro, Protobuf 같은 다른 형식도 지원됩니다.
orderConsumer-in-0은 입력 채널입니다. -in-이 입력을 의미합니다.
같은 orders-topic을 구독합니다. 중요한 설정은 group입니다.
group: order-service-group은 컨슈머 그룹을 지정합니다. Kafka에서 같은 그룹에 속한 컨슈머들은 메시지를 분산해서 받습니다.
그룹이 없으면 모든 인스턴스가 같은 메시지를 중복으로 받습니다. 보통 애플리케이션 이름을 그룹명으로 사용합니다.
마지막으로 kafka.binder.brokers는 Kafka 브로커 주소를 지정합니다. 여러 브로커가 있으면 쉼표로 구분합니다.
실제 현업에서는 어떻게 활용할까요? 대부분의 회사는 환경별 설정 파일을 분리합니다.
application-dev.yml, application-prod.yml처럼 프로파일을 나눕니다. 개발 환경에서는 로컬 Kafka를 사용하고, 운영 환경에서는 클라우드 관리형 Kafka를 사용합니다.
설정만 바꾸면 같은 코드가 모든 환경에서 동작합니다. 또한 Blue-Green 배포 시나리오에서 채널 설정이 중요합니다.
새 버전을 배포할 때 기존 버전과 다른 컨슈머 그룹을 사용하면 메시지가 중복 처리되지 않습니다. 배포가 완료되면 다시 같은 그룹으로 변경합니다.
고급 설정도 가능합니다. 파티션 수, 리플리케이션 팩터, 재시도 정책, DLQ(Dead Letter Queue) 설정 등을 모두 yml 파일에서 제어할 수 있습니다.
코드는 비즈니스 로직에만 집중하고, 인프라 설정은 yml에서 관리합니다. 하지만 주의할 점도 있습니다.
설정 파일의 오타는 런타임에만 발견됩니다. destination: orders-topci 같은 오타가 있으면 컴파일 타임에 잡히지 않습니다.
따라서 설정을 변경할 때는 반드시 로컬에서 테스트를 거쳐야 합니다. 또한 여러 팀이 같은 토픽을 사용할 때는 네이밍 규칙을 정해야 합니다.
{service-name}-{event-type} 형식으로 통일하면 충돌을 방지할 수 있습니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 도움으로 설정을 추가한 김개발 씨는 애플리케이션을 재시작했습니다. 이번에는 Kafka UI에서 메시지가 정상적으로 발행되는 것을 확인할 수 있었습니다.
채널 설정을 제대로 이해하면 유연하고 환경에 독립적인 메시지 기반 시스템을 구축할 수 있습니다. 여러분도 설정과 코드를 분리하는 원칙을 지켜보세요.
실전 팁
💡 - 컨슈머 그룹을 설정하지 않으면 모든 인스턴스가 메시지를 중복 수신하므로 반드시 지정해야 합니다
- 환경별 설정 파일(application-{profile}.yml)을 활용하여 코드 변경 없이 다양한 환경을 지원합니다
- 토픽 이름은 팀 간 네이밍 규칙을 정해서 충돌을 방지합니다
5. 파티셔닝
김개발 씨의 주문 서비스가 성공적으로 런칭되었습니다. 하지만 주문이 폭증하면서 메시지 처리 속도가 느려졌습니다.
박시니어 씨가 제안합니다. "인스턴스를 여러 개 띄워서 부하를 분산시켜보세요.
파티셔닝을 활용하면 됩니다."
**파티셔닝(Partitioning)**은 메시지를 여러 파티션으로 나누어 병렬 처리하는 기법입니다. 특정 키를 기준으로 같은 파티션에 메시지를 보내면, 순서를 보장하면서도 확장성을 확보할 수 있습니다.
마치 은행 창구를 여러 개 두되, 같은 고객은 항상 같은 창구로 안내하는 것과 같습니다.
다음 코드를 살펴봅시다.
# Producer 설정 - 메시지를 파티션별로 분배
spring:
cloud:
stream:
bindings:
orderSupplier-out-0:
destination: orders-topic
producer:
partition-key-expression: headers['customerId']
partition-count: 3
# Consumer 설정 - 특정 파티션만 처리
bindings:
orderConsumer-in-0:
destination: orders-topic
group: order-service-group
consumer:
partitioned: true
# 인스턴스별 파티션 할당
instance-index: 0 # 첫 번째 인스턴스는 0
instance-count: 3 # 총 3개 인스턴스
김개발 씨의 주문 서비스가 큰 성공을 거두었습니다. 하지만 행복도 잠시, 주문이 폭증하면서 단일 인스턴스로는 감당할 수 없게 되었습니다.
메시지가 큐에 쌓이고, 처리 지연이 발생했습니다. 박시니어 씨가 모니터링 대시보드를 보며 말합니다.
"서버를 스케일 아웃해야겠어요. 파티셔닝을 활용하면 여러 인스턴스가 메시지를 나눠서 처리할 수 있습니다." 김개발 씨는 파티셔닝이라는 단어를 들어본 적은 있지만, 정확히 어떻게 동작하는지 몰랐습니다.
그렇다면 파티셔닝이란 정확히 무엇일까요? 쉽게 비유하자면, 파티셔닝은 마치 은행 창구와 같습니다.
은행에 창구가 하나만 있으면 줄이 길게 늘어납니다. 창구를 5개로 늘리면 고객들이 분산됩니다.
하지만 중요한 규칙이 있습니다. 같은 고객은 항상 같은 창구로 안내합니다.
그래야 고객의 거래 순서가 꼬이지 않습니다. 파티셔닝이 없으면 어떻게 될까요?
Kafka 같은 메시지 브로커는 기본적으로 라운드 로빈 방식으로 메시지를 분배합니다. 고객 A의 주문 생성 메시지가 인스턴스 1로, 주문 취소 메시지가 인스턴스 2로 갈 수 있습니다.
두 메시지의 처리 순서가 뒤바뀔 수 있습니다. 취소가 먼저 처리되고 생성이 나중에 처리되면 데이터 정합성이 깨집니다.
또 다른 문제는 컨슈머 그룹의 리밸런싱입니다. 새로운 인스턴스가 추가되거나 제거될 때 파티션 할당이 재조정됩니다.
이 과정에서 메시지 처리가 일시적으로 멈출 수 있습니다. 바로 이런 문제를 해결하기 위해 파티셔닝 전략이 필요합니다.
Kafka는 메시지를 파티션 단위로 저장합니다. 같은 파티션 내에서는 순서가 보장됩니다.
파티션을 여러 개 만들고, 각 컨슈머 인스턴스가 특정 파티션만 담당하면 병렬 처리와 순서 보장을 동시에 달성할 수 있습니다. 위의 설정을 한 줄씩 살펴보겠습니다.
Producer 쪽에서 partition-key-expression: headers['customerId']를 설정했습니다. 이것은 메시지 헤더의 customerId 값을 파티션 키로 사용한다는 의미입니다.
Spring Cloud Stream은 이 키를 해시하여 파티션을 결정합니다. 같은 customerId를 가진 메시지는 항상 같은 파티션으로 갑니다.
partition-count: 3은 총 3개의 파티션을 사용한다는 의미입니다. Kafka 토픽도 3개의 파티션으로 생성되어야 합니다.
파티션 수는 나중에 늘릴 수 있지만 줄일 수는 없으므로 신중하게 결정해야 합니다. Consumer 쪽에서 partitioned: true를 설정하면 파티션을 인식하게 됩니다.
이제 각 인스턴스는 전체 파티션이 아니라 할당된 파티션만 처리합니다. instance-index: 0과 instance-count: 3은 현재 인스턴스의 번호와 전체 인스턴스 수를 지정합니다.
첫 번째 인스턴스는 index 0, 두 번째는 index 1, 세 번째는 index 2를 가집니다. Spring Cloud Stream은 이 정보를 바탕으로 파티션을 자동 할당합니다.
예를 들어 파티션이 3개이고 인스턴스가 3개이면, 인스턴스 0은 파티션 0을, 인스턴스 1은 파티션 1을, 인스턴스 2는 파티션 2를 담당합니다. 균등하게 분배됩니다.
실제 현업에서는 어떻게 활용할까요? 대형 이커머스 플랫폼에서 주문 처리 시스템을 생각해봅시다.
고객별로 주문 순서를 보장해야 합니다. customerId를 파티션 키로 사용하면, 같은 고객의 모든 주문은 같은 파티션, 즉 같은 컨슈머 인스턴스에서 순서대로 처리됩니다.
트래픽이 증가하면 파티션 수와 인스턴스 수를 함께 늘립니다. 파티션을 6개로, 인스턴스도 6개로 늘리면 처리량이 2배가 됩니다.
각 고객의 메시지는 여전히 같은 파티션에 할당되므로 순서가 보장됩니다. 파티션 키 선택도 중요합니다.
파티션 키로 무엇을 선택하느냐에 따라 성능이 크게 달라집니다. 너무 적은 종류의 키를 사용하면 특정 파티션에 메시지가 몰립니다.
예를 들어 성별(남/여)을 파티션 키로 사용하면 파티션 수와 상관없이 두 개의 파티션에만 메시지가 쌓입니다. 반대로 너무 많은 종류의 키를 사용하면 각 파티션이 골고루 분산되지만, 순서 보장의 의미가 희석됩니다.
고객 ID, 주문 ID, 상품 카테고리 ID 같은 적절한 cardinality를 가진 키를 선택해야 합니다. 하지만 주의할 점도 있습니다.
파티션 수는 한 번 정하면 줄이기 어렵습니다. Kafka는 파티션을 늘릴 수는 있지만 줄이는 것은 지원하지 않습니다.
따라서 초기에는 보수적으로 설정하고, 필요에 따라 점진적으로 늘려가는 것이 좋습니다. 또한 파티션 수와 컨슈머 인스턴스 수의 균형이 중요합니다.
파티션이 3개인데 인스턴스가 5개이면, 2개의 인스턴스는 놀게 됩니다. 파티션이 10개인데 인스턴스가 3개이면, 어떤 인스턴스는 4개의 파티션을 처리해야 합니다.
이상적으로는 파티션 수가 인스턴스 수의 배수가 되도록 설정합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 조언대로 파티셔닝을 적용한 김개발 씨는 인스턴스를 3개로 늘렸습니다. 메시지 처리 속도가 3배 가까이 빨라졌고, 각 고객의 주문 순서도 정확하게 유지되었습니다.
파티셔닝을 제대로 이해하면 확장 가능하면서도 순서를 보장하는 메시지 시스템을 구축할 수 있습니다. 여러분도 파티션 키를 신중하게 선택하여 최적의 성능을 끌어내 보세요.
실전 팁
💡 - 파티션 키는 고객 ID, 주문 ID처럼 적절한 cardinality를 가진 값을 선택합니다
- 파티션 수는 예상 트래픽과 인스턴스 수를 고려하여 보수적으로 시작하고 점진적으로 늘립니다
- Kubernetes 환경에서는 StatefulSet의 pod index를 instance-index로 활용할 수 있습니다
6. 에러 처리와 DLQ
김개발 씨의 메시지 컨슈머가 가끔 예외를 던집니다. 외부 API 호출이 실패하거나 데이터 형식이 잘못된 경우입니다.
문제는 같은 메시지를 계속 재처리하다가 결국 메시지가 사라진다는 것입니다. 박시니어 씨가 걱정스러운 표정으로 말합니다.
"DLQ를 설정해야 해요."
**에러 처리와 DLQ(Dead Letter Queue)**는 메시지 처리 실패를 안전하게 관리하는 메커니즘입니다. 재시도 정책을 설정하여 일시적 오류는 자동으로 복구하고, 복구 불가능한 메시지는 DLQ로 보내서 별도 처리합니다.
마치 우편물이 배달 실패하면 반송 센터로 보내지는 것과 같습니다.
다음 코드를 살펴봅시다.
spring:
cloud:
stream:
bindings:
orderConsumer-in-0:
destination: orders-topic
group: order-service-group
consumer:
# 최대 재시도 횟수
max-attempts: 3
# 재시도 간격 (밀리초)
back-off-initial-interval: 1000
back-off-multiplier: 2.0
back-off-max-interval: 10000
kafka:
bindings:
orderConsumer-in-0:
consumer:
# DLQ 활성화
enable-dlq: true
# DLQ 토픽 이름
dlq-name: orders-topic-dlq
# DLQ로 보낼 때 원본 메시지와 에러 정보 포함
dlq-producer-properties:
configuration:
key.serializer: org.apache.kafka.common.serialization.StringSerializer
김개발 씨의 주문 처리 서비스가 안정적으로 운영되고 있었습니다. 하지만 어느 날 모니터링 알람이 울렸습니다.
일부 주문이 처리되지 않고 사라진다는 리포트였습니다. 로그를 확인해보니 외부 결제 API 호출이 간헐적으로 실패하고 있었습니다.
더 큰 문제는 실패한 메시지가 계속 재처리되다가 결국 사라진다는 것이었습니다. 박시니어 씨가 심각한 표정으로 말합니다.
"메시지 손실은 절대 있어서는 안 됩니다. DLQ를 반드시 설정해야 합니다." 그렇다면 에러 처리와 DLQ란 정확히 무엇일까요?
쉽게 비유하자면, DLQ는 마치 우편물 반송 센터와 같습니다. 우체부가 3번 방문했는데도 수령인을 찾지 못하면, 우편물을 반송 센터로 보냅니다.
거기서 별도로 처리합니다. 메시지도 마찬가지입니다.
여러 번 처리를 시도했는데 계속 실패하면, DLQ라는 별도 토픽으로 보냅니다. 에러 처리가 없으면 어떻게 될까요?
메시지 처리 중 예외가 발생하면 컨슈머는 해당 메시지를 다시 읽습니다. 무한 루프에 빠질 수 있습니다.
잘못된 형식의 메시지 하나 때문에 전체 메시지 처리가 멈춥니다. 뒤에 있는 정상 메시지들도 처리되지 못합니다.
더 심각한 것은 기본 재시도 횟수가 지나면 메시지가 자동으로 커밋됩니다. 즉, 처리 실패했는데도 성공으로 간주되어 메시지가 사라집니다.
금융 시스템에서 이런 일이 발생하면 큰 사고입니다. 바로 이런 문제를 해결하기 위해 재시도 정책과 DLQ가 필요합니다.
재시도 정책은 일시적 오류를 자동으로 복구합니다. 네트워크 순간 장애, 외부 API의 일시적 응답 지연 같은 경우는 몇 초 후에 다시 시도하면 성공할 가능성이 높습니다.
Spring Cloud Stream은 지수 백오프(exponential backoff)를 지원하여 재시도 간격을 점진적으로 늘립니다. 하지만 재시도로도 해결되지 않는 오류가 있습니다.
데이터 형식이 잘못되었거나, 비즈니스 규칙을 위반한 경우입니다. 이런 메시지는 아무리 재시도해도 성공하지 못합니다.
이때 DLQ로 보냅니다. 위의 설정을 한 줄씩 살펴보겠습니다.
max-attempts: 3은 메시지 처리를 최대 3번 시도한다는 의미입니다. 첫 시도 + 2번의 재시도입니다.
3번 모두 실패하면 DLQ로 이동합니다. back-off-initial-interval: 1000은 첫 재시도 전에 1초를 기다린다는 의미입니다.
첫 시도가 실패하면 1초 후에 두 번째 시도를 합니다. back-off-multiplier: 2.0은 재시도 간격을 2배씩 늘린다는 의미입니다.
첫 재시도는 1초 후, 두 번째 재시도는 2초 후, 세 번째는 4초 후에 실행됩니다. 이것이 지수 백오프입니다.
back-off-max-interval: 10000은 재시도 간격의 최대값입니다. 아무리 늘어나도 10초를 넘지 않습니다.
무한정 기다리는 것을 방지합니다. enable-dlq: true는 DLQ 기능을 활성화합니다.
이 설정이 없으면 재시도 실패 후 메시지가 그냥 사라집니다. dlq-name: orders-topic-dlq는 DLQ의 토픽 이름입니다.
보통 원본 토픽 이름에 -dlq 접미사를 붙입니다. 이 토픽을 별도로 모니터링하여 실패한 메시지를 파악합니다.
실제 현업에서는 어떻게 활용할까요? DLQ에 쌓인 메시지들은 별도의 관리자 대시보드에서 확인합니다.
실패 원인을 분석하고, 수동으로 재처리하거나 데이터를 수정합니다. 예를 들어 외부 API가 며칠간 다운되었다면, API가 복구된 후 DLQ의 메시지를 다시 원본 토픽으로 보내서 재처리할 수 있습니다.
일부 회사는 DLQ 메시지를 Slack이나 PagerDuty로 알람을 보냅니다. 실패한 메시지가 발생하면 즉시 담당자가 인지하고 조치를 취합니다.
에러 처리 전략도 비즈니스에 따라 다릅니다. 금융 시스템처럼 메시지 손실이 절대 있어서는 안 되는 경우, 재시도 횟수를 늘리고 DLQ를 반드시 설정합니다.
반면 로그 수집이나 통계 같은 경우, 일부 메시지 손실이 허용되면 재시도 없이 바로 버릴 수도 있습니다. 커스텀 에러 핸들러도 구현할 수 있습니다.
특정 예외 타입에 따라 다르게 처리할 수 있습니다. IllegalArgumentException은 바로 DLQ로 보내고, TimeoutException은 재시도합니다.
Spring Cloud Stream의 ErrorHandler 인터페이스를 구현하면 세밀한 제어가 가능합니다. 하지만 주의할 점도 있습니다.
재시도 간격이 너무 길면 메시지 처리 지연이 발생합니다. 10초씩 3번 재시도하면 최대 30초가 걸립니다.
그 사이에 다른 메시지들도 대기해야 합니다. 병렬 처리를 고려하거나, 재시도 간격을 적절히 조정해야 합니다.
또한 DLQ도 무한정 커질 수 있습니다. 주기적으로 DLQ를 정리하는 배치 작업이 필요합니다.
오래된 메시지는 아카이빙하거나 삭제합니다. DLQ에서 메시지를 재처리할 때는 중복 처리 문제도 고려해야 합니다.
이미 부분적으로 처리된 메시지를 다시 처리하면 중복이 발생할 수 있습니다. 멱등성(idempotency)을 보장하는 설계가 중요합니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 조언대로 DLQ를 설정한 김개발 씨는 더 이상 메시지가 사라지지 않는다는 것을 확인했습니다.
DLQ에 쌓인 메시지들을 분석해보니, 대부분 외부 API의 일시적 장애였습니다. API가 복구된 후 DLQ의 메시지를 재처리하여 모든 주문을 정상적으로 완료했습니다.
에러 처리와 DLQ를 제대로 이해하면 안정적이고 복원력 있는 메시지 시스템을 구축할 수 있습니다. 여러분도 메시지 손실을 방지하고 에러를 체계적으로 관리해 보세요.
실전 팁
💡 - DLQ는 메시지 손실을 방지하는 마지막 안전장치이므로 반드시 설정합니다
- 재시도 간격은 지수 백오프를 사용하여 시스템 부하를 분산시킵니다
- DLQ에 쌓인 메시지는 주기적으로 모니터링하고 원인을 분석하여 시스템을 개선합니다
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
Istio 설치와 구성 완벽 가이드
Kubernetes 환경에서 Istio 서비스 메시를 설치하고 구성하는 방법을 초급 개발자도 쉽게 이해할 수 있도록 실무 스토리와 비유로 풀어낸 가이드입니다. istioctl 설치부터 사이드카 주입까지 단계별로 학습합니다.
서비스 메시 완벽 가이드
마이크로서비스 간 통신을 안전하고 효율적으로 관리하는 서비스 메시의 핵심 개념부터 실전 도입까지, 초급 개발자를 위한 완벽한 입문서입니다. Istio와 Linkerd 비교, 사이드카 패턴, 실무 적용 노하우를 담았습니다.
Helm 마이크로서비스 패키징 완벽 가이드
Kubernetes 환경에서 마이크로서비스를 효율적으로 패키징하고 배포하는 Helm의 핵심 기능을 실무 중심으로 학습합니다. Chart 생성부터 릴리스 관리까지 체계적으로 다룹니다.
관찰 가능한 마이크로서비스 완벽 가이드
마이크로서비스 환경에서 시스템의 상태를 실시간으로 관찰하고 모니터링하는 방법을 배웁니다. Resilience4j, Zipkin, Prometheus, Grafana, EFK 스택을 활용하여 안정적이고 관찰 가능한 시스템을 구축하는 실전 가이드입니다.
Prometheus 메트릭 수집 완벽 가이드
Spring Boot 애플리케이션의 메트릭을 Prometheus로 수집하고 모니터링하는 방법을 배웁니다. Actuator 설정부터 PromQL 쿼리까지 실무에 필요한 모든 내용을 다룹니다.