본문 바로가기

4. 쿼리 성능 분석과 디버깅

·12분 읽기·
목차

지난 편에서는 슬롯과 컴퓨트 과금 모델을 살펴봤습니다. baseline과 autoscale을 어떻게 배합할지를 결정하려면 결국 쿼리가 슬롯을 얼마나, 어디서 잡아먹는지를 읽을 줄 알아야 했습니다.

이번 편에서는 그 도구들을 다룹니다. 콘솔의 실행 계획(Execution Graph), 잡(job) 메타데이터 뷰(INFORMATION_SCHEMA.JOBS, JOBS_TIMELINE), slot_ms와 큐잉 시간 같은 핵심 지표를 어떻게 읽는지 살펴보고, 실무에서 자주 만나는 안티패턴들을 정리합니다.

1. 어디서부터 보는가

1-1. 세 가지 진입점

느린 쿼리를 마주했을 때 들여다볼 곳은 크게 셋입니다.

  • 콘솔의 쿼리 결과 화면 — 실행 시간, 처리 바이트, slot time 같은 요약 + Execution Graph
  • INFORMATION_SCHEMA.JOBS — 어제 돌았던 쿼리, 이번 주 ETL 같은 과거 잡(job) 메타데이터
  • INFORMATION_SCHEMA.JOBS_TIMELINE — 초 단위로 쪼개진 slot_ms 사용 추이

콘솔은 한 쿼리를 깊게 들여다볼 때 빠르고, INFORMATION_SCHEMA는 추세나 패턴(매일 같은 시간 느려진다, 특정 사용자가 비싼 쿼리를 돌린다)을 찾을 때 강합니다.

1-2. 무엇을 보는가

세 진입점에서 공통으로 보는 지표가 있습니다.

Bytes processed       스캔량 (on-demand 비용과 직결)
Slot time             누적 슬롯 시간 (compute 부담)
Elapsed time          실제 흐른 wall-clock 시간
Slot time / Elapsed   평균 동시 슬롯 사용량 (병렬도)
Queue time            슬롯 받을 때까지 기다린 시간

Slot time / Elapsed 비율이 평균 동시 사용 슬롯 수입니다. 이게 작으면 병렬화가 안 되거나 슬롯이 부족했다는 신호이고, 크면 잘 분산되었다는 뜻입니다. Queue time이 크면 슬롯 풀 자체가 모자란 상태입니다.


2. 실행 계획 읽기

2-1. Stage와 Shuffle

BigQuery의 실행 계획은 stage 단위로 쪼개진 트리입니다. 1편에서 봤듯이 한 stage는 Leaf 서버들이 부분 결과를 만들고 Mixer가 합치는 한 차례의 트리 실행이고, stage 사이에는 데이터를 재분배하는 셔플이 일어납니다. Execution Graph는 이 stage들을 노드로, 그 사이 셔플을 엣지로 보여줍니다.

Stage 노드에서 보는 값:
  Records read / written      stage가 처리한 행 수
  Slot time                   이 stage가 잡아먹은 누적 슬롯 시간
  Shuffle bytes               다음 stage로 넘긴 데이터 양
  Wait/Read/Compute/Write     stage 내부에서 시간이 어디 쓰였나

각 노드는 slot time에 비례한 색으로 칠해집니다. 진한 빨강 노드가 한두 개 있다면 거기가 병목입니다. 셔플 엣지가 두꺼우면 stage 간 데이터 이동이 많다는 뜻이고, 자주 쿼리 시간을 지배합니다.

2-2. Wait / Read / Compute / Write

stage 안에서 시간이 어디 쓰였는지를 네 갈래로 보여줍니다.

  • Wait — 워커가 스케줄되기를 기다린 시간. 앞 stage가 결과를 쓰기 시작하기를 기다리거나, 슬롯이 가용해질 때까지 기다리는 두 경우가 있습니다
  • Read — 입력(Colossus 또는 셔플 버퍼)에서 데이터를 가져온 시간
  • Compute — 실제 연산 시간 (필터, 집계, 표현식)
  • Write — 결과를 다음 stage 또는 최종 출력으로 쓴 시간

