본문 바로가기

8. CDC와 스트리밍 - 데이터를 최신 상태로 유지하기

·19분 읽기·
목차

지난 편에서 Storage Write API로 데이터를 넣는 방식을 봤습니다. 그런데 지금까지 다룬 스트리밍은 대체로 append-only, 즉 새 이벤트를 계속 밀어 넣기만 하는 그림이었습니다.

실무 데이터는 그렇게 단순하지 않습니다. RDB에는 UPDATE와 DELETE가 있고, 사용자 프로필처럼 상태가 바뀌는 테이블이 항상 존재합니다. 그 변경을 BigQuery에 어떻게 반영할지가 이번 편의 주제입니다. CDC(Change Data Capture)라고 부르는 이 기능이 실제로 어떻게 동작하는지, 어떤 함정이 있는지, 매니지드 옵션들이 그 위에 어떻게 얹혀 있는지를 이번 편에서 정리합니다.


1. CDC가 필요한 이유

1-1. Append-only의 한계

Storage Write API의 default stream은 append-only 모델입니다. 이벤트가 들어오면 테이블 끝에 붙습니다. 이벤트 로그, 클릭 스트림, IoT 텔레메트리처럼 사실이 시간순으로 쌓이는 데이터는 이 모델에 잘 맞습니다.

문제는 상태가 있는 데이터입니다. 사용자 프로필의 이메일이 바뀌었거나, 주문 상태가 pending에서 shipped로 변했거나, 계정이 탈퇴로 delete됐다면, 이걸 append-only 스트림으로만 처리하기 어렵습니다. 원본 테이블 하나를 매일 통째로 dump해서 replace할 수도 있지만, 원본이 커지면 비용과 시간이 감당이 안 됩니다.

1-2. CDC의 자리

CDC는 원본 테이블의 변경 이벤트만 뽑아 스트리밍하는 방식입니다. INSERT/UPDATE/DELETE 각각이 이벤트가 되고, BigQuery는 이 이벤트들을 받아 원본 테이블의 최신 상태를 재구성합니다.

원본 DB (Postgres)              BigQuery
users 테이블                    users 테이블 (CDC 대상)
  id=1, email=a@x.com   ─┐
  UPDATE email=b@x.com   │
                         ├──> 변경 이벤트 스트리밍
  id=2 DELETE            │
                        ─┘

핵심은 원본을 통째로 옮기지 않고 델타만 옮긴다는 점입니다. BigQuery는 이 델타들을 primary key 기준으로 병합해 원본과 같은 상태를 유지합니다.


2. BigQuery CDC의 두 층위

BigQuery CDC는 두 층위가 분리되어 있습니다. 이벤트를 받아 저장하는 층과, 저장된 이벤트를 실제 테이블 상태로 반영하는 층이 각각 다른 시점에 움직입니다.

2-1. 이벤트 수집 층 - Storage Write API + _CHANGE_TYPE

7편에서 다룬 Storage Write API의 default stream 위에, _CHANGE_TYPE이라는 pseudo column을 얹어 보냅니다. 값은 두 개만 허용됩니다.

  • UPSERT: primary key가 없으면 insert, 있으면 update
  • DELETE: primary key로 해당 행 삭제

Committed/pending/buffered stream은 CDC에서 지원되지 않고 default stream + protobuf 조합만 됩니다. 그리고 대상 테이블에는 primary key가 반드시 선언되어 있어야 합니다(composite key는 최대 16개 컬럼).

sql
-- CDC 대상 테이블 생성
CREATE TABLE `project.dataset.users` (
  id     INT64 NOT NULL,
  email  STRING,
  name   STRING,
  PRIMARY KEY (id) NOT ENFORCED
)
CLUSTER BY id
OPTIONS (
  max_staleness = INTERVAL 15 MINUTE
);

Primary key 뒤의 NOT ENFORCED가 특이합니다. BigQuery는 primary key 제약을 실제로 검사하지 않고 CDC apply의 힌트로만 사용합니다. 중복 없는 primary key를 보내는 건 소스 쪽 책임입니다.

CLUSTER BY id가 붙은 이유는 조회 성능 때문입니다. 공식 문서의 예시 DDL에도 primary key 컬럼을 그대로 클러스터 키로 씁니다. CDC 테이블은 특정 primary key의 최신 상태를 자주 lookup하는 워크로드가 많아서, PK 기반 클러스터링이 없으면 조회 쿼리가 넓은 범위를 스캔하게 됩니다.

2-2. 내부 apply 층 - changelog + merge

들어온 CDC 이벤트가 곧바로 대상 테이블에 반영되는 건 아닙니다. BigQuery는 이벤트를 내부의 changelog에 먼저 쌓아 두고, 다음 두 가지 방식 중 하나로 실제 상태를 재구성합니다.

  • Runtime merge: 쿼리가 들어오면 그 시점에 base 데이터와 changelog를 합쳐 정확한 최신 상태를 계산해 반환합니다. 결과는 항상 최신이지만 쿼리마다 merge 비용이 붙습니다
  • Background apply: BigQuery가 백그라운드에서 changelog를 base 테이블에 물리적으로 적용합니다. 쿼리 시에는 이미 apply된 상태라 merge 부담이 없습니다

