BindProject

메시징 플레이그라운드 만들기

dding-shark 2025. 8. 12. 18:14
728x90

들어가며

메시징은 마이크로서비스의 혈관이다. 그러나 현실의 메시징 인프라는 “브로커 혼용, 불일치한 컨슈머 정책, 산발적인 직렬화 규약, 미흡한 트레이싱/관측성, 표준화되지 않은 재시도/DEAD 처리”라는 복합 문제를 안고 있는 경우가 많다. 본 글은 이러한 문제를 체계적으로 요약하고, 본격 도입에 앞서 안전하게 실험(Playground)하기 위해 만든 리포지토리와 설계 아이디어를 공유한다. 최종적으로는 도메인 코드가 브로커 구현에 의존하지 않도록 추상화(DIP)를 적용하고, Outbox/Producer/Consumer/트레이싱/관측성을 하나의 스타터로 수렴하는 여정을 제시한다.

이 글은 다음 순서로 진행된다.

  • 문제점만 요약
  • 그래서 본격 도입 전에 실험실(Playground)을 만들었다
  • 일반적인 메시징 큐 시스템에서 흔한 문제들
  • 해결을 위한 첫 도입: Outbox 패턴
  • Outbox의 부족한 점들
  • 이를 해결하기 위한 추가 도입: Envelope 표준, 재시도/지수 백오프/지터/DLQ, 표준 헤더/트레이싱, DIP 기반 messaging-starter
  • 실험 절차와 체크리스트, 예시 시나리오
  • 마무리: 스타터로 수렴하는 로드맵과 기대 효과

리포지토리 전체는 여기에 공개되어 있다: https://github.com/DDINGJOO/infra_messaging_playground


1) 문제점만 요약

하단 요약은 여러 코드/문서 분석 결과를 합친 것이다.

  1. 브로커 혼용으로 인한 복잡도 상승

    • Kafka와 RabbitMQ를 동시에 사용하며, 서비스마다 설정과 토픽/큐명이 다르게 하드코딩됨.
    • 운영 모니터링/경보/배포 검증이 이원화되어 인지 부하가 큼.
  2. Consumer 표준 부족과 보일러플레이트

    • @KafkaListener/@RabbitListener 기반 소비자가 서비스 곳곳에 산재.
    • 역직렬화 실패/핸들러 예외/재시도/DLQ 정책이 제각각이며, Poison 메시지에 취약.
  3. 토픽/큐 네이밍 하드코딩, 버전/환경 전환 비용 증가

    • 도메인/버전/환경 기준의 명확한 네이밍 컨벤션과 프로퍼티 표준이 약함.
  4. Outbox 처리 표준화 미흡

    • OutboxEventPublisher/Processor는 있으나, 스케줄링/락/백오프/DEAD/DLQ/메트릭 정책이 균일하지 않음.
    • 직렬화 실패 시 Outbox 적재 자체가 실패하는 경우가 있어 운영 가시성이 떨어짐.
  5. 직렬화/컨트랙트 일관성 부족

    • 이벤트 타입/버전/Trace/발생시각 등 메타를 담는 표준 래퍼(Envelope)가 부재 또는 약함.
    • 하위호환/스키마 진화 전략이 모호.
  6. 트레이싱/로깅/메트릭 미흡

    • 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의 부족한 점들(실제 현장에서 드러난 한계)

  1. 라우팅 필드의 모호성

    • topic 필드를 Kafka 토픽과 Rabbit 익스체인지로 겸용하면 의미 충돌이 발생.
    • routingKey를 event name으로 채워 넣는 등 일관되지 않은 사용.
  2. 이벤트 메타 정보 부족

    • payload만 저장하면 발생시각, 이벤트 ID, 버전, 프로듀서 앱, traceId를 잃는다.
    • 장애 분석과 하위호환 전략 수립이 어렵다.
  3. 직렬화 실패 경로 부재

    • 직렬화 실패 시 Outbox에 적재 자체가 실패하는 설계는 문제의 은닉을 낳는다.
  4. 재시도 전략 단순

    • 고정 지연 방식은 장애 패턴에 취약. 지수 백오프와 지터가 필요.
  5. DEAD 처리의 운영 전략 부재

    • 단순 DEAD 마킹만 하면 운영 핸드오프가 막힌다. DLQ 라우팅 규칙과 모니터링이 필요.
  6. 동시성/중복 전송 우려

    • ShedLock만으로는 불충분한 경우가 있으며, 낙관적 락(@Version) 같은 보조 전략이 필요.
  7. 파티셔닝/순서 보장 미흡

    • Kafka 메시지 key가 없으면 스큐와 순서 깨짐 문제가 빈발.
  8. 관측성 부족

    • 처리량/지연/실패율/백로그/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_exchangerabbit_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/@RabbitListener
    • void 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 하나로 발행/소비를 표준화할 수 있다.

728x90