Kafka 이벤트 흐름 · Outbox — SNS 5편

2026-05-04Spring Boot SNS 마이크로서비스 포트폴리오

Spring Boot SNS 포트폴리오 시리즈 5편. 새 게시글이 만들어지면 Kafka 이벤트가 어떻게 알림 부서로 흘러가는지, Outbox Pattern + Debezium CDC가 DB 트랜잭션과 Kafka 발행을 원자적으로 묶는 흐름, JsonSerializer trusted packages·컨슈머 그룹·max.block.ms 같은 함정까지 한 글에 정리합니다.

📚 Spring Boot SNS 마이크로서비스 포트폴리오 · 5편 — Kafka 이벤트 흐름 · Outbox + Debezium

이 글은 Spring Boot SNS 마이크로서비스 포트폴리오 시리즈의 5편입니다. 1~4편에서 본 정문 보안실(API Gateway), 회원 관리부(User Service), 콘텐츠부(Post Service)가 만들어 낸 이벤트가 어떻게 알림 부서(Notification Service)까지 흘러가는지를 — Kafka 이벤트 흐름Outbox Pattern + Debezium CDC 한 묶음으로 풀어 갑니다.

비유는 1~4편을 그대로 이어 가요 — 부서 사이를 잇는 사내 게시판이 Kafka, 게시판에 글을 올리기 전에 본인 부서 일지에 먼저 적어 두는 게 Outbox, 일지에 새 줄이 올라오면 누군가 자동으로 게시판에 옮겨 주는 게 Debezium. 한 번에 묶어서 발생·발행·전달을 따로 안 끊기게 만드는 구조입니다.

프로젝트 SNS는 이메일·비밀번호와 구글·깃허브 OAuth2 로그인, 게시글·댓글·좋아요·랭킹·구독·알림·한국어 검색까지 흔한 기능을 한 번씩 다 다뤄 보면서, 그 평범한 기능들을 마이크로서비스 패턴(Database-per-Service · API Gateway · Outbox + CDC · Redisson 분산 락)과 인프라(Kafka·Redis·Elasticsearch·LocalStack S3)로 어떻게 묶어 내는가를 직접 손으로 짜 보는 학습용 포트폴리오입니다.

왜 Kafka·Outbox·Debezium이 처음엔 어렵게 느껴질까

처음 비동기 메시징 코드를 보면 막히는 지점이 두 가지예요.

첫째, 세 가지 추상이 한 번에 등장합니다. Kafka(메시지 브로커), Outbox(테이블 패턴), Debezium(WAL 감지 도구) — 셋이 한 흐름에 묶여 있는데 어디서부터 따라가야 할지가 안 잡힙니다. "DB에 저장만 했는데 왜 알림이 가지" 같은 마법처럼 보여요.

둘째, 트랜잭션 일관성과 비동기성의 결합이 까다롭습니다. "DB 저장과 Kafka 발행을 같은 트랜잭션으로 묶을 수 없다" 라는 사실이 안 박히면, "그냥 메서드 안에서 둘 다 호출하면 되지 않나" 싶거든요. 그런데 실제로는 둘 중 하나가 실패하는 케이스가 운영에서 정말 자주 일어나서, 그 케이스 하나를 막으려고 Outbox라는 한 단계가 더 들어갑니다.

해결법은 두 단계예요. 먼저 이벤트 한 줄이 어떻게 흘러가는지 — 게시글 INSERT → outbox INSERT → WAL → Debezium → Kafka 토픽 → 컨슈머 — 를 한 줄 다이어그램으로 머리에 박고, 그다음에 각 단계의 함정(JsonSerializer trusted packages, max.block.ms, consumer group offset)을 하나씩 짚어 갑니다.

알림부의 일과 — 새 게시글이 나면 알림이 어떻게 가는가

전체 흐름을 한 그림으로 그려 보면 이렇게 됩니다.

post-service                               notification-service
     │                                            │
     │── DB 트랜잭션 안에서 outbox INSERT ────────┐
     │                                            │
     ▼                                            │
