
useRef로 DOM 조작하기: 리액트 탈출구의 올바른 사용법
React에서 DOM에 직접 접근해야 할 때 useRef를 사용하는 방법과 주의사항, 그리고 forwardRef와 useImperativeHandle을 이용한 고급 패턴까지 완벽하게 정리했습니다.

React에서 DOM에 직접 접근해야 할 때 useRef를 사용하는 방법과 주의사항, 그리고 forwardRef와 useImperativeHandle을 이용한 고급 패턴까지 완벽하게 정리했습니다.
HTML은 그저 글자일 뿐입니다. 브라우저가 이걸 이해하고 조작하려면 '트리 구조의 객체(DOM)'로 바꿔야 합니다.

습관적으로 모든 변수에 `useMemo`를 감싸고 있나요? 그게 오히려 성능을 망치고 있습니다. 메모이제이션 비용과 올바른 최적화 타이밍.

React나 Vue 프로젝트를 빌드해서 배포했는데, 홈 화면은 잘 나오지만 새로고침만 하면 'Page Not Found'가 뜨나요? CSR의 원리와 서버 설정(Nginx, Apache, S3)을 통해 이를 해결하는 완벽 가이드.

진짜 집을 부수고 다시 짓는 건 비쌉니다. 설계도(가상돔)에서 미리 그려보고 바뀐 부분만 공사하는 '똑똑한 리모델링'의 기술.

