포스트

카카오 「PostgreSQL to ES: Kafka Connect CDC 파이프라인」 1·2편 리뷰 — 변경이 없어서 디스크가 차고, LSN이 사라져서 스냅샷을 다시 짜야 했던 CDC의 실제 운영 비용

엔지니어링 요약

Problem

parity-pay는 Outbox의 대안으로 CDC를 검토하고 '초기 인프라 비용이 크다'는 이유로 뺐다. 그 비용이 실제로 무엇인지는 몰랐다. 카카오 클린플랫폼 팀이 PostgreSQL→Elasticsearch 동기화를 Debezium CDC로 바꾸고 운영하며 만난 문제를 두 편으로 적었다.

Decision

1편의 구성(Kafka Connect, Debezium, ES Sink, Strimzi)과 2편의 트러블슈팅 세 가지(변경 없는 테이블의 WAL 누적으로 Disk Full, 재시작 시 LSN 무효로 유실 위험, 날짜 기반 인덱스에서 과거 데이터 갱신 시 중복)를 옮기고, parity-pay가 Outbox를 고른 판단과 대조했다.

Result

CDC의 운영 비용은 '설정이 많다'가 아니라 '실패 모드가 DB 내부에 있다'는 것이었다. WAL slot이 갱신되지 않아 디스크가 차는 문제와 LSN이 유효하지 않다고 판단되는 문제는 애플리케이션 코드 어디에도 없고, 후자는 원인을 끝내 찾지 못해 '문제가 생겨도 유실이 없게' 스냅샷 쿼리를 다시 짜는 쪽으로 풀었다. Outbox의 실패 모드가 우리 테이블과 우리 발행기 안에 있다는 것이 parity-pay가 그것을 고른 실제 이유였다는 것을 이 글을 읽고 알았다.

원문: PostgreSQL to ES: (1) Kafka Connect CDC 파이프라인 구성, (2) Kafka Connect 트러블슈팅 — 카카오 기술블로그, 핀(클린플랫폼), 2025-10-10

parity-pay 5편에서 DB와 브로커의 이중 쓰기를 푸는 방법으로 Outbox를 고르고 CDC를 뺐다. 이유는 “폴링 지연은 줄지만 커넥터·스키마 레지스트리·binlog 운영이 붙는다”였다. 그 문장은 CDC를 운영해 보지 않고 쓴 것이다. 이 글은 CDC를 실제로 운영한 팀이 무엇을 만났는지 적었고, 그래서 내 판단이 맞았는지 확인할 재료다.

원문이 말하는 것

10년 된 콘텐츠 모니터링 시스템의 PostgreSQL → Elasticsearch 동기화 파이프라인이 문제였다. DB에 큐 테이블을 두고, 배치가 읽어 유입 파이프라인으로 보내고, 거기서 RabbitMQ로, 다시 배치가 ES에 넣는 네 단계였다. ES로 갈 데이터가 굳이 거칠 필요 없는 곳을 거치며 장애 지점만 늘렸고, 문제가 나면 원인 파악이 어려웠다. Logstash, NiFi, PGSync, Kafka Connect를 검토하고 Kafka가 익숙하다는 이유로 Kafka Connect를 골랐다.

1편은 구성이다. Debezium이 PostgreSQL의 logical decoding(pgoutput)으로 변경을 읽어 Kafka에 넣고, 중간의 Python 앱이 비즈니스 로직에 맞게 변환하고, ES Sink Connector가 인덱싱한다. Debezium 설정에서 눈에 띄는 것은 ByLogicalTableRouter로 테이블별 토픽을 하나로 합친 것, ES Sink에서 behavior.on.null.values: delete로 삭제를 전파한 것, 그리고 Kubernetes에서 Strimzi로 커넥터를 GitOps로 배포한 것이다.

2편이 이 리뷰의 이유다. 운영에서 만난 문제 셋.

변경이 없어서 디스크가 찬다. Debezium은 구독 테이블에 변경이 있을 때만 LSN(WAL 위치)을 PostgreSQL에 피드백한다. 변경이 거의 없는 테이블을 구독하면 slot의 LSN이 갱신되지 않고, PostgreSQL은 slot이 필요로 하는 WAL 파일을 지우지 못한다. WAL은 클러스터 전체가 공유하므로 다른 테이블의 변경이 쌓여 디스크가 찬다. 해결은 heartbeat 설정이다. 변경이 없어도 주기적으로 heartbeat 메시지를 보내 LSN을 전진시킨다.

