본문으로 건너뛰기

호이스팅이 코드를 위로 올리는게 아니라고? (ft. V8 Engine)

· 약 6분
김현재
개발자

들어가며

자바스크립트로 코드를 작성하다 보면 가끔 의아한 현상을 목격하게 됩니다. 변수를 선언하기 전에 호출했는데 에러가 나지 않는다거나, 의도하지 않은 값(undefined)이 나오는 경우가 있습니다. 마치 선언부가 코드 최상단으로 끌어올려진(hoisted) 것처럼요.

console.log(myVar); // undefined
var myVar = 'Hello, World!';

이런 현상을 바로 호이스팅(Hoisting) 이라고 부릅니다. 면접 질문으로도 자주 출제되어 "선언문을 맨 위로 올리는 현상"이라고 외우곤 하지만, 이 글에서는 좀 더 깊게 호이스팅에 대해 파헤쳐보려고 합니다.

모든 선언이 똑같이 끌어올려질까? - var vs let, const

호이스팅을 이해하기 전, 자바스크립트가 변수를 처리하는 두 단계인 선언초기화를 구분해야 합니다.

  • 선언(Declaration): "나 myVar라는 변수 쓸 거야"라고 자바스크립트 엔진에 알리는 단계
  • 초기화(Initialization): 변수에 메모리를 할당하고, 처음에는 undefined 값을 넣어두는 단계

자바스크립트에서 변수를 선언하기 위해 사용하는 var, let, const는 이 두 단계의 진행 방식에서 차이를 가지고 있습니다.

var의 호이스팅

var로 선언된 변수는 스코프 최상단에서 선언과 초기화가 동시에 이루어집니다. 그래서 변수를 선언하기 전에 호출해도 에러가 나지 않고 undefined를 반환합니다. 아래 코드는 var로 선언된 변수가 V8 엔진을 통해 해석된 예시 코드입니다.

// V8 엔진이 코드를 이렇게 해석합니다
var myVar; // 선언 + 초기화 (undefined)
console.log(myVar); // -> undefined
myVar = 'Hello, World!'; // 할당

let, const의 호이스팅

letconst는 선언 단계만 이루어집니다. 초기화는 실제 코드 라인에 도달해야만 이루어집니다. 그래서 실제 코드에서 초기화 전에 호출하게 되면 자바스크립트 엔진은 ReferenceError를 발생시킵니다.

console.log(myValue); // ReferenceError: Cannot access 'myValue' before initialization

let myValue = 'Hello, World!';

위 코드에서 발생한 ReferenceError는 참조 에러라고도 말하며, 초기화 전에는 해당 변수에 접근할 수 없다는 것을 의미합니다.

TDZ

letconst의 호이스팅에서는 중요한 개념이 하나 존재합니다. 앞서 정리한 내용을 보면 letconst로 선언된 변수는 실제 코드 라인에 도달해서 초기화가 되어야 접근할 수 있었습니다. 이 때 선언은 되었지만 초기화가 되지 않은 영역TDZ(Temporal Dead Zone, 일시적 사각지대)라고 합니다.

함수 호이스팅 - 선언문 vs 표현식

자바스크립트에서는 함수도 호이스팅이 일어납니다. 단, 함수를 선언한 방식에 따라 호이스팅의 동작이 달라집니다.

함수 선언문

함수 선언문은 선언과 동시에 사용 가능한 상태가 됩니다. 따라서 선언문 이전에 함수를 호출해도 정상적으로 작동합니다.

sayHello(); // "Hello!"
function sayHello() {
console.log('Hello!');
}

함수 표현식

함수 표현식은 변수 호이스팅 규칙을 따릅니다. var로 선언하면 undefined가 반환되며, let또는 const로 선언하면 TDZ영역이 생성되며 ReferenceError가 발생합니다.

// var의 경우
sayHi(); // TypeError: sayHi is not a function
var sayHi = function () {
console.log('Hi!');
};

호이스팅은 왜 발생하는걸까?

지금까지 호이스팅의 정의와 변수, 함수별 동작 방식을 살펴보았습니다. 그렇다면 호이스팅은 왜 발생하는 걸까요?

이 질문의 해답을 찾기 위해서는 실행 컨텍스트라는 개념을 이해해야합니다. 이번 글에서는 호이스팅을 이해하는 데 꼭 필요한 핵심 원리 위주로 실행 컨텍스트를 가볍게 짚어보려 합니다.

실행 컨텍스트

실행 컨텍스트는 자바스크립트 코드를 실행하기 위해 필요한 정보를 모아놓은 객체입니다. 실행 컨텍스트에는 변수 및 함수 선언 정보, 스코프, this 바인딩 정보 같은 것들이 저장됩니다.

