|
| 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