제목, 태그, 카테고리로 검색

모든 글
약 30분 분량 프로젝트/위키엔진

CDC (Change Data Capture) — 이벤트 기반 동기화

목차

이전 글

조회수 Redis INCR + Write-Behind 배치 flush 전환에서 GET 요청의 DB UPDATE 충돌을 Redis INCR로 해결했습니다. 이 글은 PostService의 dual-write 구조를 이벤트 기반으로 전환하고, 최종적으로 Debezium + Kafka CDC로 모든 DB 변경을 캡처하는 과정입니다.


이전 글 요약

App 스케일아웃에서 CPU 병목이 해소되었다.

지표스케일아웃 결과
평균 응답시간37ms (482ms → 37ms, 92%↓)
P95158ms
에러율0.00%
처리량 (피크)~58 req/s (1.9배↑)
App CPU (피크)~50% (각 2대)
캐시 히트L1 64% + L2 17% = 81%, Origin 19%

인프라는 안정적이다. 하지만 애플리케이션 아키텍처에 구조적 문제가 남아 있다.


1. 정상 상태 인식

현재 아키텍처 — PostService의 dual-write 구조

PostService가 MySQL에 쓰기를 하면서 동시에 여러 Read Model을 직접 갱신하고 있다:

// PostService.createPost() — 현재 구조
@Transactional
public Post createPost(String title, String content, Long authorId, Long categoryId) {
Post saved = postRepository.save(post); // 1. MySQL 쓰기
indexSafely(saved); // 2. Lucene 인덱스 직접 호출
return saved;
}
// PostService.updatePost()
@Transactional
public void updatePost(Long id, String title, String content, Long userId) {
post.update(title, content);
indexSafely(post); // 2. Lucene
tieredCacheService.evict(postDetailL1Cache, "post:" + id); // 3. 캐시 무효화
}
// PostService.deletePost()
@Transactional
public void deletePost(Long id, Long userId) {
postRepository.delete(post);
deleteFromIndexSafely(id); // 2. Lucene
tieredCacheService.evict(postDetailL1Cache, "post:" + id); // 3. 캐시 무효화
}

현재 Read Model 동기화 현황

Read Model동기화 방식호출 위치타이밍
Lucene 인덱스indexSafely() 직접 호출PostService 내부, 트랜잭션 안동기, 즉시
게시글 상세 캐시tieredCacheService.evict()PostService 내부, 트랜잭션 안동기, 즉시
검색 결과 캐시무효화 없음-영구 stale
자동완성 Redis KVhourly @Scheduled 배치RedisAutocompleteService최대 60분 지연
조회수ViewCountService.increment() + 30초 flushPostController비동기, 30초
좋아요 수incrementLikeCount() 직접 DB UPDATEPostService동기, Lucene/캐시 미갱신

의존 관계 그래프

PostService 의존 관계 그래프


2. 문제 상황 인식

문제 1: Dual-Write 불일치 — DB 성공 + Lucene 실패 = 영구 불일치

private void indexSafely(Post post) {
try {
luceneIndexService.indexPost(post);
} catch (Exception e) {
log.error("Lucene 인덱스 실패: {}", post.getId(), e);
// DB에는 이미 커밋됨 — Lucene에는 없음
// 재시도 없음, DLQ 없음, 알림 없음
// → 영구 불일치
}
}

현재는 try-catch + log.error()로 처리한다. MySQL 트랜잭션이 커밋되면 데이터는 DB에 있지만, Lucene 인덱스에는 없다. 이 불일치를 감지하거나 복구하는 메커니즘이 없다.

실측: 부하 테스트 튜닝(200 VU)에서 Lucene indexing이 IOException으로 실패한 케이스가 관찰되었다. CPU 포화 상태에서 MMapDirectory I/O 타임아웃 발생.

문제 2: PostService 강결합 — 6개 의존성

PostService가 알아야 하는 것:

PostService 6개 의존성

새로운 Read Model(예: Elasticsearch, 추천 시스템, 알림)을 추가할 때마다 PostService를 수정해야 한다. OCP(Open-Closed Principle) 위반.

문제 3: 불완전한 캐시 무효화

연산게시글 캐시 (post:{id})검색 캐시 (search:*)자동완성 KV
게시글 생성해당 없음무효화 안 됨최대 60분 지연
게시글 수정O 무효화무효화 안 됨무효화 안 됨
게시글 삭제O 무효화무효화 안 됨최대 2시간 stale
좋아요무효화 안 됨무효화 안 됨해당 없음

게시글을 삭제해도 검색 결과에 최대 30분간 계속 노출된다(검색 결과 캐시 TTL 만료까지). 삭제된 게시글 링크를 클릭하면 404 — 사용자 경험 저하.

문제 4: Lucene 랭킹 필드 stale

Lucene 인덱스에 FeatureField("features", "viewCount")FeatureField("features", "likeCount")가 저장되어 BM25 + 부스팅 랭킹에 사용된다. 하지만:

  • 조회수: Redis INCR → 30초 배치 DB flush → Lucene에는 반영 안 됨
  • 좋아요: DB UPDATE → Lucene에는 반영 안 됨

검색 결과 랭킹이 stale 데이터 기반. 인기 게시글이 검색 상위에 올라오지 않을 수 있다.


3. 문제 분석 — 왜 dual-write가 구조적으로 위험한가

Dual-Write의 본질적 문제

두 개 이상의 데이터 저장소에 애플리케이션이 직접 쓰기를 하면, 분산 트랜잭션 없이는 원자성을 보장할 수 없다. Martin Kleppmann(“Designing Data-Intensive Applications” 저자)은 이를 “dual-write problem”으로 명명하고, race condition과 partial failure 두 가지 근본 문제를 제시한다.

“Two clients may write to System A and System B in different orders. Without a single source of ordering, the systems end up permanently inconsistent with no indication that anything is wrong.”

해결책으로 “Write to a single authoritative system (the database), then use CDC (log extraction) to propagate changes”를 권장한다. Confluent도 “Issues caused by this problem are extremely difficult to spot, and you don’t get any red flags indicating that something has become inconsistent”라고 경고한다.

