본문으로 건너뛰기

"performance" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

모든 태그 보기

RSC + Streaming SSR로 대시보드 체감 속도 2배 높이기

· 약 4분
김현재
개발자

들어가며

사내 보안 대시보드를 개발하던 중, 이상한 점을 발견했습니다. 기능 구현은 완료됐는데 Lighthouse를 돌려보니 FCP(First Contentful Paint)가 3초를 훌쩍 넘기고 있었습니다. 사용자는 페이지에 접속하자마자 3초 이상 빈 화면을 마주하고 있었던 것입니다.

원인이 무엇이였을까요? 대시보드 페이지는 여러 API를 동시에 호출해야 했고, SSR 환경에서 서버가 모든 데이터를 다 받아온 뒤에야 HTML을 클라이언트로 전송하고 있었습니다. 모든 응답이 끝날 때까지 기다리는 구조였으니, FCP가 3초를 넘는 건 당연한 결과였습니다.

이번 포스팅에서는 Next.js의 RSC(React Server Components)Streaming SSR 을 조합하여 이 문제를 해결한 경험을 정리했습니다.

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

기존 방식의 문제점

App Router에서는 페이지 컴포넌트 자체를 async로 선언하고 직접 데이터를 fetch할 수 있습니다. Promise.all로 병렬 요청을 보내면 총 대기 시간을 줄일 수 있지만, 근본적인 한계가 있습니다.

// page.tsx — async Server Component
export default async function DashboardPage() {
// 병렬 요청이지만, 모두 완료될 때까지 렌더링 불가
const [threats, assets, compliance] = await Promise.all([
fetchThreats(), // 800ms
fetchAssets(), // 700ms
fetchCompliance(), // 1200ms ← 이 API가 전체를 결정
]);

return (
<div>
<ThreatCard data={threats} />
<AssetCard data={assets} />
<ComplianceCard data={compliance} />
</div>
);
}
서버                          브라우저
|── fetchThreats() ──▶ 800ms |
|── fetchAssets() ───▶ 700ms | 사용자는
|── fetchCompliance()▶ 1200ms | 이 동안
| | 아무것도
|◀── 모든 응답 완료 ──────────| 볼 수 없음
|── HTML 전송 ───────────────▶|

Promise.all로 병렬 요청을 보내더라도, 모든 응답이 완료될 때까지 HTML 전송 자체가 불가능합니다. 가장 느린 API(1,200ms)가 전체 페이지 속도를 결정하며, 이는 Web Vital의 핵심 지표인 FCP에 직접적인 영향을 줍니다.

그렇다면 await을 하나씩 순차적으로 사용하면 어떨까요? 오히려 더 느려집니다. 직렬로 요청하면 800 + 700 + 1,200 = 2,700ms를 기다려야 하기 때문입니다. Promise.all이든 순차 await이든, 페이지 컴포넌트의 async 함수가 완전히 끝나야 React가 렌더링을 시작한다는 구조 자체가 문제입니다. 그렇다면 어떻게 이 문제를 해결할 수 있을까요?

해결책: RSC + Streaming SSR

Next.js App Router는 두 가지 핵심 기능을 제공합니다.

  • RSC(React Server Components): 컴포넌트 자체가 async 함수가 될 수 있어, 컴포넌트 단위로 데이터 패칭이 가능합니다.
  • Streaming SSR: HTML을 조각(chunk) 단위로 나눠서 준비되는 대로 클라이언트에 전송합니다.

이 두 가지를 Suspense와 조합하면 섹션별로 독립적인 스트리밍이 가능해집니다.

구현 방법

루트 페이지를 Shell로 만들기

페이지 컴포넌트를 async로 선언하지 않으면, 서버는 데이터를 기다리지 않고 즉시 HTML을 생성해 전송합니다. 이 Shell이 클라이언트에 도착하는 순간 FCP가 기록됩니다.

Shell이란 데이터 없이 즉시 렌더링할 수 있는 페이지의 뼈대를 말합니다. 사용자는 빈 화면 대신 페이지 구조를 먼저 볼 수 있습니다.

