본문으로 건너뛰기

UX를 저해하는 화면 프리징 현상, Web Worker로 해결하기

· 약 6분
김현재
개발자

들어가며

회사에서 점검 데이터를 PDF로 내보내는 기능을 구현하게 됐습니다. 요구사항은 단순했습니다. 보고서 생성 페이지에 들어가면 PDF가 자동으로 생성되고, 완료되면 다운로드 되도록 구현하는 것입니다. 그런데 이 기능을 구현하면서 예상치 못한 문제를 마주쳤습니다. 이번 포스팅에서는 그 문제를 파악하고 해결하는 과정을 기록하기 위해 작성했습니다.

본 포스팅은 사내 프로젝트의 경험을 바탕으로 작성되었으나, 보안 정책상 실제 코드와 이미지를 그대로 사용할 수 없습니다. 따라서 프로젝트에서 해결한 핵심 로직과 메커니즘을 동일하게 재현한 별도의 데모 코드와 자료를 활용하여 정리했습니다.

기술 선정

React 프로젝트에서 PDF를 생성하는 방법은 여러 가지가 있는데, 그 중에서 @react-pdf/renderer를 선택한 이유는 두 가지였습니다.

첫째, JSX로 PDF 레이아웃을 작성할 수 있습니다. HTML/CSS에 익숙한 환경에서 PDF 구조를 자연스럽게 잡을 수 있어서 좋았습니다.

둘째, 클라이언트 사이드에서 동작합니다. 서버 없이 브라우저에서 바로 PDF를 생성할 수 있으니, 별도 백엔드 작업 없이 빠르게 붙일 수 있었습니다.

이렇게 @react-pdf/renderer로 정해진 포맷에 맞춰 PDF를 생성하도록 구현했습니다. 로컬에서 10~20장 정도 만들어볼 때는 별문제가 없어 보였습니다. 그런데 실제 배포 환경에서는 최대 3,000장까지 생성해야 하는 경우가 있었고, PDF 생성 버튼을 누르는 순간 브라우저 전체가 멈춰버렸습니다.

스크롤도, 클릭도, 애니메이션도 전부 멈추고, 심지어 버튼 클릭 직후의 상태 변경(setState)조차 바로 반영이 안 됐습니다. 수십 초 동안 완전히 얼어붙어 있다가, PDF 생성이 끝나고 나서야 UI가 다시 살아났습니다.

메인 스레드 블로킹

JavaScript는 기본적으로 싱글 스레드로 동작합니다. 브라우저의 메인 스레드는 한 번에 하나의 작업만 처리할 수 있는데, 이 스레드에서 꽤 많은 일들이 동시에 일어납니다.

  • DOM 업데이트
  • 클릭, 스크롤 등 이벤트 처리
  • CSS 애니메이션
  • requestAnimationFrame 콜백 (60fps 렌더링)
  • JavaScript 코드 실행

그러면 PDF를 생성하기 위해 무거운 작업을 실행하면 어떻게 될까요? 다음 이미지를 보면 리액트 앱이 PDF를 생성해야 하기 때문에 작업이 완료될 때까지 메인 스레드가 멈춰버립니다. 사용자 입장에서는 버튼을 눌러도 반응이 없고, 스크롤도 안 되고, 애니메이션도 끊기는 상황이 발생합니다.

worker 미사용

해결책을 찾아서

문제를 파악하고 나서 세 가지 방법을 고민했습니다.

1. 청크 단위 분할 처리 — PDF를 여러 조각으로 나눠서 setTimeout으로 틈틈이 처리하는 방법입니다. 구현이 복잡하고 시간 여유가 없었기에 선택할 수 없었습니다.

2. 서버에서 생성 — PDF 생성을 서버로 넘기는 방법입니다. 일반적으로 가장 권장되는 방식이지만, 프론트엔드에서 구현해야 한다는 요구사항이 있어 선택할 수 없었습니다.

3. Web Worker — PDF 생성을 별도 스레드로 분리하는 방법입니다. 메인 스레드를 건드리지 않으니 UI는 계속 살아있고, Worker가 완료되면 결과만 받아오면 됩니다. 세 가지 중 가장 현실적인 선택지였습니다.

Web Worker를 처음 사용해 보았기에, 먼저 Web Worker가 무엇인지 알아보았습니다.

Web Worker의 종류

Dedicated Worker

가장 기본적인 Worker입니다. 하나의 스크립트(페이지)와 1:1로 연결됩니다. 생성한 스크립트만 해당 Worker와 통신할 수 있습니다. 대부분의 "백그라운드 작업 분리" 요구사항에 적합합니다.

// 생성
const worker = new Worker('./myWorker.js');

// 메시지 전송
worker.postMessage({ type: 'start' });

// 결과 수신
worker.onmessage = (e) => console.log(e.data);

Shared Worker

여러 페이지(탭, iframe)가 하나의 Worker를 공유할 수 있습니다. 탭 간 상태 공유나 공통 캐시 관리에 유용합니다. 다만 port를 통해 통신해야 해서 Dedicated Worker보다 코드가 복잡해집니다.

// 생성
const worker = new SharedWorker('./sharedWorker.js');

// port를 통해 통신
worker.port.postMessage({ type: 'getData' });
worker.port.onmessage = (e) => console.log(e.data);

Service Worker

