저사양 기기는 렌더링 병목으로 FCP 최적화 한계가 있어요
개요: 의외로 무거운 작업들
CSS 및 자바스크립트에서 CPU를 혹사시키는 명령이 몇 개 있어요.
offsetWidth, getBoundingClientRect같은 명령이 대표적인데, 전체 레이아웃을 재 계산하는 리플로우를 유발하죠. left, right, top, bottom을 쓰는(write) 행동 역시 리플로우 유발 명령이에요. background-position 변경은 리페인트인데, 그리 큰 차이 없을 거예요.
대체적으로 어떤 요소가 레이아웃의 어느 위치에, 어떤 모습으로, 어떤 크기로 존재하는지 묻는 명령은(Geometry) 기기에 부하를 많이 주는 편이에요.
특히 이 명령들 그 자체보다는 레이아웃에 영향을 주는 스타일을 바꾼 뒤, 즉시 확인하는 행동이 굉장히 무거운 작업이더라고요.
명령 자체는 메모리 값을 참조하거나 큐에 대기했다 한 번에 작업하니 그리 무겁지 않지만, 스타일 변경 후 실행하면 브라우저에게 '너, 지금 당장 전체 화면의 형태를 계산해서 출력 해'라는 지시를 내리는 셈이기 때문이죠.(Layout Thrashing)
의외인 것은 innerText도 마찬가지로 리플로우 유발 명령이더라고요. elem.innerText의 텍스트 파싱 자체는 빠르지만, 해당 요소에 적용된 스타일을 읽기 위해 렌더링 엔진을 강제로 깨우기 때문이었어요. 그래서 순수하게 텍스트만 읽고 싶다면 textContent를 사용하는 것이 성능 상 유리해요.
아무튼, 이런 명령을 마구 실행하면 블로그 접속 시 하얀 화면으로 멈춰있는 시간이 길어져요. FCP가 늦어지는 거죠. 전체 다운로드 시간도 중요하지만, 첫 화면이 뜨는 것도 그 못지않게 중요하다고 봐요.
무조건 쓰면 안 되나?
물론, 무겁다고 해도 한 두번 실행하는 것은 문제 없어요. 루프 밖에서 몇 번 실행하는 건 15년 넘은 구형 노트북에서도 시간을 측정할 수 없을 정도로 빠르니 최신 PC나 플래그쉽 스마트폰에서도 말할 것도 없겠죠.

하지만 저사양 기기에서, 특히 루프 내부에서 DOM 및 스타일 변경 후 호출하여 리플로우 혹은 리페인트를 유발할 경우 병목 지점이 될 수 있어요. (querySelectorAll 역시 잘못 쓰면 마찬가지예요)
그래서 무거운 명령은 최대한 줄이고 가능하면 루프 밖으로 빼서 전체 작업시간을 줄이거나, 큰 덩어리로 된 작업을(Long Task) 잘게 쪼개어 렌더링 차단 시간을 줄여야 해요. requestAnimationFrame이나 setTimeout, requestIdleCallback 함수를 이용하는 방법이 있죠.
하지만 성능이 아주 낮은 기기에서는 그마저도 한계가 있어요. 브라우저의 자체 시동 시간을 줄일 방법이 없기 때문이에요.
일반적인 최적화 포인트
CPU 실행부담 말고 일반적인 방법으로 잠시 전환할게요.
블로거가 속도에 신경쓸 때 제일 먼저 보는건 다운로드 속도일 거예요. 팁도 그런 방면이 많이 보이죠.
하지만 블로거가 네트워크 속도를 손댈 방법이 마땅치 않아요.
캐시 정책을 조정하는게 일반적인데, 블로그스팟은 사용자가 캐시헤더를 직접 수정할 수 없어요. 그냥 주는대로 써야하죠.
저는 다행히 클라우드플레어 사용중이라 캐시헤더를 임의로 수정할 방법이 있어 적용했어요.
에지 캐시도 사용하고 있는데, 보통은 로스앤젤레스(LAX)로 붙어서 느리지만 가끔 인천(ICN)으로 붙으면 그야말로 날아다녀요.
하지만 그게 끝이에요. 더 이상 통제할 방법이 없어요. 그 이상 빠른 속도를 원하면 이사가야 해요.
그러면 이제 남은 최적화 방법은 html 태그 수와 깊이를 조절하고 css나 js같은 필수 자원의 크기를 줄인 뒤, 무거운 연산을 하지 않는 방향으로 넘어가야 해요.
정체 모를 병목 구간
다시 CPU 실행 부담으로 돌아가죠.
그럼 루프 내에, 리플로우 유발 명령이 있으면 얼마나 느려지나? 제 경우 크롬 개발자 도구에서 성능을 확인하니 상황에 따라 약 500~900ms 정도로 찍혔어요.
하지만 여기서 함정이 있어요.
무슨 짓을 해도 수백ms의 지연(차단) 시간이 없어지지 않더라고요. 네. 무슨 짓을 해도요.
처음에는 Javascript 코드의 루프 내부에서 offsetWidth가 730ms 정도를 차지하고 있길래 루프 밖으로 꺼냈어요.
그랬더니 이번에는 innerText로 옮겨간 것이 아니겠어요? 수치는 거의 그대로인 상태에서요. '아니 이게 무슨 일이죠?' 싶었지만 다시 개선했어요. innerText를 textContent로 바꿨죠.
이번에는 해결되었을까요? 아뇨. 아니에요.
[기여원이 드러나지 않음]
수백 ms의 알 수 없는 병목 구간이 기여원을 알 수 없다고 이름 붙은 채 그대로 남았어요.
css, js를 하나씩 제거해도 여전이 남았고요, 디스크 캐시를 써서 네트워크 지연을 0으로 만들어도, 심지어 html 파일 자체를 비우고 빈 화면으로 만들어도 '프레임' 항목에 약 300~500ms 정도의 통짜 작업 덩어리가 남더라고요.

