본문 바로가기

5. 파티셔닝과 클러스터링

·10분 읽기·
목차

지난 편에서 스캔량과 프루닝 얘기를 자주 꺼냈습니다. SELECT *는 컬럼 프루닝을 깨고, WHERE에 함수를 씌우면 파티션 프루닝이 깨진다고 했습니다. 이번 편의 주제는 그 프루닝을 만드는 두 도구, 파티셔닝과 클러스터링입니다.

둘 다 스캔량을 줄이려는 목적은 같은데 동작 층위가 다릅니다. 파티셔닝은 테이블을 크게 자르고, 클러스터링은 파티션 안을 더 작게 정돈합니다. 한 번 정한 뒤에는 바꾸기 어려운 결정이 많아서 테이블 설계 시점에 잘 잡아둬야 하는 영역입니다.

1. 파티셔닝이 풀려는 문제

1-1. 스캔량 = 비용 = 시간

2편에서 다룬 storage 모델과 3편의 슬롯-시간 과금 모두 "데이터를 얼마나 읽는가"가 청구서와 직결됩니다. on-demand는 스캔 바이트로, editions는 슬롯 시간(주로 읽기 단계)으로 청구되는데, 둘 다 큰 테이블을 통째로 읽어 들이면 비용도 시간도 같이 뜁니다.

파티셔닝은 이 "통째로 읽기"를 깨기 위한 가장 기본적인 도구입니다. 테이블을 동등한 청크 여러 개로 나눠 놓고, 쿼리가 특정 청크만 골라 읽도록 만듭니다.

1-2. BigQuery가 파티션을 다루는 방식

파티션은 디렉토리 구조가 아니라 메타데이터에 기록된 데이터 범위입니다. 각 파티션마다 어떤 값 범위의 행이 들어 있는지가 메타데이터에 박혀 있고, 쿼리가 들어오면 BigQuery는 WHERE 조건과 파티션 메타데이터를 비교해 해당 범위에 걸리지 않는 파티션은 아예 읽지 않습니다.

events 테이블 (date 파티션, 1년치)

date=2025-06-28  →  파티션 1   ┐
date=2025-06-29  →  파티션 2   │
...                            ├─ WHERE date = '2026-06-15' 쿼리
date=2026-06-15  →  파티션 N   │     이 파티션만 읽음
...                            │
date=2026-06-28  →  파티션 365 ┘     다른 364개는 스캔 0 byte

읽지 않는 파티션은 청구되지 않습니다. 데이터 1TB짜리 테이블이라도 하루치만 읽으면 그날 파티션 크기(~3GB)만 청구되는 식입니다.


2. 파티셔닝 종류

2-1. 시간 단위 파티셔닝

가장 흔한 형태입니다. DATE, DATETIME, TIMESTAMP 컬럼을 파티션 키로 잡고, 단위를 hourly/daily/monthly/yearly 중에서 고릅니다.

sql
CREATE TABLE `project.dataset.events` (
  event_id    STRING,
  event_date  DATE,
  user_id     STRING,
  payload     STRING
)
PARTITION BY event_date;

기본 단위는 daily입니다. hourly로 잡으면 더 정밀한 프루닝이 가능하지만 파티션 수가 24배 늘어나고, monthly로 잡으면 파티션은 적어지지만 한 파티션이 커져서 프루닝 이득이 줄어듭니다.

2-2. Ingestion-time 파티셔닝

데이터가 BigQuery에 적재된 시점 기준으로 자동 파티셔닝하는 옵션입니다. 사용자가 직접 파티션 컬럼을 만들지 않고, BigQuery가 _PARTITIONTIME(또는 _PARTITIONDATE)이라는 의사 컬럼(pseudo column)을 자동으로 붙입니다.

sql
CREATE TABLE `project.dataset.events_ingest_partitioned` (
  event_id   STRING,
  user_id    STRING,
  payload    STRING
)
PARTITION BY DATE(_PARTITIONTIME);

-- 쿼리 시
SELECT *
FROM `project.dataset.events_ingest_partitioned`
WHERE _PARTITIONDATE = '2026-06-20';

_PARTITIONTIME_PARTITIONDATE는 pseudo column이라 SELECT *에는 안 나옵니다. 파티션 값 자체를 결과에 담고 싶으면 SELECT _PARTITIONDATE, *처럼 명시적으로 호출해야 합니다. 디버깅할 때 자주 잊는 지점입니다.

