포스트

TimescaleDB를 시계열 DB로 고른 이유 — Hypertable과 연속 집계

주식 데이터는 왜 특별한가

주식 시세 데이터에는 일반 OLTP 데이터와 다른 특성이 있다.

  1. 쓰기 패턴이 단조롭다 — 항상 현재 시각 기준으로만 INSERT, UPDATE·DELETE 거의 없음
  2. 범위 쿼리가 지배적이다 — “최근 1시간 데이터 줘”, “어제 오전 9시~10시 사이 데이터 줘”
  3. 오래된 데이터는 정밀도가 낮아도 된다 — 1년 전 데이터는 1분봉이 아닌 일봉으로 충분
  4. 데이터 양이 선형으로 증가한다 — 202개 종목 × 1초 1틱 = 하루 약 550만 행

이 특성을 일반 PostgreSQL 테이블에 저장하면 어떤 문제가 생기는지부터 살펴보자.


일반 PostgreSQL의 문제점

B-Tree 인덱스의 한계

1
2
3
4
5
6
7
-- candles_1m 테이블에 6개월치 데이터가 쌓인 상황
-- 약 202종목 × 390분봉/일 × 120일 ≈ 9,440만 행

SELECT * FROM candles_1m
WHERE stock_id = 1
  AND bucket BETWEEN '2025-01-01' AND '2025-01-31'
ORDER BY bucket;

일반 B-Tree 인덱스는 시간 범위 쿼리에서 테이블이 커질수록 성능이 저하된다. 전체 인덱스를 탐색해야 하기 때문이다.

파티셔닝의 복잡성

PostgreSQL 네이티브 테이블 파티셔닝으로 이 문제를 해결할 수 있지만, 파티션 생성·관리·제거를 직접 해야 한다.

1
2
3
4
-- 매월 새 파티션을 수동 생성해야 한다
CREATE TABLE candles_1m_2025_01
    PARTITION OF candles_1m
    FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');

월별 파티션 생성 스크립트, 오래된 파티션 아카이빙 스크립트… 인프라 코드가 늘어난다.


TimescaleDB가 해결하는 방법

TimescaleDB는 PostgreSQL 확장(Extension)이다. PostgreSQL을 교체하는 게 아니라 위에 올린다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- TimescaleDB 확장 활성화
CREATE EXTENSION IF NOT EXISTS timescaledb;

-- 일반 테이블을 Hypertable로 변환
CREATE TABLE candles_1m (
    stock_id  BIGINT        NOT NULL,
    bucket    TIMESTAMPTZ   NOT NULL,
    open      NUMERIC(18,4) NOT NULL,
    high      NUMERIC(18,4) NOT NULL,
    low       NUMERIC(18,4) NOT NULL,
    close     NUMERIC(18,4) NOT NULL,
    volume    BIGINT        NOT NULL
);

SELECT create_hypertable('candles_1m', 'bucket');

create_hypertable() 호출 하나로 자동 파티셔닝이 활성화된다. TimescaleDB가 내부적으로 청크(chunk) 단위로 데이터를 관리한다.


Hypertable의 핵심: Chunk

Hypertable은 시간 축으로 데이터를 자동 분할한다.

1
2
3
4
5
candles_1m (Hypertable)
  ├── chunk_1  2025-01-01 ~ 2025-01-07  (1주일치)
  ├── chunk_2  2025-01-07 ~ 2025-01-14
  ├── chunk_3  2025-01-14 ~ 2025-01-21
  └── ...

범위 쿼리를 실행하면 TimescaleDB가 해당 시간 범위의 청크만 탐색한다. 테이블이 커져도 쿼리 성능이 일정하게 유지된다.

1
2
3
4
5
6
7
-- 이 쿼리는 전체 테이블이 아니라 해당 주의 chunk만 탐색
EXPLAIN SELECT * FROM candles_1m
WHERE stock_id = 1
  AND bucket BETWEEN '2025-01-15' AND '2025-01-22';

-- Custom Scan (ChunkAppend) on candles_1m
--   → Seq Scan on _timescaledb_internal._hyper_1_3_chunk

연속 집계(Continuous Aggregates)

monticker에서 가장 활용도 높은 기능이다.

캔들 데이터는 1분봉(candles_1m)으로 저장되지만, 일봉(candles_1d)은 매일 1440개의 1분봉을 집계해서 만들어야 한다. 실시간으로 매번 집계하면 쿼리 비용이 높다.

연속 집계(Continuous Aggregate)는 이 집계 결과를 물리적으로 저장하고, 새 데이터가 추가될 때 증분(incremental)으로만 갱신한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-- 일봉 연속 집계 정의
CREATE MATERIALIZED VIEW candles_1d_cagg
WITH (timescaledb.continuous) AS
SELECT
    stock_id,
    time_bucket('1 day', bucket)  AS bucket,
    first(open,  bucket)          AS open,
    max(high)                     AS high,
    min(low)                      AS low,
    last(close,  bucket)          AS close,
    sum(volume)                   AS volume
FROM candles_1m
GROUP BY stock_id, time_bucket('1 day', bucket);

-- 자동 갱신 정책 (매시간 최근 2일치 갱신)
SELECT add_continuous_aggregate_policy('candles_1d_cagg',
    start_offset => INTERVAL '2 days',
    end_offset   => INTERVAL '1 hour',
    schedule_interval => INTERVAL '1 hour'
);

이 뷰를 조회하면 미리 계산된 일봉 데이터가 반환된다.

1
2
3
4
5
-- 빠르고 싸다
SELECT * FROM candles_1d_cagg
WHERE stock_id = 1
  AND bucket >= '2025-01-01'
ORDER BY bucket;

실제 마이그레이션 (Flyway V4, V10)

