본문 바로가기

BigQuery - Protobuf에서 Capacitor까지

·5분 읽기·
목차

이전 글에서는 Apache Parquet의 내부 구조를 뜯어봤습니다. 인코딩으로 크기를 줄이고, Footer 통계로 불필요한 블록을 건너뛰는 디스크에서 읽는 양을 최소화하는 포맷이었습니다.

그런데 BigQuery는 Parquet를 쓰지 않습니다. 같은 Dremel 논문에서 출발했지만 자체 포맷 Capacitor를 씁니다. Parquet의 어떤 부분이 부족해서 Capacitor가 필요했는지, 그리고 BigQuery 안에서 데이터가 어떤 형태로 흘러가는지를 알아봅시다.


1. Capacitor: Parquet와 무엇이 다른가

1-1. Dremel에서 갈라진 두 포맷

Parquet에서 다룬 개념들(중첩 데이터의 열 기반 표현, 컬럼별 인코딩, 통계 기반 블록 스킵)은 Capacitor에도 있습니다. 둘 다 2010년 Google이 발표한 Dremel 논문에서 출발했기 때문입니다.

Dremel 논문 (2010)

    ├──> ColumnIO (Google 내부 초기 구현)
    │        │
    │        └──> Capacitor (2016, ColumnIO 대체)  ← BigQuery 현재 포맷

    └──> Apache Parquet (오픈소스)  ← Dremel 논문의 인코딩 개념을 차용

Parquet가 Capacitor의 오픈소스 버전이라고 오해하기 쉬운데, Dremel 논문의 인코딩 방식을 차용한 별도 프로젝트입니다. Capacitor 코드와는 관계가 없습니다.

인코딩, 중첩 데이터 표현, 컬럼 단위 저장까지는 같습니다. 다른 점은 하나, Row Reordering입니다.

1-2. Row Reordering

Capacitor는 데이터를 저장하기 전에 행의 물리적 순서를 재배열합니다. Parquet에는 없는 기능입니다.

같은 값이 연속으로 나타나면 RLE(Run-Length Encoding) 압축 효율이 올라갑니다.

정렬 전 (country 컬럼):
[KR, US, KR, JP, US, KR, JP, US]
→ RLE: (KR,1)(US,1)(KR,1)(JP,1)(US,1)(KR,1)(JP,1)(US,1) = 8쌍

정렬 후:
[JP, JP, KR, KR, KR, US, US, US]
→ RLE: (JP,2)(KR,3)(US,3) = 3쌍

하지만 컬럼이 여러 개이면 문제가 생깁니다. country를 정렬하면 city의 연속성이 깨질 수 있습니다. 모든 컬럼에 대해 최적의 행 순서를 찾는 것은 컬럼 수가 늘어날수록 탐색 공간이 폭발하는 조합 최적화 문제입니다. Capacitor는 카디널리티, 데이터 타입, 쿼리 패턴 등을 고려하는 근사 모델로 전체 압축률이 높은 순서를 결정합니다.

1-3. 인코딩 자동 선택

같은 근사 모델이 컬럼별 인코딩도 자동으로 고릅니다. Parquet에서는 사용자가 인코딩을 직접 설정하거나 기본값을 쓰지만, Capacitor는 데이터 분포를 보고 RLE, Dictionary, Delta 중 뭐가 맞는지를 컬럼마다 판단합니다.

이 재배열과 인코딩 선택은 사용자가 지정하는 클러스터링이나 파티셔닝과는 별개로, Capacitor 파일 내부에서 자동으로 일어납니다.


2. Row Reordering이 필요한 이유

2-1. Compute와 Storage가 떨어져 있다

BigQuery의 구조를 먼저 봐야 합니다. 쿼리를 실행하는 Dremel(compute)과 데이터를 저장하는 Colossus(storage)가 물리적으로 분리되어 있고, 그 사이를 Jupiter(네트워크)가 잇습니다.

┌──────────┐      Jupiter      ┌───────────┐
│  Dremel  │ ◄──(네트워크)───►   │ Colossus  │
│ (compute)│                   │ (storage) │
└──────────┘                   └───────────┘

독립적으로 스케일링할 수 있는 장점이 있지만, 쿼리할 때마다 네트워크를 경유해야 합니다. 로컬 디스크보다 느리니, 네트워크를 타는 데이터 양 자체를 줄이는 것이 곧 성능입니다.

2-2. 압축 = 전송량 감소 = 성능

보통 압축은 저장 공간을 아끼려고 합니다. BigQuery에서는 의미가 다릅니다. 압축률이 높을수록 Colossus에서 Dremel으로 전송되는 바이트 수가 줄어듭니다. Row Reordering으로 RLE 압축률을 끌어올리는 건 디스크를 아끼려는 게 아니라 쿼리를 빠르게 하려는 것입니다.

2-3. 압축 상태에서 직접 연산

Capacitor는 압축을 풀지 않고도 연산할 수 있습니다.

  • Filter Pushdown: 블록 헤더의 통계(min, max)만 보고 조건에 맞지 않는 블록을 통째로 건너뜀
  • RLE 직접 카운팅: COUNT(\*) 같은 집계에서, RLE 상태의 (KR, 3)을 보고 3을 바로 더함. 3건을 하나씩 세지 않음

데이터를 전부 해제한 다음 세는 게 아니라, 압축된 상태 그대로 답을 계산합니다.