Dual-Write 실패 시나리오

이 문제를 해결하는 업계 표준 패턴:

패턴원리적합 상황
Transactional OutboxDB 트랜잭션에 이벤트 테이블 INSERT 포함 → 별도 폴링으로 발행단일 DB, 추가 인프라 최소
CDC (Change Data Capture)DB binlog를 외부에서 감지 → 이벤트 스트림 생성애플리케이션 코드 변경 없이 모든 변경 캡처
Event Sourcing이벤트 자체가 원본 데이터이벤트 중심 도메인

이 프로젝트에 적합한 접근: 점진적 진화

로드맵에서 설계한 3-step 진화:

점진적 진화 로드맵

단, Kafka + Debezium은 최소 5~8G RAM이 필요하여 현재 Free Tier에서는 불가. Spring Event부터 시작하여 구조적 개선을 먼저 달성한다.


4. 대안 검토

방안 1: Spring ApplicationEvent + @TransactionalEventListener

// PostService — 직접 호출 제거, 이벤트 발행으로 전환
@Transactional
public Post createPost(...) {
Post saved = postRepository.save(post);
// indexSafely(saved); ← 제거
eventPublisher.publishEvent(new PostEvent.Created(saved.getId(), saved));
return saved;
}
// 별도 EventHandler — Lucene 인덱스 갱신
@Component
public class LuceneIndexEventHandler {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onPostCreated(PostEvent.Created event) {
luceneIndexService.indexPost(event.post());
}
}
// 별도 EventHandler — 캐시 무효화
// [주의] AFTER_COMMIT 리스너에서 DB 작업이 필요하면 반드시 REQUIRES_NEW 사용
@Component
public class CacheInvalidationEventHandler {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onPostUpdated(PostEvent.Updated event) {
tieredCacheService.evict(postDetailL1Cache, "post:" + event.postId());
}
}
장점단점
추가 인프라 없음앱 재시작/크래시 시 이벤트 유실 (in-memory)
PostService 디커플링 (OCP 준수)AFTER_COMMIT 후 리스너 실패 시 재시도 없음
트랜잭션 커밋 보장 후 실행커밋~리스너 실행 사이에 짧은 불일치 window
코드 변경만으로 적용멀티 인스턴스에서 이벤트 공유 불가

멀티 인스턴스 제약: 스케일아웃에서 이미 App을 2대로 확장했다. Spring ApplicationEvent는 JVM 내부 이벤트이므로, App 1에서 게시글을 생성하면 App 1의 Lucene만 갱신되고 App 2의 Lucene은 갱신되지 않는다. 이 부분은 기존 5분 주기 rsync가 커버한다.

Spring Modulith 활용: 이 프로젝트는 Spring Modulith 2.0.2를 사용 중이다. @ApplicationModuleListener@TransactionalEventListener(AFTER_COMMIT) + 이벤트 발행 로그(Event Publication Log)를 제공하여, 리스너 실패 시 자동 재시도가 가능하다.

AFTER_COMMIT 리스너의 알려진 함정: 리스너에서 예외가 발생해도 트랜잭션은 이미 커밋되었으므로 롤백되지 않는다. Spring 6.x에서 @TransactionalEventListener의 기본 동작이 동기(같은 스레드)이므로, 리스너가 느리면 HTTP 응답도 지연된다.

방안 2: Transactional Outbox Pattern

// PostService — 이벤트를 outbox 테이블에 함께 저장
@Transactional
public Post createPost(...) {
Post saved = postRepository.save(post);
outboxRepository.save(new OutboxEvent("POST_CREATED", saved.getId(), toJson(saved)));
return saved;
}
// OutboxPoller — 별도 스케줄러가 outbox 테이블 폴링 → 이벤트 발행
// SKIP LOCKED: 멀티 인스턴스에서 같은 이벤트 중복 처리 방지
@Scheduled(fixedRate = 1000)
@Transactional
public void pollAndPublish() {
List<OutboxEvent> events = outboxRepository.findUnpublishedForUpdate(BATCH_SIZE);
for (OutboxEvent event : events) {
try {
applicationEventPublisher.publishEvent(toDomainEvent(event));
event.markPublished();
} catch (Exception e) {
event.incrementRetryCount();
log.error("Outbox event 처리 실패 (retry {}): {}",
event.getRetryCount(), event.getId(), e);
}
}
}
장점단점
이벤트 유실 없음 (DB 트랜잭션과 원자적)outbox 테이블 관리 필요 (정리, 인덱스)
앱 재시작 후에도 미발행 이벤트 처리폴링 주기만큼 지연 (1초)
멀티 인스턴스에서 안전 (DB 기반)폴링이 DB 부하 추가
순서 보장 가능 (ID 기반)

방안 3: Debezium + Kafka CDC

CDC 파이프라인 개요

장점단점
애플리케이션 코드 변경 없이 모든 변경 캡처Kafka + Debezium = 최소 5~8G RAM
at-least-once 보장 (Kafka offset)Free Tier에서 자원 부족
이벤트 리플레이 가능운영 복잡도 증가
dual-write 원천 차단 (binlog 기반)

Debezium 프로덕션 알려진 한계: Connector가 단일 태스크(단일 스레드)로 binlog를 순차 처리하므로, 대량 DML 시 lag이 누적될 수 있다. 극단적인 경우 15~20분까지 보고됨. 내장 관찰성(Observability)이 없어 별도 Prometheus + Grafana 필요. DDL 발생 시 Connector 재시작이 필요한 경우가 있다.

선택: Spring Event → Outbox → CDC 순서

Spring Event → Outbox → CDC 선택

현업 사례

회사패턴참고
배달의민족Debezium + Kafka CDCB2B 알림 서비스에 CDC 적용. “볼륨이 아닌 아키텍처 패턴으로 도입”
NetflixDebezium + Kafka마이크로서비스 간 데이터 동기화
AirbnbTransactional Outbox + Kafka이벤트 유실 방지를 위한 2-step 패턴
Debezium 공식Outbox + CDC 조합”log-based CDC is a great fit for capturing new entries in the outbox table”

