Skip to content
This page has been auto-translated and may contain errors.View in English

Ref와 DOM

React는 DOM을 만들고 업데이트합니다. 화면이 어떻게 보여야 하는지 설명하면, 실제 엘리먼트를 만드는 것은 React의 일입니다. 하지만 몇 가지 작업은 여전히 엘리먼트 자체가 필요합니다. 버튼을 클릭한 후 입력 필드에 포커스를 맞추거나, 메시지를 화면에 스크롤해서 보여주거나, 박스의 너비를 측정하거나, 동영상을 재생하는 경우입니다. 이런 작업들은 엘리먼트를 설명하는 대신 엘리먼트에 작용하므로 JSX로 표현할 수 없습니다. Ref는 이런 상황을 위한 탈출구입니다. 컴포넌트가 실제 DOM 엘리먼트를 잡아두고 직접 메서드를 호출할 수 있게 합니다.

Ref 연결하기

Ref는 useRef 훅으로 시작하며, 그 결과를 엘리먼트의 ref 속성으로 전달합니다.

jsx
import { useRef } from 'react'

function SearchBar() {
  const inputRef = useRef(null)

  function handleFocus() {
    inputRef.current.focus()
  }

  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleFocus}>포커스</button>
    </>
  )
}

useRef(null)current라는 하나의 속성을 가진 객체를 반환하며, 초기값으로 전달한 값을 담고 있습니다. 초기값은 항상 전달해야 하며, DOM ref의 경우 아직 노드가 없으므로 null을 관례적으로 시작점으로 사용합니다.

ref={inputRef}를 작성하는 것이 React가 필요로 하는 지시입니다. React가 <input>을 화면에 표시하면 inputRef.current를 DOM 노드로 설정합니다. 그 이후로 inputRef.current는 입력 엘리먼트 자체이며 브라우저가 제공하는 모든 메서드와 속성을 가지므로, 클릭 핸들러 내부의 inputRef.current.focus()는 커서를 필드로 이동시킵니다. React는 할당을 계속 최신으로 유지합니다. 엘리먼트가 화면에서 제거되면 React는 current를 다시 null로 설정합니다.

노드를 화면에 스크롤하기

포커스가 한 가지 작업이라면, 스크롤은 초반에 자주 마주치는 다른 작업입니다. 보통 최신 메시지로 점프해야 하는 채팅 로그 같은 것에서 필요합니다. 이 작업은 렌더링 후에 실행되므로 useEffect와 함께 사용합니다.

jsx
import { useRef, useEffect } from 'react'

function MessageList({ messages }) {
  const bottomRef = useRef(null)

  useEffect(() => {
    bottomRef.current.scrollIntoView({ behavior: 'smooth' })
  }, [messages])

  return (
    <div>
      {messages.map(message => (
        <p key={message.id}>{message.text}</p>
      ))}
      <div ref={bottomRef} />
    </div>
  )
}

하단의 빈 <div>는 스크롤할 대상으로만 존재하는데, 이는 흔하고 완전히 합리적인 기법입니다. 이펙트는 messages가 변할 때마다 실행되며, 그 시점에 React는 이미 새로운 메시지를 화면에 표시하고 bottomRef.current를 최종 div로 가리키고 있습니다.

Ref 변경은 렌더링을 유발하지 않습니다

상태와 ref는 모두 한 렌더링에서 다음 렌더링으로 살아남지만, 그곳이 유사성의 끝입니다. 상태 setter를 호출하면 새 값으로 컴포넌트를 다시 렌더링하도록 React에 요청합니다. ref.current에 값을 할당하면 조용히 값이 변경되고 화면은 정확히 이전 모습으로 유지됩니다.

이 침묵이 바로 핵심입니다. Ref는 UI가 표시하지 않는 것을 보유합니다. DOM 노드, 타이머 ID, 렌더링 로직이 절대 읽지 않는 플래그 같은 것입니다. 그런 것 중 하나가 변할 때 다시 렌더링하면 화면의 단 한 픽셀도 바꿀 수 없는 재화를 낭비하는 셈입니다.

또 하나 중요한 결과가 있습니다. 스케줄되어야 할 일이 없으므로 ref.current의 값은 할당 직후에 바로 읽을 수 있습니다. 상태 변수는 한 번의 렌더링 동안 고정되어 있으며 다음 렌더링에서만 변경됩니다. 상태에서 다루는 내용입니다. Ref는 언제든지 읽고 쓸 수 있는 일반 가변 상자처럼 동작합니다.

Ref가 잘못된 선택일 때

구분선은 값이 화면에 나타나는지 여부입니다. 제목의 텍스트, 배지의 숫자, 패널이 열려 있는지 여부 등은 모두 상태에 속합니다. 표시가 변할 때 값도 변해야 하기 때문입니다. Ref에 넣으면 업데이트가 보이지 않습니다. 값은 이동하지만 UI는 마지막으로 렌더링한 것을 계속 표시합니다.

