[React] react-hook-form으로 로그인, 회원가입 기능 구현

게코젤리·2023년 3월 22일
1
post-custom-banner

현재 진행하고 있는 영화 앱 프로젝트에서 react-hook-form으로 로그인(+소셜 로그인), 회원가입 기능을 구현해보았다. 타입스크립트, 스타일 컴포넌트를 사용했다.

Auth

'/auth'경로에 Auth 컴포넌트가 랜더링 되도록 했다. Auth는 로그인과 회원가입을 선택할 수 있는 UI를 제공하고 선택에 따라 (로그인) 또는 (회원가입)를 렌더링한다. 또한 useEffect를 통해 현재 로그인 상태를 추적하여 로그인 상태일 때는 '/home'으로 이동한다. 따라서 사용자가 로그인, 회원가입(자동로그인)에 성공하면 자동으로 '/home'으로 이동하게 된다.

function Auth({ isLoggedIn }: ILogin) {
  const navigate = useNavigate();
  const [newAccount, setNewAccount] = useState(false);
  const toggleAccount = () => setNewAccount((prev) => !prev);

  useEffect(() => {
    if (isLoggedIn) {
      navigate('/home');
    }
  }, [isLoggedIn]);
  
  return (
    <Wrapper>
      <h2>{!newAccount ? '로그인' : '회원가입'}</h2>
      {!newAccount ? (
        <SignIn
          toggleAccount={toggleAccount}
        />
      ) : (
        <NewAccount toggleAccount={toggleAccount} />
      )}
    </Wrapper>
  );
}

AuthInput

재사용을 위한 input 컴포넌트다. label과 input 그리고 에러메시지를 나타내는 p요소가 있다. 사실 컴포넌트를 분리해도 크게 코드가 줄어들진 않는 것 같다.

const InputField = styled.div`
  display: flex;
  flex-direction: column;
  height: 90px;
  label {
    margin-bottom: 4px;
    font-size: 14px;
    font-weight: 500;
    color: ${(props) => props.theme.white};
  }
  input {
    height: 44px;
    padding: 0 16px;
    border: 1px solid #a6adbd;
    border-radius: 4px;
    font-size: 14px;
    outline: none;
    ::placeholder {
      color: #a6adbd;
    }
  }
  span {
    color: ${(props) => props.theme.purple};
    font-size: 16px;
    margin-top: 4px;
    margin: 4px 0 0 2px;
  }
`;

const ErrorMassage = styled.p`
  margin-top: 4px;
  color: ${(props) => props.theme.purple};
  font-size: 12px;
  font-weight: 700;
`;

interface IAuthInput {
  label: string;
  name: string;
  registerOptions: any;
  placeholder?: string;
  type?: string;
  errors: any;
}

const AuthInput = ({
  label,
  name,
  registerOptions,
  placeholder = '',
  type = 'text',
  errors,
}: IAuthInput) => {
  return (
    <InputField>
      <label>
        {label}
        <span></span>
      </label>
      <input
        {...registerOptions}
        name={name}
        placeholder={placeholder}
        type={type}
        onBlur={registerOptions.onBlur}
      />
      {errors[name] && <ErrorMassage>{errors[name].message}</ErrorMassage>}
    </InputField>
  );
};

NewAccount(회원가입)

회원가입을 위한 form이다. 각각의 요소들에 registerOptions(유효성 검사 규칙)을 전달하고 있다. 유효성 검사 규칙에는 required, pattern, validate 등이 포함되며, 각각의 규칙에 따라 해당 폼 요소의 유효성을 검사한다. 이메일과 닉네임의 경우 validate에 checkEmailExists와 checkNickNameExists 함수를 전달하고 있고 이 함수들은 Firebase API를 이용하여 이메일과 닉네임 중복 여부를 확인한다.

# 처음에 알지 못해서 많이 헤맨 부분인데
useForm({ mode: 'onBlur' })
위와 같이 mode:'onBlur'를 설정하면 특정 input이 아웃 포커스될 때 유효성 검사가 자동으로 이뤄진다. onBlur 모드로 해두고 required, pattern, validate...같은 유효성 규칙만 잘 설정해두면 추가적인 코드 없이 onBlur 상황에서 사용자에게 에러메시지를 보여줄 수 있다.

# 그리고 handleValid 의 기능에 대해 많이 헷갈렸다.
결론은 handleValid은 모든 유효성 검사가 통과 됐을 때 실행하는 함수다. 그러니까 유효성 검사에 실패했을 때 어떤 기능을 추가하고 싶다면(내 경우에는 alert) handleSubmit의 두번째 인자에 실패했을 때 동작할 함수를 넣어주면 된다. 물론 이게 최적의 방법인지는 모르겠다.