3. 수집에서 분석까지 - 포맷이 세 번 바뀐다

BigQuery에서 데이터는 Protobuf(행) → Capacitor(열) → Arrow(열)로 형태를 바꿉니다. 왜 한 가지 포맷으로 안 되는지, 각 구간을 따라가 보면 이유가 보입니다.

3-1. 수집: Protobuf로 들어옴

BigQuery에 데이터를 실시간으로 넣는 방법은 Storage Write API입니다. gRPC 스트리밍 기반으로 동작하며, 직렬화 포맷으로 Protobuf를 사용합니다.

CDC(Change Data Capture)를 예로 들어 보겠습니다. 소스 데이터베이스에서 변경이 발생하면, CDC 파이프라인은 이 변경을 레코드 1건으로 캡처해서 BigQuery에 보냅니다.

┌─────────────┐     Protobuf      ┌────────────────────┐
│  Source DB  │  ──────────────>  │  BigQuery          │
│             │   {user_id: 1,    │  Storage Write API │
│  UPDATE 발생 │    name: "Alice", │  (default stream)  │
│             │    age: 31,       │                    │
└─────────────┘    _CHANGE_TYPE:  └────────────────────┘
                    "UPSERT"}

Protobuf 메시지 안에 _CHANGE_TYPE 필드가 포함되어 있으므로, BigQuery는 별도의 변환 없이 UPSERT인지 DELETE인지를 바로 인식합니다. 전송 단위가 레코드 1건이므로, 1부에서 다룬 것처럼 행 기반 포맷이 유리합니다.

Storage Write API는 세 가지 스트림을 제공합니다.

스트림가시성전송 보장용도
Default즉시 조회 가능At-least-onceCDC, 연속 스트리밍
Committed즉시 조회 가능Exactly-once (오프셋 기반)중복 제거가 필요한 스트리밍
Pending커밋 전까지 비가시Exactly-once, 원자적 커밋배치성 로드 (All-or-nothing)

CDC는 default stream에서 동작합니다. at-least-once 보장이므로 중복이 발생할 수 있는데, BigQuery는 Primary Key 기반 UPSERT로 처리합니다. 내부적으로는 변경분을 mutation log에 쌓아두고, 쿼리 시점에 base 데이터와 병합하거나 백그라운드 compaction으로 통합합니다.

3-2. 저장: Capacitor로 변환

행 단위로 들어온 데이터는 BigQuery 내부에서 열 기반으로 뒤집어집니다. 메모리 버퍼에서 Row Reordering과 인코딩이 적용되고, Capacitor 파일이 되어 Colossus에 저장됩니다.

Protobuf (행 기반)                     Capacitor (열 기반)

[1, Alice, 31]  ─┐
[2, Bob, 25]     ├──── 열 변환 ──>    user_id: [1, 2, 3]
[3, Carol, 28]  ─┘  Row Reordering  name: [Alice, Bob, Carol]
                      인코딩 선택      age: [25, 28, 31]

                                              v
                                           Colossus

3-3. 분석: Arrow로 반환

저장된 데이터를 조회하면, Dremel이 Colossus에서 Capacitor 파일을 읽어 클라이언트에 반환합니다. 이때 사용되는 것이 Storage Read API이고, 반환 포맷은 Arrow입니다.

BigQuery 내부                        클라이언트

┌──────────┐    gRPC Stream    ┌────────────────┐
│Capacitor │ ──────────────>   │ Arrow          │
│(열 기반)   │     Arrow         │ RecordBatch    │
│          │    RecordBatch    │                │
└──────────┘                   └────────────────┘

                                      v
                                 pyarrow.Table

                                      v
                                 pandas DataFrame

Capacitor도 열 기반, Arrow도 열 기반이니 열 → 열 변환입니다. JSON으로 바꿔서 내려보내는 기존 REST API보다 오버헤드가 훨씬 적습니다.

python
from google.cloud import bigquery

client = bigquery.Client()
query = "SELECT user_id, age FROM users"

# Arrow Table로 직접 변환 — RecordBatch를 합치는 과정이 Zero-Copy
arrow_table = client.query(query).to_arrow()

# Pandas DataFrame 변환 — Arrow 버퍼를 그대로 참조 (대부분 Zero-Copy)
df = arrow_table.to_pandas()

to_arrow()는 gRPC로 수신한 Arrow RecordBatch들을 합치는데, 데이터를 복사하지 않고 버퍼 참조만 연결합니다. to_pandas()도 마찬가지로, C++로 구현된 Arrow 메모리 버퍼의 주소를 Python이 그대로 가리킬 뿐입니다. 직렬화/역직렬화가 없으니 대부분의 데이터 타입에서 추가 복사가 발생하지 않습니다. Google 엔지니어링 블로그에 따르면, 이 경로(to_arrow().to_pandas())는 기존 REST API(tabledata.list) 대비 약 31배 빠릅니다.


마무리

Parquet와 거의 비슷해 보이지만 Capacitor가 다른 점은 행 순서를 바꾸는 Row Reordering입니다. compute와 storage 사이에 네트워크가 있어서, 전송량이 줄면 쿼리가 빨라지기 때문입니다.

다음 글에서는 Protobuf에서 태그를 아예 빼버린 포맷, Avro를 알아보겠습니다.