둘 사이를 조절하는 노브가 max_staleness입니다.


3. _CHANGE_TYPE_CHANGE_SEQUENCE_NUMBER

3-1. 이벤트 페이로드

Storage Write API로 CDC 이벤트를 보낼 때 protobuf 메시지에 데이터 컬럼과 함께 _CHANGE_TYPE을 실습니다.

python
# Protobuf 메시지 (개념적)
{
  "id": 42,
  "email": "new@example.com",
  "name": "Alice",
  "_CHANGE_TYPE": "UPSERT",
  "_CHANGE_SEQUENCE_NUMBER": "FFF/B"
}

# DELETE는 primary key만 있어도 됨
{
  "id": 42,
  "_CHANGE_TYPE": "DELETE"
}

두 pseudo column 다 실제 테이블 스키마에는 나타나지 않습니다. Storage Write API가 이 필드를 읽어 처리하고, 저장은 데이터 컬럼만 됩니다.

3-2. 순서 보장 — _CHANGE_SEQUENCE_NUMBER

같은 primary key에 대해 여러 이벤트가 순서대로 도착해야 정확한 최종 상태가 나옵니다. 만약 순서가 뒤바뀌면 옛날 UPDATE가 새 UPDATE를 덮어쓸 수 있습니다.

기본 동작은 BigQuery에 ingest된 시간(system time) 기준으로 순서를 정합니다. 소스에서 순서대로 보냈고 중간에 재정렬이 없다면 이 기본 동작으로 충분합니다.

문제는 재시도, 네트워크 지연, 여러 파티션에서 병렬 전송할 때 순서가 흔들릴 수 있다는 점입니다. 그럴 때 _CHANGE_SEQUENCE_NUMBER를 명시하면 그 값이 큰 이벤트가 우선합니다.

값의 형식이 특수합니다. 16진수 STRING만 허용되고, 슬래시(/)로 최대 4개 섹션까지 나눌 수 있으며 각 섹션은 최대 16자입니다. 허용 범위는 0/0/0/0부터 FFFFFFFFFFFFFFFF/FFFFFFFFFFFFFFFF/FFFFFFFFFFFFFFFF/FFFFFFFFFFFFFFFF까지입니다. 각 섹션은 부호 없는 숫자(unsigned numeric)로 비교하고, 앞 섹션이 같을 때만 다음 섹션으로 넘어갑니다. 흔히 착각하는 문자열 lexicographic 비교가 아닙니다.

공식 문서 예시:
  '77'        (섹션 1개, 값 = 0x77 = 119)
  '7B'        (섹션 1개, 값 = 0x7B = 123, 위보다 큼)
  'FFF/B'     (섹션 2개, 첫 섹션 0xFFF = 4095, 뒷 섹션 0xB = 11)
  'FFF/ABC'   (첫 섹션 같음 → 뒷 섹션 0xABC = 2748로 tie-break, 위보다 큼)

소스 DB의 트랜잭션 커밋 시각이나 LSN(Log Sequence Number)을 hex로 인코딩해 넣는 게 정공법입니다. 기본은 Unix millisecond timestamp를 hex로 넣는 방식입니다(예: '18F2EBB6480'). Postgres LSN을 쓴다면 0/16D2540 표기를 그대로 넣지 말고 상위·하위 32비트를 합친 단일 64비트 정수로 인코딩해 한 섹션에 넣는 게 안전합니다. LSN 표기의 슬래시를 그대로 넣으면 BigQuery는 이걸 섹션 구분자로 해석해서 첫 섹션이 0이 되고, 상위 워드 경계에서 순서가 깨질 수 있습니다.

두 값이 완전히 동일하면 나중에 ingest된 레코드가 우선합니다. 그리고 custom ordering을 쓰는 테이블에서는 모든 행에 이 값을 함께 보내야 합니다. 일부 행만 지정하고 나머지를 비워 두면 순서가 예측 불가능해지니, "필요할 때만" 방식으로 섞어 쓰지 않는 게 안전합니다.

초기 스냅샷과 CDC 스트림의 경계 처리도 이 필드로 해결합니다. 기존 테이블을 옮길 때 backfill 스냅샷을 먼저 넣고 그 시점 이후 CDC를 이어붙이는 흐름이 표준인데, 경계에서 중복이나 역전이 잘 생깁니다. 스냅샷 행에도 그 시점의 LSN을 _CHANGE_SEQUENCE_NUMBER로 부여해 두면, 이후 스트리밍되는 CDC 이벤트의 LSN이 그보다 크기 때문에 순서 규칙이 경계를 자동으로 정리합니다.

파이프라인이 여러 경로에서 같은 테이블에 이벤트를 주입하는 구조라면, _source_kafka_offset 같은 메타 컬럼을 함께 남겨 두는 것도 실무 팁입니다. 어떤 행이 커넥터 경로로 들어왔는지, 별도 배치 경로로 들어왔는지 나중에 구분할 수 있고, 특정 경로에서만 나타나는 이상을 디버깅할 때 결정적입니다.


4. UPSERT는 부분 업데이트가 아니라 전체 행 교체

