React ref와 forwardRef 그리고 useImperativeHandle 제대로 알기

#javascript#react#frontend

2023-01-24 15:45

대표 이미지

우리는 주로 DOM 객체를 접근하기 위해서 ref를 사용합니다. 함수형 컴포넌트와 Hook 아키텍처에서 ref를 올바르게 쓰는 방법은 무엇일까요? 예제와 함께 알아보도록 합시다.

개요


React로 서비스를 개발하다보면 DOM 객체를 접근하기 위해서 ref를 사용하게 된다. ref는 클래스 컴포넌트(Class Component) 때부터 제공하던 기능이었으나 함수형 컴포넌트(Functional Component)를 사용하게 되면서 작동 방식이나 사용 방법의 차이가 생겼다.

이번 글에서는 함수형 컴포넌트 사용 시 ref의 기본적인 동작에 대해서 살펴보고, 클래스 컴포넌트 때에 비해서 어떤 부분이 달라지게 되었는지 살펴보려고 한다. 이런 차이점을 기반으로 함수형 컴포넌트에서 구현해볼 수 있는 몇가지 패턴과 useImperativeHandle을 통한 커스터마이징에 대해서 알아볼 것이다. 마지막으로 ref의 특징을 고려했을 때 실무에서는 어떤 상황에서 사용하면 좋을지 생각해보자.

ref 알아보기


우선 ref의 기본적인 내용을 살펴보자. React에 어느 정도 익숙한 사람이라면 refstate 간의 가장 중요한 차이점 중의 하나가 reactivity 여부란 사실을 알고 있을 것이다. 여기서 reactivity란 ref에 할당된 값의 변경이 React의 렌더링을 트리거하지 않는다는 의미로 이해할 수 있다.

React를 공부할 때 DOM 객체에 직접 접근하기 위해 ref를 사용한다고 배운다. 일반적으로 jsx 상에서 props로 ref를 할당하면 해당 변수에 DOM 객체가 자동으로 바인딩된다. 이 방식은 React가 생성하는 DOM 객체를 별도의 DOM query 없이 손쉽게 접근할 수 있도록 도와준다. 그러나 ref의 사용에 있어서 몇가지 주의해야 하는 점이 있다. 어떤 점이 ref의 사용을 까다롭게 만드는지 하나씩 살펴보도록 하자.

ref의 선언과 할당

함수형 컴포넌트 기준으로 refuseRef를 이용해서 선언하고 초기화한다. ref를 생성하게 되면 내부에 current라는 변수를 가지고 있는 하나 객체로 생성이 된다. ref에 할당하는 값은 자동으로 모두 이 current라는 변수에 저장되므로, 항상 참조할 때 current 값의 존재 여부를 확인하고 사용해야 한다.

function MyRefTest() {
  const ref = useRef(null);
  
  useEffect(() => {
    console.log(ref.current?.textContent ?? '값 없음'); // '테스트 div입니다.'
  }, [])

  return (
    <div ref={ref}>
      테스트 div입니다.
    </div>
  );
}

클래스 함수를 사용하던 시절에는 Callback Refs라는 방식도 사용했다. 이 방식은 createRef를 사용하는 것이 아니라, 함수 Callback을 전달하여 ref 변수를 직접 관리하는 방법이다. 이 방식을 사용하면 React를 통해 전달되는 DOM 객체를 자유롭게 관리할 수 있다는 장점이 있다. 다만, 이 방법은 공식 문서에도 기술되어 있는 것처럼 inline 함수로 선언되는 경우 렌더링 과정에서 매번 호출되면서 계속 함수가 생성된다는 주의점이 있다. 이를 회피하기 위해서 this에 바인딩된 함수를 정의하여 사용하는 것을 권장한다.

class MyRefTest extends React.Component {
  constructor(props) {
    super(props);
    this.el = null;
  }
  
  componentDidMount() {
    console.log(this.el?.textContent ?? '값 없음'); // '테스트 div입니다.'
  }

  render() {
    return (
      <div ref={(el) => this.el = el}>
        테스트 div입니다.
      </div>
    );
  }
}

ref의 참조

