내부 비공개 저장소의 작업을 바탕으로 썼다. 외부에서 접근할 수 없어 링크는 생략하고, 수치는 근삿값으로 표현해 식별 가능성을 낮췄다.

지난 글에서 커머스 앱 주문서의 흰 화면 원인은 네트워크가 아니라 렌더링이었고, 렌더링 비용의 큰 부분이 강제 동기 레이아웃(forced reflow)이었다고 적었다. 원인을 확인한 첫 실험은 한 줄로 요약된다. 첫 페인트 전에 컨테이너 너비를 읽던 코드를 첫 페인트의 임계 경로 밖으로 옮겼다. useLayoutEffectuseEffect로 바꾼 것이다.

실험 diff만 보면 훅 이름 하나가 바뀐 것처럼 보인다. 그런데 그 한 줄에는 맞교환이 숨어 있었다. 첫 페인트를 막던 reflow를 임계 경로에서 빼는 대신, 짧은 깜빡임(flash)이 생겼다. 정식 수정에서는 useEffect 안에서 offsetWidth를 직접 읽지 않고 ResizeObserver가 건네는 contentRect.width를 사용했다. 이 글은 그 결론에 이르게 한 실험과, 그 맞교환이 두 훅의 핵심 차이인 실행 시점과 어떻게 연결되는지에 대한 기록이다.

TL;DR

  • 썸네일을 몇 개까지 보여줄지 정하려고 컨테이너 너비를 쟀는데, 원래는 useLayoutEffect(첫 페인트 전)에서 offsetWidth로 읽었다.
  • useLayoutEffect를 쓴 의도는 “정확한 개수를 첫 페인트에 바로 그려서 깜빡임을 막자”였다.
  • 그런데 DOM 반영으로 레이아웃이 무효화된 직후 offsetWidth를 읽자 강제 동기 reflow가 첫 페인트의 임계 경로에 들어왔다. 이게 렌더링 비용이었다.
  • useEffect로 옮기는 실험에서는 그 동기 측정이 첫 페인트를 막지 않게 되면서 측정 구간이 약 100ms 줄었다. 대신 기본값이 먼저 그려졌다가 실측값으로 바뀌는 짧은 flash가 생겼다.
  • 즉 현재처럼 자바스크립트 실측값으로 노출 개수를 정하는 구조에서는 “정확한 첫 페인트(reflow를 감수함)”와 “빠른 첫 페인트(flash를 감수함)”가 맞교환이다.
  • 정식 수정은 useEffectResizeObservercontentRect를 사용해 일반 경로의 동기 레이아웃 읽기를 제거했다. 다만 실측값이 나중에 반영되는 flash 가능성은 받아들였다.
  • useLayoutEffect는 “더 안전한 useEffect”가 아니다. 첫 페인트를 막고 먼저 실행된다는 그 타이밍이 비용이자 쓰임새다.

원래 왜 useLayoutEffect였나

컴포넌트는 접힌 상태에서 상품 썸네일을 두 줄까지만 보여주고, 넘치면 “+N” 버튼으로 접는다. 몇 개까지 보여줄지는 화면 너비에 달렸다. 넓으면 한 줄에 다섯 개, 좁으면 네 개다. 그래서 컨테이너의 실제 너비를 재서 “한 줄에 몇 개 → 두 줄에 몇 개 → 나머지는 +N”을 계산했다.

이 계산을 useLayoutEffect에서 했다. 이유는 분명하다. 첫 페인트 전에 너비를 재두면, 사용자는 처음부터 정확한 개수를 본다. 만약 늦게 재면, 일단 어림잡은 기본값으로 그렸다가 실측값으로 바뀌는 깜빡임이 보일 것이다. 그 깜빡임을 막으려고, 페인트 전에 재는 useLayoutEffect를 골랐다.

이건 흔한 조언이기도 하다. “레이아웃을 측정해서 그려야 하면 useLayoutEffect를 써라. useEffect로 하면 깜빡인다.” 맞는 말이다. 다만 그 조언은 비용을 말하지 않는다.

DOM 반영 직후의 크기 읽기는 reflow를 앞당긴다

offsetWidth, getBoundingClientRect 같은 레이아웃 속성을 읽는다고 언제나 reflow가 발생하는 것은 아니다. 하지만 DOM이나 스타일 변경으로 레이아웃이 무효화된 상태에서 이런 값을 읽으면, 브라우저는 정확한 값을 알려주기 위해 그 자리에서 레이아웃을 계산한다. 스크립트 실행을 멈추고 즉시 계산하는 이것이 강제 동기 레이아웃, 흔히 말하는 forced reflow다.

