Search

[Data Engineering] Apache Parquet - 열 기반 포맷의 내부 구조

Python으로 웹 스크래핑 하기
2024/09/08
Python
Data Engineering
BeautifulSoup
Python으로 웹 스크래핑 하기
2024/09/08
Python
Data Engineering
BeautifulSoup
Load more
이전 글에서는 행 기반열 기반의 차이를 살펴봤습니다. 열 기반이 분석에 유리한 이유는 필요한 컬럼만 읽고, CPU 캐시와 SIMD를 활용할 수 있기 때문입니다.
그런데 저번에 다룬 Apache Arrow는 인메모리 포맷입니다. 데이터를 디스크에 저장하려면 다른 고려가 필요합니다. Apache Parquet는 이 디스크 저장에 초점을 맞춘 열 기반 포맷입니다. 이번 글에서는 Parquet 파일이 내부적으로 어떻게 생겼는지를 뜯어봅니다.

1. Arrow와 Parquet

같은 열 기반이지만 Arrow와 Parquet는 목적이 다릅니다.
Arrow는 연산 속도를 우선합니다. 데이터를 메모리에 있는 그대로 펼쳐 놓고, CPU가 바로 접근할 수 있게 합니다. 압축하지 않습니다. 압축을 풀어야 값을 읽을 수 있다면 SIMD나 Zero-Copy의 이점이 사라지기 때문입니다.
Parquet는 저장 효율을 우선합니다. 인코딩과 압축으로 크기를 줄이고, 메타데이터를 붙여서 필요한 부분만 찾아 읽을 수 있게 합니다. 읽을 때 디코딩 비용이 발생하지만, 그만큼 디스크 I/O 양이 줄어듭니다.
Arrow
Parquet
위치
메모리
디스크
목적
연산 속도
저장 효율
압축
하지 않음
인코딩 + 압축
사용처
Pandas, Spark 내부 연산
데이터 레이크 저장
일반적인 분석 파이프라인에서는 둘이 함께 동작합니다. 디스크에는 Parquet로 저장하고, 읽을 때 Arrow로 변환해서 메모리에서 연산합니다.

2. 중첩 데이터를 열로 펼치기

2-1. 문제: 실제 데이터는 flat하지 않다

1부에서는 user_id, name, age처럼 단순한 테이블을 예시로 들었습니다. 실제 데이터는 중첩 구조인 경우가 많습니다.
레코드 1: {"name": "Alice", "phones": ["010-1234", "010-5678"]} 레코드 2: {"name": "Bob", "phones": ["010-9999"]} 레코드 3: {"name": "Carol", "phones": null}
JSON
복사
phones는 배열이고, 레코드마다 개수가 다릅니다. 아예 null인 경우도 있습니다.
flat 컬럼은 간단합니다. name["Alice", "Bob", "Carol"], 끝입니다. 하지만 phones는 레코드 1에 2개, 레코드 2에 1개, 레코드 3에 null입니다. 값만 나열하면 ["010-1234", "010-5678", "010-9999"]인데, 이것만으로는 어떤 값이 어느 레코드에 속하는지 알 수 없습니다. phones가 null인 건지 빈 배열인 건지도 구분이 안 됩니다.

2-2. Definition Level과 Repetition Level

2010년 Google이 발표한 Dremel 논문은 이 문제를 두 개의 정수로 해결했습니다. 모든 값에 Definition Level(D)Repetition Level(R)을 붙이는 방식입니다.
Definition Level은 해당 경로에서 몇 단계까지 값이 존재하는지를 나타냅니다. NULL이 어느 깊이에서 발생했는지를 구분합니다.
phones 필드의 경로를 단계별로 분해하면
단계 0: 레코드 자체 단계 1: phones 필드 단계 2: phones의 개별 원소
Plain Text
복사
D=2: 원소 값이 존재
D=1: phones 필드는 있지만 원소가 없음 (빈 배열)
D=0: phones 자체가 null
Repetition Level어느 단계에서 반복이 시작됐는지를 나타냅니다. 새 레코드의 시작과, 같은 레코드 안에서의 반복을 구분합니다.
R=0: 새 레코드의 시작
R=1: 같은 레코드 안에서 phones의 다음 원소
이 두 값을 붙이면 phones 컬럼은 이렇게 됩니다.
phones 컬럼: 값 D R 의미 ───────────────────────────────────────── "010-1234" 2 0 레코드 1의 첫 번째 번호 (새 레코드) "010-5678" 2 1 레코드 1의 두 번째 번호 (반복) "010-9999" 2 0 레코드 2의 첫 번째 번호 (새 레코드) null 0 0 레코드 3, phones 자체가 null (새 레코드)
Plain Text
복사
D와 R만 있으면 원래 구조를 복원할 수 있습니다. R=0이 나오면 새 레코드가 시작되고, R=1이면 이전 레코드에 값을 이어 붙입니다. D=0이면 null, D=2이면 실제 값입니다.
이 방식이 Parquet의 중첩 데이터 표현 방식이며, 다음 글에서 다룰 BigQuery의 Capacitor도 같은 원리를 사용합니다.

3. Parquet 파일의 내부 구조

3-1. Row Group → Column Chunk → Page

