포스트

네이버 D2 「6개월 만에 연간 수십조를 처리하는 DB CDC 복제 도구 무중단/무장애 교체하기」 리뷰 — 복제·검증·복구를 셋으로 나누고, 옛 도구와 새 도구를 서로 모르게 같이 돌린 전환

원문: 6개월 만에 연간 수십조를 처리하는 DB CDC 복제 도구 무중단/무장애 교체하기 — NAVER D2, 조찬형·이영대·김동민, 2025-11-18

한 줄 요약

네이버페이 주문 DB의 변경을 판매자·정산·배송 DB 등으로 복사해 주는 도구를 7년 만에 새로 만들어 바꿨다. 새 도구(ergate)는 일을 복제·검증·복구 셋으로 나눴고, 빠른 것은 Flink에, 복잡한 것은 Spring에 뒀다. 전환은 옛 도구와 새 도구가 서로 모르는 채로 같은 데이터를 둘 다 복제하게 한 뒤, 검증부터 먼저 켜고, 테이블을 단계별로 옮기고, 옛 도구의 검증은 맨 마지막까지 남겼다. 결과는 무중단·무장애.

배경: 왜 바꾸는가

주문 DB는 구매자 기준으로 나뉘어(샤딩) 있다. 판매자 화면은 판매자 기준으로 봐야 하므로 판매자 기준으로 다시 나눈 DB가 따로 있고, 정산·배송·호스팅사·옛 Oracle 조회 앱용 DB도 있다. 주문 DB의 변경을 이들에게 실시간으로 복사해 주는 것이 CDC 복제 도구다. CDC(Change Data Capture)는 DB가 남기는 변경 기록(MySQL의 binlog)을 읽어 “무엇이 바뀌었다”를 이벤트로 만드는 기법이고, 여기서는 사내 도구가 그것을 Kafka에 넣는다.

기존 도구(mig-data)는 DB 코어 경험이 있는 개발자가 순수 Java로 만든 것이라 백엔드 개발자가 넘겨받아 고치기 어려웠다. 그리고 예전에 양방향 복제를 지원하느라 생긴 제약이 남아 있었다. 한 건이 실패하면 전체가 밀리고(양방향 무결성 확인을 위한 의도된 동작), 컬럼을 추가할 때 Target → Source 순서를 지켜야 했고(순서가 틀리면 복제가 잠시 멈춤), 장애 시 복구할 수 있는 사람이 제한적이었다.

목표: 백엔드 개발자가 유지보수할 수 있을 것, 스키마 변경 순서에 무관할 것, 복구 도구를 다양하게 갖출 것, 현재 QPS의 10배를 처리할 것, CDC 이벤트 발행 후 1초 안에 복제할 것.

Flink를 고른 이유는 저지연·대용량·고가용성을 프레임워크가 해 줘서 복제 로직에 집중할 수 있고, Kafka 연동이 쉽고, 알리바바 같은 커머스 사례가 있어서다. Kubernetes 위에 세션 모드(공용 클러스터에 job을 제출하는 방식)로 띄웠다. 복제 job 6개와 검증 job 6개, 총 12개를 한 화면에서 보고 한 번에 배포하려면 세션 모드가 맞았다.

그런데 Flink job 안에 모든 것을 넣으니 불편했다. 그래서 Flink에는 속도가 중요한 로직만 두고, 복잡한 복구 로직은 Spring 모듈로 뗐다. 스키마 정보도 Spring이 DB에서 읽어 객체로 만들고, Flink는 HTTP로 가져와 캐시한다. Flink는 병렬도가 높아 DB 커넥션을 나눠 쓰기 어렵기 때문이다.

세 기능은 이렇다.

  • 복제(jdbc-sink): Kafka 레코드를 읽어 Target에 반영한다. 복제 시각과 원본 커밋 시각을 담는 전용 컬럼 둘을 같이 쓴다.
  • 검증(verifier): 같은 토픽을 2분 늦게 읽어 Kafka 레코드와 Target의 현재 값을 비교한다. 2분 사이에 그 행이 또 바뀌었으면 무조건 불일치로 보이므로, 1차 불일치면 Source의 현재 값을 다시 조회해 재비교한다. 그래도 불일치면 DLQ(실패 큐)로 보내고 Spring이 상태 DB에 적는다.
  • 복구(Spring): 검증 실패 건을 복구 시점에 재조회해 다시 검증하고, 정말 다르면 Target을 고친다. 순단이면 5분 뒤 자동 복구, 그래도 안 되면 배치로 재시도, 어드민 페이지로 수동 복구. 복구 뒤 30분 지나 다시 확인하고 상태 DB에서 지운다.

