대시보드에 CSV 파일 업로드 기능이 있었다. 사용자가 엑셀에서 뽑은 데이터를 올리면, 파싱하고, 유효성 검사하고, 통계를 계산해서 미리보기를 보여주는 기능. 파일이 작을 때는 문제없었는데, 1만 행짜리 CSV를 올리면 화면이 3~4초간 완전히 멈췄다.
버튼도 안 눌리고, 스크롤도 안 되고, 프로그레스 바도 멈춰 있다. 사용자 입장에서는 앱이 죽은 것처럼 보인다.
왜 멈추나
브라우저의 메인 스레드는 하나다. JavaScript 실행, DOM 업데이트, 이벤트 처리, 렌더링 — 전부 이 하나의 스레드에서 돌아간다.
function processCSV(csvText) {
const rows = csvText.split('\n'); // 1만 행 파싱
const validated = rows.map(validateRow); // 각 행 유효성 검사
const stats = calculateStats(validated); // 통계 계산
return { validated, stats };
}이 함수가 3초 걸리면, 그 3초 동안 메인 스레드는 다른 일을 못 한다. requestAnimationFrame도, 클릭 이벤트도, 스크롤도 전부 대기열에 쌓여있다가 함수가 끝나야 처리된다.
Chrome DevTools의 Performance 탭에서 녹화해보면, 노란색 JavaScript 블록이 수 초간 메인 스레드를 점유하고 있는 게 눈에 보인다. 그 구간에서 빨간 삼각형(Long Task 경고)이 뜬다.
Web Worker로 분리하기
Web Worker는 메인 스레드와 별도의 스레드에서 JavaScript를 실행한다. DOM에 접근할 수 없지만, 순수 연산에는 제약이 없다.
CSV 처리 로직을 Worker로 옮겼다:
// csv-worker.js
self.addEventListener('message', (event) => {
const { csvText } = event.data;
const rows = csvText.split('\n');
const validated = rows.map(validateRow);
const stats = calculateStats(validated);
self.postMessage({ validated, stats });
});
function validateRow(row) {
// 유효성 검사 로직
}
function calculateStats(rows) {
// 통계 계산 로직
}메인 스레드에서는 Worker를 생성하고 메시지를 주고받는다:
function useCSVProcessor() {
const [result, setResult] = useState(null);
const [isProcessing, setIsProcessing] = useState(false);
const workerRef = useRef<Worker | null>(null);
useEffect(() => {
workerRef.current = new Worker(
new URL('../workers/csv-worker.js', import.meta.url)
);
workerRef.current.addEventListener('message', (event) => {
setResult(event.data);
setIsProcessing(false);
});
return () => workerRef.current?.terminate();
}, []);
const process = useCallback((csvText: string) => {
setIsProcessing(true);
workerRef.current?.postMessage({ csvText });
}, []);
return { process, result, isProcessing };
}
new URL('../workers/csv-worker.js', import.meta.url) — 이 패턴이 Next.js와 Webpack에서 Worker 파일을 번들링하는 표준 방식이다.
진행 상황 보여주기
Worker로 분리하니 화면이 안 멈추는 건 해결됐다. 그런데 1만 행 처리에 3초가 걸리는 건 마찬가지고, 그 동안 "처리 중..." 스피너만 돌고 있으면 사용자는 여전히 불안하다.
Worker에서 진행률을 주기적으로 보내도록 했다:
// csv-worker.js
self.addEventListener('message', (event) => {
const { csvText } = event.data;
const rows = csvText.split('\n');
const total = rows.length;
const validated = [];
for (let i = 0; i < total; i++) {
validated.push(validateRow(rows[i]));
// 500행마다 진행률 전송
if (i % 500 === 0) {
self.postMessage({
type: 'progress',
progress: Math.round((i / total) * 100),
});
}
}
const stats = calculateStats(validated);
self.postMessage({
type: 'complete',
data: { validated, stats },
});
});// 메인 스레드
workerRef.current.addEventListener('message', (event) => {
if (event.data.type === 'progress') {
setProgress(event.data.progress);
} else if (event.data.type === 'complete') {
setResult(event.data.data);
setIsProcessing(false);
}
});프로그레스 바가 실시간으로 올라간다. 메인 스레드가 자유로우니까 프로그레스 바 애니메이션도 부드럽다.
주의할 점
Worker와 메인 스레드 사이의 데이터 전달은 **구조화된 복사(structured clone)**로 이루어진다. 큰 데이터를 주고받으면 복사 비용이 생긴다.
1만 행짜리 CSV를 파싱한 결과 배열을 통째로 postMessage하면, 그 배열을 복사하는 데도 시간이 걸린다. 데이터가 정말 크면 Transferable 객체를 써서 복사 대신 소유권을 이전할 수 있다:
const buffer = new ArrayBuffer(largeData);
self.postMessage({ buffer }, [buffer]);
// 이 시점 이후로 Worker에서 buffer에 접근할 수 없다
다만 대부분의 경우에는 구조화된 복사로 충분하다. 최적화가 필요한 시점이 오면 그때 적용해도 늦지 않다.
Worker는 DOM에 접근할 수 없다. document, window, React 훅 — 전부 쓸 수 없다. 순수하게 데이터를 받아서 데이터를 돌려주는 함수만 넣을 수 있다. 이게 제약처럼 보이지만, 오히려 로직을 깔끔하게 분리하도록 강제한다.
UI 프리징 문제를 만났을 때 첫 번째 선택지가 setTimeout이나 requestIdleCallback으로 청크 분할하는 것일 수 있다. 그것도 방법이긴 한데, 복잡한 연산에서는 코드가 금방 지저분해진다. Worker는 코드를 한 줄도 안 바꾸고 파일만 분리하면 되니까 훨씬 간단하다.
