Spring Boot SNS 포트폴리오 시리즈 5편. 새 게시글이 만들어지면 Kafka 이벤트가 어떻게 알림 부서로 흘러가는지, Outbox Pattern + Debezium CDC가 DB 트랜잭션과 Kafka 발행을 원자적으로 묶는 흐름, JsonSerializer trusted packages·컨슈머 그룹·max.block.ms 같은 함정까지 한 글에 정리합니다.
이 글은 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.created | post-service | notification-service | {postId, authorId, title} |
notification.email | notification-service | notification-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 가이드에 이 글의 코드와 설정이 어디서 왔는지가 자세히 정리돼 있어요.
시리즈 다른 편
- 1편 — 마이크로서비스 아키텍처 전체 그림
- 2편 — API Gateway JWT 검증
- 3편 — User Service · OAuth2
- 4편 — Redisson 분산 락 · 동시성
- 5편 — Kafka 이벤트 흐름 · Outbox (현재 글)
- 6편 — Redis 4가지 활용 패턴
- 7편 — Elasticsearch + S3 업로드
시험 직전 한 번 더 — 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 분산 락)를 한 글에 종합 정리합니다.