홈으로 돌아가기

ClickHouse의 딕셔너리: JOIN 없이 빠른 조회

이 기사는 JOIN 없이 참조 데이터를 빠르게 인메모리 조회하기 위한 ClickHouse의 딕셔너리 메커니즘을 설명합니다. 딕셔너리 유형(최대 500k 키의 flat, hashed, sparse_hashed, 범위용 range_hashed, 복합 키용 complex_key_hashed), 데이터 소스(ClickHouse, MySQL, PostgreSQL, HTTP), 함수 dictGet/dictGetOrDefault/dictHas, 환율을 위한 range 딕셔너리, system.dictionaries를 통한 모니터링, SYSTEM RELOAD DICTIONARY를 통한 핫 리로드를 다룹니다.

ClickHouse 딕셔너리: JOIN 없이 조회하는 완벽 가이드
Advertisement 728x90

ClickHouse의 딕셔너리: JOIN 없이 빠른 조회

1. 딕셔너리가 필요한 이유 — 참조 데이터의 JOIN 문제

온라인 카지노로 돌아가 봅시다. bets 테이블에는 1부터 20까지의 숫자인 sport_id가 저장되어 있습니다. 하지만 보고서에서는 스포츠 이름인 "축구", "하키", "테니스"를 보여줘야 합니다. 이 정보는 보통 별도의 참조 테이블 sports에 있습니다.

-- JOIN을 사용한 느린 쿼리
SELECT 
    b.user_id,
    s.name AS sport_name,
    sum(b.amount) AS total
FROM bets b
JOIN sports s ON b.sport_id = s.id
GROUP BY b.user_id, s.name;

bets에 10억 개의 행이 있고 sports에 20개의 행이 있는 경우, 이 JOIN은 데이터 청크마다 참조 테이블을 복사합니다. ClickHouse는 브로드캐스트 조인(작은 테이블을 모든 샤드에 전송)을 수행하는데, 이는 빠르지만 여전히 메모리와 CPU를 소모합니다.

딕셔너리는 이 문제를 다르게 해결합니다. 딕셔너리는 ClickHouse 내부에 상주하는 인메모리 참조 테이블입니다. JOIN을 실행하지 않고도 키를 조회하여 값을 마이크로초 단위로 얻을 수 있습니다.

Google AdInline article slot

실생활 비유: 딕셔너리는 시험을 위한 컨닝 페이퍼와 같습니다. 20개의 행 목록이 있습니다: "1 = 축구, 2 = 하키...". ID로 스포츠 이름을 찾아야 할 때, 두꺼운 참고서(디스크)를 보러 도서관에 가는 대신 컨닝 페이퍼(메모리)를 한눈에 보기만 하면 됩니다. 수천 배 더 빠릅니다.

ClickHouse에서 이것이 중요한 이유: ClickHouse는 데이터를 디스크에 저장하며, JOIN을 통해 작은 테이블을 읽어도 디스크 작업이 필요합니다. 딕셔너리는 메모리(압축 및 최적화된 형태)에 상주하며, 접근은 단순히 RAM에서 읽는 것입니다.

2. 딕셔너리 유형 — 구조 선택 방법

ClickHouse는 다음에 따라 여러 딕셔너리 유형(LAYOUT)을 제공합니다:

Google AdInline article slot
  • 데이터 크기(키 개수),
  • 키 유형(단일 또는 복합),
  • 범위 조회 필요 여부(예: 날짜별 환율).
유형 사용 시기 최대 키 특징
flat 매우 작은 딕셔너리(최대 500k 키) 500,000 가장 빠름, 배열에 저장. 키는 정수(UInt*)여야 함.
hashed 중간 크기 딕셔너리(수백만 키) 무제한 해시 테이블. 모든 키 유형에 적합. flat보다 약간 느림.
sparse_hashed 매우 큰 딕셔너리(수천만 개) 매우 많음 메모리 절약(null 값 미저장), 약간 느림.
range_hashed 날짜 범위(날짜별 환율) 무제한 키 + 범위(시작, 끝). get(key, date) 조회 가능.
complex_key_hashed 복합 키(예: market_id, selection_id) 무제한 키는 여러 필드의 튜플.
ip_trie IP 주소(접두사 조회) 최대 500k GeoIP용: IP로 국가/도시 찾기.

