스리슬쩍 업로드를 안했던 기술 블로그..(너무 바빴어요 ㅠㅠ)
이제 프로젝트가 다 끝남에 따라 마무리격으로 써보려고 한다.
이번 프로젝트에서 했던 경험 정리한 것으로 약간(많이) 날먹을 해보도록 하겠다.
이번 프로젝트는 주식 모의투자 플랫폼 서비스로, 사용자에게 실시간으로 변동하는 캔들 차트(분봉, 일봉, 주봉, 월봉) 데이터를 제공해야 했습니다.
시계열 데이터를 다루기 위해 초기에는 PostgreSQL을 사용했지만,
대량의 거래 데이터를 대상으로 기간별 평균, 최고가, 최저가, 종가 등을 집계하는 쿼리가 잦아지면서 조회 속도가 느려지는 문제가 발생했습니다.
특히 사용자 수가 많아지고, 각 사용자가 다양한 기간(1분, 5분 등)으로 데이터를 요청할 경우, PostgreSQL의 단순 group by 쿼리로는 빠른 응답이 불가능했습니다.
이를 해결하기 위해 TimescaleDB를 도입하여 시계열 데이터에 특화된 최적화 기능을 활용하기로 결정했습니다.
PostgreSQL 사용 시의 문제
시계열 캔들 데이터 조회 쿼리
SELECT *
FROM minute_candles
WHERE stock_code = '005930'
AND time BETWEEN '2025-01-01' AND '2025-09-25'
ORDER BY time;
집계된 데이터 조회 쿼리
SELECT date_trunc('day', time) AS day,
MAX(high), MIN(low),
FIRST(open, time), LAST(close, time)
FROM minute_candles
WHERE stock_code = '005930'
GROUP BY day;
이러한 쿼리들은 단일 테이블에서 수천만 건을 스캔하게 되므로,
응답 시간이 오래 걸리는 경우가 많았습니다.
1. InfluxDB
2. ClickHouse
3. Redis TimeSeries
1. 기존 자산 100% 활용 가능
2. 하이브리드 워크로드 완벽 지원
OLTP: 실시간 거래 데이터 삽입 (초당 1000건)
OLAP: 대용량 히스토리 데이터 집계 쿼리
💡 OLTP = Online Transaction Processing(실시간 거래 처리)
- 작은 단위의 데이터
- 조회/삽입 빈도: 매우 빈번
- 응답시간: 밀리초 단위
- 많은 사용자가 동시 접근
💡 OLAP = Online Analytical Processing(분석용 데이터 처리)
일반적인 OLAP
- 대용량 데이터 집계
- 빈도: 상대적으로 적음
- 응답시간: 초~분 단위 (복잡한 계산)
- 소수의 분석가가 사용
하지만 우리 서비스의 OLAP는 유저 대부분이 차트 조회 시 OLAP가 필요
3. 점진적 마이그레이션 가능
✅ 하이퍼테이블(Hypertable)
✅ 압축(Compression)
✅ 사전 집계(Continuous Aggregate)
time_bucket()으로 원하는 단위(예: 5분, 1일)로 집계SELECT date_trunc('day', time) AS day,
FIRST(open, time) AS daily_open,
MAX(high) AS daily_high,
MIN(low) AS daily_low,
LAST(close, time) AS daily_close
FROM minute_candles
WHERE stock_code = '005930'
AND time BETWEEN '2025-03-01' AND '2025-05-31'
GROUP BY day
ORDER BY day;

1단계: 쿼리 파싱 및 실행 계획 생성
Query Planner가 실행 계획 수립
└── 전체 테이블 스캔 계획 수립 (인덱스가 있어도 대용량에서는 비효율)
2단계: 전체 테이블 순차 스캔
minute_candles 테이블 (1억 행)
├── 2025-01-01 데이터 (불필요) ✗
├── 2025-02-01 데이터 (불필요) ✗
├── 2025-03-01 데이터 (필요) ✓
├── 2025-04-01 데이터 (필요) ✓
├── 2025-05-01 데이터 (필요) ✓
├── 2025-06-01 데이터 (불필요) ✗
└── ... (모든 데이터를 읽어야 함)
3단계: WHERE 조건 필터링
4단계: GROUP BY 집계 연산
5단계: 결과 정렬 및 반환

