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

sangho.moon·2022년 3월 24일
32
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

실행 결과

problem of ref with useEffect

컴포넌트 마운트 이후에 설정되었던 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의 형태로 변경한 것을 제외하고는 위의 코드와 동일합니다! 한 번 실행해볼까요?

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

실행 결과

use pattern of ref callback with useCallback

잘 작동하는군요! 리액트는 이제 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일

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

답글 달기