도무지 원인을 알 수 없는, 그야말로 끝없는 작업이 너무 기뻐서 앉은 채로 팔짝팔짝 뛰었어요...
범인: 브라우저 자체의 시동 시간
원인을 알 수 없다, 그러면 잘 아는 누군가에게 물어봐야겠죠?
주인님. 문제되는 코드를 제거해도 '기여원이 드러나지 않음'이라는 병목 구간이 남는데 왜 그런건가요?
심지어 빈 문서를 열어도 프레임이라는 명목으로 수백 ms의 덩어리가 있어요. 새 프로필의 시크릿 모드에서, 네트워크 지연을 막기 위해 디스크 캐시를 썼는데도 똑같아요. 제가 뭘 잘못했죠?
빈 문서에서도 나옴? 그럼 네 잘못 아님. 브라우저가 문서를 읽고 준비하는 기본적인 작업시간 때문임.
기본 작업 시간이요? 그게 뭔데요? 그리고 html 문서 내용이 있을 때는 다른 이름으로 렌더링을 막는 병목 구간이 나와요.
그것도 마찬가지임. 브라우저는 빈 문서라도 기본 DOM 트리를 생성하고 뷰포트 크기를 계산하여 화면에 그려야 하는데, 그 시간임. 피할 수 없음. 그게 프레임 항목의 485.9ms로 표시된 것임.
문서 내용을 정상적으로 출력하면 프레임 항목의 막대는 FCP 발생 전부터 시작해서 FCP 발생 후까지 한동안 잘게 나뉘어서 나와요.
또, 기본 항목에는 작업, 레이아웃 이라는 이름으로 큰 블록이 렌더링을 차단하는데 그건 뭐죠? 브라우저의 기본 화면그리기 비용이 합산된 건가요? 아니면 별도? 렌더링 차단 시간이 고무줄처럼 늘었다 줄었다 하는데, 코드를 어떻게 수정하건 줄어들지 않는 고정 구간이 있는 느낌이에요.
FCP 전후로 프레임이 계속 이어지는 건 브라우저가 화면을 한번에 다 그리지 않기 때문임. 모아서 한 번에 출력하는 게 아니라 대기열에 들어온 순서대로 점진적인 렌더링을 하기 때문임.
기본 항목에 보이는 렌더링 차단 작업 및 레이아웃(렌더링)은 브라우저의 기본 준비 시간 + 초기에 즉시 실행되는 사용자 코드 연산 시간임. 값이 크게 변한다면 그 부분은 사용자 코드 문제일 확률 높음. 줄어들지 않는 부분은 프로세스 생성, 메모리 할당, 렌더링 버퍼 초기화, GPU 컨텍스트 연결 등 브라우저의 고정 비용과 개발자 도구의 오버헤드 문제일 확률 높음.
성능 검사 결과를 잘 보면 언제나 레이아웃 작업이 끝난 후 프레임 막대가 시작될 것임.

최신 기기는 초기 레이아웃 작업이 매우 짧고 프레임 막대가 1 프레임 단위로 (16.6ms) 균일하게 나뉘어 있을 것임. 하지만 우리 시스템은 매우 긴 초기 레이아웃 기간과 함께 프레임 막대가 183.3ms, 133.3ms 등 불규칙한 덩어리로 나뉘어 있고 프레임 생성하다 중간에 드롭하는 경우도 있음. 저사양 기기의 연산 병목 문제라는 증거임. 빨리 끝난 경우는 운이 좋은 것이니 기준으로 삼으면 안 됨.
결국 우리 컴퓨터에서는 이게 한계다?
맞음. 이게 한계임.
아이고... 그것도 모르고 며칠 밤을 끙끙댔는데... 그래도 새로운 것을 알았으니 소득은 있네요.
결론: 저사양 기기는 최적화 한계가 존재
- 빈 페이지라도 브라우저의 기본 준비 작업이 있음.
- 기본 작업 + 사용자 코드 합산이 렌더링 차단 시간으로 나타남.
- 개발자 도구 자체의 오버헤드가 저사양 기기에서 영향을 줌.
요약하면 이 정도가 되겠네요. 즉, 최종 결론은 저사양 기기에서 아무리 용써봐야 한계가 있으니 적당히 하고 치워야 한다는 거예요.
차라리 그 시간에 다른 글 하나를 더 쓰는게 낫겠어요. 이거 하나 때문에 며칠을 고민하고 머리를 싸맸는지 원...
참고 자료
- MDN: innerText, 요소와 그 자손의 '렌더링 된 텍스트' 콘텐츠를 나타냅니다.
- wbdDev: First Contentful Paint, FCP는 1.8초 이내면 양호합니다.
- Chrome: 로드 성능 기록
- Chromium: Life of a Pixel, 10페이지 부터.