본문 바로가기
Development

ClickHouse 기반 로그 탐색 UI 를 만들며 배운 성능 팁 5가지

by 토마스.dev 2026. 4. 24.
반응형

ClickHouse 위에서 사용자가 인터랙티브하게 로그를 탐색하도록 UI 를 만들다 보면, 쿼리 한 줄 잘 짜는 것만으로는 부족합니다. 어떻게 쪼개고, 어떻게 샘플링하고, 어떻게 연속적으로 보여줄지가 체감 속도를 좌우합니다. 최근 사내 로그 탐색 플러그인을 개선하며 정리한 다섯 가지 팁을 공유합니다.


1. LIKE 대신 Token 계열 함수로

자유 텍스트 검색을 구현할 때 가장 쉬운 선택은 WHERE Body LIKE '%error%' 입니다. 하지만 이 순간 ClickHouse 는 풀스캔 을 시작합니다. 앞에 % 가 붙은 패턴 매칭은 인덱스가 무용지물이 됩니다.
대신, Body 같은 텍스트 컬럼에 tokenbf_v1 bloom filter 를 걸고 쿼리에서는 상황에 맞는 전용 함수를 씁니다.

-- 단일 키워드
WHERE hasToken(Body, 'ERROR')

-- OR (여러 token 중 하나라도 포함)
WHERE multiSearchAny(Body, ['ERROR', 'FATAL', 'timeout'])

-- AND (모두 포함)
WHERE hasToken(Body, 'ERROR') AND hasToken(Body, 'timeout')

Bloom filter 가 매칭될 가능성이 있는 granule 만 읽게 해서, 1일치 로그에서 희귀 키워드 찾는 쿼리가 수십 초 → 수백 ms 로 줄어듭니다.


2. JSON Path — 속도와 정확도 중 선택하기

로그 Body 가 JSON 문자열인 경우, 특정 필드로 필터링/표시하려면 JSON 함수가 필요합니다. ClickHouse 는 두 가지 계열을 제공하는데, 성격이 꽤 다르니 명시적으로 선택 해야 합니다.

정확 모드: JSONExtractString / JSONExtractFloat

정식 JSON 파서로 동작. 느리지만 거의 모든 케이스에 안전합니다.

-- 단일 depth
JSONExtractString(Body, 'status') = '200'

-- 중첩 객체 — 경로를 인자로 나열
JSONExtractFloat(Body, 'response', 'status') = 200

-- 배열 접근 — 1-indexed (주의!)
JSONExtractString(Body, 'errors', 1, 'msg') = 'fail'
  • ✅ 중첩 경로, 배열 인덱스 지원
  • ✅ 타입 캐스팅 자동 (*Float 쓰면 숫자 반환)
  • ❌ 느림 — row 하나하나 전체 JSON 파싱

고속 모드: simpleJSONExtractString / simpleJSONExtractFloat

문자열 스캔으로 동작. 훨씬 빠르지만 제약이 있습니다.

-- 단일 depth만 지원
simpleJSONExtractString(Body, 'status') = '200'
simpleJSONExtractFloat(Body, 'elapsed') > 100
  • ✅ 정식 파서보다 몇 배 빠름
  • ❌ 단일 depth 만 — 중첩 경로 / 배열 인덱스 불가
  • ❌ JSON 바깥 공백이나 특수 케이스에서 파싱 실패 가능
  • ❌ 같은 key 가 여러 번 나오면 첫 번째 값만 반환

visitParamExtract* 는 simpleJSONExtract* 의 deprecated alias 입니다. 새 코드에선 simpleJSON* 을 쓰세요.

비교/숫자 연산 시 래핑 주의

JSONExtractString 결과를 toFloat64OrNull() 로 래핑해 비교하는 코드를 자주 보는데, JSONExtractFloat 을 쓰면 이미 숫자 라 불필요합니다. 래핑이 많아질수록 ClickHouse 가 연산을 최적화하기 어려워지니 처음부터 타입에 맞는 함수를 고르는 게 좋습니다.