위 예시 코드에서 ref의 값을 참조하는 경우 componentDidMountuseEffect 안에서 사용했다. 이 두 개의 함수는 React에서 마운트(Mount) 이후에 실행되는 함수임을 떠올려보자. 우리가 참조하고자 하는 DOM 객체는 실제로 메모리 상에 생성된 후에 ref를 통해 조회가 가능하다. 이 DOM 객체가 생성되는 시점은 렌더링 및 마운트 이후이기 때문에 ref를 안전하게 접근하기 위해 componentDidMountuseEffect 안에서 사용하는 것이다. ref는 렌더링 무한루프를 피하기 위해서 당연하게 reactivity를 갖지 않게 되었을 것이라는 것도 추론해볼 수 있다.

앞서 살펴본 ref의 기본적인 사용법에서 주의 깊게 볼 것은 클래스 컴포넌트나 함수형 컴포넌트 모두 ref를 참조할 때 current를 통해 접근한다는 점이다. 일반적으로 useRefcreateRef를 사용하는 경우, 자연스럽게 우리는 ref.current 형태로 참조하여 사용한다. 렌더링 시에도 일관되게 유지되는 state와 달리, ref는 계산되는 시점에만 유효한 값이라고 볼 수 있다. state는 렌더링 전후 일관된 값을 보장하지만, refstate의 따라 렌더링 전후 값이 존재할 수도 있고, 존재하지 않을 수 있다. 따라서, ref의 이런 특성에 의해 current(현재)라는 네이밍을 사용하고 있다고 생각하면 편리하다.

function MyRefWithState() {
  const [isOpen, setOpen] = useState(false);
  const ref = useRef(null);
  
  useEffect(() => {
    // isOpen 값에 따라 값이 결정됨.
    console.log(ref.current?.textContent ?? '값 없음');
  }, [])

  return (
    <div>
      {isOpen && <span ref={ref}>state에 따라 조건부로 존재하는 ref</span>}
      <span>Mount 후에는 항상 존재하는 Element</span>
    </div>
  );
}

createRef와 useRef의 차이

마지막으로 ref에 대해서 알아볼 것은 createRefuseRef의 차이다. 위에서 상술한 것처럼, refcurrent라는 키를 가지고 있는 객체로 생성된다. 이런 특별한 객체를 생성하는 것이 createRef 함수라고 이해하면 좋다. 이 함수는 파라미터로 초기 값을 받아서 current 값을 초기화한 후 ref를 반환한다. 따라서, 우리가 사용하는 ref는 모두 createRef를 통해 생성된 객체라고 할 수 있다.

함수형 컴포넌트를 사용하게 되면서, 우리는 컴포넌트 안에서 선언하고 생성한 객체나 변수가 렌더링 시마다 재생성되지 않도록 유지시켜야 한다. useRef는 바로 이런 역할을 하는 Hook이다. 내부적으로 ref 객체를 만들고, 이를 호출한 컴포넌트에서 매번 같은 ref 객체를 참조할 수 있도록 해주는 Hook 함수다. 결국 함수형 컴포넌트를 사용하는 대부분의 경우, useRef를 사용하면 된다.

ref를 더 잘 사용해보기


앞서 기본적인 ref의 동작과 사용법을 살펴봤다. 여러분은 이미 ref를 사용하여 input의 value 값을 참조한다던가, 특정 Element의 너비나 높이를 측정하여 이에 대응하는 스타일을 갱신한다거나 하는 기능을 만들어본 적이 있을 것이다. 그렇다면 ref에 대해 딥다이브해보기 위해서 ref의 특성을 활용하는 다른 패턴과 기능도 한번 알아보자.

ref에 직접 값을 설정하고 사용하기

앞선 예시에서는 ref를 생성하고 이를 jsx에 props로 전달했다. 그러나 잘 생각해보면 ref는 단지 특정 시점에만 유효하다는 점 말고는 그저 값을 보관하고 있는 변수에 지나지 않는다. 또한, reactivity를 가지지 않기 때문에 값을 설정해도 렌더링이 일어나지 않는다. 그렇다면 이런 특성을 가장 잘 사용할 수 있는 방법은 어떤게 있을까?

onMount Hook

자주는 아니지만, 우리는 마운트 여부를 확인하고 싶을 때가 있다. 예를 들어 최초 API 호출 시에는 스켈레톤 UI을 표시했다가 API 응답에 따라 목록이나 대체 UI를 표시하고자 하는 경우가 있다. 이런 분기 조건은 생각보다 구현하기 까다로운데, 단순히 응답 데이터의 null 여부만으로는 분기 처리하기 어렵기 때문이다. 마운트 후에 단 1번만 실행되는 Effect를 만들고 싶은 경우도 있다. 이럴 때 ref의 특성을 이용해 onMount Hook을 만들어볼 수 있다.