LSN이 유효하지 않다고 한다. Debezium을 재시작하면 Kafka에 기록한 마지막 LSN부터 다시 요청하는데, 간헐적으로 PostgreSQL이 그 LSN이 유효하지 않다고 판단했다. 거의 안 나지만 나면 유실이다. PostgreSQL 담당과 원인을 찾았지만 재현되지 않아 원인을 끝내 못 찾았다. 그래서 문제를 없애는 대신 문제가 나도 유실이 없게 했다. snapshot.mode: when_needed로 LSN이 무효면 스냅샷을 다시 뜨되, 기본 스냅샷은 테이블 전체(1억 행이면 1억 행)라 SnapshotQuery SPI를 구현해 updated_at 기준 최근 N분만 읽게 했다. 적용 뒤 유실이 없었다.

과거 데이터가 갱신되면 인덱스가 둘이 된다. ES 인덱스를 생성 월로 나눴는데(article-2025_08), 8월 데이터가 9월에 수정되면 TimestampRouterarticle-2025_09에 중복 저장했다. 해결은 Python 앱이 생성 월 기준 인덱스 이름을 메시지 키에 실어 보내고, 직접 구현한 Transforms가 키에서 인덱스를 꺼내 토픽으로 바꿔치기하는 것이었다.

CDC의 비용은 설정이 아니라 실패 모드의 위치다

parity-pay가 CDC를 뺀 이유를 “인프라 비용”이라고 썼는데, 이 글을 읽고 나서 그 말이 부정확했다는 것을 알았다. 커넥터 설정과 Strimzi 배포는 1편이 보여주듯 한 번 하면 끝나는 일이다. 진짜 비용은 2편에 있다. 실패 모드가 DB 내부에 있다.

Outbox의 실패 모드는 우리 테이블과 우리 발행기 안에 있다. 발행기가 안 돌면 PENDING이 쌓이고(5편 결함 B), 브로커가 죽으면 attempt_count가 안 오르고(결함 C), 발행기를 여러 대 띄우면 순서가 깨진다(결함 F). 전부 우리 코드에서 재현하고 우리 테스트로 고정할 수 있었다. CDC의 실패 모드는 WAL slot, LSN, logical decoding 안에 있다. 변경이 없는 테이블이 디스크를 채운다는 것은 애플리케이션 코드 어디에도 없는 인과다. LSN이 무효가 되는 원인은 DB 담당과 찾아도 못 찾았다. 원문의 팀이 잘못한 것이 아니라, CDC가 DB의 복제 메커니즘 위에 서 있어서 그 메커니즘의 실패가 곧 파이프라인의 실패가 되는 구조다.

그래서 원문의 해법이 인상적이다. “원인을 못 찾았으니 문제가 나도 유실이 없게 한다.” 스냅샷 쿼리를 최근 N분으로 좁힌 것은 유실 창을 N분 이내로 묶고, LSN이 무효가 되는 빈도와 무관하게 안전하게 만든다. parity-pay 3편에서 외부 응답 유실의 원인을 통제할 수 없으니 UNKNOWN으로 보존해 수렴시킨 것과 같은 자세다. 통제할 수 없는 실패는 없애는 것이 아니라 그 실패 아래에서도 정합성이 유지되게 만든다.

같은 곳: 삭제와 갱신은 삽입과 다르다

세 번째 문제(과거 데이터 갱신 시 인덱스 중복)는 CDC 고유의 문제가 아니라 파생 데이터 전반의 문제다. 원본의 갱신이 파생 저장소에서 “새 문서”가 되면 안 되고 “같은 문서의 갱신”이어야 한다. parity-pay 7편에서 원장은 append-only이지만 잔액 스냅샷은 갱신이라 둘의 일치를 INV-010으로 검증해야 했던 것과 같은 자리다. 원문은 그것을 “인덱스 이름을 원본의 생성 월로 고정한다”로 풀었고, 그 결정을 Transforms가 아니라 앞단 앱에서 내리게 한 것이 맞다. 인덱스 이름은 비즈니스 규칙이지 라우팅 규칙이 아니다.

원문이 답하지 않는 것

  • 지연. 기존 네 단계 파이프라인 대비 CDC 파이프라인의 end-to-end 지연이 얼마인지 없다. “준실시간”이라고만 적혀 있다. 재설계의 동기가 복잡성이었지 지연이 아니었으므로 당연할 수 있지만, Outbox 폴링(500ms)과 비교할 수 있는 숫자가 있었으면 좋았을 것이다.
  • 중복 소비. Debezium은 at-least-once다. 재시작이나 스냅샷 재실행 시 같은 변경이 두 번 갈 수 있는데, ES Sink가 문서 ID로 upsert하므로 멱등이 자연히 성립한다. 원문은 그것을 명시하지 않았다. 파생 저장소가 upsert가 아니라 append였다면(예: 정산 항목 생성) 이 파이프라인은 5편의 consumed_event 같은 소비 이력이 필요했을 것이다.
  • heartbeat의 부작용. heartbeat 토픽에 메시지가 쌓인다. 정리 정책이 무엇인지, 스크레이프 지표처럼 조용히 자라는 것은 없는지는 적혀 있지 않다.

