개인정보처리방침© 2026 DEV BAK - 기술블로그. All rights reserved.
DEV BAK - 기술블로그
포스트 검색
Database

Kafka 이벤트를 서브초 쿼리로 — ClickHouse Materialized View·Projections로 초당 수만 건 집계 파이프라인 설계하기

실시간 대시보드를 처음 붙였을 때 가장 많이 듣는 말이 있습니다. "쿼리가 왜 이렇게 느려요?" 초당 수만 건의 이벤트를 Kafka에서 받아 저장해두고 보니, 대시보드 SELECT 한 번에 10초가 걸리는 현실을 마주하게 되죠. 인덱스를 더 추가하고 집계 쿼리를 최적화해봐도 근본적인 문제는 해결되지 않습니다. 쿼리 시점에 수십억 행을 집계하는 아키텍처 자체가 병목이기 때문입니다.

ClickHouse는 이 문제를 정면으로 해결하는 세 가지 도구를 제공합니다. Kafka Engine Table로 별도 커넥터 없이 Consumer를 SQL로 선언하고, Materialized View로 이벤트가 들어오는 순간 집계를 완료해두며, Projections으로 다양한 필터 패턴에 쿼리 변경 없이 최적 레이아웃을 제공합니다. Wingify가 PostgreSQL에서 이 구조로 전환해 인프라 비용을 80% 절감한 사례도 같은 원리에서 출발합니다.

이 글에서는 Kafka → Kafka Engine Table → Materialized View → AggregatingMergeTree 파이프라인의 설계 원리와 실제 코드를 단계별로 살펴봅니다.


핵심 개념

3계층 파이프라인 아키텍처

ClickHouse 실시간 집계의 핵심은 "언제 집계하는가"의 시점 전환입니다. 전통적인 OLAP 구조에서는 쿼리가 들어올 때 raw 데이터를 집계하지만, ClickHouse의 파이프라인은 삽입 시점에 집계를 끝냅니다. 대시보드 SELECT는 사전 집계된 결과만 스캔하므로 대규모 데이터에서도 응답 시간이 일정하게 유지됩니다.

실제 구조에서는 Kafka Engine Table 하나를 소스로 두 개의 Materialized View가 연결됩니다. 하나는 원본을 감사 로그용으로 저장하고, 다른 하나는 즉시 집계해서 AggregatingMergeTree에 기록합니다.

다이어그램 1

각 계층의 역할을 정리하면 이렇습니다.

계층 컴포넌트 역할
수집 Kafka Engine Table Kafka Consumer 역할, 상태 없는 스트리밍 소스
변환 Materialized View INSERT 트리거, 집계·필터·변환 로직 실행
저장 AggregatingMergeTree 집계 중간 상태 저장 및 백그라운드 병합

Kafka Engine Table — SQL 한 줄로 Consumer 구성하기

Kafka Engine Table은 ClickHouse가 직접 Kafka Consumer 역할을 수행하는 네이티브 통합 방식입니다. 별도 Kafka Connect 클러스터 없이 DDL 몇 줄로 Consumer를 선언할 수 있습니다.

sql
CREATE TABLE events_kafka
(
    event_id    String,
    user_id     UInt64,
    event_type  LowCardinality(String),
    page_url    String,
    country     LowCardinality(String),
    ts          DateTime64(3)
)
ENGINE = Kafka
SETTINGS
    kafka_broker_list          = 'kafka1:9092,kafka2:9092',
    kafka_topic_list           = 'user-events',
    kafka_group_name           = 'ch-events-consumer',
    kafka_format               = 'JSONEachRow',
    kafka_num_consumers        = 4,
    kafka_skip_broken_messages = 100;

kafka_num_consumers와 파티션 수: kafka_num_consumers 설정에는 Kafka 토픽의 파티션 수가 상한선입니다. 파티션 수보다 많은 Consumer를 설정하면 초과분은 유휴 상태가 됩니다. 파티션 8개 토픽에 kafka_num_consumers = 4는 합리적이지만, kafka_num_consumers = 16은 절반이 낭비됩니다. 이 값을 변경하려면 ClickHouse를 재시작해야 하므로 초기 설계 시 파티션 수에 맞춰두는 것이 좋습니다.