Wait가 큰 stage는 본인의 연산 문제가 아니라 앞 stage가 느리거나 슬롯이 부족했던 것입니다. 잡 자체는 슬롯을 받아 시작했더라도, stage가 요청한 슬롯을 다 못 받으면 그 시간이 Wait로 잡힙니다. 6절에서 다루는 잡 레벨 큐잉(creation_time → start_time)과는 다른 레이어라, 둘을 같이 보면 슬롯 부족이 어디서 새고 있는지 명확해집니다. Read가 큰 stage는 입력이 너무 많거나(스캔 좁히기) 셔플 데이터가 큽니다. Compute가 큰 stage는 JOIN이나 윈도우 함수처럼 stage 안에서 연산이 무거운 경우이고, Write가 큰 stage는 출력이 비대하거나 다음 stage로 보내는 데이터가 큽니다.

2-3. 실행 전 점검 — Dry Run

BigQuery는 다른 DB에서 흔한 EXPLAIN 문을 지원하지 않습니다. 대신 dry run 모드로 잡을 제출하면 실제 실행 없이 스캔 예상 바이트와 구문/참조 유효성을 검증할 수 있습니다. 슬롯을 잡지 않으므로 비용도 0원입니다.

bash
# CLI: 스캔 예상 바이트만 확인 (실행 안 됨)
bq query --dry_run --use_legacy_sql=false '
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-06-01"
GROUP BY r.name'
python
# Python 클라이언트
from google.cloud import bigquery
client = bigquery.Client()
job_config = bigquery.QueryJobConfig(dry_run=True, use_query_cache=False)
job = client.query(sql, job_config=job_config)
print(job.total_bytes_processed)  # 청구 예상 바이트

CI에서 변경된 쿼리가 스캔량을 갑자기 늘리지 않는지 점검하기에 좋은 도구입니다. 단, dry run으로 나오는 건 주로 스캔 추정치와 구문 검증이고, stage 구조나 실제 슬롯 소모는 실제 실행 후 Execution Graph와 query_plan 컬럼에서 봐야 합니다. 실행 전엔 dry run으로 비용 가드를 걸고, 실행 후엔 Execution Graph로 stage를 분석하는 식으로 둘을 분리해서 씁니다.


3. INFORMATION_SCHEMA로 보는 추세

3-1. JOBS / JOBS_BY_PROJECT

쿼리 한 건당 한 행씩 들어 있는 요약 뷰입니다. 비싼 쿼리, 느린 쿼리, 캐시 hit 비율, 사용자별 비용을 한꺼번에 볼 수 있습니다.

sql
-- 어제 가장 비쌌던 쿼리 top 20
SELECT
  job_id,
  user_email,
  ROUND(total_bytes_processed / POW(1024, 4), 3)         AS tb_processed,
  ROUND(total_slot_ms / 1000 / 60, 2)                    AS slot_minutes,
  TIMESTAMP_DIFF(end_time, creation_time, SECOND)        AS elapsed_sec,
  TIMESTAMP_DIFF(start_time, creation_time, MILLISECOND) AS queue_ms,
  cache_hit,
  SUBSTR(query, 0, 120) AS query_preview