Outbox + CDC 조합: 업계 best practice는 Outbox 테이블에 이벤트를 저장한 뒤, 폴링이 아닌 CDC(Debezium)로 outbox 테이블의 변경을 감지하여 Kafka에 전달하는 것이다. 이 프로젝트에서는 폴링 Outbox → CDC Outbox로 자연스럽게 진화할 수 있다.

비용 분석

비용 비교

운영 복잡도 정당화 — “일 200건에 Kafka가 필요한가?”

이 질문에 대한 답은 “Kafka를 운영하는 비용”과 “dual-write 불일치를 수동으로 복구하는 비용”의 비교다.

항목Kafka 없이 (@ApplicationModuleListener)Kafka 있으면 (CDC)
직접 SQL/배치 JPQL 후 Lucene 불일치 복구수동 — 전체 재인덱싱 28분 + 불일치 감지 수단 없음자동 — binlog에서 캡처, 0.7초 내 반영
Lucene 인덱스 손상 시 복구전체 재인덱싱 28분, 그 동안 검색 품질 저하Kafka 토픽 리플레이로 증분 재구축
앱 장애 중 이벤트JVM 내부 이벤트 중단, Event Publication Log에 의존Kafka에 보존, 앱 복구 후 이어서 소비
멀티 인스턴스 L1 캐시 무효화App 1 이벤트를 App 2가 모름 (TTL 만료까지 stale)양쪽 모두 CDC 이벤트로 즉시 무효화
주간 운영 시간~0 (하지만 불일치 발생 시 수 시간 디버깅)~1시간 (정기 점검)

Kafka 운영의 핵심 비용은 인프라 자체보다 운영 진입 비용과 장애 대응 복잡도에 가깝습니다. 이 프로젝트에서는 KRaft 단일 브로커 + @ConditionalOnProperty fallback 구조로 운영 부담을 최소화했습니다. Kafka가 죽어도 서비스는 @ApplicationModuleListener 수준으로 자동 전환되어 서비스 중단 없이 동작합니다. Kafka는 “평시의 정확성 보장”이고, fallback은 “장애 시 서비스 연속성 보장”입니다.

”Elasticsearch를 쓰면 CDC 자체가 불필요하지 않나?”

관점Embedded Lucene + CDCElasticsearch
인프라 비용Kafka 4G + Debezium 2G = 6G RAMES 최소 4G × 3노드 = 12G RAM (HA 구성)
dual-write 해결CDC(binlog)로 원천 차단ES도 결국 MySQL → ES 동기화 필요 (같은 문제 발생)
운영 복잡도Kafka + DebeziumES 클러스터 관리 (샤드, 레플리카, JVM 튜닝)
검색 성능7~10배 빠름 (Lucene 직접 접근)네트워크 홉 추가
비용 (AWS)t3.medium $30/월OpenSearch Serverless 최소 ~$200/월

Elasticsearch를 도입해도 MySQL → ES 동기화 문제는 동일하다. ES 공식 문서에서도 Logstash JDBC Input이나 Debezium을 사용한 CDC를 권장한다. 즉 ES를 써도 CDC 파이프라인은 필요하고, 인프라 비용은 오히려 더 높다. Embedded Lucene + CDC는 같은 정확성을 훨씬 적은 비용으로 달성하는 선택이다.


5. Spring Event 전환 구현

도메인 이벤트 설계

// sealed interface로 타입 안전성 보장
public sealed interface PostEvent {
Long postId();
record Created(Long postId, Post post) implements PostEvent {}
record Updated(Long postId, Post post) implements PostEvent {}
record Deleted(Long postId) implements PostEvent {}
record LikeChanged(Long postId) implements PostEvent {}
}

EventHandler 분리

이벤트 라우팅

Consumer 멱등성 (Idempotency)

EventHandler멱등성 보장 방식이유
LuceneIndexEventHandler자연 멱등Lucene updateDocument()는 Term 기준 삭제 후 재삽입. 같은 postId로 여러 번 호출해도 결과 동일
CacheInvalidationEventHandler자연 멱등evict(key)는 캐시에 키가 없으면 no-op
SearchCacheEventHandler자연 멱등검색 캐시 전체/부분 무효화도 no-op 안전
좋아요 카운터주의 필요incrementLikeCount()는 멱등적이지 않다. 이벤트에 변경 후 절대값을 포함하여 SET 방식으로 갱신 필요

설계 원칙: 이벤트 핸들러는 가능하면 SET(절대값 덮어쓰기) 방식으로 구현하고, INCREMENT(상대값 증가) 방식은 피한다.

Trade-off: 동기 → 비동기 전환의 일시적 불일치 window

동기 → 비동기 전환 window

이 window는 수 ms 수준이며, 커뮤니티 게시판에서 허용 가능하다.

코드 변경 Before/After

BeforeAfter
indexSafely(post) + tieredCacheService.evict(...)eventPublisher.publishEvent(new PostEvent.Updated(id, post))
deleteFromIndexSafely(id) + tieredCacheService.evict(...)eventPublisher.publishEvent(new PostEvent.Deleted(id))

코드 diff — Before(좌) / After(우)

PostService 의존성 변화

BeforeAfter
생성자 파라미터9개9개 (LuceneIndexService → ApplicationEventPublisher 교체)
쓰기 경로 외부 호출Lucene 직접 + 캐시 직접이벤트 발행만
Read Model 추가 시PostService 수정 필요EventHandler 추가만 (OCP 준수)

캐시 무효화 개선

캐시BeforeAfter
게시글 상세 (post:{id})수정/삭제 시만, 좋아요 시 안 됨수정/삭제/좋아요 모두 이벤트로 무효화
검색 결과 (search:*)무효화 없음 → 영구 staleL1 즉시 무효화 + L2 TTL(10분) 자연 만료

테스트

기존 109개에서 Lucene 직접 호출 테스트 3개 제거 + EventHandler 테스트 11개 추가 = 117개 전체 통과.