언제 ingestion-time이 일반 컬럼 파티셔닝보다 낫냐고 하면, event-time과 ingestion-time이 다를 수 있는 데이터일 때입니다. 4편에서 다뤘던 late-arriving data 패턴 — Mixpanel처럼 며칠씩 늦게 도착하는 이벤트 — 에서 event-time 파티셔닝을 쓰면 1년 전 파티션에 새 행이 들어와 long-term 분류가 깨집니다. ingestion-time 파티셔닝은 도착 시점 기준이라 이 문제를 자연스럽게 피합니다.

반면 분석이 event_date 기준이라면 일반 컬럼 파티셔닝이 직관적이고 쿼리도 단순합니다. 데이터 성격과 분석 방식에 따라 갈리는 결정입니다.

2-3. 정수 범위 파티셔닝

정수 컬럼을 일정 간격으로 잘라 파티션으로 만드는 방식입니다.

sql
CREATE TABLE `project.dataset.events_by_user` (
  user_id  INT64,
  ...
)
PARTITION BY RANGE_BUCKET(user_id, GENERATE_ARRAY(0, 10000000, 1000));

user_id를 1,000 단위로 묶어 파티션 1만 개 — 가 아니라, 정확히 여기서 한도에 닿습니다. RANGE_BUCKET(col, GENERATE_ARRAY(min, max, interval))(max - min) / interval 결과가 10,000을 넘으면 테이블 생성이 거부되므로, interval을 넉넉하게 잡아야 합니다. 정수 범위는 시간이 아니어도 자연스러운 균등 분포가 있는 키(고객 ID, 지역 코드 같은 인위 키)에서 유용합니다.

2-4. 어느 걸 골라야 하는가

대부분의 분석 워크로드는 시간 축이 자연스럽기 때문에 시간 단위 파티셔닝이 기본입니다. 세 가지 정도 결정 지점이 있습니다.

late-arriving data가 흔하다              →  ingestion-time
event 시점 기준 분석이 명확하다           →  event-time 컬럼
시간 축이 아니거나, 균등 정수 키가 있다     →  RANGE_BUCKET

ingestion-time과 event-time 둘 다 컬럼으로 두어도 됩니다. 적재할 때 event_date를 같이 채워 두고 파티션은 ingestion-time으로 잡으면, 평소 분석은 event-time으로 filter하고 ingestion-time 파티션이 long-term 분류를 보호합니다.


3. 10,000 파티션 한도

3-1. 한도가 두 종류

이 둘은 값이 서로 달라서 헷갈리기 쉽습니다.

1) 테이블당 최대 파티션 수                : 10,000개
2) 한 잡이 영향 줄 수 있는 파티션 수       :  4,000개

첫 번째는 테이블 크기 제한이고(2024년경 4,000 → 10,000으로 상향됨), 두 번째는 UPDATE, DELETE, MERGE 한 번이 동시에 건드릴 수 있는 파티션 수입니다. 즉 일별 파티션 테이블은 27년치를 만들 수 있지만, 그렇다고 한 번의 MERGE로 27년치를 다 건드릴 수는 없습니다(그건 4,000에서 막힙니다). 후자에 걸리면 연도나 월 단위로 잡을 쪼개서 실행하는 게 정공법이고, EXECUTE IMMEDIATE로 dynamic SQL을 반복문 안에 넣는 패턴이 흔합니다.

3-2. 일별 파티션은 27년이 천장

일별 파티션을 쓰면 10,000 / 365 ≈ 27년 만에 한도에 닿습니다. 대부분의 워크로드에는 충분하지만, 로그 보존이 그보다 길거나 hourly 파티션을 쓰는 경우(10,000 / 24 ≈ 416일)는 상대적으로 빨리 부딪힙니다.

3-3. 우회 전략

선택지는 크게 셋입니다.

파티션 granularity를 키우고 클러스터링으로 보완

가장 흔한 방법입니다. 일별 → 월별로 바꾸면 파티션 수가 30배 줄지만 한 파티션이 30배 커져 프루닝 효율이 떨어집니다. 이걸 클러스터링으로 메웁니다 — 월별 파티션 안에서 자주 필터링하는 컬럼(event_date, user_id 등)으로 클러스터링하면 같은 파티션 안에서도 블록 단위 프루닝이 동작합니다.

sql
CREATE TABLE `project.dataset.events` (
  event_id    STRING,
  event_date  DATE,
  user_id     STRING,
  payload     STRING
)
PARTITION BY DATE_TRUNC(event_date, MONTH)   -- 월별 파티션
CLUSTER BY event_date, user_id;              -- 그 안에서 클러스터링

