내부 private repo 작업을 바탕으로 썼다. 외부 접근이 불가한 저장소라 링크 없이, 수치는 범위로 남겨 익명화 중심으로 정리한다.
커머스 앱의 주문서(체크아웃) 화면을 여는 시간을 1초 아래로 줄이는 게 이번 목표였다. 특히 느린 건 안드로이드 웹뷰였다.
처음엔 네트워크 문제라고 생각했다. 화면을 여는 시간의 대부분이 주문 정보 API 응답을 기다리는 구간이었으니까. 그 데이터를 이전 화면에서 미리 받아두면(prefetch) 체감이 빨라질 거라고 봤다. 실제로 미리 받아두는 작업을 만들어 배포했고, 어느 정도 효과도 있었다.
그런데 거기서 한 걸음 더 들어가려다, 측정이 방향을 바꿨다.
TL;DR
- 느린 원인을 네트워크로 가정하고 prefetch 작업을 진행했다.
- 빠르게 뜬 실행과 느리게 뜬 실행의 영상을 프레임 단위로 비교했더니, 정작 흰 화면 구간은 두 경우가 거의 같았다.
- 흰 화면은 네트워크(데이터 도착 여부)가 아니라 렌더(CPU)에서 생기는 고정 비용이었다.
- 그래서 진행하던 네트워크 작업을 접고, 렌더 쪽을 파기 시작했다.
- 렌더 구간의 큰 비용 중 하나는 첫 페인트 전에 레이아웃 값을 읽어 생기는 강제 동기 reflow였고, 이를 없애 렌더 구간을 약 100ms 줄였다.
- 다만 그 정도로는 1초 아래로 내려가지 않는다. 남은 비용은 구조상 필요한 렌더와 서버 응답 시간이라, 프론트 코드만으로는 한계가 있다.
네트워크라고 생각한 이유
화면을 여는 데 걸리는 시간을 구간별로 나눠보면, 대부분이 주문 정보 API의 응답을 기다리는 시간이었다. 그러니 그 데이터를 이전 화면(장바구니)에서 결제 버튼을 누르는 시점에 미리 요청해두면, 주문서로 넘어왔을 때 바로 쓸 수 있다. 호출 수는 그대로지만 타이밍만 앞당기는 셈이라 위험이 낮았다.
이 작업은 실제로 효과가 있었다. 다만 데이터를 앞당기는 것만으로는 부족해 보여서, 적중률을 더 끌어올리는 작업과 화면 전환에 필요한 코드 묶음을 미리 받아두는 작업을 이어서 진행하고 있었다. 둘 다 그럴듯했고, 코드도 어느 정도 짜여 있었다.
측정이 방향을 바꿨다
같은 기기에서 유독 빠르게 뜬 실행과 느리게 뜬 실행을 각각 영상으로 남겨, 프레임 단위로 비교했다. 화면 상단에 헤더가 뜨는 순간을 기준선으로 맞추고, 거기서 실제 콘텐츠가 그려질 때까지 걸린 ‘흰 화면’ 구간을 쟀다.
예상은 “빠른 실행은 흰 화면이 짧고, 느린 실행은 길 것”이었다. 그런데 두 경우 모두 흰 화면 구간이 비슷했다. 빠른 실행이 오히려 살짝 길기도 했다.
이 말은, 흰 화면은 네트워크가 빠르냐 느리냐와 무관한 고정 비용이라는 뜻이었다. 데이터가 아무리 빨리 도착해도, 그 뒤에 화면을 그리는 데 드는 시간은 그대로였다. prefetch로 데이터를 앞당겨봤자 이 구간은 줄지 않는다.
하던 작업을 접었다
그 시점에 진행하던 두 작업은 모두 네트워크 구간을 건드리는 것이었고, 정작 사용자가 보는 흰 화면은 렌더 구간이었다. 그래서 두 작업을 접었다.
진행하던 작업을 접는 건 매번 아깝다. 그런데 아까움과 옳음은 다르다. 잘못된 구간을 붙잡고 있으면, 들인 노력과 무관하게 화면은 그대로다.
진짜 병목은 강제 reflow였다
흰 화면 구간을 성능 트레이스로 뜯어봤다. 데이터가 도착한 뒤 콘텐츠를 그리는 큰 작업이 있었고, 그 안에서 상당 부분이 강제 동기 레이아웃(forced reflow)이었다.
원인은 한 컴포넌트였다. 상품 썸네일을 몇 개까지 보여줄지 정하려고, 컨테이너의 실제 너비를 첫 페인트 전에 읽고 있었다.
레이아웃 속성(offsetWidth, getBoundingClientRect 같은)을 첫 페인트 전에 읽으면, 브라우저는 그 값을 알려주려고 그 자리에서 전체 레이아웃을 강제로 계산한다. 원래대로면 페인트할 때 한 번 하면 될 계산을, 미리 한 번 더 하게 되고, 그 값으로 상태를 바꾸면 렌더가 한 번 더 돌아 레이아웃이 또 생긴다.
고친 방법은 단순했다. 너비를 직접 읽는 대신, ResizeObserver가 이미 계산해서 넘겨주는 값(contentRect)을 쓰도록 바꿨다. 이 값은 읽어도 새 레이아웃을 강제하지 않는다. 화면에 보이는 동작이나 디자인은 그대로 두고, 재는 방식만 바꾼 셈이다.
로컬에서 운영과 비슷한 조건(CPU를 6배 느리게, 네트워크를 3G로)으로 전후를 비교했다. 데이터 도착 후 콘텐츠를 그리는 구간이 약 840ms에서 750ms 안팎으로, 대략 100ms 줄었다. 트레이스에서 그 reflow를 일으키던 지점도 사라졌다. 같은 조건을 세 번(원본, 실험, 정식 수정) 떠서, 100ms 안팎의 차이가 측정 노이즈가 아니라 그 지점에서 나온 것임을 확인했다.
그래도 1초 아래는 아직이다
정직하게, 이 작업만으로 1초 아래로 내려가지는 않는다.
느린 기기의 상위 백분위 기준으로 화면이 뜨는 시간은 대략 1.2초쯤이고, 이번에 줄인 100ms를 반영해도 1.1초 안팎이다. 1초 아래로 가려면 200ms가 더 필요한데, 남은 큰 덩어리는 두 가지다.
- 콘텐츠 자체를 그리는 레이아웃과 자바스크립트 번들. 이건 기능상 그만큼 필요한 작업이라 프론트 코드로 크게 줄이기 어렵다.
- 서버 응답 시간. 이건 프론트가 아니라 백엔드와 함께 봐야 하는 영역이다.
그래서 목표 시점 안에 1초 아래로 내리는 건 이 방향만으로는 어렵다고 팀에 먼저 공유했다. 대신 화면이 뜨는 절대 시간과 별개로, 기다리는 동안의 체감(로딩 표시가 멈춰 보이는 문제 등)에서 해볼 여지는 남아 있다.
배운 것
- 그럴듯한 가설과 측정이 가리키는 곳은 다를 수 있다. 이번엔 네트워크가 그럴듯했지만, 사용자가 보는 흰 화면은 렌더였다.
- 빠른 실행과 느린 실행을 같은 기준선에 맞춰 나란히 보면, 어떤 구간이 조건과 무관한 고정 비용인지 드러난다. 그 고정 구간이 대개 진짜 병목이다.
- 첫 페인트 전에 레이아웃 값을 읽는 코드는 조용히 강제 reflow를 만든다. 재는 방식을 바꾸는 것만으로 없앨 수 있는 경우가 있다.
- 진행하던 작업을 접는 결정도 성능 작업의 일부다. 잘못된 구간을 오래 붙잡는 것보다, 접고 옳은 구간으로 옮기는 편이 낫다.
- 100ms를 줄였어도 1초 아래로 못 내려간다면, 그 사실도 그대로 공유하는 편이 낫다. 한계를 먼저 말해야 다음 논의(서버, 범위, 체감)가 열린다.
마치며
목표는 1초였고, 아직 닿지 못했다. 그래도 이번에 얻은 건 숫자보다 방향이다. 흰 화면이 네트워크가 아니라 렌더라는 걸 확인했으니, 더는 네트워크에 매달리지 않아도 된다.
성능 작업은 열심히 한 구간이 아니라, 측정이 가리킨 구간에서 이뤄진다. 가끔은 하던 작업을 접는 것부터가 그 시작이다.