테스트 117개 통과

새로 추가된 파일

파일역할
PostEvent.javasealed interface — Created, Updated, Deleted, LikeChanged
LuceneIndexEventHandler.java@ApplicationModuleListener — 비동기 Lucene 인덱스 갱신
CacheInvalidationEventHandler.java@ApplicationModuleListener — 게시글 상세 캐시(L1+L2) 무효화
SearchCacheEventHandler.java@ApplicationModuleListener — 검색 결과 캐시 L1 무효화

6. k6 부하 테스트 — @TransactionalEventListener (100 VU, 20분)

Overview

k6 Overview — @TransactionalEventListener

지표스케일아웃@TransactionalEventListener변화
평균 응답시간37ms724ms악화 (쓰기 경로)
P95158ms5.01s악화 (쓰기 경로)
에러율0.00%12.6%악화
처리량 (피크)~58 req/s~41 req/s감소

읽기 vs 쓰기 분리 분석

읽기 경로는 스케일아웃 대비 동등하거나 개선:

시나리오평균P95판정
검색28ms109ms정상
자동완성8ms38ms정상
목록 조회14ms67ms정상
상세 조회19ms81ms정상
쓰기 (생성+좋아요)5,315ms13,909ms병목

에러율 12.6%의 원인은 쓰기 타임아웃. 읽기 경로는 이벤트 전환의 영향 없음.

시나리오별 응답시간 — @TransactionalEventListener

쓰기 병목 분석 — HikariCP Primary Pool 고갈

HikariCP — 커넥션 고갈

Spring의 AbstractPlatformTransactionManager.processCommit() 소스코드 기준으로, afterCommit() 콜백은 커넥션이 풀에 반환되기 전에 실행된다:

processCommit() {
doCommit() // 1. DB 커밋
triggerAfterCommit() // 2. AFTER_COMMIT 리스너 실행 ← 커넥션 아직 점유!
cleanupAfterCompletion() // 3. 커넥션 반환 ← 여기서야 반환
}

Lucene 인덱싱(~100ms)과 캐시 무효화가 끝날 때까지 DB 커넥션이 반환되지 않아, Primary pool(5개)이 고갈되고 후속 쓰기 요청이 4~5초간 커넥션 대기.

Grafana 대시보드

캐시 히트율 — @TransactionalEventListener

계층히트 비율
L1 (Caffeine)37%
L2 (Redis)40%
Origin (Lucene/MySQL)23%

L1+L2 합산 77% 히트. 스케일아웃(81%)과 유사.

MySQL — @TransactionalEventListener

Redis — @TransactionalEventListener

Host — @TransactionalEventListener

Replication — @TransactionalEventListener

k6 터미널 — @TransactionalEventListener

총 요청 수: 30,553
에러율: 12.70%
읽기: 검색 28ms / 자동완성 8ms / 목록 14ms / 상세 19ms
쓰기: 평균 5,315ms / P95 13,909ms (HikariCP primary pool 고갈)

7. @ApplicationModuleListener 전환 — 비동기 이벤트 + Event Publication Log

@TransactionalEventListener(AFTER_COMMIT)@ApplicationModuleListener로 전환. Spring Modulith가 내부적으로 @Async + @TransactionalEventListener(AFTER_COMMIT) + @Transactional(REQUIRES_NEW)를 결합하여 제공한다.

@TransactionalEventListener (Before)@ApplicationModuleListener (After)
실행 스레드HTTP 스레드 (동기, 블로킹)별도 스레드 (비동기)
DB 커넥션Lucene 끝날 때까지 점유커밋 즉시 반환
HTTP 응답Lucene 끝난 후 반환커밋 즉시 반환
이벤트 유실앱 죽으면 유실Event Publication Log로 재시도

k6 결과 — @ApplicationModuleListener 전환 후 (100 VU, 20분)

k6 Overview — @ApplicationModuleListener

지표@TransactionalEventListener@ApplicationModuleListener변화
평균 응답시간724ms38.9ms18.6배 개선
P955.01s170ms29.5배 개선
P995.09s334ms15.2배 개선
에러율12.6%0.00%완전 해소
처리량 (피크)~41 req/s~58 req/s1.4배 증가
총 요청 수30,55342,00737% 증가

시나리오별 — @ApplicationModuleListener

시나리오Before 평균After 평균변화
검색28ms37ms동등
자동완성8ms9ms동등
목록 조회14ms16ms동등
상세 조회19ms21ms동등
쓰기 (생성+좋아요)5,315ms33ms161배 개선

쓰기 병목 완전 해소. 읽기 성능은 동등 유지.

HikariCP — 커넥션 대기 해소

JVM + HikariCP — @ApplicationModuleListener

  • 커넥션 획득 시간: 4~5초 → ~1ms (즉시 획득)
  • DB 커밋 즉시 커넥션 반환 → Lucene 인덱싱은 별도 스레드에서 독립 실행

Grafana 대시보드

캐시 — @ApplicationModuleListener

계층BeforeAfter
L137%36%
L240%43%
Origin23%21%

L1+L2 합산 79% 히트. 동등.

MySQL — @ApplicationModuleListener

Host — @ApplicationModuleListener

Containers — @ApplicationModuleListener

k6 터미널 — @ApplicationModuleListener

총 요청 수: 42,007
에러율: 0.00%
읽기: 검색 27ms / 자동완성 9ms / 목록 17ms / 상세 23ms
쓰기: 평균 33ms / P95 98ms

Spring Event 전환 최종 종합

항목결과
구조 개선PostService → Lucene/캐시 직접 호출 제거, 이벤트 기반 디커플링 완료
비동기 전환@ApplicationModuleListener로 HTTP 응답 즉시 반환, Lucene/캐시는 백그라운드
쓰기 성능5,315ms → 33ms (161배 개선), 에러율 12.6% → 0.00%
읽기 성능스케일아웃 대비 동등 유지
처리량30,553 → 42,007 요청 (37% 증가)
이벤트 유실 방지Event Publication Log로 실패 시 자동 재시도
검색 캐시 무효화기존 영구 stale → L1 즉시 무효화 + L2 TTL(10분) 자연 만료