[postdb.outbox_events]                            │
     │                                            │
     │ (PostgreSQL WAL, wal_level=logical)        │
     ▼                                            │
Debezium (kafka-connect:8083)                     │
     │                                            │
     │ OutboxEventRouter SMT                      │
     │ aggregate_type → 토픽명                    │
     ▼                                            │
[Kafka 토픽 "Post"] ──────────────────────────────┘
                                          │
                                          ▼
                                    PostCreatedConsumer.consume()
                                      1. userServiceClient.getUser(authorId)
                                      2. userServiceClient.getSubscribers(authorId)
                                      3. notificationService.saveAll() → DB 저장
                                      4. kafkaTemplate.send("notification.email", ...)
                                          │
                                          ▼
                                    EmailSendConsumer.consume()
                                      1. userServiceClient.getUser(recipientUserId)
                                      2. emailService.sendNewPostNotification()
                                          → Mailhog SMTP (port 1025)

핵심은 두 단계의 분리예요. PostCreatedConsumer가 알림을 DB에 저장하고 이메일 발송 이벤트를 따로 발행, 이메일 발송 자체는 EmailSendConsumer가 처리. 이렇게 분리하는 이유 한 줄로 — 이메일 발송이 실패해도 알림은 이미 저장돼 있고, 이메일 재처리도 Kafka 재전송으로 단순하게 되니까요. 두 책임이 독립적으로 스케일링도 가능해집니다.

PostCreatedConsumer — 작성자·구독자 조회 후 알림 저장

@KafkaListener(topics = "post.created", groupId = "notification-service")
public void consume(PostCreatedEvent event) {
    // 작성자 정보 조회 (이름 표시용)
    InternalUserResponse author = userServiceClient.getUser(event.getAuthorId()).orElse(null);
    if (author == null) return;  // 작성자 삭제된 경우 건너뜀

    // 구독자 목록 조회
    List<SubscriberResponse> subscribers = userServiceClient.getSubscribers(event.getAuthorId());
    if (subscribers.isEmpty()) return;

    // DB에 알림 일괄 저장
    notificationService.saveAll(subscribers, event, author.getNickname());

    // 각 구독자에게 이메일 발송 이벤트 발행
    for (SubscriberResponse subscriber : subscribers) {
        kafkaTemplate.send("notification.email",
                String.valueOf(subscriber.getUserId()),
                EmailEvent.builder()
                        .recipientUserId(subscriber.getUserId())
                        .authorNickname(author.getNickname())
                        .postId(event.getPostId())
                        .postTitle(event.getTitle())
                        .build());
    }
}

여기서 시험 함정이 하나 있어요. userServiceClient는 동기 WebClient입니다.

public Optional<InternalUserResponse> getUser(Long userId) {
    return webClient.get()
            .uri("/internal/users/{id}", userId)
            .retrieve()
            .bodyToMono(InternalUserResponse.class)
            .blockOptional();  // ← 동기 호출
}

Notification Service는 게이트웨이가 아니라 일반 Spring MVC 부서라 WebFlux 이벤트 루프 부담이 없고, @KafkaListener 컨슈머 스레드 풀이 따로 돌기 때문에 동기 호출이 안전해요. 2편의 게이트웨이와 다르게 여기선 .blockOptional()을 써도 됩니다 — 이벤트 루프가 막히는 게 아니라 컨슈머 스레드 한 개가 잠깐 대기할 뿐이거든요.

작성자가 삭제된 경우(author == null)나 구독자가 없는 경우(subscribers.isEmpty()) 일찍 return해서 빈 작업을 방지합니다. Kafka 메시지가 들어왔다고 무조건 알림을 만들지 않고, 의미 있는 데이터가 있을 때만 처리하는 가드.

EmailSendConsumer — Mailhog SMTP로 발송