선택 방법:

  • 키가 500k 미만이고 정수형 → flat (최대 속도).
  • 키가 500k 이상이거나 정수가 아님 → hashed.
  • 키가 매우 많고 null 값이 많음 → sparse_hashed.
  • 날짜 기반 조회 필요 → range_hashed.
  • 복합 키(여러 필드) → complex_key_hashed.

비유: flat은 번호가 매겨진 서랍장(인덱스 = 번호)과 같습니다. 17번 서랍으로 바로 갑니다. hashed는 저자의 성을 해싱하여 선반을 먼저 계산하는 도서관 목록과 같습니다. range_hashed는 날짜를 알고 문서를 검색하는 아카이브와 같습니다.

3. 데이터 소스 — 딕셔너리가 데이터를 가져오는 곳

딕셔너리는 다양한 소스(SOURCE)에서 데이터를 채울 수 있습니다. ClickHouse는 지정된 간격(LIFETIME)으로 소스에서 딕셔너리를 주기적으로 업데이트합니다.

Google AdInline article slot

지원되는 소스:

  • CLICKHOUSE — 다른 ClickHouse 테이블
  • MYSQL — MySQL 테이블
  • POSTGRESQL — PostgreSQL 테이블
  • HTTP — REST API (JSON 또는 XML)
  • FILE — 로컬 파일 (CSV, TSV)
  • REDIS — Redis (키-값)
  • MONGODB — MongoDB 컬렉션

MySQL 예시:

CREATE DICTIONARY currencies_dict
(
    code String,
    name String,
    rate Decimal(10,4)
)
PRIMARY KEY code
SOURCE(MYSQL(
    host 'mysql-host'
    port 3306
    user 'reader'
    password 'secret'
    db 'reference'
    table 'currencies'
))
LIFETIME(MIN 3600 MAX 7200)   -- 1-2시간마다 업데이트
LAYOUT(HASHED());

편리한 이유: 통화 참조 데이터는 재무 부서에서 유지 관리하는 외부 MySQL 데이터베이스에서 한 시간에 한 번 업데이트할 수 있습니다. ClickHouse가 자동으로 변경 사항을 가져오므로 ETL 스크립트를 작성할 필요가 없습니다.

4. ClickHouse 테이블에서 딕셔너리 생성 — 단계별

가장 일반적인 시나리오: ClickHouse에 이미 참조 테이블이 있고 빠른 조회를 위해 딕셔너리로 전환하려는 경우입니다.

1단계: 참조 테이블 생성 (없는 경우)

CREATE TABLE sports
(
    id       UInt32,          -- 스포츠 ID (1, 2, 3...)
    name     String,          -- '축구', '하키', '테니스'
    category String           -- '팀', '개인', 'e스포츠'
)
ENGINE = MergeTree()
ORDER BY id;

-- 데이터 삽입
INSERT INTO sports VALUES (1, '축구', '팀'), (2, '하키', '팀'), (3, '테니스', '개인');

2단계: 이 테이블을 기반으로 딕셔너리 생성

CREATE DICTIONARY sports_dict
(
    id       UInt32,          -- 키 컬럼
    name     String,          -- 조회할 값
    category String           -- 또 다른 값
)
PRIMARY KEY id                -- 조회 키
SOURCE(CLICKHOUSE(
    host 'localhost'
    port 9000
    user 'default'
    password ''
    db 'default'
    table 'sports'
))
LIFETIME(MIN 300 MAX 600)     -- 5-10분마다 업데이트
LAYOUT(HASHED());              -- 20개 레코드의 경우 flat도 괜찮지만, hashed도 작동

매개변수 설명:

  • PRIMARY KEY id — 조회에 사용되는 컬럼. 고유해야 함.
  • SOURCE(CLICKHOUSE(...)) — 데이터 소스. localhost뿐만 아니라 모든 호스트를 지정할 수 있음.
  • LIFETIME(MIN 300 MAX 600) — 딕셔너리는 5~10분마다 완전히 다시 로드됨. MIN과 MAX는 모든 서버의 모든 딕셔너리가 동시에 업데이트되는 것을 방지하기 위해 무작위화에 사용됨.
  • LAYOUT(HASHED()) — 인메모리 구조. 20개 레코드의 경우 flat이 더 좋지만, 예시로 hashed를 유지함.

