LLM WikiAccess-protected knowledge portal
← 스터디 홈
121편 · 약 16분

Apache Parquet 내부 구조: 열 통계·인코딩·블룸 필터로 데이터 레이크 읽기 비용을 줄이는 방법

요약

Apache Parquet는 데이터 레이크의 사실상 표준 파일 포맷이다. Iceberg, Delta Lake, Hudi가 모두 Parquet를 기본 데이터 파일 포맷으로 사용하고, Spark, Trino, DuckDB, ClickHouse가 모두 네이티브로 읽는다.

그런데 Parquet를 쓰는 방법과 읽히는 방법에 대해 내부 구조를 이해하고 쓰는 팀은 많지 않다. 포맷 내부에는 쿼리 엔진이 불필요한 디스크 읽기를 건너뛸 수 있도록 설계된 세 가지 메커니즘이 있다.

  1. 행 그룹 수준 통계(min/max/null_count): 행 그룹 전체를 스킵할 수 있는 기반
  2. 페이지 수준 Column Index: 행 그룹 내 개별 페이지를 정밀하게 스킵
  3. Bloom Filter: 동등 조건(equality predicate)에 대한 확률적 필터

이 세 가지를 올바르게 활용하면 같은 쿼리에서 읽어야 하는 데이터양을 10배 이상 줄일 수 있다. 이 글은 Parquet 내부 구조를 파일 레이아웃부터 인코딩 방식, 통계 활용, 블룸 필터까지 운영자 관점에서 설명한다.


파일 구조 계층

Apache Parquet 파일 구조 Parquet File 4 bytes: magic "PAR1" Row Group 1 (기본 128MB) Column Chunk: user_id Dictionary Page (고유값 목록) Data Page v2 (RLE_DICTIONARY 인코딩) Data Page v2 ... 통계: min=1000 max=9999 null_count=0 distinct=1024 Column Chunk: event_time Data Page v2 (DELTA_BINARY_PACKED) Data Page v2 ... Data Page v2 ... 통계: min=2026-01-01 max=2026-01-31 null_count=0 Column Chunk: country (+ Bloom Filter) Dictionary Page Data Page v2 (RLE_DICTIONARY) 🔎 Bloom Filter (Split Block, FPP=0.05) 통계: min="AU" max="ZA" null_count=5 distinct≈180 Row Group 2 (기본 128MB) … Row Group N … File Footer (FileMedataData - Thrift 직렬화) • 스키마 정의 (Parquet Schema) • 모든 Row Group의 통계 (min/max/null_count) — 행 그룹 레벨 스킵에 사용 • Column Index (페이지별 min/max) — 페이지 레벨 스킵에 사용 (Parquet 2.10+) • Offset Index (페이지 파일 오프셋) — 특정 페이지 직접 읽기용 • Bloom Filter 오프셋 (각 Column Chunk 내) Footer 길이 (4 bytes) + 4 bytes: magic "PAR1"
Apache Parquet 파일 내부 구조

핵심 개념

행 그룹(Row Group): 파일의 주요 분할 단위다. 기본 크기는 128MB(Spark 기준)이며, 같은 데이터셋이라도 행 그룹 크기에 따라 쿼리 성능이 달라진다. 크면 클수록 통계의 선택성이 낮아지고, 작으면 메타데이터 오버헤드가 늘어난다.

열 청크(Column Chunk): 하나의 행 그룹 내에서 한 열의 데이터 전체다. 열 저장(columnar) 특성상 쿼리가 필요한 열만 읽을 수 있다.

페이지(Page): 열 청크 내 가장 작은 압축/인코딩 단위다. 기본 크기는 1MB이며, 개별 페이지 단위로 스킵하려면 Column Index가 필요하다.


인코딩 방식

딕셔너리 인코딩 (PLAIN_DICTIONARY / RLE_DICTIONARY)

저카디널리티 열(enum, 상태 코드, 국가 코드 등)에 가장 강력한 최적화다. 고유값을 별도 딕셔너리 페이지에 한 번만 저장하고, 데이터 페이지에는 딕셔너리 인덱스만 저장한다.

딕셔너리 페이지: ["KR", "US", "JP", "DE", ...]  ← 고유값 목록
데이터 페이지:   [0, 0, 1, 2, 0, 3, ...]         ← 인덱스

딕셔너리 크기가 임계값(기본 1MB)을 초과하면 자동으로 PLAIN 인코딩으로 폴백한다. 카디널리티가 높은 열(UUID, 해시 등)에는 효과가 없다.