CDC에서 가장 자주 사고 나는 지점입니다. _CHANGE_TYPE = UPSERT로 보내는 이벤트는 primary key 기준으로 행을 통째로 새 값으로 교체하는 동작이지, "지정한 컬럼만 수정하고 나머지는 유지" 방식이 아닙니다. REST API에 비유하면 `PATCH`가 아니라 `PUT` 쪽에 가깝습니다.

이벤트 payload에 email만 담아 보내면 다른 컬럼(name 등)은 NULL로 덮여 버립니다. Postgres에서 UPDATE users SET email = ...을 실행했을 때 소스 CDC 커넥터가 변경된 컬럼만 뽑아 보내는 설정이 기본값이라, 그대로 파이프에 흘리면 다른 컬럼이 조용히 사라집니다.

그래서 소스 쪽 설정이 CDC의 전제 조건이 됩니다. Datastream의 소스 설정 문서를 기준으로 최소한 이 정도는 맞춰야 합니다.

Postgres:  REPLICA IDENTITY FULL     (기본 DEFAULT는 PK만 담김)
MySQL:     binlog_row_image = FULL   (기본 FULL이지만 명시 확인)
Debezium:  full row image 사용

Datastream을 매니지드로 쓰면 대부분 이 부분을 알아서 처리해 줍니다. 다만 직접 Storage Write API + `_CHANGE_TYPE`을 짜서 넣는 경우 이 조건을 놓치면, "왜 UPDATE 후 다른 컬럼이 다 NULL이 됐지"로 며칠을 헤매게 됩니다.

이 제약 때문에 CDC 이벤트 producer 쪽에서는 변경된 컬럼만이 아니라 그 행의 현재 전체 상태를 함께 보내도록 파이프라인을 짜는 편이 안전합니다.


5. max_staleness와 apply 주기

5-1. 두 노브 사이의 트레이드오프

max_staleness"쿼리 결과가 이만큼 오래된 것까지는 허용"하는 상한입니다. 값에 따라 runtime merge와 background apply의 비중이 달라집니다.

max_staleness = 0 (또는 미설정)
  → 매 쿼리마다 runtime merge 수행
  → 항상 최신 결과, 쿼리 비용/지연 큼

max_staleness = 15 MINUTE
  → BigQuery가 background apply를 15분 주기에 맞춰 시도
  → 쿼리 시점에 base가 15분 이내면 base만 읽어도 정확 → merge 스킵
  → base가 15분보다 오래됐으면 그 시점에 델타를 merge해 신선하게 맞춰 반환

max_staleness = 4 HOUR (예시)
  → 최대 4시간까지 stale 허용, 쿼리 비용 최소
  → 데이터 신선도 낮아지는 만큼 대시보드 갱신 주기와 맞춰 잡음

Google Cloud 블로그 기준으로 max_staleness는 0분에서 24시간 사이로 지정할 수 있고, 워크로드가 허용하는 신선도에 맞춰 그 범위 안에서 정합니다. 값이 크다고 무조건 stale한 값을 주는 건 아닙니다. base가 그 상한 안에 있으면 그대로 반환하고, 벗어나면 그 시점에 델타를 merge해서 상한 안으로 끌어올려 반환합니다. 6편에서 MV의 max_staleness와 같은 계약입니다.

CDC 대상 테이블에서는 실무적으로 15분~1시간 정도가 가장 흔한 세팅입니다. 실시간 대시보드가 아니라면 이 정도 지연이 문제 되지 않고, 쿼리 비용은 크게 아낍니다.

5-2. 파티션 프루닝 무력화라는 결정적 함정

파티션이 있는 CDC 테이블에서 max_staleness를 넉넉히 잡는 것은 선택이 아니라 필수에 가깝습니다. 파티션 테이블에서 runtime merge가 발생하는 순간 파티션 프루닝이 무력화되기 때문입니다. 쿼리에 WHERE DATE(created_at) = '2026-08-15' 같은 파티션 필터가 있어도, max_staleness를 넘어 runtime merge가 도는 순간 테이블 전체가 스캔됩니다. 이유는 background apply가 아직 반영 안 된 changelog 이벤트가 어느 파티션에 붙어야 할지 아직 확정되지 않았기 때문입니다. 정확한 결과를 내려면 전체를 훑을 수밖에 없습니다.

"최근 1일만 조회하니 스캔량이 작을 것"으로 짠 대시보드 쿼리가 실제로는 테이블 전체 스캔으로 청구되고, on-demand 프로젝트라면 그대로 비용 폭탄이 됩니다. 이 함정 때문에라도 파티션이 붙은 CDC 테이블에서는 max_staleness를 신선도가 허용하는 한 크게 잡아 runtime merge 자체가 잘 안 걸리게 만드는 게 표준입니다.

5-3. Apply 진행 상황 확인

background apply가 어디까지 진행됐는지는 INFORMATION_SCHEMA.TABLESupsert_stream_apply_watermark 컬럼으로 확인합니다. 이 시각까지의 CDC 이벤트가 base 테이블에 물리적으로 반영됐다는 의미입니다.

sql
SELECT
  table_name,
  upsert_stream_apply_watermark
FROM `project.dataset.INFORMATION_SCHEMA.TABLES`
WHERE table_name = 'users';

