useEffect 의존성에 ref를 담을 때마다 찜찜하신 분들을 위해

sangho.moon·2022년 3월 24일
30
post-thumbnail

🥱 TLDR;

ref가 변경되어도 컴포넌트가 새로 렌더링되지는 않으므로, useEffect는 ref의 변경에 대해 즉각 감지를 할 수 없습니다. 다른 값 변경 등의 이유로 컴포넌트가 렌더링이 새로 됐을 때 비로소 변경된 ref 값을 볼 수 있게 됩니다.
따라서 다음 조건에 따라 각 ref의 갱신을 관리하는 것이 좋습니다.

  • 모든 렌더링 시마다 트리거되는 useEffect가 아니라면, 사용할 ref는 useCallback + callback ref로 작성하기
  • 모든 렌더링 시마다 트리거되는 useEffect라면, useRef의 값을 의존성으로 담아도 됨

다들 이런 경험 있죠?

리액트 프레임워크로 개발을 하다보면, DOM 노드의 사이즈(width, height)나 포지션(top, bottom, ...)값을 구해야하는 작업이 필요할 때가 있죠.
저도 종종 이런 상황을 겪는데요, 그 중 하나로 DOM 노드에 ref를 등록해서 height 값을 업데이트 시켜주는 커스텀 훅을 작성했었습니다. 코드는 다음과 같습니다.

// ref object에 DOM 노드가 설정되면 height 값을 설정해주는 커스텀 훅
const useGettingHeight = () => {
  const [height, setHeight] = useState(null);
  const ref = useRef<HTMLElement>();

  // ref에 DOM 노드가 설정되는 것을 감지하여 height 설정(?)
  useEffect(() => {
    if (!!ref.current) setHeight(ref.current.getBoundingClientRect().height);
  }, [ref.current]);

  return [height, ref];
};

function App() {
  const [height, ref] = useGettingHeight();

  return (
    <div ref={ref} style={{ height: `${height}px` }}>
      이렇게 작성해도 될까요?
    </div>
  );
}

위 코드는 정상적으로 <div>에 인라인 스타일 height: 18px 값이 설정되기는 합니다. 언뜻 보기에는 문제가 없는 코드같아요. 그런데 과연 진짜 문제가 없는 코드일까요?🤔

useEffect에 ref object를 의존성으로 주입하는 것의 진짜 문제

위 예시에서 우리는 useEffect의 두 번째 인자로 ref.current를 추가함에 따라, useEffect가 ref 변경을 감지하여 setHeight이 실행된다고 이해하기 쉬운데요.

아쉽게도 이는 잘못된 이해입니다.
왜냐하면 ref는 컴포넌트가 다시 렌더링될 때까지 값이 업데이트 되었는지 확인할 수 없거든요. 또한 ref의 값이 갱신되어도 컴포넌트는 새로 렌더링이 되지 않습니다. 다른 이유로 인해 컴포넌트가 다시 렌더링되어서야 갱신된 값을 확인할 수 있어요. 이 말은 곧, 렌더링을 건너뛰는 useEffect는 다음 렌더링 전에는 ref에 대한 어떤 변경도 볼 수 없다는 것을 뜻합니다.

무슨 말인지 좀 어려우신가요? 아래 예시를 보면 좀 더 이해가 수월하실 거에요.

const useGettingHeight = () => {
  const [height, setHeight] = useState(null);
  const ref = useRef<HTMLElement>();

  useEffect(() => {
    if (!!ref.current) setHeight(ref.current.getBoundingClientRect().height);
  }, [ref.current]);

  return [height, ref];
};

const useLoading = () => {
  const [loading, setLoading] = useState(true);

  // 컴포넌트 마운트 시 loading false로 업데이트
  useEffect(() => setLoading(false), []);
  return loading;
};

function App() {
  // 첫 렌더링 시 ref가 attached됨
  const [height, ref] = useGettingHeight();

  // loading 값이 true가 된 이후 ref가 attached됨
  const [lazyHeight, lazyRef] = useGettingHeight();
  const loading = useLoading();

  return (
    <>
      <div ref={ref}>ref height: {height.toString()}</div>
      {!loading && <div ref={lazyRef}>Lazy ref height: {lazyHeight.toString()}</div>}
    </>
  );
}

위에서 작성했던 DOM 노드의 height을 가져오는 훅을 사용했습니다. 눈 여겨볼 점은 useLoading가 추가된 것인데요, 컴포넌트 마운트 이후에 loading 값이 true가 되면서 추가로 두 번째 <div>가 렌더링되고 ref에 DOM node 참조값이 담깁니다. 첫 번째 ref와의 구분을 위해 lazyRef라고 명명했습니다.

이 코드를 실행하면 어떻게 될까요? 둘 다 height을 정상적으로 출력할까요?

링크를 클릭해서 직접 확인해보세요 Link

실행 결과

