데이터 엔지니어 인터뷰 — BigQuery

구조

BigQuery의 아키텍처를 설명해보세요

저장과 연산이 완전히 분리되어 있습니다. 데이터는 Colossus(분산 파일시스템)에 컬럼너 포맷(Capacitor)으로 저장되고, 쿼리는 Dremel 엔진이 처리하며, 그 사이를 Jupiter 네트워크가 연결합니다.

이 구조 덕분에 클러스터를 미리 띄워둘 필요가 없고, 저장 비용과 연산 비용이 독립적으로 과금됩니다. 반대로 연산 노드에 데이터를 붙여두는 캐시 효과가 없어서, 매번 스토리지에서 읽어옵니다. 그래서 스캔량 관리가 성능이자 비용입니다.

슬롯(slot)은 무엇인가요

쿼리를 실행하는 연산 단위입니다. 쿼리가 실행 계획으로 쪼개지면 각 단계가 슬롯에 배정되어 병렬 처리됩니다.

과금 모델이 둘입니다.

모델 과금 기준 적합한 경우
On-demand 스캔한 바이트 사용량이 불규칙, 소규모
Capacity (Editions) 예약한 슬롯 수 × 시간 사용량이 크고 예측 가능

Capacity 모델에서는 스캔량이 아무리 커도 추가 비용이 없는 대신, 슬롯이 부족하면 쿼리가 대기합니다. 예약(reservation)을 팀·워크로드별로 나눠 중요한 배치가 애드혹 쿼리에 밀리지 않게 하는 것이 운영 포인트입니다.

파티셔닝과 클러스터링

파티셔닝과 클러스터링의 차이는 무엇인가요

파티셔닝은 테이블을 물리적으로 나눕니다. 조건에 맞지 않는 파티션은 아예 읽지 않습니다(파티션 프루닝).

  • 시간 단위(일/시간/월/연)
  • 정수 범위
  • 인제스트 시각(_PARTITIONTIME)

클러스터링은 파티션 안에서 지정한 컬럼 순으로 데이터를 정렬해 블록을 만듭니다. 조건에 맞는 블록만 읽어 스캔량을 줄입니다.

CREATE TABLE ds.events (
  event_time TIMESTAMP,
  user_id    STRING,
  country    STRING,
  payload    JSON
)
PARTITION BY DATE(event_time)
CLUSTER BY country, user_id;

파티션은 개수 제한(테이블당 4,000개)이 있고 카디널리티가 낮아야 하지만, 클러스터링은 카디널리티가 높은 컬럼에 적합합니다. 보통 날짜로 파티션, 자주 필터링하는 컬럼으로 클러스터링하는 조합을 씁니다.

클러스터링 컬럼의 순서가 왜 중요한가요

정렬 순서대로 프루닝이 적용되므로, 선행 컬럼 없이는 효과가 약합니다. 복합 인덱스와 같은 원리입니다.

CLUSTER BY country, user_id라면 country 조건만으로도 효과가 있지만, user_id만으로 필터링하면 효과가 크게 떨어집니다. 자주 쓰는 조건을 앞에 둬야 합니다.

파티션 필터를 강제할 수 있나요

require_partition_filter를 켜면 파티션 조건 없는 쿼리를 거부합니다. 실수로 전체 스캔이 도는 것을 막는 안전장치입니다.

ALTER TABLE ds.events SET OPTIONS (require_partition_filter = true);

비용

쿼리 비용을 줄이는 방법을 설명해보세요

On-demand 모델에서는 읽은 컬럼의 바이트 수로 과금됩니다. 행 수가 아니라 컬럼 크기라는 점이 중요합니다.

  • SELECT * 금지 — 필요한 컬럼만. 컬럼너 저장이라 안 읽은 컬럼은 과금되지 않습니다
  • 파티션 조건 필수 — 파티션 컬럼을 가공하면 프루닝이 안 됩니다
  • LIMIT은 비용을 줄이지 않습니다 — 스캔은 이미 다 하고 결과만 자릅니다
  • 미리보기 활용 — 테이블 내용을 볼 때는 tabledata.list(미리보기)나 콘솔 프리뷰가 무료입니다
  • 결과 캐시 — 동일 쿼리는 24시간 캐시되어 무료. 단 CURRENT_TIMESTAMP() 같은 비결정적 함수가 있으면 캐시되지 않습니다
  • 물리화(materialized view) — 반복되는 집계를 미리 계산

실행 전에 스캔량을 확인하는 습관이 중요합니다.

bq query --dry_run --use_legacy_sql=false 'SELECT ...'

콘솔에서도 쿼리 입력 시 오른쪽 위에 예상 스캔량이 표시됩니다.

스토리지 비용은 어떻게 줄이나요

90일 이상 수정되지 않은 파티션은 자동으로 long-term storage 요금(약 절반)이 적용됩니다. 파티션 단위로 판정되므로, 오래된 파티션을 굳이 건드리지 않는 것이 유리합니다.

전체 테이블을 매일 덮어쓰는 방식은 모든 파티션의 수정 시각을 갱신해 long-term 할인을 날립니다. 증분 적재가 비용 면에서도 유리한 이유입니다.

파티션 만료(partition_expiration_days)를 걸어 오래된 데이터를 자동 삭제하는 것도 방법입니다.

비용이 갑자기 늘었습니다. 어떻게 원인을 찾나요