Runtime merge는 이 watermark를 전진시키지 않습니다. 오직 background apply만 watermark를 밀어 올립니다. 이 값이 계속 뒤로 밀리기만 하면 background apply가 실제로 안 돌고 있다는 신호이고, 원인은 reservation 부족(뒤의 edition 이야기) 아니면 apply를 수행할 용량 자체가 CDC 유입 속도를 못 따라가는 경우입니다.

DELETE 이벤트 하나 짚어 둘 게 있습니다. DELETE는 upsert_stream_apply_watermark가 그 DELETE의 스트리밍 시점을 지나야 실제 적용된 것으로 간주됩니다. 그리고 순서가 뒤바뀐 DELETE(늦게 도착한 오래된 DELETE)를 처리하기 위해 BigQuery는 2일의 delete retention window를 유지합니다. 이 기간 안에 뒤늦게 도착한 DELETE도 정확히 처리됩니다.

5-4. max_staleness 권장값 산정과 그 한계

공식 문서가 권장하는 산정 방식은 두 값 중 큰 쪽으로 잡는 것입니다.

  1. 워크플로가 감내 가능한 최대 staleness
  2. background apply 소요시간의 p95 × 2 + 7분 버퍼(스트리밍 버퍼 전환 여유)

두 번째 값은 아래 쿼리로 직접 뽑을 수 있습니다. background apply는 bigquery-adminbot@system.gserviceaccount.com 서비스 계정이 수행하기 때문에, 개인이 실행한 잡만 보이는 INFORMATION_SCHEMA.JOBS가 아니라 프로젝트 전체 잡을 보는 `JOBS_BY_PROJECT`에서 조회해야 합니다.

sql
SELECT
  project_id,
  destination_table.dataset_id,
  destination_table.table_id,
  APPROX_QUANTILES(
    (TIMESTAMP_DIFF(end_time, creation_time, MILLISECOND) / 1000), 100
  )[OFFSET(95)] AS p95_apply_sec,
  CEILING(
    APPROX_QUANTILES(
      (TIMESTAMP_DIFF(end_time, creation_time, MILLISECOND) / 1000), 100
    )[OFFSET(95)] * 2 / 60
  ) + 7 AS recommended_max_staleness_min
FROM `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE DATE(creation_time) BETWEEN DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY) AND CURRENT_DATE()
  AND job_id LIKE '%cdc_background%'
GROUP BY 1, 2, 3;

최근 7일 background apply job의 소요시간을 p95로 뽑아 두 배 하고 7분을 더한 값이 나옵니다. 대시보드 허용 지연이 이 값보다 크면 그대로, 작으면 조정이 필요하다는 신호입니다.

한계가 하나 있습니다. 이 공식은 apply의 소요시간만 봅니다. 그런데 실무에서는 apply 소요시간이 아니라 apply 사이의 공백(gap)이 사고의 원인이 되는 경우가 많습니다. 권장값을 3배 넉넉히 잡아두고도 터지는 케이스가 있습니다.

시나리오가 이렇습니다.

14:29  apply 종료
       ↓ 40분 공백 — 델타 축적
15:10  작은 apply (26초, 49 GiB)
15:17  대형 apply 시작 (379초, 3,192 GiB)   ← 밀린 델타 대량 처리
       ↓ 이 6분 사이에 조회 진입
15:23  ★ 조회 진입 — apply 진행 중, base 미반영 → runtime merge → 10.5 TiB 스캔
15:24  apply 완료

apply 자체는 6분 안에 끝나지만, 그 6분 안에 조회가 들어가면 base가 정합적이지 않아 runtime merge가 걸립니다. 파티션 프루닝도 무력화되고요(§5-2). 즉 소요시간 p95만 보는 산정법은 이런 "apply 도중 조회" 위험을 못 잡습니다.

실무에서는 소요시간 p95와 함께 연속 apply job 간 최대 gap을 모니터링해야 합니다.

sql
-- 연속 apply job 사이의 gap 분포
SELECT
  dataset_id,
  table_id,
  MAX(gap_sec)                                AS max_gap_sec,
  APPROX_QUANTILES(gap_sec, 100)[OFFSET(95)]  AS p95_gap_sec
FROM (
  SELECT
    destination_table.dataset_id AS dataset_id,
    destination_table.table_id   AS table_id,
    TIMESTAMP_DIFF(
      creation_time,
      LAG(end_time) OVER (
        PARTITION BY destination_table.dataset_id, destination_table.table_id
        ORDER BY creation_time
      ),
      SECOND
    ) AS gap_sec
  FROM `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
  WHERE DATE(creation_time) BETWEEN DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY) AND CURRENT_DATE()
    AND job_id LIKE '%cdc_background%'
)
WHERE gap_sec IS NOT NULL
GROUP BY 1, 2;

max_gap_sec이 예상 apply 주기보다 크게 튄다면 그 사이에 조회가 들어가는 순간 위 시나리오가 재현됩니다.


6. CDC 테이블 위 머티리얼라이즈드 뷰

CDC 테이블 위에 MV를 만들어 사전 집계를 유지하고 싶은 경우가 많습니다. 대시보드 뒤에 붙는 요약 지표를 매번 base + changelog merge로 계산하면 무거우니, MV로 미리 뽑아 두는 게 이득입니다.