실행 컨텍스트는 크게 전역 실행 컨텍스트와 함수 실행 컨텍스트로 나눌 수 있습니다. 함수가 호출되면 새로운 실행 컨텍스트가 만들어지고 콜 스택에 쌓이게 되며, 함수 실행이 종료되면 해당 실행 컨텍스트는 콜 스택에서 제거됩니다.

함수 호출

함수 호출문을 만나면 자바스크립트 엔진은 하던 일을 멈추고 해당 함수의 실행 컨텍스트를 생성합니다. 실행 컨텍스트가 생성될 때 함수 내부의 변수와 함수 정보를 환경 레코드라고 하는 실행 컨텍스트 내부 공간에 미리 기록합니다. 이 기록하는 행위 때문에 호이스팅이 발생합니다.

V8 엔진과 호이스팅

자바스크립트 코드는 자바스크립트 엔진이라고 불리는 해석기를 통해 실행됩니다. 이 글에서는 가장 대표적인 구글의 V8 엔진을 기준으로 호이스팅이 내부적으로 어떻게 처리되는지 설명할 예정입니다.

자바스크립트 코드는 자바스크립트 해석기를 통해 코드가 실행됩니다. 대표적으로 V8이 있으며, 이 글에서는 V8 기준으로 설명할 예정입니다.

변수 선언

변수 선언의 시작은 Parser::DeclareVariable 함수가 담당합니다.

// /src/parsing/parser.cc

Variable* Parser::DeclareVariable(const AstRawString* name, VariableKind kind,
VariableMode mode, InitializationFlag init,
Scope* scope, bool* was_added, int begin,
int end) {
Declaration* declaration;
if (mode == VariableMode::kVar && !scope->is_declaration_scope()) {
DCHECK(scope->is_block_scope() || scope->is_with_scope());
declaration = factory()->NewNestedVariableDeclaration(scope, begin);
} else {
declaration = factory()->NewVariableDeclaration(begin);
}
Declare(declaration, name, kind, mode, init, scope, was_added, begin, end);
return declaration->var();
}

DeclareVariable 함수는 VariableMode 타입의 mode라는 매개변수를 받고 있습니다. mode의 값으로는 kVar, kLet, kConst를 사용할 수 있습니다. 즉, mode에 따라 변수를 선언하는 키워드를 분기하는 로직이 있을 것이라고 예상할 수 있습니다.

var, let, const의 접두사로 k를 붙인 이유는 c++ 코드에서 enum 또는 상수값의 식별자로 k를 붙이는 것이 관례라고 합니다. 이 내용은 Gemini가 알려준 내용이니 궁금한 분들은 교차 검증을 추천드립니다.

다음 흐름으로 넘어가보겠습니다. 조건문에서는 변수를 선언할 때 kVar 모드이며 선언 스코프가 아닌지 확인합니다. 선언 스코프함수 스코프를 의미합니다. 이후 mode값에 따라 NewNestedVariableDeclaration 함수를 사용할 것인지, NewVariableDeclaration 함수를 사용할 것인지 결정합니다.

// /src/ast/ast.h

NestedVariableDeclaration* NewNestedVariableDeclaration(Scope* scope,
int pos) {
return zone_->New<NestedVariableDeclaration>(scope, pos);
}

VariableDeclaration* NewVariableDeclaration(int pos) {
return zone_->New<VariableDeclaration>(pos);
}

위 헤더 파일에 정의되어 있는 두 함수의 차이점으로는 매개변수 scope의 유무가 있습니다. kVar 모드의 변수는 상위 스코프와 연결되어야 하기 때문에 scope 값을 사용합니다. 반면 kLetkConst 모드의 변수는 AST스코프 체인을 통해 관리되므로 따로 명시하지 않아도 선언된 블록의 Scope 객체에 자동으로 추가됩니다.

특징varlet / const
스코프함수 스코프 또는 전역 스코프블록 스코프
스코프 정보 필요 여부필요 (상위 스코프 연결)불필요 (선언된 블록에서 관리)
호출 함수NewNestedVariableDeclarationNewVariableDeclaration

메모리 할당 및 초기화

varlet, const는 선언과 동시에 초기화 여부가 서로 달랐습니다.

// /src/ast/variables.h

static InitializationFlag DefaultInitializationFlag(VariableMode mode) {
DCHECK(IsDeclaredVariableMode(mode));
return mode == VariableMode::kVar ? kCreatedInitialized
: kNeedsInitialization;
}

InitializationFlag 라는 명칭을 보면 초기화 여부를 결정하는 함수임을 알 수 있습니다. 이 함수는 kVar 모드라면 kCreatedInitialized 를 반환하고, 그 외의 값이라면 kNeedsInitialization을 반환합니다.

