안녕하세요, 인프랩의 프론트엔드 개발자 홍시입니다.
이번 글에서는 팀원들과 함께 IT 채용 플랫폼 랠릿의 프로필·지원서 기능을 react-hook-form으로 리팩토링한 경험을 공유하려고 합니다 :)

1. react-hook-form 도입 이유

앞서 보셨던 글에서처럼 인프랩 프론트엔드 팀은 몇 백개의 form이 한 페이지에 있어 발생하는 렌더링 문제를 해결하기 위해 colocation과 ref를 이용했으나, 이 방법이 야기하는 또 다른 문제들을 마주했습니다.

colocation이란?
input 요소가 여러 개일 때, 한 input의 유효성 상태 변경 때문에 모든 컴포넌트가 리렌더링 될 필요는 없습니다. 때문에 리렌더링이 필요한 컴포넌트만 분리하여, 리렌더링되는 범위를 좁히는 방법이 많이 쓰입니다.
이런식으로 컴포넌트의 상태를 서로 관련이 있는 것들끼리만 모아 분리하여 위치시키는 방법을 state colocation이라고 합니다.

그래서 기존의 방법을 대신할 새로운 방법을 찾아 각자가 생각하기에 가장 적절한 상태 관리 툴로 실험해봤지만, 모두가 동의할만한 해결법은 도출되지 않았습니다.
시간이 더 있었다면 달라질 수도 있었겠지만 팀에게는 연구 주제가 아니라 당장 프로덕트에 적용해 생산성의 이점을 가져다줄 수 있는 결론이 필요했습니다.

그러던 중 react-hook-form에 대해 알게되었고, 먼저 간단한 데모를 만들어 우리의 문제를 해결해줄 수 있는지를 확인했습니다.
그리고 많은 부분을 해결해줄 수 있음을 확인하여 도입했습니다.

react-hook-form을 본격적으로 도입하기로 결정한 이유이자, 도입으로 얻은 결과(이점)들을 랠릿 프로젝트를 바탕으로 설명드리자면 다음과 같습니다.

1-1. 렌더링 이슈, 동기화 문제 해결

검토했던 방법들 중, 가장 핵심적인 문제였던 렌더링 이슈를 해결하면서도 부차적인 문제가 발생하지 않은 유일한 방법이었습니다.

react-hook-form은 기본적으로 비제어 컴포넌트 방식으로 구현되어있기에 렌더링 이슈를 해결하면서도, form의 데이터와 상태를 Provider 아래라면 어느 곳에서든지 props drilling 없이 사용할 수 있었습니다.

비제어 컴포넌트 방식이지만 데이터는 계속 동기화되며 분산되지 않아 데이터를 한 곳에서 관리하고자 하는 니즈도 충족할 수 있었습니다.

기존에는 이렇게 총 4개로 분산된 상태를 이용해 렌더링 이슈를 해결하고 폼 데이터를 동기화 했다면,

  1. Recoil 에서 관리하는 상태

    • 지원서의 어떤 section에 에러가 있는지 알려주는 상단 스크롤 탭의 notification badge UI에 활용됩니다.
    • 지원서 제출 버튼의 disabled를 실시간으로 업데이트하기 위해 활용됩니다. (지금은 없는, 초기 기획에 있었던 요구사항입니다)
  2. Form Control 별 local state (colocation)

    • 각 Form Control (input, textarea 등)에 표시될 에러 메세지 및 에러 UI를 표현하기 위해 활용됩니다.
  3. Context api 에서 관리하는 상태

    • 프로필의 상단 스크롤 탭 notification badge, 제출 버튼 클릭에 따른 input 에러 UI 업데이트에 활용됩니다.
    • 동적으로 늘고 줄어드는 form을 관리하는데 활용됩니다.
  4. ref로 직접 dom 접근

    • post 요청을 위한 최신 데이터.

