본문 바로가기

7. 데이터 적재 - batch load부터 Storage Write API까지

·7분 읽기·
목차

지난 편까지는 데이터를 저장하고, 읽고, 뷰로 서빙하는 얘기를 주로 다뤘습니다. 이번 편에서는 데이터를 BigQuery에 넣는 방식을 다룹니다.

batch load는 파일을 통째로 넣는 방식이고, streaming은 legacy(tabledata.insertAll)와 신형 Storage Write API로 갈립니다. 각각의 장단점을 하나씩 알아보겠습니다.

1. 세 가지 적재 방식

1-1. 셋의 성격 대비

  • Batch load: 파일(CSV/JSON/Avro/Parquet/ORC)을 통째로 테이블에 밀어 넣는 방식. 적재 자체는 무료이고, 대용량 초기 적재나 정기 ETL 배치에 잘 맞습니다
  • Legacy streaming (`tabledata.insertAll`): REST + JSON 기반. 한 행씩 스트리밍으로 넣을 수 있지만 단가가 비싸고 exactly-once 보장이 없어서 신규 프로젝트에는 권장되지 않습니다
  • Storage Write API: gRPC + Protobuf 기반의 신형 스트리밍 API. legacy 대비 대략 절반 가격이고, 스트림 종류에 따라 at-least-once/exactly-once를 선택할 수 있습니다

셋은 서로 대체 관계가 아니라 각자 다른 지점을 맡습니다. 정기 배치는 batch load, 실시간·준실시간 적재는 Storage Write API 쪽이고, legacy streaming은 이 둘 사이 어정쩡한 자리라 새 파이프라인에는 잘 안 씁니다.


2. Batch load

2-1. 파일 → 테이블

batch load는 파일을 통째로 테이블에 밀어 넣습니다. 지원 포맷은 CSV/JSON(newline-delimited)/Avro/Parquet/ORC이고, 소스는 로컬 파일이나 Cloud Storage 둘 다 됩니다. 대용량이면 GCS를 경유하는 게 표준입니다.

bash
# CLI로 Parquet 파일을 로드
bq load \
  --source_format=PARQUET \
  project:dataset.events \
  gs://my-bucket/events/2026-07-19/*.parquet

Python 클라이언트에서도 API 한 번으로 끝납니다.

python
from google.cloud import bigquery

client = bigquery.Client()
job_config = bigquery.LoadJobConfig(
    source_format=bigquery.SourceFormat.PARQUET,
    write_disposition="WRITE_APPEND",
)
uri = "gs://my-bucket/events/2026-07-19/*.parquet"
load_job = client.load_table_from_uri(
    uri, "project.dataset.events", job_config=job_config
)
load_job.result()  # 완료까지 대기

write_disposition으로 append(WRITE_APPEND)/덮어쓰기(WRITE_TRUNCATE)/빈 테이블만 허용(WRITE_EMPTY)을 정합니다. daily 시간 파티션 테이블이라면 파티션 데코레이터(events$20260719 같은 형태)로 특정 파티션만 덮어쓸 수도 있습니다. 정수 범위 파티션이나 ingestion-time 파티션은 데코레이터 문법이 조금 달라서, 5편 파티셔닝 편을 참고하는 게 좋습니다.

2-2. Batch load는 왜 무료인가

BigQuery load는 슬롯을 쓰지 않고 별도 인프라에서 처리되기 때문에 compute 비용 자체가 0원입니다. 스토리지 비용만 결과 데이터 양에 따라 나옵니다.

무료라는 게 무제한이라는 뜻은 아니고 quota가 있습니다. 두 층위인데 값이 다릅니다.

  • 테이블당 하루 1,500회 (상향 불가) — load 뿐 아니라 그 테이블을 append/overwrite하는 copy·query까지 합산됩니다. 실무에서 훨씬 자주 걸리는 한도가 이쪽입니다
  • 프로젝트당 하루 100,000회 — 프로젝트 전체 load job 총합

