ClickHouse — 열 지향 분석 DB 를 속까지 · 열로 저장한다는 것 · 이론
열로 저장한다는 것 — 파트·열 파일·압축
한 줄 요약
ClickHouse 의 MergeTree 는 행을 정렬 키 순서로 정렬해 열마다 따로 압축한 파일 묶음(파트) 으로 저장한다. 그래서 쿼리의 비용은 몇 행을 고르느냐보다 어느 열을 건드리느냐와 정렬 키로 얼마나 건너뛰느냐가 정한다.
왜 이게 필요했나
행 지향 DB 에 분석 쿼리를 던지면 "매출 합계 하나를 구하는데 왜 테이블 전체를 읽나" 라는 질문을 곧 만난다. 행 지향 저장은 한 행의 모든 열이 붙어 있어서, amount 한 열만 더하려 해도 디스크에서는 행 전체를 퍼 올린다. 거래 처리에는 그게 맞다 — 주문 한 건을 통째로 읽고 쓰니까. 그런데 분석은 반대다. 수억 행에서 두세 열만 읽는다.
열 지향 저장은 이 비대칭을 뒤집는다. 같은 열의 값끼리 모아 두면 쿼리가 쓰는 열만 읽으면 되고, 비슷한 값이 이웃해 있으니 압축도 훨씬 잘 된다. 대신 "행 하나를 바꾸기" 는 비싸진다. 열 파일 여러 개를 건드려야 하기 때문이다. ClickHouse 의 설계 결정 대부분 — 한 번 쓴 파트는 고치지 않는다, 삭제와 갱신은 나중에 병합 때 처리한다 — 은 이 트레이드오프에서 나온다.
어떻게 동작하나
공식 문서(Table parts)가 적은 INSERT 의 네 단계가 곧 저장 구조다.
1. 들어온 행을 표의 정렬 키(ORDER BY) 순서로 정렬하고 성긴 기본 인덱스를 만든다
2. 정렬된 행을 열로 쪼갠다
3. 열마다 압축한다
4. 압축한 열 파일과 인덱스를 새 파트 디렉터리 하나에 쓴다
파트는 한 번 쓰이면 바뀌지 않는다(immutable). INSERT 가 올 때마다 파트가 하나씩 늘고, 백그라운드 병합이 작은 파트들을 큰 파트로 합친다. 합쳐진 옛 파트는 비활성(inactive)이 되었다가 지워진다. 파트 이름 all_1_2_1 은 파티션(all), 포함한 블록 번호의 처음과 끝(1–2), 병합 수준(1)이다. 수준 0 은 아직 한 번도 합쳐지지 않은 파트다.
열 파일 안은 그래뉼로 나뉜다. 기본 8192행이 한 그래뉼이고, 처리의 최소 단위다. 기본 인덱스는 모든 행이 아니라 그래뉼마다 첫 행의 키 값 하나만 적는다 — 그래서 "성긴(sparse)" 인덱스다. WHERE site = 'docs.example' 이 오면 인덱스를 훑어 그 값이 있을 수 없는 그래뉼을 통째로 건너뛴다. 정렬 키에 없는 열로 거르면 건너뛸 근거가 없어 전부 읽는다. system.parts 의 marks 는 그래뉼마다 하나씩 붙는 위치 표시의 수로, 끝을 알리는 마크 하나가 더해진다(200만 행이면 그래뉼 245개에 마크 246개).
압축률은 열마다 크게 다르다. 같은 값이 길게 이어지는 열(정렬 키 첫 열, 종류가 몇 개 없는 열)은 수백 배로 줄고, 무작위에 가까운 정수는 거의 줄지 않는다. LowCardinality(String) 는 문자열을 사전 번호로 바꿔 저장하는 타입이라 사이트 이름 다섯 개짜리 열은 거의 공짜가 된다. 반대로 시각처럼 계속 커지는 정수는 기본 코덱(LZ4)만으로는 줄지 않는다 — 이것을 줄이는 코덱은 뒤의 모듈에서 다룬다.
SELECT name, data_compressed_bytes, data_uncompressed_bytesFROM system.columns WHERE database = 'col' AND table = 'events'; -- 열별 크기SELECT sum(dur_ms) FROM col.events FORMAT JSON; -- 맨 끝 statistics 에 rows_read · bytes_read비용을 재는 눈금은 두 개다. rows_read 는 건너뛰지 못하고 읽은 행 수이고, bytes_read 는 그 행들에서 건드린 열의 (압축을 푼) 바이트다. UInt32 한 열의 합을 구하면 행당 4바이트, 200만 행이면 정확히 8,000,000 바이트다. 같은 200만 행이라도 긴 문자열 열의 내용을 읽으면 몇 배가 된다. 한 가지 함정 — length(url) 은 문자열 내용이 아니라 길이만 담은 부분열(url.size)을 읽도록 바뀌므로 행당 8바이트만 읽는다. 무엇을 읽었는지는 짐작하지 말고 숫자로 확인한다.
모든 쿼리의 이 숫자는 system.query_log 에도 남는다. SETTINGS log_comment = '...' 로 꼬리표를 달아 두면 나중에 자기 쿼리를 찾기 쉽다. 로그 표는 약 1초마다 비워지므로 방금 돌린 쿼리를 바로 보려면 SYSTEM FLUSH LOGS 를 먼저 친다.
마지막으로 파트의 형식. 파트가 작으면 모든 열을 한 파일에 담는 Compact, 크면 열마다 파일을 따로 두는 Wide 가 된다. 경계는 표 설정 min_bytes_for_wide_part·min_rows_for_wide_part 가 정한다. 작은 INSERT 가 잦을 때 파일 수가 폭증하지 않게 하려는 장치다.
현장에서 만나는 모습
분석 대시보드가 느리다는 신고를 받으면 가장 먼저 SELECT * 를 찾는다. 행 지향 DB 에서는 거의 공짜였던 습관이 열 지향 저장에서는 모든 열 파일을 여는 일이 된다. 화면에 필요한 열만 고르게 바꾸는 것만으로 읽는 바이트가 몇 분의 일로 줄어드는 경우가 흔하다.
두 번째로 흔한 것은 정렬 키를 "기본 키니까 id" 로 잡은 표다. 거의 모든 쿼리가 사이트와 기간으로 거르는데 정렬 키가 id 라면, 인덱스는 있지만 한 번도 건너뛰지 못한다. rows_read 가 늘 전체 행 수와 같다면 정렬 키를 의심한다.
세 번째는 용량 계획이다. "원본 로그가 하루 100GB 니까 디스크는 100GB × 보관일" 로 잡으면 크게 틀린다. 열마다 압축률이 수 배에서 수백 배까지 다르므로, 실제 자료 하루치를 넣어 system.columns 의 압축 후 크기를 재고 거기서 곱하는 것이 맞다.
다음 실습에서 할 것
col.events 표를 정렬 키 (site, ts) 로 만들어 200만 행을 넣고, 파트를 하나로 합쳐 이름·행 수·마크 수를 system.parts 에서 읽는다. 열별 압축률에서 가장 잘 줄어든 열과 가장 덜 줄어든 열을 찾고, 한 열만 읽는 쿼리와 긴 문자열 열을 읽는 쿼리의 bytes_read 를 비교한다. 정렬 키로 거를 때와 키 밖의 열로 거를 때의 rows_read 를 재고, query_log 에서 자기 쿼리를 찾은 뒤, 작은 표를 하나 더 만들어 Compact 파트를 확인한다.