스캔 비용 최적화
비용 효율적인 쿼리 작성
1. BigQuery 과금 원리
BigQuery는 “서브쿼리를 몇 번 썼느냐”로 과금하지 않고, 최종 실행 계획에서 ‘저장소에서 읽어 온 바이트 수(= bytes processed)’로 과금합니다.
기본 동작 원리
- 서브쿼리/CTE(WITH)는 기본적으로 인라인으로 취급됩니다
- 옵티마이저가 같은 계산을 한 번만 수행해 공통 부분을 재사용하면 추가 과금이 없을 수 있습니다
- 쿼리 구조(예: 자기조인, 동일 테이블을 여러 경로로 스캔 등) 때문에 기반 테이블을 여러 번 스캔하도록 실행 계획이 잡히면 그만큼 bytes processed가 늘어 비용도 증가합니다
비용 계산 예시
원본 테이블이 100GB인데, 파티션/컬럼 프루닝으로 실제 스캔이 2GB가 되었다고 가정:
- 옵티마이저가 한 번 스캔 후 분기해서 쓰면 ≈ 2GB 과금
- 쿼리 형태상 동일 필터를 적용한 독립 스캔 두 번이 필요하면 ≈ 4GB 과금
2. 핵심 최적화 원칙
주요 포인트
- 서브쿼리를 여러 번 썼다고 해서 자동으로 ‘처음 로딩분만 과금’ 되는 것은 아닙니다
- 관건은 최종 실행 계획이 스토리지를 몇 번, 어떤 컬럼을 얼마나 읽느냐입니다
SELECT *는 불필요한 컬럼 스캔을 늘려 비용을 키웁니다. 필요한 컬럼만 선택하세요- 파티션 프루닝(파티션 컬럼으로 필터), 클러스터링 정렬키 기반 필터를 활용하면 스캔 바이트를 크게 줄일 수 있습니다
3. 비용 확인 및 제어 방법
Dry run으로 예상 비용 확인
bq query --use_legacy_sql=false --dry_run '...SQL...'
추가 확인 방법
- EXPLAIN / 실행 세부 정보에서 스캔 단계가 몇 번 일어나는지 확인
- Maximum bytes billed로 상한선 설정(콘솔/쿼리 옵션)
재사용 전략
단일 쿼리 안에서:
- 가능하면 한 번의 CTE로 정의하고 그 결과만 참조하도록 재구성
여러 쿼리 간에서:
- TEMP TABLE/퍼시스턴트 테이블로 물리화해서 작은 중간 결과를 재사용
- 이때 중간 결과를 다시 읽는 바이트는 과금되지만, 원본을 매번 크게 스캔하는 것보다 보통 저렴
4. EXPLAIN 결과 분석 예시
BigQuery에서 EXPLAIN 결과를 실제로 어떻게 읽는지 보여주는 예시입니다. 핵심은 SCAN(테이블 스캔)이 몇 번 일어나는지와 어떤 컬럼·파티션을 읽는지를 파악하는 것입니다.
참고: EXPLAIN은 “논리 실행 계획”을 보여주고, 실제 스캔 바이트는 실행 후 “Execution details”에서 확인하거나 DRY RUN으로 예상치를 봅니다.
4.1 한 번만 스캔해서 재사용되는 형태 (CTE 재사용)
쿼리:
-- 7월 이벤트에서 사용자 수와 매출 합계를 한 번에 계산
WITH filtered AS (
SELECT user_id, purchase_amount
FROM `project.dataset.events`
WHERE event_date BETWEEN '2025-07-01' AND '2025-07-31' -- 파티션 프루닝 포인트
)
SELECT
COUNT(DISTINCT user_id) AS users,
SUM(purchase_amount) AS revenue
FROM filtered;
EXPLAIN 결과 (요약/발췌):
{
"stages": [
{
"name": "Stage 1",
"steps": [
{
"kind": "SCAN",
"substeps": [
"Scan table project.dataset.events",
"Partitions selected: 2025-07-01..2025-07-31",
"Output columns: user_id, purchase_amount",
"Filter: event_date BETWEEN '2025-07-01' AND '2025-07-31'"
]
},
{ "kind": "AGGREGATE", "substeps": ["COUNT(DISTINCT user_id)"] },
{ "kind": "AGGREGATE", "substeps": ["SUM(purchase_amount)"] }
]
}
]
}
분석:
- SCAN이 1회이며, 필요한 두 컬럼만 출력하고, 파티션 필터가 적용됨을 확인
- 이후 AGGREGATE 단계가 두 번 있어도, 스캔 자체는 한 번입니다
- 이런 경우 bytes processed는 “한 번 스캔” 기준으로 계산됩니다
4.2 동일 테이블을 중복 스캔하는 형태 (서브쿼리 2개를 따로 둔 경우)
쿼리:
-- 같은 조건이지만 서브쿼리를 둘로 나눔 → 중복 스캔 가능
SELECT a.users, b.revenue
FROM (
SELECT COUNT(DISTINCT user_id) AS users
FROM `project.dataset.events`
WHERE event_date BETWEEN '2025-07-01' AND '2025-07-31'
) a
CROSS JOIN (
SELECT SUM(purchase_amount) AS revenue
FROM `project.dataset.events`
WHERE event_date BETWEEN '2025-07-01' AND '2025-07-31'
) b;
EXPLAIN 결과 (요약/발췌):
아래처럼 “SCAN TABLE …”이 두 번 나타나면, 논리 계획상 두 번 읽을 가능성이 높습니다(옵티마이저가 중복 제거를 못 했거나 안 했을 때).
{
"stages": [
{
"name": "Stage 1",
"steps": [
{"kind": "SCAN", "substeps": [
"Scan table project.dataset.events -- Scan #1",
"Partitions selected: 2025-07-01..2025-07-31",
"Output columns: user_id",
"Filter: event_date BETWEEN ..."]
},
{"kind": "AGGREGATE", "substeps": ["COUNT(DISTINCT user_id)"]}
]
},
{
"name": "Stage 2",
"steps": [
{"kind": "SCAN", "substeps": [
"Scan table project.dataset.events -- Scan #2",
"Partitions selected: 2025-07-01..2025-07-31",
"Output columns: purchase_amount",
"Filter: event_date BETWEEN ..."]
},
{"kind": "AGGREGATE", "substeps": ["SUM(purchase_amount)"]}
]
},
{ "name": "Stage 3", "steps": [
{ "kind": "JOIN", "substeps": ["CROSS JOIN results from Stage 1 and Stage 2"] }
]}
]
}
분석:
- SCAN 단계가 두 번 보입니다
- 결과적으로 동일 파티션 범위를 두 번 스캔할 수 있어 bytes processed가 증가할 가능성이 큽니다
- 단, 옵티마이저가 완전히 동일한 서브쿼리를 “공통 부분 최적화”로 합칠 때도 있으므로, DRY RUN이나 실행 후 “Execution details”에서 최종 바이트를 반드시 확인하세요
4.3 조인으로 인한 다중 스캔 예시 (자기조인)
쿼리:
-- 동일 테이블을 서로 다른 조건으로 필터링 후 자기조인
SELECT COUNT(*) AS pairs
FROM `project.dataset.events` e1
JOIN `project.dataset.events` e2
ON e1.user_id = e2.user_id
WHERE e1.event_date = '2025-07-15'
AND e2.event_date = '2025-07-31';
EXPLAIN 결과 (요약/발췌):
{
"stages": [
{
"name": "Stage 1 (Left side)",
"steps": [
{ "kind": "SCAN", "substeps": [
"Scan table project.dataset.events -- Left scan",
"Partitions selected: 2025-07-15",
"Output columns: user_id, ...",
"Filter: event_date = '2025-07-15'"] }
]
},
{
"name": "Stage 2 (Right side)",
"steps": [
{ "kind": "SCAN", "substeps": [
"Scan table project.dataset.events -- Right scan",
"Partitions selected: 2025-07-31",
"Output columns: user_id, ...",
"Filter: event_date = '2025-07-31'"] }
]
},
{
"name": "Stage 3",
"steps": [
{ "kind": "JOIN", "substeps": [
"Hash Join on user_id",
"Build side: Stage 2, Probe side: Stage 1"] }
]
}
]
}
분석:
- 동일 테이블이라도 조건이 달라 각각 별도 스캔이 필요합니다
- 파티션 프루닝으로 각 1일 파티션만 읽더라도, 스캔 자체는 2회입니다
5. 실무 팁: EXPLAIN/DRY RUN/Execution details 활용법
5.1 EXPLAIN으로 논리 계획 확인
EXPLAIN
SELECT ...;
- SCAN 단계의 개수, 필터/출력 컬럼, JOIN/AGGREGATE 배치를 봅니다
- EXPLAIN 자체는 “바이트 수”를 직접 보여주지 않습니다
5.2 DRY RUN으로 예상 과금 바이트 확인
bq query --use_legacy_sql=false --dry_run 'SELECT ...'
- “This query will process N bytes” 형태로 예상치를 보여줍니다
5.3 실행 후 콘솔의 Execution details → Query plan
- 각 Stage의 Input bytes, Records read, Partitions scanned 등을 확인
- SCAN이 여러 번인지와 각 스캔이 어느 컬럼·파티션을 읽었는지를 시각적으로 파악할 수 있습니다
6. 비용을 줄이는 구조화 요령
핵심 전략
- CTE 활용: 같은 필터/원천을 여러 군데에서 쓰면 CTE로 한 번 정의하고 그것을 재참조하도록 작성
- 컬럼 선택 최적화: 필요한 컬럼만 SELECT (
SELECT *지양) → 스캔 바이트 절감 - 파티션 프루닝: 파티션 컬럼으로 WHERE 필터 적용
- 클러스터링 활용: 클러스터링 키 기준으로 범위/동등 조건을 써서 읽기 범위를 최소화
- 중간 결과 물리화: 동일 계산을 반복 수행해야 하면 임시/물리 테이블로 중간 결과를 물리화해 재사용(원본의 대용량 재스캔 방지)