생성 후 발생하는 일: ClickHouse는 전체 sports 테이블을 읽어 해시 테이블로 메모리에 로드합니다. 이제 dictGet을 사용하여 빠르게 접근할 수 있습니다.

5. 쿼리에서 딕셔너리 사용 — dictGet과 그 외 함수

실제 마법은 SELECT에서 시작됩니다. JOIN sports 대신 딕셔너리 함수를 사용합니다.

dictGet — 주요 함수

-- sport_id로 스포츠 이름 가져오기
SELECT 
    user_id,
    sport_id,
    dictGet('sports_dict', 'name', sport_id) AS sport_name,
    amount
FROM bets
LIMIT 10;

구문: dictGet('딕셔너리_이름', '값_컬럼', 키)

dictGetOrDefault — 기본값 포함

-- sport_id를 찾을 수 없으면 '알 수 없음' 반환
SELECT 
    user_id,
    sport_id,
    dictGetOrDefault('sports_dict', 'name', sport_id, '알 수 없음') AS sport_name
FROM bets;

dictHas — 키 존재 여부 확인

-- 유효하지 않은 sport_id를 가진 베팅 찾기
SELECT DISTINCT sport_id
FROM bets
WHERE dictHas('sports_dict', sport_id) = 0;   -- 딕셔너리에 없는 sport_id 반환

집계와 함께 사용하는 전체 예시

-- JOIN 없이 총 베팅 금액 기준 상위 5개 스포츠!
SELECT 
    dictGet('sports_dict', 'name', sport_id) AS sport_name,
    sum(amount) AS total_amount,
    count() AS bet_count
FROM bets
WHERE created_at >= today() - 7
GROUP BY sport_id
ORDER BY total_amount DESC
LIMIT 5;

JOIN보다 빠른 이유: 디스크 읽기 없음, 참조 테이블을 샤드에 분산할 필요 없음, 쿼리 시 해싱 없음. 딕셔너리는 각 ClickHouse 노드의 메모리에 이미 있습니다.

6. 복합 키 — Tuple과 함께 사용하는 dictGet

키가 여러 필드로 구성된 경우(예: market_id + selection_id), LAYOUT(COMPLEX_KEY_HASHED())를 사용하고 키를 튜플로 전달합니다.

복합 키를 가진 딕셔너리 생성:

-- 배당률 딕셔너리: (market_id, selection_id) → 배당률 값
CREATE DICTIONARY odds_dict
(
    market_id    UInt32,
    selection_id UInt32,
    odds_value   Decimal(10,3)
)
PRIMARY KEY (market_id, selection_id)   -- 복합 키!
SOURCE(CLICKHOUSE(
    table 'odds_reference'
))
LIFETIME(MIN 60 MAX 120)
LAYOUT(COMPLEX_KEY_HASHED());            -- complex_key여야 함!

쿼리에서 사용:

-- 특정 마켓과 결과의 배당률 가져오기
SELECT 
    bet_id,
    market_id,
    selection_id,
    dictGet('odds_dict', 'odds_value', tuple(market_id, selection_id)) AS odds
FROM bets;

튜플이란? 튜플은 괄호로 묶인 값들의 그룹입니다. tuple(market_id, selection_id)(100, 5)와 같은 키를 만듭니다.

7. 범위 딕셔너리 — 과거 데이터용 (날짜별 환율)

매일 변하는 과거 환율 데이터가 있다고 가정해 봅시다. 유로화로 된 각 베팅에 대해 베팅 날짜의 환율이 필요합니다.

소스 테이블 (예: MySQL):

currency start_date end_date rate
EUR 2025-01-01 2025-01-31 1.05
EUR 2025-02-01 2025-02-28 1.08
EUR 2025-03-01 2099-12-31 1.10

범위 딕셔너리 생성:

