
업비트 실시간 대시보드 시리즈 마지막 편입니다. 호가창(Order Book)을 3가지 방식으로 구현하고 실제 성능을 비교해봤습니다.
전체 소스코드는 GitHub에 공개되어 있습니다.
/benchmark페이지에서 3가지 방식의 성능 차이를 직접 확인할 수 있습니다.git clone https://github.com/kimkuns91/upbit-realtime-dashboard.git cd upbit-realtime-dashboard npm install && npm run dev # http://localhost:3000/benchmark 접속
1. 왜 호가창이 어려운가?
호가창은 프론트엔드에서 구현 난이도가 가장 높은 컴포넌트 중 하나입니다. 이유는 단순합니다.
- 업비트 WebSocket으로 초당 10~20회 호가 데이터가 들어옵니다
- 매수 15호가 + 매도 15호가 = 30개 행이 동시에 바뀝니다
- 잔량 바 너비, 가격, 수량이 매번 달라집니다
- 이 모든 게 버벅임 없이 부드럽게 보여야 합니다
이걸 어떻게 처리하느냐에 따라 성능 차이가 극적으로 벌어집니다. 직접 3가지 방식을 구현해서 비교해봤습니다.
2. 방식 1 — Naive (useState)
가장 직관적인 방법입니다. WebSocket 메시지가 올 때마다 setState를 호출합니다.
const NaiveOrderBook = ({ market }: { market: string }) => {
const [orderbook, setOrderbook] = useState<UpbitOrderbook | null>(null);
useEffect(() => {
const callbacks: SocketCallbacks = {
onOrderbook: (data) => {
if (data.code === market) {
setOrderbook({ ...data }); // 매번 새 객체 → 매번 리렌더링
}
},
};
upbitSocket.addListener(callbacks);
return () => upbitSocket.removeListener(callbacks);
}, [market]);
// 매 렌더마다 30개 행 전부 VDOM diffing...
return (
<>
{units.map((u, i) => (
<BenchmarkRow key={i} price={u.ask_price} size={u.ask_size} ... />
))}
</>
);
};
결과: 매 WS 메시지마다 setState가 호출되고, 매번 30개 행에 대한 VDOM diffing이 발생합니다. 실제로 벤치마크를 돌려보니 렌더링 횟수가 13,800회까지 올라갔습니다. React DevTools Profiler를 열면 렌더링이 쉬지 않고 찍힙니다.
3. 방식 2 — Throttle (lodash throttle + useState)
업데이트 빈도를 줄이면 되지 않을까 하는 아이디어입니다. lodash.throttle로 setState 호출을 100ms마다 제한합니다.
const ThrottledOrderBook = ({ market }: { market: string }) => {
const [orderbook, setOrderbook] = useState<UpbitOrderbook | null>(null);
const throttledSet = useMemo(
() => throttle((data: UpbitOrderbook) => {
setOrderbook({ ...data });
}, 100),
[]
);
useEffect(() => {
const callbacks: SocketCallbacks = {
onOrderbook: (data) => {
if (data.code === market) throttledSet(data);
},
};
upbitSocket.addListener(callbacks);
return () => {
upbitSocket.removeListener(callbacks);
throttledSet.cancel(); // 메모리 릭 방지
};
}, [market, throttledSet]);
// ...
};
결과: throttle 덕분에 렌더링 횟수가 10,616회로 줄어듭니다. Naive보다 약 23% 줄었지만, 여전히 만 단위의 React 리렌더링이 발생합니다. VDOM diffing 비용 자체는 그대로입니다.
4. 방식 3 — Optimized (useRef + requestAnimationFrame)
근본적인 해결책은 React 렌더링 사이클을 우회하는 것입니다.
핵심 아이디어는 이렇습니다. 데이터를 useState가 아닌 useRef에 저장하고, requestAnimationFrame 주기에 맞춰 DOM을 직접 업데이트합니다.
먼저 이 패턴을 범용 훅으로 추상화했습니다.
export const useThrottledRef = <T>({ onUpdate }: { onUpdate: (data: T) => void }) => {
const dataRef = useRef<T | null>(null);
const hasNewDataRef = useRef(false);
const onUpdateRef = useRef(onUpdate);
onUpdateRef.current = onUpdate;
useEffect(() => {
const tick = () => {
// 새 데이터가 있을 때만 콜백 호출
if (hasNewDataRef.current && dataRef.current !== null) {
onUpdateRef.current(dataRef.current);
hasNewDataRef.current = false;
}
requestAnimationFrame(tick);
};
const id = requestAnimationFrame(tick);
return () => cancelAnimationFrame(id);
}, []);
// setState 대신 이걸 호출 → 리렌더링 없음
const updateData = useCallback((newData: T) => {
dataRef.current = newData;
hasNewDataRef.current = true;
}, []);
return { updateData };
};
그리고 호가창에서 DOM을 직접 조작합니다.
const OptimizedOrderBook = ({ market }: { market: string }) => {
const containerRef = useRef<HTMLDivElement>(null);
const updateDOM = useCallback((data: UpbitOrderbook) => {
const container = containerRef.current;
if (!container) return;
const askRows = container.querySelectorAll('[data-row="ask"]');
askRows.forEach((row, i) => {
const priceEl = row.querySelector('[data-field="price"]');
const barEl = row.querySelector('[data-field="bar"]') as HTMLElement;
if (priceEl) priceEl.textContent = formatPrice(units[i].ask_price);
if (barEl) barEl.style.width = `${(units[i].ask_size / maxSize) * 100}%`;
});
}, []);
const { updateData } = useThrottledRef<UpbitOrderbook>({
onUpdate: updateDOM,
});
useEffect(() => {
const callbacks: SocketCallbacks = {
onOrderbook: (data) => {
if (data.code === market) updateData(data);
},
};
upbitSocket.addListener(callbacks);
return () => upbitSocket.removeListener(callbacks);
}, [market, updateData]);
// ...
};
결과: React 리렌더링은 마운트 시 1~2회뿐입니다. 이후 모든 업데이트는 rAF 콜백에서 DOM을 직접 수정하므로 VDOM diffing 비용이 0입니다.
5. CSS 플래싱 효과
가격이 바뀌었을 때 배경색이 잠깐 번쩍이는 효과도 넣었습니다. JavaScript로 스타일을 제어하면 리플로우가 발생하지만, CSS class 토글 방식은 GPU 가속을 탈 수 있습니다.
@keyframes flash-bid {
0% { background-color: rgba(239, 68, 68, 0.3); }
100% { background-color: transparent; }
}
.flash-bid {
animation: flash-bid 0.5s ease-out;
}
// 새로운 가격이 감지되면 CSS class 토글
row.classList.remove('flash-bid');
void row.offsetWidth; // reflow 트리거 (애니메이션 재시작)
row.classList.add('flash-bid');
6. 벤치마크 결과