FROM `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time BETWEEN TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 1 DAY) AND CURRENT_TIMESTAMP()
  AND job_type = 'QUERY'
  AND state    = 'DONE'
  AND error_result IS NULL
ORDER BY total_bytes_processed DESC
LIMIT 20;

total_slot_ms는 누적 슬롯 시간이고, 보통 분 단위(/ 1000 / 60)로 환산해서 봅니다. total_bytes_processed는 on-demand 모드에서 청구되는 바이트, queue_ms는 슬롯을 기다린 시간입니다.

Airflow나 Composer 같은 오케스트레이터로 잡을 돌린다면 BigQueryInsertJobOperatorconfiguration["labels"]dag_id, task_id를 박아 두는 게 좋습니다(구버전 BigQueryOperator는 deprecated). JOBS 뷰에는 labels 컬럼이 있어서, 라벨로 그룹핑하면 "어느 DAG의 어느 task가 매일 몇 분치 슬롯을 잡아먹는지"를 바로 발라낼 수 있습니다.

python
# Airflow 2.x — DAG와 task 라벨을 BigQuery 잡에 주입
BigQueryInsertJobOperator(
    task_id="transform_silver",
    configuration={
        "query": {"query": sql, "useLegacySql": False},
        "labels": {
            "dag_id":  dag.dag_id.lower().replace(".", "_"),
            "task_id": "transform_silver",   # 소문자 + 점 없음
        },
    },
)

여기서 한 가지 함정: BigQuery 라벨 키/값은 소문자·숫자·언더스코어·대시만 허용하고, 점(.)이 들어가면 잡 제출이 400 에러로 거부됩니다. Airflow의 task_iddag_id에 점이나 대문자가 흔하니, 라벨로 박을 때는 .lower() + 점/공백을 밑줄로 치환하는 sanitize를 한 번 거쳐야 합니다.

sql
-- DAG/task별 일별 슬롯 사용량
SELECT
  (SELECT value FROM UNNEST(labels) WHERE key = 'dag_id')  AS dag_id,
  (SELECT value FROM UNNEST(labels) WHERE key = 'task_id') AS task_id,
  DATE(creation_time) AS day,
  ROUND(SUM(total_slot_ms) / 1000 / 60, 2) AS slot_minutes
FROM `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
  AND job_type = 'QUERY'
GROUP BY dag_id, task_id, day
ORDER BY slot_minutes DESC
LIMIT 20;

3-2. query_plan 컬럼

JOBS에는 query_plan이라는 RECORD 배열 컬럼이 있어서, 각 stage별 상세 통계를 SQL로 직접 뽑을 수 있습니다.

sql
-- 특정 job_id의 stage별 slot time과 셔플
SELECT
  job_id,
  stage.id           AS stage_id,
  stage.name         AS stage_name,
  stage.records_read,
  stage.records_written,
  stage.shuffle_output_bytes,
  ROUND(stage.slot_ms / 1000, 2) AS slot_seconds,
  stage.wait_ms_avg, stage.read_ms_avg, stage.compute_ms_avg, stage.write_ms_avg