DOM에 접근해서 표시되는 내용을 변경하는 것은 같은 본능을 한 단계 더 진행한 것입니다. 손으로 node.textContent를 설정하거나 클래스를 직접 토글하면 작동하는 것처럼 보이지만, 다음 렌더링은 그것을 실행 취소합니다. React가 JSX가 설명하는 것을 다시 적용하기 때문입니다. Ref는 두 가지를 다룹니다. 노드를 읽거나 명령하기(포커스, 스크롤, 측정, 재생), 그리고 표시되지 않으면서도 렌더링을 거쳐 살아남는 값을 보유하기입니다. 사용자가 화면에서 읽는 모든 것은 JSX로 설명하고 상태로 제어합니다.

Ref의 두 번째 용도는 DOM과 관계가 없습니다. 표시되지 않으면서도 렌더링을 거쳐 살아남아야 하는 모든 값은 ref.current에 살 수 있으며, 이것이 타이머 ID 같은 부기를 위한 자연스러운 보금자리입니다.

jsx
function Stopwatch() {
  const [seconds, setSeconds] = useState(0)
  const intervalRef = useRef(null)

  function start() {
    if (intervalRef.current !== null) return
    intervalRef.current = setInterval(() => setSeconds(s => s + 1), 1000)
  }

  function stop() {
    clearInterval(intervalRef.current)
    intervalRef.current = null
  }

  useEffect(() => () => clearInterval(intervalRef.current), [])

  return (
    <>
      <p>{seconds}</p>
      <button onClick={start}>시작</button>
      <button onClick={stop}>중지</button>
    </>
  )
}

setInterval이 반환하는 ID는 나중에 타이머를 중지하는 유일한 방법이므로 stop은 그것을 찾을 수 있어야 합니다. 지역 변수라면 다음 렌더링에서 사라질 것이고, 상태라면 타이머가 시작될 때마다 불필요한 재렌더링을 유발할 것입니다. intervalRef.current는 작업에 맞는 형태이며, start에서 "이미 실행 중인가?"를 확인하는 용도로도 작동합니다. 끝의 이펙트는 이펙트의 일반적인 정리 규칙입니다. 타이머가 여기서 시작했으므로, 컴포넌트가 실행 중인 동안 화면을 떠나면 무언가가 타이머를 중지해야 합니다.

같은 패턴으로 props나 상태 변수의 이전 값을 얻을 수 있으며, 여기서는 커스텀 훅으로 제공합니다.

jsx
function usePrevious(value) {
  const ref = useRef(undefined)

  useEffect(() => {
    ref.current = value
  })

  return ref.current
}

이펙트는 각 렌더링 후에 실행되므로 ref는 이전 값을 사용했던 렌더링이 완료된 후에만 업데이트되고, 다음 렌더링은 이전 값을 읽습니다. 첫 렌더링에서 훅은 undefined를 반환합니다. 명시적인 useRef(undefined)에 주목하세요. useRef는 초기값을 인자로 받으며, React 19의 타입은 그 인자가 있을 것으로 예상하므로, 의미 있는 시작 값이 없을 때 undefined를 의도적으로 전달합니다.

또 하나의 일상적인 경우: 비제어 폼 필드입니다. 값이 제출 시점에만 중요하고 사용자가 입력하는 동안 화면에 표시되는 것이 없다면, ref는 입력에서 재렌더링 없이 직접 값을 읽을 수 있습니다. 챕터에서 전체 내용을 다룹니다.

ref.current가 언제 노드를 보유하는지 정확히 이해하면 대부분의 놀라운 점을 설명할 수 있습니다. React는 두 단계로 작동합니다. 렌더 단계에서 컴포넌트 함수를 호출하여 UI 설명을 생성하며, 이 시점에는 그 설명에 대한 DOM이 아직 없습니다. 커밋 단계에서 실제 DOM에 변경을 적용하고, 바로 여기서 ref가 연결됩니다. React는 current를 방금 만들었거나 유지한 노드로 설정한 후 useLayoutEffect를 실행하고, 브라우저가 페인트하도록 한 후, useEffect를 실행합니다. Ref가 분리될 때(엘리먼트가 화면을 떠났거나 ref 속성이 다른 곳을 가리키게 되었을 때), React는 새 것을 연결하기 전에 current를 다시 null로 설정합니다.

그래서 순서는 이렇습니다. 이펙트와 이벤트 핸들러는 엘리먼트가 화면에 있을 때마다 채워진 ref를 봅니다. 하지만 렌더 본문은 보지 못합니다. 첫 렌더링에서 함수 본문이 실행되는 동안 inputRef.current는 여전히 useRef에 전달한 null입니다. 이후 렌더링에서는 이전 커밋의 노드를 보유하며, 이것은 곧 대체될 화면을 설명합니다.