kafka_skip_broken_messages 주의사항: 이 설정은 손상된 메시지를 지정한 수만큼 데이터 손실을 감수하며 건너뜁니다. 운영 코드에 아무 설명 없이 넣으면 조용히 데이터가 사라질 수 있습니다. 운영 환경에서는 기본값(0)으로 두거나, 설정하더라도 system.kafka_consumers 뷰에서 건너뛴 메시지 수를 모니터링하는 알림을 함께 구성하는 것을 권장합니다.

중요한 점은 이 테이블이 데이터를 저장하지 않는다는 것입니다. SELECT를 실행하면 Kafka 오프셋을 소비하는 스트리밍 소스처럼 동작합니다. 운영 환경에서 SELECT * FROM events_kafka를 실행하면 오프셋이 소진되므로, 데이터 확인은 별도 consumer group을 사용하거나 개발 환경에서만 수행하는 습관이 필요합니다.

Kafka 연동 방식 3가지 비교

방식 설정 복잡도 운영 부담 추천 환경
Kafka Engine 낮음 (DDL만) 중간 셀프호스팅, 빠른 시작
ClickPipes 최소 (UI 클릭) 없음 ClickHouse Cloud 사용자
Kafka Connect Sink 높음 높음 기존 Connect 인프라 활용 시

Materialized View — 삽입 시점 집계의 핵심

Materialized View는 Kafka Engine Table에 데이터가 들어오는 순간 자동으로 실행되는 INSERT-time 트리거입니다. 쿼리가 10초 걸리던 이유가 "SELECT 시점 집계"였다면, Materialized View는 그 연산을 "INSERT 시점"으로 옮겨버립니다.

sql
-- 실제 데이터를 저장할 영구 테이블
CREATE TABLE events_hourly_agg
(
    event_type   LowCardinality(String),
    country      LowCardinality(String),
    hour         DateTime,
    event_count  AggregateFunction(count),
    unique_users AggregateFunction(uniq, UInt64)
)
ENGINE = AggregatingMergeTree()
ORDER BY (event_type, country, hour)
TTL hour + INTERVAL 90 DAY;
 
-- Kafka Engine 테이블을 소스로 하는 Materialized View
CREATE MATERIALIZED VIEW mv_hourly TO events_hourly_agg AS
SELECT
    event_type,
    country,
    toStartOfHour(ts)  AS hour,
    countState()       AS event_count,
    uniqState(user_id) AS unique_users
FROM events_kafka
GROUP BY event_type, country, hour;

TO 키워드로 타겟 테이블을 명시하는 패턴을 강력히 권장합니다. TO 없이 생성하면 .inner.mv_hourly 형태의 숨겨진 내부 테이블이 자동으로 만들어져, 스키마 변경과 마이그레이션이 훨씬 까다로워집니다.

AggregatingMergeTree — State/Merge 패턴

AggregatingMergeTree는 집계 함수의 **중간 상태(State)**를 저장하고, 백그라운드 병합 시 이를 합쳐나가는 엔진입니다. SummingMergeTree가 숫자 합산만 지원하는 것과 달리, uniq, avg, quantile 등 복잡한 함수도 정확하게 병합할 수 있습니다.

sql
-- 쓰기: *State 함수로 중간 상태 저장 (Materialized View 내부)
SELECT
    countState()       AS event_count,
    uniqState(user_id) AS unique_users,
    sumState(revenue)  AS total_revenue
FROM events_kafka
GROUP BY event_type, country, hour;
 
-- 읽기: *Merge 함수로 상태를 최종 결과로 병합
SELECT
    event_type,
    country,
    hour,
    countMerge(event_count)   AS total_events,
    uniqMerge(unique_users)   AS dau,
    sumMerge(total_revenue)   AS revenue
FROM events_hourly_agg
WHERE hour >= now() - INTERVAL 24 HOUR
GROUP BY event_type, country, hour
ORDER BY hour DESC;

다이어그램 2

