뒤로가기
Web Worker로 UI 프리징 해결한 이야기

June 19, 2023

javascriptperformancefrontend

대시보드에 CSV 파일 업로드 기능이 있었다. 사용자가 엑셀에서 뽑은 데이터를 올리면, 파싱하고, 유효성 검사하고, 통계를 계산해서 미리보기를 보여주는 기능. 파일이 작을 때는 문제없었는데, 1만 행짜리 CSV를 올리면 화면이 3~4초간 완전히 멈췄다.

버튼도 안 눌리고, 스크롤도 안 되고, 프로그레스 바도 멈춰 있다. 사용자 입장에서는 앱이 죽은 것처럼 보인다.

왜 멈추나#

브라우저의 메인 스레드는 하나다. JavaScript 실행, DOM 업데이트, 이벤트 처리, 렌더링 — 전부 이 하나의 스레드에서 돌아간다.

javascript
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로 옮겼다:

javascript
// 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를 생성하고 메시지를 주고받는다:

typescript
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에서 진행률을 주기적으로 보내도록 했다:

javascript
// 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 },
  });
});
typescript
// 메인 스레드
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 객체를 써서 복사 대신 소유권을 이전할 수 있다:

javascript
const buffer = new ArrayBuffer(largeData);
self.postMessage({ buffer }, [buffer]);
// 이 시점 이후로 Worker에서 buffer에 접근할 수 없다

다만 대부분의 경우에는 구조화된 복사로 충분하다. 최적화가 필요한 시점이 오면 그때 적용해도 늦지 않다.

Worker는 DOM에 접근할 수 없다. document, window, React 훅 — 전부 쓸 수 없다. 순수하게 데이터를 받아서 데이터를 돌려주는 함수만 넣을 수 있다. 이게 제약처럼 보이지만, 오히려 로직을 깔끔하게 분리하도록 강제한다.

UI 프리징 문제를 만났을 때 첫 번째 선택지가 setTimeout이나 requestIdleCallback으로 청크 분할하는 것일 수 있다. 그것도 방법이긴 한데, 복잡한 연산에서는 코드가 금방 지저분해진다. Worker는 코드를 한 줄도 안 바꾸고 파일만 분리하면 되니까 훨씬 간단하다.