[TypeScript/Keupang] #8 회원가입 Form Component 제작

HoneyCode Lab·2024년 12월 23일

프로젝트

목록 보기
8/8
post-thumbnail

개요


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

회원가입 페이지를 제작했다.

개발한 기능은 기본적인 회원가입 Form을 제외하면 아래와 같다.

  1. 이메일 유효성을 검사하여 이메일이 확인될 시 "이메일 확인"버튼을 활성화 한다.
  2. MSW를 이용하여 이메일 검사 플로우를 재현한다.
  3. 회원가입 실패 및 인증번호 잘못입력하는 등 에러가 발생하면 React-Toastify로 사용자에게 전달한다.
  4. 회원가입 성공시 HOME으로 리다이렉트 한다.
  5. 이메일은 "직접 입력"을 선택시 사용자가 직접 입력할 수 있다.
  6. 비밀번호 옆의 눈 아이콘을 클릭하면 어떤 비밀번호를 작성했는지 확인할 수 있다.
  7. 각 필드는 유효성 검사를 모두 진행한다.
  8. 인증번호는 3분간 입력할 수 있고, 시간이 지날시 인증번호를 다시 발급하여야한다.

 


react-hook-form 사용


회원가입 필드를 관리하는데 react-hook-form 패키지를 사용했다.

이러한 Form을 구성하는데 사용할 수 있는 방법은 두 가지 정도라 생각한다.

직접 Form 만들기


제일 직관적으로 만들 수 있다.

개발자가 직접 input과 button, form을 만들어서 데이터를 관리하는 방법이다.

필요한 데이터가 적으면 효율적일 수 있다고 생각한다.

개발자가 직접 커스터마이징 하여 완전한 제어가 가능하고, 의존성을 추가할 필요가 없다.

하지만, 이 두 장점은 너무 사소하다고 생각한다.

단점은 아래와 같다.

단점

  1. 코드 복잡성 증가
  2. 유효성 검사 : 효성 검사 로직을 별도로 작성해야 하고, 재사용이 어렵다.
  3. 추적의 어려움: 특정 필드가 동적으로 추가되거나 제거될 경우 상태 관리 코드도 변경해야 한다.

실제로 나는 이메일 확인 버튼을 눌러야 인증 번호를 입력하는 칸이 보이게 하는 등 동적으로 제어하고 싶었기에 해당 방식은 사용하지 않고, react-hook-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 로직을 연결하여 폼을 제출할 수 있다.

내 프로젝트에 적용


내 프로젝트에 적용하기 위해 아래와 같은 단계를 거쳤다.

  1. 회원가입 버튼에 /signup 경로로 이동
  2. 회원가입 mock API 작성
  3. Toast적용을 위한 ToastContainer 적용
  4. 회원가입 Form 컴포넌트 작성 (많은 일이 있었다.)

useNavigation 작성

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을 작성했다.

회원가입 후 홈으로 이동하기 위한 코드도 있다.

회원가입 Mock API 작성

// 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를 작성했다.

ToastContainer 적용

import { ToastContainer } from 'react-toastify';

<ToastContainer
	position='top-right'
	autoClose={3000}
	pauseOnHover
	draggable
	closeOnClick
	hideProgressBar={true}
	newestOnTop={true}
/>

위 코드를 App.tsx에 적용했다.

라우터 부분 아래에 작성했다.

useOverlay.ts 작성

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에서 열리고 닫히고, 토글 등의 기능이 있는 부분에 사용하기 위해 작성했다.

useTimer.ts 작성

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로 바뀌어야 한다)}를 돌려준다.

이는 추 후 타이머가 필요한 코드에서 재사용 될 것이다.

그외 Hook

useEmailValidation, useEmailVerification, useInputOffset이 작성되었다.

각 각 이메일 유효성 검사, 이메일 인증 검사, "@" 위치 여부를 계산하는 Hook들이다.

이는 내 깃허브에서 확인할 수 있다.

SignupForm.tsx

이 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>
			&nbsp;
			<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할 수 있었다.

 


Summary


  • react-hook-form
  • Toastify
  • Test
profile
“왜?”라는 질문을 멈추지 않고 본질에 집중하는 개발자입니다.

0개의 댓글