스캔 비용 주의사항

비용 함정 피하기

개요

BigQuery에서 스캔 비용은 예상보다 훨씬 높게 나올 수 있으며, 이는 쿼리 작성 방식과 데이터 구조에 크게 의존합니다. 이 문서는 실제 운영 환경에서 자주 발생하는 스캔 비용 관련 문제점들과 주의사항을 다룹니다.

1. 가장 흔한 비용 증가 원인들

1.1 SELECT * 남용

❌ 위험한 예:

-- 1TB 테이블에서 필요한 것은 1개 컬럼인데 모든 컬럼을 스캔
SELECT * 
FROM `project.dataset.large_table`
WHERE user_id = 'specific_user';

-- 결과: 1TB 전체 스캔 → $5 비용 발생

✅ 올바른 예:

-- 필요한 컬럼만 선택
SELECT user_id, amount, transaction_date
FROM `project.dataset.large_table`
WHERE user_id = 'specific_user';

-- 결과: 필요한 컬럼만 스캔 → $0.1 비용 발생

1.2 중복 테이블 스캔

❌ 위험한 예:

-- 동일한 테이블을 여러 번 독립적으로 스캔
SELECT 
    (SELECT COUNT(*) FROM `project.dataset.events` WHERE event_type = 'click') as clicks,
    (SELECT COUNT(*) FROM `project.dataset.events` WHERE event_type = 'view') as views,
    (SELECT COUNT(*) FROM `project.dataset.events` WHERE event_type = 'purchase') as purchases;

-- 결과: 동일 테이블을 3번 스캔 → 3배 비용

✅ 올바른 예:

-- 한 번만 스캔해서 조건부 집계
SELECT 
    COUNTIF(event_type = 'click') as clicks,
    COUNTIF(event_type = 'view') as views,
    COUNTIF(event_type = 'purchase') as purchases
FROM `project.dataset.events`;

-- 결과: 테이블을 1번만 스캔 → 1/3 비용

1.3 파티션 프루닝 실패

❌ 위험한 예:

-- 파티션 컬럼에 함수 적용으로 프루닝 실패
SELECT *
FROM `project.dataset.sales`
WHERE EXTRACT(YEAR FROM sales_date) = 2024;

-- 결과: 모든 파티션 스캔 → 전체 테이블 비용

⚠️ 더 위험한 예:

-- 타임존 함수로 인한 파티션 프루닝 실패
SELECT *
FROM `project.dataset.events`
WHERE DATE(DATETIME(event_timestamp, "Asia/Seoul")) = '2024-01-01';

-- 결과: 타임존 변환으로 인해 모든 파티션 스캔

✅ 올바른 예:

-- 파티션 컬럼을 직접 필터링
SELECT *
FROM `project.dataset.sales`
WHERE sales_date >= '2024-01-01' 
  AND sales_date < '2025-01-01';

-- 결과: 2024년 파티션만 스캔 → 대폭 비용 절약

2. 예상치 못한 전체 테이블 스캔

2.1 LIKE 패턴의 함정

❌ 매우 위험:

-- 와일드카드가 앞에 있으면 인덱스/클러스터링 무효화
SELECT *
FROM `project.dataset.products`
WHERE product_name LIKE '%phone%';

-- 결과: 클러스터링이 되어 있어도 전체 테이블 스캔

⚠️ 부분적으로 위험:

-- 뒤쪽 와일드카드는 일부 최적화 가능하지만 여전히 위험
SELECT *
FROM `project.dataset.products`
WHERE product_name LIKE 'iPhone%';

-- 결과: 일부 최적화되지만 여전히 큰 비용 가능

2.2 REGEX 함수의 비용

❌ 매우 높은 비용:

-- 정규식은 항상 전체 스캔
SELECT *
FROM `project.dataset.logs`
WHERE REGEXP_CONTAINS(message, r'error|warning|critical');

