https://2stndard.tistory.com/notice/203
[발간예정][EPL과 유튜브로 배우는 DuckDB] 실습 코드와 데이터
EPL과 유튜브 데이터로 배우는 DuckDB에서 사용되는 실습 데이터와 코드를 제공합니다. EPL_DATA&samplefile.zip : 책에서 사용하는 영국 프리미어리그 데이터 셋과 샘플로 사용하는 파일espn.duckdb.zip : 책
2stndard.tistory.com
대부분의 데이터 전문가는 컬럼형 저장 방식이 “분석에 빠르다”는 직관을 가지고 있지만, 왜 그런지 완전히 이해하지 못한 채 넘어갑니다. 컬럼형 = 빠름. 끝.
이것은 놓치기 아까운 기회입니다. 여러분이 접해온 최적화 조언 중 상당수 — 필터를 일찍 적용하라, 특정 컬럼만 선택하라, 날짜 기준으로 파티셔닝하라, 자주 사용하는 필터 기준으로 클러스터링하라, SELECT *를 피하라 — 는 컬럼형 데이터가 저장되는 구체적인 물리적 특성 때문에 효과가 있습니다. 이러한 특성을 이해하지 못하면 그 조언은 단순한 패턴 매칭에 그칩니다. 하지만 이를 이해하면 그 조언을 스스로 추론하고 확장할 수 있습니다.
이 글은 현대 분석 시스템 아래에 있는 저장 계층에 관한 글입니다. Parquet은 데이터 엔지니어링 생태계에서 가장 널리 사용되는 컬럼형 포맷이며, 여기서는 이를 주요 기준으로 삼겠습니다. 하지만 원리는 ORC, BigQuery의 Capacitor, Snowflake의 마이크로 파티션, ClickHouse의 MergeTree 저장 방식에도 똑같이 적용됩니다. 구현 방식은 흥미로운 세부 사항에서 차이가 있습니다. 그러나 아키텍처는 같습니다.
글을 끝까지 읽고 나면, 왜 더 적은 컬럼을 선택하면 I/O가 줄어드는지, 왜 어떤 필터는 파일 수준에서 프루닝되고 어떤 필터는 그렇지 않은지, SELECT *가 물리적으로 실제로 어떤 비용을 발생시키는지, 그리고 왜 컬럼형 저장 방식이 분석에는 뛰어나지만 OLTP에는 형편없는지 설명할 수 있을 것입니다. 이 내용의 대부분은 여러 엔진에 그대로 적용됩니다.
왜 컬럼형 저장 방식이 존재하는가
전통적인 데이터베이스는 데이터를 행(row) 단위로 저장합니다. 예를 들어 직원 테이블의 한 행 — Alice, 28, Engineering, 95000, "123 Main St" — 은 디스크 상에서 함께 저장됩니다. Alice의 레코드를 읽기 위해 데이터베이스는 한 번의 I/O 작업으로 모든 필드를 가져옵니다. 이는 애플리케이션이 일반적으로 완전한 레코드 전체를 필요로 하는 트랜잭션 워크로드에 매우 적합합니다.
하지만 분석 작업에는 매우 비효율적입니다.
분석 쿼리에서 SELECT AVG(salary) FROM employees 와 같은 요청이 들어오면, 엔진은 급여(salary) 값을 얻기 위해 모든 행의 전체 데이터를 읽어야 합니다. 이름, 주소, 부서 등 실제로 필요하지 않은 모든 컬럼까지 함께 읽게 됩니다. 200개 컬럼을 가진 테이블이라면, 결국 사용하지 않을 199개 컬럼까지 읽고 있는 셈입니다.
컬럼형 저장 방식은 정반대로 동작합니다. 한 행의 필드들을 함께 저장하는 대신, 같은 컬럼의 값들을 모아서 저장합니다. 급여는 급여끼리, 이름은 이름끼리, 부서는 부서끼리 저장됩니다.
따라서 평균 급여를 계산할 때 엔진은 오직 salary 컬럼만 읽습니다. 나머지 199개 컬럼은 아예 접근조차 하지 않습니다.
분석 작업에서 컬럼형 저장 방식이 더 빠른 이유는 물리적으로 세 가지가 있습니다.
첫째, 읽는 데이터의 양이 훨씬 적습니다.
50개 컬럼을 가진 테이블에서 단 3개 컬럼만 사용하는 쿼리는 행 기반 저장 방식이라면 읽어야 할 데이터의 대부분을 그대로 읽지만, 컬럼형 저장에서는 대략 전체의 6% 정도만 읽으면 됩니다. 규모가 커질수록 이 차이는 더욱 커집니다. 예를 들어 10TB 테이블이라면 10TB 전체를 스캔하는 대신 약 600GB만 읽게 됩니다. 스캔한 바이트 수를 기준으로 비용을 부과하는 클라우드 웨어하우스(BigQuery의 온디맨드 요금제가 대표적인 사례)는 이런 절감 효과가 비용으로 바로 드러납니다.
둘째, 압축 효율이 훨씬 좋습니다.
부서(department) 컬럼의 값들을 함께 저장한다고 가정해 봅시다.
Engineering, Marketing, Sales, Engineering, Engineering, Sales, ...
이런 데이터는 값의 종류가 적고 반복이 많기 때문에 매우 잘 압축됩니다. Snappy, ZSTD, 딕셔너리 인코딩 같은 압축 알고리즘은 이러한 컬럼을 10~100배까지 줄일 수 있습니다. 반대로 행 단위 저장에서는 서로 다른 데이터 타입과 값 패턴이 한곳에 섞여 있기 때문에 압축 효율이 크게 떨어집니다. 같은 데이터를 저장하더라도 컬럼형 파일은 일반적으로 행 기반 파일보다 5~20배 더 작습니다.
셋째, 실행 속도가 더 빠릅니다.
현대 CPU는 SIMD(Single Instruction Multiple Data) 명령을 사용하여 동일한 타입의 배열을 벡터화 방식으로 처리할 수 있습니다. 예를 들어 하나의 CPU 명령으로 정수 8개를 동시에 더할 수 있습니다. 컬럼형 저장은 데이터를 정확히 이런 형태 — 하나의 타입으로 이루어진 큰 연속 배열 — 로 제공합니다.
쿼리 엔진은 이를 활용해 벡터화 실행(Vectorized Execution)을 수행하며, 한 번에 수천 개의 값을 처리합니다. 반면 행 기반 저장은 하나의 컬럼 값들이 파일 전체에 흩어져 있고 다른 컬럼 사이에 끼어 있기 때문에 이런 최적화를 적용하기 어렵습니다.
흥미로운 점은, 컬럼형 저장을 분석에 강하게 만드는 특성이 동시에 OLTP에는 약점이 된다는 것입니다.
- 한 행을 삽입하려면 모든 컬럼 블록을 수정해야 합니다.
- 하나의 필드만 수정해도 압축된 컬럼 블록을 다시 써야 할 수 있습니다.
- 하나의 완전한 레코드를 읽으려면 여러 컬럼을 모두 접근해야 합니다.
그래서 데이터베이스 세계는 크게 두 가지로 나뉘게 되었습니다.
- 애플리케이션을 위한 행 지향(Row-oriented) 시스템
- 분석을 위한 컬럼 지향(Columnar) 시스템
실제 기업들은 보통 이 둘을 함께 운영하며, 데이터를 서로 복제하면서 사용합니다.
컬럼형 파일 내부 구조
Parquet 파일은 계층적인(hierarchical) 구조를 가지고 있으며, 이 구조를 이해하는 것은 매우 중요합니다. 왜냐하면 이후에 등장하는 거의 모든 최적화 기법과 동작 방식이 이 구조를 기반으로 하기 때문입니다.
File
├── Row Group 1 (typically 128MB - 1GB of rows)
│ ├── Column Chunk: name
│ │ ├── Page 1 (compressed values)
│ │ ├── Page 2
│ │ └── Statistics: min, max, null count
│ ├── Column Chunk: age
│ │ ├── ... (same structure)
│ └── Column Chunk: department
├── Row Group 2
│ └── ...
└── Footer (file-level metadata)
├── Schema definition
└── Row group statistics (min, max per column per row group)
이 구조에서 특히 주목할 만한 몇 가지가 있습니다.
Row group은 행(row)의 배치(batch)입니다. 10GB 크기의 Parquet 파일은 10~50개의 row group으로 나뉘어 있을 수 있습니다. 각 row group 내부에서는 데이터가 컬럼형으로 저장되지만, 파일 전체는 row group 크기의 여러 조각으로 분할되어 있습니다. 이것은 의도적인 절충입니다. 완전한 컬럼형 저장 방식이라면 각 컬럼 전체가 하나의 거대한 블록으로 저장될 텐데, 그렇게 되면 병렬 읽기가 불가능해집니다(서로 다른 워커가 독립적으로 다른 부분을 읽을 수 없음). 또한 통계 기반 스킵(statistics-based skipping)도 불가능해집니다(컬럼의 일부 구간만 건너뛸 수 없음). Row group은 이 두 문제를 해결합니다. 각 row group은 독립적으로 읽을 수 있는 자기완결적인 조각이며, 자체 통계를 가지고 있습니다.
각 column chunk는 footer에 최소값/최대값(min/max) 통계를 저장합니다. 쿼리 엔진이 파일을 읽을 때 가장 먼저 읽는 것은 footer입니다. Footer는 예를 들어 다음과 같은 정보를 알려줍니다. row group 5에는 최소값이 “Alpha”, 최대값이 “Charlie”인 name 값들이 포함되어 있다. 만약 쿼리에 WHERE name = 'Zoe' 조건이 있다면, 엔진은 row group 5에 조건을 만족하는 값이 존재할 수 없다는 것을 알고 해당 row group 전체를 건너뜁니다. 실제 데이터 페이지는 전혀 읽지 않습니다.
이것을 predicate pushdown이라고 하며, 컬럼형 포맷이 선택적인 쿼리(selective queries)에 대해 선형 이하(sub-linear)의 스캔 성능을 달성하는 핵심 원리입니다. 대부분의 최적화 작업은 데이터를 읽는 일이 아닙니다. 데이터를 읽지 않는 것입니다. Footer는 어떤 row group을 건너뛸지 결정하고, 컬럼형 저장은 관련 없는 컬럼을 건너뛸 수 있게 하며, 이 두 단계를 거친 이후에야 실제 데이터 페이지가 압축 해제됩니다.
Page는 압축의 기본 단위입니다. Column chunk 내부에서 데이터는 일반적으로 몇 MB 정도 크기의 여러 page로 나뉩니다. 각 page는 독립적으로 압축되며 자체 통계를 가집니다. 이를 통해 쿼리 실행 중 더 세밀한 수준의 스킵이 가능해지고, 각 컬럼의 데이터 특성에 따라 서로 다른 압축 코덱을 적용할 수 있습니다.

