
업비트 실시간 대시보드 시리즈 2편입니다. 이전 글에서 WebSocket 파이프라인을 구축했고, 이번에는 그 위에 캔들 차트를 올립니다. REST API로 과거 200개 캔들을 불러오고, WebSocket 체결 데이터로 마지막 캔들을 실시간 업데이트하는 구조입니다.
전체 소스코드는 GitHub에 공개되어 있습니다.
git clone https://github.com/kimkuns91/upbit-realtime-dashboard.git cd upbit-realtime-dashboard npm install && npm run dev
1. 왜 TradingView Lightweight Charts인가?
금융 차트 라이브러리를 고를 때 D3.js, Recharts, ECharts 등을 검토했습니다. 결론은 Lightweight Charts 일택이었습니다.
- Canvas 기반: SVG 기반 라이브러리(D3, Recharts)와 달리 수천 개 데이터 포인트에서도 성능 문제가 없습니다
- 금융 특화: 캔들스틱, 볼륨 히스토그램, 크로스헤어 등이 내장되어 있습니다
- 가볍다: gzip 기준 약 45KB. TradingView 풀 라이브러리의 1/10 수준입니다
- TypeScript 지원: v5부터 타입이 더 정교해졌습니다
npm install로 바로 들어가는 금융 차트 라이브러리 중에서는 가장 실용적인 선택이었습니다.
2. CORS 프록시: Next.js Route Handler
업비트 REST API는 브라우저에서 직접 호출하면 CORS 에러가 납니다. Next.js의 Route Handler로 서버 사이드 프록시를 만들었습니다.
// src/app/api/candles/route.ts
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const market = searchParams.get('market');
const unit = searchParams.get('unit'); // 분봉 단위 또는 'days'
// 분봉/일봉에 따라 업비트 API 엔드포인트 결정
let url: string;
if (unit === 'days') {
url = `${UPBIT_API_BASE}/candles/days?market=${market}&count=${count}`;
} else {
url = `${UPBIT_API_BASE}/candles/minutes/${unit}?market=${market}&count=${count}`;
}
const response = await fetch(url, {
headers: { Accept: 'application/json' },
});
const data = await response.json();
return NextResponse.json(data);
}
클라이언트에서는 /api/candles?market=KRW-BTC&unit=1&count=200으로 호출하면 됩니다. 서버 사이드에서 업비트 API를 호출하므로 CORS 이슈가 없습니다.
3. 차트 컴포넌트 구현
Lightweight Charts v5에서는 시리즈 추가 방식이 바뀌었습니다. 문자열 대신 SeriesDefinition 객체를 import해서 사용합니다. 처음에 v4 문법으로 작성했다가 타입 에러를 만났습니다.
import {
createChart,
CandlestickSeries, // v5: SeriesDefinition 객체
HistogramSeries,
ColorType,
} from 'lightweight-charts';
// 차트 인스턴스 생성
const chart = createChart(containerRef.current, {
layout: {
background: { type: ColorType.Solid, color: '#1a1a2e' },
textColor: '#a0aec0',
},
grid: {
vertLines: { color: '#2a2a4a' },
horzLines: { color: '#2a2a4a' },
},
});
// v5 방식: addSeries(SeriesDefinition, options)
const candleSeries = chart.addSeries(CandlestickSeries, {
upColor: '#ef4444', // 상승: 빨강 (한국 기준)
downColor: '#3b82f6', // 하락: 파랑
wickUpColor: '#ef4444',
wickDownColor: '#3b82f6',
});
// 볼륨 히스토그램 (차트 하단 20%)
const volumeSeries = chart.addSeries(HistogramSeries, {
priceFormat: { type: 'volume' },
priceScaleId: 'volume',
});
chart.priceScale('volume').applyOptions({
scaleMargins: { top: 0.8, bottom: 0 },
});
반응형은 ResizeObserver로 처리했습니다. 컨테이너 크기가 변하면 차트도 따라 리사이즈됩니다.
const resizeObserver = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
chart.applyOptions({ width, height });
});
resizeObserver.observe(containerRef.current);
4. 실시간 캔들 업데이트 로직