이제는 모든 곳에서 react-hook-form의 useFormContext 하나에서 제공하는 form의 데이터와 에러만을 사용하면 됩니다.
덤으로, 상태 관리도 단순화 할 수 있습니다.

  1. 스크롤 탭 에러 관리
    useFormState 훅으로부터 받은 errors 객체를 이용해 각 섹션마다 에러가 있는지 확인하고, 스크롤 탭에 notification badge UI를 렌더링합니다.
    이것으로 기존의 Recoil에서 관리하는 상태와 Context api에서 관리하는 상태를 대체할 수 있습니다.

  2. Form Control 별 에러 UI 관리
    useFormState 훅으로부터 받은 errors 객체를 이용해 각 입력 인터페이스 역할을 하는 디자인 시스템 컴포넌트에 에러 존재 여부와 에러 메세지를 넘겨주어 에러 UI를 관리합니다.
    이것으로 기존의 Form Control 별 local state (colocation)를 사용하던 부분을 대체할 수 있습니다.

    const { errors } = useFormState<ProfileModel>({
      name: [`career.educations.${index}.schoolName`, `career.educations.${index}.major`],
    });
    
    // 디자인 시스템 Input 컴포넌트
    <Input
      errorMessage={
        errors.career?.educations?.[index]?.schoolName?.message || ERROR_MESSAGE_SCHOOL_NAME
      }
      error={!!errors.career?.educations?.[index]?.schoolName}
      ...
    />
  3. post 요청을 위한 최신 데이터 관리
    react-hook-form에서 제공하는 handleSubmit 함수는 2가지 인자를 전달받는데요.
    2가지 중 첫 번째가 사용자가 에러가 없는 form을 제출했을 때, 즉 유효성 검사를 통과 후 submit 할 때 실행되는 함수입니다.
    이 함수는 react-hook-form에서 관리하는 form의 데이터를 매개변수로 전달 받아 사용할 수 있습니다.
    이것으로 post 요청을 위한 최신 데이터를 가져오기 위해 ref로 직접 dom에 접근하던 것을 대체할 수 있습니다.

    <form
      ref={formRef}
      onSubmit={methods.handleSubmit(handleSubmitButton, onError)}
      ...
    />
    
    const handleSubmitButton: SubmitHandler<FieldValues> = (applicant) => {
     openModal(ConfirmModal, {
       ...
       onOk: () => onSubmit(applicant),
     });
    };
    

1-2. 여러가지 유틸과 코드 단순화

가장 중요했던 단일 출처화와 그로 인한 렌더링 문제의 해결이 가능함을 확인한 뒤, 추가적으로 조사한 부분은 ‘기존의 구현 내용을 그대로 마이그레이션 할 수 있을만큼 유틸이 다양한가?’ 였습니다.

확인 결과 기존에 전역 상태를 여럿 만들어 구현했던 기능들 상당수가 react-hook-form에서 자체적으로 제공하는 유틸로 대체 가능했고, 심지어는 아직 개발하지 못한 기획 요구사항까지 해결이 가능함을 발견했습니다.

또한 react-hook-form에서 제공하는 직관적인 유틸로 대체했을 때 프로덕트의 전체 코드가 크게 줄어들고, 단순해져 가독성이 좋아지는 이점도 있었습니다.

실제로 도입 이후 코드 라인 기준 총 6,977(+2,430, -4,547) 라인이 수정되었습니다.

codediff

이렇게 극단적으로 코드 라인이 줄어드는 경우가 많았습니다.

이 부분은 저희에게 중요한 포인트였는데요. 그 이유는 기존의 개발 담당자들도 코드의 양이 너무 많다보니 그걸 읽는 데도 한 세월이었고, 심지어 다른 프로덕트를 잠깐 만지다 돌아오면 가물가물해져 다시 봐야해 유지보수 비용이 많이 들었었기 때문입니다.

react-hook-form 적용으로 코드가 단순해지면 기존 개발 담당자 뿐 아니라 다른 팀원들의 유지보수 비용을 낮추면서 기획 요구사항을 더 빠르게 처리할 수 있을 거라 판단했습니다.