function NewAccount({ toggleAccount }: INewAccount) {
  const {
    register,
    handleSubmit,
    watch,
    formState: { errors },
  } = useForm<IForm>({ mode: 'onBlur' });

  const auth = getAuth();
  const db = getFirestore();

  // 이메일 중복 체크
  const checkEmailExists = async (email: string) => {
    const methods = await fetchSignInMethodsForEmail(auth, email);
    return methods.length > 0 ? '이미 가입된 이메일입니다' : undefined;
  };

  // 닉네임 중복 체크
  const checkNickNameExists = async (nickName: string) => {
    const querySnapshot = await getDocs(
      query(collection(db, 'users'), where('nickName', '==', nickName))
    );
    return querySnapshot.empty ? undefined : '이미 사용 중인 닉네임입니다';
  };
  // 계정 등록
  const handleValid = async ({ email, password, nickName }: IForm) => {
    try {
      // 이메일과 비밀번호로 계정 등록하기
      const userCredential = await createUserWithEmailAndPassword(
        auth,
        email,
        password
      );
      const user = userCredential.user;

      if (user) {
        const userRef = doc(db, 'users', user.uid);
        await setDoc(userRef, { nickName });
      }
      alert('가입 완료');
    } catch (error) {
      console.error(error);
    }
  };
  // 등록 실패
  const handleError = () => {
    alert('입력사항을 확인해주세요');
  };

  return (
    <div>
      <form onSubmit={handleSubmit(handleValid, handleError)}>
        <AuthInput
          label="이메일"
          name="email"
          registerOptions={register('email', {
            required: '이메일을 입력해주세요',
            pattern: {
              value: /^[a-zA-Z0-9+-\_.]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/i,
              message: '이메일이 형식에 맞지 않습니다',
            },
            validate: async (value) => await checkEmailExists(value),
          })}
          placeholder="ex) abc1234@gmail.com"
          errors={errors}
        />
        <AuthInput
          label="닉네임"
          name="nickName"
          registerOptions={register('nickName', {
            required: '닉네임을 입력해주세요',
            pattern: {
              value: /^[가-힣a-zA-Z0-9]{2,16}$/,
              message:
                '공백을 제외한 영어, 숫자, 한글 2자 ~ 12자',
            },
            validate: async (value) => await checkNickNameExists(value),
          })}
          placeholder="닉네임"
          errors={errors}
        />
        <AuthInput
          label="비밀번호"
          name="password"
          registerOptions={register('password', {
            required: '비밀번호를 입력해주세요',
            pattern: {
              value: /^(?=.*[a-zA-Z])(?=.*[?!@#$%^*+=-])(?=.*[0-9]).{8,16}$/,
              message: '숫자+영문자+특수문자 조합으로 8자리 이상 입력해주세요',
            },
          })}
          type="password"
          placeholder="숫자 + 영문자 + 특수문자 조합, 8자리 이상"
          errors={errors}
        />
        <AuthInput
          label="비밀번호 확인"
          name="passwordConfirm"
          registerOptions={register('passwordConfirm', {
            required: '비밀번호 확인을 입력해주세요',
            validate: (value) =>
              watch().password !== value
                ? '비밀번호가 일치하지 않습니다'
                : true,
          })}
          type="password"
          placeholder="숫자 + 영문자 + 특수문자 조합, 8자리 이상"
          errors={errors}
        />
        <ColoredBtn type="submit">가입하기</ColoredBtn>
        <Btn type="button" onClick={toggleAccount}>
          로그인
        </Btn>
      </form>
    </div>
  );
}

SignIn (로그인)

로그인 부분은 특별한 점이 없고, 소셜 로그인 구현에 시간이 많이 걸렸다.

구글과 깃허브 계정으로 로그인이 가능하도록 구현하였으며, Firebase의 signInWithPopup 함수를 사용하여 구현했다. 사용자는 소셜 로그인 버튼을 클릭하여 팝업된 로그인 페이지를 통해 인증을 진행할 수 있다.

문제는 소셜 로그인 시에는 일반 회원가입과 달리 닉네임 등록 절차가 없다는 것이다. Firebase의 기능으로 계정의 이름을 받아오는 방법도 있지만, 사용자가 앱 내에서 중복되지 않은 닉네임을 갖도록 하기 위해 다른 방법을 찾아야 했다.

처음 시도했던 것은 소셜 로그인 성공시 닉네임 입력 폼으로 강제로 닉네임을 받아내는 것이었다. 하지만 그 '강제로'가 정말 어려웠다. 사용자가 새로고침을 하거나 다른 경로로 이탈할 경우에 대한 문제를 지금 내 수준에서는 해결할 수가 없었다.

그래서 랜덤 닉네임을 부여했다.
randomNickName = Math.random().toString(36).slice(2, 10);
위와 같이 Math.random() 함수를 사용하여 랜덤 닉네임을 생성하고, 중복되지 않는 닉네임이 생성될 때까지 do-while문을 사용하여 반복 실행한다.
'nlibmgav'
그럼 이런 이상한 닉네임이 만들어진다. 일단 무작위로 만들어만 놓고 사용자가 수정할 수 있도록 기능 구현할 생각이다.

# 사실 이 방법엔 문제가 있다.
위 로직에 따르면 정말 우연찮게 'nlibmgav'이라는 닉네임으로 동시에 가입되는 사용자가 있을 수 있다. 물론 이런 토이 프로젝트에선 절대 일어날 수 없기도 하고 당장 너무 어려운 문제이기 때문에 넘어간다. 하지만 추후에 본격적으로 서비스해야할 프로젝트를 맡게 된다면 꼭 고려해야할 사항일 것이다.

const SignIn = ({ toggleAccount }: ISignIn) => {
  const db = getFirestore();
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<ILoginForm>({ mode: 'onBlur' });
  // 닉네임 중복체크
  const checkNickNameExists = async (nickName: string) => {
    const querySnapshot = await getDocs(
      query(collection(db, 'users'), where('nickName', '==', nickName))
    );
    return querySnapshot.empty ? false : true;
  };
  // 랜덤 닉네임 만들기(중복되지 않은)
  const generateRandomNickName = async () => {
    let randomNickName: string;
    do {
      randomNickName = Math.random().toString(36).slice(2, 10);
    } while (await checkNickNameExists(randomNickName));
    return randomNickName;
  };
  // 소셜 로그인(랜덤 닉네임 생성)
  const onSocialClick = async (event: React.MouseEvent<HTMLButtonElement>) => {
    const {
      currentTarget: { name },
    } = event;
    let provider: AuthProvider | undefined;
    if (name === 'google') {
      provider = new GoogleAuthProvider();
    } else if (name === 'github') {
      provider = new GithubAuthProvider();
    }

    if (provider) {
      try {
        const data = await signInWithPopup(authService, provider);
        const user = data.user;
        if (user) {
          const userRef = doc(db, 'users', user.uid);
          const nickName = await generateRandomNickName();
          await setDoc(userRef, { nickName });
        }
      } catch (error) {
        console.error(error);
      }
    }
  };

  const handleValid = async ({ email, password }: ILoginForm) => {
    try {
      await signInWithEmailAndPassword(authService, email, password);
      alert('로그인 성공');
    } catch (error) {
      console.error(error);
    }
  };

  const handleError = () => {
    alert('입력사항을 확인해주세요');
  };

  return (
    <div>
      <form onSubmit={handleSubmit(handleValid)}>
        <AuthInput
          label="이메일"
          name="email"
          registerOptions={register('email', {
            required: '이메일을 입력해주세요',
            pattern: {
              value: /^[a-zA-Z0-9+-\_.]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/i,
              message: '이메일이 형식에 맞지 않습니다',
            },
          })}
          placeholder="ex) abc1234@gmail.com"
          errors={errors}
        />
        <AuthInput
          label="비밀번호"
          name="password"
          registerOptions={register('password', {
            required: '비밀번호를 입력해주세요',
            pattern: {
              value: /^(?=.*[a-zA-Z])(?=.*[?!@#$%^*+=-])(?=.*[0-9]).{8,16}$/,
              message: '숫자+영문자+특수문자 조합으로 8자리 이상 입력해주세요',
            },
          })}
          type="password"
          placeholder="숫자 + 영문자 + 특수문자 조합, 8자리 이상"
          errors={errors}
        />
        <ColoredBtn type="submit">로그인</ColoredBtn>
        <Btn onClick={onSocialClick} name="google" type="button">
          구글 계정으로 로그인 <FontAwesomeIcon icon={faGoogle} />
        </Btn>
        <Btn onClick={onSocialClick} name="github" type="button">
          깃허브 계정으로 로그인 <FontAwesomeIcon icon={faGithub} />
        </Btn>
        <Btn type="button" onClick={toggleAccount}>
          회원 가입
        </Btn>
      </form>
    </div>
  );
};
post-custom-banner

0개의 댓글