*Merge 함수는 여러 파트에 흩어진 중간 상태를 올바르게 합산합니다. 읽기 쿼리에서 GROUP BY를 생략해도 countMerge(event_count) 자체는 전체 합계를 정확하게 계산합니다. 문제는 정확성이 아니라 의미론적 차이입니다. 이벤트 타입별 카운트를 원하는데 GROUP BY event_type을 빠뜨리면, 정확하지만 쓸모없는 전체 합계 하나만 돌아옵니다.

AggregateFunction 컬럼은 *Merge 함수 없이 날것으로 SELECT하면 바이너리 블롭이 반환됩니다. SELECT event_count FROM events_hourly_agg 같은 쿼리는 읽을 수 없는 값을 돌려줍니다.

FINAL 키워드 대안: GROUP BY + *Merge 방식 외에 SELECT ... FROM t FINAL로도 병합되지 않은 파트를 강제로 병합해 읽을 수 있습니다. 단, FINAL은 모든 파트를 읽어 동기적으로 병합하므로 대용량 테이블에서 성능 비용이 큽니다. 집계 테이블을 대시보드 쿼리에 쓸 때는 GROUP BY + *Merge 조합이 더 효율적이고, FINAL은 소규모 테이블이나 일회성 검증 용도에 적합합니다.

Projections — 쿼리 변경 없이 레이아웃 최적화

Projections는 기존 테이블의 데이터와 함께 물리적으로 저장되는 보조 데이터 레이아웃입니다. PRIMARY KEY와 다른 컬럼으로 자주 필터링하는 쿼리를 최적화할 때 사용하는데, 쿼리 옵티마이저가 자동으로 선택하므로 기존 쿼리를 하나도 바꾸지 않아도 성능이 개선됩니다.

sql
-- 기본 테이블 ORDER BY: (event_type, country, hour)
-- country 기준 필터를 위한 Projection 추가
ALTER TABLE events_hourly_agg
    ADD PROJECTION proj_by_country
    (
        SELECT *
        ORDER BY (country, hour, event_type)
    );
 
-- 기존 데이터에 Projection 백필 적용
ALTER TABLE events_hourly_agg MATERIALIZE PROJECTION proj_by_country;
 
-- 이후 이 쿼리는 옵티마이저가 자동으로 proj_by_country를 선택
SELECT hour, countMerge(event_count) AS events
FROM events_hourly_agg
WHERE country = 'KR'
  AND hour >= now() - INTERVAL 7 DAY
GROUP BY hour;

Projection이 실제로 선택됐는지 확인하는 가장 신뢰할 수 있는 방법은 system.query_log의 projections 컬럼입니다.

sql
SELECT query, projections
FROM system.query_log
WHERE query LIKE '%events_hourly_agg%'
  AND type = 'QueryFinish'
ORDER BY event_time DESC
LIMIT 5;

쿼리 계획 단계에서 빠르게 힌트를 얻고 싶다면 EXPLAIN indexes = 1 SELECT ...도 활용할 수 있습니다. 다만 이 명령이 보여주는 것은 옵티마이저가 고려한 인덱스 정보이며, 실제 실행에서 사용됐음을 보장하지는 않습니다. 검증 목적이라면 system.query_log가 더 확실합니다.

모든 INSERT에 Projection 데이터도 함께 기록되므로 쓰기 오버헤드가 생깁니다. 실제로 자주 쓰이는 필터 패턴에만 선별적으로 적용하는 것이 좋습니다.


실전 적용

시나리오: 사용자 행동 이벤트 실시간 집계 대시보드

마케팅팀에서 다음을 실시간으로 보고 싶다고 하는 상황입니다.

  • 이벤트 타입별·국가별·시간대별 이벤트 수
  • 시간당 DAU (Daily Active Users)
  • 국가별 필터링 시에도 서브초 응답

1단계: Kafka Engine 소스 테이블 및 Raw 저장 테이블 구성

sql
-- Kafka Engine 소스 테이블 (데이터를 저장하지 않는 스트리밍 소스)
CREATE TABLE events_kafka
(
    event_id    String,
    user_id     UInt64,
    event_type  LowCardinality(String),
    page_url    String,
    country     LowCardinality(String),
    ts          DateTime64(3)
)
ENGINE = Kafka
SETTINGS
    kafka_broker_list   = 'kafka1:9092,kafka2:9092',
    kafka_topic_list    = 'user-events',
    kafka_group_name    = 'ch-events-consumer',
    kafka_format        = 'JSONEachRow',
    kafka_num_consumers = 4;
    -- kafka_skip_broken_messages는 기본값(0)으로 두는 것을 권장
    -- 데이터 손실을 허용할 근거가 있을 때만 명시적으로 설정
 