또한 전역으로 관리해야하는 상태가 크게 줄어 들어 인증 정보 등 꼭 필요한 부분에만 전역 상태 관리 툴을 적용하게 되었고, 그로인해 Redux, MobX, Recoil 등 ‘어떤 상태 관리 도구를 이용해야 할까?’ 하는 고민으로부터도 자유로워질 수 있었습니다.

팀에서는 최종적으로 Redux를 선택했습니다. 코드 양이 불필요하게 많고 러닝 커브가 다소 있다는 단점이 있긴 하지만, 유일하게 구성원 모두가 학습하고 다뤄본 툴이기도 했고 react-hook-form 적용으로 전역 상태를 사용할 영역이 줄어들어 전보다 단점이 크게 부각되지 않을 거라 생각했기 때문입니다.

1-3. class validator 지원

랠릿에서는 서버로부터 전달받은 데이터를 1차적으로 뷰와 분리하여 데이터와 그와 관련한 메서드를 함께 관리할 수 있도록 class를 만들어 사용하고 있는데요.

class-validator를 이용하면 class-validator에서 제공하는 데코레이터로 각각의 필드에 대해 손쉽게 유효성을 검증할 수 있습니다.

예를 들면, 다음은 projectName의 최소/최대 length를 검증하는 데코레이터를 달아둔 코드입니다.

export default class ProjectModel {
  @Length(1, 100, { message: ERROR_MESSAGE_PROJECT_NAME })
  projectName = '';

  ...
}

또한 이 라이브러리는 랠릿 백엔드에서도 사용하기 때문에 프론트엔드와 백엔드 사이 유효성 검증 로직 싱크를 맞추는 데 유리한 지점이 있었습니다.

이러한 이유로 모든 유효성 검증 로직은 class-validator 라이브러리를 이용해 작성되어 있습니다.

물론, class 인스터스를 지원하지 않는 redux와 같은 라이브러리를 사용할 때에는 json으로 변환해주는 메서드를 따로 만들어 이용해야 하는 번거로움이 있습니다 🥲

이 부분이 react-hook-form에서 지원되지 않는다면 전체 유효성 검증 로직을 다시 짜야하는 비용이 발생하기에 꽤나 중요했던 부분이었는데, 다행스럽게도 @hookform/resolvers/class-validator 를 설치하면 class-validator를 그대로 사용할 수 있었습니다.

아래와 같이 classValidatorResolver를 등록해주면,

const methods = useForm<ProfileModel>({
  mode: 'onSubmit',
  resolver: classValidatorResolver(ProfileModel),
  defaultValues: jobSeeker,
});

class-validator 에서 반환하는 에러 형식 그대로가 react-hook-form 에서 반환하는 errors 객체 안에 담기게 됩니다.

이것을 이용하면 다음과 같이 에러 메세지를 렌더링할 수 있습니다.

const { errors } = useFormState<ProfileModel>({
    name: [`career.educations.${index}.schoolName`, `career.educations.${index}.major`],
  });

...

<Input
  errorMessage={
    errors.career?.educations?.[index]?.schoolName?.message || ERROR_MESSAGE_SCHOOL_NAME
  }
  ...
/>

1-4. 코드의 일관성 확보

앞서 여러가지 상태 관리 툴을 이용해보며 실험을 했던 이유는 팀원들 각자가 생각하는 이상적인 상태 관리 툴이 달랐기 때문입니다.
누군가는 Redux의 코드양이 너무 불필요하게 많다는 점을, 누군가는 MobX가 타 상태 관리 도구보다 자유로운 설계가 가능하다는 점을 비효율적인 부분이라고 생각했습니다.

하지만 react-hook-form은 엄밀하게 말하면 상태 관리 툴도 아니고 사용하는 방법이 어느정도 정해져있기 때문에, 코드의 일관성 확보를 위한 팀원들 간의 커뮤니케이션 비용을 최소화 할 수 있었습니다. (물론, 전혀 비용이 들지 않는 것은 아닙니다😅)

일관성 있는 코드는 신규입사자의 적응에 필요한 비용 뿐만 아니라, 기존 팀원의 로테이션시 필요한 학습 비용을 낮출 수 있기에 중요합니다.