// page.tsx — async 없이 즉시 렌더링
export default function DashboardPage() {
return <main>{/* 각 섹션은 Suspense로 감싸 독립적으로 스트리밍 */}</main>;
}

각 섹션을 RSC로 분리하기

// ThreatSection.tsx — async RSC
export default async function ThreatSection() {
const data = await fetchThreatData(); // 이 컴포넌트만을 위한 fetch
return <ThreatCard data={data} />;
}

각 섹션이 독립적인 컴포넌트로 분리되어 있기 때문에, 모든 fetch가 동시에 시작됩니다. 페이지 컴포넌트가 await Promise.all로 묶어서 기다리지 않아도 됩니다.

Suspense로 감싸 스켈레톤 UI 제공하기

Suspense로 감싼 RSC는 데이터가 준비될 때까지 fallback(스켈레톤)을 보여줍니다. 데이터가 도착하면 React가 스켈레톤을 실제 컴포넌트로 교체하고, 그 결과를 스트리밍으로 클라이언트에 전송합니다.

<main>
<Suspense fallback={<ThreatCardSkeleton />}>
<ThreatSection /> {/* 800ms 후 스트리밍 */}
</Suspense>

<Suspense fallback={<AssetCardSkeleton />}>
<AssetSection /> {/* 700ms 후 스트리밍 */}
</Suspense>

<Suspense fallback={<IssueTableSkeleton />}>
<IssueSection /> {/* 600ms 후 스트리밍 */}
</Suspense>

<Suspense fallback={<ComplianceTableSkeleton />}>
<ComplianceSection /> {/* 1200ms 후 스트리밍 */}
</Suspense>
</main>

스트리밍 타임라인

이렇게 구현하면 다음과 같은 순서로 화면이 채워집니다.

0ms     → Shell 즉시 전송 (FCP 기록)
0ms → 4개 섹션이 동시에 fetch 시작 (병렬)
+600ms → IssueSection 완료, 스트리밍
+700ms → AssetSection 완료, 스트리밍
+800ms → ThreatSection 완료, 스트리밍
+1200ms → ComplianceSection 완료, 스트리밍

기존 방식이라면 1,200ms가 지나도록 빈 화면이었겠지만, 이제는 600ms부터 순차적으로 콘텐츠가 채워집니다.

성능 비교

지표기존 방식RSC + Streaming
FCP (First Contentful Paint)~1,200ms 이상~200ms (shell)
LCP (Largest Contentful Paint)~3,000ms~900ms
체감 로딩 시간~3,000ms~1,500ms 이하

수치상으로는 절반이지만, 사용자가 느끼는 체감 속도 차이는 훨씬 큽니다. 기존에는 로딩이 끝날 때까지 아무것도 할 수 없었다면, 이제는 화면이 뜨는 순간부터 순차적으로 정보를 확인할 수 있습니다.

주의할 점

스켈레톤 UI는 필수

Suspensefallback이 없으면 섹션 자리가 비어있다가 갑자기 내용이 나타나 레이아웃 시프트(CLS)가 발생합니다. 실제 컴포넌트와 동일한 크기의 스켈레톤을 반드시 제공해야 합니다.

섹션 경계 설계

Suspense 경계를 너무 잘게 나누면 관리가 복잡해지고, 너무 크게 잡으면 스트리밍 효과가 줄어듭니다. 사용자에게 의미 있는 단위로 섹션을 나누는 것이 중요합니다.

예를 들어 Suspense 경계를 너무 잘게 나누면 네트워크 요청 수가 늘어나 오히려 비효율적인 방법이 될 수 있습니다.

마무리

CSR에서 SSR로 전환하면 성능이 좋아질 거라 막연히 기대했습니다. 하지만 SSR도 만능이 아니었습니다. 서버에서 모든 데이터를 기다렸다가 한 번에 렌더링하는 구조는, 오히려 CSR보다 긴 공백 화면을 만들어낼 수 있습니다.