특히 첫 번째가 자주 발목 잡습니다. 1분 단위로 잘게 쪼갠 파일 수천 개를 loop 돌면서 각각 bq load로 넣으면 1,500회 금방 채우고 파이프라인이 터집니다. 같은 대상이라면 와일드카드(`*.parquet`)나 URI 리스트로 한 번의 load job에 묶어 넣는 게 quota를 아끼는 표준 방식입니다.

대용량 초기 적재나 정기 ETL은 batch load로 처리하는 게 자연스럽습니다. 같은 양을 스트리밍 API로 밀어 넣으면 청구서가 크게 나갑니다.


3. Legacy streaming — tabledata.insertAll

3-1. REST + JSON, 한 행씩

tabledata.insertAll은 REST 엔드포인트에 JSON 페이로드를 보내 한 행씩(또는 소량 배치로) 삽입하는 옛날 방식입니다.

python
rows_to_insert = [
    {"event_id": "e1", "user_id": "u1", "event_ts": "2026-07-19T09:00:00Z"},
    {"event_id": "e2", "user_id": "u2", "event_ts": "2026-07-19T09:00:01Z"},
]
errors = client.insert_rows_json("project.dataset.events", rows_to_insert)

동작은 단순하고 언어 라이브러리마다 다 있어서 도입 장벽이 낮습니다. 문제는 다른 데 있습니다.

3-2. 두 가지 문제

비쌉니다. legacy streaming은 $0.010 per 200MB(십진법 MB 기준)로 과금되고, 그것도 행당 최소 1KB로 반올림됩니다. 작은 이벤트 로그를 초당 수천 건씩 넣는 워크로드라면 실제 데이터 양보다 훨씬 많이 청구됩니다. 뒤에서 볼 Storage Write API 가격은 GiB(이진법) 단위라 두 단위가 다르지만, 절대 금액 차이는 여전히 대략 두 배 정도입니다.

exactly-once 보장이 없습니다. 클라이언트가 재시도하면 같은 행이 중복 삽입될 수 있고, 이걸 방어하려면 애플리케이션 쪽에서 dedup 로직을 얹거나 나중에 배치로 dedup 쿼리를 돌려야 합니다.

deprecated된 API는 아니라서 기존 파이프라인은 계속 돌아가지만, 새로 짤 때는 사실상 Storage Write API 쪽으로 넘어가는 게 표준이 됐습니다.


4. Storage Write API 개요

Storage Write API는 gRPC 스트리밍 위에 Protobuf로 페이로드를 실어 보내는 신형 API입니다. 데이터 인코딩이 컴팩트하고 스트리밍 커넥션을 재사용해서 지연도 낮습니다.

어떤 스트림을 쓰느냐에 따라 데이터가 언제 보이는지, 중복이 어떻게 처리되는지가 완전히 달라지고, 배치 원자성이 필요한 경우까지 별도 스트림으로 갈라져 있습니다. legacy streaming이 하나로 다 처리하려다 어중간해진 자리를 네 갈래로 쪼갠 형태입니다.


5. Storage Write API의 네 가지 스트림

5-1. Default stream

별도로 스트림을 만들지 않고 테이블마다 항상 존재하는 default stream에 그냥 append하는 방식입니다.

  • 가시성: append 즉시 조회 가능
  • 보장: at-least-once (재시도 시 중복 가능)
  • 용도: 스트리밍 이벤트 수집, 로그 파이프라인처럼 중복이 있어도 크게 문제 없는 워크로드

스트리밍 대부분의 경우 default stream이 첫 선택지가 됩니다. 관리할 스트림 오브젝트가 없고 여러 writer가 같은 테이블에 동시에 밀어 넣어도 충돌이 없어서 운영이 단순합니다. Committed로 가면 offset 관리 로직을 writer 쪽에 얹어야 하고 스트림 생명주기(생성, 재사용, 정리)까지 챙겨야 하는데, 이 비용이 필요한 경우는 사실 흔치 않습니다. 이벤트 대부분이 그렇듯 다운스트림에서 MERGE나 window dedup으로 정리 가능하면 default가 편합니다. 중복이 절대 안 되는 결제·audit 같은 워크로드에서만 committed로 넘어갑니다.

