🧐 들어가며: 폼은 생각보다 많은 책임을 가져요
프론트엔드에서 폼은 단순한 input 모음처럼 보이지만, 실제 서비스에서는 꽤 많은 책임을 가집니다.
입력값 저장, 유효성 상태, 에러 메시지, 파일 업로드, 동적 리스트, 미리보기, 페이지 이탈 방지까지 모두 폼과 연결됩니다. 필드가 몇 개 없을 때는 useState만으로도 충분하지만, 폼이 커지는 순간부터 상태가 여러 컴포넌트에 흩어지고 관리 포인트가 빠르게 늘어납니다.
이번 프로젝트에서는 react-hook-form을 회원가입 폼과 캐릭터 생성 폼에 사용했습니다. 특히 캐릭터 생성 화면은 입력 필드가 많고, 에셋과 시나리오처럼 배열 형태의 데이터도 포함하고 있어서 폼 상태가 쉽게 복잡해질 수 있었어요.
이 글에서는 zod 같은 스키마 검증 도구는 제외하고, React Hook Form 자체를 어떻게 활용했는지를 중심으로 정리해보겠습니다.
💡 왜 React Hook Form을 선택했을까요?
처음 폼을 구현할 때 가장 단순한 방법은 각 필드를 useState로 관리하는 것입니다.
하지만 필드가 많아지고, 여러 컴포넌트에서 같은 폼 값을 읽거나 수정해야 하는 순간부터 복잡도가 빠르게 올라갑니다. 예를 들어 캐릭터 생성 폼에는 다음과 같은 값들이 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface CharacterCreateFormValues {
representativeImage: string;
title: string;
name: string;
characterIntroduce: string;
characterDetailSetting: string;
asset?: CharacterAsset[];
scenarios: ScenarioItem[];
tagIds: { id: number; label: string }[];
isPublic: boolean;
characterDescription: string;
tendency: string;
category: string;
}
문자열 필드뿐 아니라 이미지, 에셋 배열, 시나리오 배열, 태그 배열까지 포함되어 있습니다.
이 구조를 전부 useState로 관리하면 값 변경 함수, 검증 상태, 초기화 로직, 저장 로직이 여러 곳에 흩어지기 쉽습니다. React Hook Form을 사용하면 폼 상태는 하나의 흐름으로 관리하면서도, UI 컴포넌트는 작게 분리할 수 있습니다.
🛠️ FormProvider로 props drilling 줄이기
이 프로젝트에서는 폼의 최상위 컴포넌트에서 useForm을 선언하고, FormProvider로 하위 컴포넌트에 폼 메서드를 공유했습니다.
회원가입 페이지의 구조는 다음과 같습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const methods = useForm<AuthFormValues>({
mode: "onChange",
reValidateMode: "onChange",
defaultValues: {
nickname: "",
email: "",
code: "",
password: "",
passwordCheck: "",
signupToken: "",
isPrivacyAgreed: false,
isTermsAgreed: false,
},
});
return (
<FormProvider {...methods}>
<SignupForm />
</FormProvider>
);
이렇게 하면 SignupForm 아래에 있는 NicknameField, PasswordField, PasswordCheckField, Agreed 같은 컴포넌트에서 register, control, errors를 계속 props로 내려보낼 필요가 없습니다.
하위 컴포넌트에서는 useFormContext로 필요한 값만 꺼내면 됩니다.
1
2
3
4
5
const {
register,
control,
formState: { errors },
} = useFormContext();
덕분에 각 필드 컴포넌트는 자신의 UI와 입력 규칙에만 집중할 수 있었습니다.
✍️ register로 기본 input 연결하기
기본적인 input은 register로 React Hook Form에 연결합니다.
닉네임 필드는 다음처럼 작성했습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<SmartInput
{...register("nickname", {
required: "닉네임을 입력해주세요.",
maxLength: {
value: 20,
message: "최대 20자까지 가능합니다.",
},
pattern: {
value: NICKNAME_REGEX,
message: "특수문자는 사용할 수 없습니다.",
},
})}
label="닉네임"
required
value={nicknameValue}
maxLength={20}
error={error?.message as string}
/>
register를 사용하면 입력값 추적, blur/change 이벤트 처리, 기본 검증 규칙 연결을 React Hook Form이 담당합니다.
여기서 중요한 점은 필드 컴포넌트가 폼 전체 구조를 알 필요가 없다는 것입니다. NicknameField는 오직 nickname이라는 필드의 입력과 에러 표시에 집중하면 됩니다.
👀 useWatch로 필요한 값만 구독하기
폼 화면에서는 특정 값이 바뀔 때 UI도 함께 바뀌어야 하는 경우가 많습니다. 회원가입 버튼 활성화, 닉네임 중복 확인, 캐릭터 미리보기 같은 기능이 대표적이에요.
이 프로젝트에서는 useWatch를 사용해 필요한 값만 구독했습니다.
회원가입 폼에서는 필수 입력값과 약관 동의 여부를 구독해 버튼 활성화 여부를 계산했습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const {
nickname = "",
email = "",
password = "",
passwordCheck = "",
isPrivacyAgreed = "",
isTermsAgreed = "",
} = useWatch({ control });
const isFormValid =
!!(
nickname &&
email &&
password &&
passwordCheck &&
isPrivacyAgreed &&
isTermsAgreed
) &&
Object.keys(errors).length === 0 &&
password === passwordCheck;
닉네임 필드에서는 현재 입력값을 구독하고, debounce를 적용한 뒤 중복 확인 API를 호출했습니다.
1
2
3
4
5
6
const nicknameValue = useWatch({ control, name: "nickname" });
const debouncedNickname = useDebounce({
value: nicknameValue,
delay: 500,
});
이렇게 하면 사용자가 입력할 때마다 바로 API를 호출하지 않고, 일정 시간 입력이 멈춘 뒤에만 중복 확인을 수행할 수 있습니다.
캐릭터 생성 화면에서는 useWatch가 미리보기 기능과 연결됩니다.
1
2
3
4
5
6
7
8
9
const scenarios = useWatch({
control,
name: "scenarios",
});
const name = useWatch({
control,
name: "name",
});
사용자가 캐릭터 이름이나 시나리오 내용을 수정하면 오른쪽 미리보기 화면도 자연스럽게 업데이트됩니다.
🧩 useFieldArray로 동적 리스트 관리하기
캐릭터 생성 폼에서 가장 복잡한 부분은 배열 데이터였습니다. 사용자는 에셋을 여러 개 추가할 수 있고, 시나리오도 여러 개 만들 수 있습니다.
이런 동적 배열 필드는 useFieldArray로 관리했습니다.
1
2
3
4
const { fields, append, remove, move } = useFieldArray({
control,
name: "asset",
});
여기서 fields, append, remove, move는 배열 필드를 다룰 때 핵심이 되는 값과 함수입니다.
fields: 현재 배열 필드의 목록입니다. React Hook Form이 각 항목에 고유한id를 붙여주기 때문에 렌더링할 때key로 사용할 수 있습니다.append: 배열의 마지막에 새 항목을 추가합니다. 사용자가 파일을 업로드하거나 새 시나리오를 만들 때 사용합니다.remove: 특정 인덱스의 항목을 배열에서 제거합니다. 삭제 버튼을 눌렀을 때 사용합니다.move: 배열 안에서 항목의 위치를 바꿉니다. 드래그 앤 드롭으로 순서를 변경할 때 유용합니다.
에셋을 추가할 때는 파일을 선택하고, FileReader로 미리보기용 이미지를 만든 뒤 배열에 새 항목을 추가했습니다.
1
2
3
4
5
6
append({
assetFile: null,
assetName: file.name.split(".").slice(0, -1).join("."),
assetImage: reader.result as string,
assetSituation: "",
});
삭제할 때는 remove(index)처럼 제거할 항목의 인덱스를 넘기면 됩니다. 순서를 변경할 때는 move(from, to) 형태로 기존 위치와 이동할 위치를 넘깁니다.
1
2
3
4
5
const onDragEnd = (result: DropResult) => {
if (!result.destination) return;
move(result.source.index, result.destination.index);
};
직접 배열을 복사해서 setState 하는 방식보다 훨씬 안정적입니다. React Hook Form이 각 필드의 id와 상태를 관리해주기 때문에, 동적 리스트에서 흔히 생기는 렌더링 문제도 줄일 수 있습니다.
시나리오도 같은 방식으로 관리했습니다.
1
2
3
4
5
6
7
8
9
const { fields, append, remove } = useFieldArray({
control,
name: "scenarios",
});
append({
name: "다른 시나리오",
contents: [],
});
이처럼 배열 구조가 깊어져도 scenarios.0.name, scenarios.1.contents 같은 경로 기반 접근으로 폼 데이터를 다룰 수 있습니다.
🔗 setValue와 getValues로 커스텀 UI 연결하기
모든 입력이 단순 input으로 끝나지는 않습니다. 파일 업로드, 채팅 미리보기, 토글, 태그 선택처럼 커스텀 UI에서 폼 값을 직접 변경해야 하는 경우가 있습니다.
이럴 때는 setValue를 사용합니다.
프로필 이미지 필드에서는 파일 객체와 미리보기 이미지를 각각 폼 상태에 저장했습니다.
1
2
3
4
5
6
7
setValue("profileImgFile", file, {
shouldValidate: true,
});
setValue(name, reader.result, {
shouldValidate: true,
});
캐릭터 미리보기에서는 사용자가 입력한 메시지를 현재 시나리오의 contents 배열에 추가했습니다.
1
2
3
4
5
6
7
8
const currentContents =
getValues(`scenarios.${activeScenarioIndex}.contents`) || [];
setValue(
`scenarios.${activeScenarioIndex}.contents`,
[...currentContents, newContent],
{ shouldValidate: true },
);
여기서 getValues는 현재 폼 값을 읽는 용도로 사용하고, setValue는 특정 필드 경로의 값을 갱신하는 용도로 사용합니다.
이 방식 덕분에 복잡한 UI도 폼 상태와 분리되지 않습니다. 화면에서는 채팅을 추가하는 것처럼 보이지만, 내부적으로는 React Hook Form의 scenarios[n].contents 값이 업데이트됩니다.
🚨 서버 에러를 폼 에러로 통합하기
클라이언트에서 기본 검증을 통과해도 서버에서 에러가 발생할 수 있습니다. 예를 들어 이미 사용 중인 이메일이나 닉네임 같은 경우입니다.
이 프로젝트에서는 서버에서 내려온 필드별 에러를 React Hook Form의 setError로 연결하는 커스텀 훅을 만들었습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export const useFormServerError = <T extends FieldValues>() => {
const { setError, setFocus } = useFormContext<T>();
const setFieldErrors = (fields?: Partial<Record<string, string>>) => {
if (!fields) return false;
const entries = Object.entries(fields);
const lastErrorKey = entries[entries.length - 1][0];
setFocus(lastErrorKey as Path<T>);
entries.forEach(([key, message]) => {
setError(key as Path<T>, {
type: "server",
message,
});
});
return true;
};
return { setFieldErrors };
};
회원가입 요청 실패 시에는 서버 에러를 그대로 폼 에러로 반영했습니다.
1
2
3
4
5
authRegister(data, {
onError: (error) => {
setFieldErrors(error.fields);
},
});
이렇게 하면 클라이언트 에러와 서버 에러를 같은 방식으로 UI에 표시할 수 있습니다. 사용자는 어떤 검증 단계에서 발생한 에러인지 몰라도 해당 필드에서 자연스럽게 에러 메시지를 확인할 수 있습니다.
💾 isDirty로 임시 저장과 이탈 방지 처리하기
캐릭터 생성처럼 작성 시간이 긴 폼에서는 사용자가 실수로 페이지를 떠나는 상황을 막아야 합니다.
React Hook Form은 formState.isDirty를 통해 폼이 초기값에서 변경되었는지 알려줍니다.
1
2
3
4
5
const {
formState: { isDirty },
reset,
getValues,
} = methods;
임시 저장 후에는 현재 값을 기준으로 reset을 호출했습니다.
1
2
3
4
5
6
const handleSave = async () => {
const currentData = getValues();
reset(currentData);
alert("임시저장이 완료되었습니다.");
};
이렇게 하면 저장 이후에는 현재 상태가 새로운 기준점이 되기 때문에 isDirty가 다시 false가 됩니다.
페이지 이탈 방지 로직에서도 isDirty를 사용했습니다.
1
2
3
4
5
6
7
8
useNavigationGuard({
enabled: isDirty,
confirm: (info) => {
setPendingPath(info.to);
setActiveModal("UNSAVED");
return false;
},
});
폼 변경 여부를 직접 비교하지 않고도 React Hook Form의 상태를 활용해 UX를 구현할 수 있었습니다.
✅ 정리
이 프로젝트에서 React Hook Form은 폼 상태 관리를 단순화하는 도구 이상이었습니다. 여러 컴포넌트로 나뉜 폼을 하나의 흐름으로 묶고, 복잡한 사용자 입력을 안정적으로 다루는 기반이 되어주었습니다.
핵심적으로 사용한 방식은 다음과 같습니다.
FormProvider로 폼 메서드를 하위 컴포넌트에 공유useFormContext로 props drilling 없이 필드 컴포넌트에서 폼 접근register로 기본 input과 검증 규칙 연결useWatch로 필요한 값만 구독해 버튼 활성화, 중복 확인, 미리보기 구현useFieldArray로 에셋과 시나리오 같은 동적 배열 관리setValue,getValues로 파일 업로드나 채팅형 입력 같은 커스텀 UI 연결setError,setFocus로 서버 에러를 폼 에러 흐름에 통합isDirty,reset으로 임시 저장과 페이지 이탈 방지 처리
폼이 단순할 때는 React Hook Form의 장점이 크게 체감되지 않을 수 있습니다. 하지만 입력 필드가 많아지고, 컴포넌트가 분리되고, 배열 데이터와 커스텀 UI가 들어가기 시작하면 장점이 뚜렷해집니다.
결국 이 프로젝트에서 React Hook Form을 사용한 가장 큰 이유는 폼 상태를 한곳에서 관리하면서도, UI는 작고 독립적인 컴포넌트로 유지할 수 있었기 때문입니다.