개요
주식 시세처럼 실시간 데이터는 어떻게 최적화 되고 있는 것일까요?
다양한 예시들을 찾아보면서, 웹 워커로 최적화할 수 있다는 사례가 있어서 한번 테스트해보고 싶었습니다.
웹 애플리케이션은 브라우저에서 실행이 되는데요.
브라우저의 메인 스레드를 렌더링 엔진과 JS 엔진이 같이 사용하고 있기에 렌더링 작업이나 연산 작업이 오래 걸리면 애플리케이션이 멈추거나 버벅거리는 현상이 발생하는 것입니다.
실시간 데이터를 API 방식이든, 네이티브 앱에서 받든 웹 애플리케이션 입장에서는 외부에서 받을 텐데요. 웹 애플리케이션은 데이터를 가공하는 연산 작업을 거쳐 브라우저의 렌더링 작업을 통해 화면에 데이터를 UI 형태로 보여줄 것입니다.

Mock 데이터 만들기
실시간 데이터이 오는 주기는 0.01초, 0.1초, 1초 등.. 다양할 텐데요.
여기서는 0.1초, 100ms에 데이터를 보내는 것으로 테스트 하였습니다.
아래와 같이 Mock 데이터를 생성하는 함수를 만들고, 이 함수는 콜백으로 데이터를 처리할 함수를 받습니다.
const startMockQuoteStream = (
onData: (payload: WorkerInput["payload"]) => void,
) => {
const intervalId = setInterval(() => {
const basePrice = 75000;
const payload = Array.from({ length: MOCK_QUOTE_COUNT }, (_, index) => ({
price: basePrice + (index - 5) * 100,
volume: Math.floor(Math.random() * 10000),
changeRate: (Math.random() - 0.5) * 4,
}));
onData(payload);
}, 100);
return () => clearInterval(intervalId);
};
하나의 페이지에서 최적화전 컴포넌트와 최적화한 컴포넌트를 토글버튼으로 변경할 수 있도록 구현해봤고요.
데이터 갯수는 100ms당 500개, 1000개, 5000개, 10000개, 50000개로 실시간 목데이터를 생성하도록 하였습니다.
렌더링 횟수도 기록하였는데 10초, 30초, 1분 동안 렌더링이 원활하게 잘 이뤄지는지 횟수로 판단해보기로 하였습니다.
최적화전 컴포넌트 로직
최적화전 컴포넌트 로직은 아래와 같이 작동하는데요.
실시간 데이터가 오면 useState를 이용해서 가공된 데이터로 지역상태를 업데이트시킵니다.
렌더링될 때마다 렌더링 횟수를 Ref에 저장해서, 10초, 30초, 1분이 경과되면 각 렌더링 횟수를 Ref에 저장해서 화면에 보여줍니다.
// 최적화전 컴포넌트 (OriginalQuoteList)
const [quoteOutput, setQuoteOutput] = useState<WorkerOutput | null>(null);
const [clickCount, setClickCount] = useState(0);
const renderCount = useRef(0);
const startTimeRef = useRef(Date.now());
const resultRef = useRef<Record<number, number>>({
10000: 0,
30000: 0,
60000: 0,
});
renderCount.current++;
useEffect(() => {
const stop = startMockQuoteStream((payload) => {
setQuoteOutput(formatQuotes(payload)); // 메인 스레드에서 포맷 + setState
});
return stop;
}, []);
const elapsed = Date.now() - startTimeRef.current;
const closedElapsed = Math.floor(elapsed / 10000) * 10000;
if (elapsed >= 10000 || elapsed >= 30000 || elapsed >= 60000) {
if (!resultRef.current[closedElapsed]) {
resultRef.current[closedElapsed] = renderCount.current;
}
}
최적화된 컴포넌트
최적화된 컴포넌트에는 웹 워커와 rAF(requestAnimaitionFrame)을 적용해봤습니다.
앞의 로직과의 차이점은 "웹 워커를 사용해서 데이터 가공 로직을 별도의 스레드에서 처리한 점"과 "rAF를 사용해서 브라우저 페인팅 주기에 맞춰서 리액트 렌더링 과정이 처리" 하는 부분입니다.
// 최적화한 컴포넌트 (OptimizedQuoteList)
const workerRef = useRef<Worker | null>(null);
const pendingRef = useRef<WorkerOutput | null>(null); // 최신 워커 결과 버퍼
const rafRef = useRef<number>(0);
const [quoteOutput, setQuoteOutput] = useState<WorkerOutput | null>(null);
const [clickCount, setClickCount] = useState(0);
const renderCount = useRef(0);
const startTimeRef = useRef(Date.now());
const resultRef = useRef<Record<number, number>>({
10000: 0,
30000: 0,
60000: 0,
});
renderCount.current++;
useEffect(() => {
const worker = new Worker(
new URL("./worker.ts", import.meta.url),
);
workerRef.current = worker;
// setState 대신 ref에만 저장 → 렌더 미발생
worker.onmessage = (event: MessageEvent<WorkerOutput>) => {
pendingRef.current = event.data;
};
// rAF 루프: 새 데이터가 있을 때만 setState
const tick = () => {
if (pendingRef.current !== null) {
setQuoteOutput(pendingRef.current);
pendingRef.current = null;
}
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
const stop = startMockQuoteStream((payload) => {
worker.postMessage({ type: "PROCESS", payload } satisfies WorkerInput);
});
return () => {
worker.terminate();
cancelAnimationFrame(rafRef.current);
stop();
};
}, []);
테스트 결과를 아래의 표로 정리해봤습니다.
100ms 시간당 들어오는 데이터 갯수를 바꿔주면서, 렌더링횟수를 테스트하였습니다.
| 측정 시간 | 구분 | 500개 렌더링횟수 | 1000개 렌더링횟수 | 5000개 렌더링횟수 | 10000개 렌더링횟수 | 50000개 렌더링횟수 |
|---|---|---|---|---|---|---|
| 10초 경과 | 최적화 전 | 101 | 101 | 61 | 25 | 7 |
| 최적화 후 | 101 | 101 | 88 | 45 | 10 | |
| 30초 경과 | 최적화 전 | 301 | 301 | 179 | 70 | 16 |
| 최적화 후 | 301 | 301 | 268 | 134 | 28 | |
| 1분 경과 | 최적화 전 | 601 | 601 | 350 | 136 | 30 |
| 최적화 후 | 601 | 601 | 536 | 265 | 54 |
1000개/100ms 까지는 렌더링 횟수가 시간에 비례해서 최적화 여부에 상관없이 선형적으로 증가하는 것을 확인할 수 있습니다.
5000개/100ms 에서는
최적화전: 30초, 60초 경과시 렌더링 횟수는 10초 경과에 비해 2.93배, 5.73배
최적화후: 30초, 60초 경과시 렌더링 횟수는 10초 경과에 비해 3.04배, 6.09배 증가하였습니다.
10000개/100ms 에서는
최적화전: 30초, 60초 경과시 렌더링 횟수는 10초 경과에 비해 2.8배, 5.44배
최적화후: 30초, 60초 경과시 렌더링 횟수는 10초 경과이 비해 2.97배, 5.88배 가 됩니다.
50000개/100ms 에서는
최적화전: 30초, 60초 경과시 렌더링 횟수는 10초 경과에 비해 2.28배, 4.28배
최적화후: 30초, 60초 경과시 렌더링 횟수는 10초 경과이 비해 2.8배, 5.4배 가 됩니다.
-> 실시간 데이터갯수가 많아질수록 브라우저에서 렌더링하는 데는 한계가 있으며, 최적화를 하면 성능개선 효과가 더 좋아짐을 알 수 있습니다.

1000개를 넘어가면 렌더링이 원활하게 되지 않으면서 버벅거림 현상이 발생한다고 볼 수도 있는데, 리액트에서 렌더링 프로세스 시간이 100ms를 오버한다는 의미가 됩니다.
실시간 데이터갯수가 5000개일 때, 리액트 Profiler 도구로 렌더링 시간을 확인해보면 100ms을 초과하는 걸 확인할 수 있습니다.
결론
몇 백개 ~ 1,000개 정도까지는 실시간으로 렌더링하는 것은 괜찮지만 그 이상의 갯수를 실시간 렌더링하기에는 어려움이 존재합니다.
웹 워커를 사용해서 별도의 스레드에서 일부 연산 작업을 위임시켜도 수천, 수만 개의 데이터를 실시간으로 처리하기에는 한계가 있다는 것을 알 수 있습니다.
웹 워커로 최적화하는 작업은 실패했습니다. ㅎㅎㅎ
웹 워커는 수천, 수만 개의 렌더링 최적화에 쓰이기보다는, 무거운 연산작업을 위임시키는 작업에 쓰이는 것이 적절해 보입니다.
뷰포트의 UI만 렌더링이 될 수 있도록 가상화 처리를 해주고, 실시간으로 받는 데이터의 갯수를 제한하는 것이 실시간 데이터 성능최적화에 더 좋은 방법이 될 것으로 보입니다.
'React' 카테고리의 다른 글
| Tanstack-Query로 무한스크롤링 이미지 구현하기 (0) | 2025.12.24 |
|---|---|
| 카카오맵 API: 특정 마커들이 다 보이게 지도 범위(Bounds) 조절하기 (0) | 2025.12.21 |
| [React] react-hook-form의 useWatch를 이용해 실시간으로 폼 데이터 확인하기 (0) | 2024.12.25 |
| [React] 컴포넌트를 어떻게 잘 구현할 수 있을까? (0) | 2023.07.29 |
| [React] 리액트 컴포넌트 최적화해보기 (with. useCallback, React.memo) (0) | 2023.06.22 |