5-2. Committed stream

exactly-once가 필요하면 committed stream을 만들어서 씁니다.

  • 가시성: append 즉시 조회 가능
  • 보장: exactly-once (스트림 내부)
  • 용도: 결제 이벤트, audit 로그, 정확한 중복 방지가 필요한 워크로드

동작 원리는 record offset 기반입니다. 클라이언트가 AppendRows를 호출할 때 "다음에 넣을 offset이 N이다"라고 함께 지정하면, BigQuery는 서버 쪽 next offset과 값이 일치할 때만 append를 수행합니다. 클라이언트가 네트워크 문제로 재시도했는데 서버가 이미 그 offset을 받아 처리했으면, 그 재시도는 offset 불일치로 무시됩니다. 재시도 안전(idempotent)이 offset 매칭으로 성립합니다.

한 committed stream은 하나의 writer가 순차적으로 append하는 모델입니다. 여러 writer가 같은 stream에 동시에 offset을 지정하면 충돌이 나기 때문에, 실무에서는 writer마다 별도 stream을 만들어 소스 파티션(예: Kafka 토픽 파티션이나 shard 단위) 하나씩 담당하게 나눠 처리합니다.

5-3. Pending stream

배치성 적재에서 all-or-nothing이 필요할 때 pending stream을 씁니다.

  • 가시성: FinalizeWriteStream + BatchCommitWriteStreams 호출 전까지 조회 불가
  • 보장: exactly-once + 원자적 commit
  • 용도: 대량 배치 적재 중 all-or-nothing이 필요한 경우

pending stream에 append한 데이터는 스트림을 finalize + commit하기 전까지는 다른 세션에서 안 보입니다. commit 호출이 원자적이라, 그 시점 이전까지는 아무것도 없었던 것처럼 취급되고 commit 순간에 전체가 한 번에 나타납니다. 배치가 중간에 실패하면 그냥 스트림을 버리면 되고, 부분 반영된 상태로 남는 일이 없습니다.

5-4. Buffered stream

buffered stream은 append와 조회 가시성이 분리된 특수 형태입니다. append한 데이터가 곧바로 보이지 않고, 별도의 FlushRows 호출로 offset cursor를 앞으로 밀어야 그 지점까지만 조회 가능해집니다. 가시성을 세밀하게 제어하는 형태이고, 실무에서 직접 쓰는 경우는 거의 없습니다. Apache Beam의 BigQuery I/O 커넥터가 내부적으로 사용하는 정도라, Beam 밖에서는 존재만 알아둬도 됩니다.

5-5. 네 스트림 요약

스트림가시성보장대표 용도
Default즉시at-least-once스트리밍 이벤트, 로그
Committed즉시exactly-once (offset)결제, audit, dedup 필수
Pendingcommit 후원자적 exactly-once배치성 실시간 적재
BufferedFlushRows로 전진한 offset까지가시성 분리 제어Apache Beam 내부용

5-6. 스트림의 비용

가격은 $0.025 per GiB(us multi-region 기준)입니다. legacy streaming 대비 대략 절반이고, 최소 row size 반올림도 없어서 작은 이벤트 로그 워크로드에서 특히 유리합니다. 매월 처음 2TiB는 무료로 제공됩니다.


6. Exactly-once의 진짜 조건

6-1. 스트림 안에서만 보장

committed stream의 exactly-once는 하나의 스트림 안에서만 성립합니다. 여러 스트림에서 같은 논리적 이벤트를 보내면 스트림 사이에는 dedup이 되지 않습니다. 그래서 exactly-once가 정말 필요하면 이벤트 소스에서 스트림을 하나로 고정하거나, 파티션 키 → 스트림 매핑을 결정론적으로 유지해야 합니다.