쿼리 최적화 효과: 쿼리 엔진이 딕셔너리 페이지를 먼저 읽고 predicate를 딕셔너리 값에 적용한다. 해당 값이 없으면 데이터 페이지를 전혀 읽지 않아도 된다(딕셔너리 필터링).

RLE / Bit-Packing (RLE_DICTIONARY)

딕셔너리 인코딩 후 인덱스 배열에 적용된다. 연속된 동일 값은 (값, 반복횟수) 쌍으로 압축하고, 불규칙한 값은 bit-packing으로 압축한다.

원본: [0, 0, 0, 0, 0, 1, 2, 2, 2]
RLE:  (0, ×5), (1, ×1), (2, ×3)

정렬된 데이터에 매우 효과적이다. 데이터를 특정 열 기준으로 정렬해 저장하면 RLE 압축률이 극적으로 올라간다.

델타 인코딩 (DELTA_BINARY_PACKED)

연속 정수나 타임스탬프에 적합하다. 절댓값 대신 이전 값과의 차이(delta)를 저장한다.

원본: [1000, 1001, 1003, 1006, 1010]
델타: [1000, +1, +2, +3, +4]

단조 증가하는 auto-increment ID, 타임스탬프 컬럼에 사용하면 높은 압축률을 달성한다.

BYTE_STREAM_SPLIT (부동소수점)

부동소수점(float32/float64)의 바이트를 필드 단위로 재배열한다. 지수부 바이트들은 엔트로피가 낮아 범용 압축기(zstd, snappy)가 더 잘 압축한다. 센서 데이터, 좌표, 임베딩 벡터 등 부동소수점 컬럼에 유용하다.


행 그룹 수준 통계와 Predicate Pushdown

파일 푸터에는 각 행 그룹의 모든 열에 대한 min_value, max_value, null_count가 기록된다. 쿼리 엔진은 이 통계를 사용해 쿼리 predicate와 맞지 않는 행 그룹을 통째로 건너뛸 수 있다.

예시: WHERE event_time BETWEEN '2026-03-01' AND '2026-03-31'

  • Row Group 1: min=2026-01-01, max=2026-01-31 → 건너뜀 (범위 교집합 없음)
  • Row Group 2: min=2026-02-15, max=2026-03-15 → 읽음 (범위 교집합 있음)
  • Row Group 3: min=2026-03-01, max=2026-04-30 → 읽음

이 효과를 파티션 프루닝(Row Group Pruning)이라 부른다. 통계 선택성이 높을수록 읽지 않아도 되는 행 그룹 비율이 올라간다.

선택성을 높이는 방법: 데이터를 자주 필터링되는 열 기준으로 정렬해 저장한다. 예를 들어 event_time 기준으로 정렬하면 각 행 그룹의 min/max 범위가 좁아져 훨씬 정확한 스킵이 가능하다.


Column Index — 페이지 수준 스킵

Parquet 2.10(Apache Parquet spec version)에서 추가된 Column Index는 행 그룹 내 개별 페이지 단위의 min/max 통계를 제공한다.

Row Group: 128MB 데이터
├── Page 1 (1MB): min=100, max=299
├── Page 2 (1MB): min=300, max=499
├── Page 3 (1MB): min=500, max=699 ← WHERE id=550 쿼리 시 이 페이지만 읽음
└── Page 4 (1MB): min=700, max=899

행 그룹 수준 통계로는 스킵할 수 없는 쿼리도 Column Index로 128배(128페이지 중 1개)까지 스킵할 수 있다. Parquet 파일 쓰기 시 write_statistics=truewrite_page_index=true를 함께 설정해야 한다.

Offset Index: Column Index의 짝꿍이다. 페이지 번호 → 파일 내 바이트 오프셋 매핑을 제공해, 특정 페이지를 직접 Range GET으로 읽을 수 있게 한다. S3에서 Parquet를 읽을 때 불필요한 바이트 다운로드를 최소화하는 핵심이다.


Bloom Filter — 동등 조건 가속

행 그룹 수준 min/max 통계는 범위 조건에 효과적이지만 동등 조건(WHERE country = 'XX')에는 충분하지 않다. 최솟값이 'AU'이고 최댓값이 'ZA'인 행 그룹에 'KR'이 실제로 있는지는 통계만으로 알 수 없다.

Split Block Bloom Filter(Parquet 2.9+)는 이 문제를 해결한다. 각 열 청크에 확률적 멤버십 테스트 구조를 첨부한다.

작동 원리

값의 해시를 사용해 여러 비트를 설정하고, 조회 시 해당 비트들이 모두 설정됐는지 확인한다.

  • False Positive 가능: 실제로 없는 값을 "있을 수 있다"고 답할 수 있다(설정된 FPP, 기본 5%)
  • False Negative 불가: 실제로 있는 값을 "없다"고 답하지 않는다