마지막 점이 렌더 중에 ref를 읽는 것이 위험한 이유입니다. React는 렌더링이 순수하기를 기대하며, 결과를 커밋하지 않고 컴포넌트를 렌더링할 권리를 예약합니다. 엄격한 모드는 개발에서 두 번 렌더링하고, 동시 렌더링은 우선순위가 높은 업데이트가 도착하면 폐기될 수 있으며, 중단된 트리는 재시도될 수 있습니다. 렌더 중에 찍은 측정값은 더 이상 일치하지 않는 레이아웃을 설명할 수 있으며, 렌더 중에 ref.current에 쓴 값은 두 번 발생하거나 완전히 폐기될 수 있습니다. 이벤트 핸들러, 이펙트, 또는 콜백 ref에서 읽고 쓰세요. React가 커밋했으며 타이밍이 정의된 곳입니다.

콜백 ref는 다른 연결 방법입니다. 함수를 ref로 전달하면 React는 연결할 때 노드와 함께 호출합니다.

jsx
<div
  ref={node => {
    const observer = new ResizeObserver(() => {
      // 엘리먼트 크기 변경에 반응합니다
    })
    observer.observe(node)
    return () => observer.disconnect()
  }}
/>

React 19에서 콜백 ref는 정리 함수를 반환할 수 있으며, React는 그 노드가 분리될 때 호출합니다. 이것은 정리를 반환하는 모든 콜백의 이전 관례를 대체하며, 이펙트처럼 설정과 정리를 함께 놓을 수 있게 합니다. 아무것도 반환하지 않는 콜백은 분리할 때 null과 함께 두 번째로 호출되는데, 이것이 기존 코드에서 만날 형태입니다. 한 가지 주의점: 인라인 화살표 함수는 렌더링마다 새로운 함수이므로 React는 매번 노드를 분리했다가 다시 연결합니다. 그것이 문제가 될 때는 콜백을 useCallback으로 감싸서 항등성을 유지합니다.

React 19에서 ref는 일반 props처럼 도착하므로, 컴포넌트는 받아서 포커스해야 할 엘리먼트에 전달할 수 있습니다.

jsx
function TextInput({ ref, ...props }) {
  return <input ref={ref} {...props} />
}

이를 아껴서 노출합니다. 부모에게 컴포넌트 내부의 라이브 DOM 노드를 주면 그 노드가 공개 API의 일부가 되고, 아래 마크업의 모든 리팩터는 호출자를 깨뜨릴 수 있습니다.

버전 노트

React 19 이전에는 함수 컴포넌트가 ref props를 수신할 수 없었습니다. 내부 엘리먼트에 전달하려면 forwardRef로 감싸야 했습니다. React 19는 함수 컴포넌트에 ref를 일반 props로 전달하므로 새 코드에서 forwardRef는 불필요하며 더 이상 사용되지 않습니다. 더 거슬러 올라가면, 클래스 컴포넌트React.createRef()로 ref를 만들고 this에서 읽었습니다. useRef 훅은 함수 컴포넌트에서 두 역할을 모두 다룹니다.

JunoRef는 실제 DOM 노드에 도달합니다useRef(null)을 호출하고, 결과를 ref={inputRef}로 엘리먼트에 넣으면, React가 화면에 올라온 후 inputRef.current를 실제 DOM 노드로 채웁니다. 그러면 inputRef.current.focus()처럼 그것에 무언가를 할 수 있습니다. 포커스, 스크롤, 측정이 필요할 때 ref를 사용합니다. 앱을 사용하는 사람이 화면에서 읽는 모든 것은 상태에 있어야 합니다.
JunoRef는 실제 DOM 노드에 도달합니다useRef는 모든 렌더링을 거쳐 살아남는 current 속성을 가진 상자를 제공하며, 그것에 쓰는 것은 렌더링을 스케줄하지 않습니다. 이것은 두 가지 작업을 다룹니다. 포커스, 스크롤, 측정을 위해 DOM 노드에 도달하고, UI가 표시하지 않는 타이머 ID나 props의 이전 값 같은 값을 보관합니다. 값이 화면에 나타나면 상태를 사용해서 화면이 실제로 업데이트되도록 합니다.
JunoRef는 실제 DOM 노드에 도달합니다 Ref는 커밋 중에 useLayoutEffectuseEffect 전에 연결되고 null로 다시 분리되므로, 이펙트와 핸들러는 라이브 노드를 보지만 렌더 본문은 이전 커밋의 것을 봅니다. 그래서 렌더 중에 ref.current를 읽거나 쓰는 것은 위험합니다. 렌더링은 순수해야 하고 React는 렌더링을 폐기하거나 반복할 수 있습니다. React 19의 콜백 ref는 정리 함수를 반환할 수 있어서 설정과 정리를 짝지을 수 있습니다만, 인라인 콜백은 useCallback으로 안정시키지 않으면 매번 다시 연결합니다.

다음: , useState, useEffect, useRef 모두가 속한 더 큰 가족입니다.