프로젝트의 /benchmark 페이지에서 3가지 방식을 동시에 돌린 결과입니다.
| 방식 | FPS | 렌더링 횟수 | 비고 |
|---|---|---|---|
| Naive (useState) | 120 | 13,800회 | 매 WS 메시지마다 리렌더링 |
| Throttle (100ms) | 120 | 10,616회 | 줄었지만 여전히 만 단위 |
| Optimized (useRef+rAF) | 120 | 2회 | 마운트 시에만 렌더링 |
흥미로운 점은 FPS가 셋 다 120으로 동일하다는 것입니다. 최신 브라우저와 하드웨어가 좋아져서 FPS 자체는 잘 떨어지지 않습니다.
그렇다면 왜 최적화가 필요할까요? 렌더링 횟수를 보면 답이 나옵니다. Naive는 13,800회, Optimized는 단 2회. 6,900배 차이입니다. FPS가 동일하더라도 불필요한 리렌더링은 CPU 자원을 낭비하고, 다른 탭이나 인터랙션에 영향을 줍니다. 저사양 기기나 모바일에서는 이 차이가 FPS 드랍으로 직결됩니다.
Chrome DevTools Performance 탭에서 직접 확인해보시는 것을 추천합니다.
7. 언제 React 상태를 우회해야 하는가?
모든 곳에 useRef+rAF를 쓰라는 이야기는 아닙니다.
useState가 적합한 경우:
- 사용자 인터랙션 기반 업데이트 (클릭, 입력)
- 초당 1~2회 이하의 저빈도 업데이트
- 여러 자식 컴포넌트에 데이터를 props로 내려야 할 때
useRef+rAF가 적합한 경우:
- 초당 10회 이상의 고빈도 데이터 스트림
- 업데이트할 DOM 노드가 많을 때 (호가창 30행 등)
- 화면 표시만 하고 다른 로직에 영향을 주지 않는 데이터
- 불필요한 리렌더링을 최소화해야 하는 경우
트레이드오프는 분명합니다. React의 선언적 모델을 포기하는 대신 성능을 얻습니다. DOM을 직접 조작하므로 코드가 복잡해지고, React DevTools에서 상태를 추적하기 어려워집니다. 하지만 호가창처럼 고빈도 + 다수 DOM 노드 조합에서는 이 패턴이 거의 유일한 해답입니다.
정리
핵심 교훈은 하나입니다. 고빈도 실시간 데이터에서는 React 렌더링 사이클이 병목이 됩니다. 이를 인지하고, 적절한 지점에서 React를 우회하는 판단이 프론트엔드 성능 최적화의 핵심입니다.
다음 글에서는 이 프로젝트를 만들면서 느낀 점과, 실제 프로덕션 트레이딩 플랫폼을 만든다면 어떤 점들을 더 신경써야 하는지 정리해봤습니다.
'Frontend Development > [실습] 실시간 트레이딩 대시보드 만들기' 카테고리의 다른 글
| [실시간 트레이딩 대시보드 만들기 - 04] 회고 — 연습용 프로젝트와 실전의 거리 (0) | 2026.02.12 |
|---|---|
| [실시간 트레이딩 대시보드 만들기 - 02] TradingView Charts로 실시간 캔들 차트 구현 (0) | 2026.02.12 |
| [실시간 트레이딩 대시보드 만들기 - 01] WebSocket 파이프라인 설계와 Blob 파싱 (0) | 2026.02.12 |
