[번역] useEffect는 종종 페인트(paint) 이전에 동작합니다.

강엽이·2023년 7월 30일
62
post-thumbnail

원문 : https://blog.thoughtspile.tech/2021/11/15/unintentional-layout-effect/

useEffect는 업데이트 차단을 방지하기 위해 페인트 이후에 동작해야 합니다. 하지만 실제로는 페인트 후에 useEffect가 실행된다는 보장이 없다는 것을 알고 계셨나요? useLayoutEffect에서 상태를 업데이트하면 동일한 렌더링의 모든 useEffect가 페인트 전에 실행되므로 레이아웃 이펙트로 전환됩니다. 헷갈리시나요? 설명해드리겠습니다.

일반적인 흐름에서 리액트의 업데이트는 다음과 같이 진행됩니다.
1. 리액트 작업: 가상 DOM 렌더, 이펙트 스케쥴링, 실제 DOM 업데이트
2. useLayoutEffect 호출
3. 리액트가 제어를 해제하고, 브라우저가 새 DOM을 페인트 합니다.
4. useEffect 호출

리액트 문서레이아웃과 페인트 이후, 지연된 이벤트 중에 발생한다고 나와있지만, 정확히 언제 useEffect가 실행되는지 나와 있지 않습니다. 저는 항상 setTimeout(effect, 3)이라고 생각했지만, 깔끔한 MessageChannel 트릭을 사용하는 것으로 보입니다.

하지만, 문서에 더 흥미로운 구절이 있습니다.

useEffect는 브라우저가 페인팅할 때까지 지연되지만, 새로운 렌더링이 시작되기 전에 실행되도록 보장됩니다. 리액트는 항상 새 업데이트를 시작하기 전에 이전 렌더링의 이펙트를 실행(flush) 합니다.

이것은 좋은 보장입니다. 업데이트를 놓치지 않도록 보장할 수 있습니다. 그러나 이는 때때로 이펙트 페인트 전에 실행될 수 있음을 의미하기도 합니다. a) 새 업데이트가 시작되기 전에 이펙트가 실행 되고, b) 페인트 전에 업데이트가 시작될 수 있는 경우(예를 들어, useLayoutEffect를 통해 트리거 되는 경우) 이펙트는 페인트 전인 해당 업데이트 전에 실행 되어야 합니다. 다음은 이의 타임라인입니다.

  1. 리액트 업데이트 1: 가상 DOM 렌더링, 이펙트 스케쥴링, DOM 업데이트
  2. useLayoutEffect 호출
  3. 상태 업데이트, 리렌더링 스케쥴링
  4. useEffect 호출
  5. 리액트 업데이트 2
  6. 업데이트 2로 부터 useLayoutEffect 호출
  7. 리액트가 제어를 해제하고 브라우저가 새 DOM을 페인팅
  8. 업데이트 2로 부터 useEffect 호출

이 상황은 드문 일이 아닙니다. useEffect에서 상태를 업데이트할 수 없기 때문입니다. 상태를 업데이트하면 DOM이 업데이트되고, 페인트 후에 업데이트하면 사용자에게 오래된 프레임이 하나 남게 되어 눈에 띄는 깜박임이 발생합니다.

예를 들어, 입력이 300px 보다 넓은 경우에만 지우기 버튼을 렌더링하는 반응형 입력창(가짜 CSS 컨테이너 쿼리 와 같은)을 빌드해 보겠습니다. 입력창을 측정하려면 실제 DOM이 필요하므로 약간의 효과가 필요합니다. 또한 아이콘이 한 프레임 후에 나타나거나/사라지는 것을 원하지 않으므로 초기 측정값은 useLayoutEffect로 들어갑니다.

const ResponsiveInput = ({ onClear, ...props }) => {
  const el = useRef();
  const [w, setW] = useState(0);
  const measure = () => setW(el.current.offsetWidth);
  useLayoutEffect(() => measure(), []);
  useEffect(() => {
    // 깊게 생각하지 말고, ResizeObserver라고 생각하세요. 
    window.addEventListener("resize", measure);
    return () => window.removeEventListener("resize", measure);
  }, []);
  return (
    <label>
      <input {...props} ref={el} />
      {w > 200 && <button onClick={onClear}>clear</button>}
    </label>
  );
};