-- 결과: 전체 테이블을 모두 스캔하여 정규식 검사

2.3 JSON 필드 검색

❌ 예상보다 높은 비용:

-- JSON 필드 내부 검색은 비용이 많이 듦
SELECT *
FROM `project.dataset.events`
WHERE JSON_EXTRACT_SCALAR(properties, '$.user_type') = 'premium';

-- 결과: properties 컬럼 전체 스캔 + JSON 파싱 비용

3. 조인으로 인한 비용 증가

3.1 카르테시안 곱 (CROSS JOIN)

❌ 극도로 위험:

-- 실수로 조인 조건을 빠뜨린 경우
SELECT *
FROM `project.dataset.users` u
CROSS JOIN `project.dataset.events` e  -- 조인 조건 없음
WHERE u.user_id = 'specific_user';

-- 결과: users(1만) × events(1억) = 1조 행 생성 → 막대한 비용

3.2 비효율적인 조인 순서

❌ 비효율적:

-- 큰 테이블을 먼저 조인
SELECT *
FROM `project.dataset.large_events` e  -- 1억 행
JOIN `project.dataset.small_users` u   -- 1만 행
  ON e.user_id = u.user_id
WHERE u.user_type = 'premium';         -- 1000명

-- 결과: 대용량 테이블부터 읽어서 불필요한 스캔 발생

✅ 더 효율적:

-- 작은 테이블부터 필터링 후 조인
WITH premium_users AS (
  SELECT user_id
  FROM `project.dataset.small_users`
  WHERE user_type = 'premium'  -- 1000명으로 먼저 축소
)
SELECT *
FROM premium_users u
JOIN `project.dataset.large_events` e
  ON e.user_id = u.user_id;

-- 결과: 필터링된 작은 결과셋과 조인하여 스캔 최소화

4. CTE와 서브쿼리의 함정

4.1 중복 계산되는 CTE

❌ 비용 중복:

-- 같은 CTE를 여러 번 참조하면 각각 계산될 수 있음
WITH expensive_calculation AS (
  SELECT user_id, SUM(amount) as total
  FROM `project.dataset.large_transactions`  -- 1억 건
  GROUP BY user_id
)
SELECT 
  a.total,
  b.total * 0.1 as tax
FROM expensive_calculation a
CROSS JOIN expensive_calculation b  -- CTE가 두 번 실행될 수 있음
WHERE a.user_id = b.user_id;

-- 결과: 1억 건 데이터를 두 번 스캔할 수 있음

4.2 상관 서브쿼리의 비용

❌ 극도로 비싼 패턴:

-- 상관 서브쿼리로 인한 중첩 스캔
SELECT 
  user_id,
  (SELECT COUNT(*) 
   FROM `project.dataset.events` e 
   WHERE e.user_id = u.user_id  -- 각 사용자마다 events 테이블 스캔
   AND e.event_date >= '2024-01-01') as event_count
FROM `project.dataset.users` u;

-- 결과: users 수 × events 테이블 전체 스캔
-- 사용자 1만 명이면 events 테이블을 1만 번 스캔

✅ 효율적인 대안:

-- 조인으로 변경하여 각 테이블을 한 번만 스캔
SELECT 
  u.user_id,
  COUNT(e.user_id) as event_count
FROM `project.dataset.users` u
LEFT JOIN `project.dataset.events` e
  ON e.user_id = u.user_id
  AND e.event_date >= '2024-01-01'
GROUP BY u.user_id;

5. 집계 함수의 숨겨진 비용

5.1 DISTINCT의 높은 비용

⚠️ 예상보다 비싸:

-- DISTINCT는 전체 데이터를 정렬/해시해야 함
SELECT COUNT(DISTINCT user_id)
FROM `project.dataset.events`;

-- 결과: 전체 테이블 스캔 + 중복 제거 연산 비용

5.2 윈도우 함수의 파티션 비용

⚠️ 파티션 크기에 따라 비용 급증:

