프로젝트에서 로그인 회원가입 관련 로직을 전담하게 되었는데, 초기 구현을 끝내고 그냥 넘어가기에는 개선해볼 수 있는 사항이 너무 많아보여서 중간에 다른 구현은 제껴두고 리팩토링 시간을 가지게 되었다.
// EmailInput.tsx
const EmailInput = ({ mode }: EmailInputProps) => {
const [validationMessage, setValidationMessage] = useState('');
const [validationMessageColor, setValidationMessageColor] = useState('');
const {
register,
resetField,
watch,
formState: { errors },
} = useFormContext();
const email = watch('email');
const { isValid, message, isLoading } = useGetValidateEmail(email, mode);
const isEmailInvalid = () => !EMAIL_REGEX.test(email);
const clearEmailField = () => resetField('email');
const validateEmail = () => {
if (isLoading) {
setValidationMessage('이메일 검증 중...');
setValidationMessageColor('text-gray-400');
} else if (!isValid) {
setValidationMessage(message || '이미 가입된 이메일입니다.');
setValidationMessageColor('text-system-error');
} else {
setValidationMessage('가입되어 있지 않은 이메일입니다.');
setValidationMessageColor('text-system-error');
}
};
useEffect(() => {
if (mode !== 'sign-up') return;
if (email === '') {
setValidationMessage('');
setValidationMessageColor('');
} else if (isEmailInvalid()) {
setValidationMessage('올바른 이메일 형식으로 입력해주세요.');
setValidationMessageColor('text-system-error');
} else if (email) {
validateEmail();
} else {
setValidationMessage('');
}
}, [email]);
useEffect(() => {
if (errors.email) {
setValidationMessage(errors.email?.message as string);
setValidationMessageColor('text-system-error');
}
}, [errors]);
return (
<div>
<Input
{...register('email', { required: true, pattern: EMAIL_REGEX })}
type='email'
label='이메일'
labelPlacement='outside'
placeholder='이메일을 입력해주세요.'
isInvalid={false}
classNames={{
label: 'custom-label text-gray-400',
input: 'placeholder:text-gray-700',
inputWrapper: ['bg-gray-900', 'rounded-md'],
}}
onClear={clearEmailField}
/>
...
</div>
);
};
export default EmailInput;
처음 작성했던 이메일 Input 컴포넌트인데 보기에도 상당히 복잡해보인다.
폼 자체에 대해서는 폼을 관리하는 페이지에서 React Hook Form
을 통해서 폼 데이터를 효율적으로 관리하고 있지만, 각 필드에서 유효성 검사, 에러 메세지 같이 생각보다 많은 상태들을 관리하고 있다.
그리고 이메일 입력값에 대한 유효성 검증 로직이 복잡하게 얽혀있는데, 필드 내부에 입력 컴포넌트가 사용되는 폼을 직접 관리하기 때문에 상당히 복잡한 것을 볼 수 있다.
그래서 이번 리팩토링의 목적을
로 잡고 리팩토링을 수행했다.
우선 기존 컴포넌트 내부에서는 에러 메세지와 검증 결과를 표시할 문자의 색상 값을 상태로 관리하고 있었다. 이 상태들은 상태 갱신이 되는 시점이 동일하기 때문에 굳이 별개의 useState로 정의할 필요가 없다고 생각해서 객체 상태로 정의하여 한번에 관리하였다.
그리고 기존에 두 개의 유효성 검증 조건 문은 로그인/ 회원가입에 활용될 수 있게 조건의 순서를 조절하여 하나의 조건문으로 검증 가능하도록 수정하였다.
const NicknameInput = () => {
const [validateStatus, setValidateStatus] = useState({
type: '',
message: '',
messageColor: '',
});
const { register, watch } = useFormContext();
const nickname = watch('nickname');
const { isValid, message, isLoading } = useGetValidateNickName(nickname);
const isNicknameInvalid =
(nickname !== '' && !NICKNAME_REGEX.test(nickname)) ||
nickname.length > MAX_NICKNAME_LENGTH;
const validateNickname = () => {
if (nickname === '') {
return { type: '', message: '', messageColor: '' };
}
if (isNicknameInvalid) {
return {
type: 'error',
message: '사용할 수 없는 문자 또는 길이를 초과했습니다.',
messageColor: 'text-system-error',
};
}
if (isLoading) {
return {
type: 'loading',
message: '닉네임 검증 중...',
messageColor: 'text-gray-400',
};
}
if (!isValid) {
return {
type: 'error',
message: message || '중복된 닉네임이 존재합니다.',
messageColor: 'text-system-error',
};
}
return {
type: 'success',
message: '사용 가능한 닉네임입니다.',
messageColor: 'text-system-success',
};
};
useEffect(() => {
const nicknameValidateResult = validateNickname();
setValidateStatus(nicknameValidateResult);
}, [nickname, isLoading, isValid, message]);
const currentNicknameLength = nickname.length;
const nicknameLengthColor =
nickname.length === 0
? 'text-gray-700'
: currentNicknameLength > MAX_NICKNAME_LENGTH
? 'text-system-error'
: 'text-white';
return (
<div>
...
</div>
);
};
export default NicknameInput;
하지만 여전히 효과적으로 리팩토링을 했다는 생각이 들지 않았다.
우선 먼저 react hook form을 사용하면서 제출 버튼이 폼의 유효성 검증이 통과하는 경우에 활성화되도록 되어있는데, 이때 각 필드의 유효성 검증은 Input 태그 내에서 포함된 register의 속성에 정의되어있어야 한다. 그래서 어색하게 동일한 유효성 검증이 에러 메세지를 위해서, 폼 제출 여부 판단을 위해서 두 번 쓰이고 있다.
이를 개선 하기 위해 아래와 같은 두번의 시행 착오를 거쳤다.
모든 유효성 검증을 register 속성 내부로 이전
<Input
{...register('nickname', {
required: '닉네임이 입력되지 않았습니다.',
max: {
value: 10,
message: '최대 닉네임 길이를 초과했습니다.',
},
pattern: {
value: NICKNAME_REGEX,
message: '닉네임에 허용되지 않는 문자가 포함되어 있습니다.',
},
validate: {
duplicatedNickname: async (nickname) => {
const isDuplicated = await getValidateNickname(nickname);
return isDuplicated && '이미 사용 중인 닉네임입니다.';
},
},
})}
type='text'
label='닉네임'
labelPlacement='outside'
placeholder='닉네임을 입력해주세요.'
maxLength={MAX_NICKNAME_LENGTH}
classNames={{
label: 'custom-label',
input: 'placeholder:text-gray-700',
inputWrapper: ['bg-gray-900', 'rounded-md'],
}}
endContent={
<div className='flex items-center'>
<span className={`placeholder ${nicknameLengthColor}`}>
{currentNicknameLength}
</span>
<span className='placeholder text-gray-700'>
/{MAX_NICKNAME_LENGTH}
</span>
</div>
}
/>
→ 하지만 이렇게 작성하는 경우 로그인 폼 / 회원 가입 폼에 필요한 검증 방식이 다른데 폼 종류에 관계없이 모두 같은 로직을 사용해야하기 때문에 해당 방식을 활용할 수 없었다.
모든 유효성 검증을 외부 로직으로 분리
반대로 외부로직으로 모든 유효성 검증 로직을 분리하고, 대신 폼과 유효성 결과를 동기화 시키기 위해 React Hook Form에서 제공하는 setError, formState를 활용하여 기존 setState를 대신하는 방식을 사용해보았다.
→ 수정이 필요했던 부분들을 모두 반영할 수 있었지만, React Hook Form에서 다시 유효성 검증 로직을 실행하기 이전에 Error 객체를 초기화하기 때문에 입력 필드의 onChange
이벤트가 발생할 때 마다 메세지가 사라졌다가, 다시 새로운 메세지가 나와 메세지가 깜빡깜빡 거리는 현상이 생겼다.
그래서 현재 단계에서 보완이 필요한 점은
이 두가지를 목표로 다른 방식을 찾아보면서 zod라는 라이브러리를 React Hook Form에서 활용할 수 있다는 것을 알게 되었다.
Zod는 ‘스키마’라는 개념을 통해서 객체 정의가 가능하고, 동시에 객체에 대한 유효성 검사도 가능하게 하는 라이브러리이다.
import { z } from "zod";
// 스키마 선언
const userSchema = z.object({
name: z.string(),
age: z.number(),
email: z.string().email(),
});
// 타입 추출
type UserType = z.infer<typeof userSchema>;
React Hook Form에서는 useForm을 선언할 때, resolver라는 옵션을 사용할 수 있는데, 이 옵션을 사용하면 각 필드에서 작성했던 유효성 검사를 중앙에서 관리할 수 있게 해준다. resolver의 옵션으로 zodResolver라는 함수와 함께 zod로 선언한 객체의 스키마를 넘겨주면 객체 정의에 따라 유효성 검사가 가능해진다.
회원 가입 폼에 대한 스키마를 다음과 같이 정의하여 활용하였다.
const signUpValidationSchema = object({
email: string()
.min(1, '이메일은 필수입니다.')
.email('유효하지 않은 이메일 형식입니다.')
.refine(async (email) => {
const isDuplicated = await getValidateEmail(email);
return isDuplicated;
}, '이미 가입된 이메일입니다.'),
password: string()
.min(1, '비밀번호는 필수입니다.')
.regex(
PASSWORD_REGEX,
'영문, 숫자, 특수문자를 포함해 9자 이상 입력해주세요.',
),
nickname: string()
.min(1, '닉네임은 필수입니다.')
.max(MAX_NICKNAME_LENGTH, '최대 닉네임 길이를 초과했습니다.')
.regex(NICKNAME_REGEX, '한글, 영어, 숫자로 구성된 닉네임을 입력해주세요.')
.refine(async (nickname) => {
const isDuplicated = await getValidateNickname(nickname);
return isDuplicated;
}, '이미 사용중인 닉네임입니다.'),
});
그리고 스키마로 인해서 자연스럽게 각 필드에 있던 유효성 로직이 필요없어졌기 때문에 관련 코드를 이전보다 깔끔하게 정리할 수 있었다.
const EmailInput = ({ mode }: EmailInputProps) => {
const [isEmailValidating, setIsEmailValidating] = useState(false);
const {
register,
resetField,
watch,
setValue,
trigger,
formState: { errors },
} = useFormContext();
const email = watch('email');
const clearEmailField = () => resetField('email');
return (
<div>
<Input
{...register('email')}
type='email'
label='이메일'
labelPlacement='outside'
placeholder='이메일을 입력해주세요.'
isInvalid={false}
classNames={{
label: 'custom-label',
input: 'placeholder:text-gray-700',
inputWrapper: ['bg-gray-900', 'rounded-md'],
}}
onClear={clearEmailField}
/>
...
</div>
);
};
export default EmailInput;
.superRefine()으로 유효성 검사 커스텀 하기
비동기 검증 로직을 활용하면서 true/false를 반환하여 현재 이메일을 사용할 수 있는지, 사용할 수 없는지만 검증하였지만, 내부적으로 정확히 어떤 이유에서 이메일을 사용할 수 없는지 에러 메세지를 통해 전달할 필요가 있었다.
기존의 .refine()은 내부 콜백함수가 true/false를 반환해서 검증 여부만 판단하기 때문에 각 에러 케이스에 따른 에러 메세지를 설정하기 어렵다고 판단했다.
그래서 .refine의 고급 커스텀이 가능한 .superRefine()을 활용하여 아래와 같이 경우에 따른 적절한 에러 메세지를 전달할 수 있도록 개선했다.
const signUpValidationSchema = object({
email: string()
...
.superRefine(async (email, ctx) => {
const status = await getValidateEmail(email);
if (status === 400) {
ctx.addIssue({
code: ZodIssueCode.custom,
message: SIGNIN_ERROR_MESSAGE.INVALID_EMAIL,
});
}
if (status === 409) {
ctx.addIssue({
code: ZodIssueCode.custom,
message: SIGNIN_ERROR_MESSAGE.DUPLICATE_EMAIL,
});
}
}),
...
nickname: string()
...
.superRefine(async (nickname, ctx) => {
const status = await getValidateNickname(nickname);
if (status === 400) {
ctx.addIssue({
code: ZodIssueCode.custom,
message: NICKNAME_VALIDATE_ERROR_MESSAGE.INVALID_NICKNAME,
});
}
if (status === 409) {
ctx.addIssue({
code: ZodIssueCode.custom,
message: NICKNAME_VALIDATE_ERROR_MESSAGE.DUPLICATE_NICKNAME,
});
}
}),
});
debounce 적용하기
이전에 onChange마다 유효성 검사가 실행되기 때문에 에러 메세지가 깜빡거리는 현상이 있었다.
이 부분이 상당히 사용자 입장에서 좋아보이는 UI가 아니기도 했고, 유효성 검증 로직 중에 현재 입력 값이 이미 가입된 이메일인지
확인하는 요청을 보내야하는데 이것이 입력될 때 마다 요청을 주고 받는 부분에 대해서 최적화의 필요성을 느꼈다.
lodash의 debounce를 통해서 onChange 이벤트가 끝난 시점에서 특정 시간동안 입력이 없으면 검증 로직이 실행될 수 있도록 최적화 하였다.
import { debounce } from 'lodash';
...
const handleEmailInputOnChange = debounce(async (e) => {
setValue('email', e.target.value);
setIsEmailValidating(true);
await trigger('email');
setIsEmailValidating(false);
}, 300);
누군가 zod 꼭 쓰세요
라고 했던 걸 본적이 있었는데 이번 기회에 zod를 사용해보게 되어서 상당히 의미있는 시간이었던 것 같다. 그리고 React Hook Form을 사용해서 필요한 상태를 줄일 수 있다는 점에 대해서는 상당히 공감하며 사용하고 있었지만 그 외 제공해주는 다양한 기능들에 대해서는 잘 몰랐었다.
이번 기회에 공식 문서도 조금 더 파볼 수 있었고, form 데이터 관리를 좀 더 체계적으로 해볼 수 있는 경험이었던 것 같다.