
화면 멈춤의 원인부터 웹 워커 경계까지
멈춤을 재현하는 데모에서 시작해, 긴 JavaScript 작업의 진단과 웹 워커 경계·결과 적용 규칙 설계까지 정리했습니다.
Photo by Negative Space on StockSnap
검색창에 글자를 하나 더 입력했을 뿐인데 화면이 잠깐 굳습니다. 로딩 표시는 뜨지 않고 다른 버튼도 반응하지 않다가, 한 박자 늦게 결과가 한꺼번에 나타납니다. DevTools의 Network 패널을 열어 보면 요청은 진작 끝나 있습니다. 데이터는 이미 브라우저 안에 있는데, 그 데이터를 검색·필터·집계하는 JavaScript가 도는 동안 메인 스레드가 다른 일을 하지 못한 것입니다.
이 글은 그 멈춤을 재현하는 데모에서 시작합니다. 먼저 원인이 정말 긴 JavaScript 작업인지 가려내고 웹 워커(Web Worker)가 맞는 해법인지 판단합니다. 그런 다음 워커 경계와 결과 적용 규칙을 설계합니다.
적용 범위
이미 브라우저에 도착한 데이터를 검색·필터·집계하는 화면이 대상입니다. API 왕복이나 행 렌더링이 병목이라면 서버·캐시·가상화를 먼저 확인합니다.
먼저 겪어 보기
아래 데모는 서비스 이벤트 로그 10만 건을 검색하는 화면입니다. 검색어를 한 글자 칠 때마다 같은 분석 함수 analyzeEvents가 10만 건을 처음부터 다시 훑는데, 메인 스레드 모드는 그 계산을 타이핑을 받은 스레드에서 그대로 실행하고 웹 워커 모드는 워커로 보냅니다. 두 모드의 차이는 실행 위치뿐입니다.
메인 스레드 모드에서 검색창에 payment를 평소 속도로 칩니다. 그런 다음 웹 워커로 바꿔 같은 단어를 다시 칩니다. 두 기록은 나란히 남아서 실행이 끝난 뒤 비교하면 되고, 기록 지우기로 비우고 다시 잴 수 있습니다. 아래 세 가지가 뒤의 절로 이어져요.
- 글자가 뜨는 리듬 — 같은 함수인데
메인 스레드모드에서만 친 글자가 늦게 나타남 - 뜨지 않는 분석 중 표시 —
메인 스레드모드에서는분석 중이 거의 보이지 않음 - 버린 결과 —
웹 워커모드에서 빠르게 치면 기록에 버린 결과 건수가 남음
비교 데모를 불러오고 있습니다.
잠시 뒤에도 보이지 않으면 페이지를 새로고침해 다시 불러올 수 있습니다.
기록의 띠는 requestAnimationFrame(rAF) 콜백 간격을 시간 폭 그대로 그린 것입니다. 파랑(응답 가능)은 화면이 제때 갱신된 구간이고, 빨강(멈춤)은 한 프레임이 50ms를 넘겨 그동안 아무것도 갱신되지 못한 구간입니다. 보라색 표시(입력 반영 대기)는 키를 누른 시각부터 그 글자가 담긴 프레임까지의 대기이고, 최대 반영 지연이 그중 가장 긴 값입니다.
분석 부하는 기기 차이를 메우는 조절값입니다. 가볍게·보통·무겁게 세 단계는 검색 결과를 바꾸지 않고 이벤트 한 건마다 수행하는 계산량만 바꿉니다. 빠른 기기에서 차이가 잘 안 보이면 무겁게로 올립니다. 어느 단계에서든 웹 워커 기록은 파랑을 유지하고, 메인 스레드 기록의 빨간 구간만 길어져요.
이 지표들은 데모 안 비교용입니다. rAF 간격은 주사율과 탭 상태에 따라 실제 화면 갱신과 어긋날 수 있고, 최대 반영 지연도 픽셀이 표시된 시각이 아니라 프레임 콜백 시각을 씁니다. 같은 탭에서 같은 조건으로 두 모드를 비교하는 데는 충분하지만, 제품의 성능 기준으로 옮겨 적을 값은 아닙니다.
멈춤의 원인 가려내기
검색 결과가 늦게 바뀌는 화면은 겉이 비슷해도 원인이 셋으로 갈립니다. 관찰한 장면에 따라 먼저 볼 곳이 다릅니다.
| 관찰한 장면 | 먼저 확인할 곳 | 우선 검토할 선택지 |
|---|---|---|
| 요청이 끝난 뒤에야 결과가 보임 | Network 패널, 서버 트레이스 | 쿼리, 캐시, 전송량 |
| 스크롤과 행 추가에서만 느림 | Performance 기록의 렌더링·레이아웃 | 가상화, 행 구조, 렌더링 범위 |
| 입력 뒤 긴 JavaScript 구간과 함께 UI가 멎음 | Performance 기록의 입력 핸들러와 호출 트리 | 계산 축소, 작업 분할, 웹 워커 |
Chrome DevTools의 Performance 패널에서 기록을 켜고 문제가 나는 입력을 한 번 재현합니다. 입력 이벤트 뒤에 긴 JavaScript 작업이 붙고 다음 화면 갱신이 그 뒤로 밀렸다면, 문제는 계산의 양이 아니라 계산이 실행되는 위치입니다. 이 확인 없이 워커부터 붙이면 네트워크 지연이나 렌더링 병목에는 효과가 없습니다. 긴 작업을 줄이는 선택지들은 web.dev의 긴 작업 최적화 가이드에 정리되어 있습니다.
메인 스레드는 스크립트 실행·입력 처리·화면 갱신을 하나의 대기열로 처리합니다. 분석이 도는 동안 React는 다음 화면을 그릴 기회를 얻지 못합니다. 데모의 두 모드에서 글자 리듬이 달랐던 이유도 함수가 아니라 실행 위치의 차이입니다. 메인 스레드 모드에서 분석 중 표시가 거의 보이지 않는 것도 같은 구조입니다. 표시를 켜는 React 상태 변경과 무거운 계산이 같은 작업 안에 있으면 브라우저는 바뀐 화면을 그리기 전에 계산부터 끝냅니다.
다만 메인 스레드가 바쁘다고 모든 움직임이 멈추지는 않습니다. 스크롤처럼 컴포지터(compositor)가 맡는 효과는 계속될 수 있으므로, 화면이 조금 움직였다는 인상만으로 긴 작업을 배제하지 말고 트레이스를 봅니다.
디바운스가 줄이는 것, 워커가 바꾸는 것
디바운스(debounce)는 실행 횟수를 줄입니다. payment를 칠 때 p·pa·pay마다 분석하지 않고 입력이 멎은 뒤 한 번만 실행합니다. 연속 입력이 많은 화면에서 효과가 크지만, 남은 한 번이 오래 걸리면 그 순간 메인 스레드는 여전히 멈춰요.
웹 워커는 실행 위치를 바꿉니다. 계산량은 그대로지만 다른 스레드에서 돌기 때문에, 메인 스레드는 입력과 화면 갱신을 계속 처리합니다. 그래서 둘은 경쟁 관계가 아닙니다. 디바운스로 횟수를 줄이고, 그러고도 남는 한 번이 길면 그 한 번을 워커로 옮깁니다. 데모에 디바운스를 걸지 않은 것은 실행 위치 말고 다른 변수를 없애기 위해서입니다.
워커가 맞는 자리인지는 계산의 성격으로 판단합니다.
| 상황 | 먼저 할 일 | 웹 워커 판단 |
|---|---|---|
| API나 서버 쿼리가 늦음 | 서버 트레이스와 캐시 점검 | 맞지 않음 |
| 보이는 행을 그리는 비용이 큼 | 가상화, 렌더링 범위 축소 | 보통 맞지 않음 |
| 계산이 가볍지만 입력마다 반복됨 | 디바운스, 메모이제이션 | 대개 과함 |
| 이미 받은 큰 데이터를 순수하게 분석함 | 트레이스에서 JavaScript 점유 확인 | 유력한 후보 |
| 계산이 DOM이나 React 상태를 직접 만짐 | 계산과 UI 조작을 분리 | 분리한 뒤 검토 |
작업을 잘게 나눠 사이사이 메인 스레드에 양보하는 방법도 있습니다. 한 번의 분석이 길고, 중간 상태를 보여 줄 이유가 없으며, 입력 반응을 지켜야 한다면 워커 경계가 더 단순합니다.
경계는 순수 계산과 작은 메시지
워커는 DOM을 만질 수 없습니다. 이 제약이 무엇을 워커로 보낼지 정하는 기준이 됩니다. React 상태와 버튼, 로딩 표시는 메인 스레드에 남기고, 이벤트 배열을 받아 화면용 결과를 돌려주는 순수 계산 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)로 값을 복사해 전달합니다. 함수와 DOM 노드는 건너가지 못하고, 큰 배열은 복제 비용이 듭니다. INGEST를 한 번으로 제한한 이유가 이 비용입니다. 워커를 쓴다는 이유만으로 큰 입력의 전달 비용이 사라지지는 않습니다.
재야 할 것은 분석 시간이 아니라 왕복 전체
워커를 붙였는데도 느리다면 분석 시간만 보지 말고, 최초 전달 시간·결과 전달 시간·결과를 렌더링하는 시간을 트레이스에서 함께 확인합니다.
큰 이진 데이터라면 소유권을 옮기는 전송 가능 객체(transferable object)로 복사를 줄일 수 있습니다. 다만 보낸 쪽은 그 버퍼를 더 쓰지 못하므로, 메시지 모양을 줄이는 일을 먼저 하고 전송이 실제 병목일 때 검토합니다.
워커 생성은 실패할 수 있습니다. 원인은 워커를 지원하지 않는 환경일 수도 있습니다. 스크립트 로드 실패나 Content Security Policy의 worker-src 제한일 수도 있고요. 이 데모는 어느 경우든 메인 스레드 실행으로 되돌아가 안내를 띄웁니다. 워커가 없어도 동작하는 경로 하나는 어떤 화면에서든 필요해요.
최신 결과만 화면에 반영하기
검색어를 빠르게 치면 이전 조건의 결과가 최신 조건의 결과보다 늦게 돌아올 수 있습니다. 데모는 QUERY마다 커지는 requestId를 붙입니다. 응답이 돌아오면 지금 의도와 같은 requestId인지 확인합니다.
export const shouldApplyWorkerResult = ({
currentRequestId,
responseRequestId,
}: ShouldApplyWorkerResultInput): boolean => currentRequestId === responseRequestId;웹 워커 모드에서 빠르게 칠 때 기록에 남는 버린 결과가 이 가드에 걸러진 횟수입니다. p로 시작한 분석이 돌아왔을 때 사용자의 의도는 이미 pay로 넘어가 있고 그 결과는 상태에 쓰지 않고 버립니다. 결과가 도착한 시점에는 계산이 이미 끝나 있으므로, 이 가드의 효과는 CPU가 아니라 화면에 나타납니다. 지나간 조건의 결과가 잠깐 보였다가 덮이는 일이 없어져요.
단일 전용 워커가 onmessage 안에서 동기로 계산하는 이 데모에서는 결과가 순서를 앞지르지 못합니다. 메시지는 도착한 순서대로 처리되기 때문입니다. 순서가 실제로 뒤집히는 곳은 await가 섞인 분석이나 워커 풀, 원격 계산을 오가는 경로입니다. 그때도 같은 가드가 그대로 동작합니다.
requestId는 취소가 아닙니다. 무시된 41의 분석은 워커에서 이미 끝까지 돌았거나 돌고 있는 중입니다. 실행 자체를 멈춰야 한다면 작업을 나눠 취소 메시지를 확인하게 하거나, 워커 종료가 다른 작업에 미치는 영향까지 함께 설계합니다.
적용 전 체크리스트
- 입력과 결과가 정해진 순수 함수인가. DOM이나 React 상태, 콜백은 워커 밖에 있는가
- 입력과 결과의 크기를 아는가. 최초 복제 비용과 매 쿼리 전송 비용을 따로 보았는가
- 워커 생성·로드·CSP 실패 때 사용자가 이해할 안내와 메인 스레드 대체 경로가 있는가
- 비동기 경로나 워커 풀에서 최신 결과만 적용할 기준이 있는가. 취소가 필요하면 별도 프로토콜을 설계했는가
- 결과가 돌아온 뒤 메인 스레드가 큰 목록을 다시 렌더링하지 않는가
- 변경 전후를 같은 시나리오의 트레이스로 비교했는가. 분석 시간과 함께 입력이 계속 받아들여지는지 보았는가
마치며
처음 장면으로 돌아가면, 요청은 끝났는데 화면이 굳은 이유는 계산의 양이 아니라 계산이 실행되던 위치였습니다. 같은 함수를 워커로 옮기는 것만으로 타이핑이 계속 받아들여지는 것을 데모에서 확인했어요. 사용자가 기다리는 동안에도 다음 입력이 들어가고 현재 상태를 읽을 수 있다면, 긴 계산이 있어도 화면은 멈추지 않습니다.
참고 자료
- Optimize long tasks — web.dev — 긴 작업을 찾아 나누고 줄이는 선택지 모음
- Use web workers to run JavaScript off the browser's main thread — web.dev — 실행 위치를 옮기는 전략의 배경
- RenderingNG architecture — Chrome for Developers — 메인 스레드와 컴포지터 스레드의 분담 구조
- Web Workers API — MDN — 워커의 제약, 생성·종료, 전송 가능 객체까지 이어지는 기준 문서
- The structured clone algorithm — MDN —
postMessage()가 복제할 수 있는 값의 규칙 - HTML Standard: message ports — 메시지가 도착 순서대로 처리된다는 근거
- An attempt to measure smoothness — web.dev — rAF 기반 측정이 갖는 한계