Event Publication Log 재시도 검증

Spring Modulith의 Event Publication Log가 실제로 재시도에 성공하는지 검증했다.

Event Publication Log 재시도 검증

알려진 한계: (1) 이벤트 클래스의 FQCN이 DB에 저장되므로, 클래스를 리네임/이동하면 기존 미완료 이벤트의 재시도가 ClassNotFoundException으로 실패한다. 이벤트 클래스 변경 전 미완료 이벤트를 반드시 소진해야 한다. (2) GitHub Issue #835에서 런타임 중 리스너가 호출되지 않는 케이스가 보고되었다. 이 프로젝트에서는 재현되지 않았지만, Kafka CDC로 진화하는 추가적인 동기가 된다. (3) 멀티 인스턴스에서 재시작 시 미완료 이벤트 재처리가 인스턴스별로 독립적이므로 중복 처리 가능 — Consumer 멱등성이 전제조건이다.


8. Debezium + Kafka CDC

Outbox를 별도로 구현하지 않은 이유

로드맵에서는 Spring Event → Outbox → CDC를 계획했지만, Outbox를 건너뛰고 @ApplicationModuleListener에서 바로 Kafka CDC로 진화했다. Spring Modulith의 @ApplicationModuleListener가 Outbox의 핵심 기능을 프레임워크 수준에서 이미 제공하기 때문이다.

Outbox의 목표Spring Modulith 제공 여부
이벤트 유실 방지 (DB 저장)Event Publication Log — 이벤트를 DB 테이블에 기록, 미완료 시 재시도
앱 재시작 후 미발행 이벤트 처리자동 재시도IncompleteEventPublications 스케줄러가 미완료 이벤트 재처리
리스너 실패 시 재시도기본 제공 — 리스너 예외 시 미완료 상태 유지 후 다음 주기에 재시도

왜 이렇게 진화했는가

구간방식쓰기 경로문제
Beforedual-writesave()indexSafely()evict() → 커밋 → 응답Lucene 실패 시 영구 불일치, OCP 위반
동기 이벤트@TransactionalEventListenersave() → 커밋 → Lucene(동기) → 응답커밋 후에도 커넥션 점유 → 쓰기 5,315ms
비동기 이벤트@ApplicationModuleListenersave() → 커밋 → 응답 (Lucene은 별도 스레드)비동기로 해결, 쓰기 33ms. 하지만 직접 SQL 미감지, JVM 로컬
Kafka CDCKafka CDCsave() → 커밋 → 응답 (publishEvent no-op)dual-write 원천 차단. binlog 기반으로 모든 변경 캡처

Kafka + CDC가 해결하는 구조적 한계

한계@ApplicationModuleListenerCDC (Kafka + Debezium)
직접 SQL 미감지PostService를 거치지 않는 변경은 이벤트 미발생binlog 레벨에서 모든 경로의 변경 캡처
JVM 로컬 이벤트App 1의 이벤트를 App 2가 모름Consumer Group으로 자동 분산
이벤트 리플레이 불가히스토리 없음, 인덱스 손상 시 전체 재인덱싱Kafka 토픽에서 리플레이로 재구축
앱 = 이벤트 인프라앱 죽으면 이벤트 처리도 중단Kafka에 보존, 앱 복구 후 이어서 소비
순서 보장동일 트랜잭션 내에서만binlog position 기반 전역 순서

”직접 SQL 미감지”가 현실에서 발생하는 시나리오

시나리오발생 빈도설명
데이터 마이그레이션스키마 변경 시Flyway 마이그레이션 스크립트의 UPDATE 배치. PostService를 거치지 않음
긴급 데이터 수정장애 발생 시스팸 봇 게시글 일괄 삭제. API로 1,000건 삭제는 비현실적
배치 작업정기적@Modifying JPQL 벌크 연산은 PostService 이벤트 발행을 건너뜀
MySQL Replication상시Primary/Replica 불일치 순간 존재. CDC는 Primary binlog 직접 읽기
서비스 확장향후별도 모듈/마이크로서비스가 같은 DB에 접근 시

핵심은 “PostService를 통하지 않는 모든 변경 경로를 원천 차단할 수 있는가?”이다. CDC(binlog 기반)는 이 문제를 데이터베이스 레벨에서 해결한다.

Redis Streams를 쓰지 않는 이유

관점Redis StreamsKafka
내구성메모리 기반, AOF에도 커널 패닉 시 유실 가능디스크 기반 + replication, 브로커 장애에도 보존
리플레이MAXLEN 트리밍 시 영구 소실retention으로 수 주~수 개월 보존, 인덱스 재구축 가능
수평 확장단일 인스턴스 단일 스레드파티션 기반 수평 확장 네이티브

인프라 구성

인프라 구성 + CDC 파이프라인

KRaft 단일 브로커의 한계와 방어 전략: Confluent 공식 문서에서 KRaft combined mode(브로커 + 컨트롤러 단일 프로세스)는 개발/테스트 전용이라고 명시하며, 프로덕션에서는 최소 3개 컨트롤러를 권장한다. 현재 단일 브로커 구성에서 Kafka가 죽으면 CDC 파이프라인이 멈추지만, 서비스 자체는 중단되지 않는다. @ConditionalOnProperty fallback으로 @ApplicationModuleListener가 자동 전환되어 비동기 이벤트 처리 수준으로 동작한다. 이 구조에서 Kafka의 역할은 “평시의 정확성 극대화”이고, fallback은 “장애 시 서비스 연속성 보장”이다. Kafka가 복구되면 Debezium이 마지막 binlog position부터 이어서 캡처하므로 장애 동안의 변경도 소급 반영된다. 프로덕션 확장 시에는 KRaft 3노드 컨트롤러 + 전용 브로커로 HA를 확보해야 한다.

Debezium Connector 상태

Debezium Connector RUNNING

Kafka 토픽

Kafka 토픽 목록