@KafkaListener(topics = "notification.email", groupId = "notification-service")
public void consume(EmailEvent event) {
    userServiceClient.getUser(event.getRecipientUserId()).ifPresentOrElse(
        user -> emailService.sendNewPostNotification(
                user.getEmail(),
                user.getNickname(),
                event.getAuthorNickname(),
                event.getPostTitle(),
                event.getPostId()),
        () -> log.warn("수신자 조회 실패 — 이메일 스킵")
    );
}

EmailSendConsumer는 단순해요 — 이벤트에서 받은 recipientUserId로 사용자 정보를 한 번 더 조회한 뒤 이메일을 발송합니다. 학습 환경에서는 SMTP 서버 대신 Mailhog(port 1025 SMTP, 8025 UI)를 띄워서 발송된 이메일을 UI에서 확인합니다. 운영에서는 SES·Mailgun·SendGrid 같은 서비스로 교체하면 끝.

이메일 발송 자체가 실패하면 @KafkaListener가 자동 재시도(설정에 따라)해 줘서 멱등성만 잘 보장하면 안전해요.

토픽 정의 + JsonSerializer trusted packages 함정

토픽 두 개가 핵심이에요.

토픽프로듀서컨슈머페이로드
post.createdpost-servicenotification-service{postId, authorId, title}
notification.emailnotification-servicenotification-service{recipientUserId, authorNickname, postId, postTitle}

직렬화 설정은 한 줄 차이로 큰 함정이 생겨요.

# post-service producer
producer:
  value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
  properties:
    spring.json.add.type.headers: false  # Java 타입 정보를 헤더에 포함하지 않음

# notification-service consumer
consumer:
  value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
  properties:
    spring.json.trusted.packages: "com.example.notification.dto"  # 역직렬화 허용 패키지

여기서 정말 중요한 시험 함정 — spring.json.add.type.headers: false 가 한 줄 빠지면 두 부서 사이 직렬화가 깨집니다. 기본값(true)이면 Kafka 메시지 헤더에 Java 클래스 풀 패키지(com.example.post.dto.PostCreatedEvent)가 박혀요. 컨슈머는 그 패키지명을 보고 자기 쪽에서 같은 클래스를 찾으려 합니다 — 그런데 두 부서가 다른 패키지(com.example.notification.dto.PostCreatedEvent)를 쓰니 클래스를 못 찾아 폭발해요.

add.type.headers: false로 두면 컨슈머가 헤더 대신 @KafkaListener 메서드 파라미터 타입으로 역직렬화합니다. 이러면 두 부서가 같은 필드 이름만 가지면 패키지가 달라도 OK.

또 한 가지 보안 디테일 — spring.json.trusted.packages 가 안 박혀 있으면 컨슈머가 보안상 역직렬화를 거부합니다(Java deserialization 공격 방어). 컨슈머 패키지를 명시적으로 허용해야 메시지를 받을 수 있어요.

🎯 한 줄 정리

JsonSerializer 두 줄 — 프로듀서는 add.type.headers=false, 컨슈머는 trusted.packages 설정. 둘 다 안 하면 다른 부서 메시지 못 받음.

Outbox Pattern + Debezium CDC — 트랜잭션 안에 이벤트 저장

4편에서 살짝 짚었던 Outbox 패턴을 자세히 풀어 봅니다. 핵심 문제는 단순해요.

시나리오 A: DB 저장 성공 → Kafka 발행 실패
  → 게시글은 있는데 알림은 안 감 (구독자 화남)

시나리오 B: Kafka 발행 성공 → DB 롤백 (제약 위반 등)
  → 게시글이 실제로는 없는데 알림이 감 (유령 이벤트)

DB와 Kafka는 서로 다른 시스템이라 한 트랜잭션으로 못 묶입니다. 이걸 해결하는 게 Outbox Pattern — DB 트랜잭션 안에서 일반 테이블 + outbox 테이블 두 INSERT를 묶고, 그 outbox를 Kafka로 옮기는 일은 별도 프로세스가 처리.

