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

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

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

ClickHouse는 컬럼 지향형 OLAP 데이터베이스입니다. 행 단위로 저장하는 PostgreSQL과 달리 각 컬럼을 별도 파일로 저장하기 때문에, 수백 개 컬럼 중 5개만 읽는 분석 쿼리라면 나머지 컬럼의 디스크 I/O가 아예 발생하지 않습니다. SIMD 명령어 기반 벡터화 엔진 덕분에 단일 서버에서 초당 수억 행 스캔이 가능하고, Materialized View + AggregatingMergeTree 패턴으로 사전 집계된 결과를 밀리초 안에 반환할 수 있습니다.

이 글에서는 Node.js 백엔드에서 공식 클라이언트 @clickhouse/client를 사용해 이벤트 인제스션 파이프라인을 구축하고, 실시간 집계 API를 만드는 과정을 다룹니다. 스키마 설계, 배치 INSERT 최적화, SQL 인젝션 방지, 대용량 결과 스트리밍까지 실제 운영 환경에서 밟게 되는 함정들을 함께 살펴보겠습니다.


핵심 개념

컬럼 저장이 왜 분석 쿼리에서 빠를까

행 지향 DB에서 SELECT event_type, COUNT(*) FROM events GROUP BY event_type을 실행하면 엔진은 모든 행을 읽어야 합니다. user_id, page_url, properties 같은 컬럼은 쿼리와 아무 관계가 없어도 같은 행에 묶여 있으니 디스크에서 함께 읽혀 옵니다.

ClickHouse는 event_type 컬럼 파일만 열면 됩니다. 같은 타입의 값이 연속으로 저장되니 LZ4·ZSTD 압축률도 높습니다. 원본 대비 3~10배 수준의 압축률을 기대할 수 있어 스토리지 비용도 줄어듭니다.

항목 PostgreSQL (행 지향) ClickHouse (컬럼 지향)
스캔 대상 모든 컬럼의 모든 행 쿼리에 필요한 컬럼만
압축률 낮음 (이종 데이터 혼합) 높음 (동종 데이터 연속)
분석 쿼리 속도 행 수 증가에 선형 저하 컬럼 수에 비교적 독립
OLTP UPDATE/DELETE 효율적 비효율적 (mutation 연산)

MergeTree — 데이터가 디스크에 저장되는 방법

ClickHouse의 기본 스토리지 엔진 MergeTree는 LSM 트리 아이디어에 기반합니다. 데이터는 정렬된 "파트(Part)" 단위로 디스크에 기록되고, 백그라운드 프로세스가 파트들을 지속적으로 병합합니다.

희소 프라이머리 인덱스는 8,192행마다 하나씩만 인덱스 포인트를 기록해 메모리를 거의 쓰지 않으면서도 원하는 데이터 범위를 밀리초 안에 좁힐 수 있습니다. 중요한 건 ORDER BY 컬럼 선택이 곧 쿼리 성능을 결정한다는 점입니다. 분석 쿼리의 WHERE 절에 자주 등장하는 컬럼을 ORDER BY 앞쪽에 두는 것이 원칙입니다.

소규모 INSERT를 반복하면 파트 수가 폭증합니다. "Too many parts" 오류는 ClickHouse를 처음 쓸 때 가장 흔하게 마주치는 문제인데, 배치 INSERT로 해결할 수 있습니다. 이건 나중에 코드로 자세히 살펴보겠습니다.

Materialized View + AggregatingMergeTree — 집계 미리 구워두기

실시간 집계 API의 핵심 패턴입니다. 원본 이벤트 테이블에 데이터가 INSERT될 때 Materialized View가 자동으로 트리거되어 집계 중간 상태를 별도 테이블에 저장합니다. API 쿼리 시에는 수억 건 원본 대신 미리 집계된 테이블만 읽으면 됩니다. 밀리초 수준의 응답 속도는 이 사전 집계된 테이블을 읽기 때문에 가능합니다 — 수억 건 원본을 실시간으로 집계하는 게 아닙니다.

다이어그램 1

AggregatingMergeTree는 집계 함수의 중간 상태(countState, uniqState 등)를 저장하고 병합 시 자동으로 합산합니다. 조회할 때는 countMerge, uniqMerge로 최종값을 꺼내오는 방식입니다.


실전 적용

전체 아키텍처를 먼저 보겠습니다. 사용자 이벤트가 발생하면 Node.js 인제스터가 버퍼링 후 배치 INSERT를 보내고, 그 즉시 Materialized View가 집계 테이블을 갱신합니다. 분석 API는 집계 테이블만 조회해 대시보드에 결과를 돌려줍니다.