function useMount() {
  const ref = useRef(false);
  
  useEffect(() => {
    if (!ref.current) {
      ref.current = true;
    }
  }, []);
  
  return { isMount: ref.current }
}

빈 의존성 배열로 인해 한번만 실행되는 useEffect 콜백 함수 안에서 ref을 변경해주면 마운트 시점을 알려줄 수 있는 Hook이 만들어진다. 렌더링 과정에 영향을 주지 않으면서 해당 Hook을 사용하는 컴포넌트의 마운트 여부와 시점을 확인할 수 있다.

참고하기

React 18 버전부터 development 환경의 Strict 모드에서는 마운트 시점에 useEffect가 항상 2번 실행된다. 이 동작으로 인해 위 코드는 의도하지 않은 결과를 얻게 될 수 있다.

컴포넌트에 ref 전달하기 - forwardRef

forwardRef는 이름 그대로 컴포넌트로 ref를 전달하기 위해 사용한다. 그렇다면 굳이 ref를 전달해야 하는 이유는 무엇일까? ref를 jsx에 props로 전달하던 것을 기억해보자. ref를 설정하고자 하는 타겟이 자식 컴포넌트 안에 존재한다면 우리는 ref를 어떻게 넘겨줄 수 있을까? 바로 이 문제를 해결하기 위해 forwardRef를 사용한다.

대개의 경우 Prop-Drilling을 해결할 때처럼 구조적인 개선을 통해 children으로 Depth를 줄이는 방법도 있겠지만, 항상 사용할 수 있는 방법은 아니다. 그렇다고 컴포넌트에 ref를 바로 할당하는 경우, 우리가 원하는 것처럼 DOM 객체가 바인딩되는 것이 아니라 React의 경고 메시지와 함께 null이 바인딩된다. 이럴 때 forwardRef를 사용하면 외부에서 주입하는 ref를 컴포넌트 내부의 React Element에 전달할 수 있다.

forwardRef의 사용 방법은 간단하다. 외부에서 ref를 전달하고 싶은 컴포넌트를 Higher-Order Component를 사용할 때처럼 forwardRef로 감싸면 된다. 이렇게 하면 컴포넌트 사용 시 두번째 파라미터로 외부에서 설정해준 ref를 참조할 수 있게 된다.

// 두번째 파라미터로 'ref'를 참조할 수 있게 된다.
function ChildComponent(props, ref) {
  return <span ref={ref}>children ref 테스트</span>
}

// 'forwardRef'로 감싸기
const ForwardedChild = forwardRef(ChildComponent)

function ParentComponent() {
  const childRef = useRef(null);
  
  useEffect(() => {
    console.log(childRef.current?.textContent); // 'children ref 테스트'
  }, []);

  return (
    <div>
      <ForwardedChild ref={childRef} />
    </div>
  );
}

ref 커스터마이징하기 - useImperativeHandle

forwardRef를 사용해서 컴포넌트에 ref를 전달할 수 있게 되었지만, 여전히 ref를 통해서 제어할 수 있는 것은 DOM 객체에 한정되어 있다. ref를 컴포넌트에 할당하여 자식 컴포넌트의 상태나 함수에 접근할 수 있었던 클래스 컴포넌트 시절을 생각해보면, 함수형 컴포넌트의 이런 한계가 구현 상의 벽으로 느껴질 때가 있다.

useImperativeHandle은 함수형 컴포넌트의 이런 한계를 넘어서기 위해 만들어졌다고 볼 수 있다. useImperativeHandle을 사용하면 ref에 할당되는 값을 DOM 객체가 아닌 컴포넌트 내부에서 커스터마이징한 객체로 변경할 수 있다. 즉, 자식 컴포넌트에서 노출하고 싶은 ref 객체를 따로 정의할 수 있는 셈이다.

function ChildComponent(props, ref) {
  useImperativeHandle(ref, () => {
    return {
      getText: () => 'useImperativeHandle 테스트'
    };
  }, []);

  return <span>children ref 테스트</span>
}

const ForwardedChild = forwardRef(ChildComponent);

