React + Typescript + Redux - 회원가입 구현 (Feat 유효성 검사, 리팩토링)

Jinnny·2023년 7월 24일
2

React

목록 보기
3/10

Typescript 환경에서 구현 중인 프로젝트에서 회원가입과 로그인을 맡게 되었고 먼저 회원가입부터 어떤 방법으로 구현했는지 공유해보고자 한다.

구현했던 과정을 하나하나 다 적을거라 글이 길어질 것으로 예상되어 실행 결과가 궁금하다면 실행 결과 부분을 참고하길 바란다.

🛠 기능

  1. 아이디, 비밀번호, 이메일, 휴대폰 번호 유효성 검사
  2. 비밀번호 확인
  3. 아이디, 이메일 중복 검사
  4. 휴대폰 인증번호 받기 및 휴대폰 번호 중복 검사

먼저 회원가입에 들어갈 데이터는 아이디, 비밀번호, 이름, 이메일, 생년울일, 휴대폰 번호로 정했고 기능은 크게 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;

💻 실행결과

0개의 댓글