-- 파티션이 너무 크면 메모리 부족으로 디스크 사용
SELECT 
  user_id,
  event_timestamp,
  ROW_NUMBER() OVER (
    PARTITION BY user_id  -- 사용자당 이벤트가 매우 많다면?
    ORDER BY event_timestamp
  ) as rn
FROM `project.dataset.events`;

-- 결과: 일부 활성 사용자의 경우 수백만 행을 메모리에서 처리

6. 파티션 관련 함정들

6.1 파티션 경계 실수

❌ 파티션 누락:

-- 파티션 경계를 잘못 설정하여 원하지 않는 파티션까지 스캔
SELECT *
FROM `project.dataset.events`
WHERE _PARTITIONTIME >= TIMESTAMP('2024-01-01')
  AND _PARTITIONTIME < TIMESTAMP('2024-01-31');  -- 1월 31일 제외됨

-- 결과: 1월 31일 데이터 누락

6.2 NULL 파티션의 함정

⚠️ 예상치 못한 스캔:

-- NULL 값이 있는 경우 __NULL__ 파티션도 함께 스캔됨
SELECT *
FROM `project.dataset.events`
WHERE event_date IS NOT NULL;

-- 결과: 모든 날짜 파티션 + __NULL__ 파티션까지 스캔

6.3 타임존 혼동

❌ 잘못된 타임존으로 인한 전체 스캔:

-- 파티션은 UTC로 되어 있는데 KST로 필터링
SELECT *
FROM `project.dataset.events`  -- UTC 기준 파티션
WHERE DATE(event_timestamp, 'Asia/Seoul') = '2024-01-01';

-- 결과: 타임존 변환으로 인해 파티션 프루닝 실패

7. 클러스터링 관련 주의사항

7.1 클러스터링 순서의 중요성

❌ 잘못된 클러스터링 순서:

-- 높은 카디널리티 컬럼을 먼저 클러스터링
CREATE TABLE `project.dataset.events`
CLUSTER BY user_id, country;  -- user_id(백만개), country(10개)

-- 쿼리 시:
SELECT * FROM `project.dataset.events`
WHERE country = 'Korea';

-- 결과: country 필터링 효과가 떨어짐 → 불필요한 스캔 증가

7.2 클러스터링 무효화

⚠️ 클러스터링이 도움 되지 않는 경우:

-- 클러스터링 컬럼에 함수 적용
SELECT *
FROM `project.dataset.events`  -- region으로 클러스터링됨
WHERE UPPER(region) = 'ASIA';

-- 결과: 함수로 인해 클러스터링 효과 상실

8. DML 작업의 숨겨진 비용

8.1 UPDATE의 전체 테이블 재작성

❌ 예상보다 훨씬 비싼 UPDATE:

-- 작은 범위만 업데이트하는 것 같지만...
UPDATE `project.dataset.large_table`
SET status = 'processed'
WHERE id IN (SELECT id FROM temp_processed_ids);

-- 결과: UPDATE 대상이 적어도 전체 테이블을 다시 쓸 수 있음
-- 1TB 테이블이면 1TB 전체에 대한 쓰기 비용 발생

8.2 DELETE의 파티션 영향

⚠️ 파티션 전체 재작성:

-- 일부 행만 삭제하는 것 같지만...
DELETE FROM `project.dataset.events`
WHERE user_id IN ('user1', 'user2', 'user3')
  AND event_date = '2024-01-01';

-- 결과: 2024-01-01 파티션 전체가 재작성될 수 있음

9. 스캔 비용 예측의 함정

9.1 DRY RUN과 실제 비용의 차이

⚠️ DRY RUN으로 예측이 어려운 경우:

-- DRY RUN에서는 정확한 예측이 어려운 쿼리들
SELECT *
FROM `project.dataset.clustered_table`
WHERE complex_condition = 'value'  -- 클러스터링 효과 예측 어려움
  AND REGEXP_CONTAINS(text_field, r'pattern');  -- 정규식 비용 예측 불가

