부모가 자식의 DOM을 만지고 싶을 때 - forwardRef와 useImperativeHandle 완전 정복
1. "로그인 모달이 열리면 아이디에 포커스 좀 줘"
개발 초기, 멘토가 아주 간단해 보이는 미션을 줬습니다.
"사용자가 로그인 버튼을 누르면, 모달 창이 뜨는데, 그때 바로 아이디 입력창에 커서가 깜빡이게 해 줘. 사용자가 마우스로 클릭 안 해도 되게."
"아, 쉽죠!"라고 자신 있게 대답했습니다.
저는 React의 useRef를 이미 알고 있었으니까요. DOM을 직접 건드려야 할 때(포커스, 스크롤 이동, 비디오 재생, 캔버스 조작 등) 쓰는 대표적인 훅(Hook)이죠.
그래서 부모 컴포넌트인 LoginModal에서 ref를 생성해서 자식 컴포넌트인 CustomInput에게 쿨하게 넘겨주기로 했습니다.
/* ❌ 실패한 시도: 내 맘대로 코드 작성 */
function LoginModal() {
const inputRef = useRef(null);
const openModal = () => {
setShowModal(true);
// 모달이 열리면 포커스! (라고 생각함)
inputRef.current.focus();
};
// 'ref'라는 이름으로 넘겨줌 (className 처럼 될 줄 알았음)
return (
<div className="modal">
<CustomInput ref={inputRef} placeholder="아이디" />
</div>
);
}
// 자식 컴포넌트 (우리가 만든 예쁜 Input)
function CustomInput(props) {
// ❌ props.ref? 그런 거 없음. undefined.
return <input className="fancy-input" ref={props.ref} {...props} />;
}
결과는? 에러 파티였습니다.
콘솔에는 빨간 글씨로 경고가 떴고, inputRef.current는 영원히 undefined였습니다. 버튼을 아무리 눌러도 포커스는 가지 않았습니다.
Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?
2. Ref는 특별 대우가 필요하다
저는 처음에 React가 고장 난 줄 알았습니다. 버그인가 싶었죠.
className, style, onClick, value 같은 건 다 props로 술술 잘 넘어가는데, 왜 유독 ref만 안 넘어갈까요?
알고 보니 ref는 key처럼 React가 내부적으로 처리하는 예약어(Reserved Word)였습니다.
컴포넌트를 렌더링 할 때 React가 ref 속성을 발견하면, 이걸 props 객체에 넣지 않고 따로 빼갑니다. 그래서 자식 컴포넌트 입장에서는 props.ref를 조회해도 아무것도 없는 것(undefined)이죠.
급한 마음에 저는 꼼수를 썼습니다. 이름을 customRef나 innerRef라고 바꿔서 넘겼죠.
/* 😅 꼼수 사용 */
<CustomInput innerRef={inputRef} />
function CustomInput({ innerRef, ...props }) {
return <input ref={innerRef} {...props} />;
}
잘 동작했습니다. 하지만 찜찜했습니다. 마치 4차선 고속도로를 놔두고 비포장 도로로 달리는 느낌이었죠.
"이게 표준 패턴인가? 남들도 다 customRef, inputRef, innerRef 제멋대로 이름 붙여서 쓰나? 라이브러리 만들 때는 뭐라고 이름 붙여야 하지?"
역시나 표준이 아니었습니다. React 팀은 이런 상황을 위해 forwardRef라는 공식적인 해결책을 마련해 두었습니다.
3. 터널 뚫기: forwardRef
forwardRef의 개념은 아주 직관적입니다. 단어 그대로 해석하면 됩니다.
"부모로부터 받은 ref를 자식에게 전달(Forward)해 주는 터널을 뚫는 것"입니다.
이 고차 컴포넌트(HOC)로 내 컴포넌트를 감싸면, React는 "아, 얘는 ref를 받을 준비가 된 애구나"라고 인식하고, props와 분리된 두 번째 인자로 ref를 전달해 줍니다.
import { forwardRef } from 'react';
/* ✅ 성공적인 터널 개통 */
// 컴포넌트 함수의 두 번째 파라미터로 ref가 들어옵니다. (props, ref)
const CustomInput = forwardRef((props, ref) => {
// 여기서 ref는 부모가 보낸 바로 그 inputRef입니다.
// 이걸 진짜 <input> 태그에 꽂아줍니다.
return <input ref={ref} className="fancy-input" {...props} />;
});
// App.js
function LoginModal() {
const inputRef = useRef(null);
// 이제 에러 안 남! CustomInput은 ref를 받을 수 있음
return <CustomInput ref={inputRef} />;
}
이제 부모 컴포넌트는 CustomInput이 마치 일반 <input> 태그인 것처럼 자연스럽게 ref를 꽂을 수 있게 되었습니다. 캡슐화된 자식 컴포넌트 내부의 특정 DOM 요소에 직접 접근하는 길이 뚫린 것이죠.
4. TypeScript 사용자? 여기서 좌절합니다 (제네릭의 늪)
자바스크립트 사용자라면 여기서 끝이지만, 타입스크립트를 쓴다면 이제부터가 시작입니다. forwardRef의 타입 정의는 꽤나 헷갈리게 되어 있거든요.
가장 많이 하는 실수는 제네릭 타입 파라미터의 순서입니다.
보통 (Props, Ref) 순서로 생각하기 쉽지만, 타입스크립트 정의는 정반대입니다.
// ❌ 순서 틀림: (Props타입, Ref타입) -> 에러 발생
const CustomInput = forwardRef<InputProps, HTMLInputElement>((props, ref) => ...);
// ✅ 올바른 순서: <Ref타입, Props타입>
const CustomInput = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
return <input ref={ref} {...props} />;
});
기억하세요. "Ref가 주인공이다" 라고 생각하면 쉽습니다. Ref 타입이 먼저 옵니다.
제네릭 컴포넌트(Generic Component) 감싸기 제대로 파보기
만약 CustomInput 자체가 제네릭을 받는 컴포넌트라면 더 골치 아파집니다. 예를 들어 Select<T> 같은 컴포넌트 말이죠.
forwardRef는 함수 호출 형태라 제네릭 타입 인자를 직접 전달하기가 까다롭습니다.
이럴 땐 forwardRef를 쓰면서도 제네릭을 유지하기 위해 타입 캐스팅이나 래퍼 함수를 써야 하는데, 가장 깔끔한 방법은 다음과 같이 타입을 재정의하는 것입니다.
type SelectProps<T> = {
items: T[];
onSelect: (item: T) => void;
};
// 1. 일반 함수로 정의하고
const SelectInner = <T,>(props: SelectProps<T>, ref: ForwardedRef<HTMLSelectElement>) => {
return <select ref={ref}>...</select>;
};
// 2. forwardRef로 감싼 뒤 타입 단언(Assertion)
const Select = forwardRef(SelectInner) as <T>(
props: SelectProps<T> & { ref?: ForwardedRef<HTMLSelectElement> }
) => ReturnType<typeof SelectInner>;
이 패턴은 UI 라이브러리를 만들 때 정말 유용합니다.
5. React 19 업데이트 - forwardRef, 역사 속으로?
React 팀도 forwardRef가 개발자들에게 불필요한 복잡성(특히 HOC 래퍼와 타입스크립트 문제)을 준다는 것을 인지했습니다.
그래서 React 19 버전부터는 forwardRef가 더 이상 필요하지 않게 됩니다.
그냥 ref를 props로 받으면 됩니다.
/* React 19 방식 (훨씬 깔끔함!) */
function CustomInput({ ref, ...props }) {
// 그냥 props에서 꺼내 쓰면 됨
return <input ref={ref} {...props} />;
}
하지만 우리에겐 현실이 있죠.
아직 전 세계 수많은 프로젝트가 React 16, 17, 18 버전을 사용하고 있습니다. 라이브러리 개발자라면 하위 호환성을 위해 당분간(적어도 3~5년)은 forwardRef 패턴을 유지해야 합니다. 즉, 지금 배워둬도 앞으로 5년은 써먹을 지식입니다.
6. 흔한 실수 - "왜 ref.current가 null이죠?"
forwardRef를 쓰고도 Cannot read properties of null 에러를 만나는 경우가 있습니다.
대부분의 원인은 Effect 실행 시점입니다.
// ❌ 렌더링 도중에 ref 접근 금지!
function App() {
const ref = useRef(null);
// 컴포넌트 함수 본문(=렌더링 중)에서 접근
ref.current.focus(); // 💥 Error! 아직 DOM이 안 그려졌음.
return <CustomInput ref={ref} />;
}
Ref는 DOM이 화면에 실제로 그려진(Commit 단계) 직후에 채워집니다.
React가 "자, 여기 DOM 다 만들었어" 하고 ref.current에 꽂아주는 시점은 렌더링 이후입니다.
그래서 반드시:
useEffect 안에서 (마운트 된 후)
- 이벤트 핸들러 안에서 (클릭 등 사용자 액션 시점)
이 두 곳에서만 ref에 접근해야 안전합니다.
// ✅ 안전한 접근
useEffect(() => {
if (ref.current) {
ref.current.focus();
}
}, []);
7. 근데... 내 날것의 DOM을 다 줘도 되나?
forwardRef를 써서 문제를 해결하고 나니, 시니어 개발자분이 코드를 보고 한마디 하셨습니다.
"자식의 내부 구현(DOM)을 부모한테 다 노출하면 어떡해? 캡슐화 깨진 거 아니야?"
생각해 보니 그랬습니다.
부모가 inputRef.current를 갖는다는 건, <input> DOM 요소의 모든 권한을 다 갖는다는 뜻입니다.
부모 컴포넌트가 갑자기 이런 짓을 할 수도 있습니다:
inputRef.current.style.display = 'none'; // 맘대로 숨김
inputRef.current.value = '해킹됨'; // 맘대로 값 바꿈
inputRef.current.className = ''; // 스타일 다 날림
inputRef.current.remove(); // 심지어 삭제도 가능?!
이건 캡슐화(Encapsulation)를 위반합니다.
자식 컴포넌트는 "나는 포커스 기능만 외부에서 쓰게 해주고 싶은데, 왜 내 속살(DOM)을 다 보여줘야 하지?"라고 불평할 수 있습니다.
특히나 자식 컴포넌트 구조가 바뀌어서 <input> 대신 <textarea>를 쓰게 되거나, InputMask 라이브러리를 써서 내부 구조가 복잡해지면? 부모 코드는 다 망가집니다. 결합도(Coupling)가 너무 높아진 거죠.
8. 문지기 세우기: useImperativeHandle
이때 등장하는 것이 useImperativeHandle입니다.
이름이 좀 무섭고 길지만, 역할은 단순합니다.
"ref를 통해 부모가 사용할 수 있는 메서드를 내가(자식이) 정하겠다"는 선언입니다.
useImperativeHandle = "Handle(다잡이)을 Imperative(명령형)하게 사용하도록 커스텀한다".
부모 컴포넌트에게 진짜 DOM(HTMLInputElement)을 주는 대신, 내가 만든 가짜 객체(Proxy/Interface)를 줍니다.
import { useImperativeHandle, useRef } from 'react';
const CustomInput = forwardRef((props, ref) => {
const realInputRef = useRef(null); // 진짜 DOM은 내가(자식이) 가짐
// 부모에게는 이 객체만 노출됨 (ref로 연결)
useImperativeHandle(ref, () => ({
// 1. 포커스 기능만 허용
focus: () => {
realInputRef.current.focus();
},
// 2. 흔들기 애니메이션 같은 커스텀 메서드 제공
shake: () => {
realInputRef.current.classList.add('shake');
setTimeout(() => realInputRef.current.classList.remove('shake'), 500);
},
// * value 변경이나 style 변경은 제공 안 함!
}));
return <input ref={realInputRef} {...props} />;
});
이제 부모가 inputRef.current를 찍어보면, style, value, nextSibling 같은 건 없고 오직 focus와 shake 함수만 들어있습니다.
// 부모 컴포넌트
const onClick = () => {
inputRef.current.focus(); // OK
inputRef.current.shake(); // OK
inputRef.current.style.color = 'red'; // ❌ Error! style is undefined
};
자식 컴포넌트가 자신의 내부 구현을 숨기고, 안전한 API만 노출(Public Interface)하게 된 것입니다. 이것이 진정한 객체지향적인 설계입니다.
9. 활용 - 비디오 플레이어
이 패턴을 가장 잘 써먹은 건 커스텀 비디오 플레이어를 만들 때였습니다.
HTML <video> 태그는 play(), pause(), currentTime, volume, playbackRate 등 제어할 게 엄청나게 많습니다.
하지만 부모 컴포넌트가 <video> 태그에 직접 접근하게 하면, 실수로 src를 바꾸거나 이상한 속성을 건드려서 플레이어를 고장 낼 위험이 있습니다. 또는 브라우저마다 다른 비디오 동작을 통일시켜주고 싶을 수도 있고요.
const VideoPlayer = forwardRef((props, ref) => {
const videoRef = useRef(null);
useImperativeHandle(ref, () => ({
play: () => {
console.log("Analytics: Play clicked"); // 로깅 추가 가능
videoRef.current.play();
},
pause: () => videoRef.current.pause(),
seekTo: (time) => {
videoRef.current.currentTime = time;
},
restart: () => { // 커스텀 편의 메서드
videoRef.current.currentTime = 0;
videoRef.current.play();
}
}));
return (
<div className="player-wrapper">
<video ref={videoRef} src={props.src} />
{/* 커스텀 컨트롤러 UI들 */}
</div>
);
});
이렇게 하면 부모 컴포넌트는 "재생해", "멈춰", "이동해" 같은 고수준의 명령(Imperative)만 내릴 수 있고, 실제로 비디오가 어떻게 동작하는지는 알 필요가 없습니다. 내부 구현이 유튜브 플레이어로 바뀌든, 비메오로 바뀌든 부모 코드는 안전합니다.
10. 한 줄 요약
자식 컴포넌트의 DOM이나 메서드에 접근해야 한다면 forwardRef로 터널을 뚫고, useImperativeHandle로 문지기를 세워서 안전하고 필요한 기능만 노출해라.
이 패턴을 익히면 React가 지향하는 단방향 데이터 흐름(Props Down)을 깨지 않고도, 필요할 때 명령형(Imperative) 코드를 우아하게 작성할 수 있습니다.