2. Dedicated Worker를 Next.js에서 안전하게 쓰기
작성일:2025.05.26|조회수:1
Worker를 만들 수 있다는 사실보다 더 중요한 것은, Next.js가 서버와 브라우저를 오가는 동안 그 Worker 생성 코드가 언제 평가되는지 통제하는 일이다. Dedicated Worker는 하나의 호출자와 연결되는 구조라서 이미지 처리나 대용량 데이터 변환처럼 특정 화면의 작업을 분리하기에 잘 맞는다. 하지만 생성 위치, 메시지 리스너, object URL, 종료 흐름을 같이 설계하지 않으면 메인 스레드 밖으로 계산을 옮기고도 다른 종류의 누수를 만들 수 있다.
원문의 이미지 처리 예제는 좋은 출발점이다. 사용자가 파일을 올리면 canvas에 이미지를 그리고, ImageData를 Worker로 보낸 뒤, 처리된 결과를 다시 canvas에 반영한다. 이 흐름은 UI 반응성과 CPU 작업을 분리한다는 목표가 분명하다. 여기서는 그 구조를 Next.js에서 운영 가능한 훅 패턴으로 다듬어 본다.
번들러가 Worker 파일을 알아보게 만드는 경로
브라우저의 Worker 생성자는 스크립트 URL을 받는다. 그러나 Next.js 애플리케이션에서 그 URL은 단순한 정적 파일 경로가 아니라 번들러가 추적해야 하는 별도 entrypoint다. 그래서 new Worker('/worker.js')처럼 문자열만 넘기는 방식은 빌드 도구가 파일을 어떻게 묶고 배포할지 판단하기 어려운 구조가 될 수 있다.
Webpack, Vite, Turbopack 계열의 번들러는 일반적으로 new Worker(new URL('./worker.ts', import.meta.url)) 형태를 Worker entrypoint 신호로 해석한다. Next.js/Turbopack 쪽에서도 이 패턴을 기준으로 Worker 호출을 분석하고, 최근 변경들은 module worker와 SharedWorker까지 같은 계열의 진입점으로 다루는 방향으로 발전하고 있다. 따라서 Next.js에서 Worker 파일을 만들 때는 동적 문자열을 조합하기보다 정적인 new URL 형태를 유지하는 편이 안전하다.
아래처럼 Worker 생성 함수를 컴포넌트 밖에 두면 어떤 Worker를 만들지 결정하는 책임과 React 생명주기를 연결하는 책임을 분리할 수 있다. 함수 자체는 Worker 인스턴스를 만들지만, 실제 호출 시점은 훅 내부 effect에서 통제한다.
export function createGrayScaleWorker() {
return new Worker(
new URL('./workers/gray-scale.worker.ts', import.meta.url),
{ type: 'module' },
);
}
export function createNegativeWorker() {
return new Worker(
new URL('./workers/negative.worker.ts', import.meta.url),
{ type: 'module' },
);
}이 코드에서 주목할 지점은 Worker 종류를 분기하는 로직이 Worker 파일 경로를 동적으로 조합하지 않는다는 점이다. 각각의 factory가 고정된 entrypoint를 가지므로 번들러가 파일을 추적할 수 있다. 화면에서는 어떤 factory를 넘길지만 결정하면 되고, Worker 파일을 어떻게 번들링할지는 빌드 도구가 처리할 수 있는 형태로 남는다.
생성은 effect에서, 참조는 ref에서 관리한다
React 컴포넌트 렌더링 중에 new Worker()를 호출하면 렌더링과 외부 리소스 생성이 섞인다. React는 렌더링을 취소하거나 다시 시도할 수 있고, 개발 모드에서는 effect가 검증 목적으로 한 번 더 실행될 수도 있다. Worker는 DOM 노드처럼 React가 자동으로 정리해주는 대상이 아니므로, 생성과 종료의 짝을 명시적으로 맞춰야 한다.
useRef는 Worker 인스턴스를 보관하기에 적합하다. Worker 인스턴스는 렌더링 결과를 직접 바꾸는 값이 아니기 때문에 state로 둘 필요가 없다. 반면 loading 상태나 error 메시지처럼 화면에 보여야 하는 값은 state로 둔다. 이 구분이 있어야 Worker 제어 코드와 UI 상태가 서로를 과하게 끌어당기지 않는다.
아래 훅은 Worker factory를 받아 컴포넌트 생명주기 안에서 생성하고 종료한다. 메시지 리스너는 작업 단위로 붙였다가 제거하고, 언마운트 이후 상태 업데이트가 일어나지 않도록 disposed 플래그를 함께 둔다.
'use client';
import { useEffect, useRef, useState } from 'react';
type WorkerFactory = () => Worker;
interface UseImageProcessorOptions {
workerFactory: WorkerFactory;
}
export function useImageProcessor({ workerFactory }: UseImageProcessorOptions) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const workerRef = useRef<Worker | null>(null);
const disposedRef = useRef(false);
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
disposedRef.current = false;
const worker = workerFactory();
workerRef.current = worker;
const handleError = (event: ErrorEvent) => {
if (!disposedRef.current) {
setErrorMessage(event.message);
setLoading(false);
}
};
worker.addEventListener('error', handleError);
return () => {
disposedRef.current = true;
worker.removeEventListener('error', handleError);
worker.terminate();
workerRef.current = null;
};
}, [workerFactory]);
return { canvasRef, loading, errorMessage, workerRef };
}이 훅은 아직 파일 업로드까지 처리하지 않지만 중요한 뼈대를 갖고 있다. Worker 생성은 effect 안에서 일어나고, 종료는 cleanup에서 일어난다. 오류 이벤트도 등록과 해제가 같은 effect 안에 있으므로 이벤트 리스너가 컴포넌트보다 오래 남을 가능성을 줄인다.
이미지 파일을 처리할 때 정리해야 하는 것들
이미지 처리 흐름에서는 Worker만 정리 대상이 아니다. URL.createObjectURL(file)로 만든 object URL은 브라우저가 파일 데이터를 참조할 수 있게 만든 리소스이므로, 이미지 로딩이 끝났거나 실패했을 때 URL.revokeObjectURL()로 해제해야 한다. 또한 Worker에 메시지를 보낸 뒤 결과를 받을 리스너를 매번 새로 붙인다면, 결과를 받은 뒤 해당 리스너를 제거해야 한다.
아래 코드는 파일을 canvas에 그린 뒤 ImageData를 Worker로 보내는 작업 함수다. 코드가 길어 보이는 이유는 계산 자체보다 브라우저 리소스의 생명주기를 맞추는 일이 많기 때문이다. 이런 정리 코드는 부가 작업이 아니라, Worker 패턴을 실제 화면에 넣기 위한 핵심 로직이다.
async function drawAndProcessImage(
file: File,
canvas: HTMLCanvasElement,
worker: Worker,
onDone: () => void,
onError: (message: string) => void,
) {
const objectUrl = URL.createObjectURL(file);
const image = new Image();
image.onload = () => {
URL.revokeObjectURL(objectUrl);
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext('2d');
if (!context) {
onError('2D canvas context를 만들 수 없습니다.');
return;
}
context.drawImage(image, 0, 0);
const imageData = context.getImageData(0, 0, image.width, image.height);
const handleMessage = (event: MessageEvent<ImageData>) => {
context.putImageData(event.data, 0, 0);
worker.removeEventListener('message', handleMessage);
onDone();
};
worker.addEventListener('message', handleMessage);
worker.postMessage({ type: 'process-image', imageData });
};
image.onerror = () => {
URL.revokeObjectURL(objectUrl);
onError('이미지를 불러오지 못했습니다.');
};
image.src = objectUrl;
}이 코드에서 ImageData는 structured clone 대상으로 전달된다. 이미지가 커질수록 메시지 복사 비용이 생기므로, 더 큰 처리량이 필요해지면 OffscreenCanvas나 transferable object를 함께 검토해야 한다. 첫 구현부터 모든 최적화를 넣을 필요는 없지만, 어떤 데이터가 복사되고 어떤 데이터가 이동 가능한지는 성능을 해석할 때 반드시 남겨둬야 하는 질문이다.
Worker 파일은 순수한 작업 단위로 유지한다
Worker 내부 코드는 DOM과 React에 접근할 수 없다고 가정하고 작성해야 한다. 이 제약은 불편함이 아니라 작업 경계를 명확하게 만드는 장치다. Worker 파일은 입력 메시지를 받고, CPU 작업을 수행하고, 결과 메시지를 돌려주는 순수한 처리 단위에 가깝게 유지할수록 테스트와 교체가 수월하다.
예를 들어 그레이스케일 처리는 ImageData.data 배열을 순회하며 각 픽셀의 RGB 값을 조정한다. Worker 내부에는 canvas ref도, React state도, 화면에 표시할 loading 문구도 필요 없다. 화면과 작업을 분리했기 때문에 같은 Worker를 여러 컴포넌트에서 재사용하거나, factory만 바꿔 다른 필터를 적용하는 구조가 가능해진다.
아래 Worker 코드는 메시지 타입을 확인한 뒤 이미지 데이터를 처리한다. 작업이 끝나면 같은 ImageData 객체를 다시 메인 스레드로 보낸다.
interface ProcessImageMessage {
type: 'process-image';
imageData: ImageData;
}
self.addEventListener('message', (event: MessageEvent<ProcessImageMessage>) => {
if (event.data.type !== 'process-image') {
return;
}
const { imageData } = event.data;
const pixels = imageData.data;
for (let index = 0; index < pixels.length; index += 4) {
const red = pixels[index] ?? 0;
const green = pixels[index + 1] ?? 0;
const blue = pixels[index + 2] ?? 0;
const gray = Math.round(red * 0.299 + green * 0.587 + blue * 0.114);
pixels[index] = gray;
pixels[index + 1] = gray;
pixels[index + 2] = gray;
}
self.postMessage(imageData);
});이 Worker는 화면에 대해 아무것도 모른다. 그래서 호출자는 파일 입력이든 드래그 앤 드롭이든 같은 메시지 형식만 맞추면 된다. Dedicated Worker 패턴의 핵심은 바로 이 단방향성이다. 화면은 작업을 요청하고, Worker는 계산 결과만 돌려준다.
성능은 체감이 아니라 관찰로 확인한다
Worker를 적용했다면 Chrome Performance 패널에서 main thread의 긴 task가 줄었는지 확인해야 한다. Worker를 사용했는데도 메시지 복사와 canvas 반영이 메인 스레드에서 길게 남아 있다면 병목은 다른 곳으로 이동했을 뿐이다. 특히 이미지 처리에서는 파일 로딩, canvas draw, getImageData, putImageData가 모두 비용을 만들 수 있다.
운영 관점에서는 오류 처리도 성능만큼 중요하다. Worker 파일 로딩 실패, 메시지 직렬화 실패, 사용자가 처리 중 파일을 바꾸는 상황, 컴포넌트 언마운트 중 결과가 돌아오는 상황을 각각 확인해야 한다. terminate()는 Worker를 끝내지만 이미 등록된 UI 상태 업데이트까지 자동으로 막아주지는 않는다.
Dedicated Worker가 안정적으로 정리되면 하나의 화면 안에서 무거운 계산을 분리하는 문제는 해결된다. 그러나 실시간 기능처럼 여러 탭이 동시에 같은 자원을 쓰는 문제는 Dedicated Worker만으로는 부족하다. 그때는 Worker를 계산 도구가 아니라 브라우저 컨텍스트 사이의 공유 실행 단위로 바라봐야 한다.
전체 코드 흐름
앞에서는 각 조각을 따로 보았지만, 실제로 구현할 때는 파일 경계가 더 중요하다. Worker를 생성하는 factory, 화면에서 사용하는 hook, 파일 입력을 받는 컴포넌트, 실제 픽셀을 처리하는 worker 파일이 서로 다른 책임을 갖는다. 한 파일에 모두 몰아넣으면 처음에는 편해 보이지만, 필터가 하나 더 늘어나는 순간 렌더링 코드와 작업 코드가 다시 엉키기 시작한다.
아래 코드는 이 글에서 설명한 구조를 하나의 흐름으로 모은 예시다. 경로는 프로젝트 구조에 맞게 바꿔야 하지만, 중요한 점은 Worker entrypoint가 정적인 new URL(..., import.meta.url)로 유지되고, Worker 인스턴스의 생성과 종료가 hook 안에서 제어된다는 것이다.
// workers/factories.ts
export type ImageWorkerFactory = () => Worker;
export function createGrayScaleWorker() {
return new Worker(
new URL('./gray-scale.worker.ts', import.meta.url),
{ type: 'module' },
);
}
export function createNegativeWorker() {
return new Worker(
new URL('./negative.worker.ts', import.meta.url),
{ type: 'module' },
);
}
// useImageProcessor.ts
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { ImageWorkerFactory } from './workers/factories';
interface UseImageProcessorOptions {
workerFactory: ImageWorkerFactory;
}
interface ProcessImageMessage {
type: 'process-image';
imageData: ImageData;
}
export function useImageProcessor({ workerFactory }: UseImageProcessorOptions) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const workerRef = useRef<Worker | null>(null);
const disposedRef = useRef(false);
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
disposedRef.current = false;
const worker = workerFactory();
workerRef.current = worker;
const handleWorkerError = (event: ErrorEvent) => {
if (disposedRef.current) {
return;
}
setErrorMessage(event.message);
setLoading(false);
};
const handleMessageError = () => {
if (disposedRef.current) {
return;
}
setErrorMessage('Worker 메시지를 해석하지 못했습니다.');
setLoading(false);
};
worker.addEventListener('error', handleWorkerError);
worker.addEventListener('messageerror', handleMessageError);
return () => {
disposedRef.current = true;
worker.removeEventListener('error', handleWorkerError);
worker.removeEventListener('messageerror', handleMessageError);
worker.terminate();
workerRef.current = null;
};
}, [workerFactory]);
const handleFile = useCallback((file: File) => {
const canvas = canvasRef.current;
const worker = workerRef.current;
if (!canvas || !worker) {
return;
}
setLoading(true);
setErrorMessage(null);
const objectUrl = URL.createObjectURL(file);
const image = new Image();
image.onload = () => {
URL.revokeObjectURL(objectUrl);
const context = canvas.getContext('2d');
if (!context) {
setErrorMessage('2D canvas context를 만들 수 없습니다.');
setLoading(false);
return;
}
canvas.width = image.width;
canvas.height = image.height;
context.drawImage(image, 0, 0);
const imageData = context.getImageData(0, 0, image.width, image.height);
const handleMessage = (event: MessageEvent<ImageData>) => {
worker.removeEventListener('message', handleMessage);
if (disposedRef.current) {
return;
}
context.putImageData(event.data, 0, 0);
setLoading(false);
};
worker.addEventListener('message', handleMessage);
const message: ProcessImageMessage = {
type: 'process-image',
imageData,
};
worker.postMessage(message);
};
image.onerror = () => {
URL.revokeObjectURL(objectUrl);
setErrorMessage('이미지를 불러오지 못했습니다.');
setLoading(false);
};
image.src = objectUrl;
}, []);
return { canvasRef, handleFile, loading, errorMessage };
}
// ImageWorkerDemo.tsx
'use client';
import { ChangeEvent, useMemo, useState } from 'react';
import { useImageProcessor } from './useImageProcessor';
import { createGrayScaleWorker, createNegativeWorker } from './workers/factories';
type WorkerType = 'grayScale' | 'negative';
export function ImageWorkerDemo() {
const [workerType, setWorkerType] = useState<WorkerType>('grayScale');
const workerFactory = useMemo(
() => (workerType === 'grayScale' ? createGrayScaleWorker : createNegativeWorker),
[workerType],
);
const { canvasRef, handleFile, loading, errorMessage } = useImageProcessor({
workerFactory,
});
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
handleFile(file);
}
};
return (
<section>
<select
value={workerType}
onChange={(event) => setWorkerType(event.target.value as WorkerType)}
>
<option value="grayScale">Gray scale</option>
<option value="negative">Negative</option>
</select>
<input type="file" accept="image/*" onChange={handleChange} />
{loading && <p>이미지 처리 중...</p>}
{errorMessage && <p role="alert">{errorMessage}</p>}
<canvas ref={canvasRef} />
</section>
);
}위 코드는 화면과 Worker 사이의 연결부를 보여준다. 하지만 실제 계산은 여전히 Worker 파일에 있어야 한다. 이 분리가 지켜져야 화면은 파일 입력과 상태 표시만 담당하고, 픽셀 처리 방식은 Worker 파일을 교체하는 것으로 바꿀 수 있다.
// workers/gray-scale.worker.ts
interface ProcessImageMessage {
type: 'process-image';
imageData: ImageData;
}
self.addEventListener('message', (event: MessageEvent<ProcessImageMessage>) => {
if (event.data.type !== 'process-image') {
return;
}
const { imageData } = event.data;
const pixels = imageData.data;
for (let index = 0; index < pixels.length; index += 4) {
const red = pixels[index] ?? 0;
const green = pixels[index + 1] ?? 0;
const blue = pixels[index + 2] ?? 0;
const gray = Math.round(red * 0.299 + green * 0.587 + blue * 0.114);
pixels[index] = gray;
pixels[index + 1] = gray;
pixels[index + 2] = gray;
}
self.postMessage(imageData);
});
// workers/negative.worker.ts
interface NegativeProcessImageMessage {
type: 'process-image';
imageData: ImageData;
}
self.addEventListener('message', (event: MessageEvent<NegativeProcessImageMessage>) => {
if (event.data.type !== 'process-image') {
return;
}
const { imageData } = event.data;
const pixels = imageData.data;
for (let index = 0; index < pixels.length; index += 4) {
pixels[index] = 255 - (pixels[index] ?? 0);
pixels[index + 1] = 255 - (pixels[index + 1] ?? 0);
pixels[index + 2] = 255 - (pixels[index + 2] ?? 0);
}
self.postMessage(imageData);
});이 전체 흐름에서 가장 중요한 것은 코드 양이 아니라 방향이다. 컴포넌트는 Worker를 직접 알지 않고 factory만 받으며, hook은 Worker 생명주기와 메시지를 관리하고, Worker 파일은 순수한 계산만 처리한다. 이 정도 경계가 생기면 필터가 늘어나거나 오류 처리가 추가되어도 구조 전체를 다시 갈아엎지 않아도 된다.
댓글
댓글을 불러오는 중...