[CRUD 실습 - React + Vite] 리팩토링 (5) - React Hook Form

Chan의 기술 블로그·2025년 12월 27일

Gitlog & Branch
리펙토링 한 기록은 아래 링크 Branch에서 확인할 수 있다.
- feature/리펙토링 Branch

산출물 링크
- GitHub
- 배포 페이지

지난 포스팅에서는 로직이 거대했던 UsersProvider의 복잡한 삭제 로직을 Reducer로 격리하고, formonSubmit을 활용하여 코드를 리펙토링한 과정을 기록했었다.

이번 포스팅에서는 FormData를 이용한 DOM 접근 방식과 useState를 이용한 상태 관리가 혼재되어 있던 코드를 React Hook Form으로 통합한 과정을 기록했다

특히 DOM 의존성을 제거하고, 불필요한 리렌더링을 방지하여 성능과 유지보수성을 동시에 잡은 경험을 다룬다.


1. 문제점 분석

초기 프로젝트는 기능 구현에 집중하여 두 가지 방식을 혼용하고 있었다. 기능상으로는 문제가 없었지만, 코드가 커질수록 유지보수의 비용이 증가했다.

문제 1: 원시적인 FormData 파싱

기존에는 전체 수정을 처리하기 위해 HTML Form의 submit 이벤트를 활용해 FormData를 추출했다.

// [Before] Users.tsx: DOM의 name 속성에 의존하는 로직
const parseFormDataToUsers = (formData: FormData) => {
  const currentDataMap: UserIdAndEditableUserFormObject = {}
  for (const [key, value] of formData.entries()) {
    // name="first_name_123" 문자열을 정규식으로 쪼개서 ID와 필드명을 추출
    const match = key.match(/^(.+)_(\d+)$/) 
    if (!match) continue
    // ... 파싱 및 형변환 로직 ...
  }
  return currentDataMap
}

문제점: 데이터의 구조를 파악하기 위해 DOM의 name 속성 규칙(key_id)에 의존해야 했으며, 정규식 파싱 로직이 복잡하고 타입 안전성을 보장받을 수 없게 되었다.

문제 2: 상태 관리의 파편화와 렌더링 성능

개별 수정(UsersItem.tsx)은 useState로, 전체 수정은 DOM(FormData)으로 처리하다 보니 데이터 흐름이 이원화되어 있었다.

  • 비효율적 렌더링: 개별 수정 시 useState를 사용하다 보니, 텍스트를 입력할 때마다 해당 컴포넌트가 리렌더링되었다. 유저 리스트가 길어질수록 성능 저하의 우려가 있었다.
  • 로직의 불일치: 같은 데이터를 다루는데 한쪽은 state.value, 다른 쪽은 input.value를 바라보는 구조였습니다. 이는 추후 유효성 검사 로직을 추가할 때 양쪽을 모두 신경 써야 하는 부담이 되었다.

이를 해결하기 위해 모든 폼 데이터를 하나의 저장소(RHF Store)에서 관리하는 방식으로 통일을 결정했다.


2. 해결 방안: 단일 폼 전략

핵심 전략은 DOM과 State의 분리를 끝내고, RHF 하나로 통합하는 것이다. 리스트에 있는 100명의 유저를 각각 별개의 상태나 DOM 요소로 보는 것이 아니라, 하나의 거대한 폼안의 배열 필드로 관리한다.

  • Brain (Users.tsx): useForm 인스턴스를 생성하고 데이터 흐름을 총괄
  • List (UsersList.tsx: useFieldArray를 통해 데이터를 리스트로 펼침
  • Cell (UsersItem.tsx): useState 대신 register를 사용하여 RHF에 직접 연결

3. 리팩토링 과정

1 : DOM 파싱 로직 제거 (Users.tsx)

가장 먼저 복잡했던 parseFormData 로직을 제거했다. 이제 데이터는 DOM에서 긁어오는 것이 아니라, RHF가 관리하는 객체에서 바로 꺼내온다.

// [After] Users.tsx
const methods = useForm<UsersFormValues>({
  mode: 'onSubmit', 
  defaultValues: { users: [] },
})

const { handleSubmit, formState: { dirtyFields } } = methods

// onSubmit 시 더 이상 파싱할 필요 없이 data.users를 바로 사용
const onSubmit = async (data: UsersFormValues) => {
   // dirtyFields를 이용해 변경된 값만 깔끔하게 추출 가능
   const modifiedData = getModifiedUsersPayload(dirtyFields, data.users)
   // ...
}

2 : 배열 관리 (UsersList.tsx)

users.map으로 렌더링하던 방식을 useFieldArray로 변경했다. 이를 통해 폼 데이터의 추가/삭제/순서 변경을 RHF 내부 로직으로 안전하게 처리할 수 있게 되었다.

// [After] UsersList.tsx
const { control } = useFormContext()
// RHF가 관리하는 'fields' 배열을 사용
const { fields } = useFieldArray({
  control,
  name: 'users',
  keyName: 'keyId' 
})

return (
  <ul>
    {fields.map((field, index) => (
      <UsersItem key={field.keyId} index={index} id={field.id} {...field} />
    ))}
  </ul>
)

3 : useState 제거와 성능 최적화 (UsersItem.tsx)

개별 아이템의 useState를 제거하고, RHF의 register 함수로 교체했다.

// [After] UsersItem.tsx
// const [value, setValue] = useState(...)  <-- 제거!

const { register } = useFormContext()

// 비제어 컴포넌트(Uncontrolled) 방식 적용
<input 
  {...register(`users.${index}.first_name`, { 
    required: true,
    validate: (val) => !!val.trim() || '공백 금지'
  })} 
/>

개선점:

  • 렌더링 최소화: 타이핑을 해도 리액트 상태가 변경되는 것이 아니므로, 컴포넌트 리렌더링이 발생하지 않는다.
  • 유효성 검사 통일: 개별 수정이든 전체 수정이든 register에 정의된 룰(required, pattern) 하나로 통합 관리된다.

4. 추가로...

1. 개별 수정 버튼의 Validation 처리

전체 수정은 form onSubmit이 자동으로 검증해주지만, 개별 수정 버튼은 단순 onClick이다. 이때는 RHF의 trigger를 사용해 수동으로 검증을 수행했다.

const handleSubmitUserItem = async () => {
  // 해당 인덱스의 필드들만 콕 집어서 유효성 검사 수행
  const isValid = await trigger(`users.${index}`)
  if (!isValid) return 

  // 검증 통과 시 데이터 전송 로직 수행
}

2. 변경된 내용만 보내기

기존에는 FormData 전체를 순회해야 했지만, 이제는 RHF가 제공하는 dirtyFields를 활용해 변경된 필드만 정밀하게 추출할 수 있다.

// 변경 감지 (Dirty Check)
const userDirtyFields = dirtyFields.users?.[index]
if (!userDirtyFields) return 

// 변경된 키만 Payload에 담아 API 전송 (네트워크 비용 절감)

3. React.memo를 이용한 방어

RHF 도입으로 Input 입력 시 리렌더링은 사라졌지만, dirtyFields 상태가 변할 때 상위 컴포넌트가 리렌더링되는 현상이 있었다. 이를 방지하기 위해 UsersListUsersItemReact.memo로 감싸, 불필요한 렌더링 전파를 완벽하게 차단했다.


5. 결론: 리팩토링의 효과

구분Before (DOM & State)After (React Hook Form)
데이터 접근FormData 파싱타입 안전성 확보
성능 (렌더링)입력할 때마다 리렌더링 발생 (useState)비제어 컴포넌트로 리렌더링 없음
코드 구조DOM 파싱 로직과 State 로직 혼재register 하나로 로직 일원화
유효성 검사수동 검사 로직 작성 필요선언적인 Rule (validate, pattern) 사용
데이터 전송변경 여부 판단 어려움dirtyFields로 변경된 값만 전송 용이

이번 리팩토링은 단순히 라이브러리를 적용한 것을 넘어, DOM에 의존하던 명령형 코드를 상태 기반의 선언형 코드로 전환했다는 데에 큰 의의가 있다. 결과적으로 코드는 더 간결해졌고, 애플리케이션은 더 빨라졌다.

이것으로 내 첫번째 프로젝트 CRUD 실습을 마친다.

profile
퍼블리셔에서 프론트앤드로 전향하기

0개의 댓글