여기서 꼭 가져가야 할 관점은 이것입니다. Parquet 파일은 단순한 파일이 아닙니다. 그것은 자기 설명이 가능하고(self-describing), 압축되어 있으며(compressed), 풍부한 통계 정보를 담고 있고(statistics-rich), 병렬 읽기가 가능한(parallel-readable) 분석용 아티팩트입니다. 파일 포맷 자체가 디스크 위에 구현된 쿼리 최적화 데이터 구조인 것입니다.
다른 포맷들도 세부 구현은 다르지만 동일한 원리를 따릅니다. ORC는 footer 수준의 통계를 가진 “stripe” 구조로 데이터를 구성합니다. BigQuery의 Capacitor는 블록별 메타데이터를 가진 컬럼형 블록 구조를 사용합니다. Snowflake의 micropartition 역시 통계를 가진 독립적인 컬럼형 단위입니다. 사용하는 용어는 다르지만 아키텍처는 동일합니다.
행이 컬럼으로부터 어떻게 다시 구성되는가
이 질문은 실제보다 훨씬 어렵게 들립니다.
데이터를 컬럼 단위로 저장하면 원래 하나의 행에 속해 있던 값들이 물리적으로 분리됩니다. 하나의 논리적 행에 속했던 name, age, department 값이 이제 디스크의 서로 다른 위치에 저장됩니다. 그렇다면 엔진은 어떤 age 값이 어떤 name 값과 대응되는지 어떻게 알 수 있을까요?
답은 생각보다 단순합니다. 위치 기반 대응(position-based correspondence) 입니다.
하나의 row group 내부에서는 모든 column chunk가 정확히 같은 개수의 값을 가지고 있으며, 정확히 같은 순서로 저장됩니다. name 컬럼의 N번째 값은 age 컬럼의 N번째 값과 대응되고, 다시 department 컬럼의 N번째 값과 대응됩니다. 별도의 row ID도 없고, foreign key도 없으며, join key도 없습니다. 대응 관계는 위치 자체에 암묵적으로 포함되어 있습니다.
만약 row group 1에 100,000개의 행이 있다면:
- name 컬럼 chunk에는 정확히 100,000개의 값이 있습니다.
- age 컬럼 chunk에도 정확히 100,000개의 값이 있습니다.
- department 컬럼 chunk에도 정확히 100,000개의 값이 있습니다.
세 컬럼 모두에서 47,293번째 위치는 동일한 논리적 행을 의미합니다.
“47,293번째 행을 읽는다”는 것은 필요한 각 컬럼에서 47,293번째 값을 읽어 메모리에서 다시 조합(stitching)하는 것을 의미합니다. 엔진은 명시적인 row ID를 전혀 필요로 하지 않습니다. 위치 자체가 곧 row ID입니다.
이것이 row group이 하나의 단위로 존재하는 이유입니다. row group이 없다면 읽기 작업을 쉽게 병렬화할 수 없습니다. 하지만 row group이 있으면 파일은 여러 개의 독립적인 구간으로 분할되고, 각 구간은 독립적으로 읽을 수 있으며, 각 구간 내부에서는 자체적인 위치 기반 대응 관계를 가집니다. Footer는 각 column chunk의 바이트 오프셋(byte offset)을 통해 각 row group이 파일의 어디에서 시작되는지 알려줍니다. 따라서 특정 row group으로 이동하는 것은 순차 스캔이 아니라 직접 탐색(direct seek)이 됩니다.
컬럼형 저장의 진짜 영리한 점은 행을 다시 구성하는 방식에 있는 것이 아닙니다. 그 부분은 사실 단순합니다. 진짜 영리한 점은 대부분의 경우 완전한 행 자체를 다시 구성할 필요가 없다는 데 있습니다. 대부분의 분석 쿼리는 일부 컬럼만 필요로 하기 때문에 대부분의 컬럼은 아예 접근되지 않습니다. 그리고 메타데이터 기반 프루닝(metadata-based pruning) 덕분에 대부분의 row group 역시 읽지 않습니다. 행 재구성은 두 단계의 필터를 모두 통과한 아주 작은 데이터 조각에 대해서만 발생합니다.
이것이 진짜 효율입니다. 행을 더 빠르게 조립하는 것이 아니라, 대부분의 데이터에 대해 행 조립 자체를 하지 않는 것입니다.
레이아웃을 활용하는 압축 전략
컬럼형 저장 방식의 압축 이점은 우연이 아닙니다. 이 포맷은 행 기반 저장에서는 현실적으로 적용하기 어려운 특정 압축 기법들을 가능하게 합니다.
Dictionary encoding. 컬럼의 카디널리티(cardinality)가 낮은 경우 — 예를 들어 수백만 개 행에 걸쳐 고유값이 200개뿐인 country_code 컬럼 — 엔진은 각 고유값을 작은 정수 ID에 매핑하는 딕셔너리를 생성합니다. 실제 컬럼 데이터는 반복되는 문자열 대신 정수 ID의 시퀀스로 저장됩니다. 딕셔너리는 한 번만 저장됩니다. 반복값이 많은 컬럼에서는 절감 효과가 매우 큽니다. 길이가 10자인 국가명이 원본 형태에서는 행당 10바이트를 사용하지만, 딕셔너리 인코딩을 사용하면 행당 1~2바이트 정도만 사용하게 됩니다.
Run-length encoding(RLE). 컬럼에 동일한 값이 길게 반복되는 경우 — 정렬되거나 클러스터링된 데이터에서 흔한 패턴입니다 — RLE는 반복 구간을 하나의 값과 개수로 치환합니다. 예를 들어 “Engineering, Engineering, Engineering, Engineering, Engineering”은 “Engineering × 5”로 저장됩니다. 정렬된 불리언 컬럼이나 낮은 카디널리티 컬럼에서는 데이터 크기를 수십 배에서 수백 배까지 줄일 수 있습니다.
Delta encoding. 연속된 행 간 값의 변화가 작은 경우 — 타임스탬프, 순차 ID, 정렬된 숫자 데이터 등이 대표적입니다 — 엔진은 전체 값을 저장하는 대신 이전 값과의 차이만 저장합니다. 예를 들어 1초 간격으로 증가하는 타임스탬프 컬럼은 “1, 1, 1, 1, …” 형태의 차이값 시퀀스로 저장되고, 이후 RLE를 통해 다시 압축될 수 있습니다.
Bit-packing. 실제 값이 선언된 타입보다 적은 비트 수로 표현 가능한 경우 — 예를 들어 모든 값이 0~100 사이인 NUMERIC 컬럼은 전체 64비트가 필요하지 않습니다 — 엔진은 필요한 최소 비트 폭만 사용해 값을 밀집 저장합니다.
이 기법들은 조합해서 사용할 수 있습니다. 하나의 컬럼은 먼저 딕셔너리 인코딩을 적용한 뒤, 생성된 딕셔너리 ID에 대해 run-length encoding을 적용하고, 마지막으로 run-length의 개수 정보에 bit-packing을 적용할 수 있습니다. 결과적으로 데이터는 원본 대비 극히 작은 크기로 저장되면서도 전체 압축 해제 없이 바로 쿼리할 수 있습니다.
이것이 자주 조회하는 컬럼 기준으로 데이터를 미리 정렬하는 작업 — 대부분의 웨어하우스에서 “클러스터링(clustering)”이라고 부르는 방식 — 이 예상보다 훨씬 큰 성능 효과를 내는 이유입니다. 정렬된 데이터는 run-length encoding과 delta encoding의 압축 효율을 크게 높이고, 이는 더 작은 파일 크기, 더 적은 I/O, 더 빠른 쿼리로 이어집니다.
성능 향상은 단순히 쿼리 시점의 프루닝(pruning) 때문만이 아닙니다. 정돈된 데이터 위에서 기반 압축 알고리즘 자체가 더 잘 동작하기 때문입니다.
Predicate Pushdown이 실제로 하는 일
Predicate pushdown은 파일 footer에 저장된 메타데이터를 이용해 실제 데이터를 읽기 전에 먼저 필터링하는 엔진의 능력을 의미하는 용어입니다.
구체적인 예를 들어봅시다.
여러분에게 1년치 주문 데이터가 들어 있는 100GB Parquet 파일이 있다고 가정합니다. 그리고 다음 쿼리를 실행합니다.
SELECT customer_id, order_total
FROM orders
WHERE order_date >= '2026-01-01' AND order_date < '2026-02-01';
순차적 엔진이라면 파일 전체를 읽습니다. 즉, 100GB 전체를 읽은 뒤에 그 다음 단계에서 행을 필터링합니다. 하지만 pushdown을 지원하는 엔진은 다르게 동작합니다.
먼저 파일의 footer를 읽습니다(작고 빠름). Footer에는 각 row group에 대해 order_date의 최소값과 최대값이 저장되어 있습니다. 엔진은 row group 1을 확인합니다. 최소 order_date는 2025-04-15이고 최대값은 2025-04-22입니다. 쿼리의 날짜 범위 밖입니다. 따라서 이 row group 전체를 건너뜁니다.
- Row group 2: 최소 2025–04–23, 최대 2025–05–01. 건너뜀.
- Row group 47: 최소 2025–12–29, 최대 2026–01–04. 조건과 일치 가능. 이 row group을 엽니다.
- Row group 48: 최소 2026–01–04, 최대 2026–01–12. 조건과 일치 가능. 이 row group을 엽니다.
결국 엔진은 200개의 row group 중 4~5개 정도만 실제로 열게 됩니다. 그리고 그 안에서도 customer_id, order_total, order_date 컬럼만 읽고, 테이블에 존재하는 나머지 47개 컬럼은 무시합니다. order_date 컬럼은 각 행이 필터 조건을 만족하는지 확인하기 위해 읽고, 나머지 두 컬럼은 조건을 통과한 행에 대해서만 읽습니다.
최종적으로 읽은 데이터는 원래 100GB 중 약 200MB 정도에 불과할 수 있습니다. 나머지 99.8%의 데이터는 디스크에서 한 번도 이동하지 않았습니다.
여기서 여러분이 데이터 분야에서 계속 들어왔던 최적화 조언 — 필터를 일찍 적용하라(filter early), 필요한 컬럼만 선택하라(select specific columns), SELECT *를 피하라 — 의 물리적 기반이 드러납니다. 각각의 조언은 pushdown 시스템의 특정 계층과 직접 연결됩니다.
- 파티션 키로 필터링한다 → 애초에 열어야 하는 파일 수가 줄어든다.
- 클러스터링되거나 정렬된 컬럼으로 필터링한다 → 파일 내부에서 읽어야 하는 row group 수가 줄어든다.
- 특정 컬럼만 선택한다 → 각 row group 내부에서 압축 해제해야 하는 column chunk 수가 줄어든다.
- SELECT *를 피한다 → 다른 필터를 통과한 모든 row group에 대해 모든 column chunk를 읽게 된다.
이 조언들은 임의로 만들어진 규칙이 아닙니다. 저장 계층이 실제로 구성된 방식이 그대로 반영된 결과입니다.
프루닝(pruning)의 한계
자연스럽게 이런 질문이 따라옵니다. 모든 컬럼에 최소값/최대값(min/max) 통계가 있다면, 왜 모든 컬럼의 필터가 파티션 필터처럼 스캔 데이터를 줄여주지 않을까요?
답은 놓치기 쉬운 한 가지 구분에 있습니다. 프루닝이 발생할 수 있는 계층은 두 가지입니다.
File-level pruning은 파일을 열기 전에 발생합니다. 이 방식은 파일 자체를 읽지 않고도 접근 가능한 메타데이터를 사용합니다. 대표적인 예가 파티셔닝입니다. 파티셔닝된 테이블은 각 파티션 값마다 별도의 물리적 파일(또는 디렉터리)로 분리됩니다. 쿼리 엔진은 보통 디렉터리 목록이나 매니페스트 파일 같은 테이블 수준의 메타데이터를 통해 파티셔닝 구조를 알고 있으며, 파일을 열어보지도 않고 전체 파일을 건너뛸 수 있습니다.
Block-level pruning은 파일이 열린 이후에 발생합니다. 이 방식은 파일 footer에 저장된 블록별 통계를 사용합니다. 따라서 프루닝을 수행하려면 적어도 footer 정도는 읽어야 하며, 이는 파일이 부분적으로라도 접근된다는 의미입니다.
이 둘은 비용도 다르고, 확장 특성도 다르며, 완전히 다른 연산입니다.
작은 테이블에서는 — 몇 개의 파일과 몇 개의 row group 정도로 구성된 경우 — 두 종류의 프루닝 모두 비용이 작습니다. Footer 자체가 작기 때문에 이를 읽어 프루닝 여부를 판단하는 것도 빠릅니다. 현대의 컬럼형 엔진은 일반적으로 block-level pruning을 기본적으로 수행합니다.
하지만 매우 큰 테이블에서는 — 예를 들어 수십억 개 행이 수백만 개 블록으로 분산된 경우 — 메타데이터 자체가 상당한 규모가 됩니다. 모든 블록의 모든 컬럼에 대한 최소값과 최대값을 저장하면 메타데이터 크기만 해도 TB 단위까지 커질 수 있습니다. 이 정도 규모에서는 프루닝 결정을 위해 메타데이터를 읽는 행위 자체가 성능 문제가 되기 시작합니다. 그래서 대부분의 엔진은 역사적으로 대규모 환경에서 block-level pruning에 사용할 컬럼을 선택적으로 제한해 왔습니다. 보통은 파티션 컬럼과 정렬/클러스터 키가 그 대상입니다.
이 때문에 클러스터링은 사람들이 생각하는 것보다 훨씬 중요합니다. 클러스터링은 특정 컬럼 기준으로 파티션 내부의 행을 정렬합니다. 그러면 해당 컬럼의 블록별 최소값/최대값 범위가 매우 좁아집니다. 각 블록이 좁은 값 범위를 가지게 되는 것입니다. 이렇게 통계가 촘촘해지면 block-level pruning이 매우 효과적으로 동작합니다. 클러스터된 컬럼에 대한 필터 쿼리는 많은 블록의 최소/최대 범위가 조건과 겹치지 않는다는 것을 바로 판단할 수 있기 때문에 대량의 블록을 건너뛸 수 있습니다.
반대로 클러스터되지 않은 컬럼은 통계 자체는 존재하지만 범위가 넓어지는 경향이 있습니다. 예를 들어 customer_id가 무작위로 분포되어 있다면 거의 모든 블록의 최소값이 "cust_001"에 가깝고 최대값이 "cust_999"에 가까워질 수 있습니다. 이렇게 넓은 통계 범위에서는 프루닝 효과가 거의 없습니다. 결국 필터 조건은 실제 데이터 읽기 단계까지 전달됩니다.
여기서 얻을 수 있는 원칙은 이것입니다. 컬럼형 저장의 프루닝 성능은 데이터가 물리적으로 어떻게 배치되어 있는지에 달려 있습니다. 데이터가 무작위로 분포되어 있으면 통계가 존재하더라도 프루닝은 제대로 동작하지 않습니다. 반대로 클러스터되거나 정렬된 분포는 프루닝을 효과적으로 만듭니다.
따라서 어떤 컬럼을 클러스터링 대상으로 선택하는가는 결국 어떤 필터를 저렴하게 만들고, 어떤 필터를 비싸게 만들 것인지 선택하는 일입니다.
이것이 실제로 의미하는 것
수년간 옳았던 최적화 조언들은 여전히 유효합니다. 하지만 저장 계층을 이해하면 그 조언들의 의미와 무게가 달라집니다.
SELECT *가 비싼 이유는 컬럼형 저장이 선택성(selectivity)을 의미 있게 만들기 때문입니다. 선택하지 않은 모든 column chunk는 읽지 않는 데이터입니다. 반대로 선택한 모든 column chunk는 압축 해제되고 처리됩니다. 200개 컬럼을 가진 테이블에서 SELECT *는 각 row group마다 200개의 column chunk를 읽습니다. 반면 SELECT col1, col2는 단 두 개만 읽습니다. 비용 차이는 대체로 컬럼 수에 비례합니다.
가능하다면 파티셔닝 컬럼으로 필터링하세요. 이것이 가장 저렴한 필터입니다. 파일조차 열지 않습니다. 파티셔닝을 직접 설계할 수 있는 시스템에서는 어떤 시간 범위나 카테고리 조건이 쿼리에서 가장 자주 사용되는지를 기준으로 파티션 컬럼을 결정해야 합니다.
자주 필터링하는 컬럼을 기준으로 클러스터링하세요. 특히 파티션 필터가 작업 범위를 줄인 이후라면 더욱 중요합니다. 클러스터링은 블록 수준 통계를 더 촘촘하게 만들고, 그 결과 block-level pruning이 효과적으로 동작합니다. 클러스터링이 없다면 block-level 필터는 종종 파티션 프루닝 이후 남은 데이터 전체를 스캔하는 수준으로 성능이 떨어집니다.
무작위로 분포된 비클러스터 컬럼에 대한 필터는 공짜는 아니지만, 생각만큼 비싸지도 않습니다. block-level pruning이 실패하더라도 엔진은 여전히 쿼리에서 참조된 컬럼만 읽습니다. 전체 행을 읽지는 않습니다. 비용은 얼마나 많은 column chunk를 읽는지, 그리고 각 chunk를 얼마나 압축 해제해야 하는지에 비례합니다.
압축은 프루닝만큼 중요합니다. 잘 클러스터된 컬럼은 무작위 분포 컬럼보다 훨씬 더 잘 압축됩니다. 이는 프루닝 이전 단계부터 이동해야 하는 데이터와 압축 해제해야 하는 데이터 자체가 줄어든다는 의미입니다. 이 두 효과는 서로 누적됩니다.
이 규칙들은 모든 컬럼형 시스템에 적용됩니다. 이름은 조금씩 다릅니다. partitioning과 partition key, clustering과 sort key, row group과 stripe와 micropartition처럼 용어는 달라집니다. 하지만 그 아래에서 동작하는 메커니즘은 놀라울 정도로 일관되어 있습니다.
중요한 핵심 사항
이 모든 내용을 관통하는 가장 중요한 원칙은 컬럼형 저장 방식이 분석을 빠르게 만드는 건, 엔진이 대부분의 데이터를 굳이 읽지 않고 건너뛸 수 있다는 것입니다. 실제 계산, 예를 들어 평균 급여를 구한다거나 매출을 더하거나 행 개수를 세는 작업은 대체로 병목이 아니고, 진짜 병목은 디스크에서 메모리로 데이터를 옮기는 과정에서 생깁니다. 그래서 현대 컬럼형 포맷의 모든 아키텍처적인 결정은 어떻게 하면 더 적은 바이트만 옮길 수 있을지에 초점이 맞춰져 있습니다.
이 목표는 여러 계층에서 구현됩니다. 컬럼 단위 스킵 기능을 사용하면 실제로 필요한 컬럼만 읽게 할 수 있고, row group 단위의 스킵은 필터 조건에 맞을 법한 데이터 조각만 읽도록 도와줍니다. 압축을 통해 읽어야 할 데이터의 양 자체를 줄이고, 벡터화 실행을 통해 남은 데이터를 효율적으로 처리할 수 있습니다.
이런 계층들은 각자 따로 움직이는 독립적인 최적화가 아니라, 하나의 유기적인 시스템처럼 맞물려 돌아갑니다. 파티셔닝이나 클러스터링은 스킵 기능이 제대로 작동하도록 설계됩니다. 압축 역시 읽기를 더 효율적으로 만들기 위해서고요. 위치 기반 구조 덕분에 꼭 필요한 행만 다시 조립하면 됩니다. 이 중 하나라도 빠지면 전체 성능이 한꺼번에 떨어질 수밖에 없습니다.
여기서 자연스럽게 따라올 수 있는 최적화 조언이 몇 가지 있습니다. 예를 들어 쿼리를 자주 시간 조건으로 필터링한다면, 시간 기준으로 파티션 키를 잡으세요. 자주 사용하는 카테고리나 식별자가 있다면 그걸 클러스터링 키로 삼는 게 좋습니다. 필요 없는 컬럼은 아예 선택하지 말고, 필터링도 최대한 일찍 해주는 게 효율적입니다. 사실 이런 조언들 자체는 익숙할 수 있습니다. 하지만 저층의 저장 구조와 원리를 이해하고 나면, 단순히 지켜야 할 규칙이 아니라 시스템이 원래 그렇게 동작하도록 만들어졌다는 사실이 보이기 시작합니다.
이런 원칙들은 시스템을 가리지 않고 대부분 통합니다. Parquet, ORC, Capacitor, Snowflake의 마이크로파티션, ClickHouse의 MergeTree 등 세부 구현은 다양하지만 결국 비슷한 아키텍처를 따르고 있거든요. 아키텍처만 이해하면 같은 계열의 다른 시스템도 쉽게 익힐 수 있습니다. 지금 쓰는 도구가 5년 뒤까지 살아남을지 알 수 없지만, 이런 근본적인 아키텍처는 앞으로도 바뀌지 않을 가능성이 높습니다.
<출처: https://medium.com/@lasha-dolenjashvili/how-columnar-storage-actually-works-17a4c5a0a3d3>
'EPL과 유튜브 데이터로 배우는 DuckDB' 카테고리의 다른 글
| 고급 SQL 분석(Analytics)을 마스터하는 9가지 방법 (0) | 2026.05.28 |
|---|---|
| DuckDB Row Group과 statistics: pruning으로 밀리초 단위 성능 달성하기 (0) | 2026.05.26 |
| 데이터 엔지니어를 위한 DuckDB (0) | 2026.05.22 |
| 작은 거인들: 제한된 리소스 환경에서 Postgres, MySQL, ClickHouse, DuckDB 벤치마킹하기 (0) | 2026.05.20 |
| Airflow, DuckDB, Streamlit으로 StarCraft 2 데이터 탐색하기 (0) | 2026.05.20 |
댓글