본문 바로가기
공부/프로그래밍

React에서 자식 컴포넌트 제어하기(useRef, forwardRef)

by demonic_ 2025. 3. 18.
반응형

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 조작이나 자식 컴포넌트의 메서드 호출이 필요한 경우에만 사용하면 좋습니다.

 

 

 

반응형

댓글