Web Worker와 실행·전달 경계
사진 편집기에서 사진 A에 필터를 적용하다가 사진 B를 눌렀는데, 버튼이 한참 뒤에 반응할 때가 있습니다. 버튼 자체보다 사진 A의 계산이 오래 걸린 게 원인일 수도 있습니다. 그렇다면 계산을 다른 곳에 맡길 수 있을까요?
이런 상황에서 Web Worker를 검토할 수 있습니다. Worker의 역할부터 데이터 전달, React의 서버·클라이언트 경계까지 사진 편집 사례를 따라 정리했습니다.
메인과 Worker 사이의 데이터 전달
- 메인 스레드 — 객체 · ArrayBuffer 준비. 화면을 처리하면서 Worker에 보낼 값을 준비합니다.
- Structured clone — 복제 · 원본 유지. 지원되는 값을 복제해 받는 쪽에 별도 객체를 만듭니다.
- Transfer — 저장소 이전 · 원본 분리. ArrayBuffer를 이전하면 보낸 쪽 byteLength는 0이 됩니다.
- Worker — 별도 스레드에서 계산. 메시지를 받아 계산하며 페이지 DOM은 직접 바꾸지 못합니다.
- 계산 결과 — 메인에 답장 → 화면 갱신. Worker가 보낸 결과를 메인이 받아 화면에 표시합니다.
- JSON — 문자열로 표현. structured clone과 달리 Date·Map 등의 표현 규칙이 다릅니다.
- 메인 스레드 — 객체 · ArrayBuffer 준비. 화면을 처리하면서 Worker에 보낼 값을 준비합니다.
- Structured clone — 복제 · 원본 유지. 지원되는 값을 복제해 받는 쪽에 별도 객체를 만듭니다.
- Transfer — 저장소 이전 · 원본 분리. ArrayBuffer를 이전하면 보낸 쪽 byteLength는 0이 됩니다.
- Worker — 별도 스레드에서 계산. 메시지를 받아 계산하며 페이지 DOM은 직접 바꾸지 못합니다.
- 계산 결과 — 메인에 답장 → 화면 갱신. Worker가 보낸 결과를 메인이 받아 화면에 표시합니다.
- JSON — 문자열로 표현. structured clone과 달리 Date·Map 등의 표현 규칙이 다릅니다.
1. 메인 스레드와 Worker는 무엇인가
스레드(thread)는 프로그램의 명령을 실행하는 흐름입니다. 페이지의 JavaScript와 클릭 이벤트 처리는 주로 메인 스레드(Main Thread)에서 일어납니다. 메인 스레드는 DOM도 변경합니다. DOM은 버튼이나 이미지 같은 HTML 요소를 JavaScript로 다루는 구조입니다.
메인에서 긴 계산을 계속하면 다른 클릭 처리와 화면 갱신이 지연될 수 있습니다. Web Worker는 브라우저 안의 별도 스레드에서 JavaScript를 실행하는 기능입니다. 사진의 픽셀 계산이나 대용량 CSV 분석을 맡기고, 메인은 입력과 화면을 처리할 수 있게 합니다.
Worker는 브라우저 안에서 돌아가지만 페이지의 DOM을 직접 바꿀 수 없습니다. 계산 결과를 메인에 보내야 화면에 표시할 수 있습니다. 별도 스레드를 만들고 데이터를 전달하는 데도 비용이 들기 때문에, 아주 짧은 계산에는 오히려 비효율적일 수 있습니다.
사진 A에 필터를 적용할 때 사진 B를 누르면?
필터를 계산하는 중에 다음 사진을 보고 싶다는 입력이 들어온 상황을 비교합니다.
먼저 계산할 곳을 고른 뒤 1 → 2 → 3 순서로 진행해 보세요.
이 차이가 코드에서는 어떻게 보일까
async function run() {
const result = heavyFilter(photo); // 메인에서 동기 계산
show(result);
}async/await가 있는데 왜 Worker를 쓸까
async/await는 네트워크 요청처럼 결과를 기다리는 비동기 작업을 다룰 때 유용합니다. await fetch('/api/photos')로 응답을 기다리는 동안 메인은 다른 일을 처리할 수 있습니다.
하지만 async 함수 안에서 오래 계산한다고 자동으로 다른 스레드가 생기지는 않습니다.
async function applyFilter(pixels) {
return pixels.map(value => 255 - value); // 호출한 스레드에서 계산
}
| 상황 | 먼저 쓸 방법 |
|---|---|
| 서버에서 사진 목록 받아오기 | fetch + async/await |
| 버튼 글자나 상태만 바꾸기 | 메인 스레드의 일반 JavaScript |
| 큰 사진의 픽셀을 오래 계산하기 | Web Worker 검토 |
Promise.all()은 여러 비동기 결과를 함께 기다리는 기능입니다. 긴 동기 계산을 별도 스레드로 보내주지는 않습니다. Worker를 쓰기 어려울 땐 계산을 작은 작업으로 나눠 메인에 입력 처리 기회를 주는 방법도 있습니다. 기다리는 시간과 계산에 쓰는 시간을 먼저 구분하면 선택이 쉬워집니다.
2. Worker는 어떻게 사용하나
사진 필터처럼 한 페이지가 계산을 맡길 때는 Dedicated Worker를 주로 사용합니다. 메인에서 new Worker()로 만들고, postMessage()로 데이터를 보냅니다. Worker는 message 이벤트의 event.data에서 값을 받습니다.
사진 픽셀 네 개를 0~255 사이의 밝기 값으로 단순화했습니다. 실제 사진에는 훨씬 많은 픽셀이 있지만, 여기서는 네 숫자에 255 - 값을 적용해 밝기를 반전시킵니다. 숫자 네 개만 계산할 때는 Worker가 필요하지 않습니다.
const worker = new Worker('./filter.worker.js');
const pixels = [30, 80, 160, 220];
worker.postMessage({ pixels });
worker.addEventListener('message', (event) => {
const result = document.querySelector('#result');
if (result) result.textContent = event.data.pixels.join(', ');
});
self.addEventListener('message', (event) => {
const inverted = event.data.pixels.map(value => 255 - value);
self.postMessage({ pixels: inverted });
});
메인에서 보내기 → Worker에서 계산 → 메인으로 답장 → DOM 갱신 순서입니다. self는 Worker 쪽 전역 객체를 가리킵니다. postMessage()가 계산 결과를 즉시 반환하는 게 아니라, 답장이 오면 메인의 message 리스너가 실행됩니다.
예제는 페이지에 id="result" 요소가 있고 Worker 코드를 filter.worker.js로 따로 저장했다고 가정합니다. 실제로 사용할 때는 여러 요청의 결과가 뒤늦게 도착하지 않는지, Worker 오류는 어떻게 처리할지도 확인합니다. 필요 없어진 Worker는 worker.terminate()로 종료할 수 있습니다.
3. 데이터를 보내면 원본은 어떻게 되나
메인과 Worker는 일반 객체를 같은 참조로 직접 공유하지 않습니다. 메시지의 데이터는 structured clone이라는 복제 규칙을 따릅니다.
예를 들어 { score: 10 }을 보내고 받은 쪽에서 99로 바꿔도 메인의 원본은 10입니다. 배열·Date·Map도 복제할 수 있지만, 함수나 DOM 요소는 복제할 수 없어 DataCloneError가 납니다.
큰 데이터를 보낼 때: Clone과 Transfer
사진 데이터는 ArrayBuffer라는 바이트 저장소로 다룰 수 있습니다. 바이트는 데이터 크기의 단위이고, Uint8Array는 저장소의 값을 한 바이트씩 읽거나 쓰는 뷰입니다. byteLength는 저장소 크기입니다.
[buffer]는 이전할 자원을 지정하는 transfer list입니다. ArrayBuffer를 이전하면 원본 버퍼는 detached(분리된 상태)가 되어 데이터를 사용할 수 없고, byteLength가 0이 됩니다.
사진 바이트 8개를 보내면 원본이 남을까?
받은 쪽에서 첫 바이트 10을 99로 바꿉니다. 보내는 쪽의 바이트와 저장소 크기가 어떻게 되는지 비교해 보세요.
두 방식의 코드 비교
const received = structuredClone(original);
new Uint8Array(received)[0] = 99;실제 Worker에 보낼 때는 각각 worker.postMessage({ buffer }), worker.postMessage({ buffer }, [buffer])처럼 사용합니다.
// Clone: 원본 유지
worker.postMessage({ buffer });
// Transfer: 저장소 이전
worker.postMessage({ buffer }, [buffer]);
Clone은 양쪽에 별도 데이터가 남으므로 각자 수정할 수 있습니다. Transfer는 큰 데이터의 복사 비용을 줄일 수 있지만, 보낸 쪽에서 원본을 계속 쓸 수 없습니다. 원본을 계속 써야 한다면 Clone을 검토합니다. 또 Uint8Array 뷰가 아니라, 그 뷰가 가리키는 buffer를 이전 대상으로 지정합니다.
JSON과는 무엇이 다를까
JSON.stringify()는 데이터를 문자열로 바꿉니다. JSON.parse()로 다시 읽어도 Date는 문자열이 되고 Map 항목도 그대로 보존되지 않습니다. structured clone은 이런 자료형을 더 많이 지원합니다. 사용자 정의 prototype과 속성 설정까지 그대로 유지되지는 않습니다.
4. Worker 종류는 어디에 쓰나
지금까지 본 건 한 페이지가 계산을 맡기는 Dedicated Worker입니다. 이름이 비슷한 Worker도 역할은 다릅니다.
| 종류 | 역할 | 사용 사례 |
|---|---|---|
| Dedicated Worker | 페이지의 별도 계산 | 사진 필터, 큰 CSV 분석 |
| SharedWorker | 여러 같은 출처 탭이 연결해 통신 | 탭 사이 공통 연결·상태 조정 |
| Service Worker | 네트워크 요청·캐시·오프라인 이벤트 처리 | 저장한 글을 오프라인에서 열기 |
Service Worker는 브라우저가 필요할 때 시작하고 종료할 수 있습니다. 사진 계산을 계속 실행하려고 두는 Worker와는 목적이 다르며, 등록 범위와 HTTPS 같은 조건도 있습니다.
5. RSC의 Server/Client 경계는 무엇이 다른가
RSC(React Server Components)는 React 컴포넌트를 서버에서 실행할 수 있게 하는 모델입니다. 컴포넌트는 화면에 표시할 내용을 반환하는 함수입니다. 여기서 서버는 데이터 조회 등을 담당하는 곳, 클라이언트는 사용자의 브라우저입니다.
Worker는 브라우저 안에서 계산할 스레드를 나눕니다. RSC는 서버에서 준비할 UI·데이터와 브라우저에서 처리할 상호작용을 나눕니다. 하나는 반응 속도와 계산 장소, 다른 하나는 React의 실행 위치와 전달 경계를 다룹니다.
쇼핑몰에서 서버가 상품 이름과 좋아요 수를 준비하고, 브라우저가 좋아요 버튼을 처리하는 상황을 보겠습니다.
좋아요를 누르면 서버 숫자도 바뀔까?
서버가 머그컵의 좋아요 수 3을 준비하고, 브라우저에는 클릭할 수 있는 버튼이 표시됐다고 가정합니다.
이 숫자를 브라우저로 전달할 수 있지만, 서버에서 준비한 데이터가 브라우저 상태와 자동으로 연결되는 건 아니에요.
useState가 바뀝니다.RSC에서 어디에 코드를 나눠 둘까
// Server Component
<LikeButton initialLikes={3} />
// Client Component: 파일 맨 위에 'use client'
const [likes, setLikes] = useState(initialLikes);
<button onClick={() => setLikes(n => n + 1)} />코드 보기 · 서버의 상품 정보와 브라우저의 좋아요 버튼
import LikeButton from './LikeButton';
export default function Page() {
const product = { name: '머그컵', likes: 3 };
return <>
<h1>{product.name}</h1>
<LikeButton initialLikes={product.likes} />
</>;
}
'use client';
import { useState } from 'react';
export default function LikeButton({ initialLikes }: { initialLikes: number }) {
const [likes, setLikes] = useState(initialLikes);
return <button onClick={() => setLikes(n => n + 1)}>
좋아요 {likes}
</button>;
}
initialLikes는 컴포넌트에 넘긴 props(입력값)이고, : number는 숫자 타입 표시입니다. 서버의 결과와 클라이언트에 필요한 정보는 React의 RSC Payload와 직렬화 규칙을 통해 전달됩니다. 직렬화는 값을 전달 가능한 형태로 표현하는 과정입니다.
좋아요를 눌러 브라우저 상태가 4가 돼도 서버 데이터는 자동으로 바뀌지 않습니다. 서버에 저장하려면 별도의 요청이나 Server Function이 필요합니다.
Next.js의 'use client'는 그 파일에서 시작하는 클라이언트 모듈 경계를 표시합니다. Client Component도 첫 화면의 HTML을 서버에서 미리 만들 수 있으며, 이후 브라우저에서 이벤트를 연결하는 과정을 hydration이라고 합니다. Worker의 Clone·Transfer와 RSC 전달은 서로 다른 규칙입니다.
정리
화면이 멈칫한다면 네트워크 응답을 기다리는지, 메인 스레드가 오래 계산하는지부터 확인합니다. 계산을 Worker로 옮겼다면 postMessage()로 데이터를 왕복시키고, 원본이 필요한지에 따라 Clone과 Transfer를 선택합니다.
Service Worker는 네트워크와 오프라인 처리, RSC는 서버와 브라우저의 역할 분리에 가깝습니다. 어디에서 실행하고 무엇을 전달하는가를 기준으로 구분하면 됩니다.
복습 문제
복습 문제 3개 풀어보기
1. async로 감싼 사진 계산
async function filter() {
return expensiveCalculation();
}정답과 해설 펼치기
정답: 메인 스레드. async 함수는 Promise를 반환하지만 계산 장소를 바꾸지는 않습니다. 내부의 동기 계산은 함수를 실행한 스레드에서 처리됩니다.
2. 전송 직후 원본은?
const buffer = new ArrayBuffer(16);
worker.postMessage({ buffer }, [buffer]);정답과 해설 펼치기
정답: 0. transfer 목록에 담긴 ArrayBuffer의 저장소는 받는 쪽으로 이전됩니다. 보낸 쪽 객체의 byteLength는 0이 되고 원래 데이터는 더 이상 사용할 수 없습니다.
3. Worker와 RSC 중 어디에 해당할까?
정답과 해설 펼치기
정답: Server Component와 Client Component의 역할 분리. RSC는 서버 쪽 React 컴포넌트 실행과 클라이언트의 상호작용 경계를 다룹니다. Worker는 브라우저 안에서 별도 스레드로 계산을 맡기는 기능입니다. 전달 규칙도 서로 다릅니다.