토픽역할
__consumer_offsetsKafka Consumer Group offset 저장
__debezium-heartbeat.dbserver1Debezium 헬스체크 heartbeat
_debezium_configsDebezium Connect 설정 저장
_debezium_offsetsDebezium binlog position 저장
_debezium_statusDebezium Connector 상태 저장
_schema_historyMySQL 스키마 변경 이력

코드 구조 — Kafka 유무에 따른 자동 전환

Kafka 유무에 따른 자동 전환

CDC 파이프라인 동작 확인

CDC 파이프라인 성공

CDC 파이프라인 동작 — 게시글 생성

CDC Consumer 아키텍처 버그 발견 및 수정

Kafka CDC 배포 후 게시글 생성 → 검색 노출이 안 되는 문제를 발견했다.

CDC 버그 — 수정 전

근본 원인: docker-compose.yml.j2(App 1)에서 SPRING_KAFKA_BOOTSTRAP_SERVERS 환경변수 매핑이 빠져 있었다.

수정 후 구조:

CDC 버그 수정 후

현업과의 비교: 프로덕션에서는 Elasticsearch/OpenSearch 같은 별도 검색 클러스터가 자체적으로 Primary/Replica 복제를 관리하므로, “CDC Consumer가 어느 App에서 돌아야 하느냐” 문제가 발생하지 않는다. embedded Lucene은 인프라 비용을 아끼는 대신, 이런 관리 복잡성을 애플리케이션이 부담한다.

Consumer Group 분리 이유: App 1과 App 2가 CDC로 하는 일이 다르므로(인덱싱 vs 캐시 무효화), 브로드캐스트 패턴(별도 Consumer Group)을 적용했다.

CDC 정확성 검증 — 직접 SQL DELETE 테스트

CDC 정확성 검증 — 직접 SQL DELETE 테스트

CDC End-to-End 지연 측정

CDC End-to-End 지연 측정

커뮤니티 게시판에서 “게시글 작성 후 2초 뒤 검색 가능”은 허용 가능한 수준이며, CDC의 이점(직접 SQL 감지, 이벤트 리플레이, 양쪽 L1 캐시 무효화)이 이 지연을 상회한다.


9. k6 부하 테스트 — Kafka CDC (100 VU, 20분)

성능 비교

지표@TransactionalEventListener@ApplicationModuleListenerKafka CDC
평균 응답시간724ms38.9ms35.6ms
P955.01s170ms138ms
P995.09s334ms294ms
에러율12.6%0.00%0.00%
쓰기 평균5,315ms33ms24ms
처리량 (피크)~41 req/s~58 req/s~58 req/s
총 요청 수30,55342,00742,084

@ApplicationModuleListener와 Kafka CDC의 읽기/쓰기 성능이 동등하다. 성능 차이가 아니라 아키텍처 정확성(correctness)의 차이가 CDC 도입의 핵심 이유다.

k6 터미널 결과

CDC k6 터미널

프로필: LOAD (100 VU, 20분)
총 요청 수: 42,084
에러율: 0.00%
전체: 평균 35.61ms P95 137.63ms
검색 (전체): 평균 25.74ms P95 99.60ms
희귀 토큰 (10%): 평균 20.41ms P95 90.56ms
중빈도 토큰 (60%): 평균 17.48ms P95 87.26ms
고빈도 토큰 (30%): 평균 44.44ms P95 167.63ms
자동완성: 평균 10.37ms P95 65.03ms
최신 게시글: 평균 17.95ms P95 81.02ms
상세 조회: 평균 25.11ms P95 91.06ms
쓰기 (생성+좋아요): 평균 23.94ms P95 87.92ms

Overview

CDC k6 Overview

시나리오별 응답시간

CDC 시나리오별 응답시간

시나리오@ApplicationModuleListener 평균Kafka CDC 평균P95판정
검색37ms39ms138ms동등
자동완성9ms9.4ms23ms동등
목록 조회16ms16.9ms36ms동등
상세 조회21ms22.7ms52ms동등
쓰기33ms24ms88ms개선

네트워크 상세

CDC 네트워크 상세

단계평균
DNS0.06ms
연결0.009ms
대기 (TTFB)39.9ms
수신0.56ms

Debezium CDC 모니터링

Debezium 대시보드

지표의미
ConnectedCONNECTEDDebezium ↔ MySQL binlog 연결 정상
Total Events Seen40.1K부하 테스트 동안 감지한 총 이벤트 수
Erroneous Events0에러 없음
Disconnects0연결 끊김 없음
  • CDC Lag: 초기 ~10초 스파이크 후 0ms 근처로 수렴
  • Events Per Second: 부하에 따라 0 → ~60 ops/s까지 선형 증가

Kafka 모니터링

Kafka 대시보드

  • Lag by Consumer Group: wiki-cdc-consumerlag 0. Consumer가 메시지를 즉시 소비

Kafka Partitions per Topic

Spring Boot / JVM

Spring Boot Actuator

  • Heap used: 8.45% (92.5 MiB / 1 GiB)
  • HTTP Errors (5xx): 0 ops/s

JVM Misc

  • GC Pressure: 0.1%
  • Threads: live 38, daemon 31, peak 41

JVM Non-Heap + GC

Classloading + Buffer Pools

  • Mapped buffer: ~30 GB — Lucene 인덱스 파일의 memory-mapped I/O. 1,215만 건 인덱스

Nginx

Nginx

인프라 (Host)

Host — CDC

서버CPU 피크메모리Swap
서버 1 (App 1 + MySQL Primary + Redis)~40%57.7%13.1%
서버 2 (App 2 + MySQL Replica + Kafka + Debezium)38.6%38.6%0.07%
서버 3 (Grafana + Prometheus + InfluxDB)44.9%44.9%6.32%

컨테이너별 리소스

Containers — CDC

  • wiki-app-prod CPU: 피크 ~70% — @ApplicationModuleListener(~100%)보다 낮음. CDC가 Lucene 인덱싱을 분리

MySQL

MySQL — CDC

지표PrimaryReplica
QPS (피크)~150 ops/s~300 ops/s
InnoDB 버퍼 풀 히트율100.0%99.5%
Slow Queries (누적)024.8K