브라우저와 네트워크 사이에서 프록시 역할을 합니다. 오프라인 지원, 푸시 알림, 캐시 전략 구현에 쓰입니다.

Service Worker는 추후 MSW 설정에 사용해보았습니다. 자세한 내용은 블로그 글을 참고해주세요.

Dedicated WorkerShared WorkerService Worker
통신 대상생성한 스크립트 1개여러 탭/페이지브라우저 ↔ 네트워크
주요 용도CPU 집약 연산탭 간 상태 공유오프라인, 캐시, 푸시
복잡도낮음중간높음

PDF 생성에는 Dedicated Worker가 적합해보였습니다. 특정 페이지에서 요청이 오고, 그 페이지에서만 결과를 받으면 되기 때문입니다.

실제 구현

Worker 파일 작성

// src/workers/pdfWorker.tsx

self.onmessage = async (e: MessageEvent) => {
if (e.data?.type !== 'generate') return;

try {
// 1. API 요청 (Worker 스레드에서 실행 — 메인 스레드 영향 없음)
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
if (!response.ok) throw new Error(`API 오류: ${response.status}`);
const data: Post[] = await response.json();

// 2. 응답 데이터로 PDF 생성
const blob = await pdf(<ReportDocument data={data} />).toBlob();

// 3. 생성된 blob을 메인 스레드로 전달
self.postMessage({ type: 'complete', blob });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
self.postMessage({ type: 'error', message: msg });
}
};

메인 스레드가 generate 메시지를 보내면 워커가 API를 호출해 PDF를 생성하고, 완성된 PDF를 blob 형태로 다시 메인 스레드에 전달합니다.

React 컴포넌트에서 Worker 사용

const workerRef = useRef<Worker | null>(null);

// Worker는 컴포넌트 마운트 시 한 번만 생성
useEffect(() => {
const worker = new Worker('/workers/pdfWorker.js');

worker.onmessage = (e: MessageEvent) => {
if (e.data.type === 'complete') {
setBlobUrl(URL.createObjectURL(e.data.blob));
setState('done');
} else if (e.data.type === 'error') {
setState('error');
}
};

workerRef.current = worker;
return () => worker.terminate(); // 언마운트 시 정리
}, []);

const handleGenerate = () => {
setState('generating');

// Worker에게 생성 요청 — 메인 스레드는 즉시 반환
workerRef.current?.postMessage({ type: 'generate' });
};

기존 블로킹 방식과 비교

블로킹 방식 (기존)

const handleGenerate = async () => {
setState('generating');

const blob = await pdf(<ReportDocument />).toBlob();

setBlobUrl(URL.createObjectURL(blob));
setState('done');
};

Worker 방식 (개선)

const handleGenerate = () => {
setState('generating');

workerRef.current?.postMessage({ type: 'generate' });
};

코드는 비슷해 보여도 실제 동작 방식은 완전히 다릅니다. PDF 생성 작업을 Web Worker에 맡기면 메인 스레드는 자유롭게 다른 작업을 처리할 수 있어, 사용 중에도 멈추지 않고 정상적으로 동작합니다.

worker 사용

실제로 얼마나 차이 나나

requestAnimationFrame을 이용해 메인 스레드 응답성을 직접 측정해봤습니다. rAF는 메인 스레드가 살아있을 때마다 호출되기 때문에, 카운터가 멈추면 메인 스레드가 블로킹됐다는 뜻입니다.

const tick = (now: number) => {
frameRef.current++;
setCount(frameRef.current);
rafRef.current = requestAnimationFrame(tick);
};
requestAnimationFrame(tick);

결과 (30페이지 PDF 기준)

항목블로킹 방식Worker 방식
생성 소요 시간~1,200ms~1,300ms
메인 스레드 정지 시간~1,200ms0ms
rAF 카운터정지계속 증가
생성 중 클릭/스크롤불가가능
버튼 클릭 직후 UI 반응지연즉시

Worker 방식이 생성 시간 자체는 약간 더 걸립니다 (Worker 초기화 오버헤드). 하지만 메인 스레드는 한 번도 멈추지 않습니다.

페이지가 많아질수록 효과는 더 극명해집니다. 500페이지 기준으로 테스트했을 때는 블로킹 방식에서 10초 이상 UI가 완전히 먹통이 됐습니다.

worker 사용

주의할 점

DOM 접근 불가 — Worker 내부에서는 document, window, DOM API에 접근할 수 없습니다.

직렬화 비용postMessage로 주고받는 데이터는 직렬화/역직렬화됩니다. 매우 큰 데이터를 자주 주고받으면 이 비용이 커질 수 있습니다. 이번 포스팅에서 다룬 PDF 데이터는 Blob 객체를 사용했고, Blob의 경우 복사 없이 소유권을 이전할 수 있어서 효율적입니다.

Worker 초기화 비용 — Worker를 생성할 때 약간의 시간이 걸립니다.

마치며

이번 PDF 생성 이슈를 해결하면서 Web Worker를 직접 사용해보니, 기대 이상으로 실용적이고 구현도 간단하다는 것을 느꼈습니다. 이 포스팅을 읽는 독자분들도 앞으로 DOM 접근이 필요 없고 CPU 연산이 많이 들어가는 작업이라면, Web Worker에 위임하는 방식을 적극적으로 고려해보면 좋겠습니다.