FROM `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT,
     UNNEST(query_plan) AS stage
WHERE job_id = 'bquxjob_xxxxxxxx_xxxxxxxxxxx'
ORDER BY stage.id;

이 결과를 보면 콘솔 Execution Graph가 보여주는 정보를 SQL로 같은 단위에서 분석할 수 있습니다. 같은 패턴의 쿼리가 어제는 stage 3에서 5초 걸렸는데 오늘 50초가 됐다면, 거기 무엇이 변했는지(records_read 폭증? compute_ms_avg?)를 바로 확인할 수 있습니다.

3-3. JOBS_TIMELINE

JOBS가 잡 단위라면 JOBS_TIMELINE은 초 단위 슬라이스입니다. 한 잡이 1분 돌았다면 60줄이 들어옵니다.

sql
-- 특정 잡이 시간대별로 슬롯을 얼마나 잡고 있었는지
SELECT
  period_start,
  period_slot_ms,
  period_slot_ms / 1000              AS avg_concurrent_slots,  -- 1초 구간의 평균 동시 슬롯
  period_estimated_runnable_units
FROM `region-us`.INFORMATION_SCHEMA.JOBS_TIMELINE_BY_PROJECT
WHERE job_id = 'bquxjob_xxxxxxxx_xxxxxxxxxxx'
ORDER BY period_start;

JOBS_TIMELINE은 1초 슬라이스이므로, period_slot_ms / 1000이 해당 구간의 평균 동시 슬롯 수입니다. period_estimated_runnable_units는 BigQuery가 "이 시점에 이 잡이 더 받을 수 있었다면 좋았을 슬롯 수"의 추정치라서, 0보다 큰 값이 꾸준히 보이면 그 구간에 슬롯 부족이 있었다는 신호입니다. 동시 슬롯 수가 정체된 채로 runnable_units가 양수로 계속 나오면 슬롯 부족이 거의 확실합니다.


4. 자주 만나는 안티패턴

4-1. SELECT *

가장 흔하고 가장 비싼 안티패턴입니다. 컬럼 기반 저장의 이점을 통째로 날려서 100컬럼 테이블이면 실제로 필요한 컬럼만 쓸 때보다 비용이 수십 배 차이 납니다.

sql
-- 안티패턴
SELECT * FROM events WHERE event_date = '2026-06-01' LIMIT 10;
-- 비용을 키우는 건 LIMIT의 유무가 아니라 "읽는 컬럼 수"입니다.
-- LIMIT은 출력 행만 제한할 뿐, 컬럼 프루닝은 일어나지 않으므로 스캔량은 그대로.

-- 권장
SELECT event_id, user_id, event_type FROM events WHERE event_date = '2026-06-01' LIMIT 10;

SELECT *가 정말 필요하면 그 자체로 코드 리뷰 대상으로 보는 게 좋습니다. 운영 쿼리에서는 사실상 거의 항상 컬럼을 명시할 수 있습니다.

SELECT * EXCEPT (...)도 같은 맥락입니다. 손에 익기 쉽지만, 테이블에 새 컬럼이 추가되면 무방비로 스캔에 끌려옵니다. 분석가가 자주 쓰는 ad-hoc 쿼리에는 편하지만, 운영 쿼리나 dashboard 쿼리에서는 필요한 컬럼만 명시하는 게 안전합니다.

4-2. JOIN 순서와 크기

BigQuery는 옵티마이저가 JOIN 순서를 재배치하지만, 큰 테이블끼리 셔플 기반으로 JOIN되면 stage 사이 데이터 이동이 폭발합니다. 큰 쪽을 좁히는 게 먼저이고, 그게 어렵다면 작은 쪽을 broadcast 가능한 크기까지 줄이는 게 다음입니다.

sql
-- 안티패턴: 큰 테이블 두 개를 통째로 JOIN
SELECT a.user_id, b.session_id
FROM big_events a
JOIN big_sessions b ON a.user_id = b.user_id;

-- 개선: 양쪽을 먼저 좁히고 필요한 컬럼만
SELECT a.user_id, b.session_id
FROM (SELECT user_id FROM big_events    WHERE event_date = '2026-06-01') a
JOIN (SELECT user_id, session_id FROM big_sessions WHERE date = '2026-06-01') b
  ON a.user_id = b.user_id;

Execution Graph에서 JOIN 직전 stage의 Read 시간과 입력 행 수가 비대하다면, 그 입력부터 줄여야 합니다.

4-3. CROSS JOIN

key 없이 두 테이블의 모든 쌍을 만들어 내는 연산입니다. 1만 × 1만 = 1억으로 폭증하기 때문에 큰 테이블에 적용하면 거의 끝나지 않습니다.

CROSS JOIN은 보통 모르고 쓰는 경우가 많습니다. JOIN 조건이 빠진 implicit cross join이 대표적입니다.

sql
-- 안티패턴: JOIN 조건 누락 → implicit CROSS JOIN
SELECT u.name, e.event_type
FROM users u, events e;
-- 사용자 1만 × 이벤트 1억 = 1조 행. 거의 끝나지 않음.

-- 권장: 조인 키 명시
SELECT u.name, e.event_type
FROM users u
JOIN events e ON e.user_id = u.id;

진짜 cross join이 필요한 경우(예: 모든 사용자 × 모든 날짜 매트릭스)에는 양쪽을 사전에 충분히 좁히거나 집계해 두고 들어가야 합니다.

4-4. WHERE 컬럼에 함수 씌우기

파티션 컬럼이나 클러스터링 컬럼을 그대로 두지 않고 함수로 감싸면 프루닝이 깨집니다.

sql
-- 안티패턴: 파티션 프루닝 불가
WHERE DATE(event_timestamp) = '2026-06-01'

-- 권장: 파티션 컬럼 그대로
WHERE event_timestamp BETWEEN TIMESTAMP('2026-06-01') AND TIMESTAMP('2026-06-02')

같은 결과여도 첫 번째는 전체 파티션을 스캔하고, 두 번째는 하루치 파티션만 읽습니다. 비용/시간 차이가 수백 배까지 날 수 있습니다.

4-5. ORDER BY가 너무 일찍

BigQuery에서 ORDER BY는 최종 단계에서 단일 슬롯이 모든 데이터를 모아 정렬하는 동작이라 비쌉니다. 중간 stage에 ORDER BY가 들어가거나, 분석 작업이 끝난 뒤가 아니라 중간 cte에 정렬이 들어 있으면 stage가 늘어나고 셔플도 늘어납니다.

마지막 단계가 아닌 곳의 ORDER BY는 의도한 게 아니라면 제거합니다. 분석용 쿼리에서 결과 정렬이 굳이 필요 없으면 빼는 것만으로도 한참 빨라지는 경우가 많습니다.


5. Data Skew 진단

5-1. 슬롯 간 불균등

stage에 100개 슬롯이 붙어도, 한 슬롯이 9할의 데이터를 받아 처리하고 있다면 사실상 직렬 실행과 다르지 않습니다. 이걸 data skew라고 부르고, 보통 JOIN 키나 GROUP BY 키의 카디널리티가 한 값에 몰릴 때 생깁니다.

Execution Graph의 stage 노드에서 Compute time max / avg를 비교해 보면 됩니다. query_plan에는 compute_ms_avg/compute_ms_max, read_ms_avg/read_ms_max처럼 평균/최대가 worker 단위로 따로 들어 있어서, 평균 대비 최대치가 10배 이상으로 벌어지면 skew를 의심해 볼 만합니다.

정상 stage:    compute_ms_avg 200, compute_ms_max 350     → 비율 1.75배
skew stage:    compute_ms_avg 200, compute_ms_max 18,000  → 비율 90배 (병목!)

5-2. 흔한 원인과 해결

  • NULL이 한 슬롯에 몰림: JOIN과 GROUP BY에서 동작이 다릅니다. JOIN에서는 NULL = NULL이 매칭되지 않으므로 INNER JOIN이면 NULL 키 행은 어차피 결과에서 빠지고, 가짜 키로 채워봤자 상대 테이블에 그 키가 없으니 의미가 없습니다. JOIN의 NULL skew는 그냥 WHERE key IS NOT NULL로 빼면 됩니다. 반면 GROUP BY에서는 모든 NULL이 한 버킷에 몰려 한 슬롯이 받게 되므로, NULL을 보존하면서 분산시키려면 안정적인 다른 컬럼의 해시로 채우는 방법이 있습니다 — COALESCE(key, CAST(FARM_FINGERPRINT(CAST(id AS STRING)) AS STRING)) 식. 음수 해시값도 문자열 캐스팅하면 버킷 분산은 똑같이 되므로 ABS를 굳이 씌울 필요는 없습니다.
  • 특정 사용자/디바이스 ID 쏠림: 봇 트래픽, 결제 시스템처럼 한 ID에 이벤트가 압도적으로 많은 경우. salting으로 분산 후 다시 모으는 2단계가 필요합니다. 집계라면 (key, salt) 단위로 부분 집계한 뒤 key 단위로 재집계. JOIN이라면 큰 쪽에 salt를 붙이고 작은 쪽을 N배 복제해 모든 salt와 매칭시켜야 결과 누락이 없습니다 (한쪽만 salt 붙이면 키 분할에 따라 매칭이 깨짐)
  • 카디널리티 1: GROUP BY 키가 사실상 한 값이면 한 슬롯이 모든 집계를 처리. 키 차원을 늘리거나 pre-aggregation

skew를 잡을 때는 먼저 원인 키를 확인하고, 그 키의 분포를 봅니다. 보통은 SELECT key, COUNT(*) FROM ... GROUP BY key ORDER BY 2 DESC LIMIT 20으로 보는데, 수십억 건 테이블이라면 전체 스캔이 부담입니다. 이럴 땐 APPROX_TOP_COUNT(key, 10)로 어림짐작 상위 10개 키를 순식간에 뽑는 게 편합니다.

sql
-- skew 의심 키의 상위 분포 빠른 진단
SELECT APPROX_TOP_COUNT(user_id, 10) AS top_user_ids
FROM events
WHERE event_date = '2026-06-01';

6. 큐잉과 슬롯 부족 구분

6-1. 두 종류의 "느림"

쿼리가 느리다는 신고가 들어왔을 때, 원인은 크게 두 갈래입니다.

A) 쿼리 자체가 무거움
   → Slot time이 큼, Bytes processed가 큼
   → 안티패턴 정리, 파티션/클러스터링 (5편 주제)

B) 슬롯이 부족해서 못 받음
   → Queue time이 큼
   → period_estimated_runnable_units가 꾸준히 0보다 큼
   → 슬롯 늘리기 (baseline 증가, autoscale max 증가, edition 전환)

같은 쿼리가 평소엔 30초인데 오늘은 5분 걸렸다면 보통 B입니다. 쿼리 패턴은 같은데 그 시간대에 다른 워크로드가 슬롯을 다 차지하고 있었던 거고, 처방도 다릅니다.

6-2. 큐잉 시간 보는 법

creation_time → start_time 간격이 큐잉 시간입니다. 이게 1초 미만이면 정상, 수 초~수십 초 단위로 자주 잡히면 슬롯 부족입니다.

sql
-- 지난 7일간 시간대별 큐잉 시간 분포
SELECT
  TIMESTAMP_TRUNC(creation_time, HOUR) AS hour,
  COUNT(*) AS jobs,
  APPROX_QUANTILES(TIMESTAMP_DIFF(start_time, creation_time, MILLISECOND), 100)[OFFSET(50)] AS p50_queue_ms,
  APPROX_QUANTILES(TIMESTAMP_DIFF(start_time, creation_time, MILLISECOND), 100)[OFFSET(95)] AS p95_queue_ms
FROM `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
  AND job_type = 'QUERY'