6-2. 재시도의 정공법

Committed stream을 쓰는 클라이언트의 재시도 흐름입니다.

1. AppendRows(offset=100, rows=[A, B, C]) 호출
2. 네트워크 타임아웃 → 응답 못 받음
3. 재시도: AppendRows(offset=100, rows=[A, B, C])
   - 서버가 이미 100을 받아 처리했으면 → 무시 (idempotent)
   - 서버가 아직 안 받았으면 → 정상 처리
4. 다음 호출: AppendRows(offset=103, rows=[D, E])

여기서 클라이언트가 실수로 offset을 관리 안 하고 매번 next 값만 서버에 물어서 쓰면, 재시도 시 서버 상태에 따라 offset이 앞으로 튀거나 뒤로 밀려서 exactly-once가 깨집니다. offset은 어디까지나 클라이언트 로컬 상태로 잡고 있어야 합니다.

또 하나 자주 놓치는 지점: committed/pending stream은 트래픽이 없으면 시스템이 자동으로 정리합니다. committed는 약 3일, buffered는 7일 정도가 TTL이고, pending은 finalize 안 하고 방치하면 active stream 한도에 걸릴 수 있습니다. 배치 잡이 실패해서 pending stream이 남았다면 finalize 대신 delete로 명시적으로 치워두는 편이 안전합니다.

6-3. legacy streaming과의 차이

tabledata.insertAll은 클라이언트가 insertId를 지정하면 서버가 그 ID 기준으로 dedup을 시도해 주긴 했습니다. 다만 이건 best-effort라 지역이나 조건에 따라 아예 적용되지 않기도 하고, 대량 워크로드에서는 특히 자주 깨졌습니다. Storage Write API의 offset 기반 exactly-once는 그와 달리 계약 조건이 명확해서 재시도 로직이 훨씬 단순해집니다.


7. 언제 무엇을 쓰나

한 표로 놓으면 대략 이렇게 갈립니다.

상황선택
정기 batch ETL, 대용량 초기 적재Batch load (무료)
실시간 이벤트 로그, 중복 허용Storage Write API — Default stream
결제/audit처럼 정확한 중복 방지Storage Write API — Committed stream
배치성 실시간, all-or-nothing 필요Storage Write API — Pending stream
Apache Beam 파이프라인Storage Write API — Buffered (Beam 내부)
기존 파이프라인 유지보수Legacy streaming도 계속 동작 (새로 짜지는 X)

한 파이프라인 안에서 여러 방식을 섞어 쓰는 경우도 흔합니다. 초기 히스토리는 batch load로 밀어 넣고, 그 뒤로는 Storage Write API default stream으로 실시간 적재를 이어 붙이는 식입니다. 실무에서는 오히려 갭보다 overlap을 조금 두고 dedup으로 정리하는 패턴이 안전합니다. batch cutoff 시점과 스트리밍 시작 시점이 정확히 맞물리기 어려워서 살짝 겹치게 잡고, 나머지는 MERGE나 window dedup이 처리하도록 두는 편이 실전에서 사고를 줄입니다.


마무리

BigQuery에 데이터를 넣는 방식은 워크로드 성격에 따라 갈라집니다. 대용량이거나 정기 배치라면 batch load가 무료라는 이점을 살릴 수 있고, 실시간이 필요하면 Storage Write API로 자연스럽게 넘어옵니다. 네 스트림 중에 어느 걸 고르느냐에 따라 downstream에서 감당해야 하는 재시도·dedup 부담이 달라지니, 처음에 계약 조건을 잘 골라 두는 게 편합니다.

다음 편에서는 이 위에 얹히는 CDC(Change Data Capture)와 스트리밍 분석을 다룹니다. Storage Write API의 default stream을 base로 삼는 CDC 시나리오, max_staleness 옵션의 역할, 그리고 Datastream 같은 매니지드 옵션의 위치를 짚어보겠습니다.