CREATE DICTIONARY eur_rates_dict
(
    currency   String,
    start_date Date,
    end_date   Date,
    rate       Decimal(10,4)
)
PRIMARY KEY currency
SOURCE(CLICKHOUSE(table 'eur_rates'))
LIFETIME(MIN 3600 MAX 7200)
LAYOUT(RANGE_HASHED())                     -- 특수 유형
RANGE(MIN start_date MAX end_date);        -- 범위 컬럼 지정

사용:

-- 각 EUR 베팅에 대해 베팅 날짜의 환율 가져오기
SELECT 
    bet_id,
    amount_eur,
    created_at,
    dictGet('eur_rates_dict', 'rate', tuple(currency, created_at)) AS rate
FROM bets
WHERE currency = 'EUR';

ClickHouse는 주어진 통화에 대해 created_atstart_dateend_date 사이에 있는 레코드를 자동으로 찾습니다.

비유: 가격 변동의 달력과 같습니다. "3월 15일의 환율을 알려줘"라고 말하면 딕셔너리는 달력을 확인합니다. 3월 15일은 3월 1일~3월 31일 구간에 속하며, 환율은 1.10입니다.

8. 딕셔너리 모니터링 — system.dictionaries

딕셔너리의 상태를 이해하려면 시스템 테이블 system.dictionaries가 있습니다.

SELECT *
FROM system.dictionaries
WHERE name = 'sports_dict';

유용한 컬럼:

컬럼 설명
status LOADED — 로드됨, LOADING — 로딩 중, FAILED — 오류
origin 소스 (ClickHouse, MySQL...)
type 유형 (flat, hashed, range_hashed...)
key 키 유형
attribute.names 사용 가능한 컬럼
bytes_allocated 메모리 사용량 (바이트)
query_count 조회 수
hit_rate 적중률 (높을수록 좋음)
load_factor 딕셔너리 채움 정도 (hashed의 경우)
creation_time 로드된 시간
last_exception 상태가 FAILED인 경우 오류 내용

메모리 모니터링:

SELECT 
    name,
    formatReadableSize(bytes_allocated) AS memory,
    query_count,
    hit_rate
FROM system.dictionaries
WHERE status = 'LOADED'
ORDER BY bytes_allocated DESC;

딕셔너리가 기가바이트를 차지한다면 잘못된 LAYOUT(예: sparse_hashed 대신 hashed)을 선택했을 수 있습니다.

9. 핫 리로드 — SYSTEM RELOAD DICTIONARY

딕셔너리는 LIFETIME에 따라 자동으로 업데이트됩니다. 하지만 때로는 강제로 업데이트해야 할 때가 있습니다:

  • 소스의 데이터를 수정했는데 10분을 기다리기 싫을 때.
  • 딕셔너리가 실패했는데(예: 소스에 접근 불가) 문제를 해결했을 때.
-- 특정 딕셔너리 리로드
SYSTEM RELOAD DICTIONARY sports_dict;

-- 모든 딕셔너리 리로드
SYSTEM RELOAD DICTIONARIES;

발생하는 일: ClickHouse가 소스(예: sports 테이블)를 다시 읽고 메모리의 딕셔너리 내용을 교체합니다. 리로드 중에는 dictGet을 사용하는 쿼리가 대기하거나(또는 버전에 따라 이전 데이터를 반환할 수 있음)합니다. 중요 시스템의 경우 야간에 리로드를 수행하세요.

딕셔너리가 올바르게 로드되었는지 확인:

SELECT status, last_exception 
FROM system.dictionaries 
WHERE name = 'sports_dict';

상태가 LOADED이면 정상입니다. FAILED이면 last_exception을 확인하세요.

10. 예제 아키텍처: 베팅 플랫폼의 모든 참조 딕셔너리

완전한 베팅 플랫폼 아키텍처를 상상해 보세요. 수십 개의 참조 딕셔너리가 쿼리에서 데이터를 보강하는 데 지속적으로 사용됩니다.

생성할 딕셔너리:

-- 1. 스포츠 (20개 레코드, FLAT)
CREATE DICTIONARY sports_dict (id UInt32, name String, category String)
PRIMARY KEY id
SOURCE(CLICKHOUSE(table 'sports'))
LIFETIME(3600) LAYOUT(FLAT());

