React Callback Refs: 그들이 무엇인지 및 어떻게 사용하는지

개발 중에는 종종 DOM 요소와 직접 상호 작용해야 합니다. 이러한 경우 React는 refs라는 메커니즘을 제공하여 렌더링된 후에 요소에 액세스할 수 있게 합니다. 일반적으로 우리는 useRef를 통해 표준 객체 refs를 사용하지만, 콜백 refs라고 알려진 다른 접근 방식도 있습니다. 이 방법은 요소의 라이프사이클을 보다 유연하게 제어할 수 있어 DOM에 연결하거나 분리될 때 정확한 시점에 특정 작업을 수행할 수 있습니다. 

이 글에서는 콜백 refs가 무엇인지, 어떻게 작동하는지 설명하고, 만날 수 있는 함정을 논의하며 사용 예제를 보여드릴 것입니다.

콜백 Refs가 무엇이며 어떻게 작동하는지?

콜백 refs는 객체 refs보다 ref 첨부에 대해 보다 세분화된 제어를 제공합니다. 실제로 어떻게 작동하는지 살펴보겠습니다:

  1. 마운팅. 요소가 DOM에 마운트될 때, React는 DOM 요소 자체와 함께 ref 함수를 호출합니다. 이를 통해 페이지에 나타난 직후 요소와 작업을 수행할 수 있습니다.
  2. 언마운팅. 요소가 언마운트될 때, React는 ref 함수를 null과 함께 호출합니다. 이를 통해 해당 요소와 관련된 작업을 정리하거나 취소할 수 있는 기회가 주어집니다.

예: 마운트 및 언마운트 추적

TypeScript

 

import React, { useCallback, useState } from 'react';

function MountUnmountTracker() {
  const [isVisible, setIsVisible] = useState(false);

  const handleRef = useCallback((node: HTMLDivElement | null) => {
    if (node) {
      console.log('Element mounted:', node);
    } else {
      console.log('Element unmounted');
    }
  }, []);

  return (
    <div>
      <button onClick={() => setIsVisible((prev) => !prev)}>
        {isVisible ? 'Hide' : 'Show'} element
      </button>
      {isVisible && <div ref={handleRef}>Tracked element</div>}
    </div>
  );
}

export default MountUnmountTracker;

각 요소의 가시성을 토글할 때마다 handleRef 함수가 호출되며 노드 또는 null이 전달되어 요소가 부착되거나 분리되는 시점을 추적할 수 있습니다.

일반적인 문제 및 해결책

문제: 반복된 콜백 Ref 호출

콜백 Ref를 사용할 때 자주 발생하는 문제는 컴포넌트의 재 렌더마다 ref 함수가 반복적으로 생성되는 것입니다. 이로 인해 React는 새로운 ref로 간주하고 이전 ref를 null로 호출하여(정리) 새로운 ref를 초기화합니다. 심지어 요소나 컴포넌트가 실제로 변경되지 않았더라도입니다. 이는 원치 않는 부작용을 일으킬 수 있습니다.

문제의 예

Basic 컴포넌트를 고려해보세요. 이 컴포넌트에는 콜백 ref를 사용하여 div의 가시성을 토글하는 버튼과 컴포넌트를 강제로 재 렌더하는 또 다른 버튼이 있습니다.

TypeScript

 

import React, { useState, useReducer } from 'react';

function Basic() {
  const [showDiv, setShowDiv] = useState(false);
  const [, forceRerender] = useReducer((v) => v + 1, 0);

  const toggleDiv = () => setShowDiv((prev) => !prev);

  const refCallback = (node: HTMLDivElement | null) => {
    console.log('div', node);
  };

  return (
    <div>
      <button onClick={toggleDiv}>Toggle Div</button>
      <button onClick={forceRerender}>Rerender</button>
      {showDiv && <div ref={refCallback}>Example div</div>}
    </div>
  );
}

export default Basic;

다시 렌더링 버튼을 클릭할 때마다 컴포넌트가 다시 렌더링되어 새로운 refCallback 함수가 생성됩니다. 결과적으로 React는 이전의 refCallback(null)을 호출한 다음 새로운 refCallback(node)을 호출하는데, 실제로 ref가 있는 요소가 변경되지 않았습니다. 콘솔에서는 div null 그리고 그 다음에 div [node]이 반복적으로 표시됩니다. 물론 이와 같은 불필요한 호출을 피하고 싶습니다.

해결책: useCallback으로 콜백 Ref를 메모이제이션하기

