[Spark] 파일 포맷, 파일 압축, Apache Iceberg
📌 개요
Spark에서 자주 사용되는 파일 포맷에 대해 알아보자.
📌 JSON
JavaScript Object Notation, JSON
은 key-value 구조를 가진 텍스트 기반 포맷이다. 사람이 쉽게 읽고 쓸 수 있으며, 계층적 구조를 표현할 수 있다는 특징이 있다.
매우 유연한 구조를 가지고 있다. JSON 내부에 객체가 있다면 스파크는 읽을 때 자동으로 StuructType
으로 매핑하고, 배열이 있다면 ArrayType
으로 매핑한다.
스파크를 사용하는 환경같이 대규모 데이터를 다루는 경우 JSON은 다소 비효율적이다. 먼저 텍스트 기반이며, 모든 레코드마다 키 이름을 반복해서 저장하므로 공간 낭비가 발생한다. 또한 JSON 파일을 읽을 때 매번 텍스트를 파싱하여 데이터 타입을 추론하고 구조를 분석해야 한다. 이는 CPU 자원을 많이 소모하는 작업이다. 또한 스파크는 병렬 처리에 강점이 있는 도구인데, JSON 파일은 하나의 큰 객체 배열이므로 파일을 임의의 지점에서 나눌 수 없다.
또한 JSON의 크기가 큰 경우 스파크가 JSON 파일을 스캔하여 스키마를 추론하는 시간이 늘어나며, 스키마가 꼬일 수도 있다.
외부 API 등으로부터 JSON으로 데이터를 받는 것은 좋으나, 일단 스파크로 JSON 데이터를 읽었다면 이를 Parquet
등과 같이 분석에 최적화된 포맷으로 변환하는 것이 좋다.
📌 Parquet
Parquet
은 컬럼 기반으로 데이터를 저장하는 포맷이다. 흔히 데이터를 분석할 때 전체 컬럼이 아니라 일부 특정 컬럼만 사용하는 경우가 대부분이다. Parquet을 사용하면 사용하지 않는 컬럼은 아예 건드리지도 않아 I/O 오버헤드를 줄일 수 있다. 또한 Parquet은 각 컬럼 블록별로 통계 정보를 메타데이터에 저장하고 있는데, 이를 통해 필터링 작업을 효율적으로 진행할 수 있다. 메타데이터를 확인하여 조건에 맞지 않은 블록임을 확인하면 해당 블록 전체를 건너뛸 수 있다.
컬럼 단위로 데이터를 저장하게 되면 동일한 데이터 타입의 값들이 함께 모인다. 따라서 데이터를 효과적으로 압축할 수 있게 된다.
Parquet은 데이터의 스키마 정보를 파일의 끝부분에 저장한다. 이 메타데이터를 읽고 데이터의 구조를 파악하므로 스키마 추론에 따른 오류가 발생하지 않는다. 나중에 컬럼이 추가 및 변경되어도 유연하게 호환시킬 수 있다.
단점은 데이터를 컬럼별로 재구성하고, 메타데이터를 계산하는 과정에 단순히 데이터를 쓰는 CSV나 JSON보다 더 많은 자원을 요구한다는 점이다. 다만 이는 읽기 작업을 극대화하기 위한 trade-off이다. 또한 바이너리 포맷이므로 사람이 읽을 수 없다.
📌 Apache Avro
Parquet과 다르게 Avro
는 행 기반 프레임워크이다. Avro의 주된 목적인 데이터를 디스크에 저장하여 분석하는 것이 아니라 데이터를 네트워크나 메시지 큐를 통해 전송 및 저장하기 위해 바이너리 형태로 직렬화하는 것이다.
가장 중요한 특징인 데이터와 스키마를 분리한다는 점이다. JSON와 다르게 키 이름이 반복되지 않으며, 값들만 바이너리 형태로 압축되어 있다. 데이터를 읽고 쓸 때 스키마를 참조해야만 데이터의 의미를 해석할 수 있다.
Avro는 consumer과 producer가 서로 다른 버전의 스키마를 가지고 있어도 데이터를 문제없이 주고받을 수 있다. 각 컴포넌트들을 서로 독립적으로 업데이트할 수 있다. 또한 compact하기 때문에 데이터의 크기가 매우 작다. 이는 네트워크 전송 비용 절감으로 이어진다. 또한 자바, 파이썬, C++ 등 다양한 프로그래밍 언어를 지원한다.
다만 일부 컬럼의 값을 읽고 싶어도 해당 행의 모든 데이터를 읽어야 하며, 따라서 데이터 분석용 포맷으로는 적합하지 않다.
📌 Apache ORC
ORC
는 Parquet과 마찬가지로 컬럼 기반 포맷이다. ORC는 데이터를 큰 단위인 Stripe
로 나누고, 각 스트라이프 내에서 컬럼별로 데이터를 저장한다. Parquet과 마찬가지로 컬럼 기반 포맷의 핵심 장점을 모두 가지고 있다.
Parquet에 비해 가장 차별화되는 특징은 내장 인덱싱이다. ORC는 각 스트라이프와 그 안의 행 그룹에 대한 통계 정보를 포함하는 내장 인덱스를 가지고 있다. 범위 기반 쿼리가 들어오면 인덱스를 참조하여 조건에 맞는 데이터가 없음을 확인하면 해당 스트라이프 전체를 스킵할 수 있다.
마찬가지로 쓰기 부하 같이 컬럼 기반 포맷이 가지는 단점들을 가지고 있다. 또한 전반적인 생태계에서 Parquet이 De facto이기 때문에, 자주 사용되지는 않는다.
📌 압축 알고리즘
압축 알고리즘을 사용하게 되면 데이터를 물리적으로 더 작은 공간에 저장할 수 있게 되며, 빅데이터 환경에서는 큰 이점이다. 또한 네트워크를 통해 전송하는 데 걸리는 시간이 단축된다.
그리고 데이터를 처리하는 성능이 좋아질 수 있다. 압축을 푸는 데 CPU 시간이 더 걸리지 않느냐고 생각할 수 있지만, 대부분의 빅데이터 작업은 CPU Bound가 아니라 I/O Bound이다. 즉, 작업의 전체 속도는 CPU의 계산 속도가 아니라 디스크 또는 네트워크에서 데이터를 읽어오는 속도에 의해 결정된다. 데이터를 압축하게 되면 I/O 시간을 효과적으로 단축시킬 수 있다.
다만 압축 알고리즘을 사용하면 CPU 자원을 더 많이 소모하는 것은 사실이며, 알고리즘에 따라 병렬 처리의 이점을 살리지 못할 수 있다. 또한 압축 파일이 손상되면 해당 블록 또는 파일 전체의 압축을 풀 수 없게 된다.
Gzip
Gzip
은 가장 널리 사용되는 압축 알고리즘 중 하나이다. 데이터를 매우 효과적으로 압축하여 파일 크기를 줄일 수 있다. 또한 특정 도구나 시스템에 종속되지 않기 때문에 쉽게 사용할 수 있다. 다만 상대적으로 느리며, 이는 지연 시간에 매우 민감한 워크로드나 CPU 자원이 병목인 작업에서는 좋은 대안이 될 수 없다.
스파크를 사용하는 경우 Gzip을 제대로 이해하고 사용해야 한다. Gzip은 파일 전체를 하나의 연속적인 스트림으로 압축하기 때문에, 파일 중간부터 읽기 시작하여 압축을 풀 수 없다. 따라서 파일을 처리하기 위해 하나의 노드만이 처리해야 하며, 나머지는 아무 일도 하지 못하는 병목 현상이 발생한다. 따라서 스파크를 사용한다면 Gzip을 단독으로 사용하지 말고 Parquet아니 ORC와 같이 분할 가능한 파일 포맷을 같이 사용하는 것이 좋다. 각각의 블록을 개별적으로 Gzip으로 압축할 수 있기 때문이다.
Snappy
Snappy
는 압축률보다 속도에 초점을 맞춘 압축 알고리즘이다. CPU에 거의 부담을 주지 않으면서 압도적으로 빠른 압축 및 해제 속도를 제공한다. 따라서 스파크를 사용하는 환경에서 주로 Parquet과 Snappy 조합을 많이 사용하기도 한다.
다만 역시 압축률이 Gzip에 비해서는 떨어진다.
다양한 압축 알고리즘을 표로 정리해보았다.
압축 알고리즘 | 압축률 | CPU 사용량 | 분할 여부 |
---|---|---|---|
Snappy | 낮음 | 매우 낮음 | 가능 |
Gzip | 높음 | 높음 | 불가능 |
Zstandard (Zstd) | 매우 높음 | 중간 | 가능 |
📌 Apache Iceberg
Apache Iceberg
는 대규모 분석 데이터셋을 관리하기 위한 오픈소스 테이블 포맷이다. 이전에 살펴 본 Perquet과 ORC는 파일 포맷이고, Iceberg는 테이블 포맷이다. 테이블 포맷이란 파일들이 모여 하나의 테이블을 구성하는 방법을 정의한다.
특징
Iceberg는 컬럼을 이름이 아니라 고유 ID로 추적한다. 따라서 컬럼의 이름이 변경하는 행위는 메타데이터의 변경으로 기록될 뿐, 기존 데이터 파일을 변경하지 않는다. 또한 개발자가 물리적 파티션 구조를 몰라도 Iceberg가 알아서 판단하고 필터링한다.
Iceberg의 모든 CUD 작업은 새로운 스냅샷을 생성한다. 테이블의 현재 상태는 최산 스냅샷을 가리키는 포인터로 관리된다. 이를 통해 ACID 속성을 만족한다. 과거의 스냅샷 기록을 보관하기 때문에 Git처럼 테이블 변경 이력을 자유롭게 탐색할 수 있다.
테이블 구조
Data file은 테이블의 실제 데이터를 담고 있다. Iceberg 테이블에서 데이터 파일은 불변이다.
Manifest file은 특정 스냅샷에 포함된 데이터 파일들의 목록이다. 어떤 데이터 파일들이 매니페스트에 속하는지, 각 데이터 파일에 대한 컬럼 레벨 통계 정보 등을 가지고 있다. 스파크는 이 정보를 통해 data skipping을 수행한다.
Metadata file은 테이블의 모든 것을 정의한다. 테이블의 스키마, 파티셔닝 전략, 테이블 위치 정보, 스냅샷 목록, 유효한 스냅샷을 가리키는 포인터 등을 포함한다.
INSERT
작업을 통해 Iceberg가 어떻게 데이터를 처리하는지 살펴보자.
- 먼저 새로운 데이터를 담은 파일을 S3에 쓴다.
- 새로운 데이터 파일을 가리키는 새로운 매니페스트 파일을 생성한다.
- 기존 매니페스트 파일과 새로운 매니페스트 파일을 모두 포함하는 새로운 매니페스트 리스트를 생성한다.
- 새로운 매니페스트 리스트를 가리키는 새로운 스냅샷을 생성한다.
- 스키마, 파티션 정보, 새로운 스냅샷을 현재 스냅샷으로 지정하는 메타데이터 파일을 생성한다.
- 카탈로그에 저장된 테이블의 포인터를 새로운 메타데이터 파일로 교체한다.
<테이블명>.snapshots
을 통해 해당 테이블에 생성된 모든 스냅샷에 대한 정보를 알 수 있다. 또한 <테이블명>.history
를 통해 테이블의 주된 변경 흐름을 볼 수 있다.
각 테이블에서 볼 수 있는 세부적인 정보는 다음과 같다.
.snapshots
컬럼 이름 | 타입 | 설명 |
---|---|---|
committed_at | timestamp | 스냅샷이 테이블에 성공적으로 커밋된 시간 |
snapshot_id | long | 각 스냅샷을 식별하는 고유 ID |
parent_id | long | 부모 스냅샷의 ID, 부모 스냅샷이란 변경되기 전 스냅샷 |
operation | string | 이 스냅샷을 만들어낸 작업의 종류 |
summary | map | 해당 작업에 대한 매우 상세한 요약 정보 |
manifest_list | string | 이 스냅샷을 구성하는 매니페스트 리스트 파일의 실제 경로 |
.history
컬럼 이름 | 타입 | 설명 |
---|---|---|
made_current_at | timestamp | 이 스냅샷이 테이블의 버전이 된 시간 |
snapshot_id | long | 스냅샷의 고유 ID |
parent_id | long | 부모 스냅샷의 ID |
is_current_ancestor | boolean | 이 스냅샷이 현재 테이블 상태의 직계 조상인지 여부 |