PostService.createPost()
  → postRepository.save()         ┐
  → outboxEventRepository.save()  ┘ 단일 DB 트랜잭션 (둘이 같이 commit 또는 같이 rollback)

Debezium (Kafka Connect, port 8083)
  → PostgreSQL WAL 감지 (wal_level=logical)
  → OutboxEventRouter SMT (aggregate_type → 토픽명)
  → Kafka 토픽 "Post" 자동 발행

NotificationService
  → @KafkaListener(topics = "Post")

outbox_events 테이블 정의:

CREATE TABLE outbox_events (
    id             UUID         NOT NULL DEFAULT gen_random_uuid(),
    aggregate_type VARCHAR(100) NOT NULL,  -- "Post"
    aggregate_id   VARCHAR(100) NOT NULL,  -- post.id
    type           VARCHAR(100) NOT NULL,  -- "PostCreated"
    payload        JSONB        NOT NULL,
    created_at     TIMESTAMP    NOT NULL DEFAULT now()
) PARTITION BY RANGE (created_at);
-- pg_partman이 월별 파티션 자동 생성/관리

PARTITION BY RANGE (created_at) + pg_partman이 핵심이에요. outbox는 한 번 Debezium이 읽고 나면 더 이상 필요 없는 데이터인데, 그냥 두면 무한히 커집니다. 월별 파티션으로 쪼개 두면 1개월 지난 파티션은 통째로 DETACH/DROP 가능 — 디스크 공간을 깔끔하게 회수합니다.

Debezium 커넥터 설정도 한 줄로 깔끔해요.

{
  "transforms": "outbox",
  "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
  "transforms.outbox.route.by.field": "aggregate_type",
  "transforms.outbox.route.topic.replacement": "${routedByValue}"
}

aggregate_type=Post → Kafka 토픽 Post로 자동 라우팅. 다른 도메인 이벤트(예: aggregate_type=User)도 같은 outbox 테이블에 INSERT하면 Debezium이 자동으로 User 토픽에 발행해 줍니다.

컨슈머 그룹과 max.block.ms 함정

컨슈머 그룹 설정도 작은 함정이 있어요.

consumer:
  group-id: notification-service
  auto-offset-reset: earliest  # 그룹이 처음 시작할 때 가장 오래된 메시지부터 소비

같은 group-id를 가진 컨슈머 인스턴스들은 자동으로 토픽 파티션을 나눠 처리합니다. notification-service를 두 인스턴스로 띄우면 부하가 자동 분산. auto-offset-reset: earliest는 컨슈머 그룹이 처음 연결될 때 토픽에 이미 쌓여 있는 메시지부터 다 처리한다는 의미예요. latest로 두면 새 메시지만 처리하고 과거 메시지는 무시.

여기서 정말 중요한 시험 함정 — kafkaTemplate.send()는 즉시 반환되지 않아요.

kafkaTemplate.send(TOPIC, key, event)
    .whenComplete((result, ex) -> {
        if (ex != null) log.error("발행 실패", ex);
        else log.info("발행 완료");
    });

Kafka 브로커에 연결되지 않으면 메타데이터 요청을 위해 max.block.ms(기본 60초) 동안 블로킹됩니다. Kafka가 꺼져 있으면 API 응답이 최대 60초 지연돼요. 그동안 사용자는 무한 로딩만 보고 있는 끔찍한 UX가 나옵니다. 운영 환경에서는 이 값을 5초 정도로 줄이거나, Kafka 가용성을 보장(Sentinel·Cluster)해 두는 게 좋습니다.

이 한 줄이 안 짚여 있으면 부하 테스트 때 한 번 데이고 나서야 알게 돼요. Kafka 죽었는데 게시글 작성이 안 되는 이상한 증상이 나오면 거의 max.block.ms 문제.

🎯 한 줄 정리

컨슈머 그룹 = 같은 group-id끼리 파티션 분산. auto-offset-reset=earliest로 과거 메시지도 처리. 프로듀서 max.block.ms 기본 60초 — 운영은 5초로 줄여 둘 것.