-- 2. 리그/챔피언십 (1만 개 레코드, HASHED)
CREATE DICTIONARY leagues_dict (id UInt32, name String, sport_id UInt32, country_id UInt32)
PRIMARY KEY id
SOURCE(CLICKHOUSE(table 'leagues'))
LIFETIME(3600) LAYOUT(HASHED());

-- 3. 국가 (200개 레코드, FLAT)
CREATE DICTIONARY countries_dict (id UInt32, name String, code String)
PRIMARY KEY id
SOURCE(CLICKHOUSE(table 'countries'))
LIFETIME(86400) LAYOUT(FLAT());   -- 거의 변경되지 않음, 하루에 한 번 업데이트

-- 4. 과거 환율을 포함한 통화 (RANGE)
CREATE DICTIONARY exchange_rates_dict (currency String, start_date Date, end_date Date, rate Decimal(10,4))
PRIMARY KEY currency
SOURCE(CLICKHOUSE(table 'exchange_rates'))
LIFETIME(3600) LAYOUT(RANGE_HASHED()) RANGE(MIN start_date MAX end_date);

-- 5. 국가 및 베팅 유형별 수수료 (COMPLEX_KEY)
CREATE DICTIONARY commission_dict (country_id UInt32, bet_type String, commission Decimal(5,2))
PRIMARY KEY (country_id, bet_type)
SOURCE(CLICKHOUSE(table 'commissions'))
LIFETIME(7200) LAYOUT(COMPLEX_KEY_HASHED());

단일 쿼리에서 사용:

SELECT 
    b.user_id,
    dictGet('sports_dict', 'name', b.sport_id) AS sport_name,
    dictGet('leagues_dict', 'name', b.league_id) AS league_name,
    dictGet('countries_dict', 'name', dictGet('leagues_dict', 'country_id', b.league_id)) AS country_name,
    b.amount_eur * dictGet('exchange_rates_dict', 'rate', tuple('EUR', toDate(b.created_at))) AS amount_usd,
    dictGet('commission_dict', 'commission', tuple(dictGet('leagues_dict', 'country_id', b.league_id), 'prematch')) AS commission
FROM bets b
WHERE b.created_at >= today() - 7;

이 접근 방식의 장점:

  • 속도: JOIN 없음, 직접 메모리 조회만.
  • 가독성: 코드가 더 명확해짐 — 어떤 딕셔너리가 사용되는지 즉시 알 수 있음.
  • 관리 용이성: 참조 데이터 업데이트(예: 스페인 수수료)가 ETL 스크립트가 아닌 한 곳에서 이루어짐.
  • 메모리 효율성: 딕셔너리는 압축되어 저장되므로, 종종 비정규화된 테이블 컬럼보다 공간을 덜 차지함.

딕셔너리를 사용하지 않으면 어떻게 될까요? 데이터를 비정규화하거나(모든 베팅 행에 스포츠 이름을 반복 — 데이터 양이 10배 이상 증가) 모든 집계에서 JOIN을 수행해야 합니다(수십억 행에서 느리고 고통스러움).

다음 단계

이제 딕셔너리에 대해 모든 것을 알게 되었습니다. 다음 주제:

  • HTTP를 통한 딕셔너리 업데이트 — 외부 API에서 데이터를 가져오는 방법.
  • 구체화된 뷰에서 딕셔너리 사용 — 데이터 사전 보강용.
  • 딕셔너리 클러스터링 — ClickHouse 클러스터(Distributed)에서 딕셔너리가 동작하는 방식.

결론: 딕셔너리는 ClickHouse에서 참조 데이터를 다루는 데 필수적인 도구입니다. 작은 테이블과의 느린 JOIN을 번개처럼 빠른 메모리 조회로 바꿔줍니다. 규칙은 간단합니다: 참조 데이터가 1분에 한 번 이상 변경되지 않고 크기가 RAM에 저장할 수 있는 정도라면 딕셔너리로 만드세요. 그러면 쿼리가 감사할 것입니다.


이전 글:
다음 글: 특별한 ClickHouse 엔진: MergeTree가 적합하지 않을 때

— Editorial Team

Advertisement 728x90

다음 읽기