Parquet 파일은 세 단계의 계층 구조로 나뉩니다.
Parquet 파일 │ ├── Row Group 0 ← 행을 수평으로 자른 단위 │ ├── Column Chunk: name ← 컬럼별 분리 │ │ ├── Page 0 ← 데이터가 담기는 최소 단위 │ │ └── Page 1 │ ├── Column Chunk: age │ │ └── Page 0 │ └── Column Chunk: phones │ ├── Page 0 │ └── Page 1 │ ├── Row Group 1 │ ├── Column Chunk: name │ │ └── ... │ └── ... │ └── Footer ← 스키마 + 통계 정보
Plain Text
복사
Row Group은 파일을 수평으로 자른 단위입니다. 하나의 Row Group에 보통 수십만~수백만 개의 행이 포함됩니다. 병렬 처리의 기본 단위이기도 합니다. Spark에서 Parquet를 읽으면 Row Group 단위로 태스크가 나뉩니다.
Column Chunk는 한 Row Group 안에서 컬럼별로 분리한 단위입니다. name의 모든 값이 하나의 Column Chunk에, age의 모든 값이 또 다른 Column Chunk에 저장됩니다. 쿼리에서 age만 필요하면 name Column Chunk는 건드리지 않습니다. 이것이 Column Pruning입니다.
Page는 Column Chunk 안에서 데이터가 실제로 저장되는 최소 단위입니다. 인코딩과 압축은 Page 단위로 적용됩니다.

3-2. Footer: 읽을 곳을 먼저 파악하기

Parquet 파일의 Footer에는 스키마 정보와 각 Column Chunk의 통계(Statistics)가 저장됩니다. 통계에는 min, max, null count 등이 포함됩니다.
Footer 통계 예시 (Row Group 0): Column Chunk: age min: 20, max: 35, null_count: 0 Column Chunk: country min: "JP", max: "US", null_count: 2
Plain Text
복사
쿼리 엔진은 이 통계를 보고, 조건에 해당하지 않는 Row Group을 읽지 않고 건너뜁니다. WHERE age > 50이라면, max가 35인 Row Group은 확인할 필요가 없습니다. 이것이 Predicate Pushdown입니다.
Footer는 파일 끝에 위치합니다. 데이터를 처음부터 끝까지 읽지 않아도, 끝부분만 먼저 읽으면 어떤 데이터가 어디에 있는지 파악할 수 있습니다.
Column Pruning과 Predicate Pushdown은 모두 이 Footer 덕분에 가능합니다. 데이터를 읽기 전에 읽을 필요가 있는지를 판단하는 구조입니다.

4. 인코딩과 압축

Parquet는 컬럼마다 데이터 특성에 맞는 인코딩을 적용합니다. 인코딩은 데이터의 패턴을 이용해 표현을 줄이는 것이고, 압축은 그 위에 범용 알고리즘을 적용하는 것입니다.

4-1. RLE (Run-Length Encoding)

같은 값이 연속으로 반복되면, 값과 반복 횟수만 기록합니다.
원본: [KR, KR, KR, US, US, JP, JP, JP, JP] RLE: (KR, 3)(US, 2)(JP, 4) 9개 값 → 3쌍
Plain Text
복사
country, status 같은 카디널리티가 낮고 연속 반복이 많은 컬럼에 효과적입니다. 앞 섹션에서 다룬 Definition Level과 Repetition Level도 정수의 연속이므로 RLE로 인코딩됩니다.

4-2. Dictionary Encoding

컬럼의 고유 값이 적으면, 값 자체 대신 사전 인덱스로 저장합니다.
원본: ["Seoul", "Tokyo", "Seoul", "Seoul", "Tokyo"] 사전: {0: "Seoul", 1: "Tokyo"} 인코딩: [0, 1, 0, 0, 1]
Plain Text
복사
문자열을 반복 저장하는 대신, 사전에 한 번만 기록하고 정수 인덱스로 참조합니다. 정수는 문자열보다 작으므로 크기가 줄어듭니다. 사전 인코딩된 인덱스 배열에 다시 RLE를 적용하면 크기가 더 줄어듭니다.
고유 값이 너무 많은 컬럼(UUID 등)에는 사전이 커져서 효율이 떨어집니다. Parquet는 사전 크기가 Page를 넘으면 Plain Encoding(값을 그대로 저장)으로 전환합니다.

4-3. Delta Encoding

값이 순차적으로 증가하는 패턴이면, 값 자체 대신 이전 값과의 차이(delta)만 기록합니다.
원본: [1000, 1001, 1003, 1006] Delta: [1000, 1, 2, 3]
Plain Text
복사
timestamp, 자동 증가 id 같은 컬럼에 적합합니다. delta 값이 작으면 적은 비트로 표현할 수 있어 크기가 줄어듭니다.

4-4. 인코딩 위에 압축

인코딩으로 표현을 줄인 뒤, Page 단위로 범용 압축 알고리즘을 추가 적용합니다.
원본 데이터 → 인코딩 (RLE, Dictionary, Delta) → 압축 (Snappy, Gzip, Zstd)
Plain Text
복사
알고리즘
특성
사용처
Snappy
속도 우선, 압축률 보통
Spark 기본값, 실시간 처리
Gzip
높은 압축률, 느린 속도
장기 보관, 콜드 스토리지
Zstd
높은 압축률 + 빠른 속도
Snappy와 Gzip 사이의 균형
인코딩이 선행되므로, 같은 압축 알고리즘이라도 인코딩 없이 바로 압축한 것보다 결과가 좋습니다. RLE로 반복을 줄이고 Dictionary로 문자열을 정수로 바꾼 뒤 압축하면, 원본 대비 수십 분의 1 크기가 되기도 합니다.

마무리

Parquet는 디스크에서 읽는 양을 줄이는 포맷입니다. 중첩 데이터를 Definition Level과 Repetition Level로 열에 담아 필요한 컬럼만 읽고, Footer 통계로 조건에 맞지 않는 블록을 건너뛰고, 인코딩과 압축으로 읽는 크기를 줄입니다.
다음 글에서는 Parquet와 같은 Dremel 논문에서 시작되었지만, 범용성 대신 성능이라는 다른 길을 택한 BigQuery의 엔진, Capacitor에 대해 알아보겠습니다.