RSC와 Streaming SSR은 그 한계를 보완하기 위해 Next.js가 내놓은 해답이었습니다. 이번 경험을 통해 기술을 도입할 때 "왜 이 기술이 등장했는가"를 먼저 이해하는 것이 중요하다는 걸 다시 한번 느꼈습니다.

다이나믹 임포트로 번들 400KB로 줄이기

· 약 4분
김현재
개발자

들어가며

사내 보안 이벤트 트래킹 서비스를 개발하면서 성능 이슈를 마주했습니다. 서비스 특성상 여러 종류의 차트를 활용해야 했고, 점점 차트가 많아질수록 접속 속도가 눈에 띄게 느려졌습니다. 그 중 특히 이상했던 점은, 기능이 추가되며 전체 서비스 크기는 커지는데 로그인 페이지조차 점차 느려진다는 것이었습니다. 로그인 화면은 변화가 없었기 때문에 더욱 의아했습니다.

이 문제가 왜 발생했는지, 그리고 어떤 방식으로 해결했는지 이번 포스팅에 차근차근 정리해보았습니다.

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

문제 파악

원인을 찾기 위해 rollup-plugin-visualizer로 번들 구성을 확인했습니다. 트리맵을 확인해보자 전체 번들의 절반 이상을 차지하는 거대한 블록이 있었는데, 바로 apexcharts 였습니다.

문제는 용량이 아주 큰 차트 라이브러리가 서비스 진입 시점에 항상 다운로드된다는 것이었습니다. 예를 들어 로그인 페이지, 설정 페이지 등 차트가 필요 없는 페이지에서도 모두 차트를 로드하고 있었습니다.

// routes/index.tsx — 기존 코드
import DashboardPage from '../pages/DashboardPage';
import HxPage from '../pages/HxPage';
import ExPage from '../pages/ExPage';

// DashboardPage.tsx — 기존 코드
import ReactApexChart from 'react-apexcharts';

DashboardPage 컴포넌트가 차트 출력을 위해 차트 라이브러리를 import하는데, 라우터에서 모든 페이지를 한 번에 import하다 보니 사용자는 어떤 페이지를 들어가도 전체 코드가 미리 다 같이 다운로드되는 구조였습니다.

그렇다면 어떻게 최적화할 수 있을까요? 기존에는 모든 코드를 하나의 큰 번들 파일로 묶어서, 실제로 사용하지 않는 코드까지 한 번에 다운로드했습니다. 하지만 실제로는 즉시 필요하지 않은 파일들은 처음부터 모두 받을 필요가 없습니다. 여러 파일로 쪼개어, 각 파일이 정말 필요한 순간에만 개별적으로 받아올 수 있게 구조를 변경해야 합니다.

Dynamic Import란?

React에서는 React.lazy()Suspense를 사용해 컴포넌트를 동적으로 임포트할 수 있습니다. Vite(Rollup)는 lazy(() => import(...)) 구문을 만나면 해당 모듈을 별도의 청크 파일로 분리하고, 실제로 해당 컴포넌트가 렌더링될 때 비로소 네트워크 요청을 보냅니다.

Suspense는 청크가 로드되는 동안 보여줄 fallback UI를 지정하기 위해 함께 사용합니다.

**청크(Chunk)**란?

청크는 번들러가 코드를 여러 파일로 나누어 빌드할 때 만들어지는 "작게 쪼개진 JS 파일"입니다.
예를 들어 다이나믹 임포트를 사용하면, 해당 컴포넌트나 라이브러리가 별도의 청크 파일로 분리돼 필요할 때만 개별적으로 네트워크를 통해 다운로드됩니다.

차트 컴포넌트 분리

먼저 차트 관련 컴포넌트를 별도 파일로 분리합니다. 이 파일들이 동적 임포트의 단위가 됩니다.

// components/charts/HxBarChart.tsx
import ReactApexChart from 'react-apexcharts';
import type { ApexOptions } from 'apexcharts';

const options: ApexOptions = {
chart: { type: 'line', toolbar: { show: false } },
colors: ['#6366f1'],
// ...
};