문제는 타이밍이다. useLayoutEffect브라우저가 다시 페인트하기 전에 실행되고, 그 안의 상태 변경까지 페인트 전에 처리한다. 이 사례에서는 DOM 반영 직후 너비를 읽으면서 미뤄둘 수 있었던 레이아웃 계산을 즉시 실행했다. 게다가 그 값으로 상태를 바꾸자(setState) 렌더링과 후속 레이아웃 작업이 다시 이어졌다. 첫 페인트 한 번을 위해 레이아웃 계산이 반복될 수 있는 경로가 만들어진 셈이다.

주문서처럼 데이터가 늦게 도착한 뒤 한꺼번에 그려지는 화면에서는, 이 “도착 후 한 번에 그리는” 순간이 이미 무겁다. 거기에 페인트 전 강제 레이아웃이 겹치면, 흰 화면이 그만큼 길어진다. 트레이스로 뜯어보니, 데이터 도착 후 콘텐츠를 그리는 구간의 큰 비용 중 하나가 정확히 이 지점이었다.

useEffect로 옮기면: 첫 페인트는 빨라지고 flash가 온다

첫 실험은 재는 시점을 뒤로 미루는 것이었다. useEffectuseLayoutEffect와 달리 브라우저의 페인트를 막지 않는다. 이번처럼 사용자 상호작용에서 시작되지 않은 초기 Effect에서는 브라우저가 기본값으로 첫 페인트를 마친 뒤 Effect가 실행될 수 있다. 그러면 너비를 재는 작업과 그 결과를 반영하는 렌더링이 첫 페인트의 임계 경로 밖으로 빠진다. 로컬에서 운영과 비슷한 조건으로 재보니, 데이터 도착 후 그리는 구간이 약 840ms에서 750ms 안팎으로 줄었다. 대략 100ms였다.

그런데 공짜가 아니었다. 이 경로에서는 첫 페인트가 실측 전의 기본값으로 그려질 수 있다. 서버 렌더링 기본값은 여덟 개였고, 좁은 화면에서 실측하면 일곱 개가 맞았다. 그래서 순서가 이렇게 된다. 여덟 개로 한 번 그려지고 → useEffect가 돌아 일곱 개로 보정되고 → 다시 그려진다. 사용자 눈에는 짧은 깜빡임으로 보인다. CPU throttling을 크게 적용해 녹화하니 그 시간이 늘어나 또렷하게 드러났다.

이게 원래 useLayoutEffect가 막으려던 바로 그 깜빡임이다. 막으려고 페인트 전에 쟀고, 그 대가가 동기 레이아웃 계산이었다. 첫 페인트의 임계 경로에서 이 측정을 빼자, 막았던 깜빡임이 돌아왔다.

이건 공짜 교환이 아니다

여기서 분명해진다. 내가 원한 건 세 가지였다. 정확한 개수, 깜빡임 없음, 첫 페인트를 막는 reflow 없음. 그런데 현재처럼 자바스크립트로 컨테이너를 실측한 뒤 상태를 바꿔 노출 개수를 정하는 구조에서는 이 셋을 동시에 갖기 어렵다.

  • 정확한 개수를 첫 페인트에 그리려면 → 페인트 전에 너비를 알아야 하고 → 현재 구현에서는 동기 측정과 reflow를 감수해야 한다.
  • 동기 측정을 첫 페인트의 임계 경로에서 빼려면 → 실측값을 나중에 반영해야 하고 → 첫 페인트는 기본값이라 깜빡일 수 있다.

물론 CSS Grid나 컨테이너 쿼리만으로 표현하거나, 첫 렌더 전에 정확한 너비를 이미 알 수 있다면 이 전제를 바꿀 수 있다. 하지만 “+N” 개수를 자바스크립트 상태로 계산하는 현재 구조에서는 레이아웃이 무효화된 상태에서 DOM 크기를 읽는 시점이 비용을 결정했다. 고르는 축은 결국 언제 재느냐 = 어느 훅과 관찰 방식에 붙이느냐였다.

이번 문제에서 중요한 차이는 “언제 실행되나”

리액트의 Effect 훅들은 용도와 제약이 다르다. 이번 문제를 이해할 때 가장 중요한 차이는 브라우저 페인트를 기준으로 언제 실행되고, 페인트를 막는가였다.