-- 감사 로그용 raw 테이블
-- ReplicatedMergeTree는 ZooKeeper/ClickHouse Keeper가 구성된 클러스터 환경에서 사용
-- 단일 노드라면 MergeTree()로 대체
CREATE TABLE events_raw
(
    event_id    String,
    user_id     UInt64,
    event_type  LowCardinality(String),
    page_url    String,
    country     LowCardinality(String),
    ts          DateTime64(3)
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/events_raw', '{replica}')
ORDER BY (ts, event_type)
TTL ts + INTERVAL 30 DAY;
 
-- events_kafka → events_raw 연결 MV (원본 저장용)
CREATE MATERIALIZED VIEW mv_raw TO events_raw AS
SELECT *
FROM events_kafka;

2단계: 집계 테이블 설계 및 Projection 추가

sql
CREATE TABLE events_hourly_agg
(
    event_type   LowCardinality(String),
    country      LowCardinality(String),
    hour         DateTime,
    event_count  AggregateFunction(count),
    unique_users AggregateFunction(uniq, UInt64)
)
ENGINE = AggregatingMergeTree()
ORDER BY (event_type, country, hour)
TTL hour + INTERVAL 90 DAY;
 
-- country 기준 필터 최적화를 위한 Projection
ALTER TABLE events_hourly_agg
    ADD PROJECTION proj_by_country
    (SELECT * ORDER BY (country, hour, event_type));
 
ALTER TABLE events_hourly_agg
    MATERIALIZE PROJECTION proj_by_country;

3단계: Materialized View로 집계 파이프라인 연결

sql
-- events_kafka → events_hourly_agg 연결 MV (집계용)
CREATE MATERIALIZED VIEW mv_hourly TO events_hourly_agg AS
SELECT
    event_type,
    country,
    toStartOfHour(ts)  AS hour,
    countState()       AS event_count,
    uniqState(user_id) AS unique_users
FROM events_kafka
GROUP BY event_type, country, hour;

이 시점에서 events_kafka를 소스로 하는 MV가 두 개(mv_raw, mv_hourly) 존재합니다. Kafka에서 배치가 들어올 때마다 두 MV가 모두 트리거되어 원본 저장과 집계가 동시에 처리됩니다.

4단계: 대시보드 쿼리

sql
-- 최근 24시간 이벤트 타입별 통계
SELECT
    event_type,
    countMerge(event_count)  AS total_events,
    uniqMerge(unique_users)  AS dau
FROM events_hourly_agg
WHERE hour >= now() - INTERVAL 24 HOUR
GROUP BY event_type
ORDER BY total_events DESC;
 
-- 국가별 시간대 분석 (proj_by_country 자동 선택)
SELECT
    hour,
    countMerge(event_count) AS events
FROM events_hourly_agg
WHERE country = 'KR'
  AND hour >= now() - INTERVAL 7 DAY
GROUP BY hour
ORDER BY hour;

Materialized View 체이닝으로 다단계 롤업 구성하기

Materialized View를 체이닝하면 ClickHouse 내부에서 다단계 집계를 완결지을 수 있습니다. GROUP BY 기반 시간→일 롤업처럼 단순 집계만 필요한 경우가 여기에 해당합니다. 스트림 간 JOIN이나 윈도우 함수가 필요한 복잡한 변환은 Materialized View SQL에서 지원되지 않으므로, 이 경우에는 Kafka Streams나 Flink에서 전처리한 뒤 ClickHouse에 적재하는 방식이 현실적입니다.

다이어그램 3

sql
-- 일 단위 롤업 저장 테이블
CREATE TABLE events_daily_agg
(
    event_type   LowCardinality(String),
    country      LowCardinality(String),
    day          Date,
    event_count  AggregateFunction(count),
    unique_users AggregateFunction(uniq, UInt64)
)
ENGINE = AggregatingMergeTree()
ORDER BY (event_type, country, day);
 
-- 시간 단위 집계 결과를 소스로 하는 일 단위 롤업 뷰
CREATE MATERIALIZED VIEW mv_daily TO events_daily_agg AS
SELECT
    event_type,
    country,
    toDate(hour)                  AS day,
    countMergeState(event_count)  AS event_count,
    uniqMergeState(unique_users)  AS unique_users
FROM events_hourly_agg
GROUP BY event_type, country, day;

롤업 뷰에서 countMergeState() 함수를 사용하는 점이 핵심입니다. 이미 AggregateFunction 타입으로 저장된 중간 상태를 다시 Merge하고, 그 결과를 또 State로 저장하는 방식으로 체이닝이 이어집니다.


장단점 분석

장점

항목 설명 기대 효과
삽입 시점 집계 쿼리 부하를 INSERT 시점으로 이전 대시보드 쿼리 응답 시간 대폭 단축
Kafka 네이티브 통합 DDL만으로 Consumer 구성, 커넥터 불필요 인프라 컴포넌트 수 감소
Projection 투명성 쿼리 변경 없이 옵티마이저가 최적 레이아웃 선택 다중 필터 패턴 동시 최적화
TTL 자동 관리 핫/웜/콜드 계층을 SQL로 선언적 관리 스토리지 비용 통제
수평 확장성 샤딩·복제 기반 수백 노드 확장 Cloud 환경에서 스토리지-컴퓨트 분리
단순 집계의 Flink 대체 MV 체이닝으로 GROUP BY 기반 다단계 집계 내재화 JOIN·윈도우 함수 불필요한 케이스에서 운영 클러스터 수 감소

Wingify는 PostgreSQL에서 이 구조로 전환해 인프라 비용을 80% 절감했습니다 (아래 참고 자료 링크 참조).

단점 및 주요 고려사항

항목 내용 대응 방법
MV UPDATE/DELETE 미반응 소스 변경 시 집계 불일치 가능 append-only 이벤트 소싱 설계로 회피
at-least-once 시맨틱 Kafka 재처리 시 중복 삽입 가능 ReplacingMergeTree 또는 멱등 설계
Projection 저장 오버헤드 모든 INSERT에 Projection도 함께 기록 실제 사용 필터 패턴만 선별 적용
스키마 변경 복잡도 MV 재생성 및 데이터 마이그레이션 필요 Blue/green 방식의 점진적 전환
Consumer 스케일링 제약 kafka_num_consumers 변경 시 재시작 필요, 파티션 수가 상한 초기 설계 시 파티션 수에 맞춰 설정
MV SQL 제한 JOIN, 윈도우 함수 미지원 해당 변환은 Kafka Streams나 Flink에서 전처리 후 적재

실무에서 흔한 실수

1. Kafka Engine 테이블에 직접 SELECT하기

운영 환경에서 SELECT * FROM events_kafka를 실행하면 Kafka 오프셋이 소비됩니다. 데이터 확인은 별도 consumer group을 사용하거나 개발 환경에서만 수행하는 것이 좋습니다.

2. AggregatingMergeTree 읽기 시 GROUP BY와 *Merge 함수 혼동하기

sql
-- 전체 합산은 되지만 키별 분리가 없는 쿼리
-- countMerge() 자체는 올바른 값을 반환하지만, 이벤트 타입별 분리가 사라짐
SELECT countMerge(event_count) FROM events_hourly_agg;
 
-- 이벤트 타입별로 나눠보고 싶다면 GROUP BY 필수
SELECT event_type, countMerge(event_count)
FROM events_hourly_agg
GROUP BY event_type;
 
-- AggregateFunction 컬럼을 *Merge 없이 SELECT하면 바이너리 블롭 반환
SELECT event_count FROM events_hourly_agg;  -- 읽을 수 없는 값

countMerge(event_count) 자체는 GROUP BY 없이도 전체 합계를 올바르게 계산합니다. 문제는 정확성이 아니라 의미론적 차이입니다. 이벤트 타입별 카운트를 원하는데 GROUP BY를 빠뜨리면, 정확하지만 쓸모없는 전체 합계 하나만 남습니다.

3. Materialized View를 TO 없이 생성하기

sql
-- 피해야 할 패턴 (숨겨진 내부 테이블 자동 생성)
CREATE MATERIALIZED VIEW mv_hourly AS SELECT ...;
 
-- 권장 패턴 (타겟 테이블 명시)
CREATE MATERIALIZED VIEW mv_hourly TO events_hourly_agg AS SELECT ...;

TO 없이 생성하면 .inner.mv_hourly 형태의 숨겨진 내부 테이블이 만들어져, 스키마 변경과 마이그레이션이 훨씬 까다로워집니다.


마치며

"언제 집계할 것인가"를 쿼리 시점에서 삽입 시점으로 옮기는 것 — 이 아키텍처 결정 하나가 느린 대시보드를 서브초 응답으로 바꿉니다. Kafka Engine Table이 외부 컨슈머 없이 이벤트 스트림을 받고, Materialized View가 들어오는 즉시 집계하며, AggregatingMergeTree가 그 중간 상태를 효율적으로 저장·병합합니다. 여기에 Projections를 더하면 하나의 테이블이 여러 필터 패턴에 쿼리 변경 없이 최적으로 응답하게 됩니다.

지금 시작하는 3단계:

  1. ClickHouse를 Docker로 띄우고 Kafka Engine 테이블 선언 경험하기 — Confluent Cloud나 Redpanda 무료 플랜으로 실제 Kafka와 연동할 수 있습니다. JSONEachRow 포맷부터 시작하고, kafka_skip_broken_messages는 기본값(0)으로 두는 것을 권장합니다.

  2. AggregatingMergeTree + Materialized View 조합으로 *State/*Merge 패턴 익히기 — 처음엔 countState()/countMerge() 쌍만 연습해도 충분합니다. 익숙해지면 uniqState, quantileState 등으로 확장할 수 있습니다.

  3. Projection 하나 추가하고 실제 사용 확인하기 — Projection을 추가한 뒤 쿼리를 실행하고, system.query_log의 projections 컬럼에서 해당 Projection이 선택됐는지 확인합니다. 컬럼이 비어 있다면 WHERE 조건이 Projection의 ORDER BY 선두 컬럼과 맞는지 점검해보세요.


참고 자료

  • Integrating Kafka with ClickHouse — 공식 문서
  • Kafka Table Engine — 공식 문서
  • Materialized Views versus Projections — 공식 문서
  • AggregatingMergeTree — 공식 문서
  • Kafka to ClickHouse: 3 Ingestion Methods Compared — GlassFlow
  • Real-Time Analytics with ClickHouse and Kafka — GlassFlow
  • How Braze rebuilt its real-time analytics pipeline with ClickHouse Cloud
  • From Postgres to ClickHouse: Achieving Real-Time Aggregations — Wingify Engineering
  • Simplifying Real-Time Data Pipelines: How ClickHouse Replaced Flink — Medium
  • How to Compare Projections vs Materialized Views in ClickHouse — OneUptime Blog
  • ClickHouse Kafka Engine FAQ — Altinity
  • ClickHouse Projections Complete Guide — ObsessionDB
  • AggregatingMergeTree in ClickHouse: Pre-Aggregated Storage Explained — Pulse
  • ClickHouse Query Performance Optimization: Complete 2026 Guide — e6data
  • ClickHouse 2025 Roundup — 공식 블로그
#ClickHouse#Kafka#Materialized View#AggregatingMergeTree#실시간 집계#스트리밍 파이프라인
공유하기

목차

핵심 개념3계층 파이프라인 아키텍처Kafka Engine Table — SQL 한 줄로 Consumer 구성하기Materialized View — 삽입 시점 집계의 핵심AggregatingMergeTree — State/Merge 패턴Projections — 쿼리 변경 없이 레이아웃 최적화실전 적용시나리오: 사용자 행동 이벤트 실시간 집계 대시보드Materialized View 체이닝으로 다단계 롤업 구성하기장단점 분석장점단점 및 주요 고려사항실무에서 흔한 실수마치며참고 자료

추천 포스트

Cloudflare D1과 Drizzle ORM으로 엣지 네이티브 SQLite 백엔드 구축하기
Database

Cloudflare D1과 Drizzle ORM으로 엣지 네이티브 SQLite 백엔드 구축하기

서버를 따로 관리하지 않으면서도 전 세계 사용자에게 낮은 읽기 레이턴시를 제공하는 데이터베이스가 필요하다면, 그 조합이 지금 꽤 현실적인 선택지가 됐습니다. Cloudflare D1은 2025년 4월 글로벌 읽기 복제(Global Read Replication)를 공개…

2026년 07월 14일읽는 데 19분
ClickHouse를 Node.js 백엔드에 연결하기 — 수억 건 이벤트 실시간 집계 API 실전 가이드
Database

ClickHouse를 Node.js 백엔드에 연결하기 — 수억 건 이벤트 실시간 집계 API 실전 가이드

어느 날 팀 채널에 알림이 울립니다. "대시보드 로딩이 30초 넘어요." 이벤트 테이블에 쌓인 행이 5억을 넘어가면서 PostgreSQL의 GROUP BY 쿼리가 타임아웃을 내기 시작한 겁니다. 인덱스도 달아보고, 파티셔닝도 해봤지만 데이터는 계속 쌓이고 쿼리는 점점…

2026년 07월 13일읽는 데 21분
PostgreSQL 스키마를 Git처럼 관리하기 — Atlas와 GitHub Actions로 선언적 Diff, 린팅, Drift 감지, 자동 롤백까지
Database

PostgreSQL 스키마를 Git처럼 관리하기 — Atlas와 GitHub Actions로 선언적 Diff, 린팅, Drift 감지, 자동 롤백까지

"야, 누가 프로덕션 DB에서 컬럼 직접 지웠어?" — 팀 슬랙에 이런 메시지가 뜨는 순간, 그날의 배포 계획은 전부 날아갑니다. 마이그레이션 파일은 충실히 쌓여 있는데, 누군가 핫픽스랍시고 프로덕션 DB에서 직접 ALTER TABLE 을 날리면 파일과 실제 스키마가…

2026년 07월 13일읽는 데 22분
PostgreSQL 분석 집계 병목, ClickHouse + Node.js 파이프라인으로 최대 100배 빠르게 해소하기
Database

PostgreSQL 분석 집계 병목, ClickHouse + Node.js 파이프라인으로 최대 100배 빠르게 해소하기

페이지뷰·버튼 클릭·구매 퍼널을 PostgreSQL RDS에 쌓다 보면, 일 단위 집계 쿼리가 30초를 넘기는 시점이 반드시 옵니다. 인덱스를 추가하고 파티셔닝을 적용해도, 행이 수억 건을 넘어서면 GROUP BY 쿼리의 응답 시간은 단순 최적화로는 더 이상 눌러지지…

2026년 07월 13일읽는 데 22분
브라우저 속 PostgreSQL: PGlite 실전 가이드
Database

브라우저 속 PostgreSQL: PGlite 실전 가이드

인터넷 연결 없이도 데이터를 온전히 다루는 앱을 만들고 싶었던 적이 있으신가요? 저도 처음엔 서버 API 호출 실패를 어떻게 처리할까 고민하다가, 결국 복잡한 캐싱 레이어를 끼워 넣고 "오프라인 지원"이라는 이름을 붙인 적이 있습니다. 그 방식의 문제는, 네트워크가 끊…

2026년 07월 12일읽는 데 22분
Drizzle ORM으로 타입 안전한 복잡 쿼리 작성하기 — 관계형 쿼리, 마이그레이션, Edge 런타임 대응까지
Database

Drizzle ORM으로 타입 안전한 복잡 쿼리 작성하기 — 관계형 쿼리, 마이그레이션, Edge 런타임 대응까지

Prisma를 쓰다 보면 불편함이 생기는 지점이 있습니다. 여러 테이블에 걸친 복잡한 include 체인을 쓸 때 N+1 쿼리 문제나 비효율적인 JOIN 전략이 발생해도 Prisma가 생성하는 SQL을 직접 조정하기 어렵고, Cloudflare Workers 같은 Ed…

2026년 07월 12일읽는 데 19분