InnoDB Row Lock + Row 연산

MySQL Replication

MySQL Replication — CDC

  • Replication Lag: 0~1초 사이 진동 (정상)
  • CDC(Debezium)는 Primary의 binlog를 직접 읽으므로, Replication Lag과 무관하게 동작

Redis

Redis — CDC

지표
메모리 사용률1.43%
L2 캐시 히트율51.3%
Keys (전체)1.85K
OPS (피크)~60 ops/s
Eviction0

Redis Network — CDC

Application HTTP

Application HTTP — CDC

  • 두 인스턴스 모두 안정 구간에서 ~50ms 이하
  • HTTP 에러율: 초기 시점 후 0%

HikariCP

HikariCP — CDC

  • 커넥션 획득 시간: ~1ms 이하
  • 프로세스 CPU: 피크 ~50% (@ApplicationModuleListener ~100% 대비 감소)

Tiered Cache

Tiered Cache — CDC

계층@ApplicationModuleListenerKafka CDC변화
L1 (Caffeine)36%30%소폭 하락
L2 (Redis)43%43%동등
Origin21%27%소폭 상승

L1+L2 합산: 79% → 73%. CDC Consumer가 캐시를 더 적극적으로 무효화하기 때문.


10. CDC 전환 최종 종합

항목결과
dual-write 원천 차단MySQL binlog → Debezium → Kafka 경로로 모든 DB 변경 캡처. 직접 SQL도 감지
이벤트 기반 디커플링PostService → Lucene/캐시 직접 호출 제거. OCP 준수
비동기 실행쓰기 응답에서 Lucene/캐시 완전 분리. HTTP 스레드 점유 없음
성능평균 35.6ms / P95 138ms / P99 294ms / 에러율 0% — 쓰기 33ms → 24ms 소폭 개선
CDC 파이프라인 안정성Debezium CDC Lag ~0ms 수렴, Consumer Lag 0, Erroneous Events 0, Disconnects 0
이벤트 유실 방지Kafka 디스크 기반 보존 (7일). 앱 장애 시에도 이벤트 보존
이벤트 리플레이Kafka 토픽에서 리플레이하여 Lucene 인덱스 재구축 가능
App CPU 감소Lucene 인덱싱이 CDC Consumer로 분리되어 App CPU 피크 100% → ~50%
Kafka 없는 환경 fallback@ConditionalOnProperty@ApplicationModuleListener 자동 전환
모니터링kafka-exporter + Debezium JMX + kafka-ui + Grafana 대시보드 4개 자동 배포
CI/CD 자동화GitHub Actions → Ansible → Docker Compose build + Debezium Connector 등록까지 완전 자동화

CDC E2E 지연 SLA와 대응 전략

지표현재 값SLA 기준초과 시 대응
CDC E2E 지연 (생성 → 검색 가능)평균 2.1초5초Debezium MilliSecondsBehindSource 알림 → Connector 재시작
Debezium CDC Lag~0ms (정상), 피크 ~10초60초대량 DML 원인 확인 → off-peak 시간으로 배치 이동
Kafka Consumer Lag0100 messagesConsumer 스레드 수 확인, 파티션 추가 검토

주간 운영 체크리스트

주간 운영 체크리스트

운영 비용 총평: Kafka + Debezium의 주간 운영 시간은 약 30분~1시간이다. 대부분 Grafana 알림이 자동으로 커버하고, 수동 점검은 주 1회 5분 수준이다. 이 비용은 dual-write 불일치 발생 시 디버깅 + 전체 재인덱싱(28분) + 사용자 불만 대응에 소모되는 시간보다 확실히 작다.


부록: 검색엔진 자동완성 시스템 설계 — 대규모 아키텍처 관점

이 부록은 시스템 디자인 인터뷰 대비 정리이며, wikiEngine의 현재 구현과는 규모가 다르다.

요구사항

항목결정 사항
지원 언어한국어, 영어
자동완성 제안 수10개
제안 기준최근 24시간 내 가장 인기 있는 검색어 기반
최대 접두사 길이60자
규모매일 수십억 개의 검색 쿼리
최대 응답 시간240ms
일관성 모델최종 일관성(Eventual Consistency)

읽기와 쓰기의 요구사항 분리

자동완성 시스템을 설계할 때 먼저 본 것은 “같은 검색 기능처럼 보여도 실제 요구사항은 다르다”는 점이었습니다. 자동완성 제안은 사용자가 입력할 때마다 즉시 반응해야 하므로 매우 빠른 읽기 경로가 중요하지만, 검색어 빈도를 누적하는 경로는 약간의 지연을 허용하더라도 안정적으로 기록되는 편이 더 중요합니다.

이 둘을 하나의 데이터 구조로 함께 처리하면, 빠르게 보여주는 일과 정확하게 누적하는 일이 서로를 방해할 수 있습니다. 그래서 조회 경로와 집계 경로를 분리해, 자동완성은 이미 준비된 결과를 즉시 반환하고, 검색어 집계는 뒤에서 안정적으로 누적·정리하는 구조가 더 적합하다고 판단했습니다.

Trie 자료구조 — naive 구현의 한계와 프로덕션 최적화

자동완성을 생각하면 가장 먼저 떠오르는 건 Trie입니다. 소규모에서는 접두사 탐색에 자연스러운 선택이지만, 규모가 커질수록 모든 후보를 메모리에 유지하는 비용이 커지고, 특히 1~2글자 접두사에서는 분기 수가 급격히 늘어나 원하는 순서로 다시 정렬하는 과정까지 포함하면 응답 시간을 안정적으로 맞추기 어려워집니다.

그래서 이 프로젝트에서는 입력 시마다 후보를 다시 탐색하는 방식보다, 접두사마다 상위 추천 결과를 미리 정리해두고 요청이 오면 바로 반환하는 구조가 더 적합하다고 판단했습니다. 즉, 접두사마다 정렬된 추천 결과를 미리 대응시켜 두는 단순한 조회 구조로 바꾼 것입니다. 이 방식은 조회 경로를 예측 가능하게 만들고, 분산 환경에서도 같은 기준의 추천 결과를 일관되게 제공하기 쉽다는 장점이 있습니다.