9.2 실행 계획과 실제 실행의 차이

⚠️ 옵티마이저 예측 실패:

-- 옵티마이저가 잘못 예측할 수 있는 케이스
SELECT *
FROM `project.dataset.table1` t1
JOIN `project.dataset.table2` t2
  ON t1.key = t2.key
WHERE t1.filter_column = 'rare_value';  -- 실제로는 매우 적은 행

-- 예상: 작은 조인 비용
-- 실제: 옵티마이저가 selectivity를 잘못 예측하여 큰 비용 발생

10. 모니터링과 알람 설정

10.1 쿼리 비용 급증 감지

-- 일일 쿼리 비용 모니터링
SELECT 
  DATE(creation_time) as query_date,
  job_id,
  user_email,
  total_bytes_processed / POW(10, 12) as tb_processed,
  total_bytes_processed / POW(10, 12) * 5 as estimated_cost_usd
FROM `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
  AND total_bytes_processed > POW(10, 11)  -- 100GB 이상만
ORDER BY total_bytes_processed DESC;

10.2 예상치 못한 전체 테이블 스캔 감지

-- 파티션 프루닝 실패 감지
SELECT 
  job_id,
  query,
  total_bytes_processed / POW(10, 9) as gb_processed,
  total_bytes_billed / POW(10, 9) as gb_billed,
  creation_time
FROM `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 1 DAY)
  AND query LIKE '%WHERE%'  -- 필터가 있는 쿼리
  AND total_bytes_processed > POW(10, 10)  -- 그런데 10GB 이상 스캔
ORDER BY total_bytes_processed DESC;

11. 비용 제한 설정

11.1 프로젝트 수준 쿼리 비용 제한

# 프로젝트 전체에 일일 쿼리 비용 한도 설정 (100달러)
bq update --transfer_config \
    --daily_query_cost_limit=100 \
    --project_id=my-project

11.2 쿼리별 비용 제한

-- 개별 쿼리에 최대 스캔 바이트 제한 설정
SELECT *
FROM `project.dataset.large_table`
WHERE date_column >= '2024-01-01';

-- bq query --max_bytes_billed=1000000000 # 1GB 제한

12. 응급 대응 방법

12.1 실행 중인 비싼 쿼리 중단

# 실행 중인 작업 확인
bq ls -j --max_results=10

# 특정 작업 취소
bq cancel [JOB_ID]

12.2 비용 급증 원인 분석

-- 최근 비싼 쿼리들 분석
SELECT 
  job_id,
  user_email,
  query,
  total_bytes_processed / POW(10, 12) as tb_processed,
  TIMESTAMP_DIFF(end_time, start_time, SECOND) as duration_seconds,
  creation_time
FROM `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 1 HOUR)
  AND total_bytes_processed > POW(10, 11)  -- 100GB 이상
ORDER BY creation_time DESC;

결론

BigQuery의 스캔 비용은 쿼리 작성 방식에 따라 몇 배에서 수천 배까지 차이날 수 있습니다. 특히:

  1. SELECT *는 절대 금지 - 필요한 컬럼만 선택
  2. 파티션 필터링은 필수 - 함수 적용 시 프루닝 실패 주의
  3. 중복 스캔 방지 - CTE와 조인 최적화
  4. 정규식과 LIKE 패턴 신중 사용 - 전체 테이블 스캔 위험
  5. DML 작업의 숨겨진 비용 - UPDATE/DELETE는 파티션 전체 재작성 가능
  6. 실시간 모니터링 필수 - 비용 급증을 즉시 감지

가장 중요한 것은 쿼리 실행 전 DRY RUN으로 비용을 미리 확인하고, 실행 후 실제 스캔 바이트를 점검하는 습관입니다. 예상과 다른 결과가 나오면 즉시 원인을 분석하고 쿼리를 개선해야 합니다.