이를 피하는 것은 매우 간단합니다: 함수를 메모이제이션하기 위해 useCallback을 사용하십시오. 이렇게 하면 함수는 종속성이 변경될 때를 제외하고 재 렌더마다 변경되지 않습니다.

TypeScript

 

import React, { useState, useCallback, useReducer } from 'react';

function Basic() {
  const [showDiv, setShowDiv] = useState(false);
  const [, forceRerender] = useReducer((v) => v + 1, 0);

  const toggleDiv = () => setShowDiv((prev) => !prev);

  const refCallback = useCallback((node: HTMLDivElement | null) => {
    console.log('div', node);
  }, []);

  return (
    <div>
      <button onClick={toggleDiv}>Toggle Div</button>
      <button onClick={forceRerender}>Rerender</button>
      {showDiv && <div ref={refCallback}>Example div</div>}
    </div>
  );
}

export default Basic;

이제 refCallback은 초기 렌더링 시에만 생성됩니다. 이후의 재렌더링에서 추가 호출을 유발하지 않으므로 불필요한 콜백을 방지하고 성능을 향상시킵니다.

콜백 Refs, useLayoutEffect 및 useEffect의 순서

특정 문제를 해결하기 위해 코드에서 콜백 Refs를 사용하는 방법에 대해 설명하기 전에, 콜백 Refs가 useEffectuseLayoutEffect 훅과 상호작용하는 방법을 이해하여 리소스 초기화 및 정리를 올바르게 구성할 수 있도록 합시다.

실행 순서

  1. 콜백 ref – DOM 요소를 렌더링한 직후에 호출되며, 효과 훅이 실행되기 전에 호출됨
  2. useLayoutEffect – 모든 DOM 변이 후에 실행되지만 브라우저가 페인트하기 전에 실행됨
  3. useEffect – 컴포넌트가 화면에 렌더링된 후에 실행됨
TypeScript

 

import React, { useEffect, useLayoutEffect, useCallback } from 'react';

function WhenCalled() {
  const refCallback = useCallback((node: HTMLDivElement | null) => {
    if (node) {
      console.log('Callback ref called for div:', node);
    } else {
      console.log('Callback ref detached div');
    }
  }, []);

  useLayoutEffect(() => {
    console.log('useLayoutEffect called');
  }, []);

  useEffect(() => {
    console.log('useEffect called');
  }, []);

  return (
    <div>
      <div ref={refCallback}>Element to watch</div>
    </div>
  );
}

export default WhenCalled;

콘솔 출력

  1. div에 대한 콜백 ref 호출: [div 요소]
  2. useLayoutEffect 호출됨
  3. useEffect 호출됨

이 순서는 콜백 Refs가 useLayoutEffectuseEffect와 같은 훅보다 먼저 트리거되는 것을 보여줍니다. 이 점을 고려하여 로직을 작성할 때 유의해야 합니다.

코드에서 콜백 Refs가 어떤 문제를 해결하나요?

먼저, 일반 객체 Refs로 일반적으로 발생하는 문제를 재현한 후에 콜백 Refs로 문제를 해결해 봅시다.

TypeScript

 

import { useCallback, useEffect, useRef, useState } from 'react';

interface ResizeObserverOptions {
  elemRef: React.RefObject<HTMLElement>;
  onResize: ResizeObserverCallback;
}

function useResizeObserver({ elemRef, onResize }: ResizeObserverOptions) {
  useEffect(() => {
    const element = elemRef.current;

    if (!element) {
      return;
    }

    const resizeObserver = new ResizeObserver(onResize);

    resizeObserver.observe(element);

    return () => {
      resizeObserver.unobserve(element);
    };
  }, [onResize, elemRef]);
}

export function UsageDom() {
  const [bool, setBool] = useState(false);
  const elemRef = useRef<HTMLDivElement>(null);

  const handleResize = useCallback((entries: ResizeObserverEntry[]) => {
    console.log('resize', entries);
  }, []);

  useResizeObserver({ elemRef, onResize: handleResize });

  const renderTestText = () => {
    if (bool) {
      return <p ref={elemRef}>Test text</p>;
    }

    return <div ref={elemRef}>Test div</div>;
  };

  return (
    <div style={{ width: '100%', textAlign: 'center' }}>
      <button onClick={() => setBool((v) => !v)}>Toggle</button>
      {renderTestText()}
    </div>
  );
}

우리는 모든 세부 정보에 대해 깊게 파고들지 않을 것입니다. 간단히 말하자면, div 또는 p 요소의 크기를 ResizeObserver를 통해 추적하고 있습니다. 초기에 모든 것이 잘 작동합니다: 마운트될 때 요소의 크기를 가져올 수 있으며, 리사이즈도 콘솔에 보고됩니다.

