Skip to content

Commit 516cd0c

Browse files
namgung channamgung chan
authored andcommitted
Add post: Parquet 중첩 타입(Nested Types) 비교
1 parent dfb4987 commit 516cd0c

1 file changed

Lines changed: 192 additions & 0 deletions

File tree

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
---
2+
title: "Parquet 중첩 타입(Nested Types) 비교"
3+
excerpt: "Map, Struct, JSON, Variant — 스키마 유연성과 푸시다운 효율의 트레이드오프"
4+
categories:
5+
- data
6+
tags:
7+
- data
8+
- parquet
9+
- iceberg
10+
- columnar
11+
- variant
12+
---
13+
14+
## 1. 논리적 관점 (스키마, 유연성, 쿼리 표현력)
15+
16+
| 타입 | 스키마 유연성 | 중첩 구조 | 키/값 타입 제약 |
17+
|---|---|---|---|
18+
| Struct | 정적 (컴파일타임) | 가능, 고정 필드 | 필드명·타입 모두 스키마에 고정 |
19+
| Map | 반정적 | value에 중첩 가능 | key 타입 고정, value 타입 고정 |
20+
| String(JSON) | 완전 동적 | 중첩 가능 (문자열) | 없음 (그냥 바이트 덩어리) |
21+
| Variant | 완전 동적 | 중첩 가능 | 없음, 런타임 타입 정보 포함 |
22+
| Shredded Variant | 동적+정적 혼합 | 중첩 가능 | 자주 쓰는 경로는 별도 컬럼으로 고정 |
23+
24+
### Struct
25+
26+
- 스키마가 파일 작성 시점에 완전히 결정됨
27+
- 필드 추가/삭제 → 스키마 evolution 필요 (ALTER TABLE 등)
28+
- 중첩 필드에 대한 컬럼 통계(min/max/null count) 완벽히 지원
29+
- SQL: `col.field_name` 형태로 직접 접근
30+
31+
### Map
32+
33+
- key-value 쌍의 컬렉션. key 타입은 고정 (보통 STRING)
34+
- 키 집합이 행마다 달라질 수 있음 → Struct보다 유연
35+
- 그러나 "키 K의 값이 100 이상" 같은 조건은 컬럼 통계로 처리 불가
36+
- SQL: `col['key']` 형태 접근
37+
38+
### String(JSON)
39+
40+
- Parquet 입장에서는 그냥 BYTE_ARRAY. 의미론적으로만 JSON
41+
- 엔진이 JSON인지 모름 → 통계·인덱싱 전혀 없음
42+
- 쿼리 시 전체 문자열 파싱 필수 (`get_json_object` 등)
43+
- 가장 범용적이지만 성능은 최악
44+
45+
### Variant (Parquet Spec v2 / Iceberg v3)
46+
47+
- Apache Parquet에서 공식 제안된 새 논리 타입 (2023~2024)
48+
- 이진 인코딩으로 타입 정보 포함 (BOOLEAN/INT/FLOAT/STRING/ARRAY/OBJECT 등)
49+
- 런타임에 스키마 없이도 타입 체크·경로 탐색 가능
50+
- Iceberg v3의 핵심 타입으로 채택
51+
52+
### Shredded Variant
53+
54+
- Variant의 확장: 자주 접근하는 경로를 별도 Parquet 컬럼으로 "분해(shred)"
55+
- 나머지 동적 부분은 여전히 Variant로 보관
56+
- Struct의 정적 효율성 + Variant의 동적 유연성을 동시에 추구
57+
58+
---
59+
60+
## 2. 물리적 저장 구조
61+
62+
### Struct
63+
64+
```
65+
parquet row group
66+
├── col.a (INT64) ← 독립 컬럼
67+
├── col.b (STRING) ← 독립 컬럼
68+
└── col.c (DOUBLE) ← 독립 컬럼
69+
```
70+
71+
각 필드가 완전히 독립된 컬럼으로 분리 저장된다. Dremel encoding(repetition/definition levels)이 적용되며 각 컬럼에 독립적인 min/max/null 통계가 붙는다.
72+
73+
### Map
74+
75+
```
76+
parquet row group
77+
└── col (MAP)
78+
└── key_value (repeated group)
79+
├── key (STRING)
80+
└── value (INT64)
81+
```
82+
83+
key, value 각각 하나의 컬럼. `key='foo'`인 value만 가져오려면 key 컬럼 전체 스캔이 필요하다.
84+
85+
### String(JSON)
86+
87+
```
88+
parquet row group
89+
└── col (BYTE_ARRAY) ← 그냥 바이트
90+
통계: min/max는 바이트 비교 (의미 없음)
91+
```
92+
93+
완전 불투명 바이트 덩어리. 압축은 되지만 컬럼 푸시다운 전혀 불가.
94+
95+
### Variant
96+
97+
```
98+
parquet row group
99+
└── col (VARIANT)
100+
├── metadata (BYTE_ARRAY) ← 딕셔너리: 필드명 → id 매핑
101+
└── value (BYTE_ARRAY) ← 이진 인코딩된 실제 값
102+
```
103+
104+
- `metadata` 컬럼: 해당 row group에 등장한 모든 필드명을 딕셔너리로
105+
- `value` 컬럼: 타입 태그(1바이트) + 실제 값의 이진 인코딩
106+
- JSON보다 파싱 비용 낮음. 컬럼 통계는 여전히 제한적
107+
108+
### Shredded Variant
109+
110+
```
111+
parquet row group
112+
└── col (VARIANT)
113+
├── metadata (BYTE_ARRAY)
114+
├── value (BYTE_ARRAY) ← shred된 경로는 null or absent
115+
├── col.price (DOUBLE) ← shredded 컬럼 ①
116+
├── col.user_id(INT64) ← shredded 컬럼 ②
117+
└── col.tags (LIST<STRING>) ← shredded 컬럼 ③
118+
```
119+
120+
shredded 컬럼에 해당 경로의 값이 있으면 저장하고, Variant value에는 중복 저장하지 않는다. shredded 컬럼에는 완전한 Parquet 통계가 생성된다.
121+
122+
---
123+
124+
## 3. 컬럼 푸시다운 & 필터링 비교
125+
126+
| 타입 | Row Group 통계 필터링 | Page-level 필터링 | 경로 직접 컬럼 I/O | 필터 pushdown 효율 |
127+
|---|---|---|---|---|
128+
| Struct | ✅ 각 필드 || ✅ 해당 필드만 | 최상 |
129+
| Map | ⚠️ key/value 수준 | ⚠️ | ❌ 전체 스캔 | 낮음 |
130+
| String(JSON) ||| ❌ 전체 읽기 | 없음 |
131+
| Variant ||| ❌ 전체 읽기 | 없음에 가까움 |
132+
| Shredded Variant | ✅ shred된 필드만 | ✅ shred된 필드만 | ✅ shred된 경로만 독립 I/O | shred 경로는 최상, 나머지는 Variant 수준 |
133+
134+
구체적인 예시:
135+
136+
```sql
137+
-- 이 쿼리에서 각 타입이 어떻게 동작하는가
138+
SELECT * FROM t WHERE data.price > 100.0
139+
```
140+
141+
- **Struct(price DOUBLE)**: price 컬럼만 읽음. row group min/max로 블록 스킵. 최적
142+
- **Map**: key='price' 찾으려면 key 컬럼 전체 + value 컬럼 전체 읽어야 함
143+
- **String(JSON)**: 모든 행 읽고 JSON 파싱 후 필터
144+
- **Variant**: 모든 행 value 컬럼 읽고 이진 파싱 후 필터 (JSON보단 빠름)
145+
- **Shredded Variant(price shredded)**: Struct와 동일하게 동작
146+
147+
---
148+
149+
## 4. 인코딩 & 압축 효율
150+
151+
| 타입 | 인코딩 특성 |
152+
|---|---|
153+
| Struct | 각 필드 독립 인코딩. 동일 타입 연속 → RLE/Dict 최대 효과 |
154+
| Map | key 컬럼은 Dict 인코딩 잘 됨 (반복 문자열). value는 분산 |
155+
| String(JSON) | Snappy/Zstd 블록 압축만. 컬럼 지향 인코딩 효과 없음 |
156+
| Variant | metadata 딕셔너리로 필드명 중복 제거. value는 이진이라 JSON보단 낫지만 typed 컬럼보단 나쁨 |
157+
| Shredded Variant | shredded 컬럼: Struct 수준. 잔여 Variant: Variant 수준 |
158+
159+
---
160+
161+
## 5. 스키마 진화(Schema Evolution) 대응
162+
163+
| 타입 | 새 필드 추가 시 |
164+
|---|---|
165+
| Struct | ALTER TABLE 필요. 구 파일의 새 필드는 null로 읽힘 |
166+
| Map | 코드 변경 없이 새 key 그냥 삽입 가능 |
167+
| String(JSON) | 코드 변경 없이 새 key 그냥 삽입 가능. 쿼리도 즉시 사용 |
168+
| Variant | 코드 변경 없이 새 경로 삽입 가능 |
169+
| Shredded Variant | 새 경로는 Variant에 담김 (즉시 가능). 자주 쓰이면 나중에 shred 추가 (테이블 변경 필요) |
170+
171+
---
172+
173+
## 6. 언제 무엇을 써야 하나
174+
175+
| 상황 | 추천 |
176+
|---|---|
177+
| 스키마 완전히 알고 있음, 고정 | **Struct** |
178+
| 키 집합이 다양하지만 value 타입은 균일 | **Map** |
179+
| 레거시 시스템, 빠른 PoC, JSON 그대로 dump | **String(JSON)** |
180+
| 스키마 미확정, 탐색적 분석, 이벤트 로그 | **Variant** |
181+
| 일부 경로 자주 쿼리 + 나머지는 동적 | **Shredded Variant** ← 이상적 |
182+
183+
---
184+
185+
## 7. 생태계 현황 (2025 기준)
186+
187+
- **Struct / Map**: 모든 엔진 지원 (Spark, Trino, DuckDB, Hive 등)
188+
- **String(JSON)**: 모든 엔진 지원. `get_json_object`, `json_extract` 등 함수로 사용
189+
- **Variant**: Parquet spec PR 제안됨, Iceberg v3 명세 포함. Spark 4.0, DuckDB 일부 지원 시작
190+
- **Shredded Variant**: Iceberg v3 명세에 포함. Spark 4.0 실험적 지원. 아직 엔진 지원 초기 단계
191+
192+
Variant / Shredded Variant는 "반정형 데이터를 레이크하우스에서 SQL로 효율적으로 다루자"는 흐름(Snowflake VARIANT, BigQuery JSON → 표준화)의 Parquet/Iceberg 구현체다.

0 commit comments

Comments
 (0)