5. Incremental Dumps and CDC Boundaries

주기적 덤프는 시간 경계에서, CDC는 스키마 경계에서 틀린다

  • Haram Lee
  • 2026-10-13
  • studies / Topics / Data Loading

같은 DB를 두 방식으로 옮기면 어디서 틀릴까?

  • 2시간마다 증분 덤프를 할 때 회차 경계에 걸린 변경은 어떻게 될까?
  • 증분 적재가 성립하려면 무엇이 필요할까?
  • CDC는 왜 DDL 하나를 놓치면 그 뒤가 모두 틀릴까?

5.1 Incremental Needs Two Conditions

증분 덤프부터 보자. 많이 쓰는 모양은 이렇다. 원본 DB에서 updated_at이 그날인 행만 읽고, BigQuery의 그날 날짜 파티션을 덮어쓴다.

여기에는 조건이 두 개 있다. 읽기를 그날로 좁히는 필터와, 결과를 그날 파티션에만 쓰게 하는 파티션 키다. 쓰기가 항상 덮어쓰기라면 둘 중 하나만 있을 때 사고가 난다.

필터파티션 키결과
있음있음그날 파티션만 그날 변경으로 바뀐다. 의도한 증분 적재다
있음없음매 실행이 테이블 전체를 그날 변경으로 바꿔 끼운다. 변경이 없는 날 테이블이 빈다
없음있음매 실행이 원본 전체를 그날 파티션에 넣는다. 파티션마다 전체 사본이 쌓인다

두 번째 줄은 실제로 겪었다. 필터만 있고 파티션 키가 없던 증분 적재가 날짜가 바뀐 첫 실행에서 0행으로 테이블을 덮어써 테이블이 비었다. 그 뒤로는 증분을 켜면 두 값이 모두 있어야 저장되도록 검증을 넣고, 원본의 파티션 키에서 두 값을 자동으로 채우게 했다. 실수하기 쉬운 조합은 안내보다 규칙으로 막는 편이 낫다.

5.2 Rereading the Whole Day

이제 경계 문제다. 2시간마다 도는 증분 덤프에서 덤프 도중에 바뀌거나 회차 경계에 애매하게 걸린 행은 어떻게 될까.

답은 읽는 범위에 달려 있다. 내가 본 구조는 회차마다 2시간치가 아니라 UTC 하루 전체 [그날 00:00, 다음 날 00:00)를 다시 읽어 그날 파티션을 통째로 덮어썼다. 그러면 경계 문제는 대부분 생기지 않는다.

gantt
  dateFormat HH:mm
  axisFormat %H:%M
  section 읽는 범위
  2시 회차는 0시부터 2시까지 :a1, 00:00, 2h
  4시 회차는 0시부터 4시까지 :a2, 00:00, 4h
  6시 회차는 0시부터 6시까지 :a3, 00:00, 6h

하루 중간 회차에서 빠진 행은 다음 회차가 다시 읽는다. 스캔 도중 바뀐 행도, 아직 secondary에 복제되지 않은 행도, 중간 회차가 실패한 경우도 다음 회차가 같은 파티션을 다시 덮어쓰며 메운다. 1장과 2장에서 본 “파티션을 통째로 덮어쓰니 결과는 맞다"가 여기서도 같은 역할을 한다. 대신 2장에서 봤듯 결과가 맞다는 것과 얼마나 많이 읽고 옮기는지는 별개다. 하루 마지막 회차는 하루치를 통째로 다시 읽는다.

5.3 What Is Still Lost at Midnight

