Typescript 환경에서 구현 중인 프로젝트에서 회원가입과 로그인을 맡게 되었고 먼저 회원가입부터 어떤 방법으로 구현했는지 공유해보고자 한다.
구현했던 과정을 하나하나 다 적을거라 글이 길어질 것으로 예상되어 실행 결과가 궁금하다면 실행 결과 부분을 참고하길 바란다.
- 아이디, 비밀번호, 이메일, 휴대폰 번호 유효성 검사
- 비밀번호 확인
- 아이디, 이메일 중복 검사
- 휴대폰 인증번호 받기 및 휴대폰 번호 중복 검사
먼저 회원가입에 들어갈 데이터는 아이디, 비밀번호, 이름, 이메일, 생년울일, 휴대폰 번호로 정했고 기능은 크게 4가지로 분류했다.
회원가입 UI 마크업을 한 뒤 유효성 검사를 만들었는데 처음에는 무작정 데이터, 오류 메세지, 유효성 검사를 위한 state를 하나씩 만들어서 구현했다.
❌ 문제 코드
const SignupForm = () => {
const navigate = useNavigate();
// 아이디, 비밀번호, 이름, 이메일, 생년월일, 휴대폰 번호 확인
const [id, setId] = useState<string>("");
const [password, setPassword] = useState<string>("");
const [passwordConfirm, setPassword] = useState<string>("");
const [name, setName] = useState<string>("");
const [email, setEmail] = useState<string>("");
const [birthday, setBirthday] = useState<string>("");
const [phonenumber, setPhonenumber] = useState<string>("");
const [code, setCode] = useState<number>();
// 유효성 검사
const [isId, setIsId] = useState<boolean>(false);
const [isPassword, setIsPassword] = useState<boolean>(false);
const [isPasswordConfirm, setIsPasswordConfirm] = useState<boolean>(false);
const [isEmail, setIsEmail] = useState<boolean>(false);
// 오류 메세지
const [idMessage, setIdMessage] = useState<string>("");
const [passwordMessage, setPasswordMessage] = useState<string>("");
const [passwordConfirmMessage, setPasswordConfirmMessage] = useState<string>("");
const [emailMessage, setEmailMessage] = useState<string>("");
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
const response = await axios.post(
`${서버주소`,
{
id,
password,
name,
email,
birthday,
phonenumber,
},
);
if (response.status === 200) {
navigate(`/login`);
}
} catch (error) {
console.log(error);
}
};
// 아이디 유효성 검사
const handleChangeId = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
const regex = /^[a-zA-Z0-9]{5,13}$/;
setId(value);
if (!regex.test(value)) {
setIdMessage(
"영어, 숫자를 포함한 5자 이상 13자 미만으로 입력해주세요.",
);
setIsId(false);
} else {
setIsId(true);
}
},
[],
);
// 비밀번호 유효성 검사
const handleChangePassword = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
const regex = /^(?=.*[0-9])(?=.*[a-zA-Z])(?=.*[!@#$%^&*]).{8,}$/;
setPassword(value);
if (!regex.test(value)) {
setPasswordMessage(
"숫자, 영문, 특수문자를 포함하여 최소 8자를 입력해주세요",
);
setIsPassword(false);
} else {
setIsPassword(true);
}
},
[],
);
// 비밀번호 확인
const handleChangePasswordConfirm = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
setPasswordConfirm(value);
if (pwd === value) {
setPasswordConfirmMessage("비밀번호가 일치합니다.");
setIsPasswordConfirm(true);
} else {
setPasswordConfirmMessage("비밀번호가 일치하지 않습니다.");
setIsPasswordConfirm(false);
}
},
[pwd],
);
// 이름
const handleChangeName = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setName(event.target.value);
setIsName(true);
},
[],
);
// 이메일 유효성 검사
const handleChangeEmail = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
setEmail(value);
if (!regex.test(value)) {
setEmailMessage("올바른 이메일 형식이 아닙니다.");
setIsEmail(false);
} else {
setIsEmail(true);
}
},
[],
);
// 생년월일
const handleChangeBirthday = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
const date = new Date(value);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
setBitrhday(`${year}-${month}-${day}`);
setIsBirthday(true);
},
[],
);
// 휴대폰 번호
const handleChangePhonenumber = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setPhonenumber(event.target.value);
setIsPhonenumber(true);
},
[],
);
return (
<St.Section>
<St.Container>
<St.H1>회원가입</St.H1>
<form onSubmit={handleSubmit}>
{/* 아이디 input */}
<St.InputField>
<St.P>아이디</St.P>
<Input
type="text"
value={id}
placeholder="영문, 숫자 5-13자"
onChange={handleChangeId}
/>
{isUserId === false && (
<St.ErrorMessage>{userIdMessage}</St.ErrorMessage>
)}
</St.InputField>
{/* 비밀번호 input */}
<St.InputField>
<St.P>비밀번호</St.P>
<PasswordInput
value={pwd}
placeholder="숫자, 영문, 특수문자 조합 최소 8자"
onChange={handleChangePassword}
/>
{isPassword === false && <St.ErrorMessage>{PasswordMessage}</St.ErrorMessage>}
<PasswordInput
value={pwdConfirm}
placeholder="비밀번호 재입력"
onChange={handleChangePasswordConfirm}
/>
{isPwdConfirm === false && (
<St.ErrorMessage>{PasswordConfirmMessage}</St.ErrorMessage>
)}
</St.InputField>
{/* 이름 input */}
<St.InputField>
<St.P>이름</St.P>
<Input
type="text"
value={name}
placeholder="홍길동"
onChange={handleChangeName}
/>
</St.InputField>
{/* 이메일 input */}
<St.InputField>
<St.P>이메일</St.P>
<Input
type="email"
value={email}
placeholder="이메일"
onChange={handleChangeEmail}
/>
{isEmail === false && (
<St.ErrorMessage>{emailMessage}</St.ErrorMessage>
)}
</St.InputField>
{/* 생년월일 input */}
<St.InputField>
<St.P>생년월일</St.P>
<St.Birthday type="date" value={bday} onChange={handleChangeBirthday} />
</St.InputField>
{/* 휴대폰 번호 input */}
<St.InputField>
<St.P>휴대폰 번호</St.P>
<St.PhoneField>
<Input
type="text"
value={phoneNumber}
placeholder="휴대폰 번호 '-' 제외하고 입력"
onChange={handleChangePhonenumber}
/>
<St.Button type="button" onClick={handlePhonenumberClick}>
인증번호
</St.Button>
</St.PhoneField>
<St.PhoneField>
<Input
type="text"
value={verifiedCode}
placeholder="인증번호 입력"
onChange={handleCodeChange}
/>
<St.Button type="button" onClick={handleVerifyNumberClick}>
확인
</St.Button>
</St.PhoneField>
</St.InputField>
{/* 회원가입 버튼 */}
<St.SignupBtn>회원가입</St.SignupBtn>
</form>
</St.Container>
</St.Section>
);
};
이 방식으로 하니 너무 많은 state를 관리해야 했고 제일 먼저 코드의 가독성이 떨어졌다.
💡 해결 방법
먼저 signupForm이라는 객체를 만들어 그 안에 프로퍼티로 아이디와 비밀번호, 비밀번호 확인, 이름, 이메일, 생년월일, 휴대폰 번호, 인증번호 데이터들을 넣어주었다.
const [signupForm, setSignupForm] = useState({
id: "",
password: "",
passwordConfirm: "",
name: "",
email: "",
birthday: "",
phonenumber: "",
code: "",
});
그 다음 input에서 받아오는 값들을 각 프로퍼티에에 넣어주었다.
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setSignupForm({ ...signupForm, [name]: value });
};
<Input type="text" value={signupForm.id} placeholder="영문, 숫자 5-13자" onChange={handleChange} />
비밀번호, 이름, 이메일, 생년월일, 휴대폰 번호, 인증번호 input도 위와 동일한 방식으로 해주었다.
그러고 커스텀 훅을 만들어 유효성 검사를 할 수 있게 해주었다.
처음에는 휴대폰 번호 유효성 검사가 필요없을 것 같아서 제외했었는데 휴대폰 번호의 양식이 틀렸을 경우에는 인증번호를 보내지 못하게 해야 할 것 같아 휴대폰 번호 유효성 검사도 추가해주었다.
const useValid = (form: FormType) => {
// 오류 메세지
const [validMessage, setValidMessage] = useState({
idMessage: "",
passwordMessage: "",
passwordConfirmMessage: "",
emailMessage: "",
phonenumberMessage: "",
codeMessage: "",
});
// 유효성 검사
const [isValid, setIsValid] = useState({
id: false,
password: false,
passwordConfirm: false,
email: false,
phonenumber: false,
code: false,
codeBtn: false,
checkCodeBtn: false,
});
// 아이디 유효성 검사
useEffect(() => {
const regex = /^(?=.*[a-zA-Z])(?=.*[0-9])[a-zA-Z0-9]{5,13}$/;
if (!regex.test(form.id)) {
setValidMessage((prev) => ({
...prev,
idMessage: "영어, 숫자를 포함한 5자 이상 13자 이하로 입력해주세요.",
}));
setIsValid({ ...isValid, id: false });
} else {
setIsValid({ ...isValid, id: true });
}
}, [form.id]);
// 비밀번호 유효성 검사
useEffect(() => {
const regex = /^(?=.*[0-9])(?=.*[a-zA-Z])(?=.*[!@#$%^&*]).{8,15}$/;
if (!regex.test(form.password)) {
setValidMessage((prev) => ({
...prev,
passwordMessage: "숫자, 영문, 특수문자를 포함하여 최소 8자를 입력해주세요",
}));
setIsValid({ ...isValid, password: false });
} else {
setIsValid({ ...isValid, password: true });
}
}, [form.password]);
// 비밀번호 확인
useEffect(() => {
if (form.password !== form.passwordConfirm) {
setValidMessage((prev) => ({
...prev,
passwordConfirmMessage: "비밀번호가 일치하지 않습니다.",
}));
setIsValid({ ...isValid, passwordConfirm: false });
} else {
setIsValid({ ...isValid, passwordConfirm: true });
}
}, [form.passwordConfirm]);
// 이메일 유효성 검사
useEffect(() => {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!regex.test(form.email)) {
setValidMessage((prev) => ({
...prev,
emailMessage: "올바른 이메일 형식이 아닙니다.",
}));
setIsValid({ ...isValid, email: false });
} else {
setIsValid({ ...isValid, email: true });
}
}, [form.email]);
// 휴대폰 유효성 검사
useEffect(() => {
const regex = /^01([016789])(?:\d{3}|\d{4})\d{4}$/;
if (!regex.test(form.phonenumber || "")) {
setIsValid({ ...isValid, phonenumber: false });
} else {
setIsValid({ ...isValid, phonenumber: true });
}
if (
(!isValid.phonenumber && isValid.codeBtn) ||
(isValid.phonenumber && isValid.codeBtn)
) {
setValidMessage((prev) => ({
...prev,
phonenumberMessage: "인증번호를 다시 받아주세요.",
}));
setIsValid((prev) => ({ ...prev, codeBtn: false }));
}
}, [form.phonenumber]);
return { validMessage, isValid };
};
✔ 아이디, 이메일 중복 확인
그 다음 기능으로 아이디와 이메일 중복을 검사할 수 있는 기능을 만들었는데 유효성 검사와 로직이 비슷하여 유효성 검사하는 커스텀 훅에 추가해주었다.
💡 코드
const [validMessage, setValidMessage] = useState({
idMessage: "",
passwordMessage: "",
passwordConfirmMessage: "",
emailMessage: "",
phonenumberMessage: "",
codeMessage: "",
idDuplicationMessage: "",
emailDuplicationMessage: "",
});
const [isValid, setIsValid] = useState<IsValidType>({
id: false,
password: false,
passwordConfirm: false,
email: false,
name: false,
birthday: false,
phonenumber: false,
code: false,
codeBtn: false,
checkCodeBtn: false,
idDuplication: false,
emailDuplication: false,
});
// 아이디 중복확인
const checkDuplicatedId = async (id: string) => {
const response = await signup.getDuplicatedId({ id });
// 백엔드 측에서 사용 가능한 아이디일 경우 0, 중복된 아이디일 경우 1로 보내주셨다.
if (response.data === 0) {
setValidMessage((prev) => ({
...prev,
idDuplicationMessage: "사용 가능한 아이디입니다.",
}));
setIsValid((prev) => ({ ...prev, idDuplication: true }));
} else if (response.data === 1) {
setValidMessage((prev) => ({
...prev,
idDuplicationMessage: "이미 사용중인 아이디입니다.",
}));
setIsValid((prev) => ({ ...prev, idDuplication: false }));
}
};
// 이메일 중복 확인
const checkDuplicatedEmail = async (email: string) => {
const response = await signup.getDuplicatedEmail({ email });
console.log("response: ", response);
if (response.data === 0) {
setValidMessage((prev) => ({
...prev,
emailDuplicationMessage: "사용 가능한 이메일 입니다.",
}));
setIsValid((prev) => ({ ...prev, emailDuplication: true }));
} else if (response.data === 1) {
setValidMessage((prev) => ({
...prev,
emailDuplicationMessage: "이미 사용중인 이메일 입니다.",
}));
setIsValid((prev) => ({ ...prev, emailDuplication: false }));
}
};
✔ 휴대폰 인증번호 받기 및 휴대폰 번호 중복확인
마지막으로 커스텀 훅에 휴대폰번호를 입력하고 인증번호를 받을 수 있도록 해주었다.
// 휴대폰 인증번호
const requestAuthenticationNumber = async (phonenumber: string) => {
// 휴대폰 중복확인
const response = await signup.getDuplicatedPhonnumber({
phonenumber,
});
console.log(response);
if (response.data === 0) {
try {
await signup.getAuthenticationNumber({
phonenumber,
});
setValidMessage((prev) => ({
...prev,
phonenumberMessage: "인증번호가 요청되었습니다.",
}));
setIsValid((prev) => ({ ...prev, phonenumber: true }));
setIsValid((prev) => ({ ...prev, codeBtn: true }));
} catch (error) {
setValidMessage((prev) => ({
...prev,
phonenumberMessage: "인증번호 요청에 실패했습니다.",
}));
setIsValid((prev) => ({ ...prev, phonenumber: false }));
setIsValid((prev) => ({ ...prev, codeBtn: false }));
}
} else {
setValidMessage((prev) => ({
...prev,
phonenumberMessage: "현재 가입된 번호입니다.",
}));
setIsValid((prev) => ({ ...prev, phonenumber: false }));
setIsValid((prev) => ({ ...prev, codeBtn: false }));
}
};
// 인증번호 확인
const verifyAuthenticationNumber = async (
phonenumber: string,
code: string,
) => {
try {
const response = await signup.checkAuthenticationNumber({
phonenumber,
code,
});
if (response.status === 200) {
setValidMessage((prev) => ({
...prev,
codeMessage: "인증 완료 되었습니다.",
}));
setIsValid((prev) => ({ ...prev, code: true }));
setIsValid((prev) => ({ ...prev, checkCodeBtn: true }));
}
} catch (error) {
setValidMessage((prev) => ({
...prev,
codeMessage: "올바르지 않은 인증번호 입니다.",
}));
setIsValid((prev) => ({ ...prev, code: false }));
setIsValid((prev) => ({ ...prev, checkCodeBtn: false }));
}
};
구현하다보니 중복된 코드가 많아 코드가 너무 길어지고 가독성이 떨어져 리팩토링을 해보았다.
❌ 중복되는 유효성 검사 부분과 메세지 부분
isValid와 validMessage 상태값을 변경해주는 부분이 계속 중복되는 것을 볼 수 있었고 이 부분에 대해서 따로 함수로 빼어 필요한 곳에서 호출할 수 있도록 해주었다.
💡 해결 방법
const updateValid = (name: string, state: boolean) => {
setIsValid((prev) => ...prev, [name]: state)
};
const updateMessage = (name: string, message: string) => {
setValidMessage((prev) => ...prev, [name]: state)
};
// 아이디 유효성 검사
useEffect(() => {
const regex = /^(?=.*[a-zA-Z])(?=.*[0-9])[a-zA-Z0-9]{5,13}$/;
if (!regex.test(form.id || "")) {
updateMessage(
"idMessage",
"영어, 숫자를 포함한 5 ~ 13자로 입력해주세요.",
);
updateValid("id", false);
} else {
updateValid("id", true);
}
if (validState.idDuplication || !validState.idDuplication) {
updateMessage("idDuplicationMessage", "중복확인을 해주세요");
updateValid("idDuplication", false);
}
}, [form.id]);
// 비밀번호, 아이디, 휴대폰 번호 등 동일한 방식으로 진행
❌ 제대로 저장되지 않는 상태값
회원가입을 구현할 때는 발견하지 못하고 다른 부분을 구현하다 유효성 커스텀 훅을 사용할 일이 있어 호출하여 사용하던 도중 발견한 문제로 id를 입력한 후 비밀번호나 휴대폰 번호 등 다른 input에 값을 입력하면 isValid와 validMessage에 있는 프로퍼티 값들이 다시 초기값으로 돌아가는 현상이 있었다.
💡 해결 방법
useEffect를 여러 개 사용했다보니 렌더링 순서와 상태값 변경 타이밍이 맞지 않아 이러한 현상이 발생한 것으로 예상이 되어 redux를 사용하여 상태 변화를 일관성 있게 처리해주었다.
const dispatch = useDispatch();
const validState = useSelector(
(state: RootState) => state.validationReducer.validState,
);
const updateValid = (name: string, state: boolean) => {
dispatch(updateValidState({ name, value: state }));
};
const updateMessage = (name: string, message: string) => {
dispatch(updateMessageState({ name, message }));
};
유효성 검사 redux
const initialState: ValidationReducerType = {
validState: {
id: false,
password: false,
passwordConfirm: false,
email: false,
name: false,
// birthday: false,
phonenumber: false,
code: false,
codeBtn: false,
checkCodeBtn: false,
idDuplication: false,
emailDuplication: false,
profileImage: false,
},
messageState: {
idMessage: "",
passwordMessage: "",
passwordConfirmMessage: "",
emailMessage: "",
phonenumberMessage: "",
codeMessage: "",
idDuplicationMessage: "",
emailDuplicationMessage: "",
},
error: null,
};
const validationReducer = createSlice({
name: "validationReducer",
initialState,
reducers: {
updateValidState: (state, action) => {
const { name, value } = action.payload;
state.validState[name] = value;
},
updateMessageState: (state, action) => {
const { name, message } = action.payload;
state.messageState[name] = message;
},
},
});
export const { updateValidState, updateMessageState } =
validationReducer.actions;
export default validationReducer.reducer;