시리즈 다음 편 — Redis 활용 패턴 종합

여기까지가 5편입니다. Kafka 이벤트 흐름 — 토픽 정의·컨슈머 그룹·JsonSerializer 함정·max.block.ms — 와 Outbox Pattern + Debezium CDC가 어떻게 DB 트랜잭션과 Kafka 발행을 원자적으로 묶는지를 한 줄씩 따라가며 봤어요. 이 패턴 한 묶음이 마이크로서비스 비동기 메시징의 거의 모든 함정을 막아 줍니다.

6편에서는 Redis 활용 패턴 4가지를 종합합니다. 캐시(Read-Through + Cache-Aside), Sorted Set 랭킹, JWT 블랙리스트, Redisson 분산 락 — 한 인프라가 네 가지 다른 역할로 어떻게 쓰이는지를 한 글에 정리합니다.

공식 문서: Apache Kafka 공식 문서Debezium Outbox Pattern 가이드에 이 글의 코드와 설정이 어디서 왔는지가 자세히 정리돼 있어요.

시리즈 다른 편

시험 직전 한 번 더 — Kafka·Outbox 함정 압축 노트

  • Kafka 이벤트 흐름 = 프로듀서 → 토픽(파티션) → 컨슈머 그룹
  • 같은 group-id 컨슈머 인스턴스 = 파티션 자동 분산
  • auto-offset-reset: earliest = 컨슈머 그룹 첫 연결 시 과거 메시지부터 처리
  • auto-offset-reset: latest = 새 메시지만 (과거 무시)
  • Kafka 토픽 두 개 = post.created (구조 단순) + notification.email
  • @KafkaListener(topics = "...", groupId = "...") 으로 컨슈머 등록
  • 동기 WebClient(.blockOptional) = 일반 부서에서는 OK (게이트웨이는 X)
  • kafkaTemplate.send() = 즉시 반환 X, max.block.ms(기본 60초) 블로킹 가능
  • max.block.ms = 운영에서 5초 정도로 축소 권장
  • Kafka 죽으면 게시글 작성 무한 로딩 = max.block.ms 문제
  • whenComplete 콜백 = 비동기 발행 결과 확인
  • JsonSerializer add.type.headers: false 필수 — 두 부서 패키지가 달라도 OK
  • JsonDeserializer trusted.packages 명시 필수 — Java deserialization 공격 방어
  • 시리얼라이저 헤더 박혀 있으면 = 컨슈머가 못 찾는 클래스로 역직렬화 시도 → 폭발
  • Outbox Pattern = DB 트랜잭션 안에서 일반 테이블 + outbox 테이블 같이 INSERT
  • DB와 Kafka는 한 트랜잭션 불가 → outbox로 우회
  • outbox 안 쓰면 = DB 성공 + Kafka 실패(알림 누락) 또는 그 반대(유령 이벤트)
  • Debezium CDC = PostgreSQL WAL 감지 → Kafka 자동 발행
  • wal_level=logical 필수 — PostgreSQL 설정
  • Debezium OutboxEventRouter SMT = aggregate_type 필드를 토픽명으로 라우팅
  • aggregate_type=Post → Kafka 토픽 Post
  • outbox_events 테이블 = PARTITION BY RANGE (created_at) + pg_partman 월별
  • 월별 파티션 DETACH/DROP = 오래된 outbox 디스크 회수
  • Outbox는 Debezium이 한 번 읽고 나면 더는 필요 없는 데이터
  • 알림 저장과 이메일 발송 분리 = 이메일 실패해도 알림 저장 유지 + 재처리 단순
  • Mailhog 학습용 SMTP = port 1025(SMTP) + 8025(UI)
  • 운영 SMTP = SES · Mailgun · SendGrid 등으로 교체

다음 글(6편)에서는 Redis 활용 패턴 4가지(캐시·Sorted Set 랭킹·JWT 블랙리스트·Redisson 분산 락)를 한 글에 종합 정리합니다.

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

답글 남기기

error: Content is protected !!