INFORMATION_SCHEMA.JOBS를 조회하면 사용자·쿼리별 스캔량을 볼 수 있습니다.

SELECT
  user_email,
  job_id,
  total_bytes_processed / POW(1024, 4) AS tb_processed,
  query
FROM `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
  AND job_type = 'QUERY'
ORDER BY total_bytes_processed DESC
LIMIT 20;

흔한 원인은 대시보드의 자동 새로고침, 파티션 조건 없는 애드혹 쿼리, 그리고 CDC 업서트입니다. MERGE는 대상 파티션 전체를 다시 쓰므로 잦은 소량 업서트가 비용을 크게 키웁니다.

적재

데이터를 적재하는 방법에는 무엇이 있나요

방법 특징 비용
Load job (배치) GCS에서 배치 적재 무료
Storage Write API 스트리밍, 정확히 한 번 지원 유료(처리량 기준)
Legacy streaming insert 구 방식 유료, 권장하지 않음
Federated query GCS·외부 소스를 외부 테이블로 조회 스캔량
Datastream CDC 복제 별도

배치 로드가 무료라는 점이 중요합니다. 지연 요구가 분 단위라면 GCS에 모았다가 배치로 넣는 편이 스트리밍보다 훨씬 저렴합니다.

Storage Write API와 legacy streaming insert의 차이는 무엇인가요

Storage Write API가 후속 버전이고 여러 면에서 낫습니다.

  • 정확히 한 번 시맨틱 지원(스트림 오프셋 기반)
  • 더 저렴하고 처리량이 큼
  • 커밋 전까지 데이터가 보이지 않는 pending 모드 지원 — 배치처럼 원자적으로 반영 가능

legacy streaming insert는 스트리밍 버퍼에 들어간 데이터가 일정 시간 동안 DML로 수정되지 않는 제약이 있었습니다.

CDC를 BigQuery에 반영할 때 무엇을 고려하나요

MERGE로 업서트하는 것이 일반적인데, 주의점이 있습니다.

소스에 같은 키가 중복되면 MERGE가 실패합니다. CDC 피드에는 한 키의 여러 변경이 들어오므로 키별 최신 1건만 남겨야 합니다.

MERGE INTO target t
USING (
  SELECT * EXCEPT(rn) FROM (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY id ORDER BY ts DESC) AS rn
    FROM staging_changes
  ) WHERE rn = 1
) s
ON t.id = s.id
WHEN MATCHED AND s.op = 'DELETE' THEN DELETE
WHEN MATCHED THEN UPDATE SET ...
WHEN NOT MATCHED AND s.op != 'DELETE' THEN INSERT ...;

비용 측면에서는 MERGE가 대상 파티션을 재작성하므로, 파티션을 잘 잡아 영향 범위를 좁히는 것이 중요합니다. 파티션 없이 큰 테이블에 MERGE를 자주 돌리면 비용이 급격히 늘어납니다.

지연 요구가 느슨하다면 변경분을 append로만 쌓고, 조회 시점에 최신 상태를 뽑는 방식(또는 주기적으로 스냅샷 생성)이 훨씬 저렴합니다.

쿼리 작성

중첩·반복 필드(STRUCT, ARRAY)를 어떻게 다루나요

BigQuery는 중첩 구조를 네이티브로 지원합니다. 정규화해서 조인하는 대신 배열로 품고 있는 편이 성능·비용에 유리한 경우가 많습니다.

-- 배열 펼치기
SELECT o.order_id, item.sku, item.qty
FROM orders o, UNNEST(o.items) AS item;

-- 배열 안에서 집계
SELECT order_id,
       (SELECT SUM(qty) FROM UNNEST(items)) AS total_qty
FROM orders;

UNNEST 없이 배열 컬럼을 그대로 두면 스캔 대상에서 제외되므로, 필요할 때만 펼치면 비용을 아낄 수 있습니다.

성능이 안 나오는 쿼리는 어떻게 진단하나요

실행 세부정보(Execution details)에서 단계별 소요 시간과 처리량을 봅니다. 특히 확인할 것들입니다.

  • 슬롯 대기 시간 — 예약 슬롯 부족
  • 셔플 바이트 — 조인·집계에서 데이터 이동량이 큰지
  • 특정 단계의 max vs avg 시간 차이 — 크게 벌어지면 데이터 skew

skew는 조인 키에 특정 값(NULL 포함)이 몰릴 때 생깁니다. 미리 걸러내거나 키를 분산시켜야 합니다.

APPROX_COUNT_DISTINCT 같은 근사 함수도 큰 도움이 됩니다. 정확한 값이 꼭 필요하지 않다면 COUNT(DISTINCT)보다 훨씬 가볍습니다.

뷰, 물리화 뷰, 테이블을 어떻게 구분해 쓰나요

  • — 쿼리를 저장한 것. 조회할 때마다 원본을 스캔합니다. 비용 절감 효과 없음
  • 물리화 뷰(materialized view) — 결과를 미리 계산해 저장하고 증분 갱신. 원본이 바뀌면 자동 반영. 집계 패턴이 고정적일 때 효과적
  • 테이블 — 배치로 만들어 두는 것. 자유롭지만 갱신을 직접 관리

물리화 뷰는 지원하는 쿼리 형태에 제약이 있습니다(집계 함수 종류, 조인 제한). 복잡한 변환은 예약 쿼리로 테이블을 만드는 편이 현실적입니다.

관련 글