유실이 실제로 남는 경우는 그날 마지막 회차와 관련된 세 가지다. 마지막 회차는 자정이 지난 뒤에 실행되어 그날 전체를 한 번 더 읽고, 다음 날 회차는 다음 날 범위만 읽기 때문이다.

  1. 자정 직전 변경이 늦게 복제된 경우. 23:59에 바뀐 행이 마지막 회차가 읽을 때까지 secondary에 복제되지 않았다면 그날 파티션에서 빠진다. 평소에는 실행 대기와 클러스터 기동 때문에 자정 몇 분 뒤에야 읽기 시작하고, 복제 지연은 초 단위라 이런 일은 거의 생기지 않는다. 일부러 늦게 따라오는 delayed replica에서 읽는다면 이 전제가 깨진다.
  2. updated_at이 commit보다 이른 경우. 앱이 updated_at을 먼저 찍고 commit이 늦게 끝나면 자정 전 시각이 붙은 행이 자정 뒤에야 보인다.
  3. 그날 마지막 회차가 실패한 경우. 마지막 2시간치 변경이 빠진 채 남고, 저절로 복구되지 않는다. 가장 현실적인 경우다.

1번과 2번은 전날을 한 번 더 읽으면 막힌다. 다음 날 첫 회차가 전날 파티션도 다시 덮어쓰게 하거나, 범위 시작을 몇 분 앞당겨 겹쳐 읽는다. 3번은 마지막 회차의 실패를 알림으로 잡고 같은 실행 날짜로 다시 돌리면 된다. 범위를 실행 날짜로 정하므로 재실행만으로 그대로 복구된다.

경계와 별개로 이 구조에서 항상 성립하는 성질도 있다. 같은 키가 여러 날짜 파티션에 남는다. 어제 수정되고 오늘 다시 수정된 행은 어제 파티션에 옛 값이 그대로 있다. 최신 상태를 보려면 키별로 updated_at이 가장 큰 한 건을 고르는 중복 제거가 필요하다. 그리고 삭제는 잡히지 않는다.

5.4 DDL and DML in the Binlog

같은 DB를 CDC로 읽으면 경계가 생기는 곳이 달라진다. 먼저 SQL 문장의 두 갈래를 짚어 두자.

  • DDL은 데이터를 담을 구조를 바꾼다. CREATE TABLE, ALTER TABLE, RENAME TABLE 같은 문장이다. 컬럼의 이름, 순서, 타입이 바뀐다.
  • DML은 이미 있는 테이블의 행을 바꾼다. INSERT, UPDATE, DELETE 같은 문장이다.

MySQL의 ROW 기반 binlog는 둘을 다른 모양으로 남긴다. DML은 값 배열이다. TABLE_MAP_EVENT가 테이블 번호와 컬럼 타입 목록을 주고, 이어지는 ROWS 이벤트가 위치별 값 배열을 준다. 여기에 컬럼 이름은 없다.

이름을 붙이는 것은 Debezium 같은 CDC 커넥터다. 커넥터는 테이블별 스키마를 메모리에 들고 있다가 배열의 0번은 id, 1번은 name처럼 위치를 이름에 연결한다. 이 스키마는 처음 시작할 때 한 번 DB에서 읽고, 그 뒤로는 binlog에 들어오는 DDL을 파싱해서 갱신한다. DML은 값을 나르고, DDL은 그 값에 붙일 이름표를 바꾼다. 두 이벤트가 같은 binlog 안에 순서대로 있어야 이름표가 맞는다. Flink CDC는 이 스키마를 binlog offset과 함께 checkpoint에 저장해서, 재시작하면 DB의 현재 정의가 아니라 그 offset 시점의 정의로 복원한다.

5.5 When DDL Is Missing from the Binlog

큰 테이블의 무중단 스키마 변경에 많이 쓰는 pt-online-schema-change(pt-osc)는 새 구조의 빈 테이블을 만들고, 원본 행을 나눠 복사한 뒤, 마지막에 RENAME으로 바꿔 끼운다. 대량 복사가 binlog를 크게 늘리기 때문에 작업 세션을 sql_log_bin=0으로 돌리기도 한다.

그러면 복사뿐 아니라 같은 세션의 ALTER와 RENAME, 즉 DDL도 binlog에서 빠진다. 반면 sql_log_bin은 세션 설정이라 서비스 세션의 DML은 계속 binlog에 남는다.

