React hook form와 Zod를 프로젝트에 효과적으로 녹여보기 위해 씨름한 기록일지

JangGwon·2024년 5월 17일

들어가며

데브코스 팀원들과 중고 경매 거래 플랫폼 Hands Up 프로젝트를 진행하면서, react-hook-formzod를 처음으로 도입하면서 공부한 내용들을 이번 포스팅으로 정리해보려합니다.
제가 공부한 내용들을 정리했지만 틀린 내용이 있을 수 있으며, 추가적으로 틀린 내용이 있다는것을 발견시 언제든 수정 준비가 되어있습니다 😁




React Hook Form이란?

React Hook Form이란 React기반의 폼 관리 라이브러리로 복잡한 폼 유효성 검사 로직을 쉽게 작성하고, 컴포넌트 상태를 간단하게 관리할 수 있게 도와주는 라이브러리입니다!


React-hook-form을 도입하게 된 이유

이유1 - 간결하고 명료한 코드를 위해!

프로젝트를 진행하던 중 한번에 여러 정보를 입력하기 위한 폼들이 많이 만들어야 했습니다. 이 때 만드는 폼 하나 하나에 유효성 검사, input value에 따른 이벤트들을 구현하기 위해서 많은 useState를 사용하니 컴포넌트의 복잡성만 더 커졌습니다.

(예시 - 한개의 form을 사용중인 회원정보 기입 폼 퍼널 )


...
  const { topFunnelPage, popFunnel } = useFunnel();
  const [email, setEmail] = useState(""); // 유효성 검사를 위한.. 
  const [emailStatus, setEmailStatus] = useState<("Empty"|"Changed"|"CheckSuccess">("Empty"); // 중복 검사 확인용을 위한..
  const [password, setPassword] = useState("");   // 유효성 검사를 위한.. 
  const [nickName, setNickName] = useState("");   // 유효성 검사를 위한.. 
  const [profileImage, setProfileImage] = useState(""); // 유효성 검사를 위한.. 
  const [category, setCategory] = useState([]); 
  const [address, setAddress] = useState({ si: "", gu: "", dong: "" });

  const onSubmitForm = () => {
  	  switch (topFunnelPage) {
        case "EMAIL_LOGIN_FUNNEL":
          if (email.length > 8 && email.length < 30 && password > 9 && emailStatus === "CheckSuccess")
          popFunnel();
          break;
      case "USER_PROFILE_FUNNEL":
        if (profileImage && nickName.length > 1 && nickName.length <= 8) {
          pushFunnel();
        } else if (!profileImage) {
          toast.show("프로필 사진을 등록해주세요!", "warn-solid", 3000);
        } else if (nickName.length < 2) {
          toast.show("닉네임을 2글자 이상 입력해주세요!", "warn-solid", 3000);
        } else if (nickName.length > 8) {
          toast.show("닉네임을 8글자 이하로 입력해주세요!", "warn-solid", 3000);
        }
        break;
      case "RESIDENT_ENROLL_FUNNEL":
        if (address.dong) {
          popFunnel();
        } else {
          toast.show("거주지를 등록해주세요!", "warn-solid", 3000);
        }
        break;
      case "SELECT_CATEGORY_FUNNEL":
        if (category.length > 0) {
          popFunnel();
        } else {
          toast.show("선호 카테고리를 선택해주세요", "warn-solid", 3000);
        }
        break;
    }
  }
...

현재 위에 작성된 코드에서는 여러 input value에 따른 동작, 유효성 검사 및 에러 처리 로직 등이 작성되어있는데요.
여기에 더 세밀한 유효성 검사와 에러처리 그리고 새로운 input을 추가할 일이 생긴다면, 더욱 더 복잡해져서 코드를 직관적으로 보기 힘들어지고 유지보수가 어려워질거 같았습니다.


이유2 - 예측 불가능하고 불필요한 렌더링을 줄이기 위해

Form을 제어하고 관리하는데는 크게 제어 컴포넌트 방식과 비제어 컴포넌트 방식이 있습니다.
제어 컴포넌트 방식은 Form을 React에 의해서 값을 제어하는 방식이며, useState를 사용하는 것도 제어 컴포넌트 방식이라고 할 수 있습니다.

	<form>
       <input
          onChange={(event) => setEmail(event.target.value)}
        />
       <ChildCompoenntA/>
       <ChildCompoenntB/>
       <ChildCompoenntC/>
   </form>

이런 제어 컴포넌트 방식인 useState를 사용하여 Form을 관리한다면 input Value가 변경될 때마다 해당 컴포넌트는 물론 하위 컴포넌트까지 리렌더링을 하며 변경사항이 없는 부분일지라도 불필요하게 리렌더링하여 성능 문제가 생길 수 있습니다.

(예를 들면 이렇게 말이죠...)

하지만 react-hook-form 라이브러리는 리액트의 개념인 state를 활용하는것이 아닌 ref를 활용하여 Form을 관리하는 비제어 컴포넌트 방식으로 작동시키는것이 가능합니다.

그렇기 때문에, Form에서 발생하는 불필요한 렌더링을 막아 최적화에 도움이 될 수 있다고 생각했습니다.


위와 같은 장점들을 챙길 수 있다고 판단되어 도입을 결정했습니다!





react-hook-form 간단한 적용 예시

function FilterComponent() {
  const { register, handleSubmit, formState: { errors } } = useForm();

  const onSubmit = (data) => {
    console.log(data);
    // 폼 제출 시 처리할 로직 작성
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="minPrice">최소 희망 가격</label>
        <input
          id="minPrice"
          type="number"
          placeholder="최소 희망 가격"
          {...register("minPrice", {
            required: "최소 희망 가격을 입력해주세요.",
            min: {
              value: 0,
              message: "최소 희망 가격은 0 이상이어야 합니다.",
            },
          })}
        />
        <ErrorMessage
            errors={errors}
            name="minPrice"
            render={({ message }) => (
              <p className="text-red-600 mt-2">{message}</p>
            )}
          />
      </div>
      <div>
        <label htmlFor="maxPrice">최대 희망 가격</label>
        <input
          id="maxPrice"
          type="number"
          placeholder="최대 희망 가격"
          {...register("maxPrice", {
            required: "최대 희망 가격을 입력해주세요.",
            min: {
              value: 0,
              message: "최대 희망 가격은 0 이상이어야 합니다.",
            },
          })}
        />
        ErrorMessage
            errors={errors}
            name="maxPrice"
            render={({ message }) => (
              <p className="text-red-600 mt-2">{message}</p>
            )}
          />
      </div>
      <button type="submit">검색</button>
    </form>
  );
}

이렇게 React-hook-Form을 사용하면 useForm 훅을 사용하여 폼 상태 관리를 손쉽게 할 수 있습니다.
register 함수를 통해 입력 필드를 등록하고 필드의 이름, 유효성 검사 규칙, 초기값 등을 설정할 수 있습니다. 그리고 handleSubmit 함수를 사용하여 폼 제출 시 처리할 로직을 간단히 작성할 수 있으며, formState 객체를 통해 폼의 유효성 검사 결과에 접근할 수 있어, 에러 메시지를 화면에 쉽게 표시할 수 있습니다.
리액트 훅 폼에서 제공되는 ErrorMessage컴포넌트를 이용한다면 빠르게 추가적인 로직없이 빠르게 에러메세지를 띄우는것도 가능했습니다.

위 예시외로 ref를 참조할 수 없는 외부/커스텀 컴포넌트를 사용한다면 controller컴포넌트를 통해 필드 등록이 가능하며, useWatch, watch함수를 통해 폼 상태 변화를 확인 할 수 있습니다.





적용하면서 생겼던 이슈 그리고 추가적으로 공부해본것들..?


1. 하위 컴포넌트로 Form의 로직을 전달 할때, Props Drilling 문제도 해결하면서 편리하게 주고 받기!

큰 규모의 Form의 로직을 한 곳에서 통합하여 개발을 진행하다가 그로 인한 많은 코드와 다양한 Field의 로직들이 작성되는바람에 복잡함이 늘어나서 유지보수에 어려움을 느끼고 있었습니다.

그래서 저는 이러한 문제를 해결하기 위해 Form 로직들을 컨테이너 별로 분리해서 관리하는 방법을 채택하였는데, 이 때 FormProvideruseFormContext 컴포넌트를 사용하여 Form 상태를 깔끔하게 관리 할 수 있었습니다.


🧐 FormProvideruseFormContext가 뭔데 ?

FormProvider컴포넌트는 Context Api 기반으로 구현된 컴포넌트로 하위 컴포넌트에 폼 관련 데이터와 함수, 로직을 전달하는 역할을 합니다.

useFormContext는 FormProvider가 제공하는 폼 관련 데이터와 함수를 사용하기 위한 훅입니다.

요약하자면 FormProvider컴포넌트로 감싸진 하위 컴포넌트들이 useFormContext훅을 통해 useForm에서 반환하는 모든 속성과 메서드들을 사용할 수 있다는 것이다.


🧐 그래서 사용하면 뭐가 좋은데?

  1. 다른 하위 컴포넌트로 Form의 데이터나 로직을 전달 할 때 Props를 통해 넘기지 않고 Context Api 방법인 useFormContext를 통해 전달받기 때문에 Props Drilling 문제 해결 및 Props 보다 더 편리한 방법으로 데이터,로직을 전달 할 수 있다. .

  2. FormProvider 컴포넌트에서 폼 관련 로직을 관리하고, 하위 컴포넌트에서 폼 UI구현을 집중할 수 있어서 컴포넌트간 관심사가 명확하게 분리할 수 있다. (유지보수하기 좋아짐!)


Context Api 기반이라면... 혹시 FormProvider도 성능이슈 문제가...?

저는 React에서는 Context Api를 사용할 때 Context값이 변경된다면 해당 Context 사용하는 모든 컴포넌트와 그 하위 컴포넌트들이 리렌더링이 된다는것으로 알고 있습니다.
그런 문제점을 해결하기 위해 React.memo로 Memolization을 한다면 불필요한 리렌더링을 막아 최적화 할 수 있는데, FormProvider도 비슷한 문제가 있으면 따로 최적화 작업이 필요한지 궁금했습니다.


정답은..? Yes 였습니다.

변경한 Field의 하위 컴포넌트들은 리렌더링이 일어나며 공식문서에 적힌 내용대로 useMemo를 사용하여 성능이슈를 해결 할 수 있다고 합니다.



2. 외부/커스텀 컴포넌트에서 발생하는 Ref 에러 해결하기

프로젝트 초기에 저희 팀은 모두가 자주 사용할만한 공용 컴포넌트들을 우선적으로 만든 후 페이지 구성 작업을 시작하는 방향으로 진행했었습니다.
그렇게 미리 만들어둔 공용 컴포넌트를 잘 활용하는 방안으로 페이지들을 만들어가고 있었고, 그 중에 Input 컴포넌트도 빈번히 사용중이기에 저는 팀원 모두 쉽게 react-hook-form을 적용시킬 수 있게 하려했습니다.

그래서 만들어 둔 Input 컴포넌트수동으로 부모 컴포넌트로부터 ref를 props로 전달받아 연결했더니 아래와 같은 에러를 만날 수 있었습니다.

에러 메세지를 해석하자면... 함수 컴포넌트는 Refs를 넘길 수 없습니다. React.forwardRef()를 이용해주세요 라고 하네요~


음 ... 왜 ref를 그냥 넘길 수 없는걸까요? 그리고 React.forwardRef()는 뭘까요? 너무 궁금해서 한번 찾아봤습니다!


🧐 그 이유는 ref를 props로 넘기지 못하는 이유는 ... Special Props이기 때문

공식문서에 따르면 React에서는 key와 ref는 일반 props와 다르게 Special Props로 다뤄집니다.
왜냐하면 이 키워드들은 각각 배열 렌더링 시 요소 식별과 DOM 요소/컴포넌트 인스턴스 참조를 위해 사용되는 것이 목적이기 때문이라고 합니다.

그래서 props를 넘길 수 있는 방법으로는 prop에 ref라는 식별자명이 아닌 다른 식별자이름으로 넘길 수 있는 방법과 fowardRef로 감싸는 방법이 있습니다.

하지만 ref2, ref3 이런식으로 다른 식별자명으로 넘기는 방법은 좋지 않은 방법이겠죠?

그래서 React.forwardRef를 사용하여 해당 에러를 해결했습니다.




Zod를 사용하여 타입 안정성 강화시키기


Zod란?

Zod는 타입스크립트를 우선하는 스키마 선언 및 검증 라이브러리입니다.



왜 안정성 강화가 필요할까?

타입스크립트는 타입스크립트 컴파일 시점에서만 타입 에러를 찾을 수 있어서 런타임에서 받은 외부에서 들어온 데이터의 타입은 에러체킹이 힘들어집니다.

그 이유는 바로 타입스크립트의 동작과정 때문입니다.

(출처 : https://devopedia.org/typescript / ohchiri2017)

우선 대부분의 브라우저는 타입스크립트 코드를 읽을 수 없기 때문에 타입스크립트 코드는 자바스크립트 코드로 변환시키는 컴파일 과정을 거쳐야하며 이 컴파일 과정에서 typeChecker가 작성된 코드들중에서 타입 오류가 있는지 검출하는 과정이 포함되어있습니다.
그렇게 자바스크립트로 변환이 되버린 코드는 런타임상에서 type checker를 만날 일이 없으니 외부 데이터들의 타입들에서 오류들을 검출 할 수 없는것입니다.

왜요 ? 자바스크립트는 왜 타입 오류를 검출 못하나요?

자바스크립트는 컴파일 과정에 타입을 결정하고 타입이 고정하는 특성을 가진 이정적 타입 시스템을 사용하고있는 타입스크립트와 달리 동적 타입 시스템을 사용하고 있습니다. 이 동적 타입 시스템은런타임 때 변수 값에 따라 타입이 정해지는 특성타입을 변경시킬 수 있는 특성을 가지고 있는데요.

	let message = 'abc' // string
    messgae = 123       // number

위 상황을 런타임 과정이라고 가정한다면 변수에 값이 바뀜에 따라 타입을 자동으로 변경해주는것이죠.

그렇기 때문에 타입스크립트로 지정된 타입이 있다고해도 런타임 상에서 자바스크립트 엔진이 외부에서 추가한 변수 값으로 인해 type이 변경 자동으로 변경되어 버그가 생길 수 있는것입니다.

그렇기에 이zod를 사용한다면 런타임상에서도 type이 변경되지 않도록 타입 안정성을 강화시킬 수 있습니다.
(그외 다양한 장점이 있고 또 zod 라이브러리 외 yup 라이브러리 그 말고도 다양한 방법이 있습니다...)



참고

https://react-hook-form.com/
https://zod.dev/ERROR_HANDLING?id=error-handling-in-zod
https://velog.io/@hochul/%ED%95%A8%EC%88%98%ED%98%95-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-ref
https://blog.leaphop.co.kr/blogs/35/React_ref%EC%99%80_forwardRef_%EA%B7%B8%EB%A6%AC%EA%B3%A0_useImperativeHandle_%EC%A0%9C%EB%8C%80%EB%A1%9C_%EC%95%8C%EA%B8%B0

0개의 댓글