function ParentComponent() {
  const childRef = useRef(null);
  
  useEffect(() => {
    console.log(childRef.current?.getText()); // 'useImperativeHandle 테스트'
  }, []);

  return (
    <div>
      <ForwardedChild ref={childRef} />
    </div>
  );
}

ref, 어디까지 커스터마이징할 수 있을까?


React는 선언형 컴포넌트를 지향한다. 상태의 변화에 따라 사전에 정의한 결과를 보여주는 것을 선언형 컴포넌트라고 이해한다면, 명령형 함수는 이와 대척점에 있다고 할 수 있다. useImperativeHandle은 이름 그대로 명령형 함수를 사용할 수 있는 Hook을 의미한다. 따라서, React에서는 이런 명령형 훅은 자주 사용하는 것을 권장하지 않는다. 그럼에도 useImperativeHandle와 같은 Hook을 만들게 된 이유가 있다. props를 통해서 선언형 데이터만으로는 자식 컴포넌트의 동작을 구현하기 어려운 경우가 있기 때문이다. 여기서 어렵다고 표현한 이유는, 대부분의 경우 props와 useEffect 등을 통해서 불편하지만 의도한 동작을 만들 수는 있기 때문이다. 그러나 명령형 함수를 통해서 이런 구현이 훨씬 간단해질 수 있다.

그렇다고 useImperativeHandle를 사용하는 것을 권장하는 것은 아니다. focus나 scrollIntoView와 같이 DOM 객체가 제공하는 함수를 실행해야 하는 경우처럼, 꼭 명령형으로 제어하는 것이 필요할 때만 사용하는 것이 좋다. 또한, useImperativeHandle을 통해 커스터마이징하게 되면, 모든 인터페이스를 구현하는 것이 아닌 필요한 함수나 변수만을 제공하는 것이 좋다.

function MyInput(props, ref) {
  const inputRef = useRef(null);

  useImperativeHandle(ref, () => {
    return {
      focus() {
        inputRef.current.focus();
      }
    };
  }, []);

  return <input type="text" ref={inputRef} />;
}

const ForwardedMyInput = forwardRef(MyInput);

function Form() {
  const ref = useRef(null);

  function handleClick() {
    // useImperativeHandle에서 focus만 노출시키고 있다.
    // ref.current에서 input 관련 다른 변수나 함수 접근 시도 시 에러 발생한다.
    ref.current.focus();
  }

  return (
    <form>
      <ForwardedMyInput ref={ref} />
      <button type="button" onClick={handleClick}>
        Edit
      </button>
    </form>
  );
}

이렇게 사용해보자

React에서는 항상 선언형 컴포넌트를 작성하는 것이 좋다. 종종 선언적으로 props를 전달하는게 아니라, useImperativeHandle를 이용해서 자식 컴포넌트의 내부 함수를 호출하고 싶은 유혹이 생길 수 있다. 클래스 컴포넌트 시절에 자주 사용하던 방식이기도 하고, 이 방식이 때론 복잡도를 낮춰주기도 하기 때문이다. 그러나 명령형 함수는 사용하면 할수록 데이터의 흐름이 잘 보이지 않게 된다. props와 내부의 state만으로 컴포넌트의 상태를 제어하는 선언형과 달리, 외부에 노출한 커스텀 ref가 언제 어떻게 사용될지 예측하기 힘들기 때문이다. 따라서, ref의 커스터마이징은 DOM 객체를 제어하기 위한 함수 실행의 목적으로만 사용하는 것이 좋다.

맺음말


ref는 reactivity가 없기 때문에 input 등과 같이 사용자 입력 처리를 담당하는 컴포넌트에서 성능 개선의 목적으로 자주 사용한다. reactivity가 없다는 점과 ref.current에 값을 보관할 수 있다는 점 때문에 때로는 유용한 데이터 저장소로 사용할 수 있다. forwardRef를 통해 직접 작성한 컴포넌트에 ref를 전달할 수 있다. 또한, 컴포넌트에 전달하는 refuseImperativeHandle를 통해 커스터마이징을 할 수 있다. 이런 커스터마이징은 React의 선언형 UI 철학과 대치되는 점이 있기 때문에, DOM 객체의 함수 실행 등 꼭 필요한 경우에만 사용하도록 하자.

참고

https://reactjs.org/docs/refs-and-the-dom.html#callback-refs

https://beta.reactjs.org/reference/react/useImperativeHandle