useEffect로 페인트 후까지 addEventListener를 지연시키려고 했지만, useLayoutEffect의 상태 업데이트로 인해 페인트 전에 발생하도록 했습니다. (sandbox 참고)

업데이트가 초기 이펙트 실행을 강제하는 곳은 useLayoutEffect뿐이 아닙니다. 호스트 참조(<div ref={HERE}>), requestAnimationFrame 루프 및 useLayoutEffect에서 예약된 마이크로태스크도 동일한 동작을 발생시킵니다.

어떤 상황에서는 렌더링 흐름이 최적이 아닐 수도 있지만, 누가 신경 쓰겠습니까? 그래도 도구의 한계를 아는 것은 유용합니다. 다음은 4가지 실용적인 교훈입니다.

업데이트 후 동작하는 useEffect에 의존하지 마세요.

이런 함정을 알고 있더라도, 일부 useEffectuseLayoutEffect에 영향을 받지 않도록 보장하는 것은 매우 어렵습니다.

  1. 컴포넌트가 useLayoutEffect를 사용하지 않더라도 usePopper같은 사용자 정의 훅이 사용하지 않는 것을 보장하나요?
  2. 컴포넌트가 내장된 리액트 훅만을 사용하더라도 트리 위쪽의 useLayoutEffect 상태 업데이트는 useContext 또는 부모의 리렌더를 통해 누수될 수 있습니다.
  3. 컴포넌트가 useEffectmemo()만 사용하더라도 업데이트의 이펙트는 전역적으로 플러시되므로 다른 컴포넌트의 사전 페인트 업데이트는 여전히 자식 이펙트를 실행합니다.

많은 훈련을 통해 useLayoutEffect에서 상태 업데이트가 없는 코드베이스를 가질 수 있지만, 그것은 초인적인 일입니다. 가장 좋은 조언은 useMemo가 100% 안정적인 참조를 보장하지 않는 것처럼 페인트 후 useEffect에 의존하지 말라는 것입니다. 사용자에게 한 프레임 동안 그려진 무언가를 보여주고 싶다면 useEffect가 아닌 requestAnimationFrame을 두 번 시도하거나 postMessage 트릭을 사용하세요.

반대로 리액트 팀의 좋은 조언을 듣지 않고 useEffect에서 DOM을 업데이트했다고 가정해 봅시다. 테스트 해보니 깜박임이 없습니다. 나쁜 소식 - 아마도 페인트 하기 전의 상태 업데이트의 결과일 수 있습니다. 코드를 조금 수정하면 깜빡거릴 것입니다.

레이아웃 이펙트를 분할하는 데 시간을 낭비하지 마세요.

useEffect vs useLayoutEffect 의 가이드라인을 그대로 따르면, 하나의 논리적 사이드 이펙트를 레이아웃 이펙트로 분할하여 DOM을 업데이트 하는 것과 ResponsiveInput 예시처럼 "지연된" 이펙트로 나눌 수 있습니다.

// DOM 엡데이트 = 레이아웃 이펙트
useLayoutEffect(() => setWidth(el.current.offsetWidth), []);
// 구독 = 지연 로직
useEffect(() => {
  window.addEventListener('resize', measure);
  return () => window.removeEventListener('resize', measure);
}, []);

하지만 이제 알다시피 이것은 아무 소용이 없습니다. 렌더링 전에 두 이펙트 모두 플러시됩니다. 게다가 분리도 엉성합니다. 페인트 후에 useEffect가 발동한다고 가정하면 요소의 크기가 이펙트 사이에서 조정되지 않는다고 100% 확신할 수 있을까요? 그렇지 않습니다. 모든 크기 추적 로직을 단일 layoutEffect에 남겨두는 것이 더 안전하고 깔끔하며, 사전 페인트 작업의 양이 동일하고, 리액트가 관리해야 할 이펙트가 줄어드는 더 좋은 방법입니다.

useLayoutEffect(() => {
  setWidth(el.current.offsetWidth);
  window.addEventListener('resize', measure);
  return () => window.removeEventListener('resize', measure);
}, []);