여기에 한 가지 특수 규칙이 있습니다. CDC 테이블 위 MV의 `max_staleness`는 base의 최소 2배 이상으로 잡아야 합니다.

sql
-- Base CDC 테이블
CREATE TABLE `project.dataset.users` (
  id INT64 NOT NULL,
  email STRING,
  name STRING,
  created_at TIMESTAMP,
  PRIMARY KEY (id) NOT ENFORCED
)
CLUSTER BY id
OPTIONS (max_staleness = INTERVAL 15 MINUTE);

-- 그 위의 MV — max_staleness가 base의 두 배 이상
CREATE MATERIALIZED VIEW `project.dataset.user_stats`
OPTIONS (max_staleness = INTERVAL 30 MINUTE) AS
SELECT DATE(created_at) AS d, COUNT(*) AS cnt
FROM `project.dataset.users`
GROUP BY d;

근거는 이렇습니다. base CDC 테이블의 현재 버전이 MV의 `max_staleness`보다 stale하면 MV 쿼리가 실패합니다. 즉 MV가 참조하는 base 스냅샷이 MV의 신선도 상한을 벗어나면 쿼리를 반환하지 못하는 상태가 됩니다. 두 배로 잡으라는 건 base가 background apply될 여유 시간을 MV 쪽에서 확보해 두라는 뜻입니다. base 15분 + MV 30분이면, base가 apply되기까지 15분 걸리는 최악의 경우에도 MV의 30분 상한 안에 들어옵니다.


7. CDC 테이블의 제약

CDC를 활성화한 테이블은 일반 BigQuery 테이블과 몇 가지 다르게 동작합니다.

7-1. 변형 DML이 안 됨

CDC 활성 테이블에서는 DELETE, UPDATE, MERGE 같은 변형 DML을 실행할 수 없습니다. 데이터 수정은 오직 Storage Write API의 CDC 이벤트로만 이뤄집니다.

이건 dbt incremental이나 Airflow의 주기적 MERGE 파이프라인을 함께 돌리는 팀에 결정적인 제약입니다. 소스에서 CDC로 데이터가 들어오는데, 그 위에서 다시 dbt incremental이 MERGE를 쳐서 마감 처리하는 형태가 실무에 흔한데, CDC 테이블 위에서는 이 두 번째 MERGE가 아예 안 됩니다. 마감 처리가 필요하면 CDC 테이블을 원본으로 두고, MERGE 대상은 별도 테이블로 분리해야 합니다.

7-2. 조회·복사 관련 제약

CDC 활성 테이블은 다음 기능들도 지원하지 않습니다.

  • 와일드카드 테이블 조회 (FROM dataset.users_*)
  • 검색 인덱스
  • 그리고 max_staleness가 너무 낮아 runtime merge가 도는 상태에서는 추가로:
  • 테이블 복사(copy), 클론, 스냅샷
  • Storage Read API (Spark BQ 커넥터, Dataflow/Beam BigQueryIO, pandas-gbq 같은 대량 읽기 클라이언트가 이걸 씀)
  • requirePartitionFilter 옵션

특히 Storage Read API 제약은 크게 걸립니다. Spark에서 BigQuery를 읽는 경우 대부분 Storage Read API를 통하는데, 저 max_staleness의 CDC 테이블이라면 Spark job이 그대로 못 붙습니다. 배치성 downstream이 있다면 max_staleness를 충분히 크게 잡아 background apply가 돌고 있는 상태에서만 조회하도록 만들거나, downstream용 사본 테이블을 별도로 유지하는 방식으로 우회합니다.


8. 이력 소실과 CHANGES TVF

8-1. CDC 테이블은 최신 상태만 남긴다

CDC 대상 테이블은 UPSERT/DELETE가 적용된 뒤의 최신 상태만 저장합니다. 중간 변경 이력은 남지 않습니다. id = 42의 이메일이 A → B → C로 세 번 바뀌었으면 테이블에는 C만 남고, A와 B는 사라집니다.

이게 실무에서 함정이 됩니다. 감사 요건이 있거나 SCD Type 2(과거 상태 보존)가 필요한 워크로드에서는 CDC 테이블만으로는 부족합니다. "현재 이 사용자의 상태"만 필요하면 CDC가 답이지만, "이 사용자가 지난 3개월 동안 어떻게 변해 왔는가"를 봐야 하면 별도 이력 테이블이 필요합니다.

8-2. enable_change_history와 CHANGES TVF

이력이 필요할 때 대응 옵션 중 하나가 enable_change_history입니다. 테이블 옵션에 enable_change_history = TRUE를 걸어 두면 BigQuery가 변경 이력을 유지하고, CHANGES TVF로 그 이력을 조회할 수 있습니다.

sql
ALTER TABLE `project.dataset.users`
SET OPTIONS (enable_change_history = TRUE);

-- 지난 1시간 동안의 변경 이력 조회
SELECT *
FROM CHANGES(
  TABLE `project.dataset.users`,
  TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 1 HOUR),
  CURRENT_TIMESTAMP()
);

이력 유지에도 저장 비용이 붙기 때문에, 감사·재계산·CDC downstream처럼 이력이 실제로 필요한 테이블에서만 켜는 게 좋습니다.