다이어그램 2

1단계: 클라이언트 초기화와 연결 풀 설정

bash
npm install @clickhouse/client
typescript
// src/db/clickhouse.ts
import { createClient } from '@clickhouse/client'
 
export const clickhouse = createClient({
  url: process.env.CLICKHOUSE_URL ?? 'http://localhost:8123',
  username: process.env.CLICKHOUSE_USER ?? 'default',
  password: process.env.CLICKHOUSE_PASSWORD ?? '',
  database: 'analytics',
  max_open_connections: 20,
  keep_alive: {
    enabled: true,
    idle_socket_ttl: 2000,
  },
  request_timeout: 30_000,
})

idle_socket_ttl은 문서를 꼼꼼히 읽기 전까지 놓치기 쉬운 설정입니다. AWS ALB나 Nginx의 keepalive 타임아웃이 이 값보다 낮게 설정되어 있으면 서버가 먼저 연결을 끊어버리면서 간헐적 소켓 오류가 발생합니다. 운영 환경의 로드밸런서 keepalive 타임아웃을 확인하고 그보다 낮은 값으로 맞춰두시면 됩니다.

2단계: 이벤트 테이블과 집계 구조 설계

sql
-- 원본 이벤트 테이블
CREATE TABLE IF NOT EXISTS analytics.events
(
    event_id   UUID                  DEFAULT generateUUIDv4(),
    user_id    String,
    event_type LowCardinality(String),
    page_url   String,
    timestamp  DateTime64(3)         DEFAULT now64(),
    properties String
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (event_type, user_id, timestamp);
 
-- 시간별 집계 결과 테이블
CREATE TABLE IF NOT EXISTS analytics.events_hourly_agg
(
    event_type   LowCardinality(String),
    hour         DateTime,
    event_count  AggregateFunction(count),
    unique_users AggregateFunction(uniq, String)
)
ENGINE = AggregatingMergeTree()
PARTITION BY toYYYYMM(hour)
ORDER BY (event_type, hour);
 
-- events INSERT 시 자동으로 트리거되는 Materialized View
CREATE MATERIALIZED VIEW IF NOT EXISTS analytics.events_hourly_mv
TO analytics.events_hourly_agg
AS
SELECT
    event_type,
    toStartOfHour(timestamp) AS hour,
    countState()             AS event_count,
    uniqState(user_id)       AS unique_users
FROM analytics.events
GROUP BY event_type, hour;

LowCardinality(String)은 딕셔너리 인코딩을 적용해 event_type처럼 종류가 수십~수백 개인 컬럼의 저장 공간과 쿼리 속도를 개선합니다. properties 컬럼은 JSON 문자열로 저장하고 쿼리에서 JSONExtract 함수로 파싱하는 방식이 스키마 유연성과 성능의 균형을 잘 맞춰줍니다.

ORDER BY (event_type, user_id, timestamp)는 이 예제의 쿼리 패턴(WHERE event_type = ? AND timestamp BETWEEN ?)에 맞춘 선택입니다. 실제 서비스의 분석 쿼리 패턴이 다르다면 ORDER BY 설계도 달라져야 합니다.

3단계: 배치 INSERT로 파트 폭증 막기

배치 INSERT와 async_insert는 서로 다른 문제를 해결합니다.

  • 배치 INSERT: 클라이언트가 여러 이벤트를 직접 묶어 한 번에 전송합니다. 파트 생성 횟수를 줄이는 가장 직접적인 방법입니다.
  • async_insert: 클라이언트가 단건 INSERT를 보내더라도 서버 측이 일정 조건이 될 때까지 버퍼링했다가 병합합니다. 클라이언트 코드를 수정하기 어려운 상황에서 유용합니다.

클라이언트에서 이미 배치를 구성하고 있다면 async_insert를 추가할 필요가 없습니다. 여기서는 클라이언트 배치 방식을 사용합니다.

Node.js에서는 이벤트를 메모리 버퍼에 모았다가 "5,000건 도달" 또는 "2초 경과" 중 먼저 충족되는 조건에서 플러시하는 패턴이 일반적입니다.

다이어그램 3

typescript
// src/services/event-ingester.ts
import { clickhouse } from '../db/clickhouse'
 
interface EventPayload {
  user_id: string
  event_type: string
  page_url: string
  properties: Record<string, unknown>
}
 
const buffer: EventPayload[] = []
let flushTimer: ReturnType<typeof setTimeout> | null = null
let isFlushing = false
 
const FLUSH_INTERVAL_MS = 2_000
const FLUSH_BATCH_SIZE  = 5_000
 
async function flush() {
  if (isFlushing || buffer.length === 0) return
  isFlushing = true
 
  const batch = buffer.splice(0, buffer.length)
 
  try {
    await clickhouse.insert({
      table: 'analytics.events',
      values: batch.map((e) => ({
        ...e,
        properties: JSON.stringify(e.properties),
      })),
      format: 'JSONEachRow',
    })
  } catch (err) {
    // INSERT 실패 시 배치를 버퍼 앞으로 복구합니다
    buffer.unshift(...batch)
    console.error('[event-ingester] INSERT 실패, 배치 복구:', err)
  } finally {
    isFlushing = false
  }
}
 
function scheduleFlush() {
  if (flushTimer) return
  flushTimer = setTimeout(() => {
    flushTimer = null
    flush().catch((err) => console.error('[event-ingester] flush 오류:', err))
  }, FLUSH_INTERVAL_MS)
}
 
export function trackEvent(event: EventPayload) {
  buffer.push(event)
  if (buffer.length >= FLUSH_BATCH_SIZE) {
    if (flushTimer) {
      clearTimeout(flushTimer)
      flushTimer = null
    }
    flush().catch((err) => console.error('[event-ingester] flush 오류:', err))
  } else {
    scheduleFlush()
  }
}

isFlushing 플래그로 크기 조건과 타이머 조건이 동시에 트리거되더라도 중복 실행을 막습니다. INSERT 실패 시에는 splice로 꺼낸 배치를 버퍼 앞으로 되돌려 재시도 기회를 줍니다.

한 가지 중요한 한계를 밝혀둡니다. 이 방식은 프로세스 재시작 시 메모리 버퍼가 모두 유실됩니다. 데이터 유실이 용납되지 않는 파이프라인이라면 Kafka나 Redis Stream 같은 내구성 있는 큐를 앞단에 두는 구조를 고려해야 합니다.

4단계: SQL 인젝션 방지 — query_params 사용

사용자 입력을 쿼리 문자열에 직접 보간하면 SQL 인젝션에 취약해집니다. @clickhouse/client의 query_params를 사용하면 파라미터가 URL 쿼리 스트링(param_<n>= 형식)으로 서버에 전달되고, 서버가 선언된 타입에 맞게 치환합니다. RDBMS의 prepared statement와는 구현 방식이 다르지만, 사용자 입력이 SQL 문법으로 해석되는 경로를 차단한다는 보안 효과는 동일합니다.

typescript
// src/api/analytics.ts
import { clickhouse } from '../db/clickhouse'
 
interface HourlyStats {
  hour: string
  event_count: string
  unique_users: string
}
 
export async function getHourlyStats(
  eventType: string,
  fromHour: Date,
  toHour: Date,
): Promise<HourlyStats[]> {
  const result = await clickhouse.query({
    query: `
      SELECT
        hour,
        countMerge(event_count)  AS event_count,
        uniqMerge(unique_users)  AS unique_users
      FROM analytics.events_hourly_agg
      WHERE event_type = {eventType: String}
        AND hour BETWEEN {fromHour: DateTime} AND {toHour: DateTime}
      GROUP BY hour
      ORDER BY hour ASC
    `,
    query_params: {
      eventType,
      fromHour: toClickHouseDateTime(fromHour),
      toHour:   toClickHouseDateTime(toHour),
    },
    format: 'JSONEachRow',
  })
 
  return result.json<HourlyStats>()
}
 
function toClickHouseDateTime(date: Date): string {
  return date.toISOString().replace('T', ' ').slice(0, 19)
}

문자열 보간 방식(WHERE type = '${userInput}')은 피하시면 됩니다.

5단계: 대용량 결과 스트리밍

집계 테이블이 아닌 원본 이벤트를 내보내야 하는 상황(데이터 익스포트, 감사 로그 등)에서는 result.stream()을 사용하는 편이 좋습니다. 수백만 건을 한 번에 메모리에 올리면 OOM이 발생할 수 있습니다.

typescript
// Express 라우트 예시
import { Router, Request, Response } from 'express'
import { clickhouse } from '../db/clickhouse'
 
const router = Router()
 
router.get('/export/events', async (req: Request, res: Response) => {
  const { from, to } = req.query
 
  if (typeof from !== 'string' || typeof to !== 'string') {
    res.status(400).json({ error: 'from과 to 파라미터가 필요합니다' })
    return
  }
 
  const resultSet = await clickhouse.query({
    query: `
      SELECT event_id, user_id, event_type, page_url, timestamp
      FROM analytics.events
      WHERE timestamp BETWEEN {from: DateTime} AND {to: DateTime}
      ORDER BY timestamp
    `,
    query_params: { from, to },
    format: 'JSONEachRow',
  })
 
  res.setHeader('Content-Type', 'application/x-ndjson')
  res.setHeader('Transfer-Encoding', 'chunked')
 
  for await (const rows of resultSet.stream()) {
    for (const row of rows) {
      res.write(JSON.stringify(row.json()) + '\n')
    }
  }
 
  res.end()
})

청크 단위로 읽어서 곧바로 HTTP 스트림으로 내보내기 때문에 메모리 사용량이 일정하게 유지됩니다.


장단점 분석

ClickHouse가 빛나는 순간

항목 내용
압도적 쿼리 성능 단일 코어 초당 수억 행 스캔, 사전 집계 테이블 기준 수억 건 GROUP BY가 수백 밀리초 안에 반환
고압축률 LZ4·ZSTD 기본 지원, 원본 대비 3~10배 압축으로 스토리지 비용 절감
고인서트 처리량 배치 INSERT 조합으로 초당 수백만 건 처리 가능
간단한 배포 단일 바이너리, 외부 의존성 없음
풍부한 분석 함수 quantile, topK, uniqHLL12 등 OLAP에 최적화된 집계 함수 내장
무료 오픈소스 Apache 2.0, 셀프 호스팅 시 라이선스 비용 없음

주의해야 할 상황

상황 이유와 대안
빈번한 UPDATE/DELETE 파트 전체를 재작성하는 mutation 연산. 이런 패턴에는 PostgreSQL이 더 적합합니다
다중 테이블 JOIN JOIN 성능이 약점. 비정규화 와이드 테이블이나 Materialized View로 사전 집계하는 방식이 좋습니다
소규모 단건 INSERT 반복 파트 수 폭증, "Too many parts" 오류. 배치 INSERT 적용을 권장합니다
ClickHouse Cloud 비용 Basic/Scale/Enterprise 티어로 제공되며, 가격 정책이 자주 바뀌므로 공식 가격 페이지에서 최신 요금을 확인하시기 바랍니다. 대용량 서비스라면 셀프 호스팅 TCO와 비교해보시면 좋습니다

실무에서 자주 밟는 함정 네 가지

1. ORDER BY 컬럼을 잘못 잡는 경우

ORDER BY timestamp만 지정하면 WHERE event_type = ? 조건이 테이블 풀 스캔으로 흘러갑니다. 이 예제처럼 WHERE event_type = ? AND timestamp BETWEEN ? 패턴이 주된 쿼리라면 ORDER BY (event_type, user_id, timestamp) 형태가 적합합니다. 중요한 건 ORDER BY 설계가 실제 서비스의 쿼리 패턴에 맞춰져야 한다는 점입니다.

2. idle_socket_ttl을 환경에 맞지 않게 설정하는 경우

로드밸런서 keepalive 타임아웃보다 클라이언트 idle_socket_ttl이 높으면 서버가 먼저 소켓을 닫아버리면서 간헐적 오류가 발생합니다. 운영 환경에서 원인을 찾기 까다로운 유형이라 초기 설정 시 반드시 로드밸런서 keepalive 타임아웃을 확인하고 그보다 낮은 값으로 맞춰두시면 됩니다.

3. 쿼리 문자열에 사용자 입력을 직접 보간하는 경우

typescript
// SQL 인젝션에 노출됩니다
query: `SELECT ... WHERE type = '${userInput}'`
 
// query_params를 통한 바인딩이 안전합니다
query: `SELECT ... WHERE type = {type: String}`,
query_params: { type: userInput }

4. Materialized View 생성 후 기존 데이터가 집계되지 않는 경우

CREATE MATERIALIZED VIEW ... AS SELECT ...는 생성 이후 INSERT되는 데이터에만 적용됩니다. 이미 events 테이블에 쌓여 있던 기존 데이터는 MV에 반영되지 않습니다. 기존 데이터도 집계 테이블에 채워 넣으려면 MV 생성 후 수동으로 백필 INSERT를 실행해야 합니다.

sql
-- MV 생성 후 기존 데이터를 수동으로 백필합니다
INSERT INTO analytics.events_hourly_agg
SELECT
    event_type,
    toStartOfHour(timestamp) AS hour,
    countState()             AS event_count,
    uniqState(user_id)       AS unique_users
FROM analytics.events
GROUP BY event_type, hour;

마치며

결국 ClickHouse가 해결하는 것은 하나입니다. "한 번 쓰고 많이 읽는" 이벤트 데이터를 다루는 분석 쿼리의 병목입니다. PostgreSQL로 충분하다면 굳이 스택을 추가할 필요는 없습니다. 하지만 이벤트 테이블이 수천만 건을 넘어가기 시작하고 집계 쿼리가 버겁다고 느껴진다면, Materialized View + AggregatingMergeTree 경로에서는 수십 초 걸리던 집계 쿼리가 수백 밀리초 안에 반환되는 변화를 경험할 수 있습니다.

핵심을 정리하면 이렇습니다.

  • MergeTree의 ORDER BY가 곧 쿼리 성능입니다. 실제 분석 쿼리의 WHERE 조건에 맞게 설계하는 것이 가장 중요합니다.
  • Materialized View + AggregatingMergeTree 조합이 실시간 집계 API의 핵심 패턴입니다. 원본 대신 집계 테이블을 조회하기 때문에 밀리초 응답이 가능합니다.
  • 배치 INSERT 없이는 운영 환경에서 파트 폭증 문제를 만납니다. 2초 또는 5,000건 기준 버퍼링 패턴을 적용하시면 됩니다.
  • query_params를 통한 입력 바인딩이 보안의 기본입니다. 문자열 보간 방식은 SQL 인젝션에 취약합니다.
  • MV는 생성 이후 INSERT에만 적용됩니다. 기존 데이터는 수동 백필이 필요합니다.

지금 시작하신다면 이 순서로 진행해보시면 좋습니다.

  1. Docker로 로컬 ClickHouse를 띄우고 @clickhouse/client로 SELECT 1이 오가는지 확인합니다. docker run -d -p 8123:8123 clickhouse/clickhouse-server로 30초 안에 시작할 수 있습니다.
  2. 위의 DDL을 그대로 실행해 events 테이블, events_hourly_agg, Materialized View를 생성하고 더미 데이터를 INSERT해봅니다. MV가 자동으로 집계를 채우는 걸 확인하는 순간 구조가 바로 이해됩니다.
  3. 분석 쿼리 응답 시간을 현재 PostgreSQL과 비교해보시면 됩니다. 데이터가 충분히 있다면 그 차이가 바로 설득력이 됩니다.

참고 자료

  • ClickHouse 공식 JavaScript 클라이언트 문서
  • clickhouse-js GitHub 저장소
  • ClickHouse Node.js 사용 예제 — 공식 Knowledge Base
  • @clickhouse/client npm 패키지
  • Async Insert 공식 문서
  • MergeTree 엔진 패밀리 공식 문서
  • Building StockHouse: Real-time market analytics with ClickHouse
  • ClickHouse Materialized Views 가이드 (GlassFlow)
  • ClickHouse 아키텍처 개요 공식 문서
  • ClickHouse 2025 연간 회고
  • Real-Time Analytics 데이터베이스 선택 가이드 2026
  • Alibaba Cloud — MergeTree 스토리지 구조 심층 분석
#ClickHouse#Node.js#OLAP#Materialized View#실시간 집계#컬럼형 데이터베이스
공유하기

목차

핵심 개념컬럼 저장이 왜 분석 쿼리에서 빠를까MergeTree — 데이터가 디스크에 저장되는 방법Materialized View + AggregatingMergeTree — 집계 미리 구워두기실전 적용1단계: 클라이언트 초기화와 연결 풀 설정2단계: 이벤트 테이블과 집계 구조 설계3단계: 배치 INSERT로 파트 폭증 막기4단계: SQL 인젝션 방지 — query_params 사용5단계: 대용량 결과 스트리밍장단점 분석ClickHouse가 빛나는 순간주의해야 할 상황실무에서 자주 밟는 함정 네 가지마치며참고 자료

추천 포스트

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

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

실시간 대시보드를 처음 붙였을 때 가장 많이 듣는 말이 있습니다. "쿼리가 왜 이렇게 느려요?" 초당 수만 건의 이벤트를 Kafka에서 받아 저장해두고 보니, 대시보드 SELECT 한 번에 10초가 걸리는 현실을 마주하게 되죠. 인덱스를 더 추가하고 집계 쿼리를 최적화…

2026년 07월 13일읽는 데 23분
Cloudflare D1과 Drizzle ORM으로 엣지 네이티브 SQLite 백엔드 구축하기
Database

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

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

2026년 07월 14일읽는 데 19분
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분