컴포넌트 마운트 이후에 설정되었던 lazyRef의 height은 값이 설정되지 않았습니다. 결과가 이해가 되지 않는다면 다음의 실행 순서를 천천히 따라가봅시다.

  1. 컴포넌트의 첫 렌더링
    • 첫 번째 <div>가 렌더링되고, ref에 DOM node 참조값이 담깁니다.
    • lazyRef는 아직 두 번째 <div>가 렌더링되지 않았으므로 값이 없습니다.
  2. 첫 렌더링 이후 실행되는 useEffect 콜백
    • useGettingHeightref.current를 의존성으로 가지는 useEffect 콜백이 실행됩니다.
      • Note. ref.current 의존성 때문에 호출되는 것이 아닙니다! 모든 useEffect는 의존성과 관계없이 컴포넌트 첫 렌더링 이후 한 번 실행됩니다.
    • useLoadinguseEffect 콜백도 실행됩니다.
  3. useEffect에 의해 변경되는 state
    • useEffect 콜백이 처음으로 실행될 때, 첫 번째 ref.current는 DOM node 참조값을 가지고 있으므로 setHeight을 호출하여 height state를 업데이트해줍니다.
    • 두 번째 lazyRef.current는 값이 없으므로 setHeight이 호출되지 않습니다.
    • loading 값이 true가 됩니다.
  4. state 변경으로 인한 컴포넌트 리렌더링
    • 리렌더링 시, 첫 번째 height 값이 존재하므로 첫 번째 <div>의 innerText에 18이라는 값이 찍힙니다.
    • loading 값이 true이므로 두 번째 <div>가 렌더링되고, lazyRef에 DOM node 참조값이 담깁니다.
  5. 동작 끝
    • 동작은 여기서 끝이 납니다. lazyRef.current 값이 변경되었는데도 두 번째 훅의 useEffect는 호출되지 않습니다.

이제 위에서 했던 "렌더링을 건너뛰는 useEffect는 다음 렌더링 전에는 ref에 대한 어떤 변경도 볼 수 없다"는 말이 이해되시나요?

우리는 컴포넌트가 다른 state들을 다루는 마법의 방식처럼 ref의 변경에 대응할 수 있도록 할 수는 없습니다. useEffect는 ref의 변경을 즉각 감지할 수 없어요. 다만 다음 렌더링이 발생했을 때는 ref의 변경을 확인할 수 있어서 useEffect 콜백을 실행시켜 줄 수 있겠죠.

말이 길었는데 결국 한 문장으로 정리하자면 다음과 같습니다.

useEffect가 매 렌더링마다 호출되는 게 아니라면(ref의 변경을 계속해서 확인할 수 있는 상태가 아니라면), useEffect에 ref object를 의존성으로 담지 마세요🙅‍♂️

해결 방법

DOM Node를 참조하기 위한 방법으로 useRef를 사용하는 대신에, useCallback을 사용해서 Callback ref를 만드세요! 위의 useGettingHeight hook을 이를 사용하여 수정해보겠습니다.

const useGettingHeight = () => {
  const [height, setHeight] = useState(null);

  // ✅  useRef와 useEffect를 지우고 callback ref를 새로 작성
  const ref = useCallback((node: HTMLElement) => {
    if (node !== null) {
      setHeight(node.getBoundingClientRect().height);
    }
  }, []);

  return [height, ref];
};

function App() {
  const [height, ref] = useGettingHeight();
  const [lazyHeight, lazyRef] = useGettingHeight();
  const loading = useLoading();

  return (
    <>
      <div ref={ref}>ref height: {height.toString()}</div>
      {!loading && <div ref={lazyRef}>Lazy ref height: {lazyHeight.toString()}</div>}
    </>
  );
}

커스텀 훅에서 리턴하는 ref를 callback ref의 형태로 변경한 것을 제외하고는 위의 코드와 동일합니다! 한 번 실행해볼까요?

위 링크에서 직접 코드를 수정해서 결과를 확인해보세요🙌

실행 결과

잘 작동하는군요! 리액트는 이제 ref(callback ref)가 node에 붙여질 때 이 콜백을 실행할 겁니다. 붙여지는 때도 상관없고, 다른 노드에 다시 붙게 되더라도 상관없어요. 그 때마다 해당 콜백을 실행시켜 줄겁니다. 이렇게 node를 인자로 받는 콜백이 호출되면 setHeight이 실행되어 업데이트된 height 값을 확인할 수 있게 됩니다.

추가로 useCallback에 빈 의존성([])을 전달한 것을 확인할 수 있는데요. 이렇게 하면 우리가 만든 ref 콜백이 리렌더링 시에도 참조값이 변경되지 않게끔 할 수 있습니다. 따라서 리액트가 매 렌더링마다 불필요하게 콜백을 호출하지 않을 거에요.


정리

ref가 변경되어도 컴포넌트가 새로 렌더링되지는 않으므로, useEffect는 ref의 변경에 대해 즉각 감지를 할 수 없습니다. 다른 값 변경 등의 이유로 컴포넌트가 렌더링이 새로 됐을 때 비로소 변경된 ref 값을 볼 수 있게 됩니다.
아마 지금까지 ref를 useEffect에 의존성으로 담으며 찜찜함을 느끼셨을 분들이 있을 겁니다. state를 의존성으로 담았을 때와는 미묘하게 다른 현상(값의 변경이 확인이 안되는)을 겪으신 분들, 이제 자신있게 작성하실 수 있겠죠?

  • 모든 렌더링 시마다 트리거되는 useEffect가 아니라면, 사용할 ref는 useCallback + callback ref로 작성하기
  • 모든 렌더링 시마다 트리거되는 useEffect라면, 그대로 useRef의 값을 의존성으로 담아도 됨

아래 리액트 공식 문서의 Hooks FAQ에 관련된 내용이 좀 더 상세하게 나와있습니다. 더 명확한 이해를 위해 추가로 읽어보시는 것을 권장합니다🙆‍♂️

참고

profile
개발자와 디제이 두 개의 자아를 실현 중인 프론트엔드 개발자입니다.

1개의 댓글

comment-user-thumbnail
2024년 1월 18일

정말 이해가 안되었던 내용인데 이해가 쏙쏙되는 글인것 같가요
감사합니다

답글 달기