monticker는 Flyway로 DB 스키마를 관리한다. TimescaleDB 관련 마이그레이션은 두 단계로 나뉜다.

V4: 기본 테이블 생성 + Hypertable 전환

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-- V4__create_market_data.sql
CREATE TABLE candles_1m (
    stock_id  BIGINT        NOT NULL REFERENCES stocks(id),
    bucket    TIMESTAMPTZ   NOT NULL,
    open      NUMERIC(18,4) NOT NULL,
    high      NUMERIC(18,4) NOT NULL,
    low       NUMERIC(18,4) NOT NULL,
    close     NUMERIC(18,4) NOT NULL,
    volume    BIGINT        NOT NULL DEFAULT 0,
    PRIMARY KEY (stock_id, bucket)
);

SELECT create_hypertable('candles_1m', 'bucket',
    chunk_time_interval => INTERVAL '7 days');

CREATE INDEX idx_candles_1m_stock_bucket
    ON candles_1m (stock_id, bucket DESC);

V10: 연속 집계 생성

1
2
3
4
-- V10__create_continuous_aggregates.sql
CREATE MATERIALIZED VIEW candles_1d_cagg
WITH (timescaledb.continuous) AS
SELECT ...

InfluxDB·ClickHouse와의 비교

TimescaleDB 대신 다른 시계열 데이터베이스를 선택하지 않은 이유를 정리했다.

항목TimescaleDBInfluxDBClickHouse
PostgreSQL 호환✅ 완전 호환
SQL 문법표준 SQLFlux/InfluxQLSQL 방언
JOIN 지원✅ 완전제한적
기존 ORM 재사용✅ JPA/JDBC제한적
운영 복잡도낮음 (PostgreSQL과 동일)높음높음

monticker는 PostgreSQL에 이미 users, stocks, stock_events 등 일반 비즈니스 테이블이 있다. TimescaleDB를 쓰면 하나의 PostgreSQL 인스턴스에서 비즈니스 데이터와 시계열 데이터를 동시에 다룰 수 있다.

운영 복잡도가 낮고, JPA와 Spring Data JPA를 그대로 사용할 수 있다는 점이 결정적이었다.


데이터 보존 정책

시계열 데이터는 시간이 지나면 정밀도를 낮춰도 된다. TimescaleDB의 데이터 보존 정책(Data Retention Policy)으로 오래된 1분봉을 자동으로 삭제할 수 있다.

1
2
-- 1년 이상 된 1분봉 청크는 자동 삭제
SELECT add_retention_policy('candles_1m', INTERVAL '1 year');

일봉(candles_1d_cagg)은 영구 보존하므로, 장기 차트는 일봉으로 제공하고 단기 상세 차트만 1분봉으로 제공하는 전략이 가능하다.


정리

  • TimescaleDB = PostgreSQL + 자동 파티셔닝(Hypertable) + 증분 연속 집계
  • 기존 SQL과 ORM을 그대로 사용할 수 있어 도입 비용이 낮다
  • 연속 집계로 다중 해상도 캔들 데이터를 쿼리 비용 없이 서빙할 수 있다

다음 시리즈(Series 2)에서는 실시간 시세 파이프라인의 첫 번째 단계인 Go Market Gateway를 다룬다. 202개 종목에 goroutine을 하나씩 할당하는 구조를 살펴본다.

  1. 1 가격이 아니라 이벤트를 팔자 — monticker 설계 철학
  2. 2 모듈식 모놀리스를 선택한 이유 — MSA의 유혹을 거부하기
  3. 3 TimescaleDB를 시계열 DB로 고른 이유 — Hypertable과 연속 집계
  4. 4 Go goroutine으로 202개 종목 동시 수집하기 — Market Gateway 설계
  5. 5 Kafka로 시세 파이프라인 분리하기 — 토픽 설계와 at-least-once
  6. 6 Netty로 수만 연결에 시세 브로드캐스트하기 — NioEventLoopGroup 리액터 패턴
  7. 7 EMA 기반 이상 탐지 — 가격 급등과 거래량 서지 실시간 감지
  8. 8 TreeMap으로 CLOB 호가창 구현하기 — 가격/시간 우선 매칭과 슬리피지
  9. 9 주문 전 동기 리스크 게이트 설계 — VaR, 집중도, 일일손실 5가지 규칙
  10. 10 잔고를 저장하지 말고 재구성하라 — 이벤트 소싱 원장 설계
  11. 11 감정 태그 × 수익률 — 투자 습관을 데이터로 기록하기
  12. 12 룰 엔진: RSI·MACD 조건식을 JSON DSL로 — Quant Lab 설계
  13. 13 백테스트 엔진: look-ahead 없는 시뮬레이션 — Sharpe·MDD·PF 계산
  14. 14 전략 지문(SHA-256)으로 룰셋 보호하기 — 서버 사이드 실행과 역공학 방어
  15. 15 Markowitz 최적화를 솔버 없이 구현하기 — 프로젝션 경사하강법
  16. 16 Kelly Criterion: 수학적 파산 방지 베팅 비율 — Half Kelly와 백테스트 연동
  17. 17 ZigZag + 패턴 템플릿 매칭으로 차트 패턴 감지 — 헤드앤숄더·이중바닥
  18. 18 ADX로 시장 국면 분류하기 — BULL·BEAR·SIDEWAYS·HIGH_VOL
  19. 19 손익통산으로 세금 줄이기 — Tax-Loss Harvesting 시뮬레이션
  20. 20 MockK로 JdbcTemplate 목킹하기 — 311개 테스트 작성 경험
  21. 21 OpenTelemetry + Jaeger로 분산 추적 — 시세 파이프라인 지연 측정
  22. 22 Circuit Breaker로 외부 API 장애 격리 — Resilience4j + KIS·Yahoo 폴백 체인
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다