Gitlog & Branch
리펙토링 한 기록은 아래 링크 Branch에서 확인할 수 있다.
- feature/리펙토링 Branch
지난 포스팅에서는 로직이 거대했던 UsersProvider의 복잡한 삭제 로직을 Reducer로 격리하고, form과 onSubmit을 활용하여 코드를 리펙토링한 과정을 기록했었다.
이번 포스팅에서는 FormData를 이용한 DOM 접근 방식과 useState를 이용한 상태 관리가 혼재되어 있던 코드를 React Hook Form으로 통합한 과정을 기록했다
특히 DOM 의존성을 제거하고, 불필요한 리렌더링을 방지하여 성능과 유지보수성을 동시에 잡은 경험을 다룬다.
초기 프로젝트는 기능 구현에 집중하여 두 가지 방식을 혼용하고 있었다. 기능상으로는 문제가 없었지만, 코드가 커질수록 유지보수의 비용이 증가했다.
기존에는 전체 수정을 처리하기 위해 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)에 의존해야 했으며, 정규식 파싱 로직이 복잡하고 타입 안전성을 보장받을 수 없게 되었다.
개별 수정(UsersItem.tsx)은 useState로, 전체 수정은 DOM(FormData)으로 처리하다 보니 데이터 흐름이 이원화되어 있었다.
useState를 사용하다 보니, 텍스트를 입력할 때마다 해당 컴포넌트가 리렌더링되었다. 유저 리스트가 길어질수록 성능 저하의 우려가 있었다.state.value, 다른 쪽은 input.value를 바라보는 구조였습니다. 이는 추후 유효성 검사 로직을 추가할 때 양쪽을 모두 신경 써야 하는 부담이 되었다.이를 해결하기 위해 모든 폼 데이터를 하나의 저장소(RHF Store)에서 관리하는 방식으로 통일을 결정했다.
핵심 전략은 DOM과 State의 분리를 끝내고, RHF 하나로 통합하는 것이다. 리스트에 있는 100명의 유저를 각각 별개의 상태나 DOM 요소로 보는 것이 아니라, 하나의 거대한 폼안의 배열 필드로 관리한다.
Users.tsx): useForm 인스턴스를 생성하고 데이터 흐름을 총괄UsersList.tsx: useFieldArray를 통해 데이터를 리스트로 펼침UsersItem.tsx): useState 대신 register를 사용하여 RHF에 직접 연결가장 먼저 복잡했던 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)
// ...
}
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>
)
개별 아이템의 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) 하나로 통합 관리된다.전체 수정은 form onSubmit이 자동으로 검증해주지만, 개별 수정 버튼은 단순 onClick이다. 이때는 RHF의 trigger를 사용해 수동으로 검증을 수행했다.
const handleSubmitUserItem = async () => {
// 해당 인덱스의 필드들만 콕 집어서 유효성 검사 수행
const isValid = await trigger(`users.${index}`)
if (!isValid) return
// 검증 통과 시 데이터 전송 로직 수행
}
기존에는 FormData 전체를 순회해야 했지만, 이제는 RHF가 제공하는 dirtyFields를 활용해 변경된 필드만 정밀하게 추출할 수 있다.
// 변경 감지 (Dirty Check)
const userDirtyFields = dirtyFields.users?.[index]
if (!userDirtyFields) return
// 변경된 키만 Payload에 담아 API 전송 (네트워크 비용 절감)
RHF 도입으로 Input 입력 시 리렌더링은 사라졌지만, dirtyFields 상태가 변할 때 상위 컴포넌트가 리렌더링되는 현상이 있었다. 이를 방지하기 위해 UsersList와 UsersItem을 React.memo로 감싸, 불필요한 렌더링 전파를 완벽하게 차단했다.
| 구분 | Before (DOM & State) | After (React Hook Form) |
|---|---|---|
| 데이터 접근 | FormData 파싱 | 타입 안전성 확보 |
| 성능 (렌더링) | 입력할 때마다 리렌더링 발생 (useState) | 비제어 컴포넌트로 리렌더링 없음 |
| 코드 구조 | DOM 파싱 로직과 State 로직 혼재 | register 하나로 로직 일원화 |
| 유효성 검사 | 수동 검사 로직 작성 필요 | 선언적인 Rule (validate, pattern) 사용 |
| 데이터 전송 | 변경 여부 판단 어려움 | dirtyFields로 변경된 값만 전송 용이 |
이번 리팩토링은 단순히 라이브러리를 적용한 것을 넘어, DOM에 의존하던 명령형 코드를 상태 기반의 선언형 코드로 전환했다는 데에 큰 의의가 있다. 결과적으로 코드는 더 간결해졌고, 애플리케이션은 더 빨라졌다.
이것으로 내 첫번째 프로젝트 CRUD 실습을 마친다.