useLayoutEffect에서 상태를 업데이트 하지 마세요.

좋은 조언이지만 말처럼 쉽지는 않습니다. useEffect는 상태를 업데이트하기에 더 나쁜 곳입니다. 왜냐하면 깜빡임은 나쁜 UX를 초래하고, UX는 성능보다 더 중요하기 때문입니다. 렌더링 중에 상태를 업데이트 하는 것은 위험해 보입니다.

가끔씩 상태를 useRef로 안전하게 대체할 수 있습니다. 참조를 업데이트해도 업데이트가 트리거되지 않고 의도한 대로 이펙트가 실행될 수 있습니다. 이러한 경우 중 몇 가지를 살펴보는 게시물이 있습니다.

가능하다면 이펙트에 의존하지 않는 상태 모델을 생각해 보시길 바라지만, 명령에 따라 "좋은" 상태 모델을 발명하는 방법은 잘 모르겠습니다.

상태 업데이트 우회

특정 useLayoutEffect가 문제를 일으키는 경우 상태 업데이트를 우회하고 DOM을 직접 변경하는 것을 고려해 보세요. 이렇게 하면 리액트가 업데이트를 스케쥴링하지 않고 이펙트를 열심히 실행할 필요가 없습니다. 이렇게 시도해보세요.

const clearRef = useRef();
const measure = () => {
  // 걱정마, 리액트. 내가 처리할게.
  clearRef.current.display = el.current.offsetWidth > 200 ? null : none;
};
useLayoutEffect(() => measure(), []);
useEffect(() => {
  window.addEventListener("resize", measure);
  return () => window.removeEventListener("resize", measure);
}, []);
return (
  <label>
    <input {...props} ref={el} />
    <button ref={clearRef} onClick={onClear}>clear</button>
  </label>
);

이 방법은 이전 글useState 피하기에서 살펴본 적이 있는데, 이제 리액트 업데이트를 건너뛰어야 할 이유가 하나 더 생겼습니다. 하지만 DOM 업데이트를 수동으로 관리하는 것은 복잡하고 오류가 발생하기 쉬우므로 이 기법은 매우 중요한 상황(매우 중요한 컴포넌트 또는 매우 무거운 useEffect)에만 사용하세요.


오늘 우리는 가끔 useEffect가 페인트 전에 실행되는 것을 발견했습니다. 자주 발생하는 원인은 useLayoutEffect에서 상태를 업데이트 할 때 페인트 전에 다시 렌더링을 요청하고, 이펙트가 다시 렌더링하기 전에 실행되어야 하기 때문입니다. RAF 또는 마이크로태스크에서 상태를 업데이트할 때도 이런 문제가 발생합니다. 이것이 우리에게 의미하는 바는 다음과 같습니다.

  1. useLayoutEffect에서 상태를 업데이트 하는 것은 앱 성능에 좋지 않습니다. 이렇게 하지 않는 것이 좋지만, 때로는 좋은 대안이 없을 수도 있습니다.
  2. 페인트 후에 동작하는 useEffect에 의존하지 마세요.
  3. useEffect에서 DOM을 업데이트하면 깜빡임이 발생할 수 있습니다. 아마 여러분이 깜빡임을 보지 못했다면, 그것은 레이아웃 이펙트에서 상태를 업데이트 했기 때문일 것입니다.
  4. 레이아웃 이펙트에서 상태를 설정하는 경우 성능을 위해 useLayoutEffect의 일부를 useEffect로 추출하는 것은 의미가 없습니다.
  5. 성능이 중요한 경우 useLayoutEffect에서 DOM을 수동으로 변경해야 하는 또 하나의 이유입니다.
profile
FE Engineer

3개의 댓글

comment-user-thumbnail
2023년 7월 30일

좋은 정보 얻어갑니다, 감사합니다.

답글 달기
comment-user-thumbnail
2023년 8월 1일

몰랐던 부분인데, 좋은 글 잘 읽었습니다!

답글 달기
comment-user-thumbnail
2023년 8월 9일

정말 좋은 내용이네요! 많은 분들이 보셨으면 좋겠습니다 :)

답글 달기