1. 왜 Next.js에서 Worker가 필요한가
작성일:2025.05.26|조회수:2
이미지 하나를 처리했을 뿐인데 버튼 hover가 끊기고 입력창이 늦게 반응한다면, 문제는 React 렌더링보다 자바스크립트가 어디에서 실행되고 있는지에 있을 수 있다. 브라우저 화면은 HTML과 CSS만으로 움직이는 것처럼 보이지만, 사용자의 입력을 받고 레이아웃을 계산하고 JavaScript를 실행하는 일은 상당 부분 메인 스레드 위에서 경쟁한다. 이 경쟁이 길어지는 순간 사용자는 기능이 느린 것이 아니라 화면 전체가 멈춘 것처럼 느낀다.
Next.js 애플리케이션도 이 제약에서 벗어나지 않는다. 서버 컴포넌트가 렌더링 비용을 줄여주고 라우팅이 세분화되어도, 브라우저에서 실행되는 클라이언트 코드는 여전히 같은 메인 스레드 위에서 동작한다. 대용량 JSON을 파싱하거나, 이미지 픽셀을 순회하거나, 긴 정렬과 필터링을 수행하는 코드가 이벤트 핸들러 안에 들어오면 React 자체가 문제가 없어도 화면 반응성은 무너진다.
async 함수가 해결하지 못하는 종류의 느림
async/await는 오래 걸리는 작업을 표현하는 문법이지만, 그 작업을 자동으로 다른 스레드에 배치하지 않는다. 네트워크 요청처럼 브라우저가 Web API에 맡길 수 있는 작업은 대기 중에 메인 스레드를 비워둘 수 있지만, CPU가 직접 계산해야 하는 반복문은 결국 JavaScript 실행 시간으로 남는다. 그래서 await가 붙은 함수라도 내부에서 큰 배열을 동기적으로 순회하면 사용자는 여전히 멈춤을 경험한다.
이 차이를 I/O 중심 작업과 CPU 중심 작업으로 나누면 Worker가 필요한 순간이 선명해진다. 서버 응답을 기다리는 시간은 이벤트 루프가 잘 다룰 수 있지만, 이미 받은 데이터를 계산하는 시간은 메인 스레드를 점유한다. Web Worker는 바로 이 지점에서 의미가 생긴다. 코드를 비동기로 보이게 만드는 것이 아니라, 실행되는 장소를 메인 스레드 밖으로 옮긴다.
아래 코드는 겉으로는 async 함수지만 실제 계산은 메인 스레드에서 수행되는 예다. await는 함수의 결과를 Promise로 다루게 만들지만, expensiveTransform 안의 반복 계산이 브라우저 UI와 분리되는 것은 아니다.
async function handleLargePayload(payload: number[]) {
const result = expensiveTransform(payload);
await saveResult(result);
}
function expensiveTransform(payload: number[]) {
return payload.map((value) => {
let next = value;
for (let index = 0; index < 50_000; index += 1) {
next = (next * 17 + index) % 9973;
}
return next;
});
}이 코드에서 saveResult를 기다리는 시간은 브라우저가 다른 일을 처리할 여지가 있지만, expensiveTransform이 실행되는 동안에는 입력 처리와 렌더링이 밀릴 수 있다. Worker를 검토해야 하는 기준은 함수가 Promise를 반환하는지가 아니라, 긴 계산이 어느 스레드에서 실행되는지다. 이 기준을 잡아두면 Worker를 남용하지 않으면서도 필요한 곳에서는 분명하게 선택할 수 있다.
Worker는 값을 공유하지 않고 메시지를 주고받는다
Web Worker는 메인 스레드와 분리된 실행 컨텍스트에서 JavaScript를 실행한다. 분리되어 있다는 말은 DOM에 직접 접근할 수 없고, React state를 직접 만질 수도 없다는 뜻이다. 대신 두 컨텍스트는 postMessage와 message 이벤트로 데이터를 주고받는다. 이 메시지 모델은 Worker의 장점이면서 동시에 설계 비용이 되는 부분이다.
브라우저는 메시지를 보낼 때 대부분의 값을 structured clone 알고리즘으로 복제한다. 객체의 참조를 그대로 공유하는 것이 아니라, 받을 쪽에서 사용할 수 있는 새 값으로 직렬화하고 복원한다. 작은 명령 객체나 결과 값에는 이 비용이 크게 드러나지 않지만, 이미지 데이터나 대용량 버퍼처럼 크기가 큰 값은 복사 비용만으로도 성능의 일부를 잃을 수 있다.
그래서 큰 바이너리 데이터를 다룰 때는 transferable object를 함께 봐야 한다. 예를 들어 ArrayBuffer는 복사 대신 소유권을 이전할 수 있고, 이전된 뒤 원래 스레드의 버퍼는 더 이상 사용할 수 없는 상태가 된다. 이 제약은 처음 보면 낯설지만, 큰 데이터를 두 스레드에 동시에 남기지 않기 때문에 메시지 비용을 줄이는 중요한 도구가 된다.
아래 코드는 Worker로 버퍼를 보낼 때 복사 대신 이전을 선택하는 형태다. postMessage의 두 번째 인자로 transferable 목록을 넘기면 브라우저는 해당 리소스의 소유권을 Worker 쪽으로 이동시킨다.
const bytes = new Uint8Array(1024 * 1024 * 8);
worker.postMessage(
{ type: 'process-buffer', buffer: bytes.buffer },
[bytes.buffer],
);
console.log(bytes.byteLength);이후 bytes가 바라보던 버퍼는 detach되므로 메인 스레드에서 같은 메모리를 계속 읽는 방식으로 설계하면 안 된다. Worker를 사용할 때는 계산을 옮기는 것만 생각할 게 아니라, 어떤 값을 복사하고 어떤 값을 이전할지까지 같이 결정해야 한다. 특히 이미지 처리나 파일 분석처럼 데이터 크기가 큰 작업은 이 선택이 체감 성능을 좌우한다.
Next.js에서는 브라우저 경계를 먼저 확인한다
Worker는 브라우저 API다. Next.js에는 서버에서 실행되는 코드와 브라우저에서 실행되는 코드가 함께 존재하므로, Worker 생성 코드가 어디에서 평가되는지 통제해야 한다. Server Component에서 window, Worker, SharedWorker 같은 전역 객체를 직접 참조하면 서버 런타임에는 그런 객체가 없기 때문에 실패한다.
App Router 기준으로 Worker를 생성하는 코드는 Client Component 내부, 보통 useEffect 안에서 실행하는 편이 안전하다. 렌더링 중에 Worker를 만들면 React가 렌더링을 반복하거나 개발 모드에서 effect 검증이 일어날 때 의도와 다른 생성 흐름을 만들 수 있다. 생성과 종료를 effect 생명주기에 맞추면 컴포넌트가 화면에서 사라질 때 terminate()를 호출할 위치도 분명해진다.
아래 코드는 Worker 생성이 브라우저에서만 일어나도록 effect 안에 둔 형태다. Worker 파일 자체를 직접 문자열 경로로 넘기기보다 new URL(..., import.meta.url) 형태로 넘기면 번들러가 이 파일을 별도 entrypoint로 인식할 수 있다.
'use client';
import { useEffect, useRef } from 'react';
export function WorkerBoundary() {
const workerRef = useRef<Worker | null>(null);
useEffect(() => {
const worker = new Worker(
new URL('./sample.worker.ts', import.meta.url),
{ type: 'module' },
);
workerRef.current = worker;
return () => {
worker.terminate();
workerRef.current = null;
};
}, []);
return null;
}여기서 중요한 결정은 Worker를 만드는 코드가 렌더링 결과를 계산하는 과정에서 실행되지 않는다는 점이다. 컴포넌트는 화면을 설명하고, effect는 브라우저에서 필요한 실행 자원을 연결한다. Next.js에서 Worker를 다룰 때는 이 경계를 지키는 것이 첫 번째 안정성 조건이다.
Worker를 선택하기 전에 확인할 질문
Worker는 모든 느림에 대한 답이 아니다. 메시지 직렬화 비용이 있고, DOM에 직접 접근하지 못하며, Worker 파일은 별도 번들로 관리된다. 계산량보다 메시지 왕복 비용이 더 큰 작업이라면 메인 스레드에서 처리하는 쪽이 낫다. 반대로 한 번 넘긴 데이터로 충분히 긴 계산을 수행하고 결과만 돌려받는 작업이라면 Worker의 이점이 커진다.
판단 기준은 작업의 성격에서 출발해야 한다. 사용자가 입력하는 동안 동시에 계산해야 하는가, 데이터 크기가 메시지 비용을 감당할 만큼 충분한가, DOM 접근 없이 순수 계산으로 분리할 수 있는가, 작업 중 취소나 종료가 필요한가를 확인한다. 이 질문을 통과한 작업만 Worker 후보로 올리면 구조가 과해지는 일을 줄일 수 있다.
Next.js에서는 여기에 하나의 질문이 더 붙는다. 이 코드는 서버에서도 평가되는가, 아니면 브라우저에서만 평가되는가. Worker가 필요한 이유를 알았다면 남는 문제는 생성 위치와 생명주기다. 같은 Worker라도 언제 만들고 언제 없애는지에 따라 안전한 패턴이 되기도 하고, 메모리와 이벤트 리스너를 남기는 코드가 되기도 한다.
댓글
댓글을 불러오는 중...