들어가며
메시징은 마이크로서비스의 혈관이다. 그러나 현실의 메시징 인프라는 “브로커 혼용, 불일치한 컨슈머 정책, 산발적인 직렬화 규약, 미흡한 트레이싱/관측성, 표준화되지 않은 재시도/DEAD 처리”라는 복합 문제를 안고 있는 경우가 많다. 본 글은 이러한 문제를 체계적으로 요약하고, 본격 도입에 앞서 안전하게 실험(Playground)하기 위해 만든 리포지토리와 설계 아이디어를 공유한다. 최종적으로는 도메인 코드가 브로커 구현에 의존하지 않도록 추상화(DIP)를 적용하고, Outbox/Producer/Consumer/트레이싱/관측성을 하나의 스타터로 수렴하는 여정을 제시한다.
이 글은 다음 순서로 진행된다.
- 문제점만 요약
- 그래서 본격 도입 전에 실험실(Playground)을 만들었다
- 일반적인 메시징 큐 시스템에서 흔한 문제들
- 해결을 위한 첫 도입: Outbox 패턴
- Outbox의 부족한 점들
- 이를 해결하기 위한 추가 도입: Envelope 표준, 재시도/지수 백오프/지터/DLQ, 표준 헤더/트레이싱, DIP 기반 messaging-starter
- 실험 절차와 체크리스트, 예시 시나리오
- 마무리: 스타터로 수렴하는 로드맵과 기대 효과
리포지토리 전체는 여기에 공개되어 있다: https://github.com/DDINGJOO/infra_messaging_playground
1) 문제점만 요약
하단 요약은 여러 코드/문서 분석 결과를 합친 것이다.
브로커 혼용으로 인한 복잡도 상승
- Kafka와 RabbitMQ를 동시에 사용하며, 서비스마다 설정과 토픽/큐명이 다르게 하드코딩됨.
- 운영 모니터링/경보/배포 검증이 이원화되어 인지 부하가 큼.
Consumer 표준 부족과 보일러플레이트
- @KafkaListener/@RabbitListener 기반 소비자가 서비스 곳곳에 산재.
- 역직렬화 실패/핸들러 예외/재시도/DLQ 정책이 제각각이며, Poison 메시지에 취약.
토픽/큐 네이밍 하드코딩, 버전/환경 전환 비용 증가
- 도메인/버전/환경 기준의 명확한 네이밍 컨벤션과 프로퍼티 표준이 약함.
Outbox 처리 표준화 미흡
- OutboxEventPublisher/Processor는 있으나, 스케줄링/락/백오프/DEAD/DLQ/메트릭 정책이 균일하지 않음.
- 직렬화 실패 시 Outbox 적재 자체가 실패하는 경우가 있어 운영 가시성이 떨어짐.
직렬화/컨트랙트 일관성 부족
- 이벤트 타입/버전/Trace/발생시각 등 메타를 담는 표준 래퍼(Envelope)가 부재 또는 약함.
- 하위호환/스키마 진화 전략이 모호.
트레이싱/로깅/메트릭 미흡
- traceId/correlationId 전파가 불균일하고, 처리량/지연/실패율 등 핵심 지표가 일관 수집되지 않음.
이 문제를 해결하려면 “설계 원칙(의존성 역전), 표준 Envelope, Outbox 재설계, 공통 Consumer 정책, 트레이싱/메트릭, 설정 일원화, 모듈 수렴”이 필요하다. 하지만 대규모 변경은 위험하다. 그래서 우리는 먼저 작은 실험실을 만들었다.
2) 왜 ‘실험실(Playground)’을 만들었나
대규모 리팩토링이나 인프라 표준화는 실패 비용이 크다. 실제 서비스에 바로 적용하기 전에, 핵심 아이디어를 빠르게 실험하고 학습 곡선을 낮추며 팀 합의를 이끌어야 한다.
목표:
- Outbox 패턴으로 안전한 발행 보장을 로컬에서 검증
- Kafka/Rabbit 전환 가능(옵션) 구조의 Producer/Consumer를 간단히 체험
- 실패/재시도/DEAD/DLQ/트레이싱/메트릭이 한 번에 돌아가는지 엔드투엔드 확인
- “도메인은 추상화(API)만 의존”하고, 구체 구현은 스타터/인프라 모듈이 책임지는 DIP 원칙 검토
결과:
- 공통 API와 구현을 분리한 멀티모듈 레이아웃을 구성
- Outbox 스키마와 Envelope 계약을 도입한 샘플 플로우를 작성
- Consumer 예제로 Kafka, Rabbit 수신을 모두 시험
- 실패 주입 시 재시도/DEAD/DLQ 흐름을 관찰
리포지토리 링크: https://github.com/DDINGJOO/infra_messaging_playground
3) 일반적인 메시징 큐 시스템에서 흔한 문제
메시징은 “확률적 실패”가 일상이다. 다음 문제는 거의 모든 조직에서 반복된다.
- 토픽/큐 네이밍 혼선: 환경, 도메인, 버전이 뒤섞여 운영 환경별 차이 발생.
- 재시도 전략 부재: 일시적 장애와 영구 실패를 구분하지 못해 무한 재시도나 조용한 유실 발생.
- Poison 메시지: 역직렬화 실패나 비즈니스 불변 위반 데이터가 큐를 막음.
- 순서 보장 실패: Kafka key를 지정하지 않거나 파티셔닝 전략이 없어서 순서를 요구하는 이벤트가 뒤섞임.
- 트레이싱 누락: traceId/correlationId 전파가 안 되어, 생산-소비-실패를 한 눈에 추적하기 어려움.
- Consumer 보일러플레이트: 에러 핸들러, DLQ, 백오프, 헤더 처리 등을 매번 새로 작성.
- 스키마 진화 위험: payload만 보내고 메타가 없어 버전 호환/분기 처리가 난해.
이 문제는 개별 컨슈머의 노력으로는 해결하기 어렵다. 표준과 추상화를 통한 일원화가 필요하다.
4) 해결을 위한 첫 도입: Outbox 패턴
Outbox는 “DB 트랜잭션과 메시지 발행을 분리하면서도 at-least-once 전송을 보장”하는 대표적 패턴이다.
핵심 아이디어:
- 애플리케이션 트랜잭션 안에서 이벤트를 Outbox 테이블에 기록(PENDING)
- 별도 Processor가 주기적으로 Outbox를 폴링하여 브로커로 전송
- 성공 시 SENT, 실패 시 FAILED(+nextAttemptAt), 한도 초과 시 DEAD로 전이
장점:
- 데이터 변경과 이벤트 발행 간 일관성 확보
- 브로커 장애 상황에서도 재시도 가능
- 운영자가 상태 전이를 통해 장애 원인을 관찰/대응 가능
Playground에서는 다음과 같이 적용했다.
- common/outbox에 OutboxEventEntity/Repository/Processor/Properties 구현
- common/event에 Envelope, DomainEventPublisher 인터페이스 제공
- services/serviceA에서 발행 샘플, services/serviceB에서 수신 샘플 제공
그러나 Outbox만으로 모든 문제가 해결되지는 않는다.
5) Outbox의 부족한 점들(실제 현장에서 드러난 한계)
라우팅 필드의 모호성
- topic 필드를 Kafka 토픽과 Rabbit 익스체인지로 겸용하면 의미 충돌이 발생.
- routingKey를 event name으로 채워 넣는 등 일관되지 않은 사용.
이벤트 메타 정보 부족
- payload만 저장하면 발생시각, 이벤트 ID, 버전, 프로듀서 앱, traceId를 잃는다.
- 장애 분석과 하위호환 전략 수립이 어렵다.
직렬화 실패 경로 부재
- 직렬화 실패 시 Outbox에 적재 자체가 실패하는 설계는 문제의 은닉을 낳는다.
재시도 전략 단순
- 고정 지연 방식은 장애 패턴에 취약. 지수 백오프와 지터가 필요.
DEAD 처리의 운영 전략 부재
- 단순 DEAD 마킹만 하면 운영 핸드오프가 막힌다. DLQ 라우팅 규칙과 모니터링이 필요.
동시성/중복 전송 우려
- ShedLock만으로는 불충분한 경우가 있으며, 낙관적 락(@Version) 같은 보조 전략이 필요.
파티셔닝/순서 보장 미흡
- Kafka 메시지 key가 없으면 스큐와 순서 깨짐 문제가 빈발.
관측성 부족
- 처리량/지연/실패율/백로그/DLQ 유입 등 메트릭이 없으면 운영자가 상황을 모른다.
이 한계를 보완하기 위해, 우리는 Envelope 표준, 재시도/백오프/지터, DLQ 네이밍 규칙, 트레이싱/헤더 표준, DIP 기반 스타터를 함께 도입한다.
6) 보완책 1: Event Envelope 표준
Envelope는 “메타 + 페이로드”를 감싸는 표준 래퍼다. 최소 구성:
- id, type, version, occurredAt
- producer(service/env/host)
- trace(traceId, correlationId)
- routing(broker, kafka.topic/key, rabbit.exchange/routingKey)
- payload
효과:
- 소비자에서 버전/타입 분기와 트레이싱 복원이 용이
- 메시지 헤더 주입 표준화(X-Event-Type, X-Event-Version, X-Trace-Id, X-Occurred-At, X-Producer-Service)
- 장애 분석/운영 가시성 강화
리포지토리 반영:
- common/event/src/main/java/.../Envelope.java: Envelope 계약 정의
- common/data-serializing/.../EnvelopeDeserializer.java: 역직렬화 유틸
- services/serviceB/.../PayloadMappingConfig.java: Payload 타입 매핑 예시
7) 보완책 2: 재시도 전략(지수 백오프 + 지터)와 DLQ 규칙
- 재시도: base-seconds, max-seconds, jitter-rate를 프로퍼티화하여 선형이 아닌 지수 백오프를 적용. 지터로 동시 폭주를 완화.
- DEAD/DLQ: 임계 초과 시 DEAD 전이, DLQ 라우팅 이름 규칙은 Kafka
<topic>.DLQ, Rabbit<exchange>.dlx+<routingKey>.dlq. - 메트릭: outbox_processed_total, outbox_failed_total, outbox_dead_total, backlog_gauge, processing_latency_histogram 등 수집.
리포지토리 반영:
- common/outbox/.../OutboxProperties.java: retry/backoff/dlq/schedule/batch 같은 설정 포인트
- common/outbox/.../OutboxProcessor.java: 상태 전이와 전송 로직
8) 보완책 3: DIP(의존성 역전)와 스타터(Starter)
원칙:
- 도메인 코드는 “추상화(Port)”에만 의존한다.
- 구체 구현(Adapter)인 Kafka/Rabbit/Outbox/Serializer는 스타터가 자동 구성한다.
API:
- DomainEventPublisher.publish(event)
- DomainEventPublisher.publish(event, RoutingOptions)
- @EventConsumer(topic = "...", version = n)
효과:
- 서비스 코드 경량화, 브로커 전환 용이, 에러/재시도/DLQ/헤더/트레이싱 표준화
리포지토리 반영:
- common/event: BrokerType, CustomEvent, DomainEventPublisher, Envelope, Routing, RoutingOptions, TraceInfo 등
- common/infra-messaging: MessagingAutoConfig/MessagingProperties, EventProducer(Logging/Real), Kafka/Rabbit 분기
- common/outbox: MessagingStarterAutoConfiguration, DomainEventPublisherImpl(Outbox 경유 발행), OutboxProcessor
서비스 사용 예시:
- services/serviceA/src/.../DemoController.java: HTTP 요청 시 발행 샘플
- services/serviceB/src/.../KafkaDemoConsumer.java, RabbitDemoConsumer*.java: 소비 샘플
9) Playground 구조 한눈에 보기
멀티모듈(요약):
- common/event: 추상 API와 Envelope 계약
- common/infra-messaging: 브로커별 Producer, 오토컨피그
- common/outbox: Outbox 엔티티/퍼블리셔/프로세서/프로퍼티
- services/serviceA: 발행 샘플 서비스
- services/serviceB: 소비 샘플 서비스(Kafka/Rabbit 둘 다 예시)
핵심 파일 포인터:
- common/event/build.gradle, .../api/*.java
- common/infra-messaging/src/main/java/.../config/MessagingAutoConfig.java
- common/outbox/src/main/java/.../DomainEventPublisherImpl.java, OutboxProcessor.java
- services/serviceB/.../PayloadMappingConfig.java, KafkaDemoConsumer.java
10) 설정 예시(application.yaml)
리포지토리 예시를 참고하여 다음과 같은 키를 권장한다.
server:
port: 8081
spring:
application:
name: ${APP_NAME:sample-service}
datasource:
url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:app_db}
username: ${DB_USER:username}
password: ${DB_PASS:password}
messaging:
type: kafka # kafka | rabbit
kafka:
bootstrap-servers: ${KAFKA_BOOTSTRAP:localhost:9092}
rabbit:
host: ${RABBIT_HOST:localhost}
port: ${RABBIT_PORT:5672}
username: ${RABBIT_USER:guest}
password: ${RABBIT_PASS:guest}
tracing:
propagation: true
outbox:
enabled: true
batch:
size: 200
schedule:
delay-ms: 1000
retry:
max-attempts: 10
backoff:
base-seconds: 5
max-seconds: 300
jitter-rate: 0.1
dead-letter:
enabled: true
kafka-suffix: ".DLQ"
rabbit-suffix: ".dlq"
11) Outbox 테이블(DDL 예시)
CREATE TABLE event_outbox (
id BIGINT PRIMARY KEY,
broker_type VARCHAR(16) NOT NULL,
kafka_topic VARCHAR(255),
rabbit_exchange VARCHAR(255),
rabbit_routing_key VARCHAR(255),
message_key VARCHAR(255),
envelope LONGTEXT,
status VARCHAR(16) NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
next_attempt_at DATETIME NULL,
last_error_message VARCHAR(1024),
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
sent_at DATETIME NULL,
version INT NULL
);
CREATE INDEX idx_status_next_attempt_at ON event_outbox(status, next_attempt_at);
CREATE INDEX idx_created_at ON event_outbox(created_at);
CREATE INDEX idx_broker_type ON event_outbox(broker_type);
핵심 포인트:
- kafka_topic/rabbit_exchange/rabbit_routing_key/message_key를 분리하여 의미 충돌 제거
- envelope에 메타+페이로드 전체를 저장해 추적성 확보
- last_error_message를 충분히 길게 확보하여 장애 분석에 유용
12) 이벤트 계약과 라우팅 컨벤션
- Kafka 토픽:
<domain>.<aggregate>.<event>.<version>예)user.profile.updated.v2 - Rabbit 교환:
<domain>.events, 라우팅키:<aggregate>.<event>[.v2] - DLQ 네이밍: Kafka
<topic>.DLQ, Rabbit<exchange>.dlx + <routingKey>.dlq - RoutingOptions: 특정 이벤트에 대해 kafkaKey(파티션 키) 또는 routingKey를 오버라이드하여 순서/라우팅을 보장
13) 실험 절차(로컬)
1) 브로커/DB 준비: Docker로 Kafka(or Rabbit) + MySQL 기동
2) 스키마 적용: event_outbox DDL 반영
3) 빌드: ./gradlew :common:outbox:build :common:infra-messaging:build
4) Producer 서비스(serviceA) 기동 → POST /api/demo/publish
5) OutboxProcessor 로그에서 SENT/FAILED/DEAD 전이를 확인
6) Consumer 서비스(serviceB) 기동 → KafkaDemoConsumer/RabbitDemoConsumer 수신 로그 확인
7) 실패 시나리오 주입
- 컨슈머에서 예외 유발 → 재시도/백오프/DLQ 확인
- 잘못된 payload → FAILED 적재 및 last_error_message 확인
8) 브로커 전환 테스트:messaging.type = rabbit로 바꾸고 동일 플로우 재검증
검증 체크리스트:
- Outbox INSERT → Processor → Broker 전송 → Consumer 수신까지 엔드투엔드 동작 확인
- retry_count/nextAttemptAt가 지수 백오프+지터 규칙을 따르는지
- DLQ 라우팅 규칙 일관성
- Kafka key 기반 순서 보장 확인
- 헤더/트레이싱 전파 및 MDC 복원 확인
- 메트릭이 수집되고 대시보드/알람에 노출되는지
14) 문제 → 해결 스토리 7가지
1) Rabbit 라우팅 혼선
- 문제: topic을 교환/큐로 혼용
- 해결:
rabbit_exchange와rabbit_routing_key를 분리, Producer가 exchange+routingKey로 전송
2) Kafka 순서 보장 실패
- 문제: key 미지정
- 해결: RoutingOptions.kafkaKey(예: userId)로 지정하여 동일 key 내 순서 보장
3) 직렬화 실패로 Outbox 누락
- 문제: serialize 실패 시 orElseThrow로 사라짐
- 해결: 직렬화 실패도 FAILED로 Outbox에 적재하고 last_error_message 기록
4) Poison 메시지 무한 재시도
- 문제: 역직렬화/불변 위반 반복 실패
- 해결: 재시도 한도 초과 시 DEAD 전이 후 DLQ로 격리
5) 중복 전송/경쟁 상태
- 문제: 다중 인스턴스 동시 처리
- 해결: ShedLock + (옵션) @Version 낙관적 락, Kafka idempotence/acks=all
6) 버전 호환성 파손
- 문제: 컨슈머가 구버전만 처리 가능
- 해결: Envelope.version 기반 분기, 전환 기간 dual-format 지원
7) 운영 가시성 부족
- 문제: 무슨 일이 일어나는지 모름
- 해결: Micrometer 지표와 구조화 로그, traceId로 생산-소비-실패를 연결
15) 도메인 개발자가 보는 API 표면
- 발행
publisher.publish(CustomEvent event)publisher.publish(CustomEvent event, RoutingOptions opts)
- 소비
@EventConsumer(topic="...", version=2)혹은 기존@KafkaListener/@RabbitListenervoid handle(Envelope<T> e)형태의 핸들러
도메인 코드는 브로커/직렬화/헤더/재시도/로그를 신경 쓰지 않는다. “발행만” 하면 나머지는 인프라가 책임진다.
16) 코드 포인터와 읽기 가이드
- common/event
Envelope,Routing,TraceInfo,ProducerInfo,DomainEventPublisher
- common/infra-messaging
MessagingAutoConfig,MessagingProperties,EventProducer,LoggingEventProducer,RealEventProducer
- common/outbox
MessagingStarterAutoConfiguration,OutboxProperties,OutboxProcessor,DomainEventPublisherImpl,OutboxEventEntity
- services/serviceB
KafkaDemoConsumer.java,RabbitDemoConsumer.java,PayloadMappingConfig.java
이 파일들을 따라가며 “발행 → Outbox 적재 → Processor 전송 → 소비”의 흐름을 확인해보자.
17) 트레이싱/헤더/로그/메트릭 권장
- 헤더: X-Event-Type, X-Event-Version, X-Trace-Id, X-Occurred-At, X-Producer-Service
- MDC: Envelope.trace에서 traceId/correlationId를 꺼내 소비자 측 MDC에 복원
- 로그: Envelope id/type/topic(exchange)/status를 구조화 로그로 남기기
- 메트릭: outbox_processed_total, outbox_failed_total, outbox_dead_total, backlog_gauge, latency_histogram
18) 운영 체크리스트(요약)
- Outbox INSERT 후 SENT 전이 확인
- 직렬화 실패가 FAILED로 남는가
- retry_count/nextAttemptAt 계산이 백오프 규칙을 따르는가
- DLQ 네이밍 규칙 일관성
- Kafka key 기반 순서 보장
- traceId 전파 및 MDC 복원
- 메트릭 노출 및 알람 설정
19) 마이그레이션 전략(서비스 적용 시)
1) 스키마 확장: envelope, message_key, rabbit_exchange, sent_at, version(@Version) 추가
2) Publisher 전환: 기존 OutboxEventPublisher/직접 Producer 호출 → DomainEventPublisher.publish(...)로 래핑
3) Processor: Envelope 우선, 레거시 필드 fallback
4) Consumer: Envelope 역직렬화 우선, 전환 기간 dual-format
5) 운영: DLQ/재시도/알람 검증 후 정책 튜닝
6) 정리: 전 프로젝트 적용 완료 후 레거시 필드 제거
20) 정리: 스타터로 수렴하는 이유
- 의존 축소: 다수 공용 모듈을 하나의 스타터로 수렴 → 빌드/배포/버전 관리 단순화
- 보일러플레이트 제거: 발행/소비 코드 간소화, 표준 에러/재시도/DLQ/헤더/트레이싱 자동 적용
- 운영 일관성: 공통 메트릭/로그/알람 스키마로 관측성 체계화
- 브로커 전환 용이:
messaging.type만 바꾸면 서비스 코드는 그대로
다음 단계:
- 파일럿 1~2개 서비스에 PoC 스타터 적용
- 회귀 테스트/운영 대시보드 구축
- @EventConsumer 유틸과 DLQ/Backoff 표준 릴리즈
- 전사 문서화/마이그레이션 가이드 확정 및 확산
부록 A) 요청-응답형 API와 이벤트의 경계
메시징은 결국 일관성 있는 상태 전파를 위한 수단이다. 요청-응답 API와 이벤트의 경계를 명확히 하되, 이벤트는 “사실의 기록”으로 접근하자. Outbox는 그 기록을 잃지 않게 해준다.
부록 B) 비용과 리스크
- 페이로드 포맷 변경은 전환 비용을 유발한다 → dual-format 기간과 버전 관리가 필수
- DB 부하와 보관 주기 → 인덱스/배치/아카이빙/청소 정책을 함께 가져가자
- 락 전략 충돌 → 운영 환경에 따라 ShedLock/Optimistic Lock 조합을 튜닝
마무리
실험은 작은 성공과 빠른 실패를 안전하게 허용한다. 이 Playground는 Outbox/Envelope/재시도/지수 백오프/지터/DLQ/트레이싱/메트릭/DIP를 한 번에 경험하는 장이다. 이 경험이 쌓이면, 실서비스에서는 messaging-starter 하나로 발행/소비를 표준화할 수 있다.
'BindProject' 카테고리의 다른 글
| 리뷰 모듈 기획·설계·구현 정리서 (review × bandroom-info 메시징 & 캐싱 중심) (8) | 2025.08.16 |
|---|---|
| 지금까지 프로젝트 리팩토링 시즌 도입 (7) | 2025.08.12 |
| 구현된 모든 서비스를 컨테이너화 시키는 여정기 (7) | 2025.08.05 |
| 서버 구축 과정 Docker-Compose와 Nginx로 시작하는 마이크로서비스 아키텍처 (5) | 2025.08.04 |
| 유저 닉네임 캐시전략 및 캐시데이터 무결성 (3) | 2025.08.02 |