내부 비공개 저장소의 성능 개선 작업을 바탕으로 썼다. 조직과 저장소를 식별할 수 있는 정보는 걷어내고, 수치는 근삿값으로 표현했다.
커머스 앱의 체크아웃 화면을 1초 아래로 줄이는 것이 목표였다. API를 미리 요청하고, 측정 하네스를 만들고, 실기기 영상을 프레임 단위로 나누고, 브라우저 트레이스에서 강제 동기 레이아웃의 호출 스택을 전부 따라갔다.
결과적으로 실험에서는 약 100ms를 줄일 수 있는 수정안을 검증했다. 하지만 목표한 1초에는 닿지 못했다.
이번 작업에서 가장 중요한 결과는 “무엇을 더 최적화할까”가 아니었다. 현재 구조에서 프런트엔드 코드로 줄일 수 있는 비용과, 더 줄이려면 제품의 다른 축을 바꿔야 하는 비용을 구분한 것이었다.
이 글은 그 성능 작업의 마무리다.
앞선 글에서 이미 다룬 것
같은 작업에서 갈라져 나온 이야기를 앞선 글들에 각각 정리했다. 여기서는 반복하지 않고 결론만 연결한다.
- API를 당장 바꿀 수 없다면 호출 시점을 당겨본다 — 서버 응답 시간을 줄이지 못해도, 요청을 앞당겨 화면 전환과 겹칠 수 있다.
- 평균이 빨라져도 성능 개선이 아닐 수 있다 — 빠른 실행과 느린 실행이 섞인 이봉 분포에서는 평균보다 두 상태를 가르는 조건을 먼저 찾아야 한다.
- 흰 화면은 네트워크가 아니라 렌더였다 — 빠른 실행과 느린 실행의 영상을 맞춰보니, 사용자가 보는 흰 화면 구간은 거의 같은 길이였다. 그래서 진행하던 네트워크 작업을 접고 렌더로 방향을 바꿨다.
- useLayoutEffect는 ‘더 안전한 useEffect’가 아니다 — 첫 페인트 전에 정확한 레이아웃을 보여주는 것과 빠르게 먼저 그리는 것 사이의 맞교환을 정리했다.
이번 글에서 다룰 것은 그다음이다. 강제 reflow를 제거한 뒤에도 왜 큰 Layout이 남았는지, 그리고 그 지점부터 성능 문제가 어떻게 디자인·API·렌더 구조의 문제로 바뀌는지에 집중한다.
TL;DR
- 개발 서버에서 보인 큰 병목 상당수는 프로덕션 빌드에서 사라졌다. 성능 후보를 고르는 측정은 최적화된 프로덕션 빌드에서 다시 해야 한다.
- 전체 전환 시간을 탭 이후의 자바스크립트, API 대기, 콘텐츠 렌더로 나누자 서로 다른 병목이 한 숫자에 섞여 있음을 확인했다.
- 렌더 구간의 Layout 이벤트를 호출 스택 기준으로 전수 조사하니, 크게 줄일 수 있는 강제 reflow는 하나였다.
- 해당 소스를 제거하자 렌더 구간은 약 840ms에서 750ms로 줄었다. 그러나 남은 360~400ms의 Layout은 또 다른 강제 reflow가 아니라 현재 콘텐츠를 한 번 배치하는 데 필요한 레이아웃이었다.
- 따라서 다음 단계는 같은 종류의 미세 최적화를 더 찾는 일이 아니다. 정확한 첫 화면이라는 UX 요구, 약 500ms의 API 응답, 한 번에 그리는 콘텐츠 양이라는 세 개의 벽을 각 소유자와 함께 다루는 일이다.
- “더 줄일 수 없다”는 뜻은 아니다. 현재 구조를 유지한 채 낮은 위험으로 줄일 수 있는 프런트엔드 레버가 거의 소진됐다는 뜻이다.
첫 번째 교정: 개발 서버가 병목을 만들고 있었다
초기 측정은 개발 서버에서 시작했다. 반복 실행 하네스로 구간별 시간을 수집하고 React Profiler도 함께 봤다. 결과만 보면 큰 후보가 여럿 있었다.
- 수백 ms로 측정된 passive effect 처리
- 수백 개 컴포넌트가 한꺼번에 갱신되는 커밋
- 몇 초씩 잡히는 자바스크립트 파싱
- 결제 버튼이 뒤늦게 나타나는 큰 렌더 간격
숫자는 너무 커서 의심하기보다 고치고 싶어졌다. 실제로 넓은 범위의 상태 갱신을 줄이는 실험도 했다. 같은 조건으로 100번을 재봤지만 효과는 0에 가까웠다.
이상해서 프로덕션 빌드로 다시 측정했다. 그러자 앞서 보이던 큰 후보 대부분이 사라지거나 크게 줄었다. 개발 환경의 비압축 번들, 소스맵, Hot Module Replacement(HMR), 개발 전용 계측과 런타임이 실제 제품에는 없는 비용을 만들고 있었다. 한 컴포넌트에 수백 ms가 귀속된 것처럼 보이던 Profiler 결과도 브라우저 트레이스에서 다시 보니 실제 호출 비용과 맞지 않았다.
측정 도구는 병목을 보여주기도 하지만, 없는 병목을 만들어 보여주기도 한다.
이후 규칙을 바꿨다.
기능 흐름과 호출 관계 확인 → 개발 환경
성능 후보 선정과 전후 비교 → 프로덕션 빌드
최종 체감과 분포 확인 → 실기기 또는 운영 관측
Next.js 공식 문서도 next dev와 프로덕션 빌드의 동작과 최적화가 다르며, 실제 성능에 가까운 측정은 next build와 next start를 사용하라고 안내한다. 프레임워크가 무엇이든 원칙은 같다. 개발하기 좋은 환경과 성능을 대표하는 환경은 다르다.
한 숫자를 세 구간으로 쪼갰다
“체크아웃 진입 시간” 하나만 보면 네트워크와 렌더, 이전 화면의 작업이 모두 섞인다. 그래서 사용자 행동부터 콘텐츠가 보일 때까지를 다음처럼 나눴다.
결제하기 탭
↓
이전 화면의 자바스크립트 + 화면 전환
↓
체크아웃 셸과 헤더 표시
↓
주문 정보 API 대기
↓
데이터 처리 + 콘텐츠 렌더 + 페인트
같은 Android 기기에서 빠른 실행과 느린 실행을 골라, “체크아웃” 헤더가 처음 나타나는 프레임을 기준으로 영상을 맞췄다. 전체 시간은 두 배 가까이 달랐지만 헤더 이후 콘텐츠가 나타날 때까지의 흰 화면은 둘 다 약 650ms였다.
전체 시간의 격차는 렌더 바깥에도 있었고, 흰 화면은 실행이 빠르든 느리든 반복해서 지불하는 CPU 비용에 가까웠다. 이 관찰이 네트워크 적중률을 더 파던 작업을 멈추고 렌더 트레이스를 조사하게 만든 근거였다.
플랫폼 비교도 단서가 됐다. 같은 API를 사용하는 두 실기기에서 iOS는 비교적 안정적으로 빠른 반면 Android WebView는 이전 화면의 자바스크립트와 콘텐츠 렌더에서 더 오래 걸렸다. 플랫폼별로 각각 한 대씩 비교한 결과를 “iOS는 항상 빠르다”로 일반화할 수는 없다. 다만 같은 서버 응답을 사이에 두고도 큰 차이가 났고, 같은 Android 안에서도 흰 화면 길이가 반복됐다는 두 관찰을 합치면 CPU와 메인 스레드를 우선 조사할 근거는 충분했다.
흰 화면에는 멈춰 있는 로더가 있었다
영상을 확대해보니 흰 화면은 완전히 비어 있지 않았다. 로더 DOM은 이미 렌더돼 있었다. 문제는 Android에서 그 로더가 거의 움직이지 않았다는 점이었다.
해당 로더는 background-position을 바꾸는 스프라이트 애니메이션이었다. 이 속성의 변경은 페인트 작업을 필요로 한다. 주문 데이터 처리와 콘텐츠 렌더가 메인 스레드를 오래 점유하자 로더도 다음 프레임을 그리지 못했다. 사용자에게는 로딩 중인 화면이 아니라 정지한 흰 화면처럼 보였다.
여기서 절대 시간과 체감 시간을 분리할 수 있다.
- 강제 reflow 제거는 실제 렌더 시간을 줄인다.
transform이나opacity처럼 컴포지터에서 처리하기 쉬운 애니메이션으로 바꾸는 것은 메인 스레드가 바쁠 때도 진행 상태를 보여줄 가능성을 높인다.- 스켈레톤을 먼저 그리는 것은 기다림의 모양을 바꾼다.
후자의 두 방법은 API나 렌더 시간을 직접 줄이지 않는다. 그래도 “화면이 고장 난 것 같다”는 체감을 줄이는 별도의 성능 작업이다.
Layout 이벤트가 크다고 모두 forced reflow는 아니다
렌더 창의 Chrome Performance 트레이스에서 Layout과 UpdateLayoutTree 이벤트를 모으고, 각각을 유발한 자바스크립트 스택의 첫 프레임을 집계했다.
수정 전에는 유독 큰 소스가 하나 있었다. 상품 썸네일의 노출 개수를 정하려고 useLayoutEffect에서 offsetWidth를 읽는 코드였다. 레이아웃이 무효화된 직후의 동기 읽기가 브라우저에 즉시 레이아웃 계산을 요구했고, 두 번의 Layout 이벤트에 약 450ms가 귀속됐다.
직접 읽기를 없애고 ResizeObserver가 전달하는 contentRect.width를 사용하자 그 호출 스택은 트레이스에서 사라졌다. 데이터 도착 후 콘텐츠가 보일 때까지의 렌더 구간도 약 840ms에서 750ms로 줄었다.
여기까지만 보면 450ms가 모두 사라져야 할 것 같다. 하지만 총 Layout 시간은 약 510ms에서 430ms로, 80ms 안팎만 줄었다. 수정 후에도 약 360~400ms의 큰 Layout이 남았다.
이건 수정이 실패했다는 뜻이 아니었다.
호출 스택과 총 Layout 시간의 변화를 함께 보면 이렇게 해석할 수 있다. 수정 전에는 콘텐츠를 배치하는 큰 레이아웃 작업이 자바스크립트의 동기 크기 읽기에 의해 앞당겨졌고, 비용이 그 호출 스택에 귀속됐다. 수정 후에는 강제 읽기가 사라져 같은 콘텐츠의 레이아웃이 브라우저의 정상적인 렌더 프레임에 귀속됐다. 중복되고 앞당겨진 계산은 줄었지만, 현재 DOM을 화면에 한 번 배치하는 작업 자체가 사라진 것은 아니다.
따라서 Performance 패널에 큰 Layout이 남았다는 이유만으로 또 다른 forced reflow라고 판단하면 안 된다.
확인할 것은 세 가지다.
- 레이아웃 직전에 동기 geometry read(크기·위치 읽기)가 있었는가
- 호출 스택이 애플리케이션의 읽기 코드로 이어지는가
- 수정 후 같은 소스와 반복 횟수가 실제로 사라졌는가
이번에는 이 기준으로 렌더 창을 전수 조사했고, 크게 줄일 수 있는 강제 reflow 소스는 썸네일 측정 하나였다. 현재 트레이스에서 나머지 큰 Layout은 콘텐츠를 한 번 그리는 비용이었다.
그 뒤에 남은 세 개의 벽
약 100ms를 회수하고 나니, 다음 후보들은 더 이상 같은 성격의 프런트엔드 미세 최적화가 아니었다.
벽 1. 정확한 첫 화면이라는 UX 요구
썸네일을 첫 페인트부터 정확한 개수로 보여주려던 코드는 잘못된 의도로 만들어진 게 아니다. 깜빡임 없이 안정된 화면을 보여주려는 합리적인 선택이었다.
하지만 이런 요구가 여러 컴포넌트에 쌓이면 각 컴포넌트가 페인트 전에 크기를 재고 상태를 맞추려 한다. 개별적으로는 옳은 결정이 전체 화면에서는 렌더 부채가 될 수 있다.
이를 더 줄이려면 “정확한 첫 프레임”과 “빠른 첫 프레임” 중 무엇이 중요한지 선택해야 한다. 이건 훅 하나를 바꾸는 문제가 아니라 디자인·제품·프런트엔드가 함께 정할 초기 표시 정책의 문제다.
벽 2. API 응답 시간의 바닥
주문 정보 API에는 약 500ms의 응답 시간이 있었다. 프런트엔드는 이전 화면에서 요청을 시작해 라우트 전환과 겹칠 수 있다. 캐시 적중률을 높여 사용자의 대기 구간을 줄일 수도 있다.
하지만 프리페치는 서버 시간을 없애지 않는다. 사용자가 기다리기 전에 요청을 출발시킬 뿐이다. 앞 화면에서 겹칠 시간이 부족하거나 캐시가 빗나가면 원래 비용이 다시 드러난다.
이 벽을 근본적으로 낮추려면 서버 처리, 응답 크기, 데이터 계약, 캐시 전략을 함께 바꿔야 한다. 소유권도 프런트엔드 하나에 있지 않다.
벽 3. 한 번에 그리는 콘텐츠의 양
강제 reflow를 제거한 뒤 남은 것은 큰 React 서브트리를 마운트하고, 현재 DOM의 스타일과 위치를 계산해 한 번 페인트하는 비용이었다. 모바일 수준으로 CPU를 제한한 환경에서는 React 렌더와 커밋에 약 200ms, 레이아웃에 360~400ms가 필요했다.
이 비용을 더 줄이려면 계산식을 조금 고치는 것보다 처음에 그리는 양 자체를 줄여야 한다.
- 첫 화면 아래 콘텐츠를 나중에 렌더한다.
- 큰 섹션을 데이터와 함께 단계적으로 연다.
- DOM과 컴포넌트 트리의 크기를 줄인다.
- 초기 실행에 필요하지 않은 코드와 기능을 분리한다.
- 전체 콘텐츠 대신 셸이나 스켈레톤을 먼저 보여준다.
이 선택들은 렌더 순서와 사용자 경험, 테스트 범위를 바꾼다. 미세 최적화보다 효과가 클 수 있지만 작업의 크기와 회귀 위험도 함께 커진다.
“더 못 줄인다”의 정확한 뜻
성능 문서에 “더 줄일 수 없다”고 쓰면 쉽게 오해를 만든다. 코드가 완벽하거나 브라우저가 더는 빨라질 수 없다는 뜻이 아니다.
이번 결론은 다음과 같다.
현재 콘텐츠와 API 계약, 첫 화면 요구를 유지한 채 적용할 수 있는 낮은 위험의 프런트엔드 미세 최적화는 거의 소진됐다.
남은 선택지는 분명히 있다.
| 선택지 | 줄이는 것 | 필요한 결정 |
|---|---|---|
| API 처리 및 응답 구조 개선 | 실제 대기 시간 | 백엔드·데이터 계약 |
| 첫 화면 콘텐츠 축소·지연 렌더 | 렌더와 레이아웃 | 제품·디자인·프런트엔드 |
| 컴포지터 친화적인 로더·스켈레톤 | 정지한 화면의 체감 | 디자인·프런트엔드 |
| 번들 분리와 초기 코드 축소 | 다운로드·파싱·실행 | 프런트엔드 구조 변경 |
즉 성능 작업이 끝난 게 아니라 작업의 종류와 소유자가 바뀐 것이다. 같은 트레이스에서 또 다른 작은 함수를 찾는 대신, 어떤 벽을 누구와 바꿀지 결정해야 하는 단계가 됐다.
측정값을 어디까지 믿을 것인가
이 글의 수치는 운영 배포 전후의 실제 사용자 모니터링(RUM) 결과가 아니다. 로컬 프로덕션 빌드에 CPU와 네트워크 제한을 적용한 트레이스, 그리고 실기기 화면 녹화를 기반으로 한 방향성 측정이다.
제약도 있었다.
- reflow 수정의 A/B/C 트레이스는 각 조건 한 번씩이라 89ms와 114ms 중 어느 숫자가 정확한 효과인지 말할 수 없다.
- 다만 문제의 호출 스택이 수정 후 사라졌고, 두 수정안에서 같은 방향의 감소가 나타났으므로 “약 100ms 규모의 레버”라는 판단에는 사용할 수 있었다.
- 일부 실기기 영상은 프레임레이트가 낮아 경계 측정에 약 100ms의 오차가 있었다.
- 서로 다른 플랫폼의 기기 한 대씩을 비교한 결과는 일반화하지 않았다. 같은 기기 안의 빠른 실행과 느린 실행 비교를 더 강한 증거로 사용했다.
- 최종 효과를 확정하려면 반복 측정과 운영 RUM이 필요하다.
성능 숫자는 소수점까지 정밀하게 쓰는 것보다 어떤 결정을 지지할 수 있고, 어디부터는 지지하지 못하는지를 함께 적는 편이 중요하다.
마치며
목표는 1초였고, 프런트엔드 수정 하나로 거기까지 가지는 못했다. 그래도 어디가 느린지 모르는 상태와, 남은 비용의 성격과 소유자를 아는 상태는 다르다.
이번 작업으로 확인한 것은 다음과 같다.
- 개발 서버의 큰 숫자를 그대로 최적화 후보로 삼으면 안 된다.
- 전체 시간을 구간으로 나누면 네트워크와 CPU 병목을 분리할 수 있다.
- 큰 Layout 이벤트가 모두 forced reflow인 것은 아니다.
- 낮은 위험으로 제거할 수 있는 강제 reflow를 제거한 뒤에는, 디자인·API·렌더 구조가 다음 성능 레버가 된다.
성능 작업의 마무리는 목표 숫자를 달성했다고 선언하는 순간만은 아니다. 같은 곳을 다시 파지 않도록 여기까지의 증거와 한계를 남기고, 다음 문제를 올바른 소유자에게 넘기는 것도 마무리다.