이 부분이 가장 까다로웠습니다. WebSocket으로 들어오는 체결(trade) 데이터를 받아서 두 가지 케이스를 처리해야 합니다.
- 현재 캔들 시간 범위 안이면 → 기존 캔들의 high/low/close 업데이트
- 새로운 시간 범위에 진입하면 → 새 캔들 생성
const callbacks: SocketCallbacks = {
onTrade: (trade: UpbitTrade) => {
if (trade.code !== market) return;
const lastCandle = candlesRef.current[candlesRef.current.length - 1];
// 체결 시각을 캔들 간격에 맞춰 계산
const tradeTimestamp = Math.floor(trade.trade_timestamp / 1000);
const candleTime = Math.floor(tradeTimestamp / intervalSeconds) * intervalSeconds;
if (candleTime === lastCandle.time) {
// 기존 캔들 업데이트
lastCandle.high = Math.max(lastCandle.high, trade.trade_price);
lastCandle.low = Math.min(lastCandle.low, trade.trade_price);
lastCandle.close = trade.trade_price;
// 차트에 직접 업데이트 전달 (리렌더링 없음)
onCandleUpdateRef.current?.({ ...lastCandle }, { ...lastVolume });
} else if (candleTime > lastCandle.time) {
// 새 캔들 생성
const newCandle: CandleData = {
time: candleTime,
open: trade.trade_price,
high: trade.trade_price,
low: trade.trade_price,
close: trade.trade_price,
};
candlesRef.current.push(newCandle);
onCandleUpdateRef.current?.(newCandle, newVolume);
}
},
};
여기서 중요한 설계 포인트가 세 가지 있습니다.
캔들 데이터는 useRef에 저장합니다. useState에 넣으면 매 체결마다 리렌더링이 일어나서 차트가 버벅입니다.
차트 업데이트는 series.update() 한 번으로 끝냅니다. setData()로 전체를 다시 넣지 않습니다.
콜백 ref 패턴으로 훅과 차트 컴포넌트를 연결합니다. 차트 컴포넌트가 마운트되면 콜백을 등록하고, 언마운트되면 null로 초기화합니다.
5. 타임프레임 전환
TanStack Query의 queryKey에 타임프레임을 포함하면 자연스럽게 처리됩니다.
const queryKey = ['candles', market, timeframe];
const { data, isLoading } = useQuery<CandleChartData>({
queryKey,
queryFn: () => fetchCandles(market, timeframe),
staleTime: 1000 * 60, // 1분간 fresh
});
1분봉 → 5분봉으로 전환하면 queryKey가 바뀌고, 캐시에 없으면 자동으로 새 데이터를 fetch합니다. 이미 캐시에 있으면 즉시 보여줍니다. TanStack Query가 이런 부분을 정말 잘 해줍니다.
6. useEffect cleanup 주의점
차트 라이브러리를 React에서 쓸 때 가장 흔한 실수가 cleanup을 빼먹는 것입니다.
useEffect(() => {
const chart = createChart(containerRef.current, { ... });
// ...시리즈 추가
return () => {
resizeObserver.disconnect(); // 옵저버 해제
chart.remove(); // 차트 인스턴스 제거 (DOM 정리)
chartRef.current = null; // ref 초기화
};
}, []);
chart.remove()를 빼먹으면 Hot Reload 때마다 차트가 겹쳐서 생깁니다. ResizeObserver도 해제하지 않으면 메모리 릭이 발생합니다. 실제로 개발 중에 이 문제를 겪었습니다.
정리
이번 글에서 구현한 것은 다음과 같습니다.
- Next.js Route Handler로 업비트 REST API CORS 프록시
- TradingView Lightweight Charts v5 래퍼 컴포넌트 (다크 테마, 반응형)
- TanStack Query로 초기 캔들 데이터 로딩 + 캐싱
- WebSocket trade → 실시간 캔들 업데이트 (기존 캔들 수정 + 새 캔들 생성)
- ref 기반 업데이트로 불필요한 리렌더링 방지
다음 글에서는 프론트엔드에서 가장 까다로운 컴포넌트라고 할 수 있는 호가창을 다룹니다. useState, throttle, useRef+rAF 세 가지 방식의 렌더링 성능을 직접 비교해봤습니다.
'Frontend Development > [실습] 실시간 트레이딩 대시보드 만들기' 카테고리의 다른 글
| [실시간 트레이딩 대시보드 만들기 - 04] 회고 — 연습용 프로젝트와 실전의 거리 (0) | 2026.02.12 |
|---|---|
| [실시간 트레이딩 대시보드 만들기 - 03] 초당 20회 업데이트되는 호가창, 렌더링 횟수를 6,900배 줄인 방법 (0) | 2026.02.12 |
| [실시간 트레이딩 대시보드 만들기 - 01] WebSocket 파이프라인 설계와 Blob 파싱 (0) | 2026.02.12 |
