본문으로 건너뛰기
작업 노트로 돌아가기

데이터는 이미 왔는데 화면이 멈출 때

클라이언트에서 무거운 검색·필터·집계를 돌릴 때 UI가 멈추는 이유를 구분하고 웹 워커 경계와 결과 적용 규칙을 설계하는 방법을 정리했습니다.

FrontendWeb WorkerReactPerformance

Photo by Dreamy Pixel on Wikimedia Commons · CC BY

검색창에 조건을 하나 더 얹었을 뿐인데 화면이 잠깐 굳습니다. 로딩 스피너는 뜰 기미가 없고 다른 버튼을 눌러도 반응이 없다가 한 박자 늦게 결과가 한꺼번에 나타납니다. 네트워크 탭을 열어 보면 요청은 진작 끝나 있습니다. 데이터는 이미 브라우저 안에 있는데 그 데이터를 검색·필터·집계하는 JavaScript가 실행되는 동안 메인 스레드가 다른 일을 하지 못한 겁니다.

Event Explorer 데모에서 같은 analyzeEvents를 두 자리에 놓고 이 장면을 재현해 봤습니다. 한쪽은 버튼을 누른 UI 스레드에서 바로 실행하고 다른 쪽은 웹 워커(Web Worker)로 보냈습니다. 계산하는 함수는 같았는데 앞쪽은 결과가 나올 때까지 입력도 클릭도 받지 못했고, 뒤쪽은 결과를 기다리는 동안에도 인터페이스가 다음 일을 받아 냈습니다.

이 글은 그런 화면에서 긴 JavaScript 작업을 먼저 찾아내고 웹 워커가 맞는 경계인지 판단한 뒤, 메시지와 결과 적용 규칙을 설계하는 기준을 다룹니다. 범위는 좁게 잡았습니다. 서버 응답이나 데이터베이스 쿼리, 행 렌더링의 병목은 여기서 다루지 않고 이미 받아 둔 데이터를 CPU로 계산하느라 입력과 화면 갱신이 밀리는 경우에 집중합니다. 계산을 다른 스레드로 옮기는 일이 만능 처방은 아니지만 어디서 시간이 사라지는지 확인한 뒤라면 꽤 직접적인 선택지가 됩니다.

적용 범위

이미 브라우저에 도착한 데이터를 검색·필터·집계하는 화면을 기준으로 설명합니다. API 왕복이나 행 렌더링이 병목이라면 먼저 서버, 캐시, 가상화와 렌더링 비용을 확인합니다.

먼저 긴 JavaScript 작업인지 확인한다

검색 결과가 늦게 바뀌는 현상은 겉으로 비슷해도 원인이 다릅니다. 요청이 아직 끝나지 않았다면 네트워크와 서버를 봐야 하고 적은 데이터에서도 스크롤이 끊긴다면 렌더링과 레이아웃을 의심해야 합니다. 반면 입력 직후 긴 JavaScript 실행이 붙고 로딩 표시와 클릭 반응까지 함께 늦어진다면 브라우저 안의 분석 함수가 메인 스레드를 오래 점유했을 가능성이 큽니다.

관찰한 장면먼저 확인할 곳우선 검토할 선택지
요청이 끝난 뒤에야 결과가 보임Network 탭, 서버 trace쿼리, 캐시, 전송량
스크롤과 행 추가에서만 느림Performance trace의 렌더링·레이아웃가상화, 행 구조, 렌더링 범위
입력 뒤 긴 JavaScript 구간과 함께 UI가 멎음Performance trace의 입력 핸들러와 call tree계산 축소, 작업 분할, 웹 워커

Chrome DevTools에서 Performance 기록을 시작하고 문제가 나는 입력이나 버튼 동작을 한 번 재현한 뒤 기록을 멈춥니다. 입력 이벤트 다음에 긴 JavaScript 작업이 이어지고 그 사이에 다음 화면 갱신이 밀렸다면 긴 작업을 읽고 줄이는 방법부터 확인할 근거가 생깁니다. 이 단계 없이 워커부터 붙이면 네트워크 지연이나 렌더링 비용을 다른 이름으로 부르는 경우가 많습니다.

입력에서 화면 갱신까지 무엇이 무엇을 기다리는지 순서로 그리면 이렇게 됩니다.

