✍️ 필사 모드: 컬럼 지향 스토리지 완전 가이드 2025: Parquet, ORC, Apache Arrow, Dremel — 분석 DB가 10,000배 빠른 이유
한국어들어가며: 같은 데이터, 1000배 차이
한 가지 실험
1억 행의 판매 데이터가 있다. 각 행은 (id, timestamp, country, product, category, price, ...). 총 50개 컬럼.
질문: "한국에서 팔린 제품의 평균 가격은?"
SELECT AVG(price) FROM sales WHERE country = 'KR';
CSV나 MySQL 같은 row-oriented 저장소:
- 1억 행을 모두 읽음 (50컬럼 전부).
country로 필터링.price평균 계산.- 디스크 I/O: 수십 GB. 수 분 소요.
같은 데이터가 Parquet 형식이면:
country컬럼만 읽어 필터 (통계 활용 시 일부만).price컬럼만 읽어 평균 계산.- 디스크 I/O: 수백 MB. 수 초 소요.
100배~1000배 차이가 드물지 않다. Snowflake, BigQuery, Spark가 TB 단위 데이터를 수 초에 쿼리하는 것도 이 덕분이다.
왜 이제 columnar가 표준인가?
2000년대 초반까지 DBMS는 거의 모두 row-oriented였다. 그런데 2005년 MIT의 Michael Stonebraker 교수의 C-Store 논문이 columnar DB의 성능 우위를 증명했고, 이후:
- Vertica (2005, C-Store 기반)
- Apache Parquet (2013, Twitter + Cloudera)
- Apache ORC (2013, Hortonworks)
- Apache Arrow (2016, 메모리 포맷)
- ClickHouse (Yandex, 오픈소스)
- Snowflake (클라우드 네이티브)
이제 분석 워크로드(OLAP)의 사실상 표준이 컬럼 포맷이다.
이 글에서 다룰 것
- Row vs Column: 왜 분석에 컬럼이 빠른가.
- Parquet: Dremel 기반 구조, 압축, 통계.
- ORC: Hive 생태계의 답.
- Apache Arrow: 메모리에서의 columnar.
- Dremel 알고리즘: 중첩 데이터를 컬럼으로.
- 압축과 인코딩: RLE, Dictionary, Delta, Bit-packing.
- 벡터화 실행: SIMD와 columnar의 궁합.
- 실전 사용과 함정.
1. Row-oriented vs Column-oriented
Row 저장 방식
전통적인 DB(PostgreSQL, MySQL)는 각 행을 연속된 바이트로 저장한다:
Row 1: [id=1][time=T1][country=KR][product=A][price=100]
Row 2: [id=2][time=T2][country=US][product=B][price=200]
Row 3: [id=3][time=T3][country=KR][product=C][price=150]
...
장점:
- 한 행 전체를 빠르게 읽기 (insert, update, select *).
- 트랜잭션 자연스러움.
- OLTP에 최적.
단점:
- 특정 컬럼만 필요해도 전체 행을 읽어야 함.
- 디스크 I/O 낭비.
- 압축 효율 낮음 (다양한 타입이 섞여 있음).
Column 저장 방식
Columnar는 같은 컬럼의 값들을 연속된 블록에 저장:
id column: [1, 2, 3, 4, 5, ...]
time column: [T1, T2, T3, T4, T5, ...]
country column: [KR, US, KR, JP, KR, ...]
product column: [A, B, C, D, E, ...]
price column: [100, 200, 150, 300, 120, ...]
장점:
- 필요한 컬럼만 읽기 (projection 최적화).
- 같은 타입이 연속이라 압축이 잘 됨.
- CPU 캐시 친화적 (벡터화).
- OLAP 쿼리에 최적.
단점:
- 한 행을 재구성하는 데 여러 컬럼 접근 필요.
- Insert/update가 느림.
- 트랜잭션 처리 복잡.
압축 효율의 극적 차이
같은 데이터라도 컬럼 포맷에서 2~10배 더 압축된다.
이유: 한 컬럼 안에는 비슷한 값이 많다:
country컬럼: 'KR', 'KR', 'US', 'KR', 'KR'... (반복)year컬럼: 2025, 2025, 2025, 2025... (완전 반복)timestamp컬럼: 단조 증가 → delta로 표현 가능.
Row 방식에선 각 행이 섞여 있어 이런 패턴을 활용 못 한다.
OLTP vs OLAP 구분
두 방식의 장단점은 워크로드 따라 명확하다:
| 항목 | Row-oriented (OLTP) | Column-oriented (OLAP) |
|---|---|---|
| 예시 시스템 | MySQL, PostgreSQL | Parquet, ClickHouse |
| 쿼리 패턴 | 개별 레코드 접근 | 집계, 필터, 스캔 |
| 쓰기 패턴 | 작고 빈번 (insert/update) | 대량 bulk load |
| 읽기 패턴 | SELECT * FROM users WHERE id=... | SELECT AVG(price) FROM sales WHERE ... |
| 압축률 | 낮음 (2:1) | 높음 (5:1 ~ 20:1) |
| 인덱스 필요성 | 높음 | 낮음 (컬럼 통계로 대체) |
OLTP는 row, OLAP는 column. 이는 이론이 아니라 실전이다.
2. Parquet: Dremel의 영향
탄생
Apache Parquet는 2013년 Twitter와 Cloudera가 공동 개발했다. Google의 Dremel 논문(2010)에 영향을 받아 중첩 데이터를 효율적으로 저장하는 구조를 채택.
현재 거의 모든 분석 시스템이 Parquet를 지원한다: Spark, Hive, Presto, Trino, Snowflake, BigQuery, Athena, Impala.
파일 구조
Parquet 파일의 구조:
┌──────────────────────────────────────┐
│ Magic Number "PAR1" │
├──────────────────────────────────────┤
│ Row Group 1 │
│ ┌─────────────────────────────────┐ │
│ │ Column Chunk 1 (col=id) │ │
│ │ ┌─────────────┬─────────────┐ │ │
│ │ │ Data Page 1 │ Data Page 2 │ │ │
│ │ └─────────────┴─────────────┘ │ │
│ ├─────────────────────────────────┤ │
│ │ Column Chunk 2 (col=name) │ │
│ │ ... │ │
│ ├─────────────────────────────────┤ │
│ │ Column Chunk 3 (col=price) │ │
│ │ ... │ │
│ └─────────────────────────────────┘ │
├──────────────────────────────────────┤
│ Row Group 2 │
│ ... │
├──────────────────────────────────────┤
│ File Metadata (Footer) │
│ - Schema │
│ - Row group locations │
│ - Column statistics │
├──────────────────────────────────────┤
│ Footer length (4 bytes) │
│ Magic Number "PAR1" │
└──────────────────────────────────────┘
계층:
- Row Group: 수백 MB ~ 1GB 단위의 논리적 블록. 병렬 처리 단위.
- Column Chunk: 한 row group 내의 한 컬럼 전체.
- Data Page: 컬럼 청크의 작은 단위 (기본 1MB). 압축/인코딩 단위.
Row Group의 의미
왜 row group이 필요한가? 순수 컬럼 저장이면 각 컬럼을 완전히 분리하면 되지 않을까?
이유: 병렬 처리와 I/O 균형.
- 거대한 파일을 여러 row group으로 분할 → 각각 다른 워커가 처리.
- 한 row group 내의 컬럼들은 같은 물리적 위치에 있어 읽기 효율.
- 전형적 크기: 128MB ~ 512MB.
너무 작으면 병렬성 못 활용, 너무 크면 메모리 부담 + 쿼리 세밀도 저하.
메타데이터의 힘
Parquet의 footer에는 풍부한 메타데이터가 담긴다:
- Schema: 컬럼 타입 정의 (Thrift로 직렬화).
- Row group offsets: 각 row group의 위치.
- Column chunk metadata:
- 데이터 페이지 위치.
- 통계 (min/max/null count).
- Bloom filter (선택적).
- Page index (선택적).
이 메타데이터 덕분에 쿼리가 필요 없는 row group을 아예 건너뛸 수 있다.
Predicate Pushdown
쿼리 엔진이 Parquet 파일에 필터 조건을 전달할 수 있다:
SELECT * FROM sales WHERE price > 1000;
엔진은:
- 각 row group의
price컬럼 min/max 확인. max <= 1000인 row group은 스킵 (읽지 않음).min > 1000인 row group은 전체 포함 (필터 스킵).- 나머지만 실제 읽어서 필터링.
잘 정렬된 데이터에선 99%의 I/O를 절약할 수 있다. 이를 predicate pushdown 또는 row group skipping이라 한다.
Bloom Filter
Parquet 1.11+부터 Bloom filter를 선택적으로 저장할 수 있다:
SELECT * FROM events WHERE user_id = 12345;
min/max만으로는 "12345가 [100, 50000] 범위에 있을 수 있음" 수준의 정보만 얻는다. Bloom filter는:
- "이 row group에 12345가 확실히 없음" → 스킵.
- "있을 수 있음" → 실제 읽기.
특히 랜덤한 값(ID, UUID)을 필터링할 때 효과적.
Page Index
Parquet 2.9+의 page index는 페이지 수준의 min/max 통계를 별도로 저장한다. 쿼리 시:
- Footer를 먼저 읽고 파일 분석.
- 필요한 row group 판단.
- Page index로 어떤 페이지만 읽을지 결정.
- 해당 페이지만 fetch.
이는 특히 클라우드 스토리지(S3)에서 유용하다. 불필요한 바이트 범위를 fetch 안 한다.
3. Dremel 알고리즘: 중첩 데이터의 컬럼 표현
도전
컬럼 저장은 간단한 "평면" 데이터엔 잘 맞는다. 그런데 JSON, Protocol Buffer 같은 중첩된 데이터는?
{
"id": 1,
"name": "Alice",
"addresses": [
{"city": "Seoul", "zip": "12345"},
{"city": "Busan", "zip": "67890"}
]
}
addresses.city를 어떻게 컬럼으로 저장할까?
Repetition Level과 Definition Level
Dremel의 천재적 해결책: repetition level과 definition level.
각 값에 두 개의 숫자를 추가로 저장:
- Repetition level (R): "이 값이 반복에서 어느 깊이에 있는가?"
- Definition level (D): "이 값이 '정의된' 최대 깊이"
예시:
Record 1:
addresses = [{city: "Seoul"}, {city: "Busan"}]
Record 2:
addresses = [{city: "LA"}]
Record 3:
addresses = null
addresses.city 컬럼 저장:
| Value | R | D |
|---|---|---|
| "Seoul" | 0 | 2 |
| "Busan" | 1 | 2 |
| "LA" | 0 | 2 |
| NULL | 0 | 1 |
R=0: 새 레코드 시작. R=1: 같은 레코드 내에서 반복. D=2: 최대 깊이까지 정의됨. D=1: addresses는 있지만 city는 없음 (여기선 addresses 자체가 null).
이 두 숫자로 원래 구조를 완벽히 재구성할 수 있다. 수학적으로 증명됨.
왜 대단한가
이 덕분에 Parquet는:
- 중첩 데이터를 컬럼으로 저장 가능.
- Schema evolution 지원 (컬럼 추가/삭제).
- NULL 처리 효율적.
- 부분 선택:
addresses.city만 필요하면 그것만 읽음.
JSON을 그대로 쓰는 것과 비교하면 스토리지 10배, 쿼리 100배 이상 효율적일 수 있다.
Spark / Hive 사례
# Spark에서 JSON → Parquet
df = spark.read.json("data.json")
df.write.parquet("data.parquet")
# 중첩 필드 선택 (효율적)
df.select("user.id", "user.email", "events.type").show()
Spark는 Parquet의 중첩 구조를 이해하고 필요한 필드만 읽는다. JSON 대비 수십 배 빠른 쿼리.
4. 압축과 인코딩: 컬럼 포맷의 꽃
인코딩 vs 압축
두 개념을 구분:
- 인코딩(encoding): 데이터를 더 작게 표현하는 방법. 데이터 특성 이용.
- 압축(compression): 일반적 byte stream 압축 (Snappy, LZ4, Zstd, Gzip).
Parquet는 인코딩 먼저, 압축 나중이다. 이 순서가 중요.
Plain Encoding
가장 단순. 값을 그대로 저장.
- INT32: 4바이트씩.
- STRING: 길이 + 바이트.
다른 인코딩이 불리할 때 폴백으로 사용.
Dictionary Encoding
압축 효율의 왕. 값의 카디널리티가 낮으면 극도로 효과적.
원본: [KR, US, KR, JP, KR, KR, US, JP, ...]
↓
Dictionary: [KR=0, US=1, JP=2]
Values: [0, 1, 0, 2, 0, 0, 1, 2, ...]
문자열 대신 작은 정수를 저장. 이후 bit-packing으로 더 압축.
효과: country 같은 컬럼에서 1000배 이상 크기 감소 흔함.
Parquet는 dictionary가 일정 크기를 넘으면 자동으로 plain encoding으로 폴백한다 (dictionary-pagesize-limit).
Run-Length Encoding (RLE)
연속된 반복 값을 (값, 개수) 로 표현.
원본: [1, 1, 1, 1, 2, 2, 3, 3, 3, 3, 3]
RLE: [(1,4), (2,2), (3,5)]
적합: 정렬된 데이터, boolean 컬럼, 카테고리.
Parquet는 RLE를 dictionary encoding과 조합해 "RLE/Bit-Packing Hybrid"로 쓴다.
Bit Packing
정수들이 작은 범위에 있으면 필요한 비트만 사용.
값: [0, 1, 2, 3, 0, 1] (최대값 3)
각 값 2비트로 충분: 00 01 10 11 00 01
결과: [00011011 00010000] (2바이트)
32비트 정수 8개 = 32바이트를 2바이트로 → 16배 압축.
Delta Encoding
단조 증가하는 값에 효과적.
원본: [100, 102, 105, 108, 110, 115]
Delta: [100, 2, 3, 3, 2, 5]
첫 값 이후엔 증분만 저장. 타임스탬프나 sequence ID에 최적.
Parquet의 DELTA_BINARY_PACKED 인코딩이 이를 구현.
Byte Stream Split
IEEE 754 부동소수점은 압축이 어려운데, byte stream split은 트릭을 쓴다:
- 각 float의 같은 바이트 위치끼리 모음.
- "모든 첫 번째 바이트들", "모든 두 번째 바이트들"... 4개 스트림.
- 이제 비슷한 바이트들이 모여 일반 압축이 잘 먹힘.
과학 데이터, 좌표 데이터에 효과적.
압축 알고리즘 선택
Parquet는 여러 압축 알고리즘을 지원한다:
| 알고리즘 | 압축률 | 속도 | 용도 |
|---|---|---|---|
| UNCOMPRESSED | 1x | 극속 | 벤치마크 |
| SNAPPY | 2~3x | 매우 빠름 | 균형 (기본) |
| LZ4 | 2~3x | 매우 빠름 | 더 빠른 복호화 |
| GZIP | 4~5x | 느림 | 높은 압축 |
| BROTLI | 4~5x | 중간 | 웹 친화적 |
| ZSTD | 4~5x | 빠름 | 최신 권장 |
2025년 현재 권장: ZSTD. 압축률은 Gzip 수준, 속도는 Snappy에 가까움.
df.write.option("compression", "zstd").parquet("data.parquet")
인코딩 + 압축의 조합 효과
1억 행의 e-commerce 데이터:
| 저장 방식 | 크기 | 쿼리 시간 |
|---|---|---|
| CSV | 12 GB | 180 s |
| JSON | 28 GB | 300 s |
| Parquet (uncompressed) | 6 GB | 15 s |
| Parquet (snappy) | 2 GB | 8 s |
| Parquet (zstd) | 1.2 GB | 7 s |
10배 저장 공간 절약 + 25배 빠른 쿼리. 이것이 컬럼 포맷의 마법이다.
5. ORC: Hive의 파트너
탄생 배경
Apache ORC (Optimized Row Columnar) 는 2013년 Hortonworks가 Hive를 위해 개발했다. 같은 해에 Parquet도 나왔지만 ORC는 Hive 최적화에 집중.
구조 차이
ORC도 row group 기반이지만 용어가 다르다:
- Stripe: Parquet의 row group에 해당. 기본 250MB.
- Stream: Stripe 내 컬럼 데이터.
- Footer: 파일 메타데이터.
┌──────────────────────┐
│ File Header │
├──────────────────────┤
│ Stripe 1 │
│ ├── Index Streams │
│ ├── Data Streams │
│ └── Stripe Footer │
├──────────────────────┤
│ Stripe 2 │
│ ... │
├──────────────────────┤
│ File Footer │
│ - Statistics │
│ - Stripe info │
├──────────────────────┤
│ Postscript │
└──────────────────────┘
ORC vs Parquet
| 항목 | Parquet | ORC |
|---|---|---|
| 생태계 | Spark, Trino, Flink, Pandas | Hive, Presto |
| 중첩 데이터 | Dremel (강력) | Flatten 중심 |
| 통계 | Row group level | Stripe + stride (더 세밀) |
| ACID 지원 | 제한적 | Hive transactional tables |
| 압축률 | 좋음 | 약간 더 좋음 (많은 경우) |
| 쓰기 속도 | 빠름 | 비슷 |
실전 선택:
- Spark/Python/범용: Parquet.
- Hive 중심 데이터 웨어하우스: ORC.
ORC의 강점: Stride Level Statistics
ORC는 stripe 안에 stride(기본 10,000행)라는 더 작은 단위로 통계를 유지한다. 덕분에 더 세밀한 필터링이 가능.
최근 Parquet의 page index가 비슷한 기능을 제공하면서 이 이점은 줄어들었다.
Hive ACID
ORC의 독특한 기능: ACID 트랜잭션 지원. Hive 0.14+부터:
- INSERT, UPDATE, DELETE 가능.
- Base file + delta file 구조.
- 주기적 compaction.
OLTP용은 아니지만 "bulk load된 데이터를 가끔 수정"하는 워크로드에 유용.
6. Apache Arrow: 메모리의 columnar
다른 문제, 다른 답
Parquet와 ORC는 디스크 저장 포맷이다. 디스크에서 읽을 때 최대 효율.
그런데 메모리에서 데이터를 주고받을 때는 어떻게 해야 할까? 특히:
- Python pandas → Spark → Java JVM
- 데이터 엔진 간 교환
- GPU와 CPU 간 데이터 이동
각 시스템이 자기 포맷으로 변환하면 직렬화 비용이 엄청나다. 수백 GB 데이터의 변환이 쿼리보다 오래 걸리는 경우도 있었다.
Apache Arrow의 탄생
2016년 Wes McKinney (pandas 창시자)와 여러 프로젝트(Dremio, Feather)가 모여 Apache Arrow를 발표. 목표:
"언어와 시스템을 넘나드는 표준 메모리 columnar 포맷."
특징
- Zero-copy 읽기: 직렬화/역직렬화 필요 없음.
- 언어 중립적: C++, Java, Python, R, Rust, Go 등 지원.
- SIMD 친화적: 벡터화 연산 직접 실행 가능.
- Interoperability: 한 시스템에서 쓴 Arrow 배열을 다른 시스템이 바로 읽음.
메모리 레이아웃
Arrow의 배열은 연속된 buffer:
Int32 배열: [1, 2, 3, NULL, 5]
Validity bitmap: [1, 1, 1, 0, 1] (NULL 표시)
Values buffer: [1, 2, 3, 0, 5] (packed int32)
각 컬럼은:
- Validity bitmap: null 여부.
- Values buffer: 실제 값.
- (가변 길이면) Offsets buffer.
이 구조는 CPU 캐시에 완벽히 정렬되어 SIMD 연산에 최적.
Zero-Copy의 힘
예시: PySpark에서 pandas로 변환.
Before Arrow:
df.toPandas()
# Spark가 Java → Python 직렬화 (Pickle)
# Python이 역직렬화
# 시간: 수십 초 (1GB 데이터)
With Arrow:
spark.conf.set("spark.sql.execution.arrow.pyspark.enabled", "true")
df.toPandas()
# Spark가 Arrow 버퍼 생성
# Python이 메모리 포인터만 복사 (zero-copy)
# 시간: 수 초
10배 이상 빠름.
Arrow Flight: 네트워크 프로토콜
Arrow Flight는 gRPC 기반으로 Arrow 데이터를 네트워크로 주고받는 프로토콜이다. 용도:
- 데이터베이스 클라이언트 ↔ 서버.
- ML 파이프라인의 단계 간.
- 마이크로서비스 간 대량 데이터 전송.
성능: ODBC/JDBC보다 20배 이상 빠름.
Arrow DataFusion: Rust로 작성된 쿼리 엔진
Apache Arrow 프로젝트의 일부인 DataFusion은 Arrow 기반 SQL 쿼리 엔진이다. Rust로 작성, 매우 빠르다:
- Parquet 읽기 + Arrow 메모리 표현.
- 벡터화된 실행.
- DuckDB와 유사한 임베딩 가능.
Polars, InfluxDB IOx, 여러 분석 엔진이 DataFusion을 내부적으로 쓴다.
DuckDB와 Arrow
DuckDB는 SQLite의 OLAP 버전 같은 임베디드 분석 DB다. Arrow와 깊이 통합:
import duckdb
import pyarrow.parquet as pq
# Arrow 테이블 직접 쿼리
arrow_table = pq.read_table("data.parquet")
result = duckdb.query("SELECT country, AVG(price) FROM arrow_table GROUP BY country").to_df()
디스크에 쓰지 않고 메모리에서 바로 쿼리. 로컬 분석의 혁명.
7. 벡터화 실행: Columnar의 진짜 힘
전통적 Tuple-at-a-time
전통적 DB 실행 모델(Volcano):
while ((tuple = child->next()) != NULL) {
if (predicate(tuple)) {
emit(tuple);
}
}
문제:
- 각 함수 호출의 오버헤드.
- CPU 파이프라인 활용 못 함.
- 분기 예측 실패.
- 캐시 미스 많음.
벡터화 (Batch-at-a-time)
컬럼 스토리지의 궁합: 한 번에 수천 행 처리.
// 컬럼 값 배열
int32_t prices[1024];
bool mask[1024];
// SIMD로 일괄 필터링
for (int i = 0; i < 1024; i += 8) {
__m256i v = _mm256_loadu_si256((__m256i*)&prices[i]);
__m256i threshold = _mm256_set1_epi32(1000);
__m256i result = _mm256_cmpgt_epi32(v, threshold);
// ...
}
효과:
- 함수 호출 오버헤드 1/1024.
- SIMD로 한 번에 8~16 값 처리.
- CPU 파이프라인 최대 활용.
- 캐시 친화적.
2~10배 빠른 쿼리가 흔하다.
Snowflake / ClickHouse / DuckDB
이들은 모두 벡터화 엔진이다:
- Snowflake: 내부적으로 micro-batch 단위 처리.
- ClickHouse: 1024~65536 행의 컬럼 배치. 자체 SIMD 라이브러리.
- DuckDB: 1024 행 배치, LLVM JIT로 최적화.
Spark SQL도 2.x부터 Whole-stage Code Generation으로 비슷한 효과를 낸다.
Hyper/Umbra의 극단적 접근
독일 TU Munich의 연구 시스템 HyPer/Umbra는 더 나아가 쿼리를 LLVM IR로 컴파일해서 실행한다. 성능이 손으로 쓴 C++ 코드에 근접.
이 아이디어는 CockroachDB, Materialize 등 상업 제품에도 채택되고 있다.
8. 파티셔닝과 Z-Ordering
파티셔닝
대용량 데이터의 경우 디렉토리별로 분할이 일반적:
sales/
├── year=2023/
│ ├── month=01/
│ │ ├── part-00000.parquet
│ │ └── part-00001.parquet
│ ├── month=02/
│ └── ...
├── year=2024/
└── year=2025/
WHERE year=2024 AND month=03이면 그 폴더만 읽는다.
Hive-style Partitioning
Spark, Hive, Athena가 인식하는 표준 포맷:
s3://bucket/sales/year=2025/month=04/day=15/
디렉토리 이름이 그대로 파티션 컬럼이 된다. 파일에는 저장 안 됨 → 공간 절약.
주의점: 파티션 폭발
너무 세밀하면 문제:
year=2025/month=04/day=15/hour=14/minute=32/ # 분 단위!
수백만 개의 작은 파일 → list API 지연, 메타데이터 부담. "small files problem".
규칙: 각 파티션은 최소 수백 MB 되도록. 쿼리 세밀도 vs 메타 오버헤드의 균형.
Z-Ordering
Z-order (Morton code) 는 여러 컬럼의 위치를 인터리브해 만든 1차원 순서다.
예: (x, y) = (3, 5) = 0b011, 0b101
x bits: 0 1 1
y bits: 1 0 1
Z-order: 01 01 11 = 0b010111 = 23
효과: 여러 컬럼에 대한 min/max가 동시에 작동. 한 컬럼 정렬 대신 여러 컬럼을 함께 최적화.
Delta Lake와 Apache Iceberg의 OPTIMIZE ... ZORDER BY (country, product)가 이를 구현.
9. Delta Lake / Apache Iceberg / Hudi
문제: Parquet의 한계
Parquet는 파일 포맷일 뿐이다. 전체 데이터셋을 관리할 기능이 없다:
- ACID 없음.
- Schema evolution 수동.
- Time travel 없음.
- 삭제/업데이트 복잡.
Lakehouse 포맷의 등장
2018년 이후 Parquet 위에 트랜잭션 레이어를 추가하는 포맷들이 등장:
Delta Lake (Databricks):
- Parquet + transaction log (JSON).
- ACID, time travel, schema evolution.
- Z-ordering 지원.
Apache Iceberg (Netflix/Apple):
- Parquet/ORC + manifest files.
- 더 유연한 파티셔닝.
- Hidden partitioning.
- Snapshot isolation.
Apache Hudi (Uber):
- Parquet + timeline.
- Upsert/delete 최적화.
- CDC 통합.
세 포맷이 경쟁 중이며, 2024년 이후 Iceberg가 업계 표준으로 수렴 중이다 (AWS, Snowflake, Databricks 모두 지원).
Hidden Partitioning의 우아함
Iceberg의 중요한 개선: hidden partitioning.
전통:
-- 사용자가 파티션 컬럼(year, month)을 명시적으로 만들어야 함
INSERT INTO sales PARTITION (year=2025, month=04) VALUES ...
SELECT * FROM sales WHERE year=2025 AND month=4;
Iceberg:
-- 테이블 정의 시 파티션 트랜스폼 지정
CREATE TABLE sales (ts timestamp, ...) PARTITIONED BY (month(ts));
-- 사용자는 파티션 컬럼을 몰라도 됨
SELECT * FROM sales WHERE ts >= '2025-04-01';
-- Iceberg가 자동으로 month(ts) 계산해 필터링
스키마 변경이 쉽고, 사용자 실수를 줄인다.
10. 실전 팁과 함정
Tip 1: Row Group 크기
df.write.option("parquet.block.size", 134217728).parquet("out") # 128MB
너무 크면 메모리 압박, 너무 작으면 병렬성 저하. 128MB ~ 512MB가 sweet spot.
Tip 2: 컬럼 순서
Parquet는 쓰여진 순서로 저장한다. 자주 쓰는 컬럼을 먼저 놓으면 I/O 국소성 향상.
Tip 3: Dictionary 최대 크기
df.write.option("parquet.dictionary.page.size", 2097152).parquet("out")
너무 작으면 dictionary encoding을 포기하고 plain으로 폴백. 문자열 컬럼의 카디널리티가 높으면 조정.
Tip 4: 압축 알고리즘
2025년 기본 권장:
df.write.option("compression", "zstd").parquet("out")
ZSTD는 Snappy 수준 속도에 Gzip 수준 압축. 거의 항상 정답.
Tip 5: 파일 크기
하나의 Parquet 파일이 너무 작으면 list 오버헤드, 너무 크면 병렬성 제약.
Sweet spot: 256MB ~ 1GB.
Spark에서:
df.coalesce(10).write.parquet("out") # 파일 수 조절
df.repartition(10).write.parquet("out") # shuffle 포함
함정 1: Small Files
많은 작은 Parquet 파일은 재앙이다. S3의 list API 호출, 메타데이터 오버헤드, schema 불일치 위험.
해결:
- 주기적 compaction (작은 파일을 큰 것으로 merge).
- Delta Lake/Iceberg의
OPTIMIZE명령. coalesce()후 쓰기.
함정 2: Schema Evolution
Parquet 자체는 schema가 고정이다. 다른 스키마의 파일들을 함께 읽으면 문제.
해결:
- Delta Lake / Iceberg 사용 (schema evolution 관리).
- 또는 parquet의
mergeSchema옵션 (Spark).
함정 3: Decimal과 Timestamp
Parquet는 primitive 타입이 제한적이다. Decimal과 Timestamp 표현이 버전마다 다를 수 있다.
INT96타임스탬프: deprecated.INT64로 전환.- Decimal: 정밀도에 따라 저장 방식 다름.
다른 시스템으로 파일을 옮길 때 반드시 호환성 확인.
함정 4: Partition 수 폭발
앞서 말한 small files problem. 파티션 너무 세밀하면 쿼리 성능 저하.
해결: 월별, 일별 정도로 제한. 필요하면 시간 단위는 컬럼 내 필터로.
함정 5: 과도한 중첩
Parquet의 Dremel 구조는 훌륭하지만, 과도한 중첩은 메타데이터 폭발.
{"users": [{"events": [{"details": [{"key": "a"}]}]}]}
3단계 이상 중첩이면 flatten 고려. 쿼리 성능도 개선.
11. 실전 성능 비교
1억 행 e-commerce 데이터
| 포맷 | 크기 | SELECT * | SELECT price WHERE country='KR' |
|---|---|---|---|
| CSV (gzip) | 2.5 GB | 180 s | 180 s |
| JSON (gzip) | 4 GB | 300 s | 300 s |
| Avro | 2 GB | 60 s | 60 s |
| Parquet (snappy) | 1 GB | 25 s | 3 s |
| Parquet (zstd) | 700 MB | 22 s | 3 s |
| ORC (zstd) | 650 MB | 20 s | 2.5 s |
차이가 극명하다. 특히 필터링 쿼리에서 columnar가 압도적.
컬럼별 선택 비용
SELECT col_x FROM table_100cols:
| 포맷 | 시간 |
|---|---|
| Parquet | 1 컬럼만 읽음 → 100 MB I/O, 5 s |
| JSON | 전체 파싱 → 10 GB I/O, 120 s |
| CSV | 전체 파싱 → 5 GB I/O, 90 s |
Parquet의 projection pushdown의 힘. 100개 컬럼 중 1개만 필요하면 정확히 1/100의 I/O만 소모.
퀴즈로 복습하기
Q1. Row-oriented와 Column-oriented 저장의 가장 큰 성능 차이는 어디에서 오는가?
A. 세 가지 핵심 요인이다:
-
Projection (컬럼 선택):
SELECT col1, col5 FROM t같은 쿼리에서 Row는 전체 행을 읽어야 하지만, Column은 필요한 컬럼만 읽는다. 50컬럼 테이블에서 2컬럼만 선택하면 I/O가 1/25로 줄어든다. -
압축 효율: 같은 컬럼 값은 비슷한 특성을 가진다 (같은 타입, 반복 패턴 많음).
country컬럼은 dictionary encoding으로 100배 압축,timestamp는 delta encoding으로 극도 압축. Row는 다양한 타입이 섞여 있어 압축률이 낮다. -
벡터화: 같은 타입 값이 연속되어 있어 SIMD 명령으로 한 번에 8~16개 처리 가능. CPU 캐시 라인 활용도도 높다. Row에선 각 행마다 다른 메모리 위치 점프 → 캐시 미스.
이 세 요인이 결합되어 OLAP 쿼리에서 100~1000배 성능 차이가 난다. 단, OLTP(개별 행 접근, insert/update) 에는 Row가 여전히 유리하다. 워크로드에 따라 선택해야 한다.
Q2. Dremel의 repetition level과 definition level이 왜 필요한가?
A. 중첩 데이터를 손실 없이 컬럼으로 분해하기 위해서다. JSON 같은 계층 구조를 플랫한 컬럼으로 저장하려면 "이 값이 원래 어떤 구조의 어느 위치에 있었는가"를 추적해야 한다.
- Repetition level (R): "이 값이 어느 수준에서 반복된 것인가?" → 배열 내 위치 복원.
- Definition level (D): "경로의 어느 깊이까지 실제로 정의되었는가?" → NULL 처리와 선택적 필드 복원.
예를 들어 addresses[i].city 컬럼에서:
- R=0, D=2: 새 레코드의 첫 address의 city.
- R=1, D=2: 같은 레코드의 다음 address의 city.
- R=0, D=0: addresses 자체가 null.
이 두 숫자로 원본 JSON을 정확히 재구성할 수 있다. 수학적으로 증명된 lossless 인코딩이다. 이 덕분에 Parquet는 JSON/Protocol Buffer 같은 중첩 데이터도 컬럼 저장의 이점을 모두 누린다. Google Dremel(→BigQuery)이 이 기법의 시초이며, 이후 Parquet, Iceberg, Arrow 등이 모두 채택했다.
Q3. Predicate Pushdown과 Bloom Filter가 쿼리 성능을 어떻게 극적으로 개선하는가?
A. 핵심은 "읽지 않음" 이다. 가장 빠른 I/O는 일어나지 않은 I/O다.
Predicate Pushdown:
SELECT * FROM sales WHERE price > 1000;
각 row group의 price 컬럼에 저장된 min/max를 먼저 확인한다. max ≤ 1000인 row group은 절대로 조건을 만족하는 행이 없다 → 완전히 스킵. 데이터가 시간순으로 정렬되어 있거나 파티션이 잘 나뉘면 99% 이상의 row group을 스킵할 수 있다.
Bloom Filter:
Min/max는 범위 조건엔 강하지만 특정 값 조회엔 약하다. user_id=12345는 [1, 1000000] 범위 안에 있어 min/max로는 걸러지지 않는다. Bloom filter는 "이 row group에 12345가 확실히 없다" 를 판단할 수 있어 정확히 필요한 row group만 읽는다.
실전 효과: 10GB 파일에서 100MB만 실제로 읽음 → 100배 빠른 쿼리. 이것이 Parquet가 단순 압축 저장 포맷이 아닌 분석에 최적화된 포맷인 이유다.
Q4. Apache Arrow가 Parquet와 근본적으로 다른 문제를 해결하는 이유는?
A. Parquet는 디스크 포맷, Arrow는 메모리 포맷이다. 서로 다른 문제를 푼다.
Parquet의 문제: 디스크에 효율적으로 저장 → 압축, 컬럼 분리, 페이지 인코딩. 디스크 읽기 최소화가 목표.
Arrow의 문제: 이미 메모리에 로드된 데이터를 시스템 간 교환할 때 직렬화/역직렬화 오버헤드. 파이썬 pandas → Spark JVM → Java API → 또 다른 프로세스... 각 단계마다 복사와 변환이 일어난다면 원본 쿼리보다 데이터 이동이 오래 걸린다.
Arrow의 답: 표준 메모리 레이아웃을 정의. 모든 지원 언어/시스템이 같은 바이트 레이아웃을 사용하면 zero-copy 교환이 가능하다. 그냥 메모리 주소를 넘겨주면 된다.
구체 효과:
- PySpark
toPandas(): 이전 수십 초 → Arrow 활성화 시 수 초. - Database 클라이언트(Arrow Flight): ODBC 대비 20배 빠름.
- DuckDB + pandas: 변환 오버헤드 거의 0.
결론: Parquet는 "저장의 효율", Arrow는 "전송의 효율". 둘을 함께 쓰는 것이 현대 데이터 파이프라인의 표준이다 (디스크에선 Parquet, 메모리에선 Arrow).
Q5. Dictionary Encoding이 그렇게 효과적인 이유와, 언제 Parquet가 이를 포기하는가?
A.
왜 효과적인가: 대부분의 실제 컬럼은 카디널리티가 낮다. 예를 들어:
country: 전 세계 약 200개 나라.category: 수십 개.status: 5~10개.currency: 수십 개.
1억 행이라도 country 컬럼의 고유 값은 200개뿐이다. Dictionary encoding:
- 고유 값 딕셔너리:
[KR=0, US=1, JP=2, ...](수백 바이트). - 값들은 작은 정수로:
[0, 0, 1, 2, 0, 0, 1, ...]. - 작은 정수는 bit-packing으로 더 압축 (8비트로 200개 표현 가능).
결과: 1억 개의 3~10바이트 문자열 → 1억 개의 1바이트 값. 약 10배 감소. 거기에 RLE까지 적용되면 반복 구간에서 더 압축.
언제 포기하는가: Parquet는 dictionary가 커지면 plain encoding으로 자동 폴백한다. 기본 임계치는:
parquet.dictionary.page.size = 1 MB
이 크기 초과 시:
- 카디널리티가 너무 높다 (예: UUID, 전체 URL, 로그 메시지).
- 딕셔너리 자체가 메모리와 CPU 부담.
- Dictionary encoding의 이점이 사라짐.
이 경우엔 plain encoding + 일반 압축(zstd) 이 오히려 효율적이다. Parquet는 이를 각 data page 별로 자동 판단한다. 개발자가 특별히 설정할 필요 없지만, 이 원리를 알면 데이터 설계에 도움이 된다: 카디널리티가 낮은 컬럼은 적극 활용, 높은 컬럼은 다른 전략(shorter 인코딩, 압축 튜닝).
마치며: 데이터의 기하학
핵심 정리
- Row vs Column: OLTP는 row, OLAP는 column.
- Parquet: 디스크 표준. Dremel 기반 중첩 데이터 지원.
- ORC: Hive 파트너. 비슷한 성능, 다른 생태계.
- Arrow: 메모리 표준. Zero-copy 교환.
- 압축: Dictionary, RLE, Delta, Bit-packing, ZSTD.
- 벡터화: 컬럼 포맷의 진짜 힘. SIMD 친화.
- Lakehouse: Delta Lake, Iceberg, Hudi로 ACID 추가.
- 튜닝: Row group 크기, 압축 선택, 파티션 설계.
왜 이 지식이 중요한가?
2025년 현재, 거의 모든 데이터 플랫폼이 컬럼 저장을 사용한다:
- Snowflake, BigQuery, Redshift → 내부 컬럼 포맷.
- Databricks → Parquet 기반 Delta Lake.
- Athena → Parquet 직접 쿼리.
- ClickHouse → MergeTree (컬럼).
- DuckDB → 임베디드 OLAP.
- Apache Druid → 실시간 분석 컬럼 DB.
이 시스템들을 제대로 활용하려면 내부 구조를 이해해야 한다:
- 왜 파티션을 어떻게 해야 하는가.
- 왜 row group 크기가 중요한가.
- 왜 어떤 쿼리는 빠르고 어떤 건 느린가.
- 왜 압축 설정이 비용을 좌우하는가.
실전 체크리스트
- 분석 워크로드에 CSV/JSON 쓰고 있다면 → Parquet로 전환.
- Parquet 파일이 16MB 미만이면 → compaction 필요.
- 파티션이 너무 많거나 적으면 → 재설계.
- 자주 필터링되는 컬럼으로 정렬 → predicate pushdown 극대화.
- 압축 알고리즘 기본값이면 → ZSTD로 변경 고려.
- 메모리 간 데이터 이동이 느리면 → Arrow 도입.
- 트랜잭션 필요하면 → Delta Lake / Iceberg.
마지막 교훈
Stonebraker가 2005년 C-Store로 시작한 columnar 혁명은 20년이 지난 지금 데이터 산업 전체를 뒤덮었다. 같은 데이터가 1000배 빠르게 쿼리된다. 같은 하드웨어, 같은 데이터, 다른 구조. 이것이 컴퓨터 과학의 아름다움이다.
다음에 SQL 쿼리를 돌릴 때 생각해 보자: 저 데이터는 어떤 구조로 저장되어 있는가? 답이 분명하면, 왜 빠르고 느린지도 분명해진다. 그리고 더 나은 파이프라인을 만들 수 있다.
참고 자료
- Dremel: Interactive Analysis of Web-Scale Datasets (Melnik et al., 2010) - Parquet의 사상적 기반
- C-Store: A Column-oriented DBMS (Stonebraker et al., 2005)
- Apache Parquet Documentation
- Apache ORC Specification
- Apache Arrow Format
- DuckDB Paper: An Embeddable Analytical Database
- Delta Lake Paper (VLDB 2020)
- Apache Iceberg: A Modern Table Format
- Snowflake Paper: The Snowflake Elastic Data Warehouse
- ClickHouse: MergeTree Architecture
- Vectorization vs Compilation in Query Execution (Kersten et al., 2018)
현재 단락 (1/566)
1억 행의 판매 데이터가 있다. 각 행은 (id, timestamp, country, product, category, price, ...). 총 50개 컬럼.