개선 1: 컬럼 추가 순서에서 자유로워지기

기존 제약의 핵심은 “Target이 모르는 컬럼이 들어오면 처리 못 함”이었다. 원문의 관찰이 좋다. DDL(컬럼 추가)은 새벽에 하고, 그 컬럼에 실제 데이터가 들어오는 것은 코드 배포 뒤다. DDL 시점과 데이터 인입 시점은 다르다. 그러면 복제 도구가 스키마를 캐시해 두고, 복제 시점에 캐시된 컬럼만 쓰면 된다. Source에 먼저 컬럼이 생기면 Target 쪽 statement가 모르니 무시했다가 갱신 뒤 반영되고, Target에 먼저 생기면 데이터가 없으니 null이다. 어느 순서든 멈추지 않는다. 예외는 컬럼 삭제다. 캐시된 statement가 없는 컬럼을 쓰면 오류가 나므로, 삭제 전에 ignore-columns 설정을 미리 넣어 둔다.

개선 2: 실패해도 멈추지 않는다

복제 단계에서 한 건이 실패하면 무시하고 다음을 계속 복제한다. 실패 건은 검증이 잡아서 복구로 넘긴다. 잘못 잡힐 수 있으므로 복구 단계에서 다시 검증한다. Oracle은 읽기 전용 복제본에서 검증하는데 마스터와 복제본 차이로 오검출될 수 있어 복구 시 마스터에서 재조회하고, 판매자 DB는 샤딩 키 조회가 순단으로 실패할 수 있어 재조회한다. “실패를 막는다”가 아니라 “실패를 뒤에서 찾아 고친다”로 설계 원칙을 바꾼 것이다.

성능: 200ms를 40ms로

목표는 10배 QPS. 테스트 환경을 따로 만들고(같은 스키마의 논리 DB, 전용 CDC, fixture-monkey 기반 데이터 생성기 banana) 쟀더니 한 건이 Source 커밋부터 Target 커밋까지 200ms 걸렸다. 병렬도를 올리면 되지만 “DB 커넥션은 비싸다, Flink slot도 한정적이다”를 전제로 원인을 찾았다. flame graph를 켜고 하나씩 잡았다.

원인조치결과
task 간 객체 직렬화object reuse 옵션(같은 task manager 안에서는 직렬화 생략)200 → 130ms
데이터 쏠림Kafka 키(구매자 번호)를 그대로 Flink 키로 써서 파티션 쏠림이 slot까지 이어짐 → 구매자 번호 + 테이블명으로 키 구성130 → 70ms
DB I/O 횟수JdbcSink 확장: 고정 배치 크기 대신 TCP 혼잡 제어처럼 50~100ms에 할당량을 채우면 유지, 넘으면 줄이고, 못 채우면 늘리는 동적 배치70 → 40ms
네트워크 버퍼버퍼 타임아웃 0으로 처리량 대신 지연 우선소폭 개선
검증순서가 중요하지 않으므로 비동기로병렬도 낮춰도 충분

장애 테스트에서 나온 서킷 브레이커

Flink job manager·task manager·Spring 모듈을 각각 죽여 기대 동작을 확인했다. DB는 공용 개발 장비라 진짜 내릴 수 없어서, DB 커넥션 클래스를 확장해 피처 토글이 켜지면 3초 자다 예외를 던지는 BoomableConnection을 만들어 모의 장애를 냈다.

Target DB 장애에서 예상 못 한 일이 났다. 검증이 Target 행을 읽으려고 새 커넥션을 계속 시도하다 대기 시간이 쌓여 Flink 자체가 죽었다. Source 장애에서는 안 났는데, Source는 불일치일 때만 선택적으로 읽기 때문이다. 해법은 검증에 서킷 브레이커를 넣는 것이었다. DB 오류가 5회 연속이면 서킷을 열고, 열린 동안은 검증하지 않고 바로 DLQ로 보낸다. 원문에 그 구현 코드가 있다.

전환: 서로 모르는 두 도구

가장 중요한 결정이 여기 있다. 옛 도구에 “새 도구가 복제했으면 건너뛰기” 같은 방어 로직을 넣지 않았다. 옛 도구의 동작에 어떤 영향을 줄지 모르고, 운영에서 문제가 나면 위험이 크기 때문이다. 대신 둘이 각자 복제한다. 같은 행을 두 번 쓰니 일시적으로 과거 값으로 돌아갈 수 있지만, 충분히 빠르고 최종적으로 일치한다고 판단했다.

