딕셔너리 (외부 키 조회를 메모리에서, 레이아웃, 갱신, dictGet)
차원 테이블 조인을 메모리 키-값 조회로 바꾸는 딕셔너리의 만드는 법, 레이아웃(flat, hashed, cache, range_hashed, ip_trie) 선택, 갱신 주기와 운영 시 주의점을 정리합니다.
딕셔너리가 무엇인가
딕셔너리는 외부 소스(ClickHouse 테이블, PostgreSQL, MySQL, HTTP, 파일 등)의 데이터를 서버 메모리에 키-값 구조로 올려 두고 dictGet('이름', '속성', 키)로 조회하는 객체입니다. 국가 코드로 국가 이름, 상품 ID로 카테고리, IP로 지역을 붙이는 작업을 JOIN 없이 함수 호출 한 번으로 끝냅니다.
- 조회는 해시 조회 한 번이므로 수천만 행에 속성을 붙여도 빠릅니다.
- 소스가 바뀌면
LIFETIME주기에 맞춰 백그라운드로 다시 적재합니다. - 각 서버(복제본)가 자기 메모리에 따로 들고 있습니다. 메모리 크기를 노드 수만큼 곱해 계산합니다.
조인과의 비교는 JOIN 전략에 있습니다. 짧게 말하면, 오른쪽 테이블이 수천만 행 안쪽이고 자주 조인된다면 딕셔너리가 거의 항상 낫습니다.
만들기
CREATE DICTIONARY dim_country
(
country_id UInt16,
name String,
continent String DEFAULT 'Unknown',
population UInt64
)
PRIMARY KEY country_id
SOURCE(CLICKHOUSE(TABLE 'dim_country_src' DB 'ref'))
LAYOUT(FLAT())
LIFETIME(MIN 300 MAX 600);
SELECT
dictGet('dim_country', 'name', toUInt64(country_id)) AS country,
dictGetOrDefault('dim_country', 'continent', toUInt64(country_id), 'N/A') AS continent,
count() AS c
FROM web_logs
WHERE event_date = today()
GROUP BY country, continent
ORDER BY c DESC;PRIMARY KEY는 UInt64 하나(단순 키) 또는 여러 컬럼(복합 키,complex_key_*레이아웃)입니다. 단순 키 레이아웃에 넘기는 키는 UInt64로 변환해야 합니다.SOURCE는 ClickHouse 테이블이 가장 흔하고,POSTGRESQL,MYSQL,HTTP,FILE,EXECUTABLE,REDIS,MONGODB도 있습니다. 접속 정보는 네임드 컬렉션으로 빼 두면 DDL에 비밀번호가 남지 않습니다.LIFETIME(MIN 300 MAX 600)은 300~600초 사이 임의 시점에 갱신합니다. 모든 노드가 동시에 소스를 두드리지 않게 범위를 둡니다.LIFETIME(0)은 갱신하지 않음.- 속성에
DEFAULT,EXPRESSION,HIERARCHICAL,INJECTIVE,IS_OBJECT_ID같은 수식어를 붙일 수 있습니다.INJECTIVE는 키와 값이 일대일이라는 선언으로 GROUP BY 최적화에 쓰입니다.
조회 함수
| 함수 | 하는 일 |
|---|---|
dictGet(d, attr, key) | 값. 없는 키는 속성의 DEFAULT |
dictGetOrDefault(d, attr, key, def) | 없는 키에 지정한 기본값 |
dictGetOrNull(d, attr, key) | 없는 키는 NULL |
dictGet(d, ('a', 'b'), key) | 여러 속성을 튜플로 한 번에 |
dictHas(d, key) | 키 존재 여부 |
dictGetString, dictGetUInt64 등 | 타입 고정 버전 |
dictGetHierarchy, dictIsIn, dictGetChildren, dictGetDescendants | 계층(부모 ID) 딕셔너리 탐색 |
dictGetAll | regexp_tree 레이아웃에서 맞는 모든 값 |
팁 복합 키는
dictGet('d', 'attr', (tenant_id, product_id))처럼 튜플로 넘깁니다. 키 타입이 선언과 다르면 오류가 나므로toUInt64,toString으로 맞춥니다.
레이아웃 고르기
| 레이아웃 | 키 | 특징 | 쓸 때 |
|---|---|---|---|
FLAT | UInt64, 작은 범위 | 배열 인덱스 조회, 가장 빠름. 키 최대값까지 배열을 잡음 (max_array_size 기본 50만) | 연속된 작은 ID (국가, 카테고리) |
HASHED | UInt64 | 해시 맵 | 일반적인 ID |
SPARSE_HASHED | UInt64 | 메모리 적게, 조금 느림 | 키가 수천만 개 |
HASHED_ARRAY | UInt64 | 속성을 배열로 묶어 메모리 효율 좋음 | 속성이 많은 큰 딕셔너리 |
COMPLEX_KEY_HASHED | 복합 | 튜플 키 해시 | (tenant, id) 같은 키 |
RANGE_HASHED | UInt64 + 기간 | 키마다 유효 기간(RANGE(MIN start MAX end)) | 환율, 요금표, 버전별 설정 |
CACHE, COMPLEX_KEY_CACHE | UInt64, 복합 | 요청된 키만 소스에서 가져와 캐시 | 소스가 너무 커서 전부 못 올릴 때 |
SSD_CACHE | UInt64 | 캐시를 SSD에 | 캐시도 메모리에 안 들어갈 때 |
DIRECT, COMPLEX_KEY_DIRECT | 적재 없이 요청마다 소스 조회 | 소스가 빠르고 데이터가 매우 자주 바뀔 때 | |
IP_TRIE | 문자열 CIDR | IP 주소를 CIDR 범위로 찾기 | GeoIP, 사내 대역 분류 |
REGEXP_TREE | 문자열 | 정규식 트리로 매칭 | User-Agent 파싱 |
POLYGON | 좌표 | 점이 어느 다각형 안인지 | 행정 구역 |
처음에는 HASHED(단순 키)나 COMPLEX_KEY_HASHED로 시작하고, 메모리가 아쉬우면 HASHED_ARRAY나 SPARSE_HASHED, 소스가 너무 크면 CACHE로 바꿉니다.
주의
CACHE레이아웃은 캐시 미스가 많으면 소스를 계속 두드려 오히려 느려지고, 소스 DB에 부하를 줍니다. 히트율은system.dictionaries의hit_rate로 봅니다. 대부분의 분석 워크로드는 전체 적재형이 단순하고 빠릅니다.
range_hashed: 기간이 있는 값
CREATE DICTIONARY fx_rate
(
currency String,
valid_from Date,
valid_to Date,
rate Float64
)
PRIMARY KEY currency
SOURCE(CLICKHOUSE(TABLE 'fx_rate_src' DB 'ref'))
LAYOUT(COMPLEX_KEY_RANGE_HASHED())
RANGE(MIN valid_from MAX valid_to)
LIFETIME(3600);
SELECT
order_id,
amount_usd * dictGet('fx_rate', 'rate', tuple(currency), toDate(ordered_at)) AS amount_krw
FROM orders
WHERE ordered_at >= today() - 7;범위가 겹칠 때 어느 값을 쓸지는 RANGE_LOOKUP_STRATEGY 설정(min 또는 max)으로 정합니다. ASOF JOIN보다 메모리와 속도 모두 유리합니다.
ip_trie: IP 대역 분류
CREATE DICTIONARY ip_region
(
prefix String,
region String,
is_internal UInt8
)
PRIMARY KEY prefix
SOURCE(CLICKHOUSE(TABLE 'ip_region_src' DB 'ref'))
LAYOUT(IP_TRIE())
LIFETIME(86400);
SELECT dictGet('ip_region', 'region', tuple(IPv4StringToNum(client_ip))) AS region, count()
FROM web_logs
GROUP BY region;IPv4와 IPv6을 함께 다루려면 키를 IPv6으로 통일합니다(IPv4ToIPv6).
갱신과 운영
system.dictionaries에서 상태(status), 적재 시각, 메모리(bytes_allocated), 원소 수, 마지막 오류(last_exception)를 봅니다. 모니터링 대시보드에 올려 두는 것이 좋습니다. 모니터링- 즉시 갱신은
SYSTEM RELOAD DICTIONARY dim_country, 전체는SYSTEM RELOAD DICTIONARIES. - 소스가 바뀌지 않았으면 적재를 건너뛰게 하려면
SOURCE(... INVALIDATE_QUERY 'SELECT max(updated_at) FROM ...')를 둡니다. 결과가 전과 같으면 다시 읽지 않습니다. - 증분 갱신은
UPDATE_FIELD updated_at로 바뀐 행만 가져올 수 있습니다(해시 계열 레이아웃). - 기본은 지연 적재입니다. 첫 조회 때 올라가므로 첫 쿼리가 느립니다. 서버 설정
dictionaries_lazy_load = false로 시작 시 모두 적재하거나, 배포 후SYSTEM RELOAD를 한 번 호출합니다. - 적재에 실패하면 이전 데이터를 계속 쓰고
last_exception에 이유가 남습니다. 완전히 비어 있는 상태로 실패하면 조회가 예외를 던지므로, 배포 직후에는 꼭 확인합니다. - 복제본마다 따로 적재하므로 노드가 10대면 소스를 10번 읽습니다. 소스가 외부 DB라면 LIFETIME을 넉넉히 두거나 ClickHouse 테이블로 한 번 복사한 뒤 그 테이블을 소스로 삼습니다.
- ClickHouse Cloud에서는 DDL로 만든 딕셔너리가 모든 복제본에 자동으로 퍼지고, 외부 소스는 네임드 컬렉션과 네트워크 허용 설정이 필요합니다. 네트워크와 보안
딕셔너리 JOIN 문법
join_algorithm에 direct가 포함되어 있으면(기본값) 딕셔너리를 오른쪽에 둔 JOIN은 자동으로 조회로 바뀌어 해시 테이블을 만들지 않습니다. BI 도구가 JOIN 문법을 쓰더라도 성능을 챙길 수 있습니다.
SELECT l.country_id, d.name, count()
FROM web_logs AS l
LEFT JOIN dim_country AS d ON l.country_id = d.country_id
GROUP BY l.country_id, d.name;딕셔너리는 SELECT * FROM dim_country로 테이블처럼 읽을 수도 있습니다(dictionary('dim_country') 함수도 같음).
머티리얼라이즈드 뷰에서 미리 붙이기
조회 시점마다 dictGet을 호출하는 대신, INSERT 시점에 MV 안에서 dictGet으로 속성을 붙여 결과 테이블에 저장하면 조회가 더 단순해집니다. 단, 딕셔너리가 나중에 바뀌어도 과거 행은 그대로입니다. "이벤트 당시의 값"이 필요하면 이 방식이 오히려 맞습니다. 머티리얼라이즈드 뷰
체크리스트
- 키 타입을 UInt64 또는 튜플로 맞췄나.
- 레이아웃이 데이터 크기와 키 모양에 맞나 (연속 작은 ID는 FLAT, 기간은 RANGE_HASHED, IP는 IP_TRIE).
- LIFETIME에 MIN과 MAX를 두어 노드가 동시에 소스를 치지 않게 했나.
system.dictionaries의 status와 last_exception을 모니터링하나.- 메모리를 노드 수만큼 곱해 계산했나.
- 지연 적재 때문에 첫 쿼리가 느린 것을 감안했나.