이전 데이터 엔지니어링 시리즈에서 BigQuery를 한 번 다뤘습니다. 그땐 Capacitor 포맷이 주제였고, compute-storage 분리는 "Dremel과 Colossus가 Jupiter 네트워크로 연결되어 있다" 정도로만 짚고 넘어갔습니다.
이번 시리즈는 BigQuery 자체를 다룹니다. 1편에서는 SQL을 콘솔에 입력하고 결과가 돌아올 때까지 내부에서 어떤 컴포넌트가 어떤 순서로 움직이는지 알아보려고 합니다.
1. compute와 storage가 분리되어 있다
1-1. 네 가지 인프라
BigQuery 안에는 네 가지 인프라가 들어 있습니다.
┌──────────┐ Jupiter ┌──────────┐
│ Dremel │ ◄──(네트워크)────► │ Colossus │
│ (compute)│ │ (storage)│
└─────┬────┘ └──────────┘
│
│ 리소스 관리
▼
┌─────────┐
│ Borg │
└─────────┘- Dremel은 쿼리를 실행하는 분산 엔진입니다. SQL을 받아 실행 계획을 만들고, 데이터를 읽어 연산하고, 결과를 돌려줍니다
- Colossus는 Google의 분산 파일 시스템입니다. 테이블 데이터가 Capacitor 포맷으로 이 위에 저장됩니다. 이전 글에서 다룬 그 포맷입니다
- Jupiter는 데이터센터 내부의 전용 네트워크입니다. 1 petabit/sec 이상의 양방향 대역폭을 제공해서, Colossus에서 Dremel으로 데이터를 옮기는 비용이 사실상 무시할 수준입니다
- Borg는 클러스터 오케스트레이터입니다. 어떤 쿼리에 어떤 머신을 얼마나 할당할지 결정합니다. Kubernetes의 원형이기도 합니다
이 네 가지가 서로 분리된 클러스터로 운영됩니다. Dremel을 늘리고 싶으면 compute 클러스터만 늘리고, Colossus는 데이터량에 따라 독립적으로 확장됩니다.
1-2. 분리 구조가 만드는 트레이드오프
전통적인 데이터베이스는 데이터가 있는 머신에서 쿼리를 실행합니다. 디스크와 CPU가 같은 박스 안에 있으니 I/O가 빠릅니다. 대신 compute가 부족해지면 storage도 같이 늘려야 합니다. 데이터는 그대로인데 쿼리량이 폭증하면 의미 없는 디스크가 따라옵니다.
전통적 DB:
┌──────────────┐ ┌──────────────┐
│ Node 1 │ │ Node 2 │
│ │ │ │
│ CPU + Disk │ │ CPU + Disk │
│ (한 묶음) │ │ (한 묶음) │
└──────────────┘ └──────────────┘
→ compute 늘리려면 디스크도 따라옴
→ 디스크 늘리려면 CPU도 따라옴BigQuery는 둘을 분리합니다. 대신 데이터가 네트워크를 타야 하니, 일반적인 환경에서는 이 분리가 손해입니다. 클라우드 데이터베이스라도 EBS나 SSD를 같은 가용 영역에 두는 이유가 그것입니다.
BigQuery에서 이 구조가 성립하는 이유는 Jupiter 때문입니다. 데이터센터 안에서 페타비트급 대역폭을 쓸 수 있으니, Colossus에서 데이터를 끌어오는 시간이 로컬 디스크 읽기와 비슷합니다. 거기에 Capacitor가 압축률을 끌어올리고, 컬럼 단위로만 읽으니 네트워크를 타는 양 자체가 작습니다.
이 구조 때문에 BigQuery의 비용은 저장과 연산으로 완전히 분리됩니다. 데이터를 넣어두기만 해도 storage 비용이 발생하고, 쿼리를 돌리면 compute 비용이 따로 발생합니다. 둘은 서로 영향을 주지 않습니다. 이 분리가 다음 편에서 다룰 logical/physical bytes, on-demand vs editions 같은 결정의 출발점이 됩니다.
2. Dremel은 트리로 쿼리를 실행한다
2-1. Root, Mixer, Leaf
Dremel은 하나의 머신에서 쿼리를 처리하지 않고, 트리 형태로 여러 서버에 작업을 나눠 수행합니다.
┌──────────┐
│ Root │ 쿼리 수신, 실행 계획, 최종 집계
│ Server │
└─────┬────┘
│
┌─────────┼─────────┐
▼ ▼ ▼
┌─────┐ ┌─────┐ ┌─────┐
│Mixer│ │Mixer│ │Mixer│ 중간 집계
└──┬──┘ └──┬──┘ └──┬──┘
│ │ │
┌──┼──┐ ┌──┼──┐ ┌──┼──┐
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
L L L L L L L L L Leaf: Colossus에서 데이터 읽기
+ 로컬 연산 (filter, partial agg)- Root Server는 쿼리를 받아서 파싱하고, 실행 계획을 만듭니다. 트리의 꼭대기에서 작업을 분배하고, 마지막에 결과를 모아 사용자에게 돌려줍니다
- Mixer (Intermediate Server)는 Leaf들이 만든 부분 결과를 단계적으로 집계합니다.
GROUP BY나ORDER BY처럼 모아야 답이 나오는 연산을 트리 위로 올라가면서 처리합니다 - Leaf Server는 실제로 Colossus에서 데이터를 읽고, 필터를 걸고, 로컬에서 가능한 연산을 수행합니다. 트리의 가장 바닥에 있고, 가장 많이 존재합니다
이 트리는 쿼리마다 동적으로 만들어집니다. 데이터가 적으면 Leaf 몇 대로 끝나고, 데이터가 많으면 Leaf 수천 대가 동시에 작동합니다.
2-2. 왜 트리인가
데이터를 한 서버로 모아 처리하면 그 서버가 병목입니다. 트리는 연산을 가능한 한 아래쪽에서 끝내고, 위로 올라갈수록 데이터가 줄어들도록 만듭니다.
SELECT region, COUNT(*) FROM events GROUP BY region 쿼리를 보겠습니다.
Leaf 단계: 각 Leaf가 자기가 읽은 파일에 대해서만 COUNT 부분 집계
└── Leaf 1: KR=120, US=80, JP=30
└── Leaf 2: KR=200, US=150, JP=60
└── Leaf 3: KR=180, US=90, JP=40
Mixer 단계: Leaf들의 부분 결과를 region별로 합침
└── KR=500, US=320, JP=130
Root 단계: 최종 결과 반환전체 데이터를 한 서버로 옮겨 집계하지 않고, 각 Leaf가 미리 줄여 놓은 결과만 위로 올라갑니다. 네트워크에 흐르는 데이터가 줄고, 트리가 깊을수록 분산이 잘 됩니다.
3. 한 쿼리가 여러 스테이지로 쪼개진다
3-1. 스테이지와 셔플
쿼리가 단순하면 트리 한 번으로 끝납니다. 하지만 JOIN이나 다단계 집계가 들어가면, 트리가 여러 번 실행됩니다. 이 한 번의 트리 실행 단위를 스테이지(Stage)라고 부릅니다.
스테이지 사이에서는 데이터가 재분배됩니다. JOIN을 하려면 같은 키를 가진 행들이 같은 서버에 모여야 하고, GROUP BY도 같은 그룹의 데이터가 같은 자리에 있어야 합니다. 이 과정을 셔플(Shuffle)이라고 합니다. 셔플은 Jupiter 네트워크를 통해 일어나고, 중간 결과는 메모리 또는 디스크의 셔플 버퍼에 잠깐 머뭅니다.
3-2. 실제 쿼리로 보기
다음 쿼리를 보겠습니다. events 테이블과 regions 테이블을 JOIN해서 region별 사용자 수를 세는 쿼리입니다.
SELECT r.name, COUNT(DISTINCT e.user_id) AS users
FROM `project.dataset.events` e
JOIN `project.dataset.regions` r ON e.region_id = r.id
WHERE e.event_date = '2026-04-12'
GROUP BY r.name
ORDER BY users DESC;BigQuery는 이 쿼리를 대략 네 스테이지로 나눠 실행합니다.
Stage 1: events 읽기 + 필터링
└── Leaf 수백 대가 Colossus에서 event_date='2026-04-12' 파티션의
region_id, user_id 컬럼만 읽음
└── 출력: (region_id, user_id) 튜플들
↓ Shuffle: region_id 기준으로 재분배
Stage 2: regions 읽기
└── regions 테이블은 작으므로 broadcast (모든 노드에 전체 복사)
↓
Stage 3: JOIN + DISTINCT + 부분 집계
└── region_id가 같은 (e, r) 튜플들이 같은 서버에 모임
└── JOIN 수행
└── region별 DISTINCT user_id 카운트 (부분 집계)
↓ Shuffle: r.name 기준으로 재분배
Stage 4: 최종 집계 + 정렬
└── name별 카운트를 합산
└── ORDER BY users DESC
└── Root로 결과 전송셔플은 Jupiter가 빠르긴 해도 공짜는 아닙니다. 스테이지가 많고 셔플 데이터가 클수록 쿼리 시간이 셔플에 잡아먹힙니다. BigQuery 콘솔의 Execution Details 탭에서 각 스테이지의 입력/출력 행 수와 셔플 바이트를 확인할 수 있습니다. 느린 쿼리를 들여다볼 때 가장 먼저 보는 화면입니다.
3-3. Leaf가 읽는 단위는 컬럼 + 파티션
Stage 1에서 Leaf는 Colossus에서 데이터를 읽는데, 읽는 단위가 컬럼과 파티션입니다. 위 쿼리에서 events 테이블이 100개 컬럼이고 1년치 데이터가 있어도, 실제로 읽히는 건 다음과 같습니다.
events 테이블 전체:
100 columns × 365 days × 1억 행/일 = 36.5 PB
이 쿼리가 실제로 읽는 양:
- event_date='2026-04-12' 파티션만 (1일치)
- region_id, user_id 두 컬럼만
→ 약 2 GBon-demand 모드에서는 스캔한 바이트 수로 과금됩니다. 같은 답을 얻어도 컬럼과 파티션을 잘 좁히면 비용이 수만 배 차이 납니다. 파티셔닝과 클러스터링을 5편에서 따로 다루는 이유가 여기 있습니다.
4. 슬롯이라는 단위
4-1. 슬롯의 정체
여기까지 "Leaf 서버", "트리"라는 내부 용어로 설명했지만, 사용자가 실제로 마주하는 컴퓨팅 자원의 단위는 슬롯(Slot)입니다.
슬롯은 BigQuery가 추상화한 가상 워커입니다. 약 0.5 vCPU와 메모리 약간의 연산 능력에 해당합니다. 한 쿼리에 슬롯이 많이 할당되면 그만큼 더 많은 Leaf와 Mixer가 동시에 동작하고, 쿼리가 빨라집니다.
쿼리 제출
↓
Borg가 슬롯 풀에서 N개 할당
↓
Dremel이 슬롯 위에 트리 생성
↓
슬롯 1: Stage 1의 파티션 A 처리
슬롯 2: Stage 1의 파티션 B 처리
슬롯 3: Stage 1의 파티션 C 처리
슬롯 4: Stage 3 JOIN + 집계
슬롯 5: Stage 4 최종 집계
↓
완료 시 슬롯 회수같은 쿼리도 슬롯이 100개일 때와 1000개일 때 실행 시간이 다릅니다. 단, 무한정 빨라지진 않습니다. 셔플은 어차피 데이터를 한곳에 모아야 하는 단계라 병렬화에 한계가 있습니다.
4-2. 슬롯이 부족하면
슬롯은 무한히 받을 수 있는 자원이 아닙니다. on-demand 모드에서는 프로젝트당 기본 2,000 슬롯까지 사용 가능하지만, 이 풀은 다른 사용자들과 공유합니다. 피크 타임에는 신청한 만큼 못 받을 수 있고, 그러면 쿼리에 슬롯이 적게 할당되어 느려집니다. 큐잉이 발생하기도 합니다.
이 한계를 풀고 싶으면 Editions(Standard/Enterprise/Enterprise Plus) 모드로 슬롯을 직접 예약합니다. 예약된 슬롯은 다른 사용자의 영향을 받지 않습니다. 비용 모델이 완전히 달라지므로 3편에서 자세히 다룹니다.
마무리
BigQuery는 한 머신이 쿼리를 처리하지 않습니다. compute(Dremel)와 storage(Colossus)가 분리된 위에서, 쿼리는 트리로 펼쳐지고 여러 스테이지를 거쳐 결과가 됩니다. 사용자가 직접 다루는 단위는 슬롯이고, 비용도 성능도 여기서 출발합니다.
보통 BigQuery 비용 하면 쿼리를 실행할 때 드는 비용만 떠올리기 마련인데, 사실 데이터를 그냥 보관만 해도 비용이 나가고 과금 모델에 따라 차이도 꽤 큽니다. 다음 편에서는 이 저장소 비용에 대해 더 알아보겠습니다.