브라우저 메인 스레드의 역할과 이를 효율적으로 분할, 배치, 우선순위 지정, 지연 및 오프로딩하는 방법을 설명합니다.
“프론트엔드 최적화”라는 말을 들으면 무엇이 떠오르나요? 대부분은 네트워크 요청 줄이기, 번들 크기 줄이기, 캐시 잘 활용하기 같은 것을 생각합니다. 그 밖에는 리렌더링을 줄이거나 리소스를 불러오는 시점을 조정하는 정도일 수 있습니다. 메인 스레드는 보통 화제가 되지 않으며, 그럴 만한 이유가 있습니다. 대부분의 화면에서는 문제가 되지 않기 때문입니다. 하지만 상호작용이 많고 데이터가 실시간으로 들어오며 스크롤, 애니메이션, 입력이 모두 얽힌 화면에서는 상황이 달라집니다. 네트워크와 번들 크기를 아무리 줄여도 메인 스레드가 막히는 순간 화면은 멈춥니다.
스크롤이 가끔 끊기거나, 버튼이 약간 늦게 반응하거나, 검색창에 입력한 글자가 반 박자 늦게 나타나는 웹사이트를 본 적이 있을 것입니다. 짜증 날 정도로 심하진 않지만 미묘하게 신경 쓰입니다. 그런 종류의 버벅임이 바로 메인 스레드가 막혔을 때의 모습입니다.
개발자로서 이런 버벅임을 마주하면 보통 “내 코드가 느린가?”라고 생각하고 알고리즘을 뜯어보거나 낭비되는 계산을 찾기 시작합니다. 하지만 대부분의 경우 문제는 코드의 속도가 아닙니다. 코드가 느린 것이 아닙니다. 그저 메인 스레드를 붙잡고 있는 코드일 뿐입니다.
브라우저에는 여러 스레드가 있지만, 코드로 만질 수 있는 거의 모든 것은 메인 스레드에 집중되어 있습니다. 계산, 렌더링, 이벤트 처리, 네트워크 응답 처리, 프레임워크 내부 동작이 모두 여기서 처리됩니다. 하나의 자원에 산더미 같은 작업이 몰립니다.
브라우저의 메인 스레드는 비쌉니다. 대부분은 문제를 일으키지 않지만, 무언가 야심 찬 일을 하려 하면 메인 스레드를 다루는 일이 핵심이 됩니다. 이 글은 그 비싼 자원을 다루는 방법에 관한 글입니다.
먼저 메인 스레드가 실제로 무엇을 하는지 살펴보겠습니다. 작업은 크게 두 가지 범주로 나뉩니다.
첫 번째는 JavaScript 실행입니다. 우리가 작성한 코드와 이벤트 핸들러, 타이머, 네트워크 응답 콜백, 프레임워크 내부 동작이 모두 여기서 실행됩니다. 이 작업들은 화면 주기와 관계없이, 큐에 들어온 순서대로 빈틈이 생길 때마다 실행됩니다.
두 번째는 화면 그리기입니다. DOM이나 스타일이 바뀌어 화면을 갱신해야 할 때 브라우저는 프레임을 만들기 위해 대략 다음 단계를 순서대로 거칩니다.
requestAnimationFrame 콜백 실행 - 프레임을 그리기 직전에 실행하도록 등록한 JavaScript아무것도 바뀌지 않았다면 이 단계들은 통째로 건너뛰므로, 반드시 매 프레임 실행되는 것은 아닙니다. 만들어진 결과를 가져와 화면에 조립하는 마지막 합성 단계만 컴포지터 스레드1에 넘겨집니다. 즉, 화면을 그리는 파이프라인의 앞부분 대부분은 메인 스레드의 책임입니다.
화면 갱신을 위한 렌더링 파이프라인
화면이 부드러워 보이려면 디스플레이의 주사율에 맞춰 프레임을 그려야 합니다. 가장 흔한 60Hz 디스플레이에서는 초당 60프레임, 즉 프레임당 약 16.6밀리초입니다. 그리고 이 시간을 전부 쓸 수 있는 것도 아닙니다. 브라우저 자체 처리 비용을 제외하면 실질적인 예산은 보통 약 10밀리초2로 여겨지며, 120Hz 기기에서는 예산 자체가 반으로 줄어듭니다.
문제는 위 두 종류의 작업이 같은 스레드에서 한 줄로 선다는 점입니다. JavaScript는 단일 스레드 이벤트 루프 모델을 중심으로 설계되었습니다. 메인 스레드는 한 번에 하나의 작업을 처리하며, 그 작업이 실행되는 동안에는 다른 어떤 일도 일어날 수 없습니다. JavaScript 함수 하나가 200밀리초 동안 실행되면, 그 200밀리초 동안 브라우저는 화면을 다시 그릴 수도 없고 사용자의 클릭을 받을 수도 없습니다. 약 10밀리초의 프레임 예산에 비하면 치명적인 시간입니다. 이처럼 오래 실행되어 메인 스레드를 점유하는 작업을 긴 작업이라고 하며, 보통 50밀리초를 넘으면 문제로 봅니다.
말만으로는 부족하니 직접 느껴봅시다. 아래 데모에서는 버튼을 누르면 JavaScript가 잠시 메인 스레드를 잡습니다.
JS 애니메이션
메인 스레드 · rAF
CSS 애니메이션
컴포지터 · transform
버튼을 누르세요: JS 애니메이션과 입력은 멈추지만 CSS 애니메이션은 계속 회전합니다
버튼을 누르면 JS 애니메이션이 멈추고 입력 필드에 타이핑해도 아무 일도 일어나지 않습니다. 반면 CSS 애니메이션은 계속 실행됩니다. 이 차이가 어디에서 오는지는 뒤에서 다시 살펴보겠습니다. 지금 기억할 것은 메인 스레드를 오래 붙잡는 일은 화면을 멈추게 하는 일과 같다는 점입니다.
이는 웹 성능 지표와도 직접 연결됩니다. 사용자가 무언가 한 뒤 화면이 반응할 때까지 걸리는 시간을 측정하는 INP(Interaction to Next Paint), 페이지 로드 중 메인 스레드가 막힌 총시간을 측정하는 TBT(Total Blocking Time)는 본질적으로 메인 스레드가 막힌 시간을 표현하는 방식입니다. 성능 최적화의 큰 부분은 이 하나의 스레드를 얼마나 신중하게 쓰는가의 문제입니다.
신중하게 쓰는 방법은 크게 두 계열입니다. 하나는 메인 스레드 안에서 시간을 잘 나누는 것입니다. 다른 하나는 아예 메인 스레드 밖으로 작업을 보내는 것입니다. 차례대로 살펴보겠습니다.
첫 번째 계열은 메인 스레드에 머무르되 그 시간을 똑똑하게 쓰는 것입니다. 네 가지 핵심 동작이 있습니다.
이를 분할, 배치, 우선순위 지정, 지연이라 부르겠습니다. 앞의 두 가지는 작업의 크기를 다루고, 뒤의 두 가지는 작업의 시점을 결정합니다. 네 가지 가운데 분할은 나머지의 기반입니다. 작업 사이에 무엇을 끼워 넣고 무엇을 미룰지 결정하려면 먼저 작업에 경계가 있어야 합니다. 따라서 분할부터 시작하겠습니다.
라이브 스트림의 채팅 창을 떠올려 봅시다. 인기 방송에서는 초당 수백 개의 메시지가 한꺼번에 쏟아질 수 있습니다. 이런 환경에서 메시지는 하나씩 공손하게 도착하지 않습니다. 트래픽이 급증하면 서버는 수십 개씩 뭉쳐 보내고, 방에 처음 들어가는 순간에는 밀린 메시지 수백 개가 한꺼번에 내려옵니다. 도착한 묶음 전체를 즉시 한 번에 렌더링하면 어떻게 될까요? 메시지 하나를 그릴 때마다 DOM 생성, 스타일 계산, 레이아웃, 페인트가 따라오며, 수백 번의 반복이 하나의 작업 안에서 연달아 실행됩니다. 그동안 자기 메시지를 입력하려는 사용자는 끊기는 입력 필드를 보게 되고, 화면의 다른 애니메이션도 모두 버벅입니다. 다른 사람의 채팅이 메인 스레드를 독점해 내 채팅을 방해하는 셈입니다.
해결책은 앞서 말한 대로입니다. 묶음을 작은 조각으로 자르고, 조각 사이에서 잠시 메인 스레드의 제어권을 돌려줍니다. 그 틈에서 브라우저는 밀린 화면 갱신과 입력 처리를 따라잡을 수 있습니다.
아래 데모는 스트리밍 채팅 창을 시뮬레이션합니다. “Flood the chat”을 누르면 메시지가 쏟아지기 시작합니다. 위쪽의 부드러움 게이지와 fps를 보면서 입력 필드에 타이핑해 보고, “Immediate render”와 “Yielding render” 모드를 비교해 보세요.
부드러움 표시기(JS 애니메이션)60 fps
“Immediate render” 모드에서는 메시지가 도착할 때마다 DOM을 건드리므로 채팅이 쏟아지는 동안 fps가 급격히 떨어지고, 게이지가 끊기며 입력 필드가 느려집니다. 자세히 보면 채팅 메시지 자체도 눈에 띄게 늦게 나타나기 시작합니다. 메시지를 받고 처리하는 콜백 역시 메인 스레드의 줄에서 기다리는 작업이므로 다른 모든 것과 함께 지연되기 때문입니다. 이제 “Yielding render”로 바꿔 보세요. 메시지는 이전처럼 여전히 하나씩 그려지지만 입력이 다시 살아나고 화면도 다시 움직입니다. 달라진 점은 메시지 20개마다 메인 스레드를 잠시 놓아준 것뿐입니다.
여기서 잘못 이해하지 말아야 할 점은 양보한다고 작업이 더 빨라지는 것은 아니다라는 것입니다. 총 작업량은 변하지 않고, 양보할 때마다 기다리는 몇 밀리초는 추가 오버헤드이므로 실제 경과 시간은 오히려 길어집니다. 그런데 왜 입력과 함께 렌더링이 회복될까요?
앞에서 보았듯 작업이 실행되는 동안 메인 스레드는 다른 일을 할 수 없습니다. 프레임을 만드는 렌더링 파이프라인도 작업 중간에 끼어들 수 없습니다. 오직 작업과 작업 사이에서만 실행할 수 있습니다. 양보는 바로 그 틈을 만드는 행위입니다. 밀린 입력과 프레임 생성이 그 틈에서 차례를 얻고, 사용자에게는 성능이 좋아진 것처럼 느껴집니다.
코드 수준에서 고전적인 양보 방식은 이어지는 작업을 다음 작업으로 미루는 setTimeout입니다. 다음 코드를 보세요.
// 채팅 메시지 묶음이 한꺼번에 도착한다
socket.on('messages', (chats) => {
renderChats(chats);
});
// 메시지를 그리되, 20개마다 메인 스레드를 양보한다
async function renderChats(chats) {
let count = 0;
for (const chat of chats) {
appendChatNode(chat); // 메시지 하나 그리기
if (++count % 20 === 0) {
await new Promise((resolve) => setTimeout(resolve, 0)); // 여기서 양보
}
}
}
이렇게 하면 채팅이 아무리 쏟아져도 DOM 작업이 메인 스레드를 한 덩어리로 점유하지 않으며, 조각 사이에는 사용자 입력과 애니메이션을 처리할 여지가 생깁니다.
이 코드의 핵심은 setTimeout입니다. 남은 작업의 재개를 새 작업으로 예약하면 현재 작업은 그 자리에서 끝나고, 그 틈에서 밀린 입력과 렌더링이 처리됩니다. await는 예약된 작업의 차례가 돌아올 때까지 함수를 멈추었다가, 돌아오면 중단했던 곳에서 다시 시작합니다.
양보가 타임라인을 바꾸는 방식
위 예제는 들어오는 작업을 개수로 나눴습니다. 그러나 이미 애니메이션이 실행 중이거나 사용자가 스크롤 중이라면, 개수보다 시간을 기준으로 나누는 편이 안전합니다. 애니메이션은 매 프레임 메인 스레드를 조금씩 사용하므로, 무거운 작업은 계속 시간을 확인하고 프레임 예산의 남은 부분을 삼키기 전에 스스로 끊어야 합니다.
async function processDuringAnimation(items) {
let i = 0;
let frameStart = performance.now();
while (i < items.length) {
// 프레임 시작 후 5ms가 지날 때까지만 작업한다
while (i < items.length && performance.now() - frameStart < 5) {
doWork(items[i++]);
}
frameStart = await new Promise(requestAnimationFrame); // 다음 프레임의 시작 시간으로 재개
}
}
여기서 performance.now()는 예산을 넘겼는지 확인하는 스톱워치이고, requestAnimationFrame은 “다음 프레임이 그려지기 직전에 깨워 줘”라고 하는 알람입니다. 시간 기준으로 나눌 때 setTimeout 대신 rAF로 양보하는 이유도 이것입니다. 재개가 프레임 주기에 맞춰 일어납니다.
rAF는 프레임 시작 타임스탬프를 콜백에 전달하며, 위 코드는 이를 예산의 기준점으로 씁니다. 함수가 프레임을 혼자 쓰는 것이 아니기 때문입니다. 같은 프레임에서 다른 애니메이션 콜백이 먼저 실행됐다면, 그들이 쓴 시간만큼 우리의 몫도 줄여야 프레임 예산이 깨지지 않습니다. 프레임 시작 시점에 기준을 고정하면 “5ms 사용”은 “프레임 시작 후 5ms까지 사용”이 되어, 여러 애니메이션이 하나의 프레임을 공유할 때 자연스럽게 협력하게 됩니다.
왜 5밀리초인가요? 특별한 숫자는 아닙니다. 실질적인 예산이 약 10밀리초라고 했으니, 대략 절반은 백그라운드 작업에 주고 나머지는 애니메이션 콜백, 스타일, 레이아웃, 페인트에 남겨 두는 것이 합리적인 경험칙입니다. 애니메이션이 무겁다면 더 줄이세요.
이 접근법을 쓰면 무거운 작업이 진행 중이어도 매 프레임 화면을 그릴 여지가 있어 작업과 애니메이션이 부드럽게 나란히 실행됩니다. 아래 데모를 해보세요. 마우스를 움직이면 커서에서 멀어지도록 4,000개의 입자가 흩어지고, 가까운 입자끼리도 서로 밀어냅니다. 따라서 입자 하나의 방향을 정하려면 다른 모든 입자와의 거리를 확인해야 합니다. 한 번 처리할 때 대략 1,600만 번의 거리 계산이 필요하며, 이를 매 프레임 모두 다시 계산하면 그 자체로 프레임 예산을 초과합니다. “Compute all at once”와 “5ms per frame” 모드를 비교해 보세요.
4,000개 입자 · 이번 프레임 조향 비용 0.0ms 60 fps
여기에서 마우스나 손가락을 움직이면 입자가 커서에서 멀어집니다
분할은 메인 스레드 시간을 아껴 쓰는 가장 기본적인 방법입니다. 사용자가 중요하게 여기는 체감 성능, 즉 빠른 반응과 부드러운 화면을 제공하는 방법입니다.
마지막으로 주의할 점 몇 가지를 짚겠습니다. 첫째, 너무 잘게 나누면 역효과가 납니다. 양보하고 다시 돌아오는 데도 비용이 있으므로 조각이 너무 작으면 그 오버헤드가 하려는 작업보다 커질 수 있습니다.
둘째, setTimeout으로 양보하면 최소 지연 시간3이 발생하므로, 조각마다 몇 밀리초를 쓸데없이 기다릴 수 있습니다. 보통은 문제가 되지 않지만 반응성이 매우 중요한 상황에서는 지연이 문제가 될 수 있습니다.
그래서 일부 코드는 대신 MessageChannel을 통해 메시지를 보내 다음 작업을 예약합니다. React의 스케줄러도 이 방법을 사용합니다. 최근에는 이 문제를 해결하기 위해 scheduler.yield()라는 표준 API도 등장했습니다. 양보 뒤 원래 작업이 대기열 뒤로 밀리는 대신 다른 대기 작업보다 먼저 재개된다는 장점이 있습니다. 다만 브라우저 지원은 아직 고르지 않습니다.
셋째, 양보 도구마다 돌아오는 시점이 다릅니다. setTimeout과 scheduler.yield()는 렌더링 주기와 무관하게 재개하지만, requestAnimationFrame은 프레임이 그려지기 직전에 재개합니다. 따라서 화면 갱신의 리듬을 맞춰야 하는 작업에는 requestAnimationFrame이 더 적합합니다. 우선순위를 더 세밀하게 제어하고 싶다면 MessageChannel 위에 직접 큐를 만들고 양보와 재개를 관리할 수도 있습니다.
마지막으로, 분할이 항상 가능한 것은 아닙니다. 예를 들어 수 메가바이트 응답을 JSON.parse로 파싱하는 일은 하나의 원자적인 동기 호출이므로 중간에 멈추고 양보할 방법이 없습니다. 파싱이 끝날 때까지 메인 스레드는 묶입니다. 이처럼 분할할 수 없는 무거운 작업은 “현명하게 사용하기”의 명확한 한계입니다. 이 경우에는 전제를 바꿔 애초에 메인 스레드에서 작업하지 않아야 합니다. “비싼 자원을 사용하지 않기”에서 다루겠습니다.
하지만 분할만으로 모든 문제가 해결되지는 않습니다. 스트리밍 채팅 예제를 다시 생각해 봅시다. 양보는 입력과 렌더링을 구했지만 채팅을 더 빨리 그리게 하지는 못했습니다. 오히려 단위 시간당 그려지는 메시지 수인 처리량은 양보 오버헤드만큼 줄어들었습니다. 그렇다면 채팅이 처리량보다 더 빠르게 쏟아지면 어떻게 될까요? 도착이 처리를 앞지르고 백로그는 계속 쌓이며, 화면에 도달하는 메시지는 점점 더 오래된 것이 됩니다. 이를 배압이라고 합니다.
처리량을 높이려면 분할과는 다른 도구가 필요합니다. 예를 들어 메시지를 하나씩 그리지 말고 쌓인 묶음을 한 번에 그릴 수 있습니다. 메시지마다 드는 고정 비용이 합쳐져 같은 시간에 더 많은 채팅을 렌더링할 수 있습니다. 나누라고 했다가 묶으라고 하면 모순처럼 들릴 수 있지만, 둘 다 작업을 적절한 크기로 다듬는 것이 목적입니다. 분할은 렌더링이 끼어들 수 없을 정도로 긴 작업을 다루고, 배치는 파이프라인의 고정 비용을 반복해서 치러야 할 정도로 잦은 작업을 다룹니다.
배치에 가장 적합한 대상은 이벤트입니다. 스크롤, 리사이즈, 입력 이벤트는 짧은 시간에 수십 또는 수백 번 발생할 수 있습니다. 각각에서 무거운 핸들러를 실행하면 메인 스레드에는 아무것도 남지 않습니다. 그래서 여러 이벤트를 하나의 실행으로 합칩니다. “잠잠해진 뒤 한 번 실행”하거나 “일정 간격에 최대 한 번 실행”하는 방식이며, 각각 디바운스와 스로틀이라고 합니다.
아래 데모는 긴 CHANGELOG를 연 마크다운 편집기입니다. 미리보기를 만들려면 전체 문서(약 2,000줄)를 파싱하고 DOM을 처음부터 다시 만들어야 하므로, 키 입력마다 실행하기에는 너무 비쌉니다. “No debounce”를 선택하고 왼쪽 편집기에 빠르게 입력해 보세요. 문자마다 미리보기가 다시 만들어져 입력이 뒤처집니다. “Debounce 300ms”로 바꾸면 입력을 멈춘 뒤 렌더링이 한 번만 실행되어 타이핑이 부드러워집니다.
약 2,000줄의 CHANGELOG.md 60 fps
시각적 갱신에는 requestAnimationFrame을 사용할 수 있습니다. 어차피 화면은 프레임마다 한 번만 그려지므로, 갱신 요청이 아무리 많이 쌓여도 프레임당 한 번 그리면 충분합니다.
let scheduled = false;
socket.on('tick', (tick) => {
chart.push(tick); // 모든 데이터 포인트를 보관한다 — 버리는 것은 없다
if (scheduled) return; // 이번 프레임의 그리기는 이미 예약되어 있다
scheduled = true;
requestAnimationFrame(() => {
renderBoard(); // 프레임당 한 번 그린다
scheduled = false;
});
});
아래 데모는 초당 1,000개가 넘는 메시지로 60개 티커 보드를 갱신합니다. “Render every tick” 모드는 메시지마다 보드 전체를 다시 그립니다. 차트 라이브러리의 update()를 메시지마다 호출하는 것은 흔한 실수이며, 정확히 이런 모습이 됩니다. “Once per frame”으로 바꾸면 도착하는 모든 데이터 포인트는 여전히 반영되지만 fps는 회복됩니다.
부드러움 측정기(JS 애니메이션)60 fps
Bitcoin
BTC
98,000,000
0.00%
Ethereum
ETH
5,200,000
0.00%
Solana
SOL
310,000
0.00%
XRP
XRP
3,400
0.00%
Dogecoin
DOGE
480
0.00%
Cardano
ADA
1,500
0.00%
Avalanche
AVAX
62,000
0.00%
Chainlink
LINK
32,000
0.00%
Polkadot
DOT
11,000
0.00%
TRON
TRX
380
0.00%
Litecoin
LTC
160,000
0.00%
Stellar
XLM
620
0.00%
NEAR Protocol
NEAR
8,200
0.00%
Aptos
APT
13,500
0.00%
Sui
SUI
5,600
0.00%
Arbitrum
ARB
1,900
0.00%
Optimism
OP
3,100
0.00%
Polygon
POL
700
0.00%
Cosmos
ATOM
9,800
0.00%
Ethereum Classic
ETC
38,000
0.00%
Bitcoin Cash
BCH
680,000
0.00%
Uniswap
UNI
14,000
0.00%
Aave
AAVE
240,000
0.00%
Algorand
ALGO
520
0.00%
VeChain
VET
65
0.00%
Internet Computer
ICP
15,000
0.00%
Filecoin
FIL
7,800
0.00%
The Sandbox
SAND
720
0.00%
Decentraland
MANA
830
0.00%
Axie Infinity
AXS
9,200
0.00%
Theta
THETA
2,900
0.00%
EOS
EOS
1,100
0.00%
Tezos
XTZ
1,800
0.00%
NEO
NEO
21,000
0.00%
Kaia
KAIA
250
0.00%
Hedera
HBAR
380
0.00%
MultiversX
EGLD
45,000
0.00%
Flow
FLOW
1,300
0.00%
Chiliz
CHZ
160
0.00%
Enjin Coin
ENJ
420
0.00%
Zilliqa
ZIL
35
0.00%
Qtum
QTUM
4,200
0.00%
Ontology
ONT
350
0.00%
ICON
ICX
260
0.00%
Waves
WAVES
1,900
0.00%
Kyber Network
KNC
800
0.00%
0x
ZRX
620
0.00%
Basic Attention Token
BAT
340
0.00%
Synthetix
SNX
2,400
0.00%
Curve
CRV
1,100
0.00%
Compound
COMP
78,000
0.00%
Maker
MKR
2,400,000
0.00%
Yearn Finance
YFI
11,000,000
0.00%
SushiSwap
SUSHI
1,500
0.00%
The Graph
GRT
310
0.00%
1inch
1INCH
580
0.00%
Ankr
ANKR
55
0.00%
Storj
STORJ
720
0.00%
Pundi X
PUNDIX
610
0.00%
Status
SNT
48
0.00%
DOM 쓰기도 배치할 수 있습니다. 노드 백 개를 하나씩 붙이는 대신 한 연산으로 붙이거나, 스타일 속성을 각각 바꾸는 대신 클래스 하나를 전환하면 많은 변경이 하나로 합쳐져 성능에 도움이 됩니다. HTML 문자열을 조립해 innerHTML에 한 번에 대입하는 오래된 기법도 본질은 같습니다. 쓰기를 모아 렌더링 파이프라인의 고정 비용을 한 번만 치르는 것입니다.
이런 최적화는 프론트엔드 개발자에게 익숙한 패턴이며, React의 가상 DOM도 그 자체로 이를 위한 장치입니다. 상태가 몇 번 바뀌든 변경은 가상 트리에 쌓이고 먼저 비교된 뒤, 실제 차이만 한 번에 실제 DOM에 적용됩니다. 한 이벤트 핸들러 안의 여러 상태 갱신을 하나의 리렌더링으로 합치거나, 분석 이벤트를 개별 전송 대신 모아 하나의 요청으로 보내는 것도 같은 생각입니다. 항목마다 반복될 고정 비용을 묶음당 한 번만 냅니다.
분할과 배치가 작업의 크기를 다듬는다면, 우선순위 지정은 순서를 결정합니다. 사용자가 방금 누른 버튼에 반응하는 일은 빨리 일어나야 하지만 화면 밖 콘텐츠의 통계를 미리 계산하는 일은 기다릴 수 있습니다. 우선순위 지정은 급한 작업을 급하지 않은 작업보다 앞에 두는 것입니다.
아무것도 중단할 수 없는 메인 스레드에서는 순서가 곧 사용자가 느끼는 반응성이므로 순서가 중요합니다. 순서를 제어하려면 보통 작업을 넣고 꺼내는 큐를 만듭니다. 큐의 작업은 FIFO로 처리되지만, 긴급한 일이 들어오면 줄 앞쪽으로 끌어옵니다.
const queue = [];
const channel = new MessageChannel();
// 메시지 하나 = 작업 하나. 조각을 처리한 뒤 다음 것을 예약한다
channel.port1.onmessage = () => {
const job = queue.shift(); // 지금 맨 앞에 있는 것을 가져온다
if (!job) return; // 중복 예약에 대비한 방어
job();
if (queue.length > 0) channel.port2.postMessage(null);
};
function postJob(job, urgent = false) {
if (urgent) queue.unshift(job); // 긴급 작업은 줄 맨 앞으로 들어간다
else queue.push(job);
if (queue.length === 1) channel.port2.postMessage(null);
}
이 구조가 유용한 이유는 우선순위가 고정된 값이 아니기 때문입니다. 처음에는 급하지 않았던 작업도 사용자의 행동 때문에 갑자기 급해질 수 있습니다. 사용자가 게시물에 사진 수십 장을 첨부한다고 해봅시다. 비용을 아끼기 위해 클라이언트는 때때로 이미지를 서버에 올리기 전에 리사이즈하며, 이 리사이즈는 순서대로 처리해도 되는 느긋한 작업입니다.
React도 비슷한 방식으로 동작하며, 그 위에 기아 상태 방지, 배치, 연속 처리 같은 더 정교한 장치를 둡니다.
startTransition이나useDeferredValue를 쓰면MessageChannel로 양보하고 자체 우선순위 큐로 작업을 정렬하는 스케줄러가 내부에서 동작합니다.
하지만 사용자가 특정 사진이 제대로 첨부됐는지 확인하려고 클릭하면, 그 사진의 미리보기는 가장 긴급한 작업이 됩니다. 위와 같은 우선순위 큐에서는 긴급 작업을 먼저 처리할 수 있습니다. 한가할 때 작업을 미리 해두고 필요해지는 순간 서두르는 이 접근법은 때때로 idle-until-urgent 패턴4이라고 불립니다.
// 첨부한 사진의 미리보기를 순서대로 만든다
files.forEach((file, i) => {
const job = () => createPreview(file, i);
job.photoId = i; // 나중에 큐에서 찾을 수 있도록 태그를 붙인다
postJob(job);
});
// 아직 준비되지 않은 사진을 클릭하면 작업을 맨 앞으로 끌어온다 → 우선순위 상승
onClickPhoto((i) => {
const idx = queue.findIndex((job) => job.photoId === i);
if (idx > 0) queue.unshift(queue.splice(idx, 1)[0]);
});
아래 데모에서 차이를 느껴 보세요. 사진 60장이 첨부되어 있고 각 미리보기는 실제로 픽셀별 필터링으로 생성됩니다. 미리보기가 순서대로 만들어지는 동안 아직 준비되지 않은 회색 타일 하나를 클릭해 보세요. “In order” 모드에서는 그 타일의 차례까지 기다려야 하지만, “Clicked first” 모드에서는 큐를 건너뛰어 바로 채워집니다. 총 작업량은 같고 순서만 바뀌었지만 사용자 경험은 완전히 다릅니다.
첨부한 사진 60개의 미리보기를 한 번에 하나씩 만드는 중
따라서 우선순위란 바로 이 순간 사용자에게 가장 중요한 것이 무엇인지 알아내는 문제입니다.
최신 브라우저는 Scheduler API, TaskController처럼 이를 위한 표준을 제공합니다. 지원이 아직 불완전하므로 실무에서는 폴리필과 함께 쓰거나 자체 큐를 만듭니다. 이 글에서는 직접 만든 큐 접근법을 사용합니다.
메인 스레드를 아끼는 마지막이자 가장 확실한 방법은 지금 할 필요 없는 일을 지금 하지 않는 것입니다. 분할과 배치가 “어떤 크기로”를 묻고 우선순위 지정이 “어떤 순서로”를 묻는다면, 지연은 이 작업이 정말 지금 당장 일어나야 하는지를 묻습니다.
초기 페이지 로드는 지연이 효과를 내는 대표적인 곳입니다. 모든 JavaScript를 처음부터 내려받아 실행할 필요는 없습니다. 코드 분할을 사용하면 현재 화면에 필요한 코드만 먼저 실행하고 나머지는 필요해질 때 불러오므로, 시작부터 메인 스레드가 느려지는 것을 막을 수 있습니다.
렌더링 자체도 지연할 수 있습니다. 소셜 피드를 생각해 봅시다. 어떤 앱은 충분히 스크롤해 수백 개 게시물이 쌓인 뒤 알림 탭에서 돌아오면 잠시 멈춥니다. 탭을 전환하는 동안 피드의 DOM을 유지했더라도, 다시 보이는 순간 브라우저는 뷰포트와 한참 떨어진 게시물까지 포함해 수백 개 전체의 스타일과 레이아웃을 한꺼번에 다시 계산합니다. 그렇다면 화면 밖 게시물은 높이만 차지하는 빈 껍데기로 두고, 화면 가까이 올 때 실제 콘텐츠를 채우면 어떨까요? 그 “가까워지는” 순간을 알려주는 도구가 IntersectionObserver입니다. 이미지 지연 로딩 라이브러리가 사용하는 것과 같은 방법입니다.
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) fill(entry.target); // 가까워지면 채운다
else empty(entry.target); // 떠나면 자리는 유지한 채 비운다
}
},
{ rootMargin: '400px' } // 스크롤이 도착하기 전 채울 여유
);
feed.querySelectorAll('.feed-item').forEach((el) => io.observe(el));
아래 데모는 게시물 1,500개가 쌓인 피드입니다5. 처음에는 “Render only when visible”이 켜져 있습니다. 알림 탭에 갔다가 돌아와 보세요. 얼마나 많이 쌓였든 복귀는 즉시 이루어집니다. 이제 “Render everything”으로 바꾸고 다시 왕복해 보세요. 1,500개 게시물 전체의 레이아웃을 다시 잡는 동안 돌아올 때마다 수백 밀리초 동안 멈춥니다. 빠르게 스크롤할 때 잠시 비어 있는 슬롯이 보이는 것을 제외하면 두 모드는 똑같아 보입니다.
게시물 1,500개가 쌓인 피드 — 알림 탭에 갔다 와 보세요 60 fps
피드로 돌아오는 데 걸린 시간
−
보이는 경우에만 렌더링: IntersectionObserver가 뷰포트 근처의 게시물만 채운다 — 얼마나 쌓였든 빠르게 돌아온다
이 접근법이 지연하는 것은 렌더링만이 아닙니다. 화면 밖 게시물은 DOM 자체가 만들어지지 않으므로, 이를 만들고 유지하는 비용도 다른 모든 것과 함께 지연됩니다. 위젯에 무거운 초기화가 있다면 이 초기화도 위젯이 화면 가까이 올 때까지 기다릴 수 있습니다. 비슷한 효과를 한 줄로 노리는 CSS 속성 content-visibility: auto도 있습니다. 다만 이 글을 쓰는 시점에는 엔진별 구현이 다르고 Safari에는 페이지로 돌아오는 속도를 빠르게 하는 대신 느리게 만드는 성능 버그가 있으므로, 현재로서는 IntersectionObserver가 어디서나 예측 가능하게 동작하는 선택지입니다.
캐러셀, 애니메이션 프로모션 배너, 실시간 차트처럼 계속 실행되는 작업은 화면 밖에 있을 때 순수한 낭비입니다. 아무도 볼 수 없는 그림을 다시 그리는 데 매 프레임 메인 스레드 시간을 쓰고 있는 것입니다. 보일 때 실행하고 떠나면 멈추세요. 이 글의 수십 개 데모가 한 페이지에서 함께 존재할 수 있는 이유도 이것입니다. 각각 화면 밖으로 스크롤되면 멈추도록 만들어졌습니다.
지금까지는 메인 스레드를 사용하되 신중하게 사용하는 방법이었습니다. 두 번째 계열의 기법은 애초에 작업을 메인 스레드에서 하지 않는 것에 관한 것입니다.
긴 작업 데모에서 남겨 둔 질문으로 돌아가 봅시다. 메인 스레드는 완전히 막혔는데 CSS 애니메이션은 왜 아무 일 없었다는 듯 계속 실행됐을까요? 답은 그 애니메이션이 처음부터 메인 스레드에서 실행되지 않았기 때문입니다. 브라우저 내부에서는 여러 스레드가 작업을 나눕니다. 주목할 만한 스레드는 다음과 같습니다.
안타깝게도 이 스레드들을 마음대로 제어할 수는 없습니다. 컴포지터와 래스터 스레드는 브라우저가 자체적으로 관리하는 영역이라 직접 명령할 수 없고, 우리가 만들 수 있는 워커 스레드는 DOM 접근이 불가능하다는 큰 제약이 있습니다.
따라서 메인 스레드를 “사용하지 않는다”는 말은 임의의 작업을 다른 곳으로 보내는 뜻이 아닙니다. 다른 스레드가 처리할 수 있는 형태로 만들 수 있는 작업을 가려내고 그것을 보내는 것입니다. 크게 두 가지 방법이 있습니다.
컴포지터 스레드가 CSS 애니메이션이 멈추지 않았던 이유입니다. transform과 opacity는 문서 안에서 요소의 위치, 크기, 색을 바꾸지 않습니다. 이미 페인트된 레이어를 움직이거나 투명도를 조절하므로 레이아웃이나 페인트를 다시 할 필요가 없습니다. 이 덕분에 브라우저는 메인 스레드를 거치지 않고 컴포지터 스레드에서 직접 처리할 수 있습니다. 메인 스레드가 바빠도 컴포지터는 별도로 실행되므로 애니메이션은 부드럽게 유지됩니다.
대신 top, left, width, height 같은 속성으로 요소를 움직이면 매 프레임 레이아웃을 다시 계산해야 하며, 이는 메인 스레드 작업입니다. 아래 데모에서 두 상자는 같은 방식으로 좌우로 움직이지만 하나는 transform으로, 다른 하나는 left로 이동합니다. 버튼을 눌러 메인 스레드에 부하를 주세요.
transform: translateX
· 컴포지터
left
· 메인 스레드(레이아웃 유발)
메인 스레드가 바빠지면 left로 움직이는 아래 상자만 버벅이기 시작합니다. 위의 transform 상자는 컴포지터가 구동하므로 부하와 관계없이 부드럽습니다. 같은 “옆으로 이동”도 어떤 속성을 애니메이션하느냐에 따라 완전히 다른 스레드에서 처리됩니다. 그래서 left 대신 transform: translate로 요소를 움직이고, width 대신 transform: scale로 크기를 바꾸는 애니메이션이 성능에 더 좋습니다.
하지만 실제로 레이아웃이 바뀌어야 하는 애니메이션은 어떨까요? 항목 하나를 삭제하면 아래 항목들이 부드럽게 위로 올라와 자리를 채우는 목록을 생각해 봅시다. 이는 장식적인 움직임이 아닙니다. 위치가 실제로 바뀝니다. 하지만 top을 애니메이션하면 매 프레임 레이아웃이 발생합니다. 이 딜레마를 푸는 기법이 FLIP(First, Last, Invert, Play)6입니다. 요약하면 레이아웃 변경은 정확히 한 번만 일으키고, 이동 전체를 transform에 맡기는 것입니다. 순서는 다음과 같습니다.
const first = el.getBoundingClientRect(); // First: 현재 위치
list.prepend(el); // 유일한 레이아웃 변경
const last = el.getBoundingClientRect(); // Last: 최종 위치
const dx = first.left - last.left;
const dy = first.top - last.top;
// Invert: 이전 위치로 돌아간 것처럼 보이게 함 → Play: 제자리로 풀어 준다
el.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], {
duration: 300,
easing: 'ease-in-out',
});
사용자 눈에는 요소가 이전 자리에서 새 자리로 미끄러져 가는 것처럼 보이지만, 실제로 요소는 이미 새 자리에 도착해 있고 transform이 잠시 이를 이전 위치로 끌어당겼다가 제자리로 풀어 주는 것입니다. 애니메이션이 실행되는 동안 프레임마다 하는 일은 컴포지터가 transform을 보간하는 것뿐입니다. 대부분의 목록 재정렬 애니메이션은 이 방식으로 만들어지며 Vue의 TransitionGroup과 Framer Motion의 레이아웃 애니메이션도 내부적으로 FLIP을 사용합니다.
아래 데모는 차이를 한눈에 보여 줍니다. “Play rank shuffle”을 누르면 두 순위 목록이 같은 방식으로 재정렬됩니다. 왼쪽 목록은 transition으로 top을 애니메이션하고, 오른쪽 목록은 FLIP으로 transform만 움직입니다. 부하가 없을 때는 둘 다 부드러워 보입니다. 이제 “Load the main thread”를 켜고 다시 실행해 보세요. 왼쪽 목록은 버벅이며 끝까지 가지만 오른쪽 목록은 부하 속에서도 부드럽게 유지됩니다.
같은 순위 섞기가 두 모드에서 동시에 실행됩니다
top transition
· 메인 스레드
1 Wireless earbuds
2 Camping chair
3 Running shoes
4 Dehumidifier
5 Standing desk
6 Protein shake
FLIP
· 컴포지터
1 Wireless earbuds
2 Camping chair
3 Running shoes
4 Dehumidifier
5 Standing desk
6 Protein shake
마지막으로 컴포지터에 작업을 넘기기 전에 알아둘 두 가지가 있습니다. 하나는 will-change: transform입니다. 이는 “이 요소가 곧 바뀔 테니 미리 별도 레이어로 준비해 달라”는 힌트를 브라우저에 주며 애니메이션 시작을 부드럽게 할 수 있습니다. 하지만 남용하면 레이어 수가 폭증하고 메모리가 낭비됩니다.
다른 하나는 방금 FLIP 코드에서 본 레이아웃 값 읽기입니다. 레이아웃 값을 읽는 코드(getBoundingClientRect, offsetWidth)와 스타일을 쓰는 코드 사이의 순서를 잘못 잡으면 레이아웃 스래싱이라는 문제에 부딪힙니다.
// 🔴 읽기와 쓰기가 섞여 있다 — 반복마다 레이아웃 재계산을 강제한다
for (const el of elements) {
const width = el.offsetWidth; // 읽기(레이아웃 필요)
el.style.width = width + 10 + 'px'; // 쓰기(레이아웃 무효화)
}
// 🟢 모든 읽기를 끝낸 뒤 쓰기를 함께 처리한다
const widths = elements.map((el) => el.offsetWidth); // 읽기 수집
elements.forEach((el, i) => {
el.style.width = widths[i] + 10 + 'px'; // 쓰기 수집
});
레이아웃 값을 바꾼 직후 이를 읽으면 브라우저는 최신 답을 주기 위해 그 자리에서 레이아웃을 다시 계산할 수밖에 없습니다. 이것이 반복문 안에서 일어나면 한 프레임에 레이아웃이 수십 번 실행되어 메인 스레드가 느려집니다. 읽기는 읽기끼리, 쓰기는 쓰기끼리 묶는 습관만으로도 이를 피할 수 있습니다.
그렇다면 transform으로 다시 표현할 수 없는 무거운 작업은 어떨까요? 큰 페이로드 파싱, 이미지 처리, 복잡한 계산은 어떻게 할까요? 그런 순수 계산은 웹 워커로 통째로 메인 스레드 밖에 보낼 수 있습니다.
워커는 메인 스레드와 완전히 분리된 별도 스레드에서 JavaScript를 실행합니다. 무거운 계산을 워커에 맡기면 그동안 메인 스레드는 UI 반응성을 유지하는 데만 집중할 수 있습니다.
// 메인 스레드
const worker = new Worker('parser.js');
worker.postMessage(hugeRawData);
worker.onmessage = (e) => {
render(e.data); // 결과만 받아 화면에 표시한다
};
물론 공짜는 아닙니다. 앞서 보았듯 워커는 DOM에 접근할 수 없으므로 화면을 직접 만질 수 없습니다. 계산한 뒤 결과를 메인 스레드로 보내기만 할 수 있습니다. 또한 메인 스레드와 워커는 데이터를 복사(직렬화)하는 postMessage로만 통신하므로 오가는 데이터가 크면 그 비용도 상당합니다.
따라서 워커는 만능 해결책이 아닙니다. 통신 비용을 상쇄할 만큼 계산이 무겁고 DOM과 무관할 때 빛을 발합니다. 짧고 가벼운 작업을 워커로 보내면 통신 비용이 계산 비용보다 커져 손해를 봅니다. 핵심은 매번 이 작업이 정말 메인 스레드에서 실행되어야 하는지 묻는 것입니다.
말만으로는 부족하니 진짜 무거운 이미지 처리를 가져와 봅시다. 심 카빙은 사진에서 에너지가 가장 낮은(색 변화가 가장 적은) 세로 경로를 찾아 한 경로씩 제거하여, 중요한 피사체를 보존하면서 이미지 폭을 줄이는 알고리즘입니다7. 심 하나를 제거하려면 수십만 픽셀을 훑어야 하므로 약 250개 심을 제거하면 수억 번의 연산이 됩니다. 이를 메인 스레드에서 실행하면 페이지 전체가 멈춥니다. 하지만 워커로 보내면 계산이 실행되는 내내 화면은 반응성을 유지할 수 있습니다.
아래 데모에서 “Run on main thread”를 누르세요. 계산이 실행되는 1~2초 동안 페이지 전체가 멈추고, 끝난 뒤에야 결과가 한꺼번에 나타납니다. 페인트가 끼어들 작업 경계가 없으므로 원한다 해도 중간 단계를 보여줄 수 없습니다. 이제 “Run in worker”로 바꿔 보세요. 똑같은 계산이 실행되는 동안 이미지 폭이 실시간으로 줄어드는 모습을 볼 수 있습니다.
심 카빙 — 중요한 피사체를 유지하며 사진 너비를 30% 줄이기 60 fps
사진 불러오는 중…
부드러운 중간 프레임 뒤에는 한 가지가 더 있습니다. 워커가 프레임을 보낼 때마다 수 메가바이트 픽셀 버퍼를 복사했다면 그 비용도 쌓였을 것입니다. 그래서 postMessage는 데이터 복사 대신 소유권 자체를 이전하는 대안을 제공합니다. ArrayBuffer 같은 전송 가능 객체는 참조만 이동하므로 크기와 관계없이 비용이 거의 0에 가깝습니다. 버퍼를 넘긴 쪽은 더 이상 사용할 수 없지만 그 대가로 복사 비용이 사라집니다.
// 복사 없이 픽셀 버퍼를 워커에 넘긴다
// 전송 뒤 이쪽에서는 더 이상 사용할 수 없다
worker.postMessage({ buf: pixels.buffer, width, height }, [pixels.buffer]);
지금까지는 어떤 작업이 정말 메인 스레드에서 실행되어야 하는지 물었습니다. 이번에는 그 작업이 애초에 일어나야 하는지 물어봅시다. 성능에 가장 좋은 것은 처음부터 작업하지 않는 것입니다.
앞서 배압이 나왔습니다. 아무리 잘 배치해도 유입량이 최대 처리량을 넘으면 백로그는 끝없이 늘어납니다. 안타깝게도 브라우저에는 서버에 속도를 늦추라고 말할 좋은 방법이 없습니다. 어느 순간에는 주어진 모든 것을 처리한다는 생각을 포기해야 합니다. 일반적으로 작업을 없애는 방법은 세 가지입니다.
작업을 없애는 세 가지 방법
첫 번째는 버리기입니다. 실시간 로그처럼 그저 흘러가는 데이터는 처리가 뒤처지기 시작하면 가장 오래된 항목을 조용히 버려도 사용자는 알아차리지 못합니다. 모든 것을 보여주는 것보다 현재를 따라가는 것이 중요합니다.
두 번째는 병합입니다. 순위처럼 최신 값만 의미 있는 데이터는 밀린 갱신을 병합하여 최종 값만 적용할 수 있습니다. 병합을 사용하면 유입이 아무리 빨라져도 작업량은 화면이 소화할 수 있는 수준에 고정됩니다.
세 번째 건너뛰기는 들어오는 작업이 아니라 반복 작업을 대상으로 합니다. 같은 입력에 같은 결과를 내는 계산이라면 두 번 할 이유가 없습니다. 결과를 기억했다가 재사용하는 것을 메모이제이션이라고 합니다.
이 생각은 글 전체에 숨어 있었습니다. 디바운스는 타이핑 중 실행을 건너뛰었고, 피드 데모는 보이지 않는 게시물의 렌더링을 건너뛰었습니다. 이런 관점에서 보면 이 글의 절반은 작업을 없애는 방법에 관한 내용이었습니다.
최적화를 공부하면 일을 잘하는 방법에 주의가 쏠리기 쉽지만, 가장 큰 이득은 보통 작업을 제거하는 데서 옵니다. 어떤 작업을 더 빠르게 만들기 전에 먼저 생각해 보세요. 이 작업은 정말 지금, 여기서, 반드시 일어나야 하나요?
일부 개발자는 프론트엔드 작업을 쉬운 종류의 일이라고 생각합니다. 하지만 브라우저는 우리가 가정하는 것보다 훨씬 복잡한 시스템입니다. HTML, CSS, JavaScript로 화면을 그리는 것이 전부가 아닙니다.
스트리밍 플랫폼처럼 실시간 데이터가 쏟아지는 앱이나 이미지 편집기, 지도, 게임처럼 화면이 계속 바뀌는 앱은 메인 스레드가 바빠질 때마다 느리게 느껴지기 시작합니다. 모두가 최신 하드웨어를 쓰는 것은 아니므로 이런 서비스에서 최적화는 필수입니다. 그리고 이 문제를 풀려면 코드 최적화만으로는 부족합니다. 브라우저가 작동하는 방식을 이해하고, 메인 스레드 시간을 아껴 쓰며, 애초에 필요 없는 작업을 하지 않아야 합니다.
결국 충분히 깊이 파고들면 쉬운 것은 없습니다. 개발의 많은 부분은 트레이드오프이며, 상황에 따라 선택해야 하고 이는 궁극적으로 개발자의 경험과 판단에 달려 있습니다. 둘 다 빨리 만들어지지는 않지만, 공부와 실험을 통해 분명히 쌓을 수 있습니다. 이 글이 그 과정에 조금이나마 도움이 되기를 바랍니다.