실제 문제는 우리가 관측하는 요소를 전환하기 위해 상태를 토글할 때 시작됩니다. 상태를 변경하고 따라서 추적된 요소를 교체하면, 우리의 ResizeObserver가 더 이상 올바르게 작동하지 않습니다. 이미 DOM에서 제거된 첫 번째 요소를 계속 관측합니다! 원래 요소로 다시 토글해도 새로운 요소에 대한 구독이 올바르게 첨부되지 않기 때문에 도움이 되지 않습니다.

참고: 다음 솔루션은 라이브러리 컨텍스트에서 작성할 수 있는 것에 대한 보다 대표적인 것입니다. 실제 프로젝트에서는 플래그, 이펙트 등의 조합을 통해 문제를 해결할 수 있습니다. 그러나 라이브러리 코드에서는 특정 컴포넌트나 해당 상태에 대한 지식이 없습니다. 이것이 콜백 참조가 우리를 도와줄 수 있는 정황입니다.

TypeScript

 

import { useCallback, useRef, useState } from 'react';

function useResizeObserver(onResize: ResizeObserverCallback) {
  const roRef = useRef<ResizeObserver | null>(null);

  const attachResizeObserver = useCallback(
    (element: HTMLElement) => {
      const resizeObserver = new ResizeObserver(onResize);
      resizeObserver.observe(element);
      roRef.current = resizeObserver;
    },
    [onResize]
  );

  const detachResizeObserver = useCallback(() => {
    roRef.current?.disconnect();
  }, []);

  const refCb = useCallback(
    (element: HTMLElement | null) => {
      if (element) {
        attachResizeObserver(element);
      } else {
        detachResizeObserver();
      }
    },
    [attachResizeObserver, detachResizeObserver]
  );

  return refCb;
}

export default function App() {
  const [bool, setBool] = useState(false);

  const handleResize = useCallback((entries: ResizeObserverEntry[]) => {
    console.log('resize', entries);
  }, []);

  const resizeRef = useResizeObserver(handleResize);

  const renderTestText = () => {
    if (bool) {
      return <p ref={resizeRef}>Test text</p>;
    }

    return <div ref={resizeRef}>Test div</div>;
  };

  return (
    <div style={{ width: '100%', textAlign: 'center' }}>
      <button onClick={() => setBool((v) => !v)}>Toggle</button>
      {renderTestText()}
    </div>
  );
}

보시다시피, useResizeObserver 훅을 다시 작성하여 콜백 참조를 사용했습니다. 리사이즈 시 발생해야 하는 (메모이즈된) 콜백을 전달하기만 하면, 요소를 몇 번이고 토글해도 리사이즈 콜백이 여전히 작동합니다. 이는 콜백 참조 덕분에 옵저버가 새로운 요소에 부착되고 우리가 원하는 시점에 이전 요소에서 분리되기 때문입니다.

여기서의 주요 이점은 우리 훅을 사용하는 개발자가 더는 내부적으로 관찰자를 추가/제거하는 로직을 걱정할 필요가 없다는 것입니다 – 우리는 해당 로직을 훅 내에 캡슐화했습니다. 개발자는 우리 훅에 콜백을 전달하고 반환된 ref를 요소에 첨부하기만 하면 됩니다.

여러 Ref를 하나로 결합하기

여기 또 다른 시나리오가 있는데, 콜백 ref가 도움이 되는 경우가 있습니다:

TypeScript

 

import { useEffect, useRef } from 'react';
import { forwardRef, useCallback } from 'react';

interface InputProps {
  value?: string;
  onChange?: React.ChangeEventHandler<HTMLInputElement>;
}

const Input = forwardRef(function Input(
  props: InputProps,
  ref: React.ForwardedRef<HTMLInputElement>
) {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    if (!inputRef.current) {
      return;
    }

    console.log(inputRef.current.getBoundingClientRect());
  }, []);

  return <input {...props} ref={ref} />;
});

export function UsageWithoutCombine() {
  const inputRef = useRef<HTMLInputElement | null>(null);

  const focus = () => {
    inputRef.current?.focus();
  };

  return (
    <div>
      <Input ref={inputRef} />
      <button onClick={focus}>Focus</button>
    </div>
  );
}

