Ref 완전 정복 — DOM 접근부터 useImperativeHandle까지
React는 선언적으로 UI를 다루는데, 가끔은 DOM에 직접 접근해야 할 때가 있습니다. Ref는 이 "탈출구"를 제공하지만, 정확히 어떻게 동작할까요?
개념 정의
Ref(Reference)는 렌더링에 사용되지 않는 값을 보관 하는 React의 탈출구입니다. 가장 흔한 사용 사례는 DOM 노드에 직접 접근하는 것이지만, 타이머 ID나 이전 값 등 렌더링과 무관한 데이터를 저장하는 데도 활용됩니다.
왜 필요한가
React의 선언적 모델로는 처리하기 어려운 작업들이 있습니다.
- 입력 요소에 포커스 설정
- 스크롤 위치 제어
- DOM 요소의 크기/위치 측정
- 서드파티 라이브러리(D3, 비디오 플레이어 등)와의 통합
- 이전 렌더링의 값 보관
내부 동작
useRef의 구조
function Component() {
const ref = useRef(initialValue);
// ref 객체의 구조
// { current: initialValue }
// .current를 변경해도 리렌더링이 발생하지 않음
ref.current = newValue;
}
useRef는 매 렌더링에서 동일한 객체를 반환 합니다. .current 프로퍼티를 변경해도 리렌더링이 트리거되지 않는다는 점이 useState와의 핵심 차이입니다.
useRef vs useState
| 특성 | useRef | useState |
|---|---|---|
| 값 변경 시 리렌더 | 하지 않음 | 트리거함 |
| 변경 가능 | .current 직접 수정 | setter 함수 사용 |
| 렌더링 중 읽기/쓰기 | 비권장 | 안전 |
| 반환값 | { current: value } | [value, setValue] |
DOM 접근
기본 사용법
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>
);
}
스크롤 제어
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 노드가 연결/해제될 때 해당 함수가 호출됩니다.
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로 감싸야 합니다.
// 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처럼 받을 수 있습니다.
// React 19+
function CustomInput({ ref, ...props }) {
return <input ref={ref} {...props} className="custom-input" />;
}
useImperativeHandle — 노출 API 제한
부모에게 전체 DOM 노드를 노출하는 대신, 필요한 메서드만 선택적으로 공개 할 수 있습니다.
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 자체에서 관리 하는 패턴입니다.
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 안에서만 수행해야 합니다.
// ❌ 렌더링 중 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.current는 null입니다. null 체크 없이 ref.current.focus()를 호출하면 런타임 에러가 발생합니다.
useImperativeHandle 남용
모든 컴포넌트에 useImperativeHandle을 사용하면 명령형 패턴 이 확산됩니다. React의 선언적 모델에서 벗어나는 것이므로, 정말 부모가 자식의 DOM을 제어해야 하는 경우(포커스, 스크롤, 애니메이션)에만 사용해야 합니다.
정리
| 항목 | 설명 |
|---|---|
| useRef | 렌더링과 무관한 값을 저장 — 변경 시 리렌더 없음 |
| DOM 접근 | ref 어트리뷰트로 DOM 노드에 직접 접근 |
| forwardRef | 자식 컴포넌트의 DOM에 접근 (React 19부터 일반 prop으로 대체) |
| useImperativeHandle | 부모에게 노출할 API를 제한하여 캡슐화 유지 |
| callback ref | DOM 연결/해제 시점을 정확히 감지 가능 |
| 핵심 규칙 | 렌더링 중 ref 읽기/쓰기 금지 — 이벤트/effect에서만 사용 |
Ref는 React의 "탈출구"입니다. 남용하면 선언적 모델의 장점을 잃게 되므로, 정말 DOM 접근이 필요한 경우에만 사용해야 합니다.