물론 Trie 계열 자체가 항상 부적합한 것은 아닙니다. 규모가 큰 서비스는 Trie 변형이나 FST처럼 메모리와 탐색 비용을 줄인 자료구조를 사용하기도 합니다. 다만 이 글에서 강조하고 싶은 건 “어떤 자료구조가 더 멋진가”보다, 요구사항과 운영 제약 안에서 어떤 조회 방식이 더 단순하고 안정적인가를 먼저 판단했다는 점입니다.

데이터 처리 파이프라인 — MapReduce 패턴과 현대 구현

자동완성 결과는 검색처럼 매 요청마다 즉시 다시 계산해야 하는 기능은 아니라고 봤습니다. 최신 결과가 약간 늦게 반영되어도 괜찮다는 전제가 있었기 때문에, 모든 입력을 실시간으로 처리하는 복잡한 구조보다 일정 주기로 모아 집계하는 방식이 더 적합했습니다.

그래서 사용자가 입력한 검색어는 먼저 모아두고, 일정 주기마다 최근 데이터를 기준으로 접두사별 상위 추천 결과를 다시 계산하는 흐름으로 설계했습니다. 어떤 검색어가 많이 입력되면 그 검색어는 자신의 모든 접두사 후보에 반영되고, 각 접두사마다 상위 추천 결과만 남기는 식입니다. 이렇게 하면 조회 시점에는 이미 정렬이 끝난 결과를 바로 반환할 수 있고, 무거운 계산은 뒤에서 주기적으로 처리할 수 있습니다.

여기서 말하는 MapReduce는 특정 프레임워크 이름이 아니라, “데이터를 나누고, 같은 키끼리 모으고, 다시 집계한다”는 사고 패턴에 가깝습니다. 핵심은 대규모 처리 기술을 과시하는 것이 아니라, 요구사항이 허용하는 지연 범위 안에서 가장 단순하고 운영 가능한 흐름을 선택했다는 점입니다.

데이터 동기화 — 변경 전파 구조

자동완성 데이터 동기화 — CDC 흐름

자동완성 데이터의 원천은 사용자의 검색 행동이고, 게시글 데이터의 원천은 서비스의 저장소입니다. 이 둘은 성격이 다르기 때문에 같은 방식으로 다루지 않았습니다. 검색어 순위는 집계 흐름으로 만들고, 게시글 제목 변경처럼 자동완성 결과 자체에 영향을 주는 데이터는 변경 사항이 뒤에서 자동으로 전파되도록 설계했습니다.

즉, “사용자 행동은 모아서 다시 계산하고, 데이터 상태 변경은 필요한 곳에 자동 반영한다”는 식으로 경로를 나눈 것입니다. 이렇게 해야 전체를 실시간으로 처리하지 않아도 필요한 최신성은 유지하면서 복잡도를 통제할 수 있습니다.

설계 하이라이트

#설계 포인트wikiEngine 적용
1Trie → flat KV 진화Trie 자동완성에서 소규모 Trie 구현 → Redis L2에서 flat KV O(1) 전환. 프로덕션 시스템(Bing, Google)은 Trie 변형(FST, PruningRadixTrie) + flat KV 서빙의 2단계 구조
2읽기/쓰기 분리조회는 즉시 반환에, 집계는 주기적 정리에 맞춰 서로 다른 경로로 분리
3공유 조회 구조접두사별 추천 결과를 여러 인스턴스가 같은 기준으로 조회하도록 구성
4주기적 집계 파이프라인검색어를 모아 접두사별 상위 추천 결과를 다시 계산하는 흐름으로 단순화
5변경 전파 구조데이터 저장소 변경이 검색 인덱스와 캐시에 뒤에서 자연스럽게 반영되도록 구성

후속 개선 — CDC 에러 핸들링 강화

DLQ(Dead Letter Topic) + 재시도 로직

항목변경 전변경 후
예외 처리catch(Exception) { log.error(); } — 예외 삼킴, 메시지 유실throw new RuntimeException(e) — Spring Kafka DefaultErrorHandler가 재시도
재시도없음1초 간격 9회 = 총 10회 시도 (FixedBackOff)
실패 후 처리로그만DLT({토픽}.DLT)로 격리, 사후 분석/재처리 가능
offset 커밋enable-auto-commit 미명시enable-auto-commit=false + ack-mode=RECORD 명시

왜 예외를 throw해야 하는가: Spring Kafka의 DefaultErrorHandler는 리스너가 예외를 throw할 때만 동작합니다. catch에서 예외를 삼키면 Kafka 입장에서 “정상 처리”로 간주되어 offset이 커밋되고, 재시도도 DLQ 격리도 일어나지 않습니다. Confluent 공식 문서에서도 DLQ는 “운영 관찰성 시그널”로 — DLT에 메시지가 쌓이면 알림을 보내 원인을 분석하는 패턴을 권장합니다.

AckMode.RECORD를 명시한 이유: enable-auto-commit=true(기본값)면 poll() 주기마다 자동 커밋되어, 처리 실패한 메시지도 커밋될 수 있습니다. enable-auto-commit=false + ack-mode=RECORD로 설정하면 각 레코드 처리 완료 후에만 offset이 커밋되어, 실패 시 해당 레코드부터 재처리됩니다.

관련 파일:

  • CdcErrorHandlerConfig.javaDefaultErrorHandler + DeadLetterPublishingRecoverer
  • DebeziumCdcConsumer.java — catch에서 throw 추가
  • application.ymlenable-auto-commit: false, ack-mode: RECORD

Previous Post

This post follows View Count Redis INCR + Write-Behind Batch Flush, where DB UPDATE conflicts in GET requests were resolved with Redis INCR. This post covers the evolution from PostService’s dual-write architecture to event-driven synchronization and ultimately Debezium + Kafka CDC for capturing all database changes.

Author
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.