-- 나쁜 예
toFloat64OrNull(JSONExtractString(Body, 'status')) > 400

-- 좋은 예
JSONExtractFloat(Body, 'status') > 400

가능하면 hasToken 프리필터를 AND 로 붙이세요

동등 비교(=) 는 hasToken 프리필터를 조합하면 bloom filter 로 미리 granule 을 줄여 훨씬 빨라집니다.

-- status=200 검색 시
WHERE hasToken(Body, '200')
  AND JSONExtractFloat(Body, 'status') = 200

단 hasToken 단독으로는 다른 필드에도 '200' 이 있을 수 있어 false positive 가 발생하니 반드시 AND 로 결합 해야 합니다. != 에는 NOT hasToken 쓰지 말 것 (false negative).


3. Timestamp Tie, 단위 차이를 먼저 보세요

커서 기반 페이지네이션에서 "마지막 행 다음부터 보여줘" 를 WHERE ts > lastTs 로 쉽게 써버리기 쉬운데, 두 가지 함정이 있습니다.
함정 1. 단위 불일치
Grafana/JavaScript 는 밀리초 정밀도. ClickHouse DateTime64(9) 는 나노초 정밀도. 같은 ms 안에 ns 가 다른 row 들이 여러 개 있을 수 있는데, ts > lastTs 로만 자르면 이것들이 전부 누락됩니다.
함정 2. Tie
정확히 같은 timestamp 를 가진 row 가 여럿일 때, 어느 쪽이 앞이고 뒤인지 deterministic 하지 않으면 중복이나 누락이 발생합니다.
해결: +1ms 오프셋과 Uuid tiebreak 을 같이 씁니다.

ORDER BY Timestamp DESC, Uuid ASC
WHERE Timestamp < fromUnixTimestamp64Milli(lastTs + 1)
   OR (Timestamp = fromUnixTimestamp64Milli(lastTs + 1) AND Uuid > lastUuid)
  • +1ms 가 해당 ms 안의 모든 ns row 를 포괄
  • Uuid 비교가 같은 ts tie 를 deterministic 하게 해결

서버측 정렬과 클라이언트 cursor 가 같은 키로 합의되어야 페이지네이션이 안정적입니다.


4. 샘플링으로 조회 범위를 자동 조절하기

사용자가 "1일" 을 선택해도 첫 화면에 보여주는 건 300행 정도 입니다. 그런데 1일치 전체를 훑어 300행을 뽑으면, ClickHouse 는 억울합니다. 대부분의 서비스에서 로그는 최근으로 갈수록 밀도가 높기 때문에 굳이 과거까지 긁을 필요가 없거든요.
패턴: 실제 SELECT 전에 가장 최근 5분 구간에 COUNT(*) probe 를 한 번 날려 밀도를 추정하고, 이 결과로 실제 SELECT 의 시간 범위를 동적으로 좁힙니다.

  • probe 결과 5분에 500행 → "5분이면 충분" → 실제 SELECT 는 5분만
  • probe 결과 5분에 100행 → 1/3 수준으로 300행 미달 → safety factor 곱해 ~25분으로 확장
  • probe 결과 0행 → 희소 조건이므로 fallback → 원래 범위 유지

probe 자체는 작은 시간 범위 + PK prefix 덕분에 100-200ms. 대신 실제 SELECT 가 훨씬 좁은 범위를 훑게 되어 전체 체감 속도가 수십 배 빨라집니다.
핵심: 사용자가 "원래 1일 원했다" 는 의도는 유지하면서(스크롤하면 과거까지 가능), 첫 화면만 영리하게 좁히는 것.


5. TTFP 를 위해 쿼리를 잘게 쪼개서 여러 번