리액트(React)를 하다 보면 가끔 "리액트의 통제를 벗어나야 하는 순간"이 옵니다.
대부분의 UI는 state와 props로 제어되지만, 다음과 같은 상황에서는 DOM 엘리먼트를 직접 건드려야 합니다.
input.focus())element.scrollIntoView())div를 필요로 할 때.video.play())초보 시절 저는 document.getElementById('my-input')를 썼습니다.
하지만 리액트 생명주기와 맞지 않아 null 참조 에러가 터지기 일쑤였죠.
처음 그 에러를 봤을 때 솔직히 뭔 말인지 몰랐습니다. null이라고? 분명히 화면에 인풋이 보이는데 왜 null이냐고. 나중에야 이해했다 — 리액트는 렌더링 사이클이 있어서, 내가 getElementById를 호출하는 시점에 DOM이 아직 존재하지 않을 수도 있다는 것을. 리액트는 자신만의 가상 DOM을 관리하는데 내가 바깥에서 직접 들어가 뒤지고 있었으니 당연히 엇박자가 날 수밖에 없었습니다.
그 뒤로 useRef를 제대로 파보게 됐습니다. 이 글은 그 과정에서 정리해본 내용입니다.
리액트는 DOM에 접근할 수 있는 안전한 방법인 useRef 훅을 제공합니다.
import { useRef, useEffect } from 'react';
function SearchInput() {
// 1. 초기값은 null로 설정 (TS에서는 제네릭 필수)
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// 3. 마운트 된 직후에만 실행 (이 때 current가 채워짐)
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
// 2. JSX의 ref 속성에 연결
return <input ref={inputRef} placeholder="검색어를 입력하세요" />;
}
여기서 핵심 흐름을 짚어두면 — useRef(null)로 만든 객체는 { current: null } 형태입니다. JSX에서 ref={inputRef}로 연결해두면, 리액트가 해당 DOM 엘리먼트를 실제로 만든 직후 inputRef.current에 그 엘리먼트를 집어넣어 줍니다. 그리고 컴포넌트가 언마운트되면 다시 null로 돌려놓죠. 이 흐름을 이해했을 때 비로소 왜 useEffect 안에서 접근해야 하는지가 와닿았습니다 — useEffect는 마운트 이후에 실행되니까요.
가장 중요한 차이점은 "렌더링을 유발하느냐"입니다.
const countRef = useRef(0);
const handleClick = () => {
countRef.current += 1;
console.log(countRef.current);
// 로그에는 1, 2, 3... 찍히지만 화면 숫자는 안 바뀜!
};
이게 처음엔 이상하게 느껴집니다. 값은 분명 변하는데 화면은 그대로니까요. 하지만 생각해보면 이건 의도된 동작입니다. 예를 들어 setInterval ID 같은 건 화면에 보여줄 필요가 없잖아요. 그냥 나중에 clearInterval 할 때 꺼내 쓸 용도로 어딘가 보관만 해두면 됩니다. 그런 값을 useState로 관리하면 불필요한 리렌더링이 발생합니다. useRef가 그 자리를 채워주는 거라고 이해했습니다.
useRef의 실용적인 활용 중 하나가 이전 렌더링의 값을 기억하는 것입니다.
function PreviousValue({ value }: { value: number }) {
const prevValueRef = useRef<number>(value);
useEffect(() => {
// 렌더링 이후에 현재 값을 저장 → 다음 렌더링 때 꺼내쓸 수 있음
prevValueRef.current = value;
});
return (
<p>
현재: {value}, 이전: {prevValueRef.current}
</p>
);
}
이 패턴은 애니메이션이나 diff 계산이 필요한 곳에서 자주 씁니다. 커스텀 훅으로 만들어두면 재사용하기도 좋습니다.
HTML 태그(div, input)가 아니라, 내가 만든 컴포넌트(MyInput)에 ref를 걸고 싶다면 어떻게 해야 할까요?
// ❌ 작동 안 함: 커스텀 컴포넌트는 기본적으로 ref를 씹어버립니다.
<MyInput ref={inputRef} />
이때 사용하는 것이 forwardRef입니다. 부모가 준 ref를 자식 컴포넌트 내부의 특정 DOM으로 "전달(Forwarding)"해주는 역할입니다.
import { forwardRef } from 'react';
// 자식 컴포넌트
// props와 ref를 인자로 받습니다.
const MyInput = forwardRef<HTMLInputElement, { label: string }>((props, ref) => {
return (
<div className="input-wrapper">
<label>{props.label}</label>
{/* 부모가 준 ref를 실제 input 태그에 연결 */}
<input ref={ref} type="text" />
</div>
);
});
// 부모 컴포넌트
function Parent() {
const inputRef = useRef<HTMLInputElement>(null);
const focusInput = () => {
inputRef.current?.focus(); // 이제 MyInput 내부의 input이 포커스됨
};
return (
<>
<MyInput ref={inputRef} label="Name" />
<button onClick={focusInput}>Focus!</button>
</>
);
}
이 패턴은 재사용 가능한 UI 라이브러리(Button, Input 등)를 만들 때 거의 필수적입니다.
처음에 왜 이렇게 복잡하게 해야 하나 싶었는데, 직접 컴포넌트 라이브러리를 만들어 보고 나서야 이해했다. 외부에서 내 컴포넌트 내부의 DOM에 접근할 창구를 열어주되, 그 창구를 내가 통제하고 싶은 거잖아요. forwardRef가 없다면 부모는 아예 접근이 안 되고, 반대로 DOM을 통째로 노출하면 캡슐화가 깨집니다. forwardRef는 그 중간 어딘가를 담당합니다.
가끔은 DOM 전체를 노출하는 게 아니라, "내가 허락한 몇 가지 기능"만 부모에게 주고 싶을 때가 있습니다.
또는 DOM 메서드가 아닌 커스텀 메서드(예: reset(), validate())를 부모가 호출하게 하고 싶을 때도 있죠.
이때 useImperativeHandle을 사용합니다.
import { forwardRef, useImperativeHandle, useRef, useState } from 'react';
// 부모에게 노출할 메서드 타입 정의
export interface ModalHandle {
open: () => void;
close: () => void;
}
const CustomModal = forwardRef<ModalHandle, { children: React.ReactNode }>((props, ref) => {
const [isOpen, setIsOpen] = useState(false);
const closeButtonRef = useRef<HTMLButtonElement>(null);
// 부모가 ref.current를 통해 호출할 수 있는 객체를 정의
useImperativeHandle(ref, () => ({
open: () => {
setIsOpen(true);
// 모달이 열리면 닫기 버튼에 포커스 이동 (접근성)
setTimeout(() => closeButtonRef.current?.focus(), 0);
},
close: () => {
setIsOpen(false);
}
}));
if (!isOpen) return null;
return (
<div className="modal">
<div className="content">
{props.children}
<button ref={closeButtonRef} onClick={() => setIsOpen(false)}>Close</button>
</div>
</div>
);
});
// 사용법
function Page() {
const modalRef = useRef<ModalHandle>(null);
return (
<>
<button onClick={() => modalRef.current?.open()}>모달 열기</button>
<CustomModal ref={modalRef}>
<h1>안녕하세요!</h1>
</CustomModal>
</>
);
}
이렇게 하면 부모는 자식의 내부 상태(isOpen)를 직접 건드리지 않고, 자식이 제공한 open(), close() 인터페이스만 안전하게 사용할 수 있습니다. 캡슐화(Encapsulation)가 유지되는 것이죠.
이 패턴을 처음 봤을 때는 "굳이?"라는 생각이 들었습니다. 그냥 isOpen 상태를 부모에서 관리하면 되지 않나 싶었거든요. 그런데 컴포넌트가 복잡해질수록, 특히 모달처럼 내부에서 포커스 트래핑이나 스크롤 잠금 같은 부가 로직을 처리해야 할 때 이 방식이 훨씬 깔끔하다는 걸 직접 느껴봤습니다. 부모는 그냥 open() 하나만 부르면 되고, 내부 복잡도는 자식이 알아서 숨기는 거죠.
useRef는 강력하지만, 남용하면 "리액트스러운 코드"를 망칩니다. 이를 "Imperative(명령형) 프로그래밍"이라고 합니다.
리액트는 "Declarative(선언형)" 라이브러리입니다. "어떻게 해라(명령)"가 아니라 "상태가 이렇다(선언)"라고 코딩해야 합니다.
❌ 나쁜 예 (명령형):// 버튼을 누르면 모달을 연다
function BadComponent() {
const modalRef = useRef<HTMLDivElement>(null);
const openModal = () => {
// 윽... 직접 클래스를 조작하고 스타일을 바꿈
modalRef.current.style.display = 'block';
modalRef.current.classList.add('open');
};
}
✅ 좋은 예 (선언형):
function GoodComponent() {
const [isOpen, setIsOpen] = useState(false); // 상태로 정의
return (
// 상태에 따라 렌더링 결과가 달라짐
<div className={`modal ${isOpen ? 'open' : ''}`} style={{ display: isOpen ? 'block' : 'none' }}>
...
</div>
);
}
DOM의 스타일이나 내용을 바꿀 때는 state를 쓰세요.
useRef는 포커스, 스크롤, 미디어 재생 등 "React가 제어하지 않는 브라우저 API"를 쓸 때만 제한적으로 사용해야 합니다.
초반에 저도 이 경계선을 제대로 몰랐습니다. "DOM 조작이 필요하면 ref 쓰면 되는 거 아니야?"라고 생각했는데, 스타일이나 클래스를 ref로 직접 조작하기 시작하면 리액트가 다음에 리렌더링할 때 내가 바꾼 것들을 덮어써 버리는 충돌이 생깁니다. 상태는 리액트가 추적하고, DOM 조작은 리액트가 모르는 채로 일어나니까 당연히 불일치가 납니다. 이 원칙 하나를 제대로 이해하고 나서 ref 관련 버그가 눈에 띄게 줄었습니다.
가끔 요소가 마운트되거나 언마운트되는 순간을 정확히 알아야 할 때가 있습니다.
useRef는 내용물이 바뀌어도 알려주지 않습니다.
이때는 Ref에 함수를 전달하는 Callback Ref 패턴을 씁니다.
function MeasureExample() {
const [height, setHeight] = useState(0);
// 이 함수는 <h1>이 마운트될 때 호출됩니다.
const measuredRef = (node: HTMLHeadingElement) => {
if (node !== null) {
setHeight(node.getBoundingClientRect().height);
}
};
return (
<>
<h1 ref={measuredRef}>높이를 측정할 헤더</h1>
<h2>위 헤더의 높이는 {Math.round(height)}px 입니다.</h2>
</>
);
}
이 패턴은 동적으로 변하는 엘리먼트의 크기를 측정할 때 아주 유용합니다.
실제로 아코디언 UI를 만들다가 이 패턴을 처음 제대로 써봤습니다. 높이를 애니메이션하려면 실제 DOM 높이를 알아야 하는데, useRef만 쓰면 "언제 측정해야 하지?"가 애매했거든요. 콜백 ref는 노드가 DOM에 붙는 바로 그 시점에 콜백을 실행하니까 타이밍 문제가 없었습니다. 처음 보면 문법이 낯선데, 알고 보면 그냥 "ref 속성에 함수를 넘긴다"는 것뿐이라 금방 익숙해집니다.
useRef가 실제로 자주 쓰이는 패턴 중 하나가 Intersection Observer와의 조합입니다. 스크롤 기반 애니메이션이나 무한 스크롤, 뷰포트 진입 감지 등에 활용됩니다.
import { useRef, useEffect, useState } from 'react';
function FadeInSection({ children }: { children: React.ReactNode }) {
const sectionRef = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const element = sectionRef.current;
if (!element) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
// 한 번 보이면 관찰 중단
observer.unobserve(element);
}
},
{ threshold: 0.1 }
);
observer.observe(element);
// 클린업: 컴포넌트 언마운트 시 observer 해제
return () => {
observer.unobserve(element);
};
}, []);
return (
<div
ref={sectionRef}
style={{
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'translateY(0)' : 'translateY(20px)',
transition: 'opacity 0.5s ease, transform 0.5s ease',
}}
>
{children}
</div>
);
}
여기서 클린업 함수가 중요합니다. observer.unobserve(element)를 빠뜨리면 메모리 누수가 생깁니다. useEffect의 반환 함수가 그 역할을 합니다. 이 패턴을 처음 짤 때 클린업을 빠뜨려서 콘솔에 경고가 잔뜩 뜨는 걸 경험해봤는데, 그 이후로 "effect에서 구독하거나 관찰을 시작하면 반드시 해제하는 코드도 함께 쓴다"는 습관이 생겼습니다.
useRef가 DOM 참조 외에도 유용한 상황이 타이머입니다. 검색 인풋의 디바운싱이 대표적인 예입니다.
function SearchInput() {
const [query, setQuery] = useState('');
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value);
// 이전 타이머가 있으면 취소
if (timerRef.current) {
clearTimeout(timerRef.current);
}
// 새 타이머 등록
timerRef.current = setTimeout(() => {
console.log('검색 실행:', value);
// API 호출 등
}, 400);
};
// 언마운트 시 타이머 정리
useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, []);
return <input value={query} onChange={handleChange} placeholder="검색어를 입력하세요" />;
}
타이머 ID를 useState로 관리하면 clearTimeout 호출 시점마다 불필요한 리렌더링이 발생합니다. useRef에 넣으면 타이머가 바뀌어도 렌더링은 그대로입니다. 처음에 왜 이렇게 해야 하나 싶었는데, 직접 퍼포먼스 탭 열어서 비교해보고 나서야 와닿았습니다.
정리해본다면, useRef는 리액트의 선언형 모델 바깥에 있는 것들을 다룰 때 꺼내쓰는 도구입니다.
forwardRef + useImperativeHandle)반대로 상태가 바뀌면 화면이 바뀌어야 하는 것들은 useState의 몫입니다. 이 경계선 하나만 제대로 이해해도 useRef 관련 버그의 대부분은 피할 수 있습니다.
비상구는 비상 상황에서 씁니다. 리액트도 마찬가지입니다.