
useImperativeHandle: 자식 컴포넌트의 함수를 부모에서 호출하기 (feat. forwardRef)
React의 데이터 흐름은 단방향(부모->자식)이지만, 가끔은 거꾸로 명령을 내려야 할 때가 있습니다. useImperativeHandle과 forwardRef를 사용해 캡슐화를 유지하면서 자식의 함수를 호출하는 법을 정리해봤습니다.

React의 데이터 흐름은 단방향(부모->자식)이지만, 가끔은 거꾸로 명령을 내려야 할 때가 있습니다. useImperativeHandle과 forwardRef를 사용해 캡슐화를 유지하면서 자식의 함수를 호출하는 법을 정리해봤습니다.
로버트 C. 마틴(Uncle Bob)이 제안한 클린 아키텍처의 핵심은 무엇일까요? 양파 껍질 같은 계층 구조와 의존성 규칙(Dependency Rule)을 통해 프레임워크와 UI로부터 독립적인 소프트웨어를 만드는 방법을 정리합니다.

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

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

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

리액트를 배우면서 귀에 딱지가 앉도록 듣는 말이 있습니다. "데이터는 위에서 아래로(Props) 흐른다."
처음 이 말을 들었을 때는 그냥 규칙이니까 따르자고 생각했습니다. 그런데 실제로 뭔가를 만들다 보면 이 원칙이 벽처럼 느껴지는 순간이 옵니다. "아, 이건 Props로 어떻게 해결이 안 되는데?" 하는 그 순간 말이죠.
내가 처음 그 벽을 만난 건 간단한 멀티스텝 폼을 만들 때였습니다. 각 스텝이 별도의 컴포넌트였고, 상단에 '다음' 버튼이 있었는데 — 그 버튼을 눌렀을 때 현재 스텝 컴포넌트의 유효성 검사를 실행하고 싶었습니다. isValid라는 Props를 내려보내고, 폼 내부에서 올려보내고... 이러다가 Props가 너무 복잡해졌습니다. 결국 StackOverflow를 한 시간쯤 뒤지다가 useImperativeHandle이라는 걸 처음 마주쳤습니다.
이게 뭔지 이해하는 데 꽤 시간이 걸렸고, 이해하고 나서야 "아, 이걸 진작 알았더라면" 싶었습니다. 그래서 정리해본다.
하지만 일을 하다 보면 이 원칙만으로는 해결이 안 되는 순간이 반드시 옵니다. 예를 들어보겠습니다.
isOpen)는 모달 내부에 두고 싶지만, 여는 트리거는 부모에 있을 때.이럴 때 우리는 "부모가 자식에게 명령(Imperative)을 내리고 싶다"는 욕구를 느낍니다.
ref만 쓰면 안 되나요?물론 useRef와 forwardRef를 쓰면 자식의 DOM에 접근할 수 있습니다.
// 자식 (Child)
const ChildInput = forwardRef((props, ref) => {
return <input ref={ref} />;
});
// 부모 (Parent)
function Parent() {
const inputRef = useRef();
const focusInput = () => {
// 😱 자식의 모든 DOM 메서드에 접근 가능!
// inputRef.current.style.color = 'red'; (이런 짓도 가능)
inputRef.current.focus();
};
return <ChildInput ref={inputRef} />;
}
이 방식의 문제는 "너무 많이 노출된다"는 것입니다.
부모가 자식의 내장 input 요소에 직접 접근해서 style을 바꾸거나 value를 강제로 수정할 수도 있습니다.
이건 객체지향의 캡슐화(Encapsulation) 위반입니다. 자식 컴포넌트의 내부 구현이 부모에게 줄줄 새는 셈이죠.
비유를 하자면 이렇습니다. 아파트에서 손님을 받을 때, 손님에게 현관 비밀번호와 집 안 모든 열쇠를 줘버리는 것과 같습니다. 물론 손님은 집에 들어올 수 있겠지만 — 냉장고도 열고, 서랍도 뒤지고, 무선 공유기 설정도 바꿀 수 있게 되는 거죠. 우리가 원하는 건 그냥 "벨 눌러서 들어오세요" 수준이었는데 말이죠.
우리가 원하는 건 딱 하나, focus() 기능뿐인데 말이죠.
// 😱 이런 코드가 코드베이스에 퍼질 수 있습니다
function SomeParent() {
const inputRef = useRef();
const doSomethingWeird = () => {
// 자식 컴포넌트를 완전히 우회해서 DOM을 직접 조작
inputRef.current.style.display = 'none';
inputRef.current.setAttribute('disabled', 'true');
inputRef.current.value = '임의로 바꾼 값';
// ... 이런 게 쌓이면 나중에 버그 추적이 지옥이 됩니다
};
}
자식 컴포넌트 개발자는 내부 구현을 input에서 textarea로 바꾸고 싶어도, 부모가 input의 특정 속성에 의존하고 있다면 함부로 바꿀 수 없게 됩니다. 이게 진짜 문제입니다. 컴포넌트 간의 결합도(coupling)가 높아지는 것이죠.
useImperativeHandle은 부모가 받을 ref 객체를 커스터마이징하는 훅입니다.
즉, 자식 컴포넌트가 "부모님, 제 모든 걸 보여드릴 순 없고, 딱 이 함수들만 쓰세요"라고 제한하는 것입니다.
import { forwardRef, useImperativeHandle, useRef } from 'react';
const CustomInput = forwardRef((props, ref) => {
const inputRef = useRef();
// ref를 통해 부모에게 노출할 {객체}를 정의합니다.
useImperativeHandle(ref, () => ({
// 1. 포커스 함수 노출
focus: () => {
inputRef.current.focus();
},
// 2. 초기화 함수 노출
clear: () => {
inputRef.current.value = '';
},
// 3. 흔들기 애니메이션 (예시)
shake: () => {
inputRef.current.classList.add('shake');
setTimeout(() => inputRef.current.classList.remove('shake'), 500);
}
}));
return <input ref={inputRef} {...props} />;
});
이제 부모 컴포넌트에서는 이렇게 사용합니다:
function Parent() {
const inputRef = useRef();
const handleError = () => {
// ✅ 정의된 메서드만 호출 가능
inputRef.current.shake();
inputRef.current.focus();
// ❌ 접근 불가! (undefined)
// inputRef.current.style.color = 'red';
};
return <CustomInput ref={inputRef} />;
}
이것이 진정한 의미의 인터페이스(Interface) 분리입니다.
자식 컴포넌트는 내부적으로 DOM input을 쓰든 div를 쓰든 상관없습니다. 부모에게는 focus, clear, shake라는 추상화된 메서드만 제공하면 되니까요.
이걸 처음 이해했을 때 진짜 와닿았습니다. "아, 이게 그 캡슐화라는 거구나." 객체지향 강의에서 귀에 못이 박히도록 들었던 개념이 이렇게 실용적인 형태로 연결되는 순간이었습니다.
가장 유용한 예시는 비디오 플레이어입니다. HTML video 태그는 수많은 속성과 메서드를 가지고 있지만, 우리는 부모 컴포넌트에게 딱 '재생', '일시정지', '음소거' 정도만 주고 싶습니다.
/* VideoPlayer.tsx */
import { forwardRef, useImperativeHandle, useRef } from 'react';
// TypeScript 타입을 위해 정의
export interface VideoHandle {
play: () => void;
pause: () => void;
seekTo: (time: number) => void;
}
const VideoPlayer = forwardRef<VideoHandle, { src: string }>((props, ref) => {
const videoRef = useRef<HTMLVideoElement>(null);
useImperativeHandle(ref, () => ({
play: () => videoRef.current?.play(),
pause: () => videoRef.current?.pause(),
seekTo: (time) => {
if (videoRef.current) videoRef.current.currentTime = time;
}
}));
return (
<div className="video-wrapper">
<video ref={videoRef} src={props.src} />
{/* 커스텀 컨트롤 UI들... */}
</div>
);
});
/* App.tsx */
function App() {
const videoRef = useRef<VideoHandle>(null);
return (
<div>
<VideoPlayer ref={videoRef} src="movie.mp4" />
<div className="remote-control">
<button onClick={() => videoRef.current?.play()}>Play</button>
<button onClick={() => videoRef.current?.pause()}>Pause</button>
<button onClick={() => videoRef.current?.seekTo(0)}>Restart</button>
</div>
</div>
);
}
이렇게 하면 App 컴포넌트는 VideoPlayer 내부의 복잡한 DOM 구조를 알 필요 없이, 깔끔하게 제어할 수 있습니다.
내가 처음 이 패턴이 필요하다고 느꼈던 바로 그 케이스입니다. 회원가입 같은 멀티스텝 폼을 만들 때, 각 스텝 컴포넌트가 자체 유효성 검사 로직을 가지고 있고, 부모의 '다음' 버튼이 그걸 트리거해야 할 때입니다.
/* StepOneForm.tsx */
import { forwardRef, useImperativeHandle, useState } from 'react';
export interface StepOneHandle {
validate: () => boolean;
getValues: () => { name: string; email: string };
reset: () => void;
}
const StepOneForm = forwardRef<StepOneHandle, {}>((_, ref) => {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
useImperativeHandle(ref, () => ({
validate: () => {
const newErrors: Record<string, string> = {};
if (!name.trim()) newErrors.name = '이름을 입력해주세요.';
if (!email.includes('@')) newErrors.email = '올바른 이메일을 입력해주세요.';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
},
getValues: () => ({ name, email }),
reset: () => {
setName('');
setEmail('');
setErrors({});
},
}));
return (
<div>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="이름"
/>
{errors.name && <p className="error">{errors.name}</p>}
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="이메일"
/>
{errors.email && <p className="error">{errors.email}</p>}
</div>
);
});
/* MultiStepForm.tsx */
function MultiStepForm() {
const stepOneRef = useRef<StepOneHandle>(null);
const [formData, setFormData] = useState({});
const handleNext = () => {
// 자식의 validate()를 호출해서 검사 후 진행
const isValid = stepOneRef.current?.validate();
if (!isValid) return;
const values = stepOneRef.current?.getValues();
setFormData((prev) => ({ ...prev, ...values }));
// 다음 스텝으로 이동
};
return (
<div>
<StepOneForm ref={stepOneRef} />
<button onClick={handleNext}>다음 단계</button>
</div>
);
}
이 패턴을 처음 이해했을 때 "아, 이렇게 하면 폼 상태를 부모로 끌어올리지 않아도 되는구나" 하고 이해했습니다. 자식이 자신의 상태를 관리하고, 부모는 딱 필요한 순간에만 그 결과를 요청하는 방식 — 이게 생각보다 훨씬 깔끔한 설계였습니다.
또 다른 실용적인 케이스입니다. Framer Motion 같은 라이브러리를 쓸 때도 이 패턴이 빛납니다. 애니메이션 컴포넌트의 재생, 역재생, 리셋 등의 트리거를 외부에서 제어해야 할 때가 있습니다.
/* AnimatedBanner.tsx */
import { forwardRef, useImperativeHandle, useRef } from 'react';
export interface BannerHandle {
playIn: () => void;
playOut: () => void;
reset: () => void;
}
const AnimatedBanner = forwardRef<BannerHandle, { message: string }>(
({ message }, ref) => {
const bannerRef = useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => ({
playIn: () => {
bannerRef.current?.animate(
[{ opacity: 0, transform: 'translateY(-20px)' }, { opacity: 1, transform: 'translateY(0)' }],
{ duration: 400, fill: 'forwards' }
);
},
playOut: () => {
bannerRef.current?.animate(
[{ opacity: 1, transform: 'translateY(0)' }, { opacity: 0, transform: 'translateY(-20px)' }],
{ duration: 400, fill: 'forwards' }
);
},
reset: () => {
if (bannerRef.current) {
bannerRef.current.style.opacity = '0';
}
},
}));
return (
<div ref={bannerRef} style={{ opacity: 0 }} className="banner">
{message}
</div>
);
}
);
/* 사용 예 */
function NotificationSystem() {
const bannerRef = useRef<BannerHandle>(null);
const showNotification = () => {
bannerRef.current?.playIn();
setTimeout(() => bannerRef.current?.playOut(), 3000);
};
return (
<>
<AnimatedBanner ref={bannerRef} message="저장되었습니다!" />
<button onClick={showNotification}>저장</button>
</>
);
}
이 패턴이 진짜 유용한 이유는, 알림이 언제 나타나고 언제 사라질지는 부모가 결정하고, 어떻게 나타나는지의 애니메이션 구현은 자식이 담당하기 때문입니다. 관심사가 분리된다는 게 이런 뜻이라고 이해했습니다.
이 패턴을 처음 쓸 때 내가 직접 겪었던 실수들을 정리해본다.
ref.current가 null인데 바로 호출// ❌ 이렇게 하면 렌더링 직후에 에러 납니다
function Parent() {
const childRef = useRef();
// 컴포넌트가 마운트되기 전에 실행되므로 null 에러
childRef.current.focus(); // ❌
useEffect(() => {
// ✅ useEffect 안에서 호출해야 마운트 후 안전하게 접근
childRef.current?.focus();
}, []);
return <CustomInput ref={childRef} />;
}
useImperativeHandle 안에서 오래된 클로저 참조// ❌ deps 배열 없이 쓰면 state가 업데이트돼도 옛날 값을 캡처합니다
useImperativeHandle(ref, () => ({
getValue: () => value, // value가 변해도 초기값만 반환할 수 있음
})); // deps 없음
// ✅ deps 배열에 관련 state/props를 명시해야 합니다
useImperativeHandle(ref, () => ({
getValue: () => value,
}), [value]); // value가 바뀔 때마다 handle 객체가 새로 만들어짐
이 실수는 디버깅하기가 꽤 까다롭습니다. 컴포넌트가 정상적으로 렌더링되는데 왜 예전 값이 나오지? 하고 한참 헤맸던 기억이 있습니다. deps 배열이 중요한 이유를 그때 제대로 이해했습니다.
// ❌ 모달 열기를 ref로 하는 건 안티패턴
function Parent() {
const modalRef = useRef();
return (
<>
<button onClick={() => modalRef.current?.open()}>열기</button>
<Modal ref={modalRef} />
</>
);
}
// ✅ 상태로 제어하는 게 리액트답습니다
function Parent() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>열기</button>
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)} />
</>
);
}
이걸 처음엔 몰라서 모달 컴포넌트에 open(), close() 메서드를 만들었다가 팀 코드리뷰에서 지적을 받은 적이 있습니다. "왜 Props로 안 하고 ref를 써요?" 그 질문 하나로 리액트의 선언적 설계 철학을 다시 생각하게 됐습니다.
// ❌ HTMLInputElement로 타입을 지정하면 커스텀 메서드에 접근 불가
const ref = useRef<HTMLInputElement>(null);
ref.current?.focus(); // ✅ 이건 되지만
ref.current?.shake(); // ❌ 타입 에러 — HTMLInputElement에 shake 없음
// ✅ 커스텀 handle 인터페이스로 타입을 맞춰야 합니다
export interface InputHandle {
focus: () => void;
shake: () => void;
}
const ref = useRef<InputHandle>(null);
ref.current?.shake(); // ✅ 정상 동작
이 패턴은 강력하지만, 리액트의 선언적(Declarative) 특성을 거스르는 명령형(Imperative) 코드입니다. 가능하면 Props로 해결하는 것이 좋습니다.
나쁜 예:// ❌ 모달을 열기 위해 ref를 사용
modalRef.current.open();
좋은 예:
// ✅ Props로 상태 전달
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)} />
그렇다면 언제 써야 할까요?
반대로 쓰면 안 될 때를 명확히 이해하는 게 더 중요하다고 이해했습니다. Props로 해결할 수 있으면 항상 Props가 우선입니다. ref는 정말 "마지막 수단" 같은 느낌으로 꺼내야 합니다.
TypeScript와 함께 forwardRef + useImperativeHandle을 쓸 때 타입을 올바르게 지정하는 것이 처음엔 헷갈립니다. 정리해본다.
// 1. Handle 인터페이스 정의 (부모에게 노출할 메서드)
export interface MyHandle {
customMethod: () => void;
getValue: () => string;
}
// 2. Props 인터페이스 정의
interface MyProps {
label: string;
initialValue?: string;
}
// 3. forwardRef<Handle, Props> 순서로 제네릭 적용
const MyComponent = forwardRef<MyHandle, MyProps>((props, ref) => {
const [value, setValue] = useState(props.initialValue ?? '');
useImperativeHandle(ref, () => ({
customMethod: () => console.log('호출됨'),
getValue: () => value,
}), [value]); // ✅ deps 배열 필수
return <div>{props.label}: {value}</div>;
});
// 4. 부모에서 사용
function Parent() {
const ref = useRef<MyHandle>(null); // ✅ Handle 타입으로 선언
useEffect(() => {
ref.current?.customMethod(); // ✅ 자동완성 동작
const val = ref.current?.getValue(); // ✅ string 타입 추론
}, []);
return <MyComponent ref={ref} label="테스트" />;
}
이렇게 Handle 인터페이스를 별도로 export하면, 이 컴포넌트를 사용하는 사람이 어떤 메서드를 쓸 수 있는지 타입 정의만 봐도 바로 이해할 수 있습니다. API 문서 역할을 타입이 대신하는 셈입니다.
useImperativeHandle은 리액트에서 "자율성 있는 자식 컴포넌트"를 만드는 핵심 도구입니다.
이걸 처음 공부했을 때는 "이런 특수한 훅이 있구나" 정도였는데, 실제로 써보고 나서야 왜 존재하는지 제대로 와닿았습니다. 컴포넌트가 커지고 책임이 복잡해질수록, 내부 구현을 외부에 얼마나 덜 노출하느냐가 유지보수성에 직결된다는 걸 이해했습니다.
부모에게 모든 열쇠(DOM ref)를 넘겨주는 것은 위험합니다.
대신, "초인종"과 "인터폰"(useImperativeHandle)만 설치해 주세요.
그것이 부모와 자식 모두가 평화롭게 공존하는 길입니다.
정리하자면:
useImperativeHandle 활용Handle 인터페이스를 반드시 별도로 정의하고 exportdeps 배열을 빠뜨리면 오래된 클로저 참조 문제가 생기니 주의