useRef: DOM 접근을 넘어, 리렌더링 없는 변수 저장소로 활용하기

크리스가 이번에는 '스톱워치' 기능을 만들고 있다.
"시작" 버튼을 누르면 시간이 1초씩 흐르고, "정지" 버튼을 누르면 멈춰야 한다.
리액트에 익숙해진 크리스는 아주 자연스럽게 useState를 꺼내 들었다.
"시간도 변하는 값이고, 타이머를 멈추려면 setInterval의 ID도 기억해야 하니까, 둘 다 State로 만들면 되겠지?"
// ❌ 크리스의 비효율적인 코드
function Stopwatch() {
const [time, setTime] = useState(0);
const [timerId, setTimerId] = useState<number | null>(null);
const start = () => {
// 타이머 ID를 state에 저장한다 -> 렌더링 발생!
const id = window.setInterval(() => {
setTime(t => t + 1);
}, 1000);
setTimerId(id);
};
const stop = () => {
if (timerId) clearInterval(timerId);
};
return (
<div>
<span>{time}초</span>
<button onClick={start}>시작</button>
<button onClick={stop}>정지</button>
</div>
);
}기능은 작동한다. 하지만 문제가 있다. timerId를 설정하는 순간(setTimerId), 화면에는 아무런 변화가 없음에도 불구하고 컴포넌트가 리렌더링 된다.
단순히 데이터를 저장하고 싶은 건데, 리액트는 "상태가 변했다!"며 호들갑을 떨며 화면을 다시 그린다.
이처럼 "값은 유지하고 싶지만, 렌더링은 일으키고 싶지 않을 때" 사용하는 비밀 무기가 바로 useRef다.
1. useRef: 리렌더링을 유발하지 않는 주머니
우리는 보통 useRef를 document.getElementById 대신 특정 DOM을 선택할 때(inputRef.current.focus()) 사용한다고 배웠다. 하지만 useRef의 진짜 능력은 "컴포넌트의 생애주기 동안 유지되는, 렌더링과 무관한 저장소"라는 점에 있다.
useRef는 { current: ... } 형태의 단순한 자바스크립트 객체를 반환한다. 우리는 이 current 프로퍼티에 무엇이든 담을 수 있다. 숫자, 문자열, 객체, 심지어 함수까지도.
가장 중요한 특징은 current 값이 바뀌어도 리액트는 컴포넌트를 다시 그리지 않는다는 것이다.
해결: 타이머 ID를 ref에 저장하기
크리스의 코드를 useRef로 최적화해 보자.
// ✅ 최적화된 코드
function Stopwatch() {
const [time, setTime] = useState(0);
// 렌더링과 상관없는 값은 ref에 저장한다.
const timerIdRef = useRef<number | null>(null);
const start = () => {
// 이미 실행 중이면 무시
if (timerIdRef.current) return;
timerIdRef.current = window.setInterval(() => {
setTime(t => t + 1);
}, 1000);
// timerIdRef.current에 값을 넣어도 리렌더링은 발생하지 않는다.
};
const stop = () => {
if (timerIdRef.current) {
clearInterval(timerIdRef.current);
timerIdRef.current = null;
}
};
// ...
}이제 타이머 ID를 저장하거나 지워도 불필요한 렌더링은 발생하지 않는다. 오직 시간이 변할 때(setTime)만 화면이 갱신된다.
2. 변수(let) vs State vs Ref: 무엇을 써야 할까?
초보자들이 흔히 하는 질문이 있다.
"그냥 컴포넌트 안에 let timerId = 0이라고 변수를 선언해서 쓰면 안 되나요?"
안 된다. 그 이유는 리액트의 렌더링 동작 원리 때문이다.
3. 심화 활용: 이전 값(Previous Value) 기억하기
useRef의 또 다른 훌륭한 사용처는 "이전 렌더링의 값을 기억하는 것"이다.
예를 들어, 주식 가격이 변했을 때 "상승했는지 하락했는지" 알려면 이전 가격과 현재 가격을 비교해야 한다.
useEffect 안에서 ref를 업데이트하면, 렌더링 이후의 시점에 값을 저장할 수 있다.
function PriceDisplay({ price }: { price: number }) {
const prevPriceRef = useRef<number>(price);
useEffect(() => {
// 렌더링이 끝난 후, 현재 가격을 '이전 가격'으로 저장해 둔다.
// 다음 렌더링 때 써먹기 위함이다.
prevPriceRef.current = price;
}, [price]);
const prevPrice = prevPriceRef.current;
const direction = price > prevPrice ? '상승 🔺' : '하락 🔻';
return <div>{price}원 ({direction})</div>;
}이 패턴은 리액트의 함수형 컴포넌트가 가지지 못한 componentDidUpdate(prevProps) 생명주기 메서드의 기능을 완벽하게 대체한다.
4. 주의사항: 렌더링 중에는 읽지 마라
useRef를 사용할 때 반드시 지켜야 할 철칙이 있다.
"렌더링 로직(Return 문이나 컴포넌트 본문) 안에서 ref.current를 읽거나 쓰지 마라."
function BadComponent() {
const count = useRef(0);
// ❌ 금지: 렌더링 결과가 예측 불가능해진다.
count.current = count.current + 1;
// ❌ 금지: 화면에 ref 값을 직접 보여주면 안 된다.
// (값이 바뀌어도 화면 갱신이 안 되므로, 사용자는 옛날 값을 보게 됨)
return <div>{count.current}</div>;
}ref의 수정과 조회는 반드시 이벤트 핸들러나 useEffect 내부에서만 이루어져야 한다. 화면에 무언가를 그려야 한다면, 그것은 useRef가 아니라 useState가 해야 할 일이다.
핵심 정리
이제 변수를 다루는 법을 마스터했다. 다음은 컴포넌트 간의 관계를 다룰 차례다.
부모가 자식 컴포넌트의 input에 포커스를 주고 싶다면 어떻게 해야 할까? props로는 불가능한 이 작업을 위해 forwardRef를 써왔지만, React 19에서는 이 문법이 완전히 바뀐다.
"forwardRef의 은퇴와 useImperativeHandle: 이제 ref는 그냥 Prop입니다" 편에서 계속된다.