React는 선언적으로 UI를 다루는데, 가끔은 DOM에 직접 접근해야 할 때가 있습니다. Ref는 이 "탈출구"를 제공하지만, 정확히 어떻게 동작할까요?

개념 정의

Ref(Reference)는 렌더링에 사용되지 않는 값을 보관 하는 React의 탈출구입니다. 가장 흔한 사용 사례는 DOM 노드에 직접 접근하는 것이지만, 타이머 ID나 이전 값 등 렌더링과 무관한 데이터를 저장하는 데도 활용됩니다.

왜 필요한가

React의 선언적 모델로는 처리하기 어려운 작업들이 있습니다.

  • 입력 요소에 포커스 설정
  • 스크롤 위치 제어
  • DOM 요소의 크기/위치 측정
  • 서드파티 라이브러리(D3, 비디오 플레이어 등)와의 통합
  • 이전 렌더링의 값 보관

내부 동작

useRef의 구조

JSX
function Component() {
  const ref = useRef(initialValue);

  // ref 객체의 구조
  // { current: initialValue }

  // .current를 변경해도 리렌더링이 발생하지 않음
  ref.current = newValue;
}

useRef는 매 렌더링에서 동일한 객체를 반환 합니다. .current 프로퍼티를 변경해도 리렌더링이 트리거되지 않는다는 점이 useState와의 핵심 차이입니다.

useRef vs useState

특성useRefuseState
값 변경 시 리렌더하지 않음트리거함
변경 가능.current 직접 수정setter 함수 사용
렌더링 중 읽기/쓰기비권장안전
반환값{ current: value }[value, setValue]

DOM 접근

기본 사용법

JSX
function TextInput() {
  const inputRef = useRef(null);

  const handleClick = () => {
    inputRef.current.focus();     // DOM API 직접 호출
    inputRef.current.select();    // 텍스트 전체 선택
  };

  return (
    <div>
      <input ref={inputRef} type="text" />
      <button onClick={handleClick}>입력 필드에 포커스</button>
    </div>
  );
}

스크롤 제어

JSX
function ScrollToBottom() {
  const bottomRef = useRef(null);

  const scrollToBottom = () => {
    bottomRef.current.scrollIntoView({ behavior: 'smooth' });
  };

  return (
    <div>
      <button onClick={scrollToBottom}>맨 아래로</button>
      <div style={{ height: '2000px' }}>긴 콘텐츠...</div>
      <div ref={bottomRef} />
    </div>
  );
}

Callback Ref

함수를 ref로 전달하면, DOM 노드가 연결/해제될 때 해당 함수가 호출됩니다.

JSX
function MeasuredBox() {
  const [height, setHeight] = useState(0);

  const measuredRef = (node) => {
    if (node !== null) {
      // 노드가 마운트될 때
      setHeight(node.getBoundingClientRect().height);
    }
    // node가 null이면 언마운트된 것
  };

  return (
    <div>
      <div ref={measuredRef}>
        <p>이 요소의 높이를 측정합니다</p>
      </div>
      <p>높이: {height}px</p>
    </div>
  );
}

callback ref는 조건부로 렌더링되는 요소나, 동적으로 나타나는 DOM 노드의 시점을 정확히 잡아야 할 때 유용합니다.

forwardRef — 자식에게 ref 전달

함수형 컴포넌트는 DOM 요소가 아니므로 ref를 직접 받을 수 없습니다. forwardRef로 감싸야 합니다.

JSX
// React 19 이전
const CustomInput = forwardRef(function CustomInput(props, ref) {
  return <input ref={ref} {...props} className="custom-input" />;
});

// 부모에서 사용
function Form() {
  const inputRef = useRef(null);

  return (
    <div>
      <CustomInput ref={inputRef} placeholder="입력하세요" />
      <button onClick={() => inputRef.current.focus()}>포커스</button>
    </div>
  );
}

React 19: ref as prop

React 19부터는 forwardRef 없이 ref를 일반 prop처럼 받을 수 있습니다.

JSX
// React 19+
function CustomInput({ ref, ...props }) {
  return <input ref={ref} {...props} className="custom-input" />;
}

useImperativeHandle — 노출 API 제한

