2025-10-01 22:07
-
useRef는 리렌더링을 유발하지 않고 값을 저장하거나 DOM 요소에 직접 접근하기 위한 React 훅이다.
-
컴포넌트의 생명주기 동안 유지되는
.current프로퍼티를 가진 객체를 반환하며, 이 값을 변경해도 화면은 다시 그려지지 않는다. -
주로 포커스 관리, 미디어 재생, DOM 요소 크기 측정 등 선언적 방식만으로 해결하기 어려운 명령형 작업에 사용된다.
React useRef 완벽 정복 가이드 렌더링을 지배하는 자
React의 세계는 ‘상태(State)‘가 지배한다. 상태가 변경되면 컴포넌트는 마법처럼 다시 렌더링되고, UI는 최신 상태를 반영한다. 이 아름다운 선언적 패러다임은 React를 특별하게 만드는 핵심이다. 하지만 때로는 이 마법에서 한 발짝 벗어나야 할 때가 온다. 렌더링 사이클에 영향을 주지 않으면서 무언가를 기억해야 하거나, React의 추상화 계층을 뚫고 실제 DOM 요소와 직접 대화해야 할 필요가 생긴다.
이때 등장하는 것이 바로 useRef 훅이다. useRef는 React의 선언적 세계와 브라우저의 명령형 세계를 잇는 다리 역할을 한다. 많은 개발자들이 useRef를 단순히 DOM 요소에 접근하는 도구로만 알고 있지만, 그 능력은 훨씬 더 광범위하고 심오하다. useRef를 제대로 이해하고 활용하면, 불필요한 렌더링을 방지하여 성능을 최적화하고, 복잡한 상호작용을 우아하게 구현할 수 있다.
이 핸드북은 useRef의 탄생 배경부터 기본 구조, 핵심 사용법, 그리고 useState와의 근본적인 차이점, 나아가 forwardRef와 같은 고급 개념까지 모든 것을 파헤친다. 이 글을 끝까지 읽고 나면, 당신은 더 이상 useRef를 막연하게 사용하지 않고, React 애플리케이션의 성능과 유연성을 한 단계 끌어올리는 강력한 무기로 활용하게 될 것이다.
1. useRef는 왜 만들어졌을까 탄생의 배경
useRef의 필요성을 이해하려면 먼저 React의 기본 철학인 ‘선언적 렌더링’의 한계를 알아야 한다.
선언적 세계의 한계
React에서 UI는 상태의 함수다. 즉, 주어진 상태에 따라 UI가 어떻게 보일지를 JSX로 선언하면, React가 알아서 DOM을 조작하여 화면을 그려준다. 개발자는 “어떻게”가 아닌 “무엇을”에만 집중하면 된다.
JavaScript
function Counter() {
const [count, setCount] = useState(0);
// 'count'라는 상태가 주어지면,
// UI는 <p> 태그 안에 그 값을 보여줘야 한다고 '선언'한다.
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}이 방식은 대부분의 경우 훌륭하게 작동한다. 하지만 다음 두 가지 시나리오에서는 문제가 발생한다.
1. 리렌더링을 원치 않는 값을 기억해야 할 때
컴포넌트가 리렌더링될 때마다 함수 내부의 모든 변수는 초기화된다. 리렌더링 사이클을 넘어 값을 유지하려면 useState를 사용해야 한다. 하지만 useState는 값이 변경될 때마다 반드시 리렌더링을 유발한다.
만약 타이머의 ID나 이전 props 값처럼, 값이 바뀌어도 화면이 즉시 바뀔 필요가 없는 데이터를 저장하고 싶다면 어떻게 해야 할까? useState를 사용하면 불필요한 렌더링이 발생하여 성능 저하의 원인이 된다. 컴포넌트의 생명주기 동안 살아있으면서도, 그 변화가 렌더링을 촉발하지 않는 ‘조용한 저장소’가 필요했다.
2. DOM 요소에 직접 접근해야 할 때
React는 가상 DOM(Virtual DOM)을 통해 실제 DOM 조작을 추상화한다. 우리는 <input>, <div> 같은 JSX 요소를 다룰 뿐, 실제 브라우저의 HTMLInputElement나 HTMLDivElement를 직접 만지지 않는다.
하지만 때로는 이 추상화 계층을 넘어서야 한다.
-
특정 input 요소에 자동으로 포커스 주기
-
비디오를 재생하거나 일시 정지하기
-
DOM 요소의 크기나 위치를 측정하기
-
서드파티 DOM 라이브러리(e.g., D3.js, Chart.js)와 연동하기
이러한 작업들은 “상태가 이러하니 UI는 이래야 한다”는 선언적 방식으로는 표현하기 어렵다. “지금 당장 이 input 요소에 포커스를 줘!” 와 같은 명령형(Imperative) 방식의 접근이 필요하다.
useRef는 바로 이 두 가지 문제, 즉 **‘리렌더링 없는 값의 유지’**와 **‘DOM 요소에 대한 직접적인 접근’**을 해결하기 위해 탄생했다.
2. useRef의 구조 해부하기
useRef는 생각보다 매우 단순한 구조를 가지고 있다. 그 본질을 이해하면 활용법은 무궁무진해진다.
단 하나의 속성 .current
useRef를 호출하면, React는 일반 자바스크립트 객체(Object)를 반환한다.
JavaScript
const myRef = useRef(initialValue);이 myRef 객체는 오직 하나의 프로퍼티, 바로 current를 가지고 있다.
JavaScript
// myRef의 실제 모습
{
current: initialValue
}useRef의 모든 마법은 이 .current 프로퍼티에서 일어난다.
-
가변성(Mutable):
myRef.current의 값은 언제든지 직접 변경할 수 있다.myRef.current = 'new value'처럼 말이다. -
지속성(Persistent): 컴포넌트가 리렌더링되어도
myRef객체는 계속해서 동일한 객체를 유지한다. 따라서.current에 저장된 값은 리렌더링되어도 사라지지 않고 유지된다. -
렌더링과 무관: 가장 중요한 특징이다.
myRef.current의 값을 아무리 변경해도, React는 이를 감지하지 못하며, 절대 리렌더링을 유발하지 않는다.
이를 비유하자면, 컴포넌트라는 함수가 실행될 때마다 비워지는 작업 공간이 있고, 그 옆에 useRef가 제공하는 ‘개인 사물함’이 하나 있다고 생각할 수 있다. 리렌더링(새로운 작업 시작)이 되어도 사물함과 그 안의 내용물은 그대로 유지된다. 우리는 언제든 그 사물함을 열어(myRef.current) 내용물을 확인하거나 바꿀 수 있다.
3. useRef의 핵심 사용법 두 가지 시나리오
useRef의 활용은 크게 두 가지로 나뉜다. 이 두 가지만 확실히 이해하면 useRef의 90%를 마스터하는 것이다.
시나리오 1: DOM 요소에 접근하기
가장 흔하고 직관적인 사용법이다.
단계별 사용법:
-
Ref 생성:
useRef를 호출하여 ref 객체를 만든다. 초기값은 보통null로 설정하는데, 이는 아직 어떤 DOM 요소와도 연결되지 않았음을 의미한다.JavaScript
const inputRef = useRef(null); -
Ref 연결: JSX 요소의
ref속성에 생성한 ref 객체를 전달한다.JavaScript
<input ref={inputRef} type="text" /> -
Ref 사용: React가 DOM을 그리고 해당
<input>요소를 생성하면,inputRef.current는 이제 실제HTMLInputElement를 가리키게 된다. 이제 이 DOM 노드의 모든 API(e.g.,.focus(),.select())를 사용할 수 있다.
주의할 점: ref.current 값은 컴포넌트가 마운트된 후에 채워진다. 따라서 렌더링 과정 중에는 ref.current에 접근할 수 없다. DOM 요소에 접근하는 로직은 주로 useEffect 훅이나 이벤트 핸들러 내부에서 실행해야 한다.
예제: 페이지 로드 시 input에 자동 포커스 주기
JavaScript
import React, { useRef, useEffect } from 'react';
function AutoFocusInput() {
// 1. Ref 생성
const inputRef = useRef(null);
// useEffect를 사용하여 컴포넌트가 마운트된 직후에 코드를 실행
useEffect(() => {
// 3. Ref 사용: inputRef.current는 실제 <input> DOM 요소를 가리킨다.
if (inputRef.current) {
inputRef.current.focus();
}
}, []); // 빈 배열을 전달하여 컴포넌트가 처음 마운트될 때 한 번만 실행되도록 한다.
return (
<div>
<p>페이지가 로드되면 이 입력창에 자동으로 포커스됩니다.</p>
{/* 2. Ref 연결 */}
<input ref={inputRef} type="text" placeholder="여기를 보세요!" />
</div>
);
}이 예제에서 useEffect는 React가 렌더링을 마치고 실제 DOM에 <input>을 그린 직후에 실행된다. 이때 inputRef.current는 더 이상 null이 아니라 실제 DOM 노드를 가리키므로, 우리는 .focus() 메소드를 안전하게 호출할 수 있다.
시나리오 2: 리렌더링 없는 값 저장하기 (인스턴스 변수처럼)
클래스 컴포넌트에서는 this.timerId나 this.isMounted처럼 this에 값을 저장하여 리렌더링과 무관하게 데이터를 유지할 수 있었다. 함수 컴포넌트에서는 useRef가 이 역할을 완벽하게 수행한다.
예제: 타이머(Interval) 관리하기
setInterval을 사용해 1초마다 숫자를 증가시키는 컴포넌트를 만든다고 가정해 보자. 이 interval은 컴포넌트가 언마운트될 때 clearInterval로 반드시 정리해주어야 메모리 누수를 막을 수 있다. 이때 interval ID를 어디에 저장해야 할까?
-
일반 변수?
let timerId;→ 리렌더링될 때마다undefined로 초기화되므로clearInterval을 호출할 수 없다. (탈락) -
useState?const [timerId, setTimerId] = useState(null);→ 작동은 하지만,setTimerId가 호출될 때마다 불필요한 리렌더링이 발생한다. interval ID 값 자체가 UI에 표시되는 것이 아니므로 낭비다. (탈락) -
useRef? 정답이다. 값은 리렌더링 사이클을 넘어 유지되면서도, 그 값의 변경이 리렌더링을 유발하지 않는다.
JavaScript
import React, { useState, useRef, useEffect } from 'react';
function TimerComponent() {
const [count, setCount] = useState(0);
// interval ID를 저장하기 위한 ref
const intervalRef = useRef(null);
useEffect(() => {
// 컴포넌트가 마운트되면 interval 시작
intervalRef.current = setInterval(() => {
setCount(prevCount => prevCount + 1);
}, 1000);
// 컴포넌트가 언마운트될 때 실행될 클린업 함수
return () => {
clearInterval(intervalRef.current);
};
}, []); // 컴포넌트 마운트/언마운트 시 한 번만 실행
return (
<div>
<h2>타이머: {count}</h2>
{/* 이 버튼은 예시를 위한 것으로, 실제 동작과 직접적 관련은 없음 */}
<button onClick={() => clearInterval(intervalRef.current)}>타이머 중지</button>
</div>
);
}이 코드에서 intervalRef.current는 setInterval이 반환한 ID를 담는 ‘상자’ 역할을 한다. useEffect의 클린업 함수는 컴포넌트가 사라지기 직전에 호출되는데, 이때 intervalRef.current에 저장된 ID를 사용하여 interval을 안전하게 정리할 수 있다. 이 과정에서 단 한 번의 불필요한 렌더링도 발생하지 않는다.
4. 심화 탐구: useRef vs useState
useRef와 useState는 둘 다 리렌더링 사이클을 넘어 값을 기억한다는 공통점이 있지만, 그 목적과 동작 방식은 완전히 다르다. 이 차이를 명확히 아는 것이 React를 깊이 있게 이해하는 열쇠다.
| 특징 | useState | useRef |
|---|---|---|
| 주목적 | 컴포넌트의 상태 관리. 이 값은 UI에 직접적으로 영향을 줌. | 참조(Reference) 값 관리. DOM 요소 참조 또는 리렌더링을 유발하지 않는 값 저장. |
| 리렌더링 유발 | 상태 변경 함수(setState)가 호출되면 반드시 리렌더링을 예약함. | .current 프로퍼티를 변경해도 절대 리렌더링하지 않음. |
| 값의 변경 | setState 함수를 통해 비동기적으로 업데이트됨. | .current 프로퍼티에 직접 할당하여 동기적으로 업데이트됨. |
| 값의 접근 | [value, setValue]에서 value를 직접 사용. | ref.current를 통해 접근. |
| 언제 사용해야 하는가 | 화면에 보이는 모든 데이터, 사용자의 인터랙션으로 변경되어 UI가 업데이트되어야 하는 모든 것. | • DOM 요소에 명령형으로 접근해야 할 때 • 이전 상태/props 값을 저장할 때 • 타이머 ID, 구독(subscription) 등 렌더링과 무관한 값을 관리할 때 |
핵심적인 질문: “이 값이 바뀌었을 때, 화면이 다시 그려져야 하는가?”
-
YES →
useState를 사용하라. -
NO →
useRef를 사용하라.
useRef를 상태 관리용으로 잘못 사용하면 심각한 버그를 유발할 수 있다. 예를 들어, ref.current 값을 JSX에 직접 렌더링했다고 가정해 보자.
JavaScript
function BadCounter() {
const countRef = useRef(0);
return (
<div>
{/* 이 값은 절대 업데이트되지 않는다! */}
<p>Count: {countRef.current}</p>
<button onClick={() => {
countRef.current += 1;
console.log(countRef.current); // 콘솔에는 값이 증가하지만...
}}>
Increment
</button>
</div>
);
}버튼을 클릭하면 countRef.current 값은 분명히 증가하지만, 화면의 Count: 0은 꿈쩍도 하지 않는다. useRef는 리렌더링을 유발하지 않기 때문이다. React는 값이 바뀌었는지조차 모른다.
5. 더 깊게: forwardRef와 부모-자식 컴포넌트
만약 부모 컴포넌트가 자식 컴포넌트 내부의 특정 DOM 요소에 접근해야 한다면 어떻게 해야 할까? 예를 들어, 부모에 있는 버튼을 클릭했을 때 자식의 <input>에 포커스를 주고 싶을 수 있다.
단순히 ref를 props로 넘겨주는 것은 작동하지 않는다. ref는 key처럼 React가 특별하게 다루는 속성이기 때문이다.
JavaScript
// 🚨 이렇게 하면 작동하지 않는다!
function MyInput(props) {
// props.ref는 존재하지 않는다.
return <input ref={props.ref} />;
}
function Parent() {
const inputRef = useRef(null);
// ...
return <MyInput ref={inputRef} />;
}이 문제를 해결하기 위해 React.forwardRef가 등장했다. forwardRef는 컴포넌트를 감싸서 두 번째 인자로 ref를 받을 수 있게 해주는 고차 컴포넌트(HOC)다.
JavaScript
import React, { forwardRef } from 'react';
// 1. 컴포넌트를 React.forwardRef로 감싼다.
// 2. 함수는 props와 ref, 두 개의 인자를 받는다.
const MyInput = forwardRef((props, ref) => {
return (
<div>
<label>{props.label}</label>
{/* 3. 전달받은 ref를 내부의 DOM 요소에 연결한다. */}
<input ref={ref} {...props} />
</div>
);
});
// 부모 컴포넌트에서는 이제 MyInput에 ref를 직접 전달할 수 있다.
function ParentComponent() {
const textInputRef = useRef(null);
const handleFocus = () => {
if (textInputRef.current) {
textInputRef.current.focus();
textInputRef.current.value = "포커스 성공!";
}
};
return (
<div>
<MyInput ref={textInputRef} label="내 입력창:" />
<button onClick={handleFocus}>포커스 주기</button>
</div>
);
}forwardRef를 통해 부모 컴포넌트는 자식 컴포넌트의 내부 구현을 알 필요 없이, 마치 일반 HTML 요소에 ref를 전달하듯 자식 내부의 DOM 노드에 대한 참조를 얻을 수 있게 된다. 이는 컴포넌트의 캡슐화를 유지하면서도 필요할 때 명령형 조작을 가능하게 하는 강력한 패턴이다.
결론: 선언적 세계의 비상구
useRef는 React의 선언적 패러다임이 불편하거나 불가능한 상황에서 사용하는 ‘비상구’와 같다. 남용해서는 안 되지만, 적재적소에 사용하면 애플리케이션의 성능을 최적화하고 사용자 경험을 풍부하게 만드는 데 결정적인 역할을 한다.
이 핸드북을 통해 우리는 useRef가 단순히 DOM 요소에 접근하는 것을 넘어, 리렌더링 사이클에서 자유로운 ‘영속적인 저장 공간’이라는 더 큰 그림을 보았다.
useRef를 기억하는 세 가지 핵심:
-
DOM 참조: input 포커싱, 요소 크기 측정 등 명령형 DOM 조작이 필요할 때 사용한다.
-
인스턴스 변수: 리렌더링을 유발하지 않으면서 값을 기억해야 할 때 (e.g., 타이머 ID, 이전 값) 사용한다.
-
.current: 모든 마법은 가변적이고 영속적인.current프로퍼티에서 일어난다. 이것의 변경은 리렌더링을 유발하지 않는다.
이제 useState의 역할과 useRef의 역할을 명확히 구분하고, 언제 어떤 훅을 사용해야 할지 자신 있게 판단할 수 있을 것이다. useRef를 당신의 React 툴킷에 추가하여, 더 유연하고 효율적인 코드를 작성해 나가길 바란다.