export default function HxBarChart({ data }: { data: number[] }) {
return <ReactApexChart options={options} series={[{ name: '이벤트 추이', data }]} type="line" height={300} />;
}

기존 정적 임포트

import DashboardPage from '../pages/DashboardPage';
import HxPage from '../pages/HxPage';
import ExPage from '../pages/ExPage';

export default function Router() {
return (
<div>
<DashboardPage />
<HxPage />
<ExPage />
</div>
);
}

다이나믹 임포트 적용

import { lazy, Suspense } from 'react';

const DashboardPage = lazy(() => import('../pages/DashboardPage'));
const HxPage = lazy(() => import('../pages/HxPage'));
const ExPage = lazy(() => import('../pages/ExPage'));

function PageSkeleton() {
return (
<div
style={{
height: 300,
background: '#f1f5f9',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
로딩 중...
</div>
);
}

export default function Router() {
return (
<div>
<Suspense fallback={<PageSkeleton />}>
<DashboardPage />
</Suspense>

<Suspense fallback={<PageSkeleton />}>
<HxPage />
</Suspense>

<Suspense fallback={<PageSkeleton />}>
<ExPage />
</Suspense>
</div>
);
}

실제 빌드 결과 비교

정적 임포트

dist/assets/
└── index-[hash].js 916 KB (gzip: 260 KB) ← 전체가 하나의 파일

사용자가 어느 페이지에 접속하든 apexcharts를 포함한 전체 코드가 한 번에 다운로드됩니다.

다이나믹 임포트

dist/assets/
├── index-[hash].js 400 KB (gzip: 130 KB) ← 초기 번들
├── react-apexcharts.min-[hash].js 516 KB (gzip: 135 KB) ← 차트 진입 시 로드
├── DashboardPage-[hash].js 4.56 KB

초기 번들이 400KB로 줄었습니다. apexcharts는 대시보드 페이지에 처음 진입하는 시점에 별도로 로드됩니다. 로그인, 설정 등 차트가 없는 페이지에서는 다운로드 자체가 일어나지 않습니다.

성능 지표 비교

지표BeforeAfter
초기 번들 크기916 KB (gzip: 260 KB)400 KB (gzip: 130 KB)
초기 번들 감소율56% 감소
차트 페이지 진입 시이미 포함+516 KB 추가 로드
비 차트 페이지apexcharts 포함apexcharts 없음

실제로 전송되는 전체 데이터 양은 동일하지만, 초기 진입 속도가 크게 달라집니다.

주의할 점

너무 잘게 쪼개지 않기

모든 컴포넌트에 lazy 로딩을 적용하면 네트워크 요청이 불필요하게 많이 발생할 수 있습니다. 이는 오히려 리소스 낭비로 이어질 수 있으니, 꼭 필요한 컴포넌트에만 동적으로 임포트하는 것이 좋습니다.

첫 방문 시 로딩 시간 증가

초기 번들이 작아지는 대신, 해당 페이지에 처음 진입할 때는 청크를 다운로드하는 시간이 추가됩니다. 사용자가 항상 방문하는 핵심 페이지라면 lazy 적용 여부를 신중하게 판단해야 합니다.

언제 Dynamic Import를 쓸까?
  • 번들 크기가 큰 외부 라이브러리를 사용하는 경우 (차트, 에디터, PDF 등)
  • 특정 조건에서만 보이는 모달, 드로어 같은 UI
  • 어드민, 설정, 마이페이지처럼 접속 빈도가 낮은 페이지
  • 초기 화면과 무관한 기능 영역

반면 로그인 후 항상 보이는 네비게이션, 핵심 레이아웃 컴포넌트는 굳이 lazy로 분리하지 않아도 됩니다.

마치며

단순히 감에 의존해 문제를 추측하기보단, 실제로 번들 크기를 분석하면서 명확한 원인을 찾아낼 수 있었습니다. 프론트엔드 개발자라면 서비스 제공을 넘어 최적의 사용자 경험을 끊임없이 고민해야 합니다.

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에 위임하는 방식을 적극적으로 고려해보면 좋겠습니다.