6. Hands-on Lab

로컬 ClickHouse로 웹 성능 알림이 울리게 해 보기

  • Haram Lee
  • 2026-10-18
  • studies / Topics / ClickHouse

5장의 설계를 로컬에서 직접 돌려 본다. docker로 ClickHouse를 띄우고, 합성 웹 성능 이벤트를 보내다가 한 페이지만 느려지게 만들어 알림이 울리는지 확인한다. Kafka와 Flink는 빼고, 이벤트 생성기가 sink 역할을 대신한다.

6.1 Files

파일은 여섯 개다. 모두 블로그에 올려 두었다.

파일하는 일
compose.yamlClickHouse 24.8 서버를 띄운다
schema.sql원본 table, 10초 상태 table, materialized view를 만든다
alert.sql5.3절의 알림 쿼리를 담았다
send_events.py합성 LCP, INP 이벤트를 async insert로 보낸다
alert_poll.py15초마다 알림 쿼리를 실행하고, 두 번 연속 조건에 걸리면 알린다
bigquery.sql5.9절에서 BigQuery로 옮긴 SQL을 담았다. 이 실습에서는 쓰지 않는다

Python 스크립트는 표준 라이브러리만 쓰므로 따로 설치할 패키지가 없다. ClickHouse와는 HTTP 인터페이스(8123 포트)로 통신한다.

6.2 Starting the Server

compose.yaml은 ClickHouse 컨테이너 하나만 띄운다. 실습용 계정은 사용자 이름과 비밀번호가 모두 demo다.

yaml
services:
  clickhouse:
    image: clickhouse/clickhouse-server:24.8
    ports:
      - "8123:8123"
    environment:
      CLICKHOUSE_USER: demo
      CLICKHOUSE_PASSWORD: demo
      CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1"
    ulimits:
      nofile:
        soft: 262144
        hard: 262144

파일 여섯 개를 한 폴더에 받아 두고, 그 폴더에서 서버를 띄운 뒤 table을 만든다.

bash
docker compose up -d
docker compose exec -T clickhouse clickhouse-client \
  --user demo --password demo --multiquery < schema.sql

schema.sql의 내용은 5.2절의 SQL과 같다. 원본 table은 7일, 상태 table은 30일 보관한다.

6.3 Sending Events

send_events.py는 /home, /search, /item 세 페이지의 이벤트를 만든다. 이벤트의 절반은 LCP, 절반은 INP다. 평소에는 LCP는 평균 1.6초, INP는 평균 120ms 근처에서 나온다. --degraded 옵션을 주면 /item 페이지만 LCP 평균 3.2초, INP 평균 350ms로 나빠진다.

이벤트는 1초마다 한 묶음씩 INSERT ... FORMAT JSONEachRow로 보낸다. 5.5절의 설정을 그대로 URL 파라미터로 붙인다.

python
params = urllib.parse.urlencode({
    "query": "INSERT INTO web_perf_raw FORMAT JSONEachRow",
    "async_insert": 1,
    "wait_for_async_insert": 1,
    "date_time_input_format": "best_effort",
})

6.4 Polling for Alerts

alert_poll.py는 15초마다 alert.sql을 실행한다. 쿼리 결과에 나온 (metric, page)마다 연속으로 조건에 걸린 횟수를 세고, 두 번째부터 FIRING으로 표시한다. 다음 평가에서 결과에 나오지 않으면 횟수를 지우고 resolved를 출력한다. 5.4절에서 Grafana의 pending period에 맡긴 일을 스크립트로 옮겼다.

python
for key, r in found.items():
    streak[key] = streak.get(key, 0) + 1
    state = "FIRING" if streak[key] >= CONSECUTIVE else "pending"

6.5 Running It

터미널 두 개를 연다. 첫 번째 터미널에서는 알림 폴러를 띄운다.

bash
python3 alert_poll.py

두 번째 터미널에서 정상 이벤트를 먼저 보내고, 이어서 /item 페이지만 느려진 이벤트를 보낸다.

bash
python3 send_events.py --count 1200 --rps 400
python3 send_events.py --count 2400 --rps 400 --degraded

직접 돌렸을 때 폴러는 이렇게 출력했다.

text
ok
ok
[pending] LCP /item p75=3342ms threshold=2500ms n=601
[pending] INP /item p75=366ms threshold=200ms n=639
[FIRING] LCP /item p75=3342ms threshold=2500ms n=601
[FIRING] INP /item p75=366ms threshold=200ms n=639

정상 이벤트만 있을 때는 ok가 나온다. 느린 이벤트가 쌓이자 첫 평가에서 /item의 LCP와 INP가 pending이 됐고, 15초 뒤 다음 평가에서 FIRING으로 바뀌었다. /home과 /search는 기준을 넘지 않아 결과에 나오지 않았다.

실습을 마치면 컨테이너와 데이터를 지운다.

bash
docker compose down -v

6.6 Exercises

기본 흐름을 확인했다면 5장에서 다룬 문제를 하나씩 직접 재현해 볼 수 있다.

1. pending에서 firing까지 걸리는 시간 재기. 느린 이벤트를 보내기 시작한 시각부터 FIRING이 찍힌 시각까지 몇 초가 걸리는지 잰다. 폴러의 평가 주기와 연속 횟수 중 무엇이 이 시간을 정하는지 생각해 본다.

2. 늦게 도착한 이벤트 넣기. event_time을 2분 전과 10분 전으로 정해 이벤트를 직접 넣는다. 2분 전 이벤트는 다음 평가에 잡히고, 10분 전 이벤트는 알림에 영향을 주지 않아야 한다. 상태 table에서 해당 bucket의 countMerge(n)가 늘었는지도 확인한다.

sql
INSERT INTO web_perf_raw
SELECT generateUUIDv4(), now64(3) - INTERVAL 2 MINUTE, 'LCP', '/late', 5000
FROM numbers(200);

3. 같은 묶음 두 번 보내기. 같은 행들을 두 번 INSERT한 뒤 n이 두 배가 되는지 본다. 이 실습의 table은 복제 없는 MergeTree이고 async insert를 쓰므로, 5.8절의 기본값대로라면 중복이 걸러지지 않는다. 이어서 insert_deduplication_token을 지정하고 다시 보내 차이를 확인한다.

4. 읽은 양 비교하기. 같은 p75를 원본 table에서 직접 구하는 쿼리와 상태 table에서 구하는 쿼리를 각각 실행한 뒤, system.query_log의 read_rows를 비교한다. 이벤트 수를 늘려 가며 두 값이 어떻게 달라지는지 보면 5.6절의 결과를 직접 확인할 수 있다.

sql
SELECT query, read_rows, formatReadableSize(read_bytes) AS read
FROM system.query_log
WHERE type = 'QueryFinish' AND query LIKE '%web_perf%'
ORDER BY event_time DESC
LIMIT 5;
Discussion