희소한 조건(희귀 필터, 드문 키워드) 에서는 샘플링이 안 먹힙니다. 5분에 0건이면 결국 전체 범위를 훑어야 하죠. 그리고 이때 단일 쿼리는 timeout 에 걸려 아예 결과가 안 나오는 케이스 가 꽤 많습니다.
패턴: 시간 범위를 5분 단위 청크 로 쪼개서  N개씩 병렬로 순차 실행. 청크 응답이 도착할 때마다 UI 에 점진적으로 붙여 보여줍니다.

  • 첫 청크 응답 ~200ms 에 사용자는 이미 데이터를 보기 시작 함 (TTFP 단축)
  • 청크당 5분이라 timeout 에 걸릴 일이 거의 없음 → 넓은 범위(1일, 1주일)를 천천히, 끝까지 조회 가능
  • 300행 목표 도달하면 나머지 청크 dispatch 중단
  • 중간에 실패한 청크가 있어도 이미 보여진 데이터는 유지

"작은 쿼리를 여러 번" 이 "큰 쿼리 한 번" 보다 UX 관점에서 우월한 경우가 많습니다. 사용자는 전체 결과보다 처음 몇 줄 을 훨씬 중요하게 여기거든요.


결국 UI 콘솔이 필요합니다 — "SQL 처럼 보이지만 SQL 이 아니다"

ClickHouse 는 생긴 건 SQL 이지만, 성능을 제대로 내려면 전용 함수를 정확히 골라 써야 합니다.

  • 텍스트 검색: LIKE 말고 hasToken / multiSearchAny
  • JSON 필드: 정확성이 필요하면 JSONExtract*, 속도가 필요하면 simpleJSONExtract*, 타입에 맞게 *String/*Float 선택
  • 시간 리터럴: fromUnixTimestamp64Milli() 로 감싸야 DateTime64 와 비교 가능
  • 히스토그램 버킷: toStartOfInterval(ts, INTERVAL N second)
  • Map/Array 필드: arrayElement() / tupleElement()

일반 SQL 쓰듯 쓰면 성능이 쏟아지거나(LIKE), 정확도가 떨어지거나(simpleJSON 의 첫 값만 반환), 아예 결과가 안 맞거나(타입/타임존 이슈) — 학습 곡선이 금방 가파릅니다. 데이터 엔지니어가 아니라 서비스 개발자 / 오퍼레이터가 하루에 수십 번 검색하는 툴이라면 이 학습 곡선은 현실적으로 극복 대상이 아닙니다.
그래서 프론트 쿼리 언어는 Lucene 처럼 친숙한 문법 으로 받고, 뒷단에서 ClickHouse 전용 함수로 자동 컴파일 해주는 레이어가 필요합니다. 예를 들어:

사용자 입력 (Lucene):
  level:ERROR AND message:"timeout"

백엔드에서 자동 컴파일:
  WHERE hasToken(Body, 'ERROR') 
    AND multiSearchAny(Body, ['timeout'])
    AND Timestamp >= fromUnixTimestamp64Milli(...)

JSON path 도 마찬가지로 사용자는 $.response.status > 400 같은 익숙한 표현만 쓰고, 백엔드가 Fast 모드/정확 모드 토글을 보고 simpleJSONExtractFloat 또는 JSONExtractFloat 를 자동으로 골라 컴파일해줍니다.
여기에 더해 앞서 본 샘플링, 청크 쪼개기, cursor +1ms tiebreak, 점진적 렌더링 같은 성능 패턴도 전부 UI 레벨에서 자동 적용되어야 실용적입니다. 사용자가 "Run" 한 번 누를 때마다 내부에서 probe → narrowing → chunked streaming → in-order flush 가 알아서 돌아가야 하죠.
사내에 ClickHouse 같은 고성능 OLAP 를 올려두고도 쿼리 UX 가 느리다면, 쿼리 엔진 자체 의 문제가 아니라 그 앞에 놓인 UI 의 문제일 가능성이 큽니다. 조회 패턴을 분석해 위 다섯 가지 같은 패턴을 앱 레벨에서 자동으로 적용해주는 콘솔 을 만들면, 쿼리 엔진을 업그레이드하지 않고도 체감 속도를 수십 배 끌어올릴 수 있습니다.


각 팁은 Grafana + ClickHouse 조합이 아니어도 대체로 그대로 적용됩니다. 로그/메트릭 탐색 UI 를 만드시는 분들께 참고가 됐으면 합니다 🙂

반응형