ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [OLAP] ClickHouse 살펴보기
    Software Development/Database 2026. 1. 4. 20:00

    들어가며[1]

    클릭하우스는 OLAP 기반의 고성능, 컬럼기반 SQL DBMS이다. 오픈소스와 클라우드로 이용할 수 있다.

    OLAP이란 무엇일까? 대용량 데이터셋에 대해서 집계, 산술연산, 스트링 프로세싱이 SQL로 가능하다.

    OLTP는 쿼리당 작은 row를 읽어서 반환하는 것과 다르게 OLAP은 수십억 로우 이상에 대해서 분석 쿼리가 가능하다.

    OLAP의 특징은 real-time이라는 수식어가 붙는데 이는 초단위 내로 분석 쿼리 결과를 반환하는 시스템을 의미한다.

     

    아래의 쿼리 질의에 대해서 강력한 성능을 Row 기반보다 빠르게 제공하는게 Column 기반 스토리지의 특징이다.

    SELECT MobilePhoneModel, COUNT() AS c
    FROM metrica.hits
    WHERE
          RegionID = 229
      AND EventDate >= '2013-07-01'
      AND EventDate <= '2013-07-31'
      AND MobilePhone != 0
      AND MobilePhoneModel not in ['', 'iPad']
    GROUP BY MobilePhoneModel
    ORDER BY c DESC
    LIMIT 8;

     

    이외에도 데이터 복제를 통한 가용성을 제공하고 RBAC, Adaptive 조인 기반 쿼리 최적화, 근사치 계산 등 일반적인 집계 쿼리 성능을 최적화하는 주요한 기능을 제공한다.

     

    Why is ClickHouse so fast?[2]

    그러면 Clickhouse가 다른 OLAP에 비해서 왜 빠른 성능을 제공하는지 알아보자.

    아키텍처 관점에서 Clickhouse는 스토리지 레이어와 쿼리 프로세싱 레이어로 구성된다. 스토리지 레이어는 데이터를 저장하고 로드하고 테이블 데이터를 운영하는 역할을 맡고 쿼리 프로세싱 레이어가 유저의 쿼리를 실행하는 역할을 담당한다.

    Clickhouse에서 테이블은 여러개의 "table part"로 구성된다. 

    사용자가 테이블에 데이터를 삽입(INSERT 문)할 때마다 하나의 파트(part)가 생성된다. 모든 쿼리는 해당 쿼리가 시작되는 시점에 존재하는 모든 테이블 파트를 대상으로 실행된다.

    너무 많은 파트가 쌓이는 것을 방지하기 위해 Clickhouse는 백그라운드에서 여러 작은 파트를 하나의 파트로 합치는 작업을 진행한다.

    이러한 방식은 장점이 있는데 모든 데이터 처리를 백그라운드에서 진행하기 때문에 데이터 쓰기 작업을 작고 효율적으로 운영할 수 있다.

    개별 INSERT는 테이블 단위로 업데이트에 영향을 받지 않는 구조이기 때문에 여러 INSERT가 동시에 발생해도 동기화가 필요가 없어 디스크 I/O 성능과 대등한 성능을 제공한다.

    그리고 INSERT는 SELECT와 완벽하게 격리된다. 그렇기 때문에 서로의 영향도가 없이 쿼리의 동시성을 해치지 않는다.

    다른 DB와 달리 추가적인 데이터 변환 작업을 백그라운드 Merge 프로세스에서 실행하기 때문에 데이터 쓰기 작업을 가볍고 효율적으로 유지한다.

    - Replacing merges: 최신 버전의 row만 남기고 다른 버전의 row는 삭제한다. Merge 시점에 수행되는 정리 작업이다. 

    - Aggregating merges: input part에 중간 집계를 결합하여 새로운 집계 상태를 생성한다. 증분 집계라 보면 되겠다. 

    - TTL (time-to-live) merges: 시간 기반으로 특정 행을 압축, 이동, 삭제한다. 

     

    즉, 위 작업을 통해서 사용자 쿼리 런타임에서 실행이 필요한 연산을 Merge시에 미리 해놓는 것이라 보면 되겠다. 이는 두가지 측면에서 중요하다 볼 수 있다.

    사용자의 쿼리가 1000배 가량 빨라질 수 있다는 것이다. 즉 사전 집계 데이터셋을 사용해서 이렇게 빨라질 수 있는 것이다.

    Merge 실행 시간의 대부분은 Input 파트를 로드하고 Output 파트를 저장하는 데 소요된다. 따라서 머지 중에 데이터를 변환하는 추가적인 노력은 대개 전체 머지 시간에 큰 영향을 주지 않는다.

    즉, Disk I/O가 문제이지 데이터 입출력이 발생할 때 변환 작업에 대한 시간은 크게 중요하지 않다는 것을 말해준다.

     

    그리고 일반적으로 분석 쿼리는 다이나믹하기 보다는 일정한 조회 패턴을 보인다. 즉 집계 쿼리를 거의 동일하고 쿼리 실행 시 들어가는 매개변수가 달라진다고 보면 되겠다. 

    일정한 조회 패턴이 있는 경우, 인덱스를 구성하거나 테이블을 재구성하여 쿼리 성능을 최적화할 수 있다. 이러한 기술을 'Data Pruning'이라 부르며 클릭하우스는 3가지 기술을 제공한다.

    - Primary key indexes: 테이블 데이터의 정렬 순서를 정의한다. 기본 키를 잘 선택하면 전체 컬럼 스캔 대신 빠른 이진 탐색을 통해 필터 조건(예: 위 쿼리의 WHERE 절)을 평가할 수 있다.

    - Table projections: 동일한 데이터를 저장하되 다른 기본 키로 정렬된 테이블을 저장한다. 빈번하게 사용되는 필터 조건이 둘 이상일 때 유용하다.

    - Skipping indexes: 컬럼에 추가적인 데이터 통계(예: 컬럼의 최솟값 및 최댓값, 고유 값 집합 등)를 저장한다. 스키핑 인덱스는 기본 키 및 테이블 프로젝션과 독립적으로 작동하며, 컬럼의 데이터 분포에 따라 필터 평가 속도를 크게 높일 수 있다.

     

    클릭하우스는 데이터 압축을 제공한다. ZSTD와 같은 데이터 압축은 데이터베이스 테이블의 저장 크기를 줄여줄 뿐만 아니라, 로컬 디스크 및 네트워크 I/O의 낮은 대역폭이 병목이 되는 경우가 많기 때문에 많은 경우 쿼리 성능까지 향상시킨다.

     

    State-of-the-art query processing layer

    클릭하우스는  모든 리소스의 성능과 효율성을 위해 쿼리 실행을 최대한 병렬화하는 vectorized query processing layer를 제공한다.

    '벡터화(Vectorization)'란 무엇일까? 쿼리 실행의 중간 결과값을 row 단위가 아닌 batch 단위로 처리하게 된다는 것을 의미한다. 이는 CPU 활용도를 최대한 높이고 SIMD(Single Instruction Multiple Data)를 적용하여 여러 값을 한 번에 처리할 수 있다.

     

    벡터화 연산 예시를 조금 더 살펴보자. 핵심은 "데이터를 한 번에 하나씩 처리하느냐, 아니면 묶음으로 처리하느냐"의 차이다.

    1. 일반적인 처리 방식 (Scalar Processing)

    for (int i = 0; i < a.length; i++) {
        res[i] = a[i] + b[i];
    }

     

    2. 벡터화 처리 방식 (Vectorized Processing)

    var SPECIES = IntVector.SPECIES_PREFERRED;
    
    for (int i = 0; i < a.length; i += SPECIES.length()) {
        var va = IntVector.fromArray(SPECIES, a, i);
        var vb = IntVector.fromArray(SPECIES, b, i);
        var vr = va.add(vb);
        vr.intoArray(res, i);
    }

     

    컴퓨터의 CPU 안에는 데이터를 담는 '레지스터'라는 있는데 SIMD 레지스터는 숫자 여러개는 한 번에 담을 수 있다.

     

    이어서 클릭하우스가 소화할 수 없는 기능들에 대해서 알아보자.[3]

    완전한 트랜잭션 부재: 일반적인 서비스 DB와 같은 엄격한 트랜잭션은 지원하지 않는다.

    개별 수정/삭제의 어려움: 이미 삽입된 데이터를 빈번하게 수정하거나 삭제하는 용도로는 적합하지 않다.

    포인트 쿼리에 약함: 인덱스가 희소 인덱스(Sparse Index) 방식이므로, 특정 키로 단 한 줄만 찾아오는 작업은 일반 DB보다 효율이 떨어진다

    즉, 클릭하우스는 모든 Row에 대해서 인덱스에 담지않고 대표값만 인덱스를 담고 이 대표값을 통해서 덩어리(Granule)를 메모리에 올려서 여기서 찾게된다. 모든 Row에 대해서 인덱스를 생성하면 크기가 너무 커져서 메모리에 올릴 수 없기 때문에 이 사이즈를 줄이고자 Sparse Index를 사용한다. 이를 조절하기 위해 index_granularity로 튜닝할 수 있다.

     

    클릭하우스에서 테이블 생성 쿼리를 알아보자.[4]

    CREATE DATABASE IF NOT EXISTS helloworld
    
    CREATE TABLE helloworld.my_first_table
    (
        user_id UInt32,
        message String,
        timestamp DateTime,
        metric Float32
    )
    ENGINE = MergeTree()
    PRIMARY KEY (user_id, timestamp)

    ClickHouse의 기본 키는 테이블의 각 행에 대해 유일(Unique)하지 않다. 즉, 동일한 기본 키를 가진 여러 행이 존재할 수 있다.

    PRIMARY KEY로 정의했더라도 INSERT를 통해 같은 키 값을 입력하면 데이터는 중복해서 그대로 쌓이게 된다.

     

    중복을 허용하고 싶지 않거나, 최신 데이터로 업데이트하고 싶다면 일반적인 MergeTree 대신 ReplacingMergeTree를 사용하면 된다.

    ReplacingMergeTree를 사용해도 Merge 시점 이전에는 중복이 있을 수 있다. 이를 해결하기 위해서 FINAL 키워드를 사용해야 한다.

    SELECT * FROM users FINAL WHERE user_id = 123;
    

     

    다만, 백그라운드 머지가 완료되지 않았다면 조회 성능이 많이 느려질 수 있다.

    백그라운드 머지가 완벽하게 완료되어 Part가 하나만 남은 상태라면 FINAL을 써도 거의 느려지지 않는다.

    그러면 백그라운드 머지는 어떤 기준으로 트리거될까? 파트가 2~10개 정도만 쌓여도 ClickHouse는 머지 대상을 찾는다.

     

    테스트 데이터셋 및 테이블 생성은 아래의 명령어로 실행 가능하다.

    CREATE TABLE user_events (
      event_id UUID,
      user_id UInt32,
      event_type LowCardinality(String),
      event_time DateTime
    ) ENGINE = MergeTree
    ORDER BY event_time;
    
    INSERT INTO user_events
    SELECT
      generateUUIDv4() AS event_id,
      rand() % 10000 AS user_id,
      arrayJoin(['click','view','purchase']) AS event_type,
      now() - INTERVAL rand() % 3600*24 SECOND AS event_time
    FROM numbers(1000000);

     

    클리하우스에 데이터 입력 방법을 알아보자.[5]

    클릭하우스는 초당 수백만 Row를 적재할 수 있다. 이는 병렬 아키텍처와 효율적인 열 기반 압축으로 가능하다. 강력한 Append-Only를 제공하지만 최종적 일관성을 보장한다.

    클릭하우스에서는 Part가 성능에 영향을 주는 주된 요소이다. 그렇기 때문에 이를 최대한 작게 유지하는게 필요하다.

    대량 데이터를 배치로 적재할 때는 크게 문제가 될 부분이 없다.

    소량의 데이터를 적재할 때는 ASYNC Batch를 사용하는 것을 권장한다. 이 방식을 사용하면 버퍼에 데이터를 저장하고 추후에 Flush하는 구조이다. 

     

    이어서 하드웨어 스펙을 특정하는 법에 대해서 알아보자.[6]

    하드웨어 스펙을 설정할 때는 사용하는 워크로드에 따라서 달라지게 된다. 아래의 요소들을 고려할 수 있다.

    • Concurrency (requests per second)
    • Throughput (rows processed per second)
    • Data volume
    • Data retention policy
    • Hardware costs
    • Maintenance costs

    Disk

    성능을 최적화하고 싶다면 IOPS를 높은 타입을 사용하자.

    비용을 아끼고 싶다면 다목적 SSD를 사용하자.

    티어링도 가능하다. hot/warm/cold Architecture 지원이 가능하다. 그리고 S3와의 연동도 가능하다.

     

    CPU

    동시성이 높다면(예를 들어 초당 쿼리 수 100건 이상이라면) Cpu 리소스를 많이 투입해라 AWS C 타입을 사용하면 된다.

    ad-hoc 분석이 목적이라면, R장비를 사용하면 되겠다. 

    클릭하우스의 CPU 사용률은 낮게 유지해야 하는 것을 권장한다. 일반적으로 10 - 20%를 추천한다. 그 이유는 다음과 같다.

    클릭하우스는 병렬처리를 한다. 즉, 쿼리 하나가 가용한 모든 CPU를 점유하려고 한다. 평상시에 사용률이 높다면 대량의 쿼리가 인입될 경우 쿼리 전체가 멈추거나 응답 속도가 급격히 떨어질 수 있다. 머지 작업은 CPU를 지속적으로 소모하는데 이 용도로 사용량 CPU 여유분이 필요하다. 

     

    Memory

    쿼리가 복잡하면(JOIN, GROUP BY, DISTINCT) 메모리를 더 많이 필요하게 된다. 그리고 한 번에 처리해야 하는 데이터가 많을수록 더 사용량이 늘어나게 된다. 

    사용 용도별로 달라지지만 고성능/고객 서비스용이라면 1:30 - 1:50을 추천한다. 즉, 데이터 10TB당 메모리 250GB 정도가 필요하다.

     

    대량의 워크로드에 대한 추천 설정

    구분 B2B SaaS (분석) 통신사 (로그)
    주요 목표 200+ 동시 쿼리 (빠른 응답) 월 4.8PB 유입 (대량 처리)
    보관 기간 18개월 (장기) 30일 (단기)
    전체 vCPU 2,700개 (매우 높음) 1,600개 (보통)
    Storage 540TB 608TB
    RAM:Disk 비율 1:50 (빠른 분석용) 1:60 (로그 검색용)

    Primary Key 선정 기준[7]

    일반적인 OLTP의 PK와는 다르다는 것은 위에서 설명했다. PK는 데이터의 물리적인 저장 방식을 결정한다. 이는 압축에 즉각적으로 영향을 주게된다. 압축이 잘되는 방향으로 PK를 설정하는 것이 중요하다.

    Ordering Key를 선택할 때 쿼리에 자주 사용되는 컬럼을 기준으로 설정하자.

    PK 순서는 범위가 큰 것에서 작은 순서로 배치하자. (날짜 -> 카테고리 -> ID)

    너무 많은 컬럼을 PK로 잡으면 인덱스 자체가 커져서 메모리를 많이 먹습니다. 문서에서 말하듯 4~5개 이내가 적당하다.

     

    Partition Key 선정 기준[8]

    파티션 키는 데이터 관리 기법이지 주된 쿼리 최적화 기법은 아니다. 데이터 라이프 사이클 관점에서 선택하는 것이 중요하다. 

     

    ARCHITECTURE[9]

     

    아키텍처는 위에서 알아본 Query Processing Layer, Storage Layer, Intergration Layer가 있다.

     

    Storage Layer

    MergeTree 계열 엔진의 각 테이블은 불변(Immutable) 상태의 테이블 파트(Parts) 집합으로 구성된다.

    파트는 테이블에 데이터(행 세트)가 삽입될 때마다 생성되고, 각 파트는 중앙 카탈로그를 추가로 조회하지 않고도 그 내용을 해석할 수 있도록 모든 메타데이터를 포함하는 Self-contained 구조를 가진다.

    테이블당 파트 개수를 적게 유지하기 위해, 백그라운드 머지 작업이 주기적으로 여러 개의 작은 파트들을 결합하여 설정된 파트 크기(기본값 150GB)에 도달할 때까지 더 큰 파트로 합친다.

    파트들은 테이블의 기본 키(Primary Key) 컬럼에 따라 정렬되어 있기 때문에, 머지 시 효율적인 k-way 머지 정렬 알고리즘이 사용된다. 머지가 완료되면 소스(원본) 파트들은 'Inactive' 상태로 표시되며, 해당 파트를 읽는 쿼리가 더 이상 없어 Reference count가 0이 되는 즉시 최종적으로 삭제된다.

     

    [1] https://clickhouse.com/docs/intro

    [2] https://clickhouse.com/docs/concepts/why-clickhouse-is-so-fast

    [3] https://clickhouse.com/docs/about-us/distinctive-features

    [4] https://clickhouse.com/docs/guides/creating-tables

    [5] https://clickhouse.com/docs/guides/inserting-data

    [6] https://clickhouse.com/docs/guides/sizing-and-hardware-recommendations

    [7] https://clickhouse.com/docs/best-practices/choosing-a-primary-key

    [8] https://clickhouse.com/docs/best-practices/choosing-a-partitioning-key

    [9] https://clickhouse.com/docs/academic_overview

    댓글

Designed by Tistory.