8-3. 전통적 대안 — append-only + MERGE

CDC가 등장하기 전부터 쓰이던 패턴이 있고, 여전히 유효합니다.

  • 소스 이벤트를 append-only 이력 테이블에 그대로 쌓음
  • 그 위에 ROW_NUMBER() OVER (PARTITION BY pk ORDER BY ts DESC) = 1 뷰로 최신 상태 뽑음
  • 또는 dbt incremental로 주기적 MERGE 실행해서 스냅샷 테이블 유지

BigQuery의 네이티브 CDC는 이 패턴을 엔진 안으로 내재화한 것에 가깝습니다. 트레이드오프는 명확합니다.

방식이력 보존파이프라인 복잡도조회 성능
네이티브 CDC❌ (별도 change_history 필요)낮음 (BigQuery가 처리)좋음 (background apply 뒤)
append-only + MERGE✅ (원본 이력 그대로)높음 (MERGE 스케줄, dedup)MERGE 결과 테이블 기준

이력이 중요하면 append-only 방식이 여전히 유효하고, 상태만 최신으로 유지되면 되는 워크로드는 CDC가 훨씬 간단합니다.


9. 매니지드 옵션들

Storage Write API에 _CHANGE_TYPE을 직접 실어 보내는 건 소스에서 이벤트를 뽑고 protobuf로 변환하는 코드를 짜야 한다는 뜻입니다. RDB CDC에서는 대부분 매니지드 옵션이 더 편합니다.

9-1. Datastream — DB → BigQuery 매니지드 CDC

Datastream은 MySQL/PostgreSQL/Oracle/SQL Server의 binlog나 WAL을 읽어 BigQuery로 실시간 replication해 주는 매니지드 서비스입니다. 최근에는 MongoDB, Spanner, Salesforce 같은 소스도 지원 목록에 들어와 있습니다.

Source DB (Postgres)          Datastream          BigQuery
   │                              │                   │
   │  logical replication slot   │                   │
   ├─────────> WAL 스트리밍 ────>│                   │
   │                              │  내부적으로       │
   │                              │  Storage Write API├─> users 테이블 (CDC)
   │                              │  (default stream) │   automatic apply
   │                              │  + _CHANGE_TYPE  │

Datastream 내부는 결국 Storage Write API + _CHANGE_TYPE을 씁니다. 사용자가 직접 짤 필요 없이 매니지드가 처리해 줄 뿐입니다.

스키마 변경 범위는 좁게 봐야 합니다. 컬럼 추가 같은 schema drift는 자동으로 따라가지만, 몇 가지 제약이 있습니다.

  • PK 없이 복제 중인 테이블에 PK를 추가하거나, PK가 있는 테이블에서 PK를 제거하는 스키마 변경은 지원 안 함
  • 지원하는 PK 타입이 DATE, BOOL, GEOGRAPHY, INT64, NUMERIC, BIGNUMERIC, STRING, TIMESTAMP, DATETIME 9종으로 제한됨
  • 미지원 타입의 PK를 가진 테이블은 아예 복제 대상에서 제외됨 (FLOAT, REAL 등은 CDC 스트리밍 자체가 불가)

"컬럼 추가는 자동, PK 구조 변경이나 미지원 타입은 별도 대응 필요"로 이해하는 게 실무에 맞습니다.

언제 쓰나: 소스가 지원 대상 DB이고, 순수 replication만 필요하면 Datastream이 정답에 가깝습니다.

9-2. Pub/Sub → BigQuery subscription

BigQuery subscription은 Pub/Sub 토픽의 메시지를 별도 Dataflow 없이 바로 BigQuery 테이블로 흘려보내는 옵션입니다.

Publisher → Pub/Sub Topic ─── BigQuery subscription ──> BigQuery Table
                              (Dataflow 없음, 직접 write)

특징:

  • at-least-once 보장만 (중복 가능, exactly-once 아님)
  • BigQuery ingestion 요금은 없지만, Pub/Sub 측에 BigQuery subscription throughput 요금(발행 시점 기준 $50/TiB, 리전·시점에 따라 변동 가능)이 별도로 붙습니다. 일반 Pub/Sub subscribe throughput($40/TiB)보다 단가가 높고, 10 GiB 무료 티어도 없습니다. 단순히 "무료"라고 생각하고 들어가면 청구서에서 놀랄 수 있습니다
  • 스키마 매핑을 subscription에 정의하면 메시지가 그대로 행으로 저장

_CHANGE_TYPE을 메시지에 실어 보내면 이 경로로도 CDC를 구성할 수 있습니다. 이벤트가 이미 Pub/Sub에 있는 파이프라인이라면 이 경로가 가장 간단하고, throughput 요금을 감안해도 Dataflow보다 저렴한 경우가 많습니다.

9-3. Dataflow가 필요한 경우

Dataflow는 위의 두 매니지드로 커버 안 되는 케이스에 씁니다.

  • 복잡한 변환: 메시지를 그대로 넣는 게 아니라 정제·집계·enrichment가 필요할 때
  • 여러 소스 join: 스트리밍 중 다른 테이블/토픽과 결합해서 넣어야 할 때
  • exactly-once가 필요한 스트리밍 파이프라인: Pub/Sub subscription의 at-least-once가 부족할 때
  • Debezium 같은 외부 CDC 툴 연동: MySQL/Postgres 외 소스에서 CDC 이벤트를 뽑아 Dataflow로 정리 후 BigQuery로