입력·필터 변경이 긴 JavaScript 분석을 실행하고, 분석이 메인 스레드를 점유하는 동안 다음 paint·클릭 반응·rAF 콜백이 그 뒤에서 대기하는 흐름
긴 분석이 메인 스레드를 점유하는 동안 다음 paint·클릭·rAF는 그 뒤에서 대기합니다.

이 흐름에서 봐야 할 건 계산에 걸리는 시간보다 계산이 줄의 어디에 서 있느냐입니다. Chromium의 메인 스레드는 스크립트와 렌더링 이벤트 루프, 입력 이벤트 전달을 함께 맡기 때문에 긴 계산이 실행되는 동안에는 React도 다음 화면을 반영할 기회를 얻지 못합니다. 스피너 상태를 먼저 바꿔 두어도 같은 작업 안에서 무거운 함수를 곧바로 실행하면 브라우저는 스피너가 바뀐 화면을 그리기 전에 계산부터 끝낼 수밖에 없습니다. RenderingNG의 스레드 구조도 이 구분을 이해하는 데 도움이 됩니다.

다만 메인 스레드가 바쁘다고 모든 시각 변화가 반드시 멈추는 것은 아닙니다. 브라우저와 속성에 따라 컴포지터(compositor)가 맡은 스크롤이나 일부 시각 효과는 계속될 수 있습니다. 그래서 화면이 조금 움직였다는 인상만으로 긴 JavaScript 작업을 지우지 말고 입력과 그 뒤의 trace를 함께 봐야 합니다.

디바운스와 웹 워커가 해결하는 문제는 다르다

디바운스(debounce)는 실행 횟수를 줄입니다. 사용자가 payment를 입력할 때 p, pa, pay마다 분석하지 않고 입력이 잠시 멎은 뒤 한 번 실행하도록 조절합니다. 검색 입력처럼 연속 이벤트가 많은 곳에서는 매우 유용하지만 남은 한 번의 분석이 오래 걸리면 그 순간 메인 스레드는 여전히 멈춥니다.

웹 워커는 실행 위치를 바꿉니다. 브라우저가 이미 가진 큰 데이터를 순회하고 집계하는 순수 계산을 별도 스레드에서 실행하므로 메인 스레드는 입력과 화면 갱신을 처리할 여지를 얻습니다. web.dev의 off-main-thread 가이드가 설명하듯, 이 선택은 전체 계산량을 없애기보다 메인 스레드의 경합을 줄이는 데 초점이 있습니다.

상황먼저 할 일웹 워커 판단
API나 서버 쿼리가 늦음서버 trace와 캐시를 점검맞지 않음
보이는 행을 그리는 비용이 큼가상화와 렌더링 범위를 줄임보통 맞지 않음
계산이 가볍지만 입력마다 반복됨디바운스, 메모이제이션을 적용대개 과함
이미 받은 큰 데이터를 순수하게 분석함trace에서 JavaScript 점유 시간을 확인유력한 후보
계산이 DOM이나 React 상태를 직접 만짐계산과 UI 조작을 분리분리한 뒤 검토

작업을 작게 나누어 메인 스레드에 양보하는 방법도 있습니다. 다만 한 번의 분석이 길고, 중간 상태를 UI에 노출할 이유가 없으며, 입력 반응을 지켜야 한다면 워커 경계가 더 단순할 때가 있습니다. 계산의 성격이 선택 기준입니다.

웹 워커 경계는 순수 계산과 작은 메시지에서 시작한다

워커는 문서 객체 모델(DOM)을 직접 만질 수 없습니다. MDN의 Web Workers API는 워커가 별도 스레드에서 스크립트를 실행하며 DOM을 직접 조작할 수 없다고 설명합니다. 이 제약은 불편한 제한처럼 보여도 경계를 드러내는 기준입니다. React state, 버튼, 로딩 표시는 UI 스레드에 남기고 이벤트 배열을 검색·필터·집계해 화면용 결과를 만드는 analyzeEvents만 워커로 보냅니다.

이 데모의 메시지 계약은 한 번의 INGEST와 반복되는 QUERY로 나뉩니다. 워커 모드에 들어갈 때 이벤트 배열을 한 번 보내고 준비 응답을 받은 뒤에는 검색 조건만 보냅니다. 아래 타입은 현재 데모가 두 스레드 사이에 허용하는 데이터 모양입니다.