렌더 → DOM 반영 → useLayoutEffect → [브라우저 페인트] → useEffect(대체로)
  • useInsertionEffect: 레이아웃 Effect보다 먼저 동적 스타일을 주입하기 위한, CSS-in-JS 라이브러리용 훅이다. DOM이 이미 반영됐는지는 보장하지 않으므로 레이아웃 측정용으로 쓰면 안 된다.
  • useLayoutEffect: DOM 반영 후, 페인트 전. 여기서 읽고 상태를 바꾸면 그 결과까지 첫 페인트에 반영된다. 그동안 브라우저 페인트는 막힌다.
  • useEffect: 브라우저 페인트를 막지 않는다. 이번과 같은 비상호작용 Effect는 대체로 첫 페인트 뒤에 실행되므로, 시각적 상태 변경이 사용자에게 보일 수 있다.

정확히는 사용자 상호작용에서 시작된 useEffect가 페인트 전에 실행되는 경우도 있다. 따라서 핵심은 단순한 전후 순서보다 useLayoutEffect는 페인트 전 실행을 보장하며 페인트를 막고, useEffect는 페인트를 막지 않는다는 것이다. 이번 실험은 이 컴포넌트의 측정을 페인트를 막는 경로에서 빼냈고, reflow와 flash의 맞교환은 그 선택의 다른 이름이었다.

그래서 무엇을 골라야 하나

정답은 상황마다 다르다. 이번에는 페인트를 막지 않는 쪽, 즉 flash 가능성을 감수하는 쪽이 맞았다. 흰 화면을 줄이는 게 목표였고, 첫 페인트를 막는 reflow가 바로 그 목표를 갉아먹고 있었기 때문이다. 썸네일 개수가 잠깐 뒤늦게 보정되는 것은 화면이 100ms 빨리 뜨는 것에 비하면 영향이 작았다.

실험으로 선택을 확인한 뒤 정식 수정에서는 useEffectResizeObserver를 함께 썼다. 일반 경로에서는 offsetWidth를 직접 읽지 않고, 브라우저가 이미 계산해 전달한 contentRect.width로 개수를 정했다. ResizeObserver가 없는 환경에서만 기존 offsetWidth 측정을 한 번 수행하도록 폴백을 남겼다. 첫 페인트를 막는 동기 측정은 피하면서, 화면 너비 변화에 대응하는 동작은 유지한 것이다.

게다가 솔직히, 그 깜빡임은 실제로는 잘 보이지도 않았다. 그 페이지는 데이터가 늦게 도착해 여러 섹션이 “빈 껍데기 → 실제 값”으로 한꺼번에 바뀌는 화면이라, 배송지도 주소가 나중에 채워지고, 다른 값들도 뒤늦게 들어온다. 그 큰 움직임들 속에서 썸네일 하나가 여덟 개에서 일곱 개로 바뀌는 건 묻혔다. 굳이 그 깜빡임까지 지우려면 레이아웃을 CSS만으로 결정하거나 첫 렌더의 기본값을 뷰포트별로 더 정확하게 추정할 수도 있었지만, 이번 목표엔 과했다.

하지만 그건 이 화면의 사정이고, 반대인 경우도 많다. 첫 화면에 값이 또렷하게 한 번 바뀌는 게 눈에 거슬리는 UI라면, 그땐 페인트 전 동기 측정 비용을 감수하고 useLayoutEffect로 첫 페인트를 정확히 그리는 게 맞다. 요점은 어느 쪽이 항상 옳은 게 아니라, 무엇을 내주는지 알고 고르는 것이다.

남는 생각

useLayoutEffect를 “깜빡임을 막아주는, 더 안전한 useEffect”쯤으로 기억하기 쉽다. 실제로 깜빡임은 막아준다. 다만 그건 첫 페인트를 자기 뒤로 미루는 대가로 그렇게 하는 것이다. 그 대가가 이번처럼 성능이 중요한 렌더 경로에서는 꽤 크게 나온다.

그래서 이 훅을 고를 때 질문은 “깜빡임을 막고 싶은가”가 아니라 “이 측정을 첫 페인트보다 먼저 해야 할 만큼 정확한 첫 화면이 중요한가”여야 한다. 그 질문에 그렇다고 답할 수 있을 때만 페인트를 미룰 값어치가 있다. 아니라면 빠르게 그리고 한 박자 뒤에 보정하는 편이 낫다.

한 줄짜리 diff였지만, 그 한 줄이 고르라고 요구한 건 결국 이 질문이었다.

함께 읽기