letconst를 사용해서 변수를 선언하면 초기화 이전, 즉 TDZ 영역이 존재했고 이 영역에서 변수에 접근하면 ReferenceError가 발생합니다. kNeedsInitialization 값을 받으면 초기화 전에 접근을 하지 못하도록 하는 키워드임을 알 수 있습니다.

다음으로 kVar 모드라면 선언과 동시에 undefined로 초기화를 해주어야 하므로 메모리를 할당해주어야 합니다. 그래서 kVar의 경우 메모리를 할당하는 코드를 찾아보았지만, 클론 받은 패키지에서는 찾지 못했습니다.

그래서 구글링을 통해 코드 소스를 확인했고, 궁금하신 분은 아래 링크의 532번째 줄을 참고해주세요.

kVar 모드 메모리 할당

auto var = scope->DeclareParameter(name, VariableMode::kVar, is_optional,
is_rest, &is_duplicate,
ast_value_factory(), beg_pos);
DCHECK(!is_duplicate);
var->AllocateTo(VariableLocation::PARAMETER, 0);

kVar 모드라면 변수 객체를 생성한 후 AllocateTo 함수를 통해 메모리를 할당합니다. 하지만 kLetkConst 모드는 다르게 작동합니다.

// /src/parsing/parser.cc

VariableProxy* tdz_proxy = DeclareBoundVariable(
bound_name, VariableMode::kLet, kNoSourcePosition);
tdz_proxy->var()->set_initializer_position(position());

VariableProxy* proxy =
DeclareBoundVariable(local_name, VariableMode::kConst, pos);
proxy->var()->set_initializer_position(position());

kLetkConst 모드로 생성한 변수 객체는 메모리를 할당하는 AllocateTo 함수 대신 set_initializer_position 함수의 매개변수로 position 값을 넘겨주고 있습니다.

kVar 모드와 다르게 kLetkConst 모드는 메모리를 할당 받지 못했고 이 시점에 변수에 접근하려 한다면 ReferenceError가 발생합니다.

마무리

이번 글에서는 단순히 면접용 답변으로 외웠던 호이스팅을 넘어, 실행 컨텍스트와 V8 엔진의 내부 동작까지 살펴보았습니다.

다이나믹 임포트로 번들 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를 생성하는 방법은 여러 가지가 있는데, 그 중에서 pdfmake를 선택한 이유는 두 가지였습니다.

첫째, JavaScript 객체(문서 정의)로 PDF 레이아웃을 작성할 수 있습니다. JSON 형태의 docDefinition으로 구조를 명확하게 선언할 수 있어, 복잡한 표나 이미지가 포함된 리포트도 체계적으로 구성할 수 있었습니다.

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

이렇게 pdfmake로 정해진 포맷에 맞춰 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.ts
import pdfMake from 'pdfmake/build/pdfmake';
import pdfFonts from 'pdfmake/build/vfs_fonts';

pdfMake.vfs = pdfFonts.pdfMake.vfs;

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

try {
const { data, chartBase64 } = e.data;

// 메인 스레드에서 전달받은 데이터로 문서 정의 생성
const docDefinition = buildDocDefinition(data, chartBase64);

// pdfmake로 PDF blob 생성
const blob = await new Promise<Blob>((resolve, reject) => {
pdfMake.createPdf(docDefinition).getBlob((result) => resolve(result));
});

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

Worker는 PDF 생성에만 집중합니다. API 호출과 차트 이미지 생성은 메인 스레드에서 처리하고, 결과 데이터만 Worker로 전달받아 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 = async () => {
setState('generating');

// 1. 메인 스레드에서 API 호출
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. 차트 이미지 생성 (Canvas API → base64)
const chartBase64 = await generateChartImage(data);

// 3. 리포트 데이터와 차트 이미지를 Worker로 전송
workerRef.current?.postMessage({ type: 'generate', data, chartBase64 });
};

generateChartImage는 Canvas API를 이용해 차트를 그린 뒤 canvas.toDataURL()로 base64 문자열을 반환하는 함수입니다. Worker는 DOM에 접근할 수 없기 때문에 Canvas 기반 렌더링은 반드시 메인 스레드에서 처리해야 합니다.

기존 블로킹 방식과 비교

블로킹 방식 (기존)

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

const docDefinition = buildDocDefinition();
const blob = await new Promise<Blob>((resolve) => {
pdfMake.createPdf(docDefinition).getBlob(resolve);
});

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

Worker 방식 (개선)

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

// API 호출, 차트 생성은 메인 스레드에서
const data = await fetchReportData();
const chartBase64 = await generateChartImage(data);

// 무거운 PDF 생성만 Worker에 위임
workerRef.current?.postMessage({ type: 'generate', data, chartBase64 });
};

코드는 비슷해 보여도 실제 동작 방식은 완전히 다릅니다. 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 사용

마치며

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