React 개발을 하다 보면 부모 컴포넌트에서 자식 컴포넌트의 메서드나 상태에 직접 접근해야 할 필요가 있습니다. 이런 경우 useRef와 forwardRef 를 사용한다면 도움이 됩니다. 이 글에서는 이 두 기능을 활용하여 자식 컴포넌트를 효과적으로 제어하는 방법을 살펴보겠습니다.
왜 useRef와 forwardRef가 필요한가?
React의 기본 철학은 단방향 데이터 흐름입니다. 부모에서 자식으로 props를 전달하고, 자식은 이벤트를 통해 부모와 소통합니다. 하지만 이런 패턴만으로는 해결하기 어려운 상황이 있습니다:
- 폼 요소의 초기화나 포커스 제어
- 애니메이션 직접 트리거
- 미디어 요소(비디오, 오디오) 제어
- 모달이나 드롭다운 같은 UI 요소 직접 제어
이럴 때 useRef와 forwardRef를 활용하면 명령형 프로그래밍 방식으로 자식 컴포넌트를 제어할 수 있습니다.
실제 사례: 이미지 업로더 컴포넌트
실제 개발 현장에서 자주 마주치는 이미지 업로더 컴포넌트를 예로 들어보겠습니다.
1. 기본 구현
이미지 업로더 컴포넌트는 웹 애플리케이션에서 자주 사용되는 요소입니다. 사용자가 이미지를 선택하면 미리보기를 표시하고, 선택된 파일을 상태로 관리합니다. 그런데 이런 경우, 부모 컴포넌트에서 이 업로더를 초기화하거나 현재 선택된 파일을 가져와야 하는 상황이 발생합니다.
- 이미지 파일 선택 및 미리보기 기능
- 외부에서 미리보기 초기화 가능
- 현재 선택된 이미지 파일 정보 접근 가능
아래 코드는 forwardRef와 useImperativeHandle을 활용하여 외부에서 접근 가능한 메서드를 제공하는 이미지 업로더 컴포넌트입니다:
// ImageUploader.tsx
import React, { useState, forwardRef, useImperativeHandle } from 'react';
// 외부에서 접근할 메서드 타입 정의
export interface ImageUploaderRef {
resetPreview: () => void; // 미리보기 초기화 메서드
getImageFile: () => File | null; // 현재 파일 가져오기 메서드
}
interface Props {
onImageChange?: (file: File | null) => void; // 이미지 변경 시 콜백
label?: string; // 업로드 버튼 라벨
}
// forwardRef를 사용하여 ref를 받을 수 있는 컴포넌트 생성
// 제네릭 타입으로 <노출할 메서드 타입, Props 타입>을 지정
const ImageUploader = forwardRef<ImageUploaderRef, Props>((props, ref) => {
// 내부 상태 관리
const [imageFile, setImageFile] = useState<File | null>(null);
const [preview, setPreview] = useState<string>('');
// 파일 선택 처리
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] || null;
setImageFile(file);
if (file) {
// FileReader를 사용하여 이미지 미리보기 생성
const reader = new FileReader();
reader.onloadend = () => {
setPreview(reader.result as string);
};
reader.readAsDataURL(file);
props.onImageChange?.(file); // 부모에게 변경 알림
} else {
setPreview('');
props.onImageChange?.(null);
}
};
// ref를 통해 노출할 메서드 정의
// 이 부분이 핵심! 외부에서 접근 가능한 메서드를 여기서 정의함
useImperativeHandle(ref, () => ({
resetPreview: () => {
// 모든 상태 초기화 및 부모에게 알림
setImageFile(null);
setPreview('');
props.onImageChange?.(null);
},
getImageFile: () => imageFile // 현재 파일 반환
}));
return (
<div className="image-uploader">
{/* 이미지 미리보기 영역 - 이미지가 있을 때만 표시 */}
{preview && (
<div className="preview">
<img src={preview} alt="미리보기" />
</div>
)}
{/* 파일 입력 필드 */}
<input
type="file"
accept="image/*"
onChange={handleFileChange}
id="image-input"
/>
<label htmlFor="image-input">{props.label || '이미지 선택'}</label>
</div>
);
});
export default ImageUploader;
2. 부모 컴포넌트에서 사용하기
위에서 만든 ImageUploader 컴포넌트를 부모 컴포넌트에서 어떻게 활용하는지 살펴보겠습니다. 여기서 핵심은 useRef를 사용하여 자식 컴포넌트의 메서드에 접근하는 방법입니다.
// ProfileEditor.tsx
import React, { useRef } from 'react';
import ImageUploader, { ImageUploaderRef } from './ImageUploader';
const ProfileEditor = () => {
// useRef를 사용하여 ImageUploaderRef 타입의 ref 생성
// 초기값은 null로 설정
const uploaderRef = useRef<ImageUploaderRef>(null);
// 폼 제출 처리
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// ref를 통해 자식 컴포넌트의 메서드 호출
// uploaderRef.current가 존재할 때만 getImageFile() 메서드에 접근 (옵셔널 체이닝)
const imageFile = uploaderRef.current?.getImageFile();
if (imageFile) {
// 이미지 파일이 존재하면 업로드 로직 실행
await uploadProfileImage(imageFile);
}
};
// 초기화 버튼 클릭 시 처리
const handleReset = () => {
// ref를 통해 자식 컴포넌트의 resetPreview 메서드 호출
// 이렇게 호출하면 자식 컴포넌트 내부 상태가 초기화됨
uploaderRef.current?.resetPreview();
};
return (
<form onSubmit={handleSubmit}>
<h2>프로필 수정</h2>
{/* ImageUploader 컴포넌트에 ref 전달 */}
{/* 이렇게 하면 자식 컴포넌트의 메서드에 접근할 수 있음 */}
<ImageUploader
ref={uploaderRef} // 여기서 ref 전달이 핵심!
onImageChange={(file) => console.log('이미지 변경:', file)}
label="프로필 사진 업로드"
/>
<div className="actions">
{/* 초기화 버튼 클릭 시 자식 컴포넌트의 상태 초기화 */}
<button type="button" onClick={handleReset}>초기화</button>
<button type="submit">저장</button>
</div>
</form>
);
};
// 실제 구현에서는 이런 함수가 API 호출 등을 담당
function uploadProfileImage(file: File) {
// 이미지 업로드 로직
return Promise.resolve();
}
동작 원리 설명
위 예제에서 사용된 핵심 기술들의 동작 원리를 자세히 살펴보겠습니다.
1. Ref 전달 흐름
1. 부모 컴포넌트에서 ref 생성: useRef<ImageUploaderRef>(null)로 참조 객체를 생성합니다.
2. 자식 컴포넌트로 ref 전달: <ImageUploader ref={uploaderRef} ... />와 같이 ref prop으로 전달합니다.
3. 자식 컴포넌트에서 ref 수신: forwardRef<ImageUploaderRef, Props>((props, ref) => { ... })로 ref를 받습니다.
4. 메서드 노출: useImperativeHandle(ref, () => ({ ... }))로 자식이 부모에게 노출할 메서드를 정의합니다.
5. 부모에서 메서드 호출: uploaderRef.current?.resetPreview()처럼 자식의 메서드를 호출합니다.
2. 각 훅의 역할
forwardRef:
- React의 기본 동작에서는 ref prop이 일반 props처럼 자식 컴포넌트로 전달되지 않습니다.
- forwardRef는 이 제한을 해결하여 컴포넌트가 상위 컴포넌트로부터 ref를 받을 수 있게 해줍니다.
- TypeScript와 함께 사용할 때는 forwardRef<RefType, PropsType> 형태로 제네릭을 지정하여 타입 안정성을 확보합니다.
useImperativeHandle:
- 일반적으로 ref는 DOM 노드에 직접 접근하는 용도로 사용됩니다.
- 하지만 useImperativeHandle을 사용하면 부모 컴포넌트에 노출할 객체를 직접 정의할 수 있습니다.
- 이를 통해 컴포넌트 내부 구현은 숨기고, 필요한 메서드만 선택적으로 노출할 수 있습니다.
- 예: resetPreview와 getImageFile 메서드만 외부에 노출하여 내부 상태인 imageFile과 preview는 캡슐화합니다.
useRef:
- 리렌더링 사이에 유지되는 값을 저장하는 훅입니다.
- 여기서는 자식 컴포넌트의 메서드에 접근하기 위한 참조 객체로 사용됩니다.
- .current 속성을 통해 현재 값(이 경우 자식 컴포넌트의 메서드들)에 접근합니다.
3. 데이터 흐름 이해하기
일반적인 React의 데이터 흐름과 ref 기반 명령형 접근의 차이를 비교해보겠습니다:
- 일반적인 props 기반 방식:
// 자식에게 상태와 상태 변경 함수를 전달
<ImageUploader
preview={preview}
onResetPreview={() => setPreview('')}
/>
- ref 기반 명령형 방식:
// 자식 컴포넌트 자체에 상태를 두고, 외부에서 메서드로 제어
<ImageUploader ref={uploaderRef} />
// 나중에 필요할 때: uploaderRef.current?.resetPreview()
ref 기반 방식은 자식 컴포넌트가 자체적으로 상태를 관리하면서도, 부모가 필요할 때 직접 개입할 수 있는 유연성을 제공합니다.
다른 활용 사례
useRef와 forwardRef의 조합은 이미지 업로더 외에도 다양한 UI 컴포넌트에서 활용할 수 있습니다. 아래는 실무에서 자주 사용되는 두 가지 예시입니다.
1. 모달 컴포넌트 제어
모달은 여러 화면에서 재사용되는 대표적인 UI 요소입니다. 모달을 열고 닫는 동작을 ref로 제어하면 컴포넌트의 재사용성을 크게 높일 수 있습니다.
// Modal.tsx
import React, { useState, forwardRef, useImperativeHandle } from 'react';
// 노출할 메서드 타입 정의
export interface ModalRef {
open: () => void;
close: () => void;
}
interface ModalProps {
title: string;
children: React.ReactNode;
onClose?: () => void;
}
// Modal 컴포넌트 정의
const Modal = forwardRef<ModalRef, ModalProps>((props, ref) => {
// 모달 표시 여부 상태
const [isOpen, setIsOpen] = useState(false);
// 외부에서 접근 가능한 메서드 정의
useImperativeHandle(ref, () => ({
// 모달 열기
open: () => setIsOpen(true),
// 모달 닫기
close: () => {
setIsOpen(false);
props.onClose?.(); // 닫힘 이벤트 콜백 실행
}
}));
// 모달이 닫혀있으면 아무것도 렌더링하지 않음
if (!isOpen) return null;
return (
<div className="modal-overlay">
<div className="modal-content">
<div className="modal-header">
<h3>{props.title}</h3>
<button onClick={() => setIsOpen(false)}>×</button>
</div>
<div className="modal-body">
{props.children}
</div>
</div>
</div>
);
});
// 부모 컴포넌트에서의 사용 예
function ProductPage() {
// Modal 컴포넌트를 제어할 ref 생성
const modalRef = useRef<ModalRef>(null);
// 버튼 클릭 시 모달 열기
const openProductModal = () => {
modalRef.current?.open();
};
return (
<div>
<h1>제품 상세</h1>
<button onClick={openProductModal}>상품 옵션 보기</button>
{/* 모달 컴포넌트 - 필요할 때만 표시됨 */}
<Modal
ref={modalRef}
title="상품 옵션"
onClose={() => console.log('모달이 닫혔습니다')}
>
<p>원하는 상품 옵션을 선택하세요</p>
{/* 모달 내용 */}
</Modal>
</div>
);
}
이 접근 방식의 장점:
- 모달 상태(열림/닫힘)를 모달 자체에서 관리합니다.
- 다양한 컴포넌트에서 모달을 쉽게 재사용할 수 있습니다.
- 조건부 렌더링을 모달 자체에서 처리하므로 부모 컴포넌트 코드가 단순해집니다.
2. 폼 제어
복잡한 폼의 경우, 초기화나 유효성 검사 같은 기능을 ref를 통해 제공하면 편리합니다.
// CustomForm.tsx
import React, { useState, forwardRef, useImperativeHandle } from 'react';
// 폼 데이터와 에러 타입 정의
interface FormData {
name: string;
email: string;
message: string;
}
interface FormErrors {
name?: string;
email?: string;
message?: string;
}
// 외부에 노출할 메서드 타입
export interface FormRef {
reset: () => void;
validate: () => boolean;
getData: () => FormData;
}
// 폼 컴포넌트 정의
const CustomForm = forwardRef<FormRef, {onSubmit?: (data: FormData) => void}>((props, ref) => {
// 초기 상태 값
const initialValues: FormData = { name: '', email: '', message: '' };
// 폼 상태 관리
const [values, setValues] = useState<FormData>(initialValues);
const [errors, setErrors] = useState<FormErrors>({});
// 입력 필드 변경 처리
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setValues(prev => ({
...prev,
[name]: value
}));
};
// 폼 유효성 검사 함수
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
// 이름 필드 검사
if (!values.name.trim()) {
newErrors.name = '이름을 입력해주세요';
}
// 이메일 필드 검사
if (!values.email.trim()) {
newErrors.email = '이메일을 입력해주세요';
} else if (!/\S+@\S+\.\S+/.test(values.email)) {
newErrors.email = '유효한 이메일 형식이 아닙니다';
}
// 메시지 필드 검사
if (!values.message.trim()) {
newErrors.message = '메시지를 입력해주세요';
}
// 에러 상태 업데이트
setErrors(newErrors);
// 에러가 없으면 true 반환
return Object.keys(newErrors).length === 0;
};
// 외부에 노출할 메서드 정의
useImperativeHandle(ref, () => ({
// 폼 초기화
reset: () => {
setValues(initialValues);
setErrors({});
},
// 유효성 검사 실행
validate: validateForm,
// 현재 폼 데이터 반환
getData: () => values
}));
return (
<form>
<div className="form-group">
<label htmlFor="name">이름</label>
<input
type="text"
id="name"
name="name"
value={values.name}
onChange={handleChange}
/>
{errors.name && <span className="error">{errors.name}</span>}
</div>
{/* 다른 폼 필드들... */}
</form>
);
});
// 부모 컴포넌트에서의 사용 예
function ContactPage() {
// 폼 컴포넌트를 제어할 ref 생성
const formRef = useRef<FormRef>(null);
// 폼 제출 처리
const handleSubmit = () => {
// ref를 통해 유효성 검사 메서드 호출
if (formRef.current?.validate()) {
// 유효성 검사 통과 시 데이터 가져오기
const formData = formRef.current.getData();
// 데이터 처리 로직
console.log('제출된 데이터:', formData);
}
};
// 초기화 버튼 클릭 처리
const handleReset = () => {
// ref를 통해 초기화 메서드 호출
formRef.current?.reset();
};
return (
<div className="contact-page">
<h1>문의하기</h1>
{/* 폼 컴포넌트에 ref 전달 */}
<CustomForm ref={formRef} />
<div className="form-actions">
<button type="button" onClick={handleReset}>초기화</button>
<button type="button" onClick={handleSubmit}>제출</button>
</div>
</div>
);
}
주의사항 및 모범 사례
ref를 통한 자식 컴포넌트 제어는 강력한 기능이지만, 남용하면 코드가 복잡해지고 유지보수가 어려워질 수 있습니다. 아래는 useRef와 forwardRef를 사용할 때 알아두면 좋은 주의사항과 모범 사례입니다.
주의사항
1. 과도한 사용 지양
- ref를 통한 직접 제어는 React의 선언적 패러다임에 반하므로, 꼭 필요한 경우에만 사용해야 합니다.
- 상태 변경이나 이벤트 전달은 가능한 props와 콜백 함수를 통해 처리하세요.
- ref는 DOM 조작, 포커스 제어, 애니메이션 트리거, 타이머 관리 등 props로 처리하기 어려운 경우에 사용하세요.
2. 타입 안정성 확보
- TypeScript를 사용할 때는 ref와 관련된 타입을 명확히 정의해야 합니다.
// 잘못된 방식 - 타입이 명확하지 않음
const someRef = useRef();
// 올바른 방식 - 구체적인 타입 명시
interface MyComponentRef {
doSomething: () => void;
}
const typedRef = useRef<MyComponentRef>(null);
3. 테스트 어려움
- ref를 통한 명령형 코드는 테스트하기 어려울 수 있습니다.
- 테스트 시에는 컴포넌트 인스턴스에 직접 접근하여 ref를 통한 메서드를 호출해야 할 수 있습니다.
// 테스트 예시
const { result } = renderHook(() => useRef<FormRef>(null));
render(<CustomForm ref={result.current} />);
// 이렇게 접근하여 테스트해야 함
act(() => {
result.current.current?.validate();
});
4. 생명주기 고려
- 초기에는 ref.current가 null입니다. 컴포넌트가 마운트된 후에만 값이 할당됩니다.
- 항상 옵셔널 체이닝(ref.current?.method())을 사용하여 안전하게 접근하세요.
모범 사례
1. 명확한 인터페이스 정의
- useImperativeHandle에서 노출할 메서드를 명확히 정의하고 문서화하세요.
- 인터페이스를 최소한으로 유지하고, 꼭 필요한 메서드만 노출하세요.
2. 컴포넌트 내부 로직 캡슐화
- 컴포넌트의 내부 상태와 로직은 최대한 캡슐화하고, 외부에 필요한 기능만 노출하세요.
// 잘못된 방식 - 내부 상태를 그대로 노출
useImperativeHandle(ref, () => ({
setIsVisible: setIsVisible, // 내부 상태 변경 함수를 직접 노출
visible: isVisible // 내부 상태를 직접 노출
}));
// 올바른 방식 - 명확한 메서드만 노출
useImperativeHandle(ref, () => ({
show: () => setIsVisible(true),
hide: () => setIsVisible(false),
isVisible: () => isVisible // 읽기 전용 접근자
}));
3. 의미 있는 이름 사용
- ref와 노출하는 메서드에 의미 있는 이름을 사용하여 코드의 가독성을 높이세요.
// 구체적이고 의미 있는 이름 사용
const modalRef = useRef<ModalRef>(null);
useImperativeHandle(ref, () => ({
openWithAnimation: () => { /* ... */ }, // 구체적인 행동 설명
closeAndReset: () => { /* ... */ } // 구체적인 행동 설명
}));
결론
useRef와 forwardRef는 React에서 자식 컴포넌트를 제어하는 강력한 도구입니다. 적절히 사용한다면 컴포넌트의 재사용성과 유지보수성을 높일 수 있습니다. 하지만 남용하면 코드의 복잡성이 증가하고 디버깅이 어려워질 수 있으므로, 명확한 필요성이 있을 때만 사용하는 것이 좋습니다.
Props를 통한 상태 관리가 가능한 경우에는 기본적인 React 데이터 흐름을 따르고, 직접적인 DOM 조작이나 자식 컴포넌트의 메서드 호출이 필요한 경우에만 사용하면 좋습니다.
'공부 > 프로그래밍' 카테고리의 다른 글
Spring scheduling 을 DB 기반으로 동적 스케줄링 하기 (0) | 2025.03.21 |
---|---|
flutter 무한스크롤: riverpod 상태관리 삽질(스크롤 위치 초기화 및 리빌드 문제) (0) | 2025.03.13 |
[Java] enum 으로 되어있는 공통코드를 목록 조회로 공통화 하기 (0) | 2025.03.05 |
Flutter에서 VerticalDivider 올바르게 사용하기 (0) | 2025.03.04 |
TypeScript와 ES6로 Enum처럼 코드 집합 관리하기: 공통 메서드와 타입 안전성 (0) | 2025.03.04 |
댓글