Dataflow는 유연하지만 잡을 계속 돌리는 비용이 들고 관리 대상이 늘어납니다.

9-4. 선택 흐름

CDC 소스가 무엇인가?

  RDB (MySQL/Postgres/Oracle/SQL Server 등)
    → Datastream (매니지드 replication)

  Pub/Sub에 이미 이벤트가 흐르고, 변환 없이 그대로 저장
    → BigQuery subscription (Dataflow 없이, at-least-once)

  변환·enrichment·exactly-once·복잡한 join 필요
    → Dataflow (Storage Write API 위에 세부 로직 얹기)

  외부/커스텀 소스, 직접 구현
    → Storage Write API + _CHANGE_TYPE 직접

이력이 반드시 남아야 하는가?
  → 네이티브 CDC 대신 append-only 이력 테이블 + 뷰/스냅샷 MERGE 병행 고려
    (또는 CDC + enable_change_history 조합)

10. 실무 모니터링과 함정

CDC 파이프라인은 조용히 밀리기 시작하면 티가 잘 안 납니다. 대시보드 숫자는 그대로 나오는데 뒤에서 apply가 계속 밀리다가, 어느 순간 쿼리 비용이나 storage 크기로 터집니다. 몇 지점만 주기적으로 봐 두면 조기에 잡을 수 있습니다.

10-1. Apply 지연 (upsert_stream_apply_watermark)

5-3절에서 본 것처럼 INFORMATION_SCHEMA.TABLESupsert_stream_apply_watermark가 정공법입니다. 현재 시각 대비 이 값이 얼마나 뒤처져 있는지가 apply 지연입니다. Datastream을 쓰는 경우에는 Datastream 자체의 모니터링 대시보드에서 소스 대비 대상 지연(replication lag)도 함께 볼 수 있습니다.

10-2. Storage Write API quota와 에러

CDC 파이프라인은 결국 Storage Write API 호출이라, 그쪽 quota와 에러 상황을 함께 봐야 합니다.

sql
-- 최근 1시간 CDC 관련 write 실패 확인
SELECT
  job_id,
  user_email,
  error_result.reason,
  error_result.message,
  creation_time
FROM `region-us.INFORMATION_SCHEMA.JOBS_BY_PROJECT`
WHERE creation_time > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 1 HOUR)
  AND destination_table.table_id = 'users'
  AND error_result IS NOT NULL
ORDER BY creation_time DESC;

10-3. 파티션 프루닝 무력화의 관측과 방어

메커니즘은 §5-2에서 다뤘습니다. 실무에서 이 함정이 어떤 얼굴로 나타나는지, 그리고 뭘로 막을 수 있는지를 봅니다.

첫째, 이 함정은 간헐적으로 나타납니다. "max_staleness를 넘으면 무력화"라는 이분법적 서술은 개념적으론 맞지만, 실무 체감과는 다릅니다. 평소엔 멀쩡하다가 어느 순간 잡 하나만 튑니다. 조회 수백 건 중 한두 건만 폭발하는 분포가 드물지 않고, 그 분포 형태가 흔히 이렇습니다.

p50 = 300 GiB
p90 = 800 GiB
p99 = 850 GiB
max = 10,000 GiB   ← p99까지 정상 범위인데 max만 10배 이상

p99 기반 알람으로는 이 max가 안 잡힙니다. 스테이징이나 테스트에서 재현도 안 됩니다(트래픽이 없으면 델타가 안 쌓임). "잘 돌고 있다"는 확신이 쌓인 상태에서 터지는 종류입니다. 원인은 §5-4에서 본 apply gap과 대형 apply 진행 중 조회가 겹치는 순간입니다.

둘째, `max_staleness`를 넉넉히 잡는 것만으로는 완전한 방어가 안 됩니다. 확률을 낮출 뿐 차단은 아닙니다. 위 시나리오처럼 대형 apply와 조회가 우연히 겹치면, 권장값의 3배로 잡아 두어도 그 순간에는 무력합니다.

유일한 하드 가드는 잡 단위 `maximum_bytes_billed` 상한입니다. 초과 시 쿼리가 시작 전 거부되므로, 다른 워크로드에 영향 없이 그 잡만 차단됩니다.

sql
-- 세션 수준으로 걸기
SET @@query.maximum_bytes_billed = 1099511627776;  -- 1 TiB

SELECT * FROM `project.dataset.users` WHERE ...;

또는 파이프라인 코드에서 job config로 걸어 두는 방식이 더 안전합니다.

python
job_config = bigquery.QueryJobConfig(
    maximum_bytes_billed=1024 * 1024 * 1024 * 1024  # 1 TiB
)

파이프라인 전반에 일괄 적용하려면 CLI(bq query --maximum_bytes_billed=...)나 프로젝트 기본값으로 거는 편이 편합니다. 개별 잡마다 걸지 않아도 프로젝트 전체 조회가 상한 아래로 강제됩니다.

