Ref와 DOM
React는 DOM을 만들고 업데이트합니다. 화면이 어떻게 보여야 하는지 설명하면, 실제 엘리먼트를 만드는 것은 React의 일입니다. 하지만 몇 가지 작업은 여전히 엘리먼트 자체가 필요합니다. 버튼을 클릭한 후 입력 필드에 포커스를 맞추거나, 메시지를 화면에 스크롤해서 보여주거나, 박스의 너비를 측정하거나, 동영상을 재생하는 경우입니다. 이런 작업들은 엘리먼트를 설명하는 대신 엘리먼트에 작용하므로 JSX로 표현할 수 없습니다. Ref는 이런 상황을 위한 탈출구입니다. 컴포넌트가 실제 DOM 엘리먼트를 잡아두고 직접 메서드를 호출할 수 있게 합니다.
Ref 연결하기
Ref는 useRef 훅으로 시작하며, 그 결과를 엘리먼트의 ref 속성으로 전달합니다.
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와 함께 사용합니다.
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로 설명하고 상태로 제어합니다.
버전 노트
React 19 이전에는 함수 컴포넌트가 ref props를 수신할 수 없었습니다. 내부 엘리먼트에 전달하려면 forwardRef로 감싸야 했습니다. React 19는 함수 컴포넌트에 ref를 일반 props로 전달하므로 새 코드에서 forwardRef는 불필요하며 더 이상 사용되지 않습니다. 더 거슬러 올라가면, 클래스 컴포넌트는 React.createRef()로 ref를 만들고 this에서 읽었습니다. useRef 훅은 함수 컴포넌트에서 두 역할을 모두 다룹니다.
useRef(null)을 호출하고, 결과를 ref={inputRef}로 엘리먼트에 넣으면, React가 화면에 올라온 후 inputRef.current를 실제 DOM 노드로 채웁니다. 그러면 inputRef.current.focus()처럼 그것에 무언가를 할 수 있습니다. 포커스, 스크롤, 측정이 필요할 때 ref를 사용합니다. 앱을 사용하는 사람이 화면에서 읽는 모든 것은 상태에 있어야 합니다. 다음: 훅, useState, useEffect, useRef 모두가 속한 더 큰 가족입니다.