type WorkerInboundMessage =
  | { type: 'INGEST'; events: DemoEvent[] }
  | { type: 'QUERY'; requestId: number; query: AnalysisQuery };

type WorkerOutboundMessage =
  | { type: 'INGESTED'; total: number }
  | { type: 'RESULT'; requestId: number; result: AnalysisResult }
  | { type: 'ERROR'; requestId?: number; message: string };

postMessage()는 구조화 복제(structured clone)로 데이터를 전달하는 메시지 경계입니다. MDN의 Worker.postMessage() 문서에 따르면 메시지는 이 알고리즘으로 다룰 수 있는 값이어야 하며 구조화 복제 규칙은 함수와 DOM node를 복제하지 못합니다. 객체 배열을 처음 INGEST할 때도 복제와 전달 비용이 생기므로 워커를 쓴다는 이유만으로 큰 입력의 비용이 사라지지는 않습니다.

전송 비용도 측정 대상입니다

INGEST를 한 번으로 제한한 이유는 매 쿼리마다 큰 이벤트 배열을 복제하지 않기 위해서입니다. 입력과 결과가 큰 화면에서는 분석 시간 하나만 보지 말고 최초 전달 시간, 결과 전달 시간, 결과를 렌더링하는 시간까지 trace에서 같이 확인합니다.

큰 이진 데이터를 다룬다면 전송 가능 객체(transferable object)를 검토할 수 있습니다. ArrayBuffer처럼 소유권을 옮길 수 있는 리소스는 복사 비용을 줄일 수 있지만 전송을 마친 쪽은 같은 버퍼를 계속 사용할 수 없습니다. 이벤트 객체 배열에 무조건 transfer를 적용하기보다 먼저 메시지 모양을 줄이고 실제 전송 비용이 병목인지 확인하는 편이 안전합니다.

생성 경로도 제품의 일부입니다. MDN의 Worker() 생성자 문서는 모듈 워커의 URL, same-origin 조건, worker-src를 포함한 Content Security Policy(CSP) 제약을 설명합니다. 워커 생성 또는 로드에 실패했을 때는 상태를 알리고 메인 스레드의 가벼운 대체 경로로 돌아갈지, 기능을 제한할지 결정해야 합니다. 워커를 종료하는 것은 별개의 선택이며 Worker.terminate()은 진행 중인 작업을 끝까지 마치게 하지 않고 즉시 중단합니다.

결과 적용 규칙은 동시성의 경계에 둔다

requestId는 최신 사용자 의도만 화면에 반영하는 작은 가드이며 비동기·병렬 경로가 생길 때도 그대로 쓸 수 있는 계약입니다. 지금 데모의 워커는 onmessage 안에서 analyzeEvents를 동기로 실행하고 곧바로 RESULT를 보냅니다. 같은 전용 워커에 QUERY(41)QUERY(42)가 차례로 도착하면 41의 핸들러가 끝나기 전에는 42가 실행되지 않으므로 이 구현만으로도 결과가 순서를 앞지르는 일은 없습니다. HTML Standard의 MessagePort 작업 큐 규칙이 포트 큐에 쌓인 순서를 그렇게 정의합니다.

그래서 이 데모 하나만 보면 requestId는 놀고 있는 것처럼 보입니다. 정작 이 가드가 일하는 자리는 따로 있습니다. 검색 입력처럼 최신 조건이 계속 바뀌는 UI, await를 포함한 분석, 워커 풀, 재시도와 원격 계산을 섞는 경로에서는 오래된 RESULT(41)이 최신 의도가 된 42보다 늦게 도착할 수 있습니다. 그때 41의 결과를 화면에 반영하지 않는 것이 requestId가 하는 일입니다. 이 판단은 계산을 취소하는 일이 아니라 그 결과를 React state에 쓸지 정하는 일입니다.

아래 시퀀스는 결과가 이렇게 경쟁하는 비동기·병렬 실행에서의 적용 규칙입니다. 지금의 단일 동기 워커에서는 나타나지 않는 상황입니다.

UI가 QUERY 41과 최신 QUERY 42를 보낸 뒤, currentRequestId가 42인 상태에서 RESULT 42는 state에 반영하고 뒤늦게 도착한 RESULT 41은 id 불일치로 무시하는 시퀀스
currentRequestId가 42로 바뀐 뒤 도착한 RESULT 41은 id가 달라 반영하지 않습니다.

