
✅Keupang Github
✅Keupang 배포 주소로 이동

회원가입 페이지를 제작했다.
개발한 기능은 기본적인 회원가입 Form을 제외하면 아래와 같다.
- 이메일 유효성을 검사하여 이메일이 확인될 시 "이메일 확인"버튼을 활성화 한다.
- MSW를 이용하여 이메일 검사 플로우를 재현한다.
- 회원가입 실패 및 인증번호 잘못입력하는 등 에러가 발생하면 React-Toastify로 사용자에게 전달한다.
- 회원가입 성공시 HOME으로 리다이렉트 한다.
- 이메일은 "직접 입력"을 선택시 사용자가 직접 입력할 수 있다.
- 비밀번호 옆의 눈 아이콘을 클릭하면 어떤 비밀번호를 작성했는지 확인할 수 있다.
- 각 필드는 유효성 검사를 모두 진행한다.
- 인증번호는 3분간 입력할 수 있고, 시간이 지날시 인증번호를 다시 발급하여야한다.
회원가입 필드를 관리하는데 react-hook-form 패키지를 사용했다.
이러한 Form을 구성하는데 사용할 수 있는 방법은 두 가지 정도라 생각한다.
제일 직관적으로 만들 수 있다.
개발자가 직접 input과 button, form을 만들어서 데이터를 관리하는 방법이다.
필요한 데이터가 적으면 효율적일 수 있다고 생각한다.
개발자가 직접 커스터마이징 하여 완전한 제어가 가능하고, 의존성을 추가할 필요가 없다.
하지만, 이 두 장점은 너무 사소하다고 생각한다.
단점은 아래와 같다.
실제로 나는 이메일 확인 버튼을 눌러야 인증 번호를 입력하는 칸이 보이게 하는 등 동적으로 제어하고 싶었기에 해당 방식은 사용하지 않고, react-hook-form방식을 택했다.
React Hook Form은 비제어 컴포넌트를 사용하여 폼 상태를 관리하고, 성능을 최적화하는 라이브러리이다.
간단한 API와 유효성 검사 기능을 제공하며, 불필요한 리렌더링을 최소화하여 성능을 향상시킨다.
watch명령어 등으로 필드 입력 값을 추적하기도 용이하다.
yarn add react-hook-form
위의 명령어로 설치해주었다.
import React from 'react';
import { useForm } from 'react-hook-form';
const SimpleForm = () => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm();
const onSubmit = (data) => {
console.log('폼 데이터:', data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label>이메일:</label>
<input
type="email"
{...register('email', { required: '이메일을 입력해주세요.' })}
/>
{errors.email && <p>{errors.email.message}</p>}
</div>
<div>
<label>비밀번호:</label>
<input
type="password"
{...register('password', {
required: '비밀번호를 입력해주세요.',
minLength: {
value: 8,
message: '비밀번호는 최소 8자 이상이어야 합니다.',
},
})}
/>
{errors.password && <p>{errors.password.message}</p>}
</div>
<button type="submit">제출</button>
</form>
);
};
export default SimpleForm;
위의 코드를 보며 간단히 설명할 수 있다.
useForm을 import하여 사용할 메서드를 가져온다.
register: 입력 필드와 상태를 연결. name 속성과 유효성 검사를 정의.
handleSubmit: 폼 제출 시 호출되는 함수. 유효성 검사가 통과하면 데이터를 콜백 함수로 전달.
formState.errors: 각 필드의 유효성 검사 결과를 저장.
각 메서드는 위와 같은 역할을 한다.
즉, input과 register를 연결하여 유효성 검사를 할 수 있고, 필요시 watch를 활용하여 입력 값 상태를 추적할 수 있도록 한다.
required를 통해 유효성 검사 규칙을 간단히 설정할 수 있고, 이에 대한 에러 메세지를 작성할 수 있다.
이를 받아서 바로 사용자에게 피드백을 하는 환경을 제공할 수 있다.
handleSubmit에 submit 로직을 연결하여 폼을 제출할 수 있다.
내 프로젝트에 적용하기 위해 아래와 같은 단계를 거쳤다.
- 회원가입 버튼에 /signup 경로로 이동
- 회원가입 mock API 작성
- Toast적용을 위한 ToastContainer 적용
- 회원가입 Form 컴포넌트 작성 (많은 일이 있었다.)
import { useNavigate } from 'react-router-dom';
export const useNavigation = () => {
const navigation = useNavigate();
const goToSignup = () => navigation('/signup');
const goToHome = () => navigation('/');
return { goToSignup };
return { goToSignup, goToHome };
};
회원가입 페이지로 이동하기 위한 네비게이션 관련 Hook을 작성했다.
회원가입 후 홈으로 이동하기 위한 코드도 있다.
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
interface BodyType {
email: string;
password: string;
name: string;
phone: string;
code: string;
}
let verificationCode: string | null = null;
export const handler = [
http.get('/api/products', () => {
return HttpResponse.json([
{ id: 1, name: 'Product A', price: 100 },
{ id: 2, name: 'Product B', price: 200 },
]);
}),
http.post('/api/users/register', async ({ request }) => {
const body = (await request.json()) as BodyType;
if (!body.email || !body.password || !body.name || !body.phone) {
return HttpResponse.json(
{ message: '필수 필드가 누락되었습니다.' },
{ status: 400 }
);
}
return HttpResponse.json(
{
message: '회원가입 성공!',
data: {
id: Math.floor(Math.random() * 1000),
email: body.email,
name: body.name,
},
},
{ status: 201 }
);
}),
http.post('/api/auth/send-email', async ({ request }) => {
const { email } = (await request.json()) as BodyType;
verificationCode = Math.floor(100000 + Math.random() * 900000).toString(); // 6자리 랜덤 숫자
console.log(`발송한 이메일 주소 ${email}`);
console.log(`생성된 인증코드 ${verificationCode}`);
return HttpResponse.json(
{ message: '인증 이메일이 전송되었습니다.' },
{ status: 200 }
);
}),
http.post('/api/auth/verify-code', async ({ request }) => {
const { code } = (await request.json()) as BodyType;
if (!code) {
return HttpResponse.json(
{ message: '인증 코드를 입력해주세요.' },
{ status: 400 }
);
}
if (code !== verificationCode) {
return HttpResponse.json(
{ message: '인증 코드가 유효하지 않습니다.' },
{ status: 401 }
);
}
return HttpResponse.json(
{ message: '이메일 인증에 성공했습니다!' },
{ status: 200 }
);
}),
];
간단하게 post메서드로 이메일 인증과 회원가입 관련 Mock API를 작성했다.
import { ToastContainer } from 'react-toastify';
<ToastContainer
position='top-right'
autoClose={3000}
pauseOnHover
draggable
closeOnClick
hideProgressBar={true}
newestOnTop={true}
/>
위 코드를 App.tsx에 적용했다.
라우터 부분 아래에 작성했다.
import { useState } from 'react';
export const useOverlay = (initialState: boolean = false) => {
const [isOpen, setIsOpen] = useState(initialState);
const open = () => setIsOpen(true);
const close = () => setIsOpen(false);
const toggle = () => setIsOpen((prev) => !prev);
return { isOpen, open, close, toggle };
};
회원가입 Form에서 열리고 닫히고, 토글 등의 기능이 있는 부분에 사용하기 위해 작성했다.
import { useState, useEffect } from 'react';
export const useTimer = (initialTime: number) => {
const [timeLeft, setTimeLeft] = useState(initialTime);
const [isTimerExpired, setIsTimerExpired] = useState(false);
useEffect(() => {
if (timeLeft <= 0) {
setIsTimerExpired(true);
return;
}
const timer = setInterval(() => {
setTimeLeft((prev) => prev - 1);
}, 1000);
return () => clearInterval(timer);
}, [timeLeft]);
return { timeLeft, isTimerExpired, setTimeLeft, setIsTimerExpired };
};
이메일 인증 시 3분 타이머를 만드는 Custom-Hook이다.
props로 초기화 시간을 받고, {남은시간, 시간 지남여부, 시간 제어 함수(이메일 인증을 다시 하면 setTimeLeft(180)을 할 필요가 있다), 시간 지남여부를 바꿀수 있는 함수(이것도 이메일 인증 다시 할 시 false로 바뀌어야 한다)}를 돌려준다.
이는 추 후 타이머가 필요한 코드에서 재사용 될 것이다.
useEmailValidation, useEmailVerification, useInputOffset이 작성되었다.
각 각 이메일 유효성 검사, 이메일 인증 검사, "@" 위치 여부를 계산하는 Hook들이다.
이는 내 깃허브에서 확인할 수 있다.
이 Hook들을 사용하여 컴포넌트를 작성했다.
import React, { useRef } from 'react';
import { useForm } from 'react-hook-form';
import 'react-toastify/dist/ReactToastify.css';
import {
validatePassword,
validatePhone,
validateName,
} from '../utils/validation';
import { FaRegEye, FaRegEyeSlash } from 'react-icons/fa';
import { EMAIL_DOMAIN } from '../constants/emailDomain';
import { Button } from '../components/Button';
import { useNavigation } from '../hooks/useNavigation';
import { useTimer } from '../hooks/useTimer';
import { useEmailValidation } from '../hooks/useEmailValidation';
import { useCustomDomain } from '../hooks/useCustomDomain';
import { useEmailVerification } from '../hooks/useEmailVerification';
import { useInputOffset } from '../hooks/useInputOffset';
import { useOverlay } from '../hooks/useOverlay';
import {
FormContainer,
Title,
InputWrapper,
Input,
ErrorText,
Notice,
ToggleIcon,
EmailInput,
Select,
AtSymbol,
Timer,
} from './styles/SignupForm.styled';
import { handleSignupSubmit } from '../utils/handleSignupSubmit';
export interface SignupFormData {
emailLocal: string;
emailDomain: string;
customEmailDomain?: string;
emailVerification: string;
password: string;
confirmPassword: string;
name: string;
phone: string;
}
const SignupForm: React.FC = () => {
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<SignupFormData>();
const emailLocal = watch('emailLocal');
const emailDomain = watch('emailDomain');
const customEmailDomain = watch('customEmailDomain');
const code = watch('emailVerification');
const emailInputRef = useRef<null | HTMLInputElement>(
null
) as React.MutableRefObject<HTMLInputElement | null>;
const passwordVisibility = useOverlay();
const confirmPasswordVisibility = useOverlay();
const { timeLeft, isTimerExpired, setTimeLeft, setIsTimerExpired } =
useTimer(180);
const { isCustomDomain, handleDomainChange } = useCustomDomain(setValue);
const {
showVerificationInput,
isConfirmEmail,
handleSendEmail,
handleVerifyCode,
} = useEmailVerification(setIsTimerExpired, setTimeLeft);
const { isEmailValid, email } = useEmailValidation(
emailLocal || '',
emailDomain || '',
customEmailDomain || '',
isCustomDomain
);
const { goToHome } = useNavigation();
const leftOffset = useInputOffset(emailInputRef, isCustomDomain);
const minutes = Math.floor(timeLeft / 60);
const seconds = timeLeft % 60;
const onSubmit = handleSignupSubmit(isConfirmEmail, isCustomDomain, goToHome);
return (
<FormContainer onSubmit={handleSubmit(onSubmit)}>
<Title>회원가입을 위한 정보를 입력하세요</Title>
<Notice>아래 양식에 필요한 정보를 입력해 주세요.</Notice>
<InputWrapper style={{ margin: 0 }}>
<EmailInput
type='text'
placeholder='이메일 아이디'
{...register('emailLocal', {
required: '이메일 아이디를 입력해주세요.',
})}
ref={(el) => {
register('emailLocal').ref(el);
emailInputRef.current = el;
}}
/>
<AtSymbol leftOffset={leftOffset}>@</AtSymbol>
{isCustomDomain ? (
<EmailInput
type='text'
placeholder='직접 입력'
{...register('customEmailDomain', {
required: '도메인을 입력해주세요.',
})}
style={{ flex: 1 }}
/>
) : (
<Select
{...register('emailDomain', { required: '도메인을 선택해주세요.' })}
onChange={handleDomainChange}>
<option value=''>선택</option>
{EMAIL_DOMAIN.map((domain) => (
<option key={domain} value={domain}>
{domain}
</option>
))}
<option value='custom'>직접 입력하기</option>
</Select>
)}
</InputWrapper>
<Button
variant='primary'
size='small'
withBorder={false}
disabled={!isEmailValid}
onClick={(e) => {
e.preventDefault();
handleSendEmail(email);
}}
style={{ margin: '10px' }}
type='button'>
이메일 확인
</Button>
{errors.emailLocal && <ErrorText>{errors.emailLocal.message}</ErrorText>}
{showVerificationInput && (
<>
<InputWrapper>
<Input
type='text'
placeholder='인증번호 입력'
{...register('emailVerification', {
required: '인증번호를 입력해주세요.',
validate: () =>
!isTimerExpired ||
isConfirmEmail ||
'유효시간이 지났습니다. 다시 요청해주세요.',
})}
style={{ width: '70%' }}
disabled={isConfirmEmail}
/>
<Button
variant='primary'
size='small'
withBorder={false}
disabled={isTimerExpired}
onClick={(e) => {
e.preventDefault();
if (isTimerExpired) {
alert('유효시간이 지났습니다. 인증번호를 다시 요청해주세요.');
} else {
handleVerifyCode(code);
}
}}>
인증번호 확인
</Button>
<Timer
isTimerExpired={isTimerExpired}
isConfirmEmail={isConfirmEmail}>
{isConfirmEmail
? '인증이 완료되었습니다.'
: isTimerExpired
? '유효시간이 지났습니다. 인증번호를 다시 요청해주세요.'
: `${minutes}:${seconds < 10 ? `0${seconds}` : seconds}`}
</Timer>
</InputWrapper>
{errors.emailVerification && (
<ErrorText>{errors.emailVerification.message}</ErrorText>
)}
</>
)}
<InputWrapper>
<Input
type={passwordVisibility.isOpen ? 'text' : 'password'}
placeholder='비밀번호'
{...register('password', {
required: '비밀번호를 입력해주세요.',
validate: (value) =>
validatePassword(value) ||
'비밀번호는 8~20자이며, 대문자, 소문자, 숫자, 특수문자를 포함해야 합니다.',
})}
/>
<ToggleIcon type='button' onClick={passwordVisibility.toggle}>
{passwordVisibility.isOpen ? (
<FaRegEye size={24} />
) : (
<FaRegEyeSlash size={24} />
)}{' '}
</ToggleIcon>
</InputWrapper>
{errors.password && <ErrorText>{errors.password.message}</ErrorText>}
<InputWrapper>
<Input
type={confirmPasswordVisibility.isOpen ? 'text' : 'password'}
placeholder='비밀번호 확인'
{...register('confirmPassword', {
required: '비밀번호를 다시 입력해주세요.',
})}
/>
<ToggleIcon type='button' onClick={confirmPasswordVisibility.toggle}>
{confirmPasswordVisibility.isOpen ? (
<FaRegEye size={24} />
) : (
<FaRegEyeSlash size={24} />
)}
</ToggleIcon>
</InputWrapper>
{errors.confirmPassword && (
<ErrorText>{errors.confirmPassword.message}</ErrorText>
)}
<InputWrapper>
<Input
type='text'
placeholder='이름'
{...register('name', {
required: '이름을 입력해주세요.',
validate: (value) =>
validateName(value) ||
'이름은 2~50자의 한글 또는 영문이어야 합니다.',
})}
/>
</InputWrapper>
{errors.name && <ErrorText>{errors.name.message}</ErrorText>}
<InputWrapper>
<Input
type='tel'
placeholder='전화번호 -빼고 입력해주시기 바랍니다.'
{...register('phone', {
required: '전화번호를 입력해주세요.',
validate: (value) =>
validatePhone(value) ||
'전화번호는 숫자만 10~11자리로 입력해야 합니다.',
})}
/>
</InputWrapper>
{errors.phone && <ErrorText>{errors.phone.message}</ErrorText>}
<Button variant='primary' size='large' withBorder={false} type='submit'>
가입하기
</Button>
<Notice>가입하기를 클릭하면 이용약관에 동의하는 것입니다.</Notice>
</FormContainer>
);
};
export default SignupForm;
작성한 Hook들을 사용하여 만들 수 있었다.
이제 테스트를 일일이 하나하나 올리는 건 스크롤 압박이 너무 심해질거같아 올리지 않을 예정이다.
테스트 결과만 이미지로 첨부하려고 한다.
yarn test --ui
ui를 볼 수 있는 패키지를 설치했다.

테스트가 모두 통과하여 merge할 수 있었다.