데브월드 트러블 슈팅 3
회원가입 프로세스 개선
문제 상황
- 회원가입을 위해 사용자가 입력해야 하는 값들 관리
사용자가 피로감을 느낄 수 있어 회원가입 단계를 이탈하는 문제.
- 이메일 입력
- 이메일 중복검사, 유효성 검사
- 인증번호 입력
- 닉네임 입력
- 닉네임 중복 검사
- 닉네임 유효성 검사(2~16)
- 비밀번호 입력
- 비밀번호 확인 입력
- 비밀번호가 매치하는지 검사
- 비밀번호의 유효성 검사 (8~16)
- 이메일 인증 API의 응답이 느린 문제 (2~3초)
- 인증 메일이 성공적으로 보내졌다는 응답을 기다리는 동안 사용자의 다른 액션으로 인해서 버그를 유발
- 회원가입 단계를 떠나는 문제 발생
문제 해결을 위한 아이디어
1. 피로감을 느끼지 않는 UI / UX
- 회원 가입 단계를 구분하기
- 퍼널 패턴 적용
- Progress Bar를 제공해서 사용자가 답답함을 느끼지 않도록
- 에러가 발생한다면 메세지와 모달을 통해 즉각적인 피드백 제공
2. 이메일 인증 API의 응답이 느린 경우 (낙관적 업데이트 적용)
- 처음에는 인증 메일을 보낸 경우 버튼을 disabled 처리하거나, 로딩 스피너 컴포넌트를 제공해서 사용자에게 피드백을 보내는 방법을 고려했으나 사용자 경험을 향상시키는 방법이 아니라고 생각했습니다.
- 그래서 인증 메일을 전송한 경우 사용자가 메일을 확인할 때 까지 평균 3초 정도 걸리는 것을 생각하고 우선 API 응답과 관계 없이 회원 가입 단계를 진행하도록 설계했습니다. (사용자가 메일함을 확인할 때 쯤이면 이메일 인증 전송 메일이 도착해있음)
- 에러가 발생했을 경우를 대비해서 에러코드를 정의하고 회원가입 단계를 롤백하도록 처리했습니다.
3. 사용자가 입력한 값들을 관리하고 유효성 검사 요구사항 만족
복잡한 Form 컴포넌트 내부를 효과적으로 관리하고 유효성 검사 기능을 추가하기 위해 React Hook Form과 Zod를 도입했습니다. 이를 통해 폼 상태를 손쉽게 관리하며, 에러 상태를 활용하여 사용자에게 실시간으로 피드백을 제공할 수 있었습니다. 또한 타입을 분리해 작성함으로써 타입 안정성을 확보할 수 있었고, 복잡한 유효성 검사 요구사항도 충족할 수 있었습니다.
결과
-
낙관적 업데이트 적용
-
퍼널 패턴 적용
코드
"use client";
import { Progress } from "@/shared/ui/progress";
import { EmailAuthForm } from "./email-verify-form";
import { useSignupProgressStore } from "@/app/_store/singup-form-progres-store";
import { EmailInputOTPForm } from "./email-otp";
import { PassWordForm } from "./password-form";
export const SignUp = () => {
const { step, progress } = useSignupProgressStore();
return (
<div className="flex h-full w-full items-center justify-center px-2">
<div className="w-[440px] min-w-[400px] flex-col items-center bg-zinc-900 p-8 rounded-md">
<h3 className="mb-4 text-4xl font-bold text-primary">Sign Up</h3>
<Progress value={progress[step]} className="my-4" />
{step === "email_auth" && <EmailAuthForm />}
{step === "otp" && <EmailInputOTPForm />}
{step === "password" && <PassWordForm />}
</div>
</div>
);
};
- Progress Bar
-
모달 UI
-
-
-
-
에러 메세지
- 코드
import { z } from "zod";
export const SignupUserSchema = z
.object({
email: z.string().trim().email(),
password: z
.string()
.min(8, {
message: "비밀번호는 최소 8자 이상 입력해주세요.",
})
.max(16, {
message: "비밀번호는 16자 이하로 입력해주세요.",
}),
passwordConfirm: z
.string()
.min(8, {
message: "비밀번호 확인 글자는 최소 8글자 이상입니다.",
})
.max(16, {
message: "비밀번호 확인 글자는 최대 16글자 이상입니다.",
}),
devName: z
.string()
.min(2, {
message: "데브월드 닉네임은 2글자 이상입니다.",
})
.max(16, {
message: "데브월드 닉네임은 16글자 이상입니다.",
}),
})
.refine((data) => data.password === data.passwordConfirm, {
message: "비밀번호가 일치하지 않습니다.",
path: ["passwordConfirm"],
});
export type SignupUserFormData = z.infer<typeof SignupUserSchema>;
<p
className={cn(
`text-xs mb-1 ${errors.password ? "text-destructive" : "text-cyan-600"}`
)}
>
{errors.password ? errors.password.message : "8 ~ 16자 사이로 입력해주세요"}
</p>