Expiration 정책으로 한도 도달 자체를 막기

데이터셋 또는 테이블 단위로 파티션 만료를 걸어 두면 오래된 파티션이 자동 삭제됩니다. 보존 기간이 명확한 로그성 데이터에 효과적입니다.

sql
ALTER TABLE `project.dataset.events`
SET OPTIONS (partition_expiration_days = 365 * 3);  -- 3년 보존

테이블을 시기별로 나누기

events_2024, events_2025 같이 시기별 테이블로 분할하고 UNION ALL 뷰로 묶는 방법도 있습니다. 한도는 피하지만 쿼리가 복잡해지고 관리 부담이 커서 마지막 카드입니다.


4. 파티션 프루닝의 원리

4-1. SARGable predicate

파티션 프루닝이 동작하려면 WHERE 조건이 SARGable해야 합니다. SARG는 "Search ARGument"의 줄임말로 원래 SQL Server 쪽에서 온 용어라 BigQuery 공식 문서에는 등장하지 않고(공식 표현은 "qualifying filter"), 다만 개념은 그대로 통용됩니다. 쿼리 옵티마이저가 파티션 메타데이터와 직접 비교할 수 있는 형태의 조건을 가리킵니다.

sql
-- SARGable: 파티션 컬럼이 그대로 비교됨
WHERE event_date = '2026-06-20'
WHERE event_date BETWEEN '2026-06-01' AND '2026-06-30'
WHERE event_date >= '2026-06-15'

-- Non-SARGable: 함수가 컬럼을 감쌈
WHERE DATE(event_timestamp) = '2026-06-20'   -- DATE() 함수
WHERE FORMAT_DATE('%Y%m', event_date) = '202606'
WHERE EXTRACT(MONTH FROM event_date) = 6

함수가 컬럼을 감싸면 옵티마이저는 각 행을 실제로 평가하기 전까지는 그 조건이 만족되는지 모르기 때문에, 모든 파티션을 일단 열어야 합니다. 같은 결과를 얻으면서 SARGable한 형태로 바꿔 쓰면 프루닝이 살아납니다.

4-2. 자주 깨지는 패턴

서브쿼리/조인 결과를 파티션 필터로

sql
-- 깨짐: 서브쿼리 결과는 런타임에 결정되므로 정적 프루닝 불가
WHERE event_date = (SELECT MAX(event_date) FROM events_metadata)

-- 살림: declared variable로 두 단계 분리
DECLARE target_date DATE DEFAULT (SELECT MAX(event_date) FROM events_metadata);
SELECT * FROM events WHERE event_date = target_date;

DECLARE로 변수에 미리 담아 두면 BigQuery가 변수 값을 정적으로 알 수 있어 프루닝이 동작합니다.

파라미터화된 쿼리에서 타입 불일치

sql
-- 깨질 수 있음: 파라미터 타입이 STRING이면 BigQuery가 DATE로 캐스팅하는데
--             그 캐스팅이 함수 래핑처럼 동작
WHERE event_date = @date_param   -- @date_param이 STRING인 경우

파라미터는 컬럼과 같은 타입(DATE)으로 바인딩해야 합니다.

JOIN 조건의 파티션 필터

sql
-- 깨짐: 파티션 필터가 ON 절에만 있고 WHERE 절에 없음
SELECT * FROM events e
JOIN dim_users u ON e.user_id = u.id
                AND e.event_date = '2026-06-20'

-- 살림: 파티션 필터를 WHERE에 명시
SELECT * FROM events e
JOIN dim_users u ON e.user_id = u.id
WHERE e.event_date = '2026-06-20'

옵티마이저가 ON 절의 필터를 푸시다운해 주는 경우도 있지만, 보장된 동작이 아니라 WHERE로 옮기는 게 안전합니다.

4-3. 프루닝됐는지 확인

dry run으로 청구 예상 바이트만 보면 됩니다.

bash
# 쿼리가 정말 하루 파티션만 읽는지 확인
bq query --dry_run --use_legacy_sql=false '
SELECT user_id FROM `project.dataset.events`
WHERE event_date = "2026-06-20"'

예상 바이트가 전체 테이블 크기에 가까우면 프루닝이 깨졌다는 뜻입니다. 4편의 INFORMATION_SCHEMA 쿼리로 사후에도 확인할 수 있지만, 의심될 땐 dry run이 가장 빠릅니다.

파티션 필터 강제하기