1단계: 쿼리 파싱 및 하이퍼테이블 인식
Query Planner가 하이퍼테이블 메타데이터 확인
└── 관련 청크 식별을 위한 Constraint Exclusion 준비
2단계: 청크 프루닝 (Chunk Pruning)
전체 50개 청크 중 필요한 청크만 식별:
chunk_202501 (2025-01-01~01-07) ✗ 시간 범위 밖
chunk_202502 (2025-01-08~01-14) ✗ 시간 범위 밖
...
chunk_202509 (2025-03-01~03-07) ✓ 조건 일치!
chunk_202510 (2025-03-08~03-14) ✓ 조건 일치!
...
chunk_202522 (2025-05-24~05-31) ✓ 조건 일치!
3단계: 병렬 청크 스캔
청크를 CPU 코어별로 병렬 처리
4단계: 압축 데이터에서 직접 집계
압축 블록 메타데이터 활용:
block_1: MIN=50000, MAX=51000, COUNT=1000, FIRST_TIME=09:00, LAST_TIME=09:01
block_2: MIN=51000, MAX=52000, COUNT=1000, FIRST_TIME=09:01, LAST_TIME=09:02
...
GROUP BY day는 압축 블록의 메타데이터만으로 90% 계산 완료
실제 행 데이터 접근은 FIRST/LAST 계산 시에만 필요
5단계: 청크별 결과 병합
minute_candles(분봉), daily_candles(일봉), weekly_candles(주봉), monthly_candles(월봉)을 각각 Hypertable로 변환했습니다.
SELECT create_hypertable('minute_candles', 'time', 'stock_code');
SELECT create_hypertable('daily_candles', 'time', 'stock_code');
SELECT create_hypertable('weekly_candles', 'time', 'stock_code');
SELECT create_hypertable('monthly_candles', 'time', 'stock_code');
time 컬럼 기준으로 시간별 chunk 분할stock_code 기준으로 병렬 처리 효율 향상과거 데이터(예: 3개월 이상 지난 캔들 데이터)는 자주 조회되지 않으므로
TimescaleDB의 압축 기능을 적용했습니다.
ALTER TABLE minute_candles SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'stock_code'
);
SELECT compress_chunk(i) FROM show_chunks('minute_candles', older_than => INTERVAL '90 days') i;
MAX, MIN, AVG 등 aggregate 함수 바로 실행 가능1분봉 데이터를 기반으로 3분봉/5분봉 등을 생성하는 쿼리를 위해
사전 집계(Materialized View)를 활용했습니다.
💡 Materialized View?
- 일반 PostgreSQL 기능
- 쿼리 결과를 실제 테이블로 저장
💡 Continuous Aggregate?
- TimescaleDB가 시계열용으로 확장한 Materialized View
WITH (timescaledb.continuous)옵션을 사용- 자동으로 새로운 데이터가 추가될 때 집계 갱신 가능
- 주로
time_bucket()과 함께 사용 → 시계열 집계 최적화- 기존 MV와 달리 자동/부분 갱신 기능 제공
-- 5분봉 continuous aggregate 생성
CREATE MATERIALIZED VIEW five_minute_from_minute
WITH (timescaledb.continuous) AS
SELECT
time_bucket('5 minutes', time) AS five_minute,
stock_code,
FIRST(open, time) AS open,
MAX(high) AS high,
MIN(low) AS low,
LAST(close, time) AS close,
SUM(volume) AS volume
FROM minute_candles
GROUP BY five_minute, stock_code;
-- 초기 집계 실행
CALL refresh_continuous_aggregate('five_minute_from_minute', NULL, NULL);

| 항목 | PostgreSQL | TimescaleDB |
|---|---|---|
| 데이터 수 | 144,646행 | 144,646행 |
| 조회 쿼리 (일별 평균) | 약 0.0482 | 약 0.0063 |
사실 timescaledb를 도입할때는 큰 고민이 없었다.
'우리가 PostgreSQL을 쓰니까 당연히 시계열은 그 확장인 TimescaleDB를 써야지~'라고만 생각했으니까!!
그런데 프로젝트를 경험으로써 정리하는 과정에서는 그 이유가 꼭 필요하더라..
그래서 선도입 후고민을 좀 해보았다!(클로드와 함께)
벌써 특화 프로젝트가 끝났다! 짧게만 느껴졌는데(실제로 엄청엄청대박왕짧음), 다른 팀들은 이 기간에 어떻게 개발한건지 너무 신기하다.
마지막에 유저테스트를 하루(원래 계획은 사흘이었다..) 진행했는데, 너무 재밌었다!
버그가 발생하는걸 실시간으로 지켜보고, 문제가 발생할 예정인 걸 뒤늦게 알아채고 급하게 서버 내리고..
찬이형이 없어서 그랬는지 막판에 불안불안~했지만 그래도 재밌게 해주신 분들이 계셔서 뿌듯했다! 실사용자 피드백 받은건 처음이야!!
실제 사용할만한 서비스를 만든다는게 얼마나 힘든건지 다시한번 느끼면서.. 이번 프로젝트도 너무 고생 많았다! 팀원분들도 나도!!