HomeAboutMeBlogGuest
© 2025 Sejin Cha. All rights reserved.
Built with Next.js, deployed on Vercel
🚀
개발 노트
/
🎨
아트집 - 본론
/
📜
<아트집> 기술 문서
/
📌
리액트 폼 성능 최적화 - 상태 함께두기2
📌

리액트 폼 성능 최적화 - 상태 함께두기2

 

지난 이야기

사용자가 입력 필드 하나와 상호작용함에도
폼 전체가 리렌더링되어 성능이 저하하는 현상이 관찰되었다.
notion image
 
이에 상태 함께두기(State colocation) 기법을 적용하여
폼의 구조를 아래와 같이 리팩토링하였다.
notion image
그러나 한 가지 문제가 있었다.
폼에서 데이터를 제출하려면
각 입력 필드의 state 및 error를 반드시 알아야 하는데,
현재 구조로서는 이것이 어려워졌다는 것이다.
(리액트는 단방향 흐름이므로,
자식에서 부모 컴포넌트의 state를 전달받기는 쉽지만, 그 반대는 어렵다)
 
이를 해결하는 한 가지 방법은
ref를 사용하여 인풋 필드의 dom에 직접 접근하여 value를 얻는 것이다.
대표적으로, 비제어 컴포넌트 폼인 react-hook-form 라이브러리가 그렇다.
 
그러나 나의 경우는 이 방식을 따르기 어려웠다.
왜나하면, 각 입력 필드는 Ant Design의 컴포넌트였기 때문에
ref로 직접 접근하는 것이 불가능했기 때문이다.
대신에, 나는 그 대안으로 useImperativeHandle 훅을 사용하였다.
 

useImperativeHandle

useImperativeHandle은 부모 컴포넌트가 ref를 통해,
자식 컴포넌트의 상태 또는 로직에 접근할 수 있는 훅이다.
imperative(명령적인)라는 의미에서 알 수 있듯이 명령형 방식이다.
 
useImperativeHandle(ref, createHandle, [deps])
 
첫 번째 인자는 프로퍼티를 부여할 ref이고, 두 번째 인자는 객체를 리턴하는 함수다.
이 객체에 추가하고 싶은 메서드를 정의하면 된다.
이해하기가 조금 어려울 수 있다. 코드와 함께 살펴보도록 하자.
 
먼저 타입스크립트를 사용하므로,
부모에서 자식으로 내려보낼 ref 객체의 타입을 정의할 필요가 있다.
아래와 같이 FieldGetter라는 인터페이스를 만들었다.
이는 getFieldValue와 getFieldError라는 메서드로 구성된다.
이름에서 유추할 수 있듯이, 필드의 value 및 error 값을 가져오는 메서드이다.
맵드 타입(Mapped Type)을 사용하였다.
 
다음으로, 부모 컴포넌트인 Form에서 ref 배열을 선언한다.
 
위 코드가 조금 복잡할 수 있으니, 차근차근 설명하겠다.
  1. fields는 ref 객체이다.
  1. fields.current는 ref 객체들의 배열이다. 각 ref 객체는 FieldGetter이다.
  1. fields.current의 초기값을 선언해준다.
    1. createRef를 사용하여, ref 객체가 FIELD_LENGTH개 담긴 배열을 생성한다.
       
즉, ref 객체(FieldGetter)를 6개 생성하여 배열에 담은 셈이다.
이를 각 입력 필드에 ref로 전달해준다.
아래는 입력 필드 중 하나인 TitleInput에 props로 전달하는 코드다.
 
이제 자식 컴포넌트인 TitleInput을 보도록 하자.
TitleInput은 함수 컴포넌트이며, 그대로는 ref를 받을 수 없다.
forwardRef라는 고차 컴포넌트를 사용하여 래핑해주어야 한다.
그러면 두 번째 인자로 ref를 받을 수 있게 된다.
아래 두 번째 줄의 코드를 참고해주기 바란다.
 
이제 useImperativeHandle 훅을 사용할 수 있다.
아래와 같이 title과 error의 값이 변화할 때마다 ref 객체를 새롭게 정의한다.
 
해당 ref 객체는 부모 컴포넌트인 Form에서 사용할 수 있다.
예컨대 아래와 같이 말이다.
 
 
getFieldValues는 각 입력 필드를 순회하면서
ref.current.getFieldValue를 사용해 value 값을 가져와 객체(acc)에 담는다.
getFieldValues가 리턴하는 이 객체는 예컨대 아래와 같은 모양이다.
 
getFieldErrors도 이와 마찬가지다.
 
이제
 
 
 
 
 
 
 
 
 
 

성능 개선 테스트

 
export type FieldValue = string | number | boolean | number[]; export type FieldError = string; export interface FieldGetter { getFieldValue: () => { [key: string]: FieldValue; }; getFieldError: () => { [key: string]: FieldError; }; }
const FIELD_LENGTH = 6; const ReviewEditForm = () => { const fields = useRef<RefObject<FieldGetter>[]>( Array.from({ length: FIELD_LENGTH }, () => createRef<FieldGetter>()), ); // 중략...
<TitleInput prevTitle={prevData?.title} wasSubmitted={wasSubmitted} ref={fields.current[2]} // 자식인 인풋 컴포넌트에 ref를 넘겨준다. />
const TitleInput = forwardRef( ({ prevTitle, wasSubmitted }: TitleInputProps, ref: ForwardedRef<FieldGetter>) => { const [title, setTitle] = useState(prevTitle || ''); const [touched, setTouched] = useState(false); const [error, setError] = useState(prevTitle ? MESSAGE.NO_ERROR : MESSAGE.REQUIRED_VALUE); const displayErrorMessage = (wasSubmitted || touched) && !!error; useEffect(() => { setTitle(prevTitle ? prevTitle : ''); setError(prevTitle ? MESSAGE.NO_ERROR : MESSAGE.REQUIRED_VALUE); }, [prevTitle]); const handleChange = (value: string) => { setTitle(value); validate(value); }; const validate = (value: string) => { if (!value) { setError(MESSAGE.REQUIRED_VALUE); return; } if (value.length > MAX_LENGTH) { setError(MESSAGE.EXCEEDED_MAX_LENGTH); return; } setError(MESSAGE.NO_ERROR); };
useImperativeHandle( ref, () => ({ getFieldValue: () => ({ title, }), getFieldError: () => ({ title: error, }), }), [title, error], ); return ( <> <Input id={LABEL.TITLE} placeholder="제목을 입력해주세요" showCount value={title} onChange={(e) => handleChange(e.target.value)} onBlur={() => setTouched(true)} /> <ErrorMessage message={error} visible={displayErrorMessage} testId={LABEL.TITLE} /> </> ); }, );
// ReviewEditForm // 중략... const getFieldValues = () => { return fields.current.reduce((acc, field) => { if (field.current) { const value = field.current.getFieldValue(); Object.assign(acc, value); } return acc; }, {}); }; const getFieldErrors = () => { return fields.current.reduce((acc, field) => { if (field.current) { const error = field.current.getFieldError(); Object.assign(acc, error); } return acc; }, {}); };
{ exhibitionName: '전시회'; date: '2022-11-14'; title: '제목'; content: '내용'; isPublic: true; }
{ exhibitionName: '필수 입력값 입니다.'; date: ''; title: ''; content: '필수 입력값 입니다'; isPublic: ''; }