즉, 블룸 필터가 "없음"이라고 답하면 그 행 그룹은 반드시 건너뛸 수 있다.

쿼리: WHERE country = 'XY'
Bloom Filter 조회: "XY"가 filter에 없음 → Row Group 확실히 스킵
쿼리: WHERE country = 'KR'  
Bloom Filter 조회: "KR"이 filter에 있을 수 있음 → Row Group 실제 읽기

설정

# PyArrow로 블룸 필터 포함 쓰기
import pyarrow.parquet as pq

pq.write_table(
    table,
    "output.parquet",
    write_batch_size=1024,
    bloom_filter_enabled=True,
    bloom_filter_expected_ndv=100000,  # 예상 고유값 수
    bloom_filter_fpp=0.05,             # false positive probability
)

카디널리티와 블룸 필터 크기: 기대 고유값 수(NDV)와 FPP를 기반으로 블룸 필터 크기가 결정된다. NDV가 클수록 필터가 커진다. 매우 고카디널리티 열(UUID 등)은 블룸 필터가 오히려 과도한 공간을 차지하므로 적용하지 않는 것이 낫다.


쓰기 최적화: 데이터를 정렬해 저장하라

Parquet의 통계 효과를 극대화하는 가장 강력한 방법은 자주 필터링되는 열 기준으로 데이터를 정렬해 쓰는 것이다.

전략효과트레이드오프
정렬 없이 저장행 그룹 min-max 범위가 넓음빠른 쓰기, 낮은 읽기 효율
1개 열 기준 정렬해당 열 쿼리에서 대부분 스킵 가능정렬 비용, 다른 열 효과 제한
Z-order / Hilbert curve여러 열에 걸쳐 클러스터링높은 쓰기 비용, 다차원 쿼리 효과

Iceberg와 Delta Lake의 Compaction이 하는 핵심 작업 중 하나가 바로 이 정렬이다. ORDER BY 또는 OPTIMIZE WRITE ... ORDER BY 같은 명령이 실제로는 Parquet 행 그룹 통계를 개선하는 작업이다.

행 그룹 크기 설정 가이드

  • 너무 크면 (1GB+): 각 행 그룹의 통계 범위가 넓어져 스킵 효과 감소
  • 너무 작으면 (수 MB): 파일 메타데이터 오버헤드 증가, 작은 파일 문제 발생
  • 권장 범위: 128MB–512MB (쿼리 패턴에 따라 조정)

엔진별 활용 현황

엔진행 그룹 스킵Column IndexBloom Filter딕셔너리 필터링
Apache Spark 3.4+
DuckDB 1.x+✓ (v1.2+)
Trino 420+
ClickHouse 24+부분적
Apache Arrow/PyArrow읽기 지원

DuckDB는 Parquet 통계 활용에서 특히 적극적이다. S3 Range GET과 Column Index를 결합해 페이지 단위 정밀 읽기를 수행한다.


체크리스트

  • [ ] 자주 필터링되는 날짜/ID 열 기준으로 데이터를 정렬해 저장하고 있는가
  • [ ] 행 그룹 크기가 128MB–512MB 범위인지 확인했는가
  • [ ] Parquet 쓰기 시 write_page_index=True (Column Index)를 활성화했는가
  • [ ] 저카디널리티 필터 열(상태 코드, 국가, 카테고리)에 Bloom Filter를 적용했는가
  • [ ] 고카디널리티 열(UUID, 해시)에는 Bloom Filter를 비활성화했는가
  • [ ] Iceberg/Delta compaction 주기를 설정해 정렬 상태를 유지하고 있는가
  • [ ] 쿼리 엔진의 predicate pushdown이 실제로 동작하는지 EXPLAIN으로 확인했는가

References

  • Apache Parquet 공식 스펙 (Format 명세): https://parquet.apache.org/docs/file-format/
  • Apache Parquet Column Index 스펙: https://parquet.apache.org/docs/file-format/pageindex/
  • Apache Parquet Bloom Filter 스펙: https://parquet.apache.org/docs/file-format/bloomfilter/
  • PyArrow Parquet 쓰기 문서: https://arrow.apache.org/docs/python/parquet.html
  • DuckDB Parquet 읽기 문서: https://duckdb.org/docs/data/parquet/overview.html
  • Iceberg Sort Order와 행 그룹 최적화: https://iceberg.apache.org/docs/latest/performance/
  • Delta Lake Z-order 클러스터링: https://docs.delta.io/latest/optimizations-oss.html