부모에게 전체 DOM 노드를 노출하는 대신, 필요한 메서드만 선택적으로 공개 할 수 있습니다.

JSX
const VideoPlayer = forwardRef(function VideoPlayer({ src }, ref) {
  const videoRef = useRef(null);

  useImperativeHandle(ref, () => ({
    play() {
      videoRef.current.play();
    },
    pause() {
      videoRef.current.pause();
    },
    seekTo(time) {
      videoRef.current.currentTime = time;
    },
    // DOM 노드 자체는 노출하지 않음
  }));

  return <video ref={videoRef} src={src} />;
});

// 부모에서 사용
function App() {
  const playerRef = useRef(null);

  return (
    <div>
      <VideoPlayer ref={playerRef} src="/video.mp4" />
      <button onClick={() => playerRef.current.play()}>재생</button>
      <button onClick={() => playerRef.current.pause()}>일시정지</button>
      <button onClick={() => playerRef.current.seekTo(0)}>처음으로</button>
    </div>
  );
}

이 패턴은 컴포넌트의 캡슐화를 유지 하면서 부모에게 제한된 인터페이스를 제공합니다.

비제어 컴포넌트

폼 요소의 값을 React state가 아닌 DOM 자체에서 관리 하는 패턴입니다.

JSX
function UncontrolledForm() {
  const nameRef = useRef(null);
  const emailRef = useRef(null);

  const handleSubmit = (e) => {
    e.preventDefault();
    // 제출 시점에 DOM에서 직접 값을 읽음
    console.log({
      name: nameRef.current.value,
      email: emailRef.current.value,
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input ref={nameRef} defaultValue="홍길동" />
      <input ref={emailRef} type="email" defaultValue="" />
      <button type="submit">제출</button>
    </form>
  );
}

비제어 컴포넌트는 간단한 폼에서는 코드가 줄어들지만, 실시간 유효성 검사 나 조건부 UI 가 필요하면 제어 컴포넌트가 더 적합합니다.

주의할 점

렌더링 중에 ref를 읽거나 쓰지 않기

렌더링 중에 ref.current를 읽으면 결과가 예측 불가합니다. React는 렌더링을 순수 함수처럼 취급하는데, ref는 mutable이므로 렌더링의 순수성을 깨뜨립니다. ref 읽기/쓰기는 이벤트 핸들러 나 useEffect 안에서만 수행해야 합니다.

JSX
// ❌ 렌더링 중 ref 접근 — 결과가 예측 불가
function Bad() {
  const ref = useRef(null);
  return <div>{ref.current?.innerText}</div>;
}

// ✅ useEffect에서 접근
function Good() {
  const ref = useRef(null);
  const [text, setText] = useState('');
  useEffect(() => {
    if (ref.current) setText(ref.current.innerText);
  }, []);
  return <div ref={ref}>콘텐츠</div>;
}

ref가 null인 시점

컴포넌트가 마운트되기 전이나 조건부로 렌더링되지 않는 요소에서 ref.currentnull입니다. null 체크 없이 ref.current.focus()를 호출하면 런타임 에러가 발생합니다.

useImperativeHandle 남용

모든 컴포넌트에 useImperativeHandle을 사용하면 명령형 패턴 이 확산됩니다. React의 선언적 모델에서 벗어나는 것이므로, 정말 부모가 자식의 DOM을 제어해야 하는 경우(포커스, 스크롤, 애니메이션)에만 사용해야 합니다.

정리

항목설명
useRef렌더링과 무관한 값을 저장 — 변경 시 리렌더 없음
DOM 접근ref 어트리뷰트로 DOM 노드에 직접 접근
forwardRef자식 컴포넌트의 DOM에 접근 (React 19부터 일반 prop으로 대체)
useImperativeHandle부모에게 노출할 API를 제한하여 캡슐화 유지
callback refDOM 연결/해제 시점을 정확히 감지 가능
핵심 규칙렌더링 중 ref 읽기/쓰기 금지 — 이벤트/effect에서만 사용

Ref는 React의 "탈출구"입니다. 남용하면 선언적 모델의 장점을 잃게 되므로, 정말 DOM 접근이 필요한 경우에만 사용해야 합니다.

댓글 로딩 중...