sequenceDiagram
  participant App as 서비스 세션
  participant Tool as pt-osc 세션 (sql_log_bin=0)
  participant Log as binlog
  participant CDC as CDC connector
  Tool->>Tool: ALTER, 복사, RENAME (binlog에 안 남음)
  App->>Log: 새 구조의 TABLE_MAP + 값 배열
  Log->>CDC: 값 배열
  CDC->>CDC: 옛 스키마로 이름을 붙임
  Note over CDC: column이 중간에 추가됐다면<br/>값이 한 칸씩 밀린 이름으로 나간다

컬럼이 중간에 추가되면 값이 한 칸씩 밀린 채 다른 이름으로 나간다. 타입이 같으면 변환 오류도 나지 않아서 job은 정상 실행 상태로 남는다. DB의 현재 스키마로 재시작해도 해결되지 않는다. 이미 내보낸 레코드는 그대로 남고, checkpoint에서 재시작하면 옛 스키마가 함께 복원되기 때문이다.

새 스키마를 적용할 정확한 지점을 찾기도 어렵다. RENAME이 binlog에 없으니 전환점이 보이지 않는다. 컬럼 수와 타입 구조가 새 스키마에만 맞는 TABLE_MAP이 올 때부터 새 스키마를 적용하는 방법을 생각할 수 있지만, 이름만 바꾸거나 같은 타입끼리 순서만 바꾼 변경은 TABLE_MAP만으로 구분되지 않는다. 모든 DDL을 자동으로 따라가는 커넥터보다, 컬럼 추가처럼 좁은 경우부터 검증하는 편이 현실적이다.

5.6 Two Kinds of Boundaries

같은 DB를 읽어도 두 방식은 틀리는 곳이 다르다.

주기적 증분 덤프CDC
읽는 것쿼리로 읽은 지금 있는 행, 즉 DML의 결과binlog의 DML 이벤트와 DDL 이벤트
스키마를 아는 방법실행할 때마다 지금 테이블을 읽는다처음에 한 번 읽고, 그 뒤에는 binlog의 DDL로 갱신한다
경계 문제시간 경계. 자정 직전 변경, 복제 지연, 마지막 회차 실패스키마 경계. binlog에 없는 DDL, 알 수 없는 RENAME 시점
무중단 스키마 변경의 영향다음 회차가 새 구조를 그대로 읽는다. BigQuery 쪽 스키마가 따라가는지만 남는다이름이 밀린 레코드가 조용히 나간다
틀린 것을 바로잡는 방법다음 회차가 그날 파티션을 다시 덮어쓴다이미 나간 레코드는 남는다. 다시 읽고 다운스트림에서 정정한다

덤프는 매번 결과, 즉 현재 상태를 다시 읽는다. 그래서 중간에 틀려도 다음 회차가 덮어쓰며 스스로 회복한다. CDC는 변경 이력을 순서대로 따라간다. 그래서 이력 하나를 놓치면 그 뒤가 모두 틀린 채 진행된다. 대신 CDC는 몇 초 단위로 따라가고 삭제도 잡는다.

두 방식이 틀림에 대비하는 방법은 결국 같은 발상으로 모인다. 덤프는 전날을 한 번 더 읽어 자정 경계를 덮고, CDC 결과를 최종 테이블에 주기적으로 MERGE하는 구조는 최근 몇 시간을 겹쳐 다시 MERGE해서 늦게 바로잡은 레코드를 반영한다. 경계를 정확히 맞추기 어려우면 겹쳐 읽고, 겹친 부분은 키로 정리한다.

5.7 Wrapping Up

이 시리즈의 다섯 이야기는 모두 적재 단계에서 문서가 말해 주지 않던 자리였다. autodetect와 스키마를 같이 줬을 때, STS가 이미 있는 객체를 다시 옮길 때, 처음 만든 실행이 정한 REQUIRED, 이름 없는 배열, 그리고 시간과 스키마의 경계다.

배운 것을 짧게 남기면 이렇다. 기본값에는 이유가 적혀 있어야 한다. 문서가 침묵하는 동작은 대조군과 카운터로 확인한다. 결과가 맞는다는 것과 비용이 맞는다는 것은 따로 확인한다. 그리고 적재가 어디서 틀리는지 알면, 어디를 겹쳐 읽어야 하는지도 보인다.

Discussion