분석가가 무심코 파티션 필터 없이 쿼리하는 걸 막으려면, 테이블에 require_partition_filter 옵션을 켤 수 있습니다.

sql
ALTER TABLE `project.dataset.events`
SET OPTIONS (require_partition_filter = TRUE);

이후 파티션 컬럼이 WHERE에 없는 쿼리는 실행 자체가 거부됩니다. 비용 가드로 매우 효과적입니다.


5. 클러스터링

5-1. 블록 단위 정렬

파티셔닝이 테이블을 크게 자른다면, 클러스터링은 파티션 안의 블록을 정렬합니다. 클러스터링 컬럼 값에 따라 비슷한 행끼리 같은 블록에 모이고, 각 블록의 메타데이터(min/max 값)가 기록됩니다. 쿼리가 들어오면 그 메타데이터로 블록 단위 프루닝이 일어납니다.

파티션 (date = '2026-06-20', 10GB)
  ┌──────────────────────────────┐
  │ Block 1: user_id 100~500     │  ┐
  │ Block 2: user_id 501~999     │  │  WHERE user_id = 750
  │ Block 3: user_id 1000~1500   │  ├─ Block 2만 읽음
  │ Block 4: user_id 1501~2000   │  │
  │ ...                          │  ┘  나머지는 스캔 0 byte
  └──────────────────────────────┘

파티션 프루닝과 작동 원리는 같습니다. 단위만 파티션(보통 수십 MB~수 GB) 대신 블록(수십 MB에서 수백 MB 단위, Colossus에서 동적으로 관리)입니다.

5-2. 4 컬럼 한도 + 순서가 영구적

sql
CREATE TABLE `project.dataset.events` (...)
PARTITION BY event_date
CLUSTER BY user_id, event_type, country, device;

최대 4개 컬럼까지 클러스터링할 수 있고(단, JSON·ARRAY·STRUCT처럼 등호·비교 연산자가 정의되지 않는 복합 타입은 클러스터링 컬럼으로 지정할 수 없습니다), 컬럼 순서가 효과를 결정합니다. 왼쪽 컬럼이 가장 강하게 정렬되고, 오른쪽으로 갈수록 약해집니다. user_id, event_type 순서로 클러스터링한 테이블에서:

sql
-- 가장 효과적 (왼쪽 컬럼 사용)
WHERE user_id = 'u123'

-- 효과적 (왼쪽부터 순서대로)
WHERE user_id = 'u123' AND event_type = 'click'

-- 효과 약함 (왼쪽 컬럼 안 씀)
WHERE event_type = 'click'

-- 효과 거의 없음 (셋째 컬럼 단독)
WHERE country = 'KR'

쿼리에서 가장 자주 등장하는 필터를 첫 번째 컬럼에 놓는 게 원칙입니다. 그리고 이 순서는 한 번 정하면 변경하려면 테이블을 재생성해야 합니다. 컬럼 추가/제거는 ALTER TABLE로 가능하지만, 순서 변경은 안 됩니다. 처음 정할 때 워크로드를 잘 보고 결정해야 합니다.

한 가지 흔한 오해: 클러스터링은 저장 계층의 정렬일 뿐, 쿼리 결과의 정렬을 보장하지 않습니다. 클러스터링된 테이블에서 SELECT를 던져도 결과 순서는 임의이고, 정렬된 결과가 필요하면 ORDER BY를 여전히 붙여야 합니다.

5-3. 자동 재클러스터링

새 데이터가 들어오면 클러스터링이 점점 흐트러집니다 (새 행이 기존 블록 어디에 들어가야 할지 매번 정렬할 수 없으니까). BigQuery는 이걸 백그라운드에서 자동으로 재정렬합니다.

자동 재클러스터링
- 사용자가 따로 트리거하지 않아도 자동 실행
- 쿼리에 전혀 영향 없음 (별도 슬롯 사용)
- 청구 안 됨 (BigQuery 운영 비용)

데이터가 자주 변경되는 테이블에서도 클러스터링이 점차 회복되어 프루닝 효과가 유지됩니다. 다만 이 효과가 즉시 나타나는 건 아니라서, 대량 적재 직후의 쿼리는 클러스터링 이득이 평소보다 적을 수 있습니다.


6. 같이 쓰기

6-1. 보완 관계

파티셔닝은 시간/범위 축으로 크게 자르고, 클러스터링은 같은 파티션 안의 다른 컬럼들로 세분화합니다. 둘이 다른 차원에서 동작하기 때문에 한 테이블에 같이 적용해도 효과가 누적됩니다.

