2025. 05
반응하지 않는 내 사이트 살리기 (feat. Worker)
비동기도 큐에 쌓이면 사이트가 멈춥니다
- 최적화
- 비동기
- RxJS
- Web Worker
비상사태 발생!
실시간 대선토론 팩트체크 프로젝트를 진행하면서 실시간으로 큰 배열이 계속해서(초당 1번) 업데이트되어야 하는 컴포넌트를 구현하던 중이었습니다. 사진은 아래와 같은데요.

실시간으로 토론 스크립트를 자막처럼 하나씩 붙여 주는 컴포넌트입니다. 처음에는 괜찮았는데, 방송 시간이 길어지자 페이지 내부의 버튼을 누르면 UI가 반응하기까지 1초 수준의 딜레이가 생기는 현상이 벌어졌습니다.
원인
큰 사이즈의 배열(방송이 길어지면 배열 length가 수천이 되는 상황이었습니다)을 map으로 렌더링할 때 UI 업데이트가 이벤트 스트림에 의해 블락되는 것으로 추정했습니다.

Bottom-up 탭을 통해 살펴보면, React Fiber가 엄청난 시간을 쓰고 있는 걸 알 수 있었습니다. 컴포넌트를 하나씩 지우면서 뭐가 원인인지 찾았는데, 역시 SSE 스트림으로부터 오는 큰 배열이 계속 갱신되는 것이 원인인 것 같았습니다.
이 방법을 개선하려면 두 가지 최적화를 해야 한다고 생각했는데요,
Web Worker로 SSE 스트림 처리 최적화
React 컴포넌트 단에서의 렌더링 최적화
원인 자체는 비동기 큐의 블락(1
)에 더 무게가 있다고 생각했지만, 둘 중에 어떤 것이 더 효과적으로 딜레이를 줄여줄지 예상할 수 없었기 때문에 두 가지 최적화를 모두 진행해 보기로 했습니다.
Web Worker
Web Workers API - Web API | MDN
https://developer.mozilla.org/ko/docs/Web/API/Web_Workers_API
웹 워커(Web worker)는 스크립트 연산을 웹 어플리케이션의 주 실행 스레드와 분리된 별도의 백그라운드 스레드에서 실행할 수 있는 기술입니다. 웹 워커를 통해 무거운 작업을 분리된 스레드에서 처리하면 주 스레드(보통 UI 스레드)가 멈추거나 느려지지 않고 동작할 수 있습니다. … 원하는 코드는 뭐든 워커 스레드에서 실행할 수 있으나 몇 가지 예외가 존재합니다. 예를 들어 워커에서 DOM을 직접 조작할 수 없고, window
의 일부 메서드와 속성은 사용할 수 없습니다. 그러나 WebSocket과 IndexedDB를 포함한 많은 수의 항목은 사용 가능합니다.
Web Worker는 브라우저의 메인 스레드와 분리된 환경입니다. 즉 Worker과 기존 코드 사이에서 데이터를 주고받는 코드를 직접 작성해야 합니다. 예를 들어 인풋에 있는 데이터를 보내려면 postMessage()
로 보내고, 워커에서는 onMessage
이벤트 핸들러로 받습니다. 워커에 있는 데이터도 같은 방식으로 보낼 수 있습니다.
또 메인 스레드와 분리된 환경이기 때문에 DOM 조작이 불가능하고, window
같은 객체의 사용에 일부 제약이 있습니다. 이런 의미에서 Worker의 전역 객체는 window가 아닌 워커의 종류에 따라 xxxxxxWorkerGlobalScope
가 됩니다.
With React
export function WorkerProvider({ children }: { children: ReactNode }) {
const [worker, setWorker] = useState<Worker | null>(null)
useEffect(() => {
if (!worker) {
setWorker(
() =>
new Worker(new URL('...', import.meta.url), {
type: 'module',
name: '...',
})
)
}
return () => {
if (worker) {
worker.terminate()
setWorker(null)
}
}
}, [])
return <WorkerContext.Provider value={worker}>{children}</WorkerContext.Provider>
}
/// <reference lib="webworker" />
declare const self: DedicatedWorkerGlobalScope //전역 객체 타입 헬퍼
...
//아래 두 코드는 메인 스레드에서 postMessage()한 이벤트를 받아
//콘솔에 출력하는 같은 역할을 합니다.
self.addEventListener('message', (event) => {
console.log(event.data)
})
self.onMessage = (event) => {
console.log(event.data)
}
//worker에서 메인 스레드로 데이터를 보낼 때도 poseMessage를 씁니다
self.postMessage(data)
SSE 스트림 이관
task queue
가 꽉 찬 지금의 상황에서, SSE를 Web Worker로 이관하는 것이 좋은 방법이라고 일차적으로 생각했습니다. React에 Worker를 로드하기 위해 Provider 형태로 만들어 앱의 최상단에 두었습니다. (ref.current의 사용성 때문에 state로 처리했습니다)

최적화 후, 750ms가 줄었습니다. 아직 만족스러운 수치는 아니어서 결국 React 쪽 렌더링 최적화도 진행해야 합니다.
React 렌더링 최적화
먼저 간단하게, 별 기대가 되지 않는(…) React.memo를 적용한 후 성능 측정을 해 보겠습니다. 이 때 렌더링되는 리스트는 약 1800개였습니다.

37ms 정도가 줄었습니다. 이 정도면 꽤나 유의미하다고 생각합니다. 40ms는 가시적으로 보이기 시작하는 수치기 때문입니다. 그래도 0.2초의 딜레이가 남아 있어서 렌더링 자체가 덜 되도록 할 필요가 있었고… react-window
를 사용해 보았습니다.

이게 제대로 된 렌더링 딜레이가 아닐까요? react-window의 경우 스크롤되는 부분을 제외하고는 빈 공간으로 두었다가, 컴포넌트가 보여야 하는 부분에만 실제로 컴포넌트를 렌더링하는 방식을 사용하기 때문에 이런 식으로 획기적으로 딜레이가 줄어들 수 있습니다.
마무리
막상 일이 벌어지니 잠시 뇌가 멎었었는데, 웹 워커라는 것이 있다는 걸 기억해 내고 검색해서 사이트를 살려낼 수 있었습니다. Worker는 처음 사용해 보았는데 API가 잘 나와 있어서 어렵지 않게 사용할 수 있었습니다. 마지막에 react-window를 도입할 때는 performance 탭을 켜고 하루종일 씨름을 했었는데, 프론트에서 UI 반응이 늦는 것은 사이트의 전체적인 인상과 사용감을 결정하는데 지대한 역할을 한다는 걸 알기에 더 열심히 깎으려고 노력했습니다.