스캔 비용 주의사항
비용 함정 피하기
개요
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의 스캔 비용은 쿼리 작성 방식에 따라 몇 배에서 수천 배까지 차이날 수 있습니다. 특히:
- SELECT *는 절대 금지 - 필요한 컬럼만 선택
- 파티션 필터링은 필수 - 함수 적용 시 프루닝 실패 주의
- 중복 스캔 방지 - CTE와 조인 최적화
- 정규식과 LIKE 패턴 신중 사용 - 전체 테이블 스캔 위험
- DML 작업의 숨겨진 비용 - UPDATE/DELETE는 파티션 전체 재작성 가능
- 실시간 모니터링 필수 - 비용 급증을 즉시 감지
가장 중요한 것은 쿼리 실행 전 DRY RUN으로 비용을 미리 확인하고, 실행 후 실제 스캔 바이트를 점검하는 습관입니다. 예상과 다른 결과가 나오면 즉시 원인을 분석하고 쿼리를 개선해야 합니다.