events 테이블 (3년치, 10TB)
  ↓ event_date 파티션 → 하루치 ~10GB만 읽기
  ↓ user_id 클러스터링 → 그 하루 안에서도 특정 user 블록만 읽기
  → 최종 스캔: ~100MB

같은 쿼리가 파티셔닝만 있으면 10GB, 둘 다 있으면 100MB. 100배 차이가 흔히 납니다.

6-2. 실무 패턴

대부분의 이벤트성 테이블은 비슷한 패턴으로 잡힙니다.

sql
CREATE TABLE `project.dataset.events` (
  event_id     STRING,
  event_date   DATE,
  event_type   STRING,
  user_id      STRING,
  country      STRING,
  device       STRING,
  payload      JSON
)
PARTITION BY event_date
CLUSTER BY user_id, event_type, country, device
OPTIONS (
  require_partition_filter = TRUE,
  partition_expiration_days = 365 * 3
);
  • 파티션: event_date (분석 축이 시간)
  • 클러스터링 1순위: user_id (가장 자주 필터됨)
  • 2~4순위: 그 다음 자주 쓰는 필터들 (선택성 큰 순서로)
  • require_partition_filter: 사고 예방
  • partition_expiration_days: 10,000 한도 자동 관리

이 한 줄이 청구서 한 자리를 깎는 차이를 만듭니다.


7. 잘못된 사용 패턴

7-1. 너무 작은 테이블에 파티셔닝

테이블이 1GB 미만이면 파티셔닝 자체가 손해입니다. 파티션 메타데이터 관리 오버헤드가 프루닝 이득보다 커지고, 작은 파티션 다수가 큰 파티션 하나보다 비효율적입니다. 작은 dimension 테이블, 설정 테이블, lookup 테이블에는 굳이 파티셔닝을 걸지 않는 게 좋습니다.

7-2. 카디널리티가 너무 낮은 클러스터 컬럼

gender(2~3값), is_active(boolean), country(전 세계지만 트래픽이 한두 나라에 쏠림) 같은 컬럼은 클러스터링 효과가 약합니다. 한 블록 안에 같은 값들이 모이긴 하지만 블록 수 자체가 적어 프루닝할 게 별로 없습니다.

반대로 카디널리티가 너무 높은 컬럼(event_id, uuid 같은 unique 키)도 의미가 없습니다. 모든 블록에 골고루 분산되어 어떤 블록도 건너뛸 수 없습니다.

좋은 클러스터 컬럼:
  - 카디널리티 중간~높음 (수천~수백만 정도)
  - 쿼리에서 자주 등장
  - 동등 비교(=) 또는 범위 비교가 많음

피해야 할 컬럼:
  - 카디널리티 극단 (boolean, unique key)
  - 거의 안 쓰는 컬럼 (왜 클러스터링하나)

7-3. 클러스터 컬럼 순서 잘못

WHERE country = 'KR'이 가장 자주 도는데 클러스터링이 user_id, country, ... 순서면 첫 번째 컬럼(user_id)이 안 쓰여 프루닝이 잘 안 됩니다. 컬럼 순서는 재정의가 어려우니, 운영하면서 새 패턴이 보이면 새 테이블 만들고 점진 마이그레이션하는 게 정공법입니다.


마무리

파티셔닝은 큰 칼로 자르는 일이고, 클러스터링은 그 안을 작게 정돈하는 일입니다. 둘 다 BigQuery 옵티마이저가 메타데이터만 보고도 "이건 안 읽어도 된다"고 판단할 수 있게 만드는 도구이고, 4편에서 안티패턴으로 짚었던 WHERE 함수 래핑이나 서브쿼리 필터는 정확히 이 메타데이터 판단을 깨는 패턴이었습니다.

테이블 설계 시점에 파티션 키와 클러스터링 컬럼을 잘 잡아두면, 같은 워크로드에서 청구서가 한 자릿수 차이로 줄어듭니다. 다만 한 번 정한 순서를 바꾸기 어렵다는 제약 때문에 처음 결정이 중요하고, require_partition_filter로 사용 규약을 강제해 두는 게 안전합니다.

다음 편에서는 같은 데이터를 다른 형태로 노출하는 도구, 를 다룹니다. 일반 뷰, 머티리얼라이즈드 뷰, 그리고 권한 분리를 위한 승인된 뷰. 각자 비용 모델과 사용 패턴이 꽤 다릅니다.