GROUP BY hour
ORDER BY hour DESC;

p95가 시간대별로 어떻게 변하는지 보면, 슬롯 부족이 어느 시간대에 집중되는지 한눈에 들어옵니다. 매일 같은 시간대에 p95가 튀어 오른다면 그 시간에 도는 ETL이 baseline을 넘어 autoscale을 끝까지 끌어 쓰고 있다는 신호입니다.


7. 진단 흐름 정리

느린 쿼리 하나를 만났을 때 보통 이 순서로 봅니다.

1. 콘솔의 Execution Graph로 stage별 slot time 분포 확인
   → 한두 stage에 빨강이 몰려 있나? (병목 stage 식별)

2. 그 stage의 Wait/Read/Compute/Write 비율 확인
   → Wait 크면 앞 stage 문제 또는 슬롯 부족 (stage 레벨)
   → Read 크면 입력 줄이기 (스캔/셔플)
   → Compute 크면 안티패턴 점검
   → max/avg 비교로 skew 여부 확인

3. INFORMATION_SCHEMA.JOBS에서 같은 패턴 쿼리들의 추세 확인
   → 평소에도 느렸나, 오늘만 느렸나?

4. queue_ms 확인
   → 크면 슬롯 부족, 작으면 쿼리 자체 문제

이 흐름이 손에 익으면 "느린 쿼리"라는 추상적인 보고가 들어와도 어느 단계에서 막혔는지를 5분 안에 좁힐 수 있습니다.


마무리

쿼리 성능 분석에서 결국 손에 잡히는 도구는 Execution Graph와 INFORMATION_SCHEMA 둘입니다. Graph로 병목 stage를 좁히고 나면, 그 stage 안에서 시간이 어디 쓰였는지를 Wait/Read/Compute/Write로 분해해 봅니다. 큐잉 시간은 그 다음입니다. 슬롯 부족이 의심될 때만 따로 떼서 봐도 늦지 않습니다. 같은 도구로 같은 질문을 반복해서 던지다 보면, 어느 단계에서 막혔는지를 보는 감이 따로 길러집니다.

다음 편에서는 안티패턴 중에서도 가장 자주 부딪히는 영역인 파티셔닝과 클러스터링을 다룹니다. 이번 편에서 스캔량과 프루닝 얘기를 자주 꺼냈는데, 그 프루닝이 실제로 어떻게 동작하고 어디서 깨지는지를 본격적으로 들여다보겠습니다.

BigQuery Deep Dive 시리즈 (4개의 글)