프론트엔드 최적화라고 하면 무엇이 떠오르는가? 대체로 네트워크 요청을 줄이거나 번들 크기를 줄이고 캐시를 잘 쓰는 일을 떠올린다. 그 외에는 리렌더링 줄이기나 리소스를 불러오는 타이밍쯤이 떠오를 것이다. 보통 메인 스레드를 떠올리기는 쉽지 않은데 대부분의 화면에서는 실제로 문제가 되는 일이 드물기 때문이다. 하지만 실시간으로 데이터가 쏟아지거나 스크롤과 애니메이션과 입력이 뒤엉키는 인터랙션 많은 화면에서는 이야기가 달라진다. 아무리 네트워크를 아끼고 번들을 줄여도 메인 스레드가 막히는 순간 화면은 멈춘다.
가끔 스크롤이 뚝뚝 끊기거나 버튼을 눌렀는데 살짝 늦게 반응하고 검색창에 글자를 치면 글자가 반박자 늦게 나타나는 웹사이트를 본 적이 있을 것이다. 짜증 날 정도는 아닌데 묘하게 신경을 거슬리게 하는 이런 버벅임이 바로 메인 스레드가 막혀서 생기는 현상이다.
개발자로서 이런 버벅임을 만나면 흔히 “코드가 느린가?”라고 생각하며 알고리즘을 뜯어보거나 불필요한 연산을 찾기 시작한다. 그런데 대부분의 경우 문제는 코드의 속도 자체가 아니다. 코드가 느려서가 아니라 그 코드가 하필 메인 스레드를 붙잡고 있어서 문제가 된다.
브라우저엔 여러 스레드가 존재하지만 우리가 코드로 건드릴 수 있는 거의 대부분은 메인 스레드에 몰려 있다. 연산, 렌더링, 이벤트 처리, 네트워크 응답 처리, 프레임워크의 내부 동작까지 모두 메인 스레드가 처리한다. 자원은 하나인데 할 일은 산더미다.
브라우저의 메인 스레드는 비싸다. 평소에는 문제가 안 되지만 뭘 좀 해보려 하면 이 메인 스레드 문제를 해결하는 것이 중요해진다. 이 글에서는 왜 메인 스레드라는 비싼 자원을 다루는 방법에 대해 이야기해볼 것이다.
메인 스레드는 무슨 일을 하나
먼저 메인 스레드가 정확히 무슨 일을 하는지부터 살펴보자. 메인 스레드가 하는 일은 크게 두 종류로 나눌 수 있다.
첫 번째는 자바스크립트 실행이다. 우리가 짠 코드는 물론이고 이벤트 핸들러, 타이머, 네트워크 응답 콜백, 프레임워크의 내부 동작까지 모든 자바스크립트가 여기서 돈다. 이 작업들은 화면 갱신 주기와 무관하게 큐에 들어온 순서대로 틈나는 대로 실행된다.
두 번째는 화면 그리기다. DOM이나 스타일이 바뀌어 화면을 갱신할 일이 생기면 브라우저는 대략 다음 단계를 순서대로 거쳐 한 프레임을 만든다.
requestAnimationFrame콜백 실행 - 프레임을 그리기 직전에 실행하기로 등록해둔 자바스크립트- 스타일 계산 - 각 요소에 적용될 최종 CSS 값을 계산
- 레이아웃 - 각 요소의 위치와 크기를 계산 (리플로우reflow라고도 부름)
- 페인트 - 무엇을 어떤 색으로 그릴지 페인트 명령을 만든다
바뀐 것이 없으면 이 단계들은 통째로 건너뛰므로 매 프레임 반드시 도는 것은 아니다. 그리고 이렇게 만들어진 결과를 넘겨받아 화면에 합치는 합성 단계만이 컴포지터 스레드1로 넘어간다. 즉, 화면을 그리는 파이프라인의 앞쪽 대부분이 메인 스레드의 몫이다.
화면이 부드럽게 보이려면 디스플레이의 주사율만큼 프레임을 그려야 한다. 가장 흔한 60Hz 디스플레이 기준으로 초당 60프레임, 즉 한 프레임에 주어진 시간이 약 16.6밀리초다. 이마저도 통째로 쓸 수 있는 것이 아니다. 브라우저 자체의 처리 비용을 빼면 실제 가용 예산은 10밀리초 남짓으로 보는 것이 보통이고2, 주사율이 120Hz인 기기라면 예산 자체가 절반으로 줄어든다.
문제는 앞서 본 두 종류의 일이 같은 스레드에서 한 줄로 선다는 것이다. 자바스크립트는 단일 스레드 이벤트 루프 모델로 설계되었다. 메인 스레드는 한 번에 하나의 작업만 처리하며 그 작업이 실행되는 동안에는 다른 어떤 것도 할 수 없다. 만약 자바스크립트 함수 하나가 200밀리초 동안 돌고 있다면 200밀리초 동안 브라우저는 화면을 다시 그리거나 사용자의 클릭을 받지 못한다. 10밀리초 남짓의 프레임 예산 앞에서는 아주 치명적인 시간이다. 이렇게 오래 실행되어 메인 스레드를 붙잡는 작업을 롱 태스크라 부르며 보통 50밀리초를 넘기면 문제로 간주한다.
말로만 들으면 와닿지 않으니 직접 느껴보자. 아래 데모에서 버튼을 누르면 자바스크립트가 잠시 메인 스레드를 붙잡는다.
버튼을 누른 순간 JS 애니메이션은 멈추고 입력창에 글자를 쳐도 반응이 없다. 반면 CSS 애니메이션은 계속 돈다. 이 차이가 어디서 오는지는 뒤에서 다시 이야기하겠다. 지금 기억해야 할 것은 메인 스레드를 오래 붙잡는 것은 곧 화면을 멈추는 것과 같다는 사실이다.
이러한 점은 웹 성능 지표와도 곧바로 연결된다. 사용자가 무언가를 눌렀을 때 화면이 반응하기까지의 시간을 재는 INPInteraction to Next Paint나 페이지 로딩 중 메인 스레드가 막혀 있던 총 시간을 재는 TBTTotal Blocking Time 같은 지표들은 결국 “메인 스레드가 얼마나 오래 막혀 있었는가”를 표현한 것에 가깝다. 성능 최적화란 상당 부분 이 하나의 스레드를 어떻게 아껴 쓰느냐의 문제인 셈이다.
그리고 그 방법은 크게 둘로 나뉜다. 하나는 메인 스레드 안에서 시간을 잘 나눠 쓰는 것이고 다른 하나는 아예 일을 메인 스레드 바깥으로 내보내는 것이다. 차례대로 살펴보자.
비싼 자원을 아껴 쓰기
첫 번째는 메인 스레드를 쓰면서 시간을 영리하게 쓰는 것이다. 핵심은 네 가지다.
- 너무 긴 작업을 어떻게 잘게 나눌 것인가
- 너무 잦은 작업을 어떻게 묶을 것인가
- 여러 작업 중 무엇을 먼저 할 것인가
- 지금 꼭 안 해도 되는 작업을 어떻게 미룰 것인가
여기서는 각각 쪼개기, 모으기, 우선순위, 미루기라고 부를 것이다. 앞의 둘은 태스크의 크기를 다듬는 일이고 뒤의 둘은 실행의 시기를 정하는 일이다. 그리고 이 중 쪼개기가 나머지의 기반이 된다. 태스크에 경계가 생겨야 비로소 그 사이에 무엇을 먼저 넣고 무엇을 뒤로 보낼지도 정할 수 있기 때문이다. 그래서 쪼개기부터 시작한다.
쪼개기
라이브 스트리밍 방송의 채팅창을 떠올려보자. 인기 방송에서는 채팅이 순간적으로 초당 수백 개씩 쏟아질 때도 있다. 이런 환경에서 채팅은 하나씩 얌전히 도착하지 않는다. 트래픽이 몰리면 서버가 수십 개씩 뭉텅이로 보내기도 하고 방에 입장하는 순간에는 밀린 채팅 수백 개가 한꺼번에 내려오기도 한다. 이 덩어리를 받은 즉시 한 호흡에 전부 그린다면 어떻게 될까? 채팅 하나를 그릴 때마다 DOM 생성과 스타일 계산, 레이아웃, 페인트가 따라붙는데 그 수백 번이 하나의 태스크 안에서 연속으로 실행된다. 그동안 사용자가 자기 채팅을 입력하려 해도 입력창은 버벅이고 화면의 다른 애니메이션도 끊긴다. 쏟아지는 남의 채팅이 메인 스레드를 독차지해 내 입력을 방해하는 셈이다.
해법은 앞서 이야기한 그대로다. 이 덩어리를 작은 조각으로 나누고 조각과 조각 사이에서 메인 스레드에 잠깐씩 제어권을 돌려주면 된다. 그 틈에 브라우저는 밀려 있던 화면 갱신과 입력 처리를 해낼 수 있다.
아래 예제는 스트리밍 방송의 채팅창을 시뮬레이션한 것이다. “채팅 쏟아지게 하기”를 누르면 채팅이 쉴 새 없이 올라온다. 이때 입력창에 직접 타이핑해보고 위쪽의 부드러움 지표와 fps를 지켜보면서 “즉시 렌더”와 “양보 렌더” 모드를 각각 확인해보자.
“즉시 렌더” 모드에서는 채팅이 도착할 때마다 곧바로 DOM을 건드리기 때문에 채팅이 쏟아지는 동안 fps가 뚝 떨어지고 지표가 멈칫거리며 입력창도 버벅인다. 잘 보면 채팅이 올라오는 것 자체도 눈에 띄게 느려지는데, 채팅을 받아 처리하는 콜백 역시 메인 스레드에 줄을 서는 태스크라서 함께 밀리기 때문이다. 이제 “양보 렌더” 모드로 바꿔보자. 채팅을 그리는 방식은 한 개씩 그대로인데도 입력이 살아나고 화면이 다시 움직인다. 바뀐 것은 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이다. 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밀리초인지 궁금할 수 있다. 딱히 5밀리초라는 숫자에 특별한 근거가 있는 것은 아니다. 앞서 실제 가용 예산을 10밀리초 남짓이라 했으니 그 절반쯤을 배경 작업에 떼어주고 나머지는 애니메이션 콜백과 스타일, 레이아웃, 페인트의 몫으로 남겨두는 적당한 휴리스틱 값이다. 만약 애니메이션이 무겁다면 더 줄이면 된다.
이렇게 하면 무거운 작업이 진행되는 동안에도 매 프레임 화면을 그릴 여유가 남아 작업과 애니메이션이 함께 부드럽게 돌아간다. 아래 예제를 살펴보자. 마우스를 움직이면 4,000개의 파티클이 커서를 피해 흩어지는데 가까운 파티클끼리도 서로 밀어내도록 되어 있어 한 파티클의 방향을 정하려면 다른 모든 파티클과의 거리를 확인해야 한다. 한 바퀴에 약 1,600만 번의 거리 계산이 필요한 셈이라 매 프레임 전부 다시 계산하면 그것만으로 프레임 예산을 훌쩍 넘긴다. 예제에서 “통째로 계산” 모드와 “프레임당 5ms씩” 모드의 차이점을 확인해보자.
이처럼 쪼개기는 메인 스레드의 시간을 아껴 쓰는 가장 기본적인 방법이다. 이를 통해 사용자에게 빠른 반응과 부드러운 화면이라는 체감 성능을 제공할 수 있다.
마지막으로 유의할 점들에 대해 알아보자. 먼저 너무 잘게 쪼개면 오히려 손해다. 양보하고 다시 돌아오는 데에도 비용이 들기 때문에 조각이 지나치게 작으면 그 오버헤드가 정작 처리하려는 작업보다 커질 수 있다.
그리고 setTimeout으로 양보하면 최소 지연 시간3이 있어 조각마다 몇 밀리초씩 헛되이 기다리게 될 수 있다. 보통 이러한 점이 문제가 되지 않지만, 정말 고도의 반응성을 요구하는 상황에서는 이 지연이 문제될 수 있다.
그래서 MessageChannel로 메시지를 보내 다음 태스크를 예약하는 기법을 사용하는 경우도 있다. React 스케줄러가 이 방법을 사용한다. 그리고 최근에는 이 문제를 해결하기 위한 scheduler.yield()라는 표준 API도 등장했다. scheduler.yield()는 양보 후에도 원래 작업이 다른 태스크들에게 밀리지 않고 우선적으로 재개된다는 장점이 있다. 다만 아직 브라우저 지원이 고르지 않다는 문제가 있다.
다음으로 양보 도구마다 다시 돌아오는 시점이 다르다. setTimeout이나 scheduler.yield()의 재개 시점은 렌더링 주기와 무관한 반면 requestAnimationFrame은 프레임을 그리기 직전에 맞춰 재개되므로 화면 갱신에 리듬을 맞춰야 하는 작업이라면 requestAnimationFrame이 더 적합하다. 조금 더 세밀하게 우선순위를 조정하고 싶다면 MessageChannel로 직접 큐를 만들어 양보하고 재개하는 방법도 있다.
마지막으로 쪼개기가 언제나 가능한 것은 아니다. 예를 들어 JSON.parse로 수 MB짜리 응답을 파싱하는 일은 하나의 원자적인 동기 호출이라 중간에 끊어 양보할 방법이 없다. 파싱이 끝날 때까지 메인 스레드는 통째로 멈춘다. 이렇게 쪼갤 수 없는 무거운 작업은 “아껴 쓰기”의 명백한 한계다. 이럴 때는 발상을 바꿔 아예 메인 스레드에서 하지 않아야 한다. 그 이야기는 “비싼 자원을 안 쓰기”에서 다룬다.
모으기
그렇지만 무작정 쪼갠다고 모든 문제가 해결되는 것은 아니다. 앞서 스트리밍 채팅 예제를 다시 떠올려보자. 양보하면 입력과 화면을 살려냈지만 채팅을 그리는 속도 자체를 올려주지는 못한다. 오히려 양보에 드는 오버헤드만큼 시간당 그릴 수 있는 채팅 수인 처리량은 줄어든다. 그런데 만약 채팅이 처리량보다 빠르게 쏟아진다면 어떻게 될까? 도착하는 채팅이 처리량을 앞질러 점점 쌓이고 화면에 올라오는 채팅은 점점 옛날 것이 된다. 이런 상황을 배압이라 한다.
처리량을 올리려면 쪼개기가 아닌 다른 방법이 필요하다. 예를 들어, 채팅을 하나씩 그리는 대신 쌓인 채팅을 묶어 한 번에 그리면 개당 고정 비용이 접히면서 같은 시간에 더 많은 채팅을 그릴 수 있다. 쪼개라더니 이제 모으라니 모순처럼 들리지만 렌더링이 끼어들 틈을 주지 않는 너무 긴 태스크 혹은 파이프라인 비용을 횟수만큼 반복시키는 너무 잦은 태스크를 적정한 크기로 다듬는 것이 핵심이다.
모으기에 가장 좋은 타깃은 이벤트다. 스크롤이나 리사이즈, 입력 이벤트는 짧은 시간에 수십, 수백 번씩 발생할 수 있다. 만약 이 모든 이벤트마다 무거운 핸들러를 돌리면 메인 스레드가 남아나지 않는다. 그래서 “일정 시간 잠잠해지면 한 번만 실행”하거나 “일정 간격으로 한 번씩만 실행”하도록 여러 번의 이벤트를 한 번의 실행으로 모으는 방법을 사용할 수 있다. 이를 각각 디바운스debounce와 스로틀throttle이라 부른다.
아래 데모는 긴 CHANGELOG를 열어둔 마크다운 에디터다. 미리보기를 만들려면 문서 전체(약 2,000줄)를 파싱해 DOM을 통째로 다시 만들어야 하는데 이를 키 입력마다 실행하기엔 굉장히 비용이 크다. “디바운스 없음”으로 왼쪽 편집기에 빠르게 타이핑해보자. 글자 수만큼 미리보기가 다시 만들어지며 입력이 밀린다. “디바운스 300ms”로 바꾸면 타이핑이 멈춘 뒤 딱 한 번만 렌더돼 입력이 매끄러워진다.
시각적인 갱신이 필요하다면 requestAnimationFrame을 이용할 수 있다. 화면은 어차피 프레임당 한 번만 그려지므로 갱신 요청이 아무리 쏟아져도 실제 그리기는 프레임당 한 번이면 충분하다.
let scheduled = false;
socket.on('tick', (tick) => {
chart.push(tick); // 데이터는 하나도 버리지 않고 쌓아둔다
if (scheduled) return; // 이번 프레임의 그리기는 이미 예약되어 있다
scheduled = true;
requestAnimationFrame(() => {
renderBoard(); // 그리기는 프레임당 한 번
scheduled = false;
});
});
아래 데모는 초당 1,000건이 넘는 메시지로 60개 종목의 보드를 갱신한다. “틱마다 그리기” 모드는 메시지가 올 때마다 보드 전체를 다시 그리는데 차트 라이브러리의 update()를 메시지마다 호출하는 흔한 실수가 정확히 이 모습이다. “프레임당 한 번”으로 바꾸면 도착한 데이터를 똑같이 전부 반영하면서도 fps가 살아난다.
DOM 쓰기도 모을 수 있다. 노드 백 개를 하나씩 붙이는 대신 모아서 한 번에 붙이거나 스타일을 속성 하나씩 고치는 대신 클래스 하나를 토글해 변경을 한 번으로 만드는 것도 성능 개선에 도움이 된다. HTML 문자열을 조립해 innerHTML에 한 번에 할당하는 오래된 기법의 본질도 같다. 쓰기를 한 번으로 모아서 렌더링 파이프라인의 고정 비용을 한 번으로 줄이는 것이다.
이런 최적화는 프론트엔드 개발자에겐 익숙한 패턴인데 React의 가상 DOM부터가 최적화를 위한 장치다. 가상 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장을 첨부한 상황이고, 각 미리보기는 픽셀 단위 필터링으로 실제로 생성한다. 미리보기가 차례로 만들어지는 동안 아직 준비되지 않은 회색 타일을 클릭해보자. “순서대로” 모드에서는 그 타일의 차례가 올 때까지 기다려야 하지만 “클릭한 것 먼저” 모드에서는 큐를 건너뛰어 곧바로 채워진다. 처리하는 총량은 같고 순서만 바꿨을 뿐인데 사용자가 느끼는 체감이 완전히 달라진다.
우선순위란 결국 “지금 이 순간 사용자에게 가장 중요한 것이 무엇인가”를 따지는 문제라고 할 수 있다.
최신 브라우저에선 이에 대해 Scheduler API, TaskController와 같은 표준을 제공한다. 다만, 아직 지원되지 않는 브라우저가 있어 폴리필을 함께 쓰거나 직접 큐를 만들어 쓰는 경우가 많다. 이 글에서는 직접 큐를 만들어 우선순위를 다루는 방법을 보여주었다.
미루기
마지막으로 가장 확실하게 메인 스레드를 아끼는 방법은 지금 하지 않아도 되는 일을 지금 하지 않는 것이다. 쪼개기와 모으기가 “어떤 크기로 할까”, 우선순위가 “어떤 순서로 할까”의 문제라면 미루기는 “지금 이 작업을 꼭 해야 하나”를 되묻는 것이다.
초기 로딩은 미루기가 활약하는 대표적인 방법이다. 처음부터 모든 자바스크립트를 내려받아 실행할 필요는 없다. 코드 스플리팅으로 지금 화면에 필요한 코드만 먼저 실행하고 나머지는 필요해질 때 불러오면 메인 스레드가 초기부터 느려지는 것을 막을 수 있다.
렌더링 자체를 미룰 수도 있다. SNS 피드를 생각해보자. 한참 스크롤해 게시물 수백 개가 쌓인 채로 알림 탭에 다녀오면 돌아오는 순간 화면이 잠깐 얼어붙는 앱들이 있다. 탭을 오갈 때 피드의 DOM을 살려두더라도 다시 보이는 순간 브라우저가 보이지도 않는 게시물까지 수백 개 전체의 스타일과 레이아웃을 한꺼번에 다시 계산하기 때문이다. 그렇다면 화면 밖 게시물은 높이만 차지하는 빈 껍데기로 두고, 화면에 가까워지는 순간에 실제 콘텐츠를 채우면 어떨까. 그 “가까워지는 순간”을 알려주는 도구가 IntersectionObserver다. 이미지 lazy 로딩 라이브러리들이 쓰는 방법이 바로 이것이다.
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가 쌓인 피드다. 처음엔 “보일 때만 렌더”가 켜져 있다. 알림 탭에 다녀와 보면 쌓인 양과 무관하게 즉시 돌아온다. 이제 “그대로 렌더”로 바꿔 다시 다녀와 보자. 돌아올 때마다 1,500개 전체를 다시 레이아웃하느라 복귀가 수백 밀리초씩 얼어붙는다. 빠르게 스크롤할 때 아직 채워지지 않은 자리가 잠깐 비쳐 보이는 것 말고는 두 모드의 화면은 같다.
이 방식이 미뤄주는 것은 렌더링만이 아니다. 화면 밖 게시물은 DOM 자체를 만들지 않으므로 그것을 만들고 유지하는 비용까지 함께 미뤄진다. 무거운 초기화가 딸린 위젯이라면 그 초기화도 화면에 가까워질 때까지 미룰 수 있다. 비슷한 효과를 CSS 한 줄로 노리는 content-visibility: auto라는 속성도 있다. 다만 이 글을 쓰는 시점에는 엔진별 구현 편차가 있어 사파리에서는 오히려 복귀가 느려지는 성능 버그가 있으므로, 아직은 IntersectionObserver 쪽이 어디서나 예측 가능하게 동작한다.
캐러셀, 움직이는 프로모션 배너, 실시간 차트처럼 계속 도는 작업도 화면 밖에서는 순수한 낭비다. 보이지도 않는 그림을 매 프레임 다시 그리며 메인 스레드를 쓰고 있는 것이다. 보일 때만 돌리고 벗어나면 멈추면 된다. 사실 이 글에 실린 십수 개의 데모가 한 페이지에서 공존할 수 있는 비결도 화면을 벗어나면 멈추도록 만들었기 때문이다.
비싼 자원을 안 쓰기
지금까지는 메인 스레드를 쓰되 아껴 쓰는 법이었다. 두 번째 방법은 애초에 그 일을 메인 스레드에서 하지 않는 것이다.
앞서 롱 태스크 데모에서 던져둔 질문으로 돌아가보자. 메인 스레드가 완전히 막혔는데도 왜 CSS 애니메이션은 태연하게 계속 돌았을까? 답은 그 애니메이션이 애초에 메인 스레드에서 돌고 있지 않았기 때문이다. 브라우저 안에서는 여러 스레드가 각자 다른 일을 나눠 맡고 있다. 대표적으로 이런 것들이 있다.
- 메인 스레드: 자바스크립트를 실행하고, DOM을 다루고, 스타일을 계산하고, 레이아웃을 잡고, 이벤트를 처리한다.
- 컴포지터 스레드: 이미 그려진 레이어들을 화면에 합성한다. 스크롤이나 일부 애니메이션을 담당한다.
- 래스터 스레드: 페인트 명령을 실제 픽셀로 변환한다.
- 워커 스레드: 우리가 명시적으로 만든 별도의 자바스크립트 실행 공간이다.
다만 안타깝게도 이 스레드들을 우리 마음대로 건드릴 수는 없다. 컴포지터와 래스터 스레드는 브라우저가 알아서 굴리는 영역이라 직접 명령을 내릴 수 없고 워커 스레드는 우리가 만들 수 있지만 DOM에 접근할 수 없다는 큰 제약이 있다.
따라서 “안 쓰기”는 아무 일이나 밖으로 내보내는 것이 아니라 메인 스레드 밖에서도 할 수 있는 형태의 일을 골라 내보내는 것이다. 여기에는 두 가지 대표적인 방법이 있다.
컴포지터에 올리기
CSS 애니메이션이 멈추지 않았던 비밀이 바로 컴포지터 스레드다. transform과 opacity는 요소의 위치나 크기, 색을 바꾸는 것이 아니라 이미 그려진 레이어를 옮기거나 투명도만 조절하는 것이기 때문에, 레이아웃과 페인트를 다시 할 필요가 없다. 그래서 브라우저는 이 작업을 메인 스레드를 거치지 않고 곧바로 컴포지터 스레드에서 처리할 수 있다. 메인 스레드가 아무리 바빠도 컴포지터 스레드는 별개로 돌기 때문에 애니메이션은 부드럽게 유지된다.
반대로 top, left, width, height 같은 속성으로 요소를 움직이면 매 프레임마다 레이아웃을 다시 계산해야 하므로 메인 스레드의 몫이다. 아래 데모에서 두 상자는 똑같이 좌우로 움직이지만, 하나는 transform으로, 다른 하나는 left로 움직인다. 버튼을 눌러 메인 스레드에 부하를 줘보자.
메인 스레드가 바빠지는 순간 left로 움직이는 아래 상자만 버벅이기 시작한다. 위의 transform 상자는 컴포지터가 굴리고 있어 부하와 무관하게 부드럽다. 같은 “옆으로 이동”이라도 어떤 속성을 쓰느냐에 따라 일이 벌어지는 스레드가 완전히 달라지는 것이다. 그래서 위치를 옮기는 애니메이션은 left가 아니라 transform: translate로, 크기를 바꾸는 애니메이션은 width 대신 transform: scale로 만드는 것이 성능에 좋다.
그런데 진짜로 레이아웃이 바뀌어야 하는 애니메이션은 어떻게 해야 할까? 목록에서 항목 하나가 삭제되어 아래 항목들이 부드럽게 당겨 올라오는 장면을 생각해보자. 이것은 장식적인 움직임이 아니라 실제 위치 변경이다. 그렇다고 top을 애니메이션하면 매 프레임이 레이아웃이다. 이 딜레마를 푸는 기법이 FLIPFirst, Last, Invert, Play이다6. 짧게 요약하자면 레이아웃 변경은 단 한 번만 일으키고 움직이는 과정은 전부 transform에게 맡긴다라고 할 수 있다. 다음 순서를 따른다.
- First: 움직이기 전 위치를 잰다
- Last: 레이아웃을 실제로 바꾸고 새 위치를 잰다. 레이아웃은 여기서 한 번만 일어난다
- Invert: 새 위치의 요소에 transform을 걸어 이전 위치에 있는 것처럼 되돌려 놓는다
- Play: 그 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의 layout 애니메이션도 내부적으로 FLIP이다.
아래 데모에서 차이를 한눈에 보자. “순위 변동 재생”을 누르면 두 랭킹 목록이 똑같이 순위를 뒤섞는다. 왼쪽은 top을 트랜지션으로 애니메이션하고 오른쪽은 FLIP으로 transform만 움직인다. 부하가 없을 때는 둘 다 매끄러워 보인다. 이제 “메인 스레드 부하 주기”를 켜고 다시 재생해보자. 왼쪽은 뚝뚝 끊기다 도착하지만 오른쪽 FLIP은 부하와 무관하게 그대로 부드럽다.