이 사슬이 왜 반복되는가는 apply 리소스 문제로 이어집니다. 다음 항목에서 봅니다.

10-4. Edition에 따른 apply 비용 차이와 인과 사슬

Background apply의 실행 비용이 BigQuery edition에 따라 다르게 청구됩니다.

  • Enterprise 이상: 별도의 BACKGROUND reservation을 붙여 apply 작업이 그 슬롯 풀에서 실행되게 할 수 있습니다. reservation 크기가 곧 apply 처리량을 결정합니다
  • Standard edition: BACKGROUND reservation을 쓸 수 없어, 밀린 행 수정 적용이 on-demand 과금 모델로 처리됩니다. 스캔량 기반 요금으로 apply가 청구됩니다

이 항목이 §10-3과 사실은 하나의 사슬입니다.

BACKGROUND reservation 없음 (Standard, 또는 Enterprise인데 미배정)
  → apply 처리량 보장 없음
  → apply 사이의 공백 발생 (실측: 40분 공백)
  → 델타 축적 → 대형 apply 유발 (수천 GiB)
  → 그 apply 도중 조회 진입 (§10-3 시나리오)
  → runtime merge → 파티션 프루닝 무효 → 전량 스캔
  → 그 apply 자체도 on-demand 과금

즉 reservation 문제는 apply 비용만 늘리는 게 아니라, §10-3의 조회 비용 폭탄까지 유발합니다. Standard에서 CDC를 크게 돌리다 예상 못 한 on-demand 스캔 요금이 나오는 케이스, Enterprise인데 apply가 밀리는 케이스 모두 이 사슬의 어느 지점입니다.

원인을 진단할 때는 JOBS_BY_PROJECT에서 job_id LIKE '%cdc_background%'로 apply job의 소요시간과 gap을 보고, reservation 사이징이 필요하면 `JOBS_TIMELINE`에서 같은 필터로 초 단위 슬롯 사용량을 뽑습니다. 전자는 잡 단위 요약, 후자는 초 단위 슬롯 사용 추이라서 용도가 다릅니다.

10-5. 비용 관측

CDC 테이블은 네 가지 비용이 겹칩니다.

  • Storage Write API ingestion 비용
  • Base 테이블 storage 비용 (physical 모드에서는 changelog 누적분도 청구)
  • Runtime merge를 하는 쿼리의 슬롯/스캔 비용
  • Standard edition에서는 background apply의 on-demand 스캔 비용

세 번째가 max_staleness를 낮게 잡을수록 커집니다. INFORMATION_SCHEMA.JOBS_BY_PROJECT에서 해당 테이블을 조회하는 쿼리들의 total_slot_ms를 주기적으로 보고, 예상보다 크면 max_staleness를 늘려서 background apply 쪽으로 부담을 옮기는 게 정공법입니다.

10-6. EXPORT 함정

한 가지 더 짚어 둘 게 있습니다. CDC 활성 테이블에서 일반 export 작업은 아직 background apply되지 않은 최근 스트리밍 변경분을 내보내지 않습니다. 조용히 데이터가 누락되는 종류라 모르면 위험합니다.

전체 스냅샷을 정확히 내보내려면 일반 export가 아니라 EXPORT DATA 문을 써야 합니다.

sql
EXPORT DATA
OPTIONS (
  uri = 'gs://my-bucket/users-snapshot-*.parquet',
  format = 'PARQUET',
  overwrite = true
)
AS SELECT * FROM `project.dataset.users`;

EXPORT DATA는 쿼리 결과를 내보내는 경로라 최근 변경분까지 반영된 정확한 스냅샷이 나옵니다.


마무리

CDC는 append-only 스트리밍만으로는 커버 못 하는 "상태가 바뀌는 데이터"를 BigQuery로 옮길 때 씁니다. Storage Write API 위에 _CHANGE_TYPE을 얹어 UPSERT/DELETE를 실어 보내면, BigQuery는 이걸 changelog에 쌓아 두었다가 max_staleness 정책에 따라 background apply하거나 쿼리 시점에 merge해서 최신 상태를 만들어 돌려줍니다. Datastream이나 Pub/Sub subscription 같은 매니지드 옵션도 결국 이 저수준 계층 위에서 돌아가는 형태라, 안에서 무슨 일이 일어나는지 알아두면 문제가 생겼을 때 훨씬 빨리 원인을 찾을 수 있습니다.

편하게 쓸 수 있는 대신 챙겨야 할 지점이 많습니다. UPSERT가 부분 업데이트가 아니라는 점, 변형 DML이 막힌다는 점, 파티션 프루닝이 조건부로 무력화된다는 점, edition에 따라 apply 비용 청구 주체가 갈린다는 점. 여기에 이력이 필요한 워크로드라면 append-only 병행을 함께 고려해야 하고, MV를 얹으려면 base의 두 배 이상 max_staleness가 필요합니다.

다음 편에서는 이렇게 들어온 데이터에 대한 접근 통제를 다룹니다. BigQuery는 IAM 위에 행 단위(RLS), 컬럼 단위(정책 태그), 동적 데이터 마스킹이 층층이 얹혀 있는 구조라, 각 층이 어디에서 만나고 어떤 순서로 평가되는지를 짚어보겠습니다.