가져갈 것

  • CDC와 Outbox의 차이는 지연이나 설정량이 아니라 실패 모드가 어디에 있는가다. Outbox는 우리 코드 안, CDC는 DB 복제 메커니즘 안.
  • 원인을 못 찾은 실패는 없애지 말고 그 아래에서 정합성이 유지되게 만든다. 스냅샷 창을 좁히는 것이 그 예다.
  • 변경이 없는 것도 상태다. slot이 전진하지 않으면 다른 테이블의 WAL이 쌓인다.
  • 파생 저장소에서 원본의 갱신은 새 문서가 아니라 같은 문서의 갱신이어야 하고, 그 판단은 라우팅이 아니라 비즈니스 계층이 한다.
시리즈

빅테크 기술 블로그 리뷰

31편 중 10편

  1. 1 카카오 「실시간 메시징 시스템 개발기」(3편) 리뷰 — Redis에 몰린 부하를 서버로 옮기고, 그 서버를 pprof로 세 번 깎은 이야기
  2. 2 카카오 「추가배포 없이 API의 case 통일시키기」 리뷰 — 받는 쪽이 자기 케이스에 맞춰 알아서 읽게 하면 배포 순서가 사라진다
  3. 3 카카오 「MySQL DATETIME, TIMESTAMP 데이터 타입에 대한 분석」 리뷰 — 바이트 단위 저장 구조부터 아직 안 고쳐진 Y2K38까지
  4. 4 네이버 D2 「6개월 만에 연간 수십조를 처리하는 DB CDC 복제 도구 무중단/무장애 교체하기」 리뷰 — 복제·검증·복구를 셋으로 나누고, 옛 도구와 새 도구를 서로 모르게 같이 돌린 전환
  5. 5 카카오 「MySQL ALTER DDL 수행 방식에 대한 이해」 리뷰 — Copy·In-Place·Instant는 '무엇을 복사하느냐'보다 '언제 Exclusive 메타 락을 잡느냐'로 구분된다
  6. 6 카카오 「MySQL Orchestrator 기반의 새로운 HA 표준 개발기」 리뷰 — 10년 멈춘 Perl 도구를 떠나 Raft 클러스터로, 그리고 slave_net_timeout 한 줄
  7. 7 카카오 「MySQL Ver. 8.0 New Feature: Instant DDL Algorithm에 대한 이해」 리뷰 — 컬럼을 0.01초에 추가하는 대가는 '읽을 때마다 버전을 대조하는 것'이다
  8. 8 네이버 D2 「CDC 복제 이후 오라클이 느려졌다? child cursor 폭증이 만든 예상치 못한 문제」 리뷰 — 같은 SQL인데 바인딩 타입이 다르면 Oracle은 다른 쿼리로 본다
  9. 9 카카오 「MySQL InnoDB Log에 대한 이해 - (1)」 리뷰 — 트랜잭션 하나가 어떻게 MTR 여러 개로 쪼개져 Redo Log Buffer에 들어가는가
  10. 10 카카오 「PostgreSQL to ES: Kafka Connect CDC 파이프라인」 1·2편 리뷰 — 변경이 없어서 디스크가 차고, LSN이 사라져서 스냅샷을 다시 짜야 했던 CDC의 실제 운영 비용
  11. 11 LINE 「기획서 없이 내재화하기: 검증 로직으로 동일함을 증명하다」 리뷰 — 블랙박스는 입력과 출력만 정의하면 통계로 같음을 증명할 수 있다
  12. 12 뱅크샐러드 「게임을 만들 때 데이터 정합성을 유지하는 법 (feat. 낙관적 락)」 리뷰 — 같은 WHERE version 조건인데, 충돌한 요청을 어떻게 하는가에서 갈리는 두 설계
  13. 13 우아한형제들 「Spring Batch와 Querydsl」 리뷰 — offset을 버린 Reader가 21분을 4분으로 만든 이유, 그리고 그 Reader가 답하지 않는 두 가지
  14. 14 네이버 D2 「테스트는 어떻게 좋은 코드를 만드는가(feat. 험블 객체 패턴)」 리뷰 — 목이 많아지는 것은 테스트의 문제가 아니라 설계의 신호
  15. 15 당근 「QR을 찍으면 무슨 일이 벌어질까? 당근페이 현장 결제의 모든 것」 리뷰 — 카드망을 빌려 7주 만에 낸 결제와, 그 글이 다루지 않은 승인 응답이 사라지는 순간
  16. 16 네이버 D2 「스마트스토어센터 Oracle에서 MySQL로의 무중단 전환기」 리뷰 — 두 DB에 동시에 쓰되 한쪽 실패는 무시하고, 읽기 트래픽을 복제해 성능을 재고, 6개월간 불일치를 0으로 만든 과정
  17. 17 야놀자 「RESTful API validation 자동화 하기」 리뷰 — 인터페이스 하나에서 검증·문서·타입을 뽑는 이유, 그리고 자동화가 없으면 누락이 필연이라는 문장
  18. 18 카카오 「MySQL Json 데이터 타입의 저장 구조와 성능 비교」 리뷰 — 통째로 넣고 통째로 꺼내면 TEXT, 키로 파고들면 JSON
  19. 19 LINE 「도메인에 의존하지 않는 채팅 플랫폼은 어떻게 만들었을까?」 리뷰 — 사용자를 모르는 채팅 플랫폼, 웹으로 만든 클라이언트, SOFT STOP으로 갈아 끼우는 챗봇 시나리오
  20. 20 카카오페이 「MSA 환경에서 네트워크 예외를 잘 다루는 방법」 리뷰 — Unknown을 타입으로 만든 글과, Unknown을 상태로 저장한 프로젝트가 갈리는 지점
  21. 21 카카오 「메시징 서버의 스트레스 테스트 노하우와 AI가 덜어 준 부분」 리뷰 — 지표를 네 층으로 내려가 읽는 법, 그리고 LLM에게 맡긴 것과 맡기지 못한 것
  22. 22 네이버 D2 「일 3,000만 건의 네이버페이 주문 메시지를 처리하는 Kafka 시스템의 무중단 전환 사례」 리뷰 — 두 벌로 발행해 대조한 검증기와, 발행 제어 키를 파티션 키와 같게 둔 이유
  23. 23 카카오 「잃어버린 리포트를 찾아서: 카카오 메시징 시스템의 경쟁 조건 문제와 안티 패턴 제거 과정」 리뷰 — 벤더가 8ms 만에 리포트를 보냈고, 우리는 101ms짜리 트랜잭션 안에 있었다
  24. 24 컬리 「컬리의 입고 시스템이 외부 인입 데이터를 안전하게 동기화하는 방법」 리뷰 — 145회 재시도가 맞는 도메인과 재시도를 금지한 도메인, 그리고 발행부와 수신부가 각자 책임지는 구조
  25. 25 네이버 D2 「@RequestCache: HTTP 요청 범위 캐싱을 위한 커스텀 애너테이션 개발기」 리뷰 — 캐시의 수명을 '요청 하나'로 맞추면 TTL 고민이 사라진다, 그리고 @RequestScope가 안 되는 이유
  26. 26 무신사 「Kafka와 Strimzi를 이용하여 6개의 도메인을 하나의 도메인으로 합쳐보았습니다」 리뷰 — 배치 없이 CDC와 Kafka Streams로 옮긴 결정, 그리고 통합 모델이 원본과 같다는 것을 누가 확인하는가
  27. 27 쿠팡 「대용량 트래픽 처리를 위한 쿠팡의 백엔드 전략」 리뷰 — 캐시 두 겹과 '분 단위 99.99% 동일'이라는 문장, 그리고 그 0.01%를 누가 어떻게 세는가
  28. 28 LINE 「LINE에서 Kafka를 사용하는 방법 - 1편」 리뷰 — 초당 4GB 클러스터가 바이트가 아니라 요청 수를 제한하는 이유, 그리고 2,000틱짜리 파이프라인에서 같은 것을 본 기록
  29. 29 카카오 「MySQL 인증 플러그인 caching_sha2_password에 대한 이해」 리뷰 — 비밀번호 해시가 바뀌는 것보다 '평문이 서버까지 가야 한다'는 점이 전환의 진짜 비용
  30. 30 LINE 「초당 100만 건, LINE 앱에 Apache Kafka 종단 간 암호화 적용기」 리뷰 — 인터셉터와 시리얼라이저만으로 브로커에 평문을 남기지 않는 법
  31. 31 카카오뱅크 「하루 N억 건의 알림 시스템 구축기 (1)」 리뷰 — P99의 80%가 대기였다는 진단, Age 기반 Work-Stealing, 그리고 큐를 나누는 순간 순서를 잃는 문제
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다