과거 회귀는 이미 기존 구조에 있던 문제였다. 옛 도구(비동기) 옆에 API로 즉시 복제하는 forceSync(동기)가 있었고, 짧은 시간에 A→B→C로 바뀌면 forceSync가 C를 먼저 쓰고 옛 도구가 A→B→C를 다시 써서 잠깐 과거로 갔다. 해법은 “복제 반영 시점” 컬럼을 두고 WHERE :commit_ts >= sync_seq 조건으로 더 오래된 커밋은 무시하는 것이다. 새 도구도 같은 논리로 rpc_time 컬럼을 썼다.

순서도 신중하다. 검증을 제일 먼저 배포한다. 검증은 데이터를 건드리지 않고, 자동 복구를 안 켜 두면 검출돼도 문제가 없으므로 오검출·과검출을 미리 볼 수 있다. 그리고 옛 도구의 검증은 맨 마지막까지 남긴다. 정상일 때는 검출이 없어 새 검증이 맞는지 알 수 없으니, 옛 검증이 정답지 역할을 한다. 테이블은 영향도 낮은 것부터 1차 5개, 2차 13개, 3차 나머지로 나눠, 각 차수마다 1주일 동시 복제 → 옛 도구 중단 → 새 도구 단독을 반복했다.

트러블슈팅 둘

  • job manager Metaspace OOM. 세션 모드에서는 job/task manager를 띄우는 Parent 클래스로더와 사용자 코드를 읽는 Child 클래스로더가 나뉘고, job이 끝나면 Child가 사라지며 자원을 놓는다. 그런데 사용자 코드의 클래스를 Parent 쪽 어딘가(예: Jackson ObjectMapper의 static Map)가 참조하면 Child가 못 사라진다. 배포를 반복하면 쌓여 OOM. 원인은 “Parent에 없는 사용자 클래스가 static 필드 타입으로 정의된 경우”였다. DB 드라이버 같은 것은 fat JAR로 클러스터에 미리 넣고, 사용자 코드에서 static 리소스를 피하고, ArchUnit 테스트로 재발을 막았다.
  • job manager split brain. Kubernetes HA에서 리더의 네트워크가 잠깐 끊긴 찰나에 팔로워가 리더를 가져갔는데 옛 리더가 내려오지 않아 둘이 됐다. 재배포 시 빠른 교체를 위해 lease-duration과 renew-deadline을 둘 다 5초로 뒀던 것이 원인이었다. 둘이 같으면 이 문제가 생길 수 있다. 30초·20초·6초로 바꾸자 사라졌다.

결과

1월 말 시작, 3월부터 본격 착수, 5월에 3명. 1차 목표인 판매자 DB 전환을 7월에 끝냈고 목표 전부 달성. 서비스 영향, 데이터 사고, 사용자 문의 없음. forceSync는 새 도구만으로 충분히 빨라 비활성화 뒤 내리는 중. 결정 과정은 ADR로 남겼다.

읽고 남는 질문

  • “서로 모르는 두 도구가 같은 행을 쓴다”는 전략은 >= 조건 덕에 안전하지만, 옛 도구와 새 도구가 같은 밀리초에 다른 값을 쓰면(ms를 절삭해 발행하므로) 어느 쪽이 남는지 정해지지 않는다. 원문도 >가 아니라 >=인 이유를 ms 절삭 때문이라고 적었는데, 그 창에서 실제 불일치가 몇 건 검출됐는지 궁금하다.
  • 검증의 2분 지연은 어떻게 정했는지. 너무 짧으면 오검출이 늘고 너무 길면 장애 발견이 늦어지는데, 그 사이의 값을 고른 근거가 있으면 좋겠다.
  • 동적 배치 크기가 TCP 혼잡 제어를 닮았다고 했는데, 트래픽이 급감할 때 할당량이 너무 작아져 지연이 늘지 않는지, 하한이 있는지가 없다.

한 줄로 가져가기

복제 도구의 안전은 “복제가 틀리지 않게”가 아니라 “틀린 것을 검증이 찾고 복구가 고치게” 셋으로 나누는 데서 나오고, 도구 교체의 안전은 옛것과 새것이 서로를 모른 채 같이 돌게 두고 검증을 먼저 켜는 데서 나온다.

시리즈

빅테크 기술 블로그 리뷰

31편 중 4편

  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 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다