ClickHouse의 SELECT 쿼리: 10년간 PostgreSQL을 사용하다가 사고방식을 바꾼 방법
10년 동안 PostgreSQL을 사용하다가 ClickHouse를 처음 접했을 때, 익숙한 SELECT * FROM bets WHERE sport = 'football' ORDER BY created_at DESC LIMIT 10 쿼리를 실행해봤습니다. 작동은 했지만, 의심스러울 정도로 빨랐습니다. 그런 다음 PREWHERE와 SAMPLE에 대해 배웠습니다. 이들은 행 기반 스토리지인 PostgreSQL에는 존재하지도 않고 존재할 수도 없는 구문입니다.
ClickHouse는 단순히 SQL을 실행하는 것이 아니라, 컬럼 기반 아키텍처에 맞게 SQL을 재고합니다. 아래는 제 쿼리(때로는 프로덕션)를 망가뜨린 모든 차이점입니다.
1. 기본 구문: 익숙한 얼굴, 컬럼 기반 특성
기본 SELECT는 익숙해 보입니다:
-- 간단한 선택
SELECT user_id, amount, odds
FROM betting.bets
WHERE created_at >= today() - 7
ORDER BY amount DESC
LIMIT 100;
하지만 EXPLAIN을 보면 차이가 드러납니다. PostgreSQL은 Seq Scan, Index Scan, Bitmap Heap Scan으로 계획을 세웁니다. ClickHouse는 읽은 그라뉼과 파티션의 수를 보여줍니다.
핵심 차이점: PostgreSQL에서는 SELECT *가 때로는 괜찮습니다(거의 모든 컬럼이 필요할 때). ClickHouse에서는 SELECT *가 디스크에서 모든 컬럼을 읽습니다. 30개 중 20개 컬럼이 필요 없다면 필요한 컬럼만 나열하세요. 이 방법으로 디스크 I/O를 70% 절약했습니다.
2. PREWHERE — PostgreSQL이 부러워하는 최적화
PREWHERE는 컬럼이 압축 해제되어 읽히기 전에 필터링합니다.
-- PREWHERE 없음 (느림)
SELECT user_id, amount, odds, sport
FROM betting.bets
WHERE outcome = 'win' AND amount > 1000;
-- PREWHERE 사용 (빠름)
SELECT user_id, amount, odds, sport
FROM betting.bets
PREWHERE outcome = 'win'
WHERE amount > 1000;
작동 방식:
- ClickHouse가 먼저
outcome컬럼(디스크의 하나의 파일)을 읽습니다. - 행을 필터링하여
win만 남깁니다. - 필터링된 행에 대해서만 나머지 컬럼을 읽습니다.
- 그런 다음
amount > 1000을 적용합니다.
ClickHouse가 PREWHERE를 자동으로 적용하는 경우: WHERE outcome = 'win'이라고 쓰면 옵티마이저가 가벼운 조건을 자동으로 PREWHERE로 이동시킬 수 있습니다. 하지만 저는 복잡한 조건의 경우 항상 명시적으로 작성합니다.
저를 괴롭힌 점: PREWHERE는 PRIMARY KEY의 컬럼에서는 작동하지 않습니다. ClickHouse는 여전히 인덱스를 먼저 읽습니다. 이미 빠른 것을 최적화하려 하지 마세요.
3. SAMPLE — 0.1초 만에 근사치 답변
비즈니스에서는 때때로 "시간당 베팅의 대략적인 볼륨을 추정해 주세요, ±5% 정도면 됩니다"와 같은 질문을 받습니다. 절대적인 정확도는 필요하지 않습니다.
-- 10% 무작위 행 (SAMPLE 0.1)
SELECT
toHour(created_at) AS hour,
count() * 10 AS estimated_total_bets
FROM betting.bets
SAMPLE 0.1
WHERE created_at >= now() - INTERVAL 1 HOUR
GROUP BY hour;
SAMPLE의 물리적 작동 방식: ClickHouse는 모든 그라뉼을 완전히 읽지 않고, N번째 그라뉼만 읽습니다. 이는 디스크의 데이터가 섞이지 않고 그라뉼 내에서 ORDER BY로 정렬되어 있기 때문에 가능합니다.
SAMPLE에 대한 제 규칙:
- 수십억 행의 집계에는 SAMPLE 0.01로 2-3% 정확도면 충분합니다.
- 정확한 계산(금융, 지급)에는 사용하지 마세요.
- 테이블이
SAMPLE BY키(또는 ORDER BY)로 생성된 경우에만 작동합니다.
4. FINAL — 초보자를 위한 지뢰밭
ReplacingMergeTree(중복 제거 엔진)를 사용하면 행에 여러 버전이 있을 수 있습니다. FINAL은 ClickHouse가 즉시 병합하도록 강제합니다.
-- 느림 (하지만 때로는 필요)
SELECT user_id, max(amount)
FROM betting.bets_replacing
FINAL
GROUP BY user_id;
제가 FINAL을 거의 사용하지 않는 이유: 모든 파트를 읽고 메모리에서 병합하도록 강제합니다. 수십억 행이 있으면 쿼리가 메모리 부족으로 실패합니다.
FINAL의 대안:
argMax를 사용한 그룹화 (권장)- 백그라운드에서 주기적인
OPTIMIZE TABLE ... FINAL - ReplacingMergeTree를 아예 사용하지 않기
-- FINAL 대신
SELECT user_id, argMax(amount, version) AS last_amount
FROM betting.bets_replacing
GROUP BY user_id;
5. 서브쿼리와 JOIN의 IN/NOT IN
PostgreSQL에서는 JOIN이 서브쿼리보다 빠른 경우가 많습니다. ClickHouse에서는 반대입니다. 서브쿼리와 함께 IN을 사용하는 것이 더 빠릅니다.
-- ClickHouse에서 빠름
SELECT user_id, sum(amount)
FROM betting.bets
WHERE user_id IN (SELECT user_id FROM betting.fraud_users)
GROUP BY user_id;
-- 느림 (하지만 더 읽기 쉬움)
SELECT b.user_id, sum(b.amount)
FROM betting.bets b
JOIN betting.fraud_users f ON b.user_id = f.user_id
GROUP BY b.user_id;
IN이 더 빠른 이유: ClickHouse는 서브쿼리를 메모리의 상수 집합으로 바꾸고 컬럼 연산을 사용하여 필터링합니다. JOIN은 행 단위 매칭이 필요합니다.
JOIN이 여전히 필요한 경우:
- 테이블이 두 개 이상인 경우
- SELECT에서 두 테이블의 컬럼이 모두 필요한 경우
- 복잡한 조인 조건(단순 동등 조건이 아닌 경우)
6. ANY / ALL 수식어 — 초기 시절의 유물
이 수식어는 다른 DBMS와의 호환성을 위해 존재합니다. 저는 거의 사용하지 않습니다.
-- ANY: 그룹화에서 MIN과 유사
SELECT user_id, ANY(sport) AS any_sport
FROM betting.bets
GROUP BY user_id;
-- ALL: MAX와 유사
SELECT user_id, ALL(amount) AS all_amounts -- 모든 금액의 배열
FROM betting.bets
GROUP BY user_id;
하지만 저는 명시적인 집계 함수(min(), max(), groupArray())를 선호합니다.
7. DISTINCT와 그 성능
ClickHouse의 SELECT DISTINCT는 PostgreSQL보다 빠르지만, 공짜는 아닙니다.
-- 모든 고유 스포츠
SELECT DISTINCT sport FROM betting.bets;
-- ORDER BY와 함께 DISTINCT
SELECT DISTINCT user_id, created_at
FROM betting.bets
ORDER BY created_at DESC
LIMIT 100;
내부 동작: ClickHouse는 메모리에 해시 테이블을 만듭니다. 수십억 개의 고유 값을 가진 컬럼에 DISTINCT를 실행하면 OOM이 발생합니다.
제 팁:
SELECT DISTINCT user_id대신GROUP BY user_id사용 (동일한 결과)- 근사 고유 개수:
uniq()와uniqHLL12() - 상위 고유 값:
topK()
8. FORMAT — 클라이언트가 필요로 하는 출력 형식
ClickHouse는 수십 가지 형식으로 결과를 반환할 수 있습니다. 저는 다섯 가지를 사용합니다:
-- 사람이 읽기 쉬운 (콘솔용)
SELECT * FROM bets LIMIT 3 FORMAT Pretty;
-- 간결 (기본값)
SELECT * FROM bets LIMIT 3 FORMAT PrettyCompact;
-- API용 JSON
SELECT * FROM bets LIMIT 3 FORMAT JSON;
-- 행 단위 JSON (파싱 시 메모리 절약)
SELECT * FROM bets LIMIT 3 FORMAT JSONEachRow;
-- Excel용 CSV
SELECT * FROM bets LIMIT 3 FORMAT CSV;
# 명령줄에서 형식 재정의 가능
clickhouse-client --format=JSON --query="SELECT * FROM bets LIMIT 3"
9. 베팅 분석을 위한 상위 10개 쿼리 (프로덕션 준비 완료)
1. 오늘 스포츠별 베팅
SELECT
sport,
count() AS bets,
sum(amount) AS total_staked,
round(avg(odds), 2) AS avg_odds
FROM betting.bets
WHERE created_at >= today()
GROUP BY sport
ORDER BY total_staked DESC;
2. 이번 주 매출 상위 10명의 플레이어
SELECT
user_id,
count() AS bets,
sum(amount) AS total_staked,
sumIf(amount * odds, outcome = 'win') AS total_won,
round(total_won / total_staked, 4) AS roi
FROM betting.bets
WHERE created_at >= today() - 7
GROUP BY user_id
ORDER BY total_staked DESC
LIMIT 10;
3. 오늘 시간별 베팅 볼륨
SELECT
toHour(created_at) AS hour,
count() AS bets,
sum(amount) AS volume
FROM betting.bets
WHERE created_at >= today()
GROUP BY hour
ORDER BY hour;
4. 배당률 구간별 승률
SELECT
CASE
WHEN odds < 1.5 THEN '1.00-1.49'
WHEN odds < 2.0 THEN '1.50-1.99'
WHEN odds < 3.0 THEN '2.00-2.99'
ELSE '3.00+'
END AS odds_range,
count() AS total_bets,
countIf(outcome = 'win') AS wins,
round(wins / total_bets, 4) AS win_rate
FROM betting.bets
WHERE created_at >= today() - 7
GROUP BY odds_range
ORDER BY odds_range;
5. 요일별 가장 활동적인 시간
SELECT
toDayOfWeek(created_at) AS dow,
toHour(created_at) AS hour,
count() AS bets
FROM betting.bets
WHERE created_at >= today() - 30
GROUP BY dow, hour
ORDER BY dow, hour;
6. 사용자별 평균 베팅 및 배당률 (LTV)
SELECT
user_id,
avg(amount) AS avg_bet,
avg(odds) AS avg_odds,
count() AS total_bets,
now() - max(created_at) AS hours_since_last_bet
FROM betting.bets
GROUP BY user_id
HAVING total_bets > 100
ORDER BY avg_bet DESC
LIMIT 50;
7. 시간당 대략적인 고유 플레이어 수
SELECT
toStartOfHour(created_at) AS hour,
uniq(user_id) AS unique_users_approx,
uniqExact(user_id) AS unique_users_exact
FROM betting.bets
WHERE created_at >= today() - 1
GROUP BY hour
ORDER BY hour;
8. 일별 지급 및 환불
SELECT
toDate(created_at) AS day,
sum(amount) AS staked,
sumIf(amount * odds, outcome = 'win') AS paid,
sumIf(amount, outcome = 'void') AS refunded,
round((paid + refunded) / staked, 4) AS net_hold_pct
FROM betting.bets
GROUP BY day
ORDER BY day DESC
LIMIT 30;
9. 팔레이 vs 단폴 베팅
SELECT
bet_type,
count() AS bets,
avg(amount) AS avg_stake,
avg(odds) AS avg_odds,
avgIf(amount * odds, outcome = 'win') AS avg_payout
FROM betting.bets
GROUP BY bet_type;
10. 의심스러운 패턴의 사용자 (사기)
SELECT
user_id,
count() AS bets_5min,
stddevPop(amount) AS stake_variance,
stddevPop(odds) AS odds_variance
FROM betting.bets
WHERE created_at >= now() - INTERVAL 5 MINUTE
GROUP BY user_id
HAVING bets_5min > 30 AND stake_variance < 1 AND odds_variance < 0.1;
10. PostgreSQL에서 ClickHouse로 SQL 마이그레이션 시 흔한 실수
실수 1: 서브쿼리에서 SELECT * 사용
PostgreSQL에서는 괜찮습니다. ClickHouse에서는 모든 레벨에서 모든 컬럼을 읽습니다.
실수 2: 서브쿼리의 ORDER BY가 유지될 것이라고 기대
ClickHouse에서 서브쿼리는 ORDER BY가 있어도 순서를 보장하지 않습니다. 최상위 레벨에서만 정렬하세요.
실수 3: 상관 서브쿼리 ClickHouse는 상관 서브쿼리를 제대로 최적화하지 못합니다. JOIN이나 윈도우 함수로 다시 작성하세요.
-- 나쁨 (느림)
SELECT user_id, amount
FROM bets b1
WHERE amount = (SELECT max(amount) FROM bets b2 WHERE b2.user_id = b1.user_id);
-- 좋음 (빠름)
SELECT user_id, max(amount) AS max_amount
FROM bets
GROUP BY user_id;
실수 4: 트랜잭션 무결성 기대
ClickHouse에는 REPEATABLE READ가 없습니다. 쿼리 중에 데이터를 삽입하면 일부만 보일 수 있습니다.
실수 5: ALTER TABLE 없이 UPDATE와 DELETE 사용
ClickHouse에서 이들은 비동기적이고 무거운 뮤테이션입니다. 한 명령으로 백만 행을 업데이트하지 마세요.
다음은?
이제 ClickHouse에서 SELECT를 예상치 못한 문제 없이 작성하는 방법을 알게 되었습니다. 다음 글에서는 고급 집계와 윈도우 함수를 다룹니다.
← 이전 글: ClickHouse에 데이터 로드: 한 행씩 삽입을 멈추고 처리 속도를 500배 향상시킨 방법
→ 다음 글: ClickHouse 집계 함수: uniqHLL12와 quantileTDigest에 대한 두려움을 극복한 방법
— Editorial Team
아직 댓글이 없습니다.