스캔 비용 최적화

비용 효율적인 쿼리 작성

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. 비용을 줄이는 구조화 요령

핵심 전략

  1. CTE 활용: 같은 필터/원천을 여러 군데에서 쓰면 CTE로 한 번 정의하고 그것을 재참조하도록 작성
  2. 컬럼 선택 최적화: 필요한 컬럼만 SELECT (SELECT * 지양) → 스캔 바이트 절감
  3. 파티션 프루닝: 파티션 컬럼으로 WHERE 필터 적용
  4. 클러스터링 활용: 클러스터링 키 기준으로 범위/동등 조건을 써서 읽기 범위를 최소화
  5. 중간 결과 물리화: 동일 계산을 반복 수행해야 하면 임시/물리 테이블로 중간 결과를 물리화해 재사용(원본의 대용량 재스캔 방지)