
배경
이번에 팀 프로젝트를 진행하면서 이메일 인증을 위한 페이지 제작 업무를 맡게 되었다. 지금까지 로그인/회원가입 기능은 많이 구현해봤지만, 이메일 인증을 구현하는 것은 처음이었다.
물론 input 태그와 button 태그 하나로 간단하게 구현할 수 있겠지만, 좀 더 사용자 입장에서 사용하기 편한 이메일 인증 폼을 만들고 싶었다. 이를 위해 네이버, 깃허브 등 잘 만들어진 이메일 인증 폼을 보면서 어떤 기능을 구현할 지 고민하게 되었다.
기술 스택
- React 18
- React-Hook-Form
- Tanstack-Query
설계
이메일 인증 폼을 구현하기 위해 페이지를 따로 제작하기로 하였고, 이메일 인증 페이지의 사용자 흐름은 아래와 같이 설계하였다.
이메일 인증 동작 흐름
- 신규 회원가입 시 회원가입 처리 후 이메일 인증 페이지로 이동한다.
1-1. 이메일 인증이 안된 계정이 로그인 시도 시 이메일 인증 페이지로 이동한다. - 이메일 인증 코드 6자리를 입력한다.
2-1. 인증 코드 재전송 버튼 클릭 시 새로운 인증 코드를 전송한다. - 입력된 인증 코드를 기반으로 백엔드에 요청을 보내 검증 작업을 거친다.
3-1. 인증 성공 시 로그인 페이지로 이동한다.
3-2. 인증 실패 시 에러 메시지를 화면에 표시한다.
또한, 인증 코드를 입력하는 폼에 적용할 기능을 아래와 같이 정리하였다.
이메일 인증 폼 기능
- 페이지가 로딩되면 첫 번째 입력 요소에 포커스 적용
- 숫자만 입력 허용
- n번째 입력 요소에 숫자 입력 시 자동으로 n+1번째 입력 요소에 포커스 적용
- 모든 요소 입력 시 자동으로 인증 코드 제출 동작 수행
- 붙여넣기를 통해 한 번에 여러 입력 요소에 숫자 입력
구현
이메일 인증 페이지 이동
이메일 인증 페이지로 이동하기 위해서 useNavigate 훅을 활용하였다. useNavigate 훅을 통해서 페이지 이동 후 추가적인 동작이나 데이터 전달을 원할하게 할 수 있기 때문이다. 이메일 페이지를 이동하기 위한 조건은 아래와 같다.
- 회원가입 API 요청 시 성공 응답을 반환한 경우에 이동한다.
- 로그인 API 요청 시 에러 코드 U006 을 반환할 경우에 이동한다.(U006은 팀원끼리 협의한 이메일 인증이 안된 계정의 로그인 시도에 대한 에러 코드이다.)
아래는 회원가입 요청 시 수행되는 로직이다.
signup.mutate(registerInfo, {
onSuccess: () =>
// 이메일 정보를 가지로 /verify-email 페이지로 이동
navigate(PATH.VERIFY_EMAIL, { state: { email: registerInfo.email } }),
onError: err => console.error('회원가입 실패:', err),
});
만약 요청이 성공한 경우, email 정보를 state로 전달하며 이메일 인증 페이지로 이동한다.
이메일 인증이 안된 계정이 로그인을 시도할 경우 동작하는 로직은 아래와 같다.
const onSubmit = async (data: LoginValues) => {
try {
await loginAndFetchUser(data);
navigate('/');
} catch (err) {
const { code, msg } = ParseErrorMsg(err);
if (code === 'U006') {
// 에러 코드가 U006인 경우 이메일 정보를 가지고 /verify-email 페이지로 이동
navigate(PATH.VERIFY_EMAIL, { state: { email: data.email } });
}
setError(msg);
}
};
/verify-email 페이지에서는 state로 전달받은 이메일 정보를 활용하기 위해 useLocation 훅을 활용하였다. location 변수를 useLocation()으로 초기화하고, location.state에서 전달받은 데이터 객체를 활용할 수 있다.
이메일 정보를 전달받은 이유는 아래와 같다.
- 인증 코드 재전송 동작 수행 시 메일 전송 대상이 필요하다.
- URL 입력을 통해 메일 인증 페이지 접속을 차단하기 위해 필요하다.
위 요구사항을 충족하기 위해 VerifyEmailPage 컴포넌트를 아래와 같이 작성하였다.
export default function VerifyEmailPage() {
const location = useLocation();
const email = location.state?.email;
const navigate = useNavigate();
const { mutate: reSendMutate } = usePostSendEmailVerifyCode();
// 이메일 인증 코드 재전송 함수
const handleResend = () => {
reSendMutate(
{ email },
{
onError: error => console.error(`인증 코드 전송 실패: ${error}`),
},
);
};
useEffect(() => {
// 이메일 정보가 없을 경우 메인 페이지로 변환
if (!email) {
navigate(PATH.INDEX, { replace: true });
}
}, [email]);
return (
<div className={styles.page}>
<div className={styles.container}>
<Card>
<form>
<div className={styles.formContent}>
<FormErrorContainer error={error} />
<div className={styles.inputContainer}>
<VerifyCodeInputField />
</div>
<div className={styles.resendMail}>
<Button variant='ghost' onClick={handleResend}>
<span className={styles.resendMailText}>
<RefreshCw />
인증 코드 재전송
</span>
</Button>
</div>
<Button
size='medium'
type='submit'
>
<span className={styles.submitButton}>
인증하기
</span>
</Button>
</div>
</form>
</Card>
</div>
</div>
);
}
이메일 인증 코드 입력 폼 구현
이메일 인증 코드 입력 폼은 비제어 컴포넌트로 구현하는 것이 효율적이라고 판단했다. 그 이유는 코드를 하나하나 입력하는 과정에서 리렌더링을 발생시킬 필요가 없기 때문이다. 인증 코드 재전송 버튼, 인증하기 버튼 2개와 상호작용하기 때문에 제어 컴포넌트로 구현하는 것은 불필요한 리렌더링을 발생시키는 것이라고 판단했다.
그래서 폼을 비제어 컴포넌트로 간편하게 구현하기 위해 react-hook-form 라이브러리를 활용하였다. 해당 라이브러리를 통해 6자리 인증 코드를 입력하고 백엔드에 요청을 보내는 과정은 다음과 같다.
인증 코드 입력 폼 템플릿
// 6자리 인증 코드를 관리하기 위한 기본 입력값
const defaultValues = {
1: '',
2: '',
3: '',
4: '',
5: '',
6: '',
};
export default function VerifyEmailPage() {
const location = useLocation();
const email = location.state?.email;
const navigate = useNavigate();
const formState = useForm({ defaultValues });
const { mutate: reSendMutate } = usePostSendEmailVerifyCode();
const { isPending, mutate: verifyMutate } = usePostVerifyEmail();
const [error, setError] = useState<string | null>(null);
// 제출하기 버튼 클릭 시 동작
const onSubmit = (data: Record<string, string>) => {
const code = Object.values(data).join('');
verifyMutate(
{ email, code },
{
onSuccess: () => {
console.log('이메일 인증 성공');
navigate(PATH.LOGIN, { replace: true });
},
onError: (error: Error) => {
const { msg } = ParseErrorMsg(error);
setError(msg);
},
},
);
};
// 인증 실패 시 동작
const onError = (error: unknown) => {
const { msg } = ParseErrorMsg(error);
console.error(msg);
};
const handleResend = () => {
reSendMutate(
{ email },
{
onError: error => console.error(`인증 코드 전송 실패: ${error}`),
},
);
};
useEffect(() => {
// 이메일 정보가 없을 경우 메인 페이지로 변환
if (!email) {
navigate(PATH.INDEX, { replace: true });
}
}, [email]);
return (
<div className={styles.page}>
<div className={styles.container}>
<Card>
<FormProvider {...formState}>
<form onSubmit={formState.handleSubmit(onSubmit, onError)}>
// 기존 코드
<div className={styles.resendMail}>
<p>인증 코드를 받지 못하셨나요?</p>
<Button variant='ghost' onClick={handleResend}>
<span className={styles.resendMailText}>
<RefreshCw />
인증 코드 재전송
</span>
</Button>
</div>
<Button
ref={submitButtonRef}
size='medium'
disabled={isPending}
type='submit'
>
<span className={styles.submitButton}>
{isPending ? '인증 중...' : '인증하기'}
</span>
</Button>
</form>
</FormProvider>
</Card>
</div>
</div>
);
}
VerifyCodeInputField 컴포넌트
다음으로 코드를 입력하기 위한 VerifyInputField 컴포넌트를 구현하였다. 페이지 컴포넌트에서 FormProvider를 사용하였으니 FormContext를 통해 폼 메서드를 가져온다. 이때, 6자의 인증 코드를 입력하기 위해서 keys 값들을 배열로 만들고 .map()을 사용하여 6개의 input 태그를 렌더링 하도록 하였다. names변수에 상수 배열을 선언하고 useMemo를 사용하였는데, 컴포넌트가 렌더링 될 때마다 새로운 배열을 선언하고 이에 따라 불필요한 리렌더링이 발생하는 현상을 막고자 하였다. 이를 통해 미세하지만 약간의 성능 향상을 이룰 수 있었다.
export default function VerifyCodeInputField() {
const { setValue, getValues } = useFormContext();
const names = useMemo(() => ['1', '2', '3', '4', '5', '6'] as const, []);
const inputsRef = useRef<HTMLInputElement[]>([]);
return (
<div className={styles.container}>
{names.map((name, idx) => (
<div key={name} className={styles.inputBox}>
<input
ref={el => {
if (el) inputsRef.current[idx] = el;
}}
className={styles.input}
type='text'
inputMode='numeric'
autoComplete='one-time-code'
pattern='[0-9]*'
maxLength={1}
aria-label={`인증 코드 ${idx + 1}번째 숫자`}
/>
</div>
))}
</div>
);
}
첫 번째 input 포커스
만약 내가 사용자라면 이메일 인증 페이지에 접속하였을 때, 클릭으로 인한 포커스 설정 없이 키 입력만으로 인증 코드를 입력할 수 있기를 바랄 것이다. 그래서 useEffect를 통해 input 태그의 가장 첫 번째 요소에 포커스를 주었다.
useEffect(() => {
inputsRef.current[0]?.focus();
}, []);
다음 input 요소 포커스 함수
하나의 input 태그에는 하나의 숫자만 입력할 수 있기 때문에, 하나의 숫자를 입력하면 자동으로 다음 input 요소를 포커스하는 것이 편리하다고 판단하였다. 포커스를 이동하기 위한 함수를 먼저 구현한다.
const focusIndex = (idx: number) => {
if (idx < 0 || idx >= names.length) {
return;
}
inputsRef.current[idx]?.focus();
};
숫자만 허용하는 formatter 함수
숫자만 입력을 받아야 하기 때문에 다른 문자가 입력으로 들어오는 경우 빈 문자열로 대체해야 한다. 우선 숫자를 제외한 모든 문자를 빈 문자로 변경하는 formatter를 구현하였다.
const NUMBER_ONLY_REGEX = /[^0-9]/g;
export const formatOnlyNumber = (value: string) => {
return value.replace(NUMBER_ONLY_REGEX, '');
};
FormField 컴포넌트 적용
폼 필드의 상태를 공유하는 FormField 컴포넌트(FormField 관련 글 업로드 예정)를 활용하여 각 input 요소를 특정 필드가 관리하도록 한다.
<FormField
control={control}
name={name}
render={({ field }) => (
<input
{...field}
ref={el => {
field.ref(el);
if (el) inputsRef.current[idx] = el;
}}
className={styles.input}
type='text'
inputMode='numeric'
autoComplete='one-time-code'
pattern='[0-9]?'
maxLength={1}
value={(field.value as string) ?? ''}
aria-label={`인증 코드 ${idx + 1}번째 숫자`}
/>
)}
/>
onChange 이벤트 핸들러
formatter를 활용하여 숫자만 입력을 허용하고, 숫자 입력 후 다음 input 요소를 포커스하는 역할을 onChange 이벤트 핸들러에 할당하기로 했다. 이를 위해, formatOnlyNumber 함수로 숫자로만 이루어진 문자열로 변경하고 field.onChange 메서드를 활용하여 변경된 문자열을 필드에 반영하였다. 이러한 방법도 리렌더링이 발생하지만 특정 상태의 필드를 구독하였기 때문에, 폼 전체가 리렌더링 되지 않고 변경이 발생한 필드만 리렌더링 된다.
const handleChange = (
idx: number,
e: React.FormEvent<HTMLInputElement>,
field: ControllerRenderProps<FieldValues, string>,
) => {
const target = e.currentTarget;
const nextValue = formatOnlyNumber(target.value).slice(0, 1);
field.onChange(nextValue);
if (nextValue && idx < names.length - 1) {
focusIndex(idx + 1);
}
};
onKeyDown 이벤트 핸들러
onKeyDown에는 주로 포커스 관련 로직을 작성하기로 했다. 왼쪽 방향키를 누르면 왼쪽으로 포커스가 이동하고, 오른쪽 방향키를 누르면 오른쪽으로 포커스가 이동한다. 또한 BackSpace를 통해 숫자를 지우고 한번 더 입력하는 경우, 왼쪽으로 포커스가 이동하도록 하였다. BackSpace를 꾹 누르면 모든 인증 코드가 지워질 것을 기대하기 때문이다. 방향키 입력 시, e.prevantDefault() 메서드를 통해 기본 동작을 막아줬는데, input 요소마다 입력 길이가 1로 제한되어 있기 때문에 input 요소 내에서 포커스 이동은 의미가 없다고 판단하였다.
const handleKeyDown = (
e: React.KeyboardEvent<HTMLInputElement>,
idx: number,
) => {
const key = e.key;
if (key === 'Backspace') {
const length = inputsRef.current[idx]?.value.length ?? 0;
if (length === 0 && idx > 0) {
e.preventDefault();
focusIndex(idx - 1);
}
return;
}
if (key === 'ArrowLeft') {
e.preventDefault();
focusIndex(Math.max(0, idx - 1));
return;
}
if (key === 'ArrowRight') {
e.preventDefault();
focusIndex(Math.min(names.length - 1, idx + 1));
return;
}
};
onPaste 이벤트 핸들러
사용자가 인증코드를 일일이 입력하기 귀찮은 경우에는 복사 붙여넣기를 활용하고 싶을 것이다. 하지만 지금 구현된 폼으로는 일반적인 복사 붙여넣기를 사용할 수 없다. input 요소 6개가 하나의 숫자만 담당하고 있기 때문이다. 그래서 별도의 onPaste 이벤트 핸들러를 구현해줬다. ClipboardEvent를 인자로 받아서 현재 클립보드에 저장된 문자열 데이터를 불러와서 6개의 input 요소에 골고루 배치한다.
const handlePaste = (
e: React.ClipboardEvent<HTMLInputElement>,
idx: number,
) => {
const text = e.clipboardData
.getData('text')
.replace(/\D/g, '')
.slice(0, names.length);
if (!text) {
return;
}
for (let i = 0; i < text.length; i++) {
const targetIdx = idx + i;
if (targetIdx >= names.length) {
break;
}
setValue(names[targetIdx], text[i]);
}
const next = Math.min(idx + text.length, names.length - 1);
focusIndex(next);
};
인증 코드 자동 제출
내가 사용자라고 가정하면 인증 코드를 입력한 후 인증하기 버튼을 누르기도 귀찮을 수 있다. 솔직히 인증 코드를 입력하는 이유도 메일 인증을 진행하고 싶은 이유 하나밖에 없을 것이다. 그래서 6개의 input 태그 모두 입력이 이루어진 경우 자동으로 인증하기 버튼을 클릭하도록 하였다.
인증하기 버튼 참조
먼저, 인증하기 버튼은 페이지 컴포넌트에 작성되어 있기 때문에 useRef를 추가하여 인증하기 버튼을 참조해준다.
export default function VerifyEmailPage() {
const submitButtonRef = useRef<HTMLButtonElement>(null);
// 기존 코드
return (
// 기존 코드
<Button
ref={submitButtonRef}
size='medium'
disabled={isPending}
type='submit'
>
<span className={styles.submitButton}>
{isPending ? '인증 중...' : '인증하기'}
</span>
</Button>
// 기존 코드
);
}
인증하기 버튼 자동 클릭하는 로직 추가
VerifyCodeInputField 컴포넌트에 submitButtonRef를 props로 넘긴다. 그리고 handleChange 이벤트 핸들러에서 모든 필드가 입력 되었을 경우 submitButtonRef.current.click()을 호출한다.
const handleChange = (
idx: number,
e: React.FormEvent<HTMLInputElement>,
field: ControllerRenderProps<FieldValues, string>,
) => {
// 기존 코드
const isFilled = names.every(name => getValues(name));
if (isFilled) {
submitButtonRef?.current?.click();
}
};
결과
여기까지 구현하고 실행해본 결과 인증 코드가 6자인 부분을 확실하게 알 수 있으며, 연속 입력 및 복붙을 자유롭게 할 수 있는 인증 코드 입력 폼이 완성되었다. 또한, 회원가입 후 또는 이메일 인증을 거치지 않은 계정이 로그인을 시도한 경우에만 해당 페이지로 이동할 수 있도록 하여 예상치 못한 동작을 방지하였다. UX에 대해서 깊게 고민하고 만들어낸 결과물이 너무 뿌듯하고 재밌었다.

'Frontend > React.js' 카테고리의 다른 글
| [React] N개의 요청에 대해 N번의 토큰 재발급 문제 해결 과정 (0) | 2025.10.17 |
|---|---|
| [React] Single Step Form vs Multi Step Form 어떤 폼이 더 좋을까? (0) | 2025.09.29 |
| [React] 이벤트 전파 방지 (0) | 2025.05.11 |
| [React] Suspense와 동적 로딩을 활용한 성능 최적화(feat. 고차 컴포넌트) (0) | 2025.05.01 |
| [React] react-i18next 다국어를 위한 JSON 파일 한번에 생성하기 (0) | 2025.04.08 |