현재 코드의 가드는 의도적으로 작습니다. currentRequestId와 도착한 응답의 requestId가 같은지만 확인해 결과를 React state에 적용할지 판단합니다.

export const shouldApplyWorkerResult = ({
  currentRequestId,
  responseRequestId,
}: ShouldApplyWorkerResultInput): boolean => currentRequestId === responseRequestId;

이 차이를 분명히 해두어야 합니다. requestId는 취소가 아니므로 오래된 분석은 이미 워커에서 계속 실행 중일 수 있습니다. 실제 취소가 필요하다면 작업을 작은 단계로 나누고 취소 메시지를 확인하게 하거나, 해당 워커를 종료할 때 다른 작업에도 미치는 영향을 함께 설계해야 합니다.

데모로 관찰하고 제품에서는 trace로 확인한다

데모는 10만 개의 고정 이벤트와 CPU 부하로 같은 조건을 반복 실행해 메인 스레드와 워커의 반응성 차이를 관찰하게 합니다. 값 자체를 성능 기준으로 읽기보다 같은 브라우저와 같은 탭에서 실행 위치를 바꿨을 때 입력과 화면 갱신에 어떤 신호가 남는지 보는 용도입니다.

동일한 분석 조건을 준비하고 있습니다.

메인 스레드와 웹 워커에서 같은 작업을 실행해, 계산 중에도 화면이 반응하는지 비교하는 데모입니다.

잠시 뒤에도 보이지 않으면 페이지를 새로고침해 다시 불러올 수 있습니다.

  1. 메인 스레드를 선택하면 같은 분석이 바로 시작됩니다. 결과가 나온 뒤 동일 조건 실행을 눌러 다시 관찰할 수 있습니다.
  2. 웹 워커를 선택하면 준비가 끝난 뒤 같은 분석이 자동으로 시작됩니다. 결과가 돌아오는 동안 메인 스레드가 다음 UI 작업을 처리할 여지가 남는지 관찰합니다.

여기서 rAF FPS 최저·평균은 rAF(requestAnimationFrame) 콜백 간격을 초당 횟수로 환산한 값이고 간격 막대도 같은 콜백을 기록한 데모용 대리 지표(proxy)입니다. web.dev의 smoothness 측정 논의는 rAF polling이 가변 주사율, 탭 가시성, 컴포지터에서 독립적으로 진행되는 업데이트 때문에 거짓 신호를 낼 수 있고 모든 시각 업데이트를 포착하지 못한다고 설명합니다. 데모의 막대가 길어졌다고 해서 실제 사용자 경험 전체를 하나의 숫자로 판정하면 안 됩니다.

제품에서는 Performance trace에서 긴 JavaScript 작업과 렌더링 구간을 보고 실제 입력이 받아들여지는 시점과 화면이 갱신되는 시점을 함께 관찰합니다. 데모는 어느 trace를 열어 봐야 하는지 알려 주는 용도로 씁니다.

적용 전에는 이 항목을 확인한다

  • 입력과 결과가 정해진 순수 함수로 계산되는가. DOM, React state, callback은 워커 밖에 남겨 두었는가.
  • 입력 데이터와 결과 데이터의 크기를 알고 있는가. 최초 복제와 매 쿼리 전송 비용을 따로 보았는가.
  • 워커 생성, 로드, CSP 실패 때 사용자가 이해할 수 있는 상태와 대체 경로가 있는가.
  • 비동기 경로나 워커 풀이 생길 때 최신 결과만 적용할 기준이 있는가. 취소가 필요하면 별도 프로토콜을 설계했는가.
  • 결과가 돌아온 뒤 UI 스레드가 큰 목록을 다시 렌더링하지 않는가. 필요하면 결과 수를 제한하거나 가상화를 적용했는가.
  • 변경 전후에 같은 시나리오를 trace로 비교했는가. 분석 시간과 입력이 계속 받아들여지는지를 함께 확인했는가.

사용자가 기다리는 동안에도 다음 입력을 넣고 현재 상태를 읽을 수 있다면 긴 계산은 화면의 일을 빼앗지 않습니다. 데이터는 이미 왔는데 화면이 멈춘 상황에서는 계산이 서 있는 줄을 먼저 바꾸는 편이 직접적인 해결책일 때가 있습니다.