위의 코드에서는 ref를 설정한 간단한 입력 컴포넌트가 있습니다. 이 ref는 forwardRef를 사용하여 props에서 가져옵니다. 그러나 Input 컴포넌트 내에서 inputRef를 어떻게 사용해야 할까요? 아마도 외부에서 초점을 맞추기와 같은 작업을 위해 밖에서 ref가 필요할 수 있습니다. 아마도 입력 컴포넌트 자체에서 getBoundingClientRect와 같은 다른 작업을 수행하려고 할 것입니다. 프롭 ref를 내부 ref로 교체하면 외부에서 초점을 맞출 수 없게 됩니다. 그래서 이 두 ref를 어떻게 결합할까요?

여기서 다시 콜백 ref가 도움이 되는 곳입니다:

TypeScript

 

import { useEffect, useRef } from 'react';
import { forwardRef, useCallback } from 'react';

type RefItem<T> =
  | ((element: T | null) => void)
  | React.MutableRefObject<T | null>
  | null
  | undefined;

function useCombinedRef<T>(...refs: RefItem<T>[]) {
  const refCb = useCallback((element: T | null) => {
    refs.forEach((ref) => {
      if (!ref) {
        return;
      }

      if (typeof ref === 'function') {
        ref(element);
      } else {
        ref.current = element;
      }
    });
  }, refs);

  return refCb;
}

interface InputProps {
  value?: string;
  onChange?: React.ChangeEventHandler<HTMLInputElement>;
}

const Input = forwardRef(function Input(
  props: InputProps,
  ref: React.ForwardedRef<HTMLInputElement>
) {
  const inputRef = useRef<HTMLInputElement>(null);
  const combinedInputRef = useCombinedRef(ref, inputRef);

  useEffect(() => {
    if (!inputRef.current) {
      return;
    }

    console.log(inputRef.current.getBoundingClientRect());
  }, []);

  return <input {...props} ref={combinedInputRef} />;
});

export function UsageWithCombine() {
  const inputRef = useRef<HTMLInputElement | null>(null);

  const focus = () => {
    inputRef.current?.focus();
  };

  return (
    <div>
      <Input ref={inputRef} />
      <button onClick={focus}>Focus</button>
    </div>
  );
}

설명

개발자가 표준 ref, 콜백 ref 및 선택적으로 null 또는 undefined를 전달할 수 있는 useCombinedRef 훅을 구현했습니다. 훅 자체는 ref 배열을 순환하는 useCallback일 뿐입니다. 인수가 null인 경우 무시하고, ref가 함수인 경우 요소와 함께 호출하며, 표준 ref 객체인 경우 ref.current를 요소로 설정합니다.

이렇게 하면 여러 참조를 하나로 병합할 수 있습니다. 위 예제에서 Input 컴포넌트 내부의 getBoundingClientRect와 외부 포커스 호출이 모두 올바르게 작동할 것입니다.

콜백 참조에 대해 React 19에서 무엇이 변경되었나요?

자동 정리

React는 이제 요소가 언마운트될 때 콜백 참조의 정리를 자동으로 처리하여 리소스 관리를 간단하게 만듭니다. 다음은 React 팀이 문서에서 보여주는 예시입니다:

TypeScript

 

 {
    // 참조 생성

    // 새로운: 리셋할 정리 함수 반환
    // 요소가 DOM에서 제거될 때 참조를 재설정하는
    return () => {
      // 참조 정리
    };
  }}
/>

공식 블로그 글에서 더 자세한 내용을 확인할 수 있으며, 곧 null을 통해 참조 정리하는 것이 폐기될 수 있어 단일 표준화된 방법으로 참조 정리하는 방식이 남게 될 것입니다.

일반 참조와 콜백 참조 사이 선택하기

  • 일반 참조 (useRef)를 사용하면 DOM 요소에 간단히 액세스하거나 렌더 사이에 일부 값을 유지하려는 경우에 추가 조치 없이 사용합니다.
  • 콜백 참조를 원하는 경우, 요소의 라이프사이클에 대해 더 세밀한 제어가 필요한 경우, 유니버설 코드(예: 자체 라이브러리나 패키지)를 작성하는 경우, 또는 여러 참조를 함께 관리해야 하는 경우에 사용합니다.

결론

React에서의 콜백 참조는 개발자들이 DOM 요소와 작업할 때 추가적인 유연성과 제어를 제공하는 강력한 도구입니다. 대부분의 경우, useRef를 통한 표준 객체 참조가 일상적인 작업에 충분하지만, 위에서 논의한 것과 같이 콜백 참조는 그 이상의 복잡한 시나리오에서 도움이 될 수 있습니다.

Source:
https://dzone.com/articles/react-callback-refs-guide