이러한 이점들로 react-hook-form을 도입했지만, 그 결정의 과정이 마냥 순탄했던 것은 아닙니다.

회사 프로덕트의 핵심적인 요소에, 혹은 앞으로 있을 모든 프로덕트에 외부 라이브러리를 이렇게 전면적으로 사용하는 것이 과연 안전할까? 라는 의견이 있었습니다.
혹시라도 라이브러리가 유지보수를 중단하기라도 하면, 혹은 라이브러리의 방향성이 우리가 동의하지 않는 방향으로 크게 바뀐다면 그때에는 어떻게 다 걷어내고 수정하냐는 것이었습니다.

이러한 우려는 가능성이 아예 없는 걱정은 아니었지만 올지 안올지도 모르는 미래를 벌써부터 너무 과도하게 걱정하여 현재의 생산성을 저해할 수는 없다는 점에 팀원들의 공감대가 모이며 일단락 되었습니다.
또, 이미 그렇게 쓰이고 있는 React라는 예시가 있기도 했고요 :)

2. react-hook-form 도입 과정

react-hook-form이 우리 팀이 마주한 문제들을 해결해줄 수 있고, 팀에 가져다 줄 수 있는 이점을 확인했으니 이제는 적용할 차례입니다.

그 과정에서의 전략과, 마주했던 이슈들을 소개합니다.

랠릿의 개인 프로필을 작성하는 페이지와 지원서를 작성하는 페이지를 react-hook-form으로 마이그레이션 하는 리팩토링 작업을 진행하기로 결정하며, 먼저 Product Owner 파트와 Product Designer 파트에 양해를 구했습니다.

코드 충돌을 방지하기 위해 리팩토링이 종료되기까지 해당 페이지에서는 꼭 필요한 티켓만 진행하기로 하고, 대부분의 개선 사항은 리팩토링이 종료된 후 진행하기로 합의를 하고 시작했습니다.

2-1. 전략

전략 후보로 크게 두 가지가 있었습니다.

  1. 해당 페이지에서 기존 상태관리를 모두 걷어내 뷰 로직만 남겨두고 react-hook-form을 도입한다.
  2. 기존 코드를 그대로 두고 점진적으로 적용한다.

전자로 하면 일을 두 번 하는 거란 생각이 있기도 했고 후자보다 시간이 더 걸릴 것이라고 생각해, 저희 팀이 선택한 방법은 후자였습니다.

일단 기존처럼 모든 기능이 정상적으로 동작하는 상황에서, react-hook-form을 점진적으로 적용하며 적용 후에도 기능들이 제대로 동작하는지 확인하기로 했습니다.

마냥 좋기만 한 방법은 아니었습니다. 지금 이게 기존 코드 때문에 정상 동작하는 것인지, 아닌지 헷갈릴 때도 있었기 때문입니다. 모든 리팩토링이 완료된 후에도 앞서 검증했던 기능들이 제대로 동작하는지 한 번 더 확인해야 했습니다.

2-2. react-hook-form 적용 과정에서의 이슈

공식문서를 기반으로 학습하며 적용하다가 마주했던 이슈들을 공유하고자 합니다.

2-2-1. register

react-hook-form은 기본적으로 비제어 컴포넌트와 input을 활용합니다. input이 react-hook-form에 의해 비제어 컴포넌트로 동작하기 위해서는 register 함수가 반환하는 값을 컴포넌트의 prop으로 전달받아야 합니다.

<textarea
  ...
  {...register('ability.introduce')}
/>

register 함수가 반환하는 값에는 ref object도 포함되어 있는데요.

랠릿에서는 자체적으로 개발한 디자인시스템을 사용하고 있으므로, ref object를 전달하기 위해서는 디자인시스템 컴포넌트를 forwardRef로 감싸는 작업이 필요했습니다.

여기까지는 매우 수월하게 진행이 되었지만, 다소 문제가 있었던 부분은 이미 전달받은 ref를 자체적으로 사용하고 있던 컴포넌트들이거나, 제어 방식으로 value를 관리하는 컴포넌트였습니다.

이 경우 register 함수로 손쉽게 해결할 수 없습니다. 유저가 입력한 값의 업데이트를 setValue 를 이용해 수동으로 진행해야 합니다.

다행히 react-hook-form은 이런 귀찮은 일을 대신 해줄 수 있도록 Controller(공식문서)를 제공합니다.
Controller를 이용해 해당 요소를 감싼다면, register를 적용한 것처럼 react-hook-form에서 form 요소를 제어할 수 있습니다.

<Controller
  name={`career.companies.${index}.employmentStatus`}
  render={({ field }) => (
    <Dropdown label="재직 여부" autoSelect options={employmentStatusOptions} {...field} />
  )}
/>

2-2-2. 배열 관리 - useFieldArray

랠릿의 프로필, 지원서에서 사용하는 데이터 구조는 다음과 같이 객체들로 이루어진 배열이기에 배열을 관리하는 useFieldArray 훅을 사용해야 합니다.

{
  ...
  educations = [
    {schoolName, schoolGrade, schoolEnrolledAt, schoolEndedAt, ...}, {}, {} ...
  ];
  ...
}

사실 공식문서가 상세하기에 이것만 잘 읽어보더라도 큰 이슈 없이 사용할 수 있긴 하지만, useFieldArray를 이용하며 특히 주의해야 했던 부분들을 소개합니다.

  1. React에선 배열의 요소를 map 돌릴 때 반드시 key에 유니크한 값을 넣어주어야 하는데, 이때 배열의 index를 key로 활용하는 것이 그닥 좋은 방법이 아니라는 것은 모두가 알고 계실 것입니다. useFieldArray는 index대신 key로 사용할 수 있는 uniq id를 자동으로 생성해줍니다.

    <ul>
      {_.map(data, (item, index) => {
        return (
          <li key={item.id}>
            {' '}
            // BAD: key={index}
            ...
          </li>
        );
      })}
    </ul>
  2. 데이터를 append, prepend, insert, update 할 때 모든 필드는 default value를 제공해야 합니다. 이 부분을 모르고 기본값을 주지 않았다가 제대로 동작하지 않는 이슈가 발생했었습니다.

    export default class ProjectModel {
      @Length(1, 100, { message: ERROR_MESSAGE_PROJECT_NAME })
      // default value setting
      projectName = '';
      ...
    }
  3. 타입스크립트 사용시, register 할 때 as const 를 사용해야 하며 nested field array인 경우에는 각각의 이름으로 캐스팅해야 합니다.

    const { fields } = useFieldArray({ name: `test.${index}.keyValue` as 'test.0.keyValue' });

2-2-3. Dev tool

react-hook-form은 dev tool을 제공하는데요, 이것으로 form의 데이터 변화를 콘솔에 찍지 않고도 GUI로 확인할 수 있어 아주 유용하게 활용했습니다.

적용하는 방법은 아주 간단합니다.

먼저 패키지를 설치하고,

yarn add @hookform/devtools

다음과 같이 DevTool 컴포넌트에 control만 등록하면 끝입니다.

<>
  <FormProvider {...methods}>...</FormProvider>
  <DevTool control={methods.control} />
</>

대신, cypress와 같은 툴로 e2e 테스트를 돌릴 때는 비활성해야 합니다.

e2e 테스트를 돌릴 때 개발 환경에서 실행되기 때문에 dev tool이 노출되어 테스트에 필요한 요소를 가려 유저 이벤트가 실행되는 것을 방해하기 때문입니다.

2-2-4. File input 컨트롤하기

file input을 다룰 때에는 UI 커스터마이징도 필요하기 때문에 일반적인 요소와는 조금 다르게 register해야 했습니다.

  1. ‘파일 추가하기’ 버튼을 누르면 먼저 숨겨진 빈 file input 요소를 생성합니다 (append).
    먼저 빈 요소를 생성하는 이유는 유저가 파일을 선택하지 않고 취소할 경우 그 시점을 코드로 특정하기가 어렵기 때문입니다.
    그리고 생성된 file input 요소를 click 함수로 클릭해 유저가 그들의 디바이스에서 첨부할 파일을 선택할 수 있도록 합니다.

    export default function useAttachFiles(formRef: RefObject<HTMLFormElement>) {
      const { control } = useFormContext();
    
      const {
        fields: attachFiles,
        append: appendFile,
        remove: removeFile,
        update: updateFile,
      } = useFieldArray({ control, name: 'additional.attachFiles' });
    
      useEffect(() => {
        // 리훅폼에 append해서 돔이 생성되는 타이밍을 보장할 수 없으므로 useEffect에서 구독해 순서를 보장한다
        // 빈 모델이 생성되면 click하여 파일 선택을 할 수 있게 합니다.
        handleOpenBrowserFileSelector();
      }, [attachFiles]);
    
      const getEmptyFile = () => {
        return formRef.current?.querySelector(`.${ATTACH_FILE_CLASS_NAME}--invalid`) || null;
      };
    
      const handleOpenBrowserFileSelector = () => {
        const emptyFile = getEmptyFile();
    
        if (!emptyFile) {
          return;
        }
    
        (emptyFile.querySelector('input[name="attachFile"]') as HTMLInputElement)?.click();
      };
    
      const handleClickAddFileButton = () => {
        handleOpenBrowserFileSelector();
    
        const emptyFile = getEmptyFile();
    
        if (!emptyFile && attachFiles.length < 5) {
          appendFile(new AttachFileModel());
        }
      };
    
      ...
    }
  2. 유저가 선택한 파일을 랠릿의 CDN에 올리기 위한 통신을 진행하고, 그 응답값을 받아 form의 첨부파일 데이터를 업데이트 후 어떤 파일이 첨부되었는지 화면에 렌더링 합니다 (update).

    // 파일 업로드 이벤트
    const fileUploadEventHandler: ChangeEventHandler<HTMLInputElement> = async ({
      target: { files },
    }) => {
      ...
      try {
        ...
        // cdn 업로드 후 업로드된 결과를 응답값으로 반환
        const { data: file } = await fileService.uploadFile(files[0]);
    
        // 응답값을 form의 데이터에 업데이트
        updateFile(index, plainToClass(AttachFileModel, file));
      } catch (error) {
        ...
      } finally {
        ...
      }
    };
  3. 파일을 삭제할 때에는 다른 배열 데이터와 마찬가지로 useFieldArrayremove 함수를 이용해 form의 데이터를 삭제합니다.

     const {
       ...
       remove: removeFile,
     } = useFieldArray({ control, name: 'additional.attachFiles' });
    
     ...
    
     const openRemoveFileModal = (index: number) => {
       // 파일 삭제를 클릭했을 때 발생할 모달을 만드는 함수입니다.
       openModal(ConfirmModal, {
         ...
         // 파일 삭제 확인을 클릭했을 때 호출되는 함수입니다.
         onOk: () => {
           removeFile(index);
         },
       ...
       });
      };

3. 결론

랠릿 개발 당시 빠듯했던 일정 속에서 변경되는 기획을 맞추기 위해 선택했던 기존의 설계를 뒤엎지 않고 계속해서 새로운 구조를 추가하는 방법은 돌이켜보니 더 큰 병목 지점을 만들고 있었습니다.

react-hook-form을 사용한다고 해서 모든 것이 마냥 마법처럼 해결되는 것은 아니지만, 기존에 4개로 분산되어 있던 상태를 한 곳에서 관리할 수 있게 되며 상태 관리가 단순해졌고, 명령적이던 코드를 선언적으로 작성할 수 있게 되었으며, 몇 천 라인을 삭제함으로써 코드의 가독성과 예측 가능성이 높아졌습니다.

이런 결과들이 앞으로의 확장 및 유지보수에 도움이 될 것이라고 기대합니다.

이 글이 react-hook-form 도입을 고민하고 있는 분들께 도움이 되었으면 좋겠습니다.

감사합니다 :)