✅ 들어가기 전에
1. React Hook Form 라이브러리란?
Performant, flexible and extensible forms with easy-to-use validation. (docs)
성능이 좋고, 유연하고 확장 가능하며 입력 값이 유효한지에 대한 검증을 쉽게 할 수 있는 폼을 만들 수 있도록 도와주는 라이브러리입니다.
2. Zod란?
Zod is a TypeScript-first schema declaration and validation library. (docs)
Zod는 스키마 선언 및 유효성 검사(검증) 라이브러리입니다. 가능한 개발자 친화적으로 설계되었으며 중복된 타입 선언을 제거하는 것이 목표라고 합니다. Zod의 사용법은 공식 문서에 자세히 나와있습니다.
스키마란? 데이터 구조의 청사진으로 데이터가 어떻게 구성되어 있는지, 어떤 데이터가 포함되어 있는지, 데이터의 유효성 검사 규칙은 무엇인지 등을 정의한 것을 말합니다.
3. schema validation
스키마 검증은 들어오는 데이터가 '스키마'라고 불리는 사전에 정의된 규칙 또는 데이터 타입 집합을 준수하는지 확인하는 프로세스입니다. 데이터의 질을 보장하고 에러를 방지하며 성능을 개선하는 데 사용됩니다. 특히 사용자 입력, API 응답 또는 외부 데이터 소스를 처리할 때 중요합니다.
스키마 검증 기준
데이터 타입 - string, number, boolean, array, object 등과 같은 예상 데이터 유형을 지정
형식 제약 조건 - 유효한 이메일 주소, 전화번호, 날짜 등과 같은 데이터 형식에 대한 규칙 정의
구조 - 중첩 객체, 배열 및 각 속성의 올바른 구조를 보장
검증 조건 - 데이터가 유효하거나 유효하지 않은 것으로 간주되는 조건을 지
4. Why Zod?
Typescript를 사용하고 있는데 왜 Zod를 사용하는 것일까? Typesciprt는 정적 타입 검사에 도움이 되지만 컴타일 타임에만 동작합니다. 빌드 프로세스 후 Typescript의 타입 안정성이 사라집니다. Zod를 사용하면 런타임 타입 검사 및 안정성을 제공하여 애플리케이션의 보안 및 안정을 높이는 데 도움이 됩니다. 그리고 Zod는 Zod schema에서 Typescript 타입을 자동으로 생성하여 Zod validation schema과 Typescript 정적 타입을 동기화할 수 있습니다.
🔥 시작하기
npm install react-hook-form zod @hookform/resolvers
const [email, setEmail] = useState<string>("");
const [username, setUsername] = useState<string>("");
const [password, setPassword] = useState<string>("");
const [gender, setGender] = useState<string>("");
const [error, setError] = useState<string>("");
const [usernameError, setUsernameError] = useState<string>("");
const [checkUsername, setCheckUsername] = useState<boolean>(false);
const [checkEmail, setCheckEmail] = useState<boolean>(false);
const [checkPassword, setCheckPassword] = useState<boolean>(false);
const [emailError, setEmailError] = useState<string>("");
const [usernameFocus, setUsernameFocus] = useState<boolean>(false);
const [emailFocus, setEmailFocus] = useState<boolean>(false);
const [passwordFocus, setPasswordFocus] = useState<boolean>(false);
const [passwordError, setPasswordError] = useState<string>("");
react hook form을 사용하지 않고 form 데이터의 검증을 구현하기 위해 선언된 state의 예입니다. 보기만 해도 복잡하고 폼을 관리하는 일이 피로하게 느껴질 것 같습니다. 그렇다면 react hook form과 zod를 이용해 validation을 구현해 봅시다.
🟢 시그마 정의하기
우선 validation에 사용할 시그마를 정의해 줍니다. name, email, password에 대한 검사 규칙을 정의합니다.
import { z } from "zod";
export const signUpSchema = z.object({
name: z
.string()
.min(2, { message: "최소 2자~10자 이하로 입력해 주세요." })
.max(10, { message: "최소 2자~10자 이하로 입력해 주세요." })
.regex(NAME_REGEX, {
message: "유효하지 않은 문자가 포함되어 있습니다.",
}),
email: z
.string()
.min(1, { message: "이메일을 작성해주세요." })
.email({ message: "유효한 이메일 주소가 아닙니다." }),
password: z
.string()
.min(6, { message: "최소 6자 이상으로 입력해 주세요." })
.regex(PASSWORD_REGEX, {
message: "영문과 숫자를 하나 이상 포함해 주세요.",
})
.toLowerCase(),
});
export type TSignUpSchema = z.infer<typeof signUpSchema>;
z.infer(Zod에서 제공하는 유티릴리티 타입)를 이용하여 스키마에서 유추된 실제 데이터 유형을 재사용 가능한 TypeScript 타입으로 정의했습니다.
🟢 useForm() - docs
react-hook-form에서 제공하는 useForm은 폼을 쉽게 관리할 수 있게 해주는 커스텀 훅입니다.
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
watch,
} = useForm<TSignUpSchema>({
resolver: zodResolver(signUpSchema),
mode: "all",
defaultValues: {
name: "",
email: "",
password: "",
},
});
resolver 속성에는 Zod 스키마를 해석하는 zodResolver 함수를 전달하여 데이터 유효성 검사를 설정해 줍니다.
각 변수의 역할
- register: 폼의 각 입력 요소를 등록하고 제어합니다.
- handleSubmit: 폼이 제출될 때 실행되는 함수를 정의합니다.
- formState: 폼의 상태 정보를 포함하는 객체로, 여기서는 errors와 isSubmitting을 사용했습니다.
- watch: 폼 입력 값의 변경을 감지하는 데 사용됩니다.
🟢 인풋 동기화
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-y-2">
<input
{...register("email", {
required: "이메일을 작성해주세요.",
})}
type="email"
placeholder="Email"
className="px-4 py-2 rounded"
/>
{errors.email && (
<p className="text-red-500">{`${errors.email.message}`}</p>
)}
</form>
register 함수를 이용하여 폼에 필드를 등록합니다. register는 필드의 이름인 name과 필드에 대한 옵션을 포함하는 객체인 options를 받습니다. errors 객체를 사용해 이메일 필드의 error 메세지에 접근합니다. 각 필드에 대한 errors 객체는 message 속성이 있습니다.
⚠️ 주의
위 코드처럼 register를 통해 input과 직접 동기화하는 것은 문제가 되지 않지만 커스텀 훅을 사용하면 정상적으로 작동하지 않을 수 있습니다. 이 부분에 대해선 register를 이해할 필요가 있습니다. register를 사용하면 React Hook Form은 내부적으로 ref를 생성하고 해당 ref를 인풋에 연결합니다. 이 ref는 useForm이 인풋 값을 추적하고 필요한 상태를 관리하는 데 사용됩니다.
<input {...register('example')} />
그렇다면 커스텀 인풋과 동기화하기 위해서 즉, ref로 연결하기 위해서 어떻게 해야 할까요? 컴포넌트 외부에서 ref를 받기 위해선 forwarRef를 사용해야 합니다. register를 통해 외부에서 전달된 ref를 내부에서 사용 가능하게 하는 것입니다.
export const UserInput = forwardRef<HTMLInputElement, Props>(function UserInput(
{
id = "",
label = "",
type = "text",
disabled,
actions,
error,
size,
placeholder = "",
...rest
},
ref
) {...}
자 이렇게 forwarRef를 감싸면 커스텀 인풋도 정상적으로 동작합니다.
🟢 서버로부터 '이름'의 중복 값 확인하기
onSubmit이 실행될 때 서버로부터 각 인풋의 중복 값을 확인하여 에러를 반환할 수 있습니다. 하지만 만약 유저가 '이름'의 중복을 확인하기 위해 계속 submit을 수행해야 한다면 UX 측면에서 좋지 않다고 생각했습니다. 그렇기에 사용자 입력이 이루어졌을 때 중복을 확인해야 할 필요가 있었습니다.
.refine(async (value) => !(await usernameExists(value)), {
message: '이미 존재하는 이름입니다.',
}),
refine은 비동기 함수를 받을 수 있습니다. 코드 한 줄로 간단하게 해결되는 줄 알았지만 문제가 발생했습니다. 모든 검증에 대한 요청이 발생하기 때문에 좋은 해결책이 아니라고 생각했습니다.
import { MutableRefObject, useEffect, useRef } from "react";
export interface Refinement<T> {
// Refinement 함수 시그니처
(data: T): boolean | Promise<boolean>;
// Refinement을 무효화(invalidate)하고, 다시 수행할 수 있도록 한다.
invalidate(): void;
}
export interface RefinementCallback<T> {
(data: T, ctx: { signal: AbortSignal }): boolean | Promise<boolean>;
}
interface RefinementContext<T> {
callback: RefinementCallback<T>;
debounce?: number;
}
RefinementCallback
- 제네릭 타입 T를 받습니다.
- 콜백 함수 시그니처: 이 인터페이스는 함수처럼 동작하며 boolean 또는 Promise<boolean> 반환
- data: T - 판단을 수행할 데이터를 나타냅니다.
- ctx: { signal: AbortSignal } - 판단 중에 사용될 컨텍스트를 나타냅니다. 여기서 signal은 AbortSignal 타입의 객체로, 비동기 작업을 중단시키는 데 사용될 수 있는 신호를 제공할 수 있습니다.
* AbortSignal: Web API로 주로 네트워크 요청이나 타이머와 같은 비동기 작업을 취소하고자 할 때 사용합니다.
export default function useRefinement<T>(
callback: RefinementCallback<T>,
{ debounce }: { debounce?: number } = {}
): Refinement<T> {
const ctxRef = useRef() as MutableRefObject<RefinementContext<T>>;
const refinementRef = useRef() as MutableRefObject<{
refine: Refinement<T>;
abort(): void;
}>;
// RefinementContext<T> 초기화
ctxRef.current = { callback, debounce };
if (refinementRef.current == null) {
refinementRef.current = createRefinement(ctxRef);
}
useEffect(() => () => refinementRef.current.abort(), []);
return refinementRef.current.refine;
}
useRefinement 훅
- ctxRef: RefinementContext<T> 타입을 갖는 가변적인(Mutable) Ref 객체입니다.
- refinementRef: refine와 abort라는 두 개의 속성을 갖는 가변적인 Ref 객체입니다.
- refinementRef.current가 null일 경우에만, createRefinement 함수를 사용하여 새로운 Refinement 객체를 생성하고 refinementRef.current에 할당합니다. 그렇지 않으면 이미 존재하는 Refinement 객체를 재사용합니다.
- 컴포넌트가 해제될 때 (cleanup 함수), refinementRef.current.abort()를 호출하여 판단 함수의 실행을 중단시킵니다.
* 가변적인 Ref 객체: .current 속성을 통해 값이 변경될 수 있는 객체
function createRefinement<T>(ctxRef: MutableRefObject<RefinementContext<T>>) {
let abortController: AbortController | null = null;
let result: Promise<boolean> | null = null;
let timeout: ReturnType<typeof setTimeout> | null = null;
// ...
}
abortController: AbortController 객체를 담을 변수로, 비동기 작업의 중단을 관리하는 데 사용됩니다.
result: 판단 함수의 결과를 담을 변수로, 중복 호출을 방지하기 위해 한 번만 실행되도록 합니다.
timeout: setTimeout 함수의 반환값을 담을 변수로, debounce 옵션이 설정된 경우 판단 함수의 실행을 지연시키기 위해 사용됩니다.
const start = async (data: T) => {
abortController = new AbortController();
if (ctxRef.current.debounce != null) {
await new Promise((resolve) => {
timeout = setTimeout(resolve, ctxRef.current.debounce);
});
}
const result = await ctxRef.current.callback(data, {
signal: abortController.signal,
});
abortController = null;
return Promise.resolve(result);
};
판단 함수를 실행하는 역할을 하 AbortController를 생성하여 비동기 작업의 중단을 관리합니다. debounce 옵션이 설정되어 있으면 지정된 시간만큼 대기한 후에 판단 함수를 실행합니다.
ctxRef.current.callback은 RefinementCallback<T>로 정의된 판단 함수를 나타냅니다. 주어진 데이터(data)와 AbortSignal을 사용하여 판단 함수를 실행합니다. 여기서 abortController.signal은 AbortController의 signal 속성으로, 해당 신호를 통해 판단 함수가 중단되었는지 확인할 수 있습니다.
const refine = async (data: T) => {
if (result != null) {
return result;
}
return (result = start(data));
};
const abort = () => {
if (timeout != null) {
clearTimeout(timeout);
}
timeout = null;
abortController?.abort();
abortController = null;
};
refine.invalidate = () => {
abort();
result = null;
};
refine 함수: 판단 함수를 실행하고, 이미 결과가 있다면 해당 결과를 반환합니다. 결과가 없는 경우, start 함수를 호출하여 판단 함수를 실행하고 결과를 저장한 후 반환합니다.
abort 함수: 실행 중인 판단 함수를 중단하고 초기화하는 역할을 합니다. timeout이 설정되어 있다면, clearTimeout을 사용하여 debounce 타이머를 취소합니다. abortController를 사용하여 비동기 작업을 중단하고, 변수들을 초기화합니다.
마지막은 refine 함수에 invalidate라는 메서드를 추가합니다. 이 메서드를 호출하면 실행 중인 판단 함수를 중단하고, 결과를 초기화합니다.
function createRefinement<T>(ctxRef: MutableRefObject<RefinementContext<T>>) {
...
return { refine, abort };
}
createRefinement 함수는 최종적으로 refine 함수와 abort 함수를 반환합니다. 다시 돌아가서 refinementRef는 createRefinement의 실행 결과인 refine과 abort가 current에 담기고 useRefinement 훅은 refinementRef.current의 refine 함수를 반환합니다.
사용하는 쪽
const uniqueName = useRefinement(checkNameToBeUnique(), {
debounce: 500,
});
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
watch,
} = useForm<TSignUpSchema>({
resolver: zodResolver(
signUpSchema.refine(uniqueName, {
message: "이미 존재하는 이름입니다.",
path: ["name"],
})
),
위에서 설명했다시피 useRefinement는 callback과 debounce값을 받습니다. signUpSchema에 refine을 추가하여, uniqueName 즉, 판단함수(refine)를 전달해 줍니다. 그리고 onChange가 발생할 때마다 판단 함수를 중단하고 초기화하는 invalidate를 호출해 주면 됩니다.
export function checkNameToBeUnique(): RefinementCallback<TSignUpSchema> {
return async (data, { signal }) => {
let timeoutRef: ReturnType<typeof setTimeout>;
signal?.addEventListener("abort", () => {
clearTimeout(timeoutRef);
});
try {
// fetch
} catch (e) {
return false;
}
};
}
signal이 AbortSignal 객체인 경우, 해당 신호의 "abort" 이벤트에 대한 핸들러를 등록합니다. abort 이벤트가 발생하면 설정된 타임아웃(timeoutRef)을 취소하고 중단 처리가 이루어집니다.
자 그럼 실행해 봅시다. 서버에는 테스트1, 테스트3 이라는 이름이 이미 추가되어 있습니다.
완료!!
react hook form과 zod를 이용하여 즉각적인 서버 측 검증까지 해보았습니다. 처음 사용해 보는 기술이라 어려움이 많았고 3일간의 긴 시간을 투자했습니다. 값어치 있는 여정이었고 어려움을 겪고 계신 분들에게 도움이 되었을 바랍니다.
'개발자의 공부 > React' 카테고리의 다른 글
Zustand with TypeScript (0) | 2024.04.04 |
---|---|
[React] 유연하고 재사용 가능한 버튼 컴포넌트 만들기 with tailwindcss (0) | 2024.01.06 |
이미지 최적화에 대한 명확한 가이드 (0) | 2023.02.01 |
프론트엔드 최적화 시도 - 1 (0) | 2023.01.30 |
[React]useRef (0) | 2023.01.28 |