메인 스레드는 비싸다 - 프레임 예산 10밀리초를 지키는 여덟 가지 전략
60Hz에서 실제 가용 프레임 예산이 10밀리초 남짓이라는 계산에서 출발해, 메인 스레드를 아껴 쓰는 네 전략과 아예 쓰지 않는 세 전략을 데모와 코드로 정리한 글. 양보가 총 작업량을 늘리면서도 체감 성능을 올리는 이유를 파이프라인 구조로 설명한다.
메인 스레드는 비싸다
TL;DR
- 문제 인식이 통념을 교정하며 시작하는데 프론트엔드 최적화라고 하면 대체로 네트워크 요청이나 번들 크기, 캐시를 떠올리고 메인 스레드를 떠올리기는 쉽지 않다는 것이다. 그런데 진단이 정확하다. 버벅임을 만나면 코드가 느린가 생각하며 알고리즘을 뜯어보지만 대부분의 경우 문제는 코드의 속도 자체가 아니고, 코드가 느려서가 아니라 그 코드가 하필 메인 스레드를 붙잡고 있어서 문제가 된다.
- 예산이 수치로 못 박히는데 60Hz 기준 한 프레임에 주어진 시간이 약 16.6밀리초인데 브라우저 자체의 처리 비용을 빼면 실제 가용 예산은 10밀리초 남짓이고 120Hz면 절반으로 줄어든다. 그래서 임계가 명확하다. 자바스크립트 함수 하나가 200밀리초 동안 돌면 그동안 화면을 다시 그리거나 클릭을 받지 못하며, 보통 50밀리초를 넘기면 롱 태스크로 문제로 간주한다.
- 가장 반직관적인 통찰이 양보에 있는데 양보가 일을 빠르게 만들어주지는 않고 총 작업량은 그대로이며 오히려 대기 오버헤드가 생겨 실제로는 더 오래 걸린다는 것이다. 그런데도 효과가 나는 이유가 구조에 있다. 렌더링 파이프라인은 태스크 중간에 끼어들지 못하고 태스크와 태스크 사이에서만 실행될 수 있으므로, 양보란 바로 그 사이를 만들어주는 행위다.
- 아껴 쓰기가 네 갈래로 정리되는데 쪼개기·모으기·우선순위·미루기이고 앞의 둘은 크기를 다듬는 일, 뒤의 둘은 시기를 정하는 일이며 순서도 있다. 쪼개기가 나머지의 기반인데 태스크에 경계가 생겨야 비로소 그 사이에 무엇을 먼저 넣고 무엇을 뒤로 보낼지 정할 수 있기 때문이다.
- 결론이 관점을 뒤집는데 최적화를 공부하다 보면 일을 잘 처리하는 법에 눈이 가지만 가장 큰 이득은 대개 일을 지우는 데서 나온다는 것이다. 저자 스스로 글을 되짚는다. 디바운스는 타이핑 중의 실행을 생략했고 피드 데모는 보이지 않는 게시물의 렌더링을 생략했으니, 관점을 바꿔 보면 이 글의 절반은 일을 없애는 이야기였던 셈이다.
Source
브라우저의 메인 스레드는 비싸다 — 이선협(kciter), Platform Engineer, 2026년 7월 12일
Knowledge
코드가 느린 것이 아니라 자리가 문제다
글은 통념을 교정하며 출발한다. 프론트엔드 최적화라고 하면 대체로 네트워크 요청을 줄이거나 번들 크기를 줄이고 캐시를 잘 쓰는 일을 떠올린다는 것이다. 그 외에는 리렌더링 줄이기나 리소스를 불러오는 타이밍쯤이 떠오른다고 한다. 그런데 메인 스레드는 잘 떠올리지 않는데 이유가 있다. 대부분의 화면에서는 실제로 문제가 되는 일이 드물기 때문이다. 다만 조건이 달라지면 이야기가 바뀐다. 실시간으로 데이터가 쏟아지거나 스크롤과 애니메이션과 입력이 뒤엉키는 인터랙션 많은 화면에서는 아무리 네트워크를 아끼고 번들을 줄여도 메인 스레드가 막히는 순간 화면은 멈춘다는 것이다.
증상이 익숙한 경험으로 제시된다. 스크롤이 뚝뚝 끊기거나 버튼을 눌렀는데 살짝 늦게 반응하고 검색창에 글자를 치면 반박자 늦게 나타나는 웹사이트를 본 적이 있을 것이라는 것이다. 짜증 날 정도는 아닌데 묘하게 신경을 거슬리게 하는 이런 버벅임이 바로 메인 스레드가 막혀서 생기는 현상이라고 규정된다.
그리고 진단이 핵심을 찌른다. 개발자로서 이런 버벅임을 만나면 흔히 코드가 느린가 생각하며 알고리즘을 뜯어보거나 불필요한 연산을 찾기 시작한다는 것이다. 그런데 대부분의 경우 문제는 코드의 속도 자체가 아니라는 것이다. 코드가 느려서가 아니라 그 코드가 하필 메인 스레드를 붙잡고 있어서 문제가 된다는 것이다.
자원 구조도 설명된다. 브라우저엔 여러 스레드가 존재하지만 우리가 코드로 건드릴 수 있는 거의 대부분은 메인 스레드에 몰려 있다는 것이다. 연산과 렌더링, 이벤트 처리, 네트워크 응답 처리, 프레임워크의 내부 동작까지 모두 메인 스레드가 처리하므로 자원은 하나인데 할 일은 산더미라고 정리된다.
프레임 예산 10밀리초라는 계산
메인 스레드가 하는 일이 크게 두 종류로 나뉜다. 첫 번째는 자바스크립트 실행인데, 우리가 짠 코드는 물론이고 이벤트 핸들러와 타이머, 네트워크 응답 콜백, 프레임워크의 내부 동작까지 모든 자바스크립트가 여기서 돈다는 것이다. 이 작업들은 화면 갱신 주기와 무관하게 큐에 들어온 순서대로 틈나는 대로 실행된다.
두 번째는 화면 그리기다. DOM이나 스타일이 바뀌어 화면을 갱신할 일이 생기면 브라우저가 순서대로 단계를 거쳐 한 프레임을 만든다는 것이다. requestAnimationFrame 콜백 실행, 각 요소에 적용될 최종 CSS 값을 계산하는 스타일 계산, 각 요소의 위치와 크기를 계산하는 레이아웃, 그리고 무엇을 어떤 색으로 그릴지 페인트 명령을 만드는 페인트다. 조건도 붙는다. 바뀐 것이 없으면 이 단계들은 통째로 건너뛰므로 매 프레임 반드시 도는 것은 아니라는 것이다. 그리고 경계가 명시된다. 이렇게 만들어진 결과를 넘겨받아 화면에 합치는 합성 단계만이 컴포지터 스레드로 넘어가므로, 화면을 그리는 파이프라인의 앞쪽 대부분이 메인 스레드의 몫이라는 것이다.
예산이 수치로 계산된다. 화면이 부드럽게 보이려면 디스플레이의 주사율만큼 프레임을 그려야 하는데, 가장 흔한 60Hz 기준으로 초당 60프레임이므로 한 프레임에 주어진 시간이 약 16.6밀리초라는 것이다. 그런데 이마저도 통째로 쓸 수 없다. 브라우저 자체의 처리 비용을 빼면 실제 가용 예산은 10밀리초 남짓으로 보는 것이 보통이고 주사율이 120Hz인 기기라면 예산 자체가 절반으로 줄어든다는 것이다.
문제의 구조가 정리된다. 앞서 본 두 종류의 일이 같은 스레드에서 한 줄로 선다는 것이다. 자바스크립트는 단일 스레드 이벤트 루프 모델로 설계되었으므로 메인 스레드는 한 번에 하나의 작업만 처리하며 그 작업이 실행되는 동안에는 다른 어떤 것도 할 수 없다. 그래서 사례가 제시된다. 자바스크립트 함수 하나가 200밀리초 동안 돌고 있다면 200밀리초 동안 브라우저는 화면을 다시 그리거나 사용자의 클릭을 받지 못한다는 것이고 10밀리초 남짓의 프레임 예산 앞에서는 아주 치명적인 시간이라는 것이다. 이렇게 오래 실행되어 메인 스레드를 붙잡는 작업을 롱 태스크라 부르며 보통 50밀리초를 넘기면 문제로 간주한다고 한다.
데모로 체감이 확인된다. 버튼을 누른 순간 JS 애니메이션은 멈추고 입력창에 글자를 쳐도 반응이 없는데, 반면 CSS 애니메이션은 계속 돈다는 것이다. 지금 기억해야 할 것은 메인 스레드를 오래 붙잡는 것은 곧 화면을 멈추는 것과 같다는 사실이라고 정리된다.
지표와의 연결도 짚는다. 사용자가 무언가를 눌렀을 때 화면이 반응하기까지의 시간을 재는 INP나 페이지 로딩 중 메인 스레드가 막혀 있던 총 시간을 재는 TBT 같은 지표들은 결국 메인 스레드가 얼마나 오래 막혀 있었는가를 표현한 것에 가깝다는 것이다. 그래서 성능 최적화란 상당 부분 이 하나의 스레드를 어떻게 아껴 쓰느냐의 문제인 셈이라고 규정된다. 그리고 방법이 크게 둘로 나뉜다. 하나는 메인 스레드 안에서 시간을 잘 나눠 쓰는 것이고 다른 하나는 아예 일을 메인 스레드 바깥으로 내보내는 것이다.
아껴 쓰기 ① 쪼개기 — 양보가 왜 효과가 있는가
아껴 쓰기의 핵심이 네 가지 질문으로 제시된다. 너무 긴 작업을 어떻게 잘게 나눌 것인가, 너무 잦은 작업을 어떻게 묶을 것인가, 여러 작업 중 무엇을 먼저 할 것인가, 지금 꼭 안 해도 되는 작업을 어떻게 미룰 것인가다. 각각 쪼개기와 모으기, 우선순위, 미루기라고 부르며 앞의 둘은 태스크의 크기를 다듬는 일이고 뒤의 둘은 실행의 시기를 정하는 일이라고 구분된다. 그리고 순서가 정해진다. 이 중 쪼개기가 나머지의 기반이 되는데 태스크에 경계가 생겨야 비로소 그 사이에 무엇을 먼저 넣고 무엇을 뒤로 보낼지도 정할 수 있기 때문이라는 것이다.
문제 상황이 라이브 스트리밍 채팅창으로 설정된다. 인기 방송에서는 채팅이 순간적으로 초당 수백 개씩 쏟아질 때도 있는데, 트래픽이 몰리면 서버가 수십 개씩 뭉텅이로 보내기도 하고 방에 입장하는 순간에는 밀린 채팅 수백 개가 한꺼번에 내려오기도 한다는 것이다. 이 덩어리를 받은 즉시 한 호흡에 전부 그리면 채팅 하나를 그릴 때마다 DOM 생성과 스타일 계산, 레이아웃, 페인트가 따라붙고 그 수백 번이 하나의 태스크 안에서 연속으로 실행된다. 결과가 지목된다. 쏟아지는 남의 채팅이 메인 스레드를 독차지해 내 입력을 방해하는 셈이라는 것이다.
데모 결과가 대비된다. 즉시 렌더 모드에서는 채팅이 쏟아지는 동안 fps가 뚝 떨어지고 지표가 멈칫거리며 입력창도 버벅인다는 것이다. 그리고 관찰이 하나 더 붙는다. 잘 보면 채팅이 올라오는 것 자체도 눈에 띄게 느려지는데, 채팅을 받아 처리하는 콜백 역시 메인 스레드에 줄을 서는 태스크라서 함께 밀리기 때문이라는 것이다. 양보 렌더로 바꾸면 채팅을 그리는 방식은 한 개씩 그대로인데도 입력이 살아나고 화면이 다시 움직이며 바뀐 것은 20개를 그릴 때마다 메인 스레드를 잠깐 놓아준다는 것뿐이라고 한다.
가장 반직관적인 대목이 여기서 나온다. 오해하지 말아야 할 것은 양보가 일을 빠르게 만들어주지는 않는다는 점이라는 것이다. 총 작업량은 그대로이며 오히려 양보를 위해 몇 밀리초씩 기다리는 오버헤드가 생기므로 실제로는 더 오래 걸린다고 명시된다.
그런데도 렌더링까지 살아나는 이유가 구조로 설명된다. 메인 스레드는 태스크가 실행되는 동안에는 아무것도 할 수 없으며 프레임을 만드는 렌더링 파이프라인도 태스크 중간에 끼어들지 못하고 태스크와 태스크 사이에서만 실행될 수 있다는 것이다. 그래서 정의가 나온다. 양보란 바로 그 사이를 만들어주는 행위이며 밀려 있던 입력과 프레임 생산이 그 틈에 차례를 얻는 것이고 사용자에게는 오히려 성능이 개선된 것처럼 느껴진다는 것이다.
구현이 두 방식으로 제시된다. 개수로 쪼개는 방식은 setTimeout으로 다음 작업을 다음 태스크로 미루는 고전적인 방법이고 setTimeout이 남은 작업의 재개를 새로운 태스크로 미루면 현재 태스크는 거기서 끝나고 그 틈에 밀려 있던 입력과 렌더링이 처리된다는 것이다. 그런데 조건이 달라지면 방식도 바뀐다. 이미 애니메이션이 돌고 있거나 사용자가 스크롤하는 도중이라면 개수보다 시간으로 쪼개는 편이 안전하다는 것이다. 애니메이션은 매 프레임 메인 스레드를 조금씩 쓰고 있으므로 무거운 작업이 한 프레임의 남은 예산을 삼켜버리지 않도록 시간을 재며 잘라야 한다.
기준점의 설계가 세밀하다. rAF는 콜백을 깨울 때 프레임 시작 시각을 넘겨주는데 이를 예산의 기준점으로 쓴다는 것이다. 이유가 있다. 한 프레임을 해당 함수 혼자 쓰는 것이 아니어서 같은 프레임에서 먼저 실행된 애니메이션 콜백이 있다면 그들이 쓴 시간만큼 몫을 줄여야 프레임 예산이 지켜진다는 것이다. 그래서 기준점을 프레임 시작 시각으로 잡으면 그냥 5밀리초 쓰기가 아니라 프레임이 시작된 지 5밀리초까지만 쓰기가 되어 여러 애니메이션과 한 프레임을 나눠 쓰는 상황에서도 자연스럽게 협조하게 된다고 한다.
5밀리초라는 숫자에 대해서도 솔직하다. 딱히 특별한 근거가 있는 것은 아니고 실제 가용 예산을 10밀리초 남짓이라 했으니 그 절반쯤을 배경 작업에 떼어주고 나머지는 애니메이션 콜백과 스타일, 레이아웃, 페인트의 몫으로 남겨두는 적당한 휴리스틱 값이라는 것이다. 애니메이션이 무겁다면 더 줄이면 된다고 덧붙인다.
파티클 데모의 규모가 제시된다. 마우스를 움직이면 4,000개의 파티클이 커서를 피해 흩어지는데 가까운 파티클끼리도 서로 밀어내도록 되어 있어 한 파티클의 방향을 정하려면 다른 모든 파티클과의 거리를 확인해야 한다는 것이다. 그래서 한 바퀴에 약 1,600만 번의 거리 계산이 필요한 셈이라 매 프레임 전부 다시 계산하면 그것만으로 프레임 예산을 훌쩍 넘긴다고 한다.
유의점도 정리된다. 너무 잘게 쪼개면 오히려 손해인데 양보하고 다시 돌아오는 데에도 비용이 들기 때문에 조각이 지나치게 작으면 그 오버헤드가 정작 처리하려는 작업보다 커질 수 있다는 것이다. 그리고 setTimeout의 제약이 명시된다. HTML 명세는 setTimeout의 중첩 호출이 5번을 넘으면 최소 4밀리초의 지연을 강제하므로, 루프 안에서 반복해 양보하면 금세 이 조건에 걸려 지연 시간을 0으로 지정해도 조각마다 최소 4밀리초씩 기다리게 된다는 것이다. 그래서 대안이 제시된다. MessageChannel로 메시지를 보내 다음 태스크를 예약하는 기법을 쓰는 경우도 있고 React 스케줄러가 이 방법을 쓴다는 것이다. 최근에는 scheduler.yield()라는 표준 API도 등장했는데 양보 후에도 원래 작업이 다른 태스크들에게 밀리지 않고 우선적으로 재개된다는 장점이 있지만 아직 브라우저 지원이 고르지 않다고 한다.
도구별 재개 시점의 차이도 짚는다. setTimeout이나 scheduler.yield()의 재개 시점은 렌더링 주기와 무관한 반면 requestAnimationFrame은 프레임을 그리기 직전에 맞춰 재개되므로 화면 갱신에 리듬을 맞춰야 하는 작업이라면 rAF가 더 적합하다는 것이다.
한계도 분명히 한다. 쪼개기가 언제나 가능한 것은 아닌데, JSON.parse로 수 MB짜리 응답을 파싱하는 일은 하나의 원자적인 동기 호출이라 중간에 끊어 양보할 방법이 없다는 것이다. 파싱이 끝날 때까지 메인 스레드는 통째로 멈추므로, 이렇게 쪼갤 수 없는 무거운 작업은 아껴 쓰기의 명백한 한계라고 규정된다.
아껴 쓰기 ② 모으기 — 배압이라는 개념
쪼개기만으로 해결되지 않는 문제가 제시된다. 양보하면 입력과 화면을 살려냈지만 채팅을 그리는 속도 자체를 올려주지는 못하고 오히려 양보에 드는 오버헤드만큼 시간당 그릴 수 있는 채팅 수인 처리량은 줄어든다는 것이다. 그래서 새 상황이 정의된다. 채팅이 처리량보다 빠르게 쏟아지면 도착하는 채팅이 처리량을 앞질러 점점 쌓이고 화면에 올라오는 채팅은 점점 옛날 것이 되는데, 이런 상황을 배압이라 한다는 것이다.
해법이 반대 방향이다. 채팅을 하나씩 그리는 대신 쌓인 채팅을 묶어 한 번에 그리면 개당 고정 비용이 접히면서 같은 시간에 더 많은 채팅을 그릴 수 있다는 것이다. 모순처럼 들리는 것도 인정한다. 쪼개라더니 이제 모으라니 모순처럼 들리지만 렌더링이 끼어들 틈을 주지 않는 너무 긴 태스크 혹은 파이프라인 비용을 횟수만큼 반복시키는 너무 잦은 태스크를 적정한 크기로 다듬는 것이 핵심이라는 것이다.
모으기의 대상이 열거된다. 가장 좋은 타깃은 이벤트인데 스크롤이나 리사이즈, 입력 이벤트는 짧은 시간에 수십, 수백 번씩 발생할 수 있다는 것이다. 그래서 일정 시간 잠잠해지면 한 번만 실행하거나 일정 간격으로 한 번씩만 실행하도록 모으는 방법을 각각 디바운스와 스로틀이라 부른다고 한다. 데모는 약 2,000줄짜리 CHANGELOG를 열어둔 마크다운 에디터인데, 미리보기를 만들려면 문서 전체를 파싱해 DOM을 통째로 다시 만들어야 하므로 키 입력마다 실행하기엔 비용이 크다는 것이다. 디바운스 300ms로 바꾸면 타이핑이 멈춘 뒤 딱 한 번만 렌더돼 입력이 매끄러워진다고 한다.
DOM 쓰기도 대상이 된다. 노드 백 개를 하나씩 붙이는 대신 모아서 한 번에 붙이거나 스타일을 속성 하나씩 고치는 대신 클래스 하나를 토글해 변경을 한 번으로 만드는 것도 성능 개선에 도움이 된다는 것이다. HTML 문자열을 조립해 innerHTML에 한 번에 할당하는 오래된 기법의 본질도 같은데, 쓰기를 한 번으로 모아서 렌더링 파이프라인의 고정 비용을 한 번으로 줄이는 것이라고 정리된다.
프레임워크와의 연결도 짚는다. React의 가상 DOM부터가 최적화를 위한 장치인데, 가상 DOM을 이용하면 상태가 몇 번을 바뀌든 변경을 가상 트리에 모아 먼저 비교하고 실제 DOM에는 달라진 부분만 한 번에 반영할 수 있다는 것이다. 한 이벤트 핸들러 안에서 일어난 상태 갱신 여러 건을 리렌더 한 번으로 합쳐주거나 분석 이벤트를 낱개로 보내지 않고 모아뒀다 한 번에 전송하는 것도 같은 이야기라고 한다.
아껴 쓰기 ③ 우선순위 — idle-until-urgent
순서가 왜 중요한지가 명확하다. 도중에 끼어들 수 없는 메인 스레드에서는 순서가 곧 사용자가 느끼는 반응성이기 때문이라는 것이다. 순서 조절을 위해선 보통 큐를 만들어 작업을 넣고 빼는 구조를 만드는데, 큐에 들어간 작업은 FIFO 방식으로 처리되고 급한 작업이 들어오면 큐의 맨 앞으로 당겨서 먼저 처리한다는 것이다.
이 구조의 장점이 지목된다. 우선순위가 고정된 값이 아니어서 처음엔 급하지 않던 작업이 사용자의 행동에 따라 갑자기 급해지기도 한다는 것이다. 예시가 구체적이다. 게시물에 사진 수십 장을 첨부하는 상황에서 비용을 아끼려 클라이언트가 리사이즈를 하는데 이 작업은 차례로 처리하면 되는 한가한 일이라는 것이다. 그런데 사용자가 잘 첨부됐는지 확인하려고 특정 사진을 클릭하는 순간 그 사진의 미리보기만큼은 가장 급한 일이 된다. 그래서 이렇게 한가할 때 미리 해두다가 필요해지는 순간 급히 처리하는 접근을 idle-until-urgent 패턴이라 부르기도 한다고 한다.
데모는 사진 60장이고 각 미리보기는 픽셀 단위 필터링으로 실제로 생성한다는 것이다. 순서대로 모드에서는 그 타일의 차례가 올 때까지 기다려야 하지만 클릭한 것 먼저 모드에서는 큐를 건너뛰어 곧바로 채워진다고 한다. 그리고 결론이 붙는다. 처리하는 총량은 같고 순서만 바꿨을 뿐인데 사용자가 느끼는 체감이 완전히 달라지므로, 우선순위란 결국 지금 이 순간 사용자에게 가장 중요한 것이 무엇인가를 따지는 문제라는 것이다.
React와의 연결도 언급된다. React도 이와 비슷하게 만들어 사용하는데 스타베이션이나 배치 처리, 컨티뉴에이션 등 조금 더 정교한 기능들이 있고 startTransition과 useDeferredValue를 사용하면 내부에서 MessageChannel로 양보하고 자체 우선순위 큐로 순서를 정하는 스케줄러가 돌아간다는 것이다. 최신 브라우저에선 Scheduler API와 TaskController 같은 표준을 제공하지만 아직 지원되지 않는 브라우저가 있어 폴리필을 함께 쓰거나 직접 큐를 만들어 쓰는 경우가 많다고 한다.
아껴 쓰기 ④ 미루기 — 지금 꼭 해야 하나
가장 확실한 방법이 마지막에 온다. 지금 하지 않아도 되는 일을 지금 하지 않는 것이라는 것이다. 쪼개기와 모으기가 어떤 크기로 할까, 우선순위가 어떤 순서로 할까의 문제라면 미루기는 지금 이 작업을 꼭 해야 하나를 되묻는 것이라고 구분된다.
초기 로딩이 대표적인 사례다. 처음부터 모든 자바스크립트를 내려받아 실행할 필요는 없고 코드 스플리팅으로 지금 화면에 필요한 코드만 먼저 실행하고 나머지는 필요해질 때 불러오면 메인 스레드가 초기부터 느려지는 것을 막을 수 있다는 것이다.
렌더링 자체를 미루는 사례가 SNS 피드로 제시된다. 한참 스크롤해 게시물 수백 개가 쌓인 채로 알림 탭에 다녀오면 돌아오는 순간 화면이 잠깐 얼어붙는 앱들이 있는데, 탭을 오갈 때 피드의 DOM을 살려두더라도 다시 보이는 순간 브라우저가 보이지도 않는 게시물까지 수백 개 전체의 스타일과 레이아웃을 한꺼번에 다시 계산하기 때문이라는 것이다. 그래서 해법이 나온다. 화면 밖 게시물은 높이만 차지하는 빈 껍데기로 두고 화면에 가까워지는 순간에 실제 콘텐츠를 채우는 것이며 그 가까워지는 순간을 알려주는 도구가 IntersectionObserver라고 한다. 이미지 lazy 로딩 라이브러리들이 쓰는 방법이 바로 이것이라고 덧붙인다.
데모 규모와 결과가 제시된다. 게시물 1,500개가 쌓인 피드에서 보일 때만 렌더가 켜져 있으면 알림 탭에 다녀와도 쌓인 양과 무관하게 즉시 돌아온다는 것이다. 그대로 렌더로 바꾸면 돌아올 때마다 1,500개 전체를 다시 레이아웃하느라 복귀가 수백 밀리초씩 얼어붙는다고 한다. 그리고 부수 효과도 지목된다. 이 방식이 미뤄주는 것은 렌더링만이 아니라 화면 밖 게시물은 DOM 자체를 만들지 않으므로 그것을 만들고 유지하는 비용까지 함께 미뤄지며 무거운 초기화가 딸린 위젯이라면 그 초기화도 미룰 수 있다는 것이다.
CSS 대안의 현재 상태도 정확히 기록된다. 비슷한 효과를 CSS 한 줄로 노리는 content-visibility: auto라는 속성도 있지만 이 글을 쓰는 시점에는 엔진별 구현 편차가 있어 사파리에서는 오히려 복귀가 느려지는 성능 버그가 있으므로 아직은 IntersectionObserver 쪽이 어디서나 예측 가능하게 동작한다는 것이다.
낭비의 사례도 짚는다. 캐러셀과 움직이는 프로모션 배너, 실시간 차트처럼 계속 도는 작업도 화면 밖에서는 순수한 낭비여서 보이지도 않는 그림을 매 프레임 다시 그리며 메인 스레드를 쓰고 있는 것이라는 것이다. 그리고 자기 글로 증명한다. 이 글에 실린 십수 개의 데모가 한 페이지에서 공존할 수 있는 비결도 화면을 벗어나면 멈추도록 만들었기 때문이라는 것이다.
안 쓰기 ① 컴포지터에 올리기와 FLIP
앞서 던진 질문으로 돌아간다. 메인 스레드가 완전히 막혔는데도 왜 CSS 애니메이션은 태연하게 계속 돌았느냐는 것이고 답은 그 애니메이션이 애초에 메인 스레드에서 돌고 있지 않았기 때문이라는 것이다.
스레드가 네 종류로 정리된다. 메인 스레드는 자바스크립트를 실행하고 DOM을 다루고 스타일을 계산하고 레이아웃을 잡고 이벤트를 처리한다. 컴포지터 스레드는 이미 그려진 레이어들을 화면에 합성하며 스크롤이나 일부 애니메이션을 담당한다. 래스터 스레드는 페인트 명령을 실제 픽셀로 변환한다. 워커 스레드는 우리가 명시적으로 만든 별도의 자바스크립트 실행 공간이다.
그런데 제약이 있다. 컴포지터와 래스터 스레드는 브라우저가 알아서 굴리는 영역이라 직접 명령을 내릴 수 없고 워커 스레드는 우리가 만들 수 있지만 DOM에 접근할 수 없다는 큰 제약이 있다는 것이다. 그래서 안 쓰기의 정의가 좁혀진다. 아무 일이나 밖으로 내보내는 것이 아니라 메인 스레드 밖에서도 할 수 있는 형태의 일을 골라 내보내는 것이라는 것이다.
컴포지터의 비밀이 밝혀진다. transform과 opacity는 요소의 위치나 크기, 색을 바꾸는 것이 아니라 이미 그려진 레이어를 옮기거나 투명도만 조절하는 것이기 때문에 레이아웃과 페인트를 다시 할 필요가 없다는 것이다. 그래서 브라우저는 이 작업을 메인 스레드를 거치지 않고 곧바로 컴포지터 스레드에서 처리할 수 있으며 메인 스레드가 아무리 바빠도 컴포지터 스레드는 별개로 돌기 때문에 애니메이션은 부드럽게 유지된다고 한다. 반대로 top과 left, width, height 같은 속성으로 요소를 움직이면 매 프레임마다 레이아웃을 다시 계산해야 하므로 메인 스레드의 몫이라는 것이다. 그래서 처방이 나온다. 위치를 옮기는 애니메이션은 left가 아니라 transform: translate로, 크기를 바꾸는 애니메이션은 width 대신 transform: scale로 만드는 것이 성능에 좋다는 것이다.
진짜 레이아웃이 바뀌어야 하는 경우의 딜레마도 다뤄진다. 목록에서 항목 하나가 삭제되어 아래 항목들이 부드럽게 당겨 올라오는 장면은 장식적인 움직임이 아니라 실제 위치 변경인데, 그렇다고 top을 애니메이션하면 매 프레임이 레이아웃이라는 것이다. 이 딜레마를 푸는 기법이 FLIP이고 요약하면 레이아웃 변경은 단 한 번만 일으키고 움직이는 과정은 전부 transform에게 맡긴다는 것이다. 네 단계가 제시된다. First는 움직이기 전 위치를 재고 Last는 레이아웃을 실제로 바꾸고 새 위치를 재며 레이아웃은 여기서 한 번만 일어난다. Invert는 새 위치의 요소에 transform을 걸어 이전 위치에 있는 것처럼 되돌려 놓고 Play는 그 transform을 원래대로 풀어내는 애니메이션을 돌리는데 이 과정은 컴포지터의 몫이다.
착시의 정체가 설명된다. 사용자 눈에는 요소가 옛 자리에서 새 자리로 미끄러져 가는 것처럼 보이지만 실제로는 이미 새 자리에 도착해 있는 요소가 transform으로 잠시 되돌아갔다가 제자리로 풀려나는 것이며 애니메이션이 도는 동안 매 프레임 일어나는 일은 컴포지터의 transform 보간뿐이라는 것이다. 그리고 채택 사례가 붙는다. 리스트 재정렬 애니메이션은 대부분 이 기법으로 만들어지고 Vue의 TransitionGroup이나 Framer Motion의 layout 애니메이션도 내부적으로 FLIP이라는 것이다.
주의점 두 가지도 정리된다. 하나는 will-change: transform인데 브라우저에게 이 요소는 곧 변할 테니 미리 별도 레이어로 준비해두라는 힌트를 줘서 애니메이션의 시작을 매끄럽게 만들 수 있지만 남용하면 레이어가 과도하게 늘어나 오히려 메모리를 낭비한다는 것이다. 다른 하나는 레이아웃 스래싱이다. getBoundingClientRect나 offsetWidth처럼 레이아웃 값을 읽는 코드와 스타일을 쓰는 코드의 순서를 잘못 짜면 생기는 문제인데, 레이아웃 값을 바꾼 직후 다시 그 값을 읽으면 브라우저는 최신 값을 주기 위해 그 자리에서 레이아웃을 다시 계산할 수밖에 없다는 것이다. 이것이 반복문 안에서 벌어지면 한 프레임에 레이아웃이 수십 번 일어나 메인 스레드가 느려지므로, 읽을 것은 읽을 것끼리 쓸 것은 쓸 것끼리 모으는 습관만으로도 피할 수 있다고 한다.
안 쓰기 ② 워커로 보내기
transform으로 옮길 수 없는 무거운 일이 대상이 된다. 대용량 데이터 파싱이나 이미지 처리, 복잡한 계산 같은 순수한 계산 작업이라면 웹 워커로 통째로 메인 스레드 바깥에 내보낼 수 있다는 것이다. 워커는 메인 스레드와 완전히 분리된 별도의 스레드에서 자바스크립트를 실행하므로, 무거운 계산을 워커에게 맡기면 메인 스레드는 그동안 오직 UI를 반응시키는 일에만 집중할 수 있다고 한다.
비용도 명시된다. 워커는 DOM에 접근할 수 없으므로 화면을 직접 건드릴 수 없고 오직 계산만 한 뒤 결과를 메인 스레드로 돌려보내야 한다는 것이다. 또한 메인 스레드와 워커는 postMessage로만 소통하는데 이때 데이터가 복사되므로 주고받는 데이터가 크면 그 비용이 만만치 않다고 한다. 그래서 판단 기준이 나온다. 워커는 만능이 아니고 통신 비용을 상쇄할 만큼 계산이 무겁고 DOM과 무관할 때 빛을 발하며 짧고 가벼운 작업을 굳이 워커로 보내면 통신 비용이 계산 비용보다 커져 오히려 손해라는 것이다.
데모가 시임 카빙이다. 사진에서 에너지가 가장 낮은 세로 경로를 찾아 한 줄씩 제거해서 중요한 피사체는 지키면서 이미지의 폭을 줄이는 알고리즘이라는 것이다. 규모가 제시된다. 시임 하나를 제거할 때마다 픽셀 수십만 개를 훑는 계산이 필요해서 시임 250여 개를 제거하면 수억 번의 연산이 된다는 것이다. 결과가 대비된다. 메인 스레드에서 실행하면 계산이 도는 1~2초 동안 화면 전체가 통째로 얼어붙고 결과는 다 끝난 뒤에야 한꺼번에 나타나는데, 페인트가 끼어들 태스크 경계가 없기 때문에 중간 과정을 보여주고 싶어도 불가능하다는 것이다. 워커에서 실행하면 같은 계산이 도는 동안 이미지가 실시간으로 좁아지는 과정까지 구경할 수 있다고 한다.
부드러운 중간 과정의 비밀도 공개된다. 워커가 중간 프레임을 보낼 때마다 수 MB짜리 픽셀 버퍼를 복사한다면 그 비용도 아까우므로, postMessage에는 데이터를 복사하는 대신 소유권을 통째로 넘기는 방법이 있다는 것이다. ArrayBuffer처럼 이전 가능한 객체는 참조만 이동하므로 크기와 무관하게 비용이 거의 0이고 넘긴 쪽에서 더는 그 버퍼를 쓸 수 없게 되는 대신 복사 비용이 사라진다고 한다.
안 쓰기 ③ 일 자체를 없애기
질문이 한 단계 더 올라간다. 지금까지는 정말 메인 스레드에서 할 필요가 있는 일인가를 물었는데, 이번에는 이 일을 하긴 해야 하나를 따져보자는 것이다. 성능에 가장 좋은 것은 일 자체를 안 하는 것이라고 규정된다.
배압으로 되돌아온다. 아무리 모아서 처리해도 유입이 최대 처리량을 넘어서면 밀린 일은 한없이 쌓이는데, 안타깝게도 브라우저가 서버에게 천천히 보내라고 말할 방법은 마땅치 않다는 것이다. 그래서 결론이 나온다. 받은 일을 전부 하겠다는 생각을 버리는 수밖에 없다는 것이다.
방법이 세 가지로 정리된다. 첫 번째는 버리기인데, 실시간 로그처럼 흘러가면 그만인 데이터라면 처리가 밀리기 시작했을 때 오래된 것부터 조용히 버려도 사용자는 눈치채지 못하며 최신을 따라가는 것이 전부를 보여주는 것보다 중요하기 때문이라는 것이다. 두 번째는 합치기인데, 순위처럼 최신값만 의미 있는 데이터라면 밀린 업데이트를 병합해 마지막 값만 반영하면 되고 이렇게 하면 유입이 아무리 빨라져도 일의 양은 화면이 소화할 수 있는 만큼으로 고정된다는 것이다. 세 번째 생략하기는 밀려드는 일이 아니라 반복되는 일을 겨냥하는데, 같은 입력에 같은 결과가 나오는 계산이라면 두 번째부터는 다시 할 이유가 없고 결과를 기억해뒀다가 재사용하는 것을 메모이제이션이라 부른다고 한다.
그리고 저자가 자기 글을 되짚는다. 사실 이 생각은 글 곳곳에 이미 숨어 있었는데 디바운스는 타이핑 중의 실행을 생략했고 피드 데모는 보이지 않는 게시물의 렌더링을 생략했다는 것이다. 그래서 관점을 바꿔 보면 이 글의 절반은 일을 없애는 이야기였던 셈이라고 정리된다.
결론이 통념을 뒤집는다. 최적화를 공부하다 보면 일을 잘 처리하는 법에 눈이 가지만 가장 큰 이득은 대개 일을 지우는 데서 나온다는 것이다. 그래서 질문을 남긴다. 어떤 작업을 더 빠르게 만들기 전에 먼저 이 일이 지금, 여기서, 꼭 일어나야 할까를 생각해보자는 것이다.
쉬운 일은 없다
마치며에서 태도를 짚는다. 같은 개발자지만 프론트엔드 작업은 쉬운 일이라고 생각하는 개발자들이 간혹 있는데, 브라우저는 우리가 생각하는 것보다 훨씬 복잡한 시스템이며 단순히 HTML과 CSS, 자바스크립트로 화면을 그리는 것이 전부가 아니라는 것이다.
대상 서비스가 열거된다. 스트리밍 플랫폼처럼 실시간 데이터가 쏟아지거나 이미지 편집과 지도, 게임처럼 화면이 계속 바뀌는 앱은 메인 스레드가 바쁘면 곧바로 체감 성능이 떨어진다는 것이다. 그리고 조건이 하나 더 붙는다. 모두가 최신형 기기를 쓰는 것이 아니기 때문에 이런 서비스에선 최적화가 필수적이라는 것이다. 그래서 요구가 정리된다. 단순히 코드를 최적화하는 것만으로는 부족하고 브라우저가 어떻게 돌아가는지 이해하면서 메인 스레드의 일을 아껴 쓰고 필요 없는 일은 아예 하지 않는 전략이 필요하다는 것이다.
마지막 문장이 남는다. 결국 깊게 들어가면 어렵지 않은 일은 없으며 개발의 많은 것은 트레이드오프고 상황에 맞게 선택해야만 한다는 것이다. 그리고 이는 결국 개발자의 경험과 감각에 달려 있는데, 이 두 가지를 쌓는 것이 쉽지는 않지만 공부와 실험을 통해 충분히 쌓을 수 있다고 한다.
더 생각해보기
- 양보가 총 작업량을 늘리면서도 체감 성능을 올린다는 사실은, 성능 지표를 무엇으로 잡아야 하는지에 대해 어떤 교훈을 주는가.
- 프레임 예산 10밀리초라는 값은 120Hz가 보편화되면 어떤 전략을 먼저 무력화시키는가.
- 쪼개기가 불가능한 원자적 동기 호출(JSON.parse 등)은 워커 외에 다른 우회로가 있는가.
- 쪼개기와 모으기가 반대 방향인데, 적정 크기를 판단하는 기준을 어떻게 수치화할 수 있는가.
- idle-until-urgent 패턴은 어떤 종류의 작업에서 오히려 낭비가 되는가.
- content-visibility의 사파리 성능 버그처럼 표준이 있어도 쓸 수 없는 상황은 어떻게 판단하고 기록해야 하는가.
- FLIP이 레이아웃을 한 번으로 줄인다면, 레이아웃 자체가 무거운 경우에는 어떤 대안이 남는가.
- 워커의 통신 비용을 상쇄할 계산량의 경계는 실측 없이 추정할 수 있는가.
- 일을 버리는 전략은 데이터 정합성이 중요한 도메인에서 어디까지 허용되는가.
- 브라우저 내부 구조를 이해해야 최적화가 가능하다면, 프레임워크가 그 이해를 대신해줄 수 있는 범위는 어디까지인가.