본문 바로가기
개발 기록장

[React] 회원가입, 로그인 구현하기 (Form Help Text)

by heereal 2023. 1. 2.

리액트 팀 프로젝트에서 axios와 json-server를 이용해서 로그인, 회원가입 기능을 구현했다. 부족한 부분이 많겠지만 로그인, 회원가입을 이렇게도 구현할 수 있다는 것을 기록으로 남기려 한다.


회원가입

 

SignUpPage.jsx 전체 코드

더보기
import React, { useEffect, useRef, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import { __getUsers, __signUp } from "../../redux/modules/usersSlice";
import { v4 as uuidv4 } from "uuid";
import {
  Wrap,
  SignUpContainer,
  SignUpForm,
  InputBox,
  CheckMsg,
  Button,
  ButtonBox,
  SwitchText,
} from "./style";

const SignUpPage = () => {
  const dispatch = useDispatch();
  const navigate = useNavigate();

  useEffect(() => {
    dispatch(__getUsers());
  }, [dispatch]);

  // 로그인했을 경우 메인으로 이동
  useEffect(() => {
    const currentUserDi = localStorage.getItem("id");
    if (currentUserDi !== null) {
      navigate("/");
    }
  });

  const { error, users } = useSelector((state) => state.users);

  const [userDi, setUserDi] = useState("");
  const [userPw, setUserPw] = useState("");
  const [checkUserPw, setCheckUserPw] = useState("");
  const [userName, setUserName] = useState("");

  const userDi_input = useRef();
  const userPw_input = useRef();
  const userName_input = useRef();
  const checkUserPw_input = useRef();
  const userDi_msg = useRef();
  const userPw_msg = useRef();
  const checkUserPw_msg = useRef();
  const userName_msg = useRef();
  const singUpBtn = useRef();

  // FIXME: input 문제 해결 위해 추가했는데 콘솔이 두 번씩 찍힘
  useEffect(() => {
    // 첫 렌더링 시 form help text 띄우지 않도록 설정
    if (!userDi) return;
    onChangeUserDiHandler();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [userDi]);

  useEffect(() => {
    if (!userPw) return;
    onChangeUserPwHandler();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [userPw]);

  useEffect(() => {
    if (!checkUserPw) return;
    onChangeUserPwCheckHandler();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [checkUserPw]);

  // TODO: 커스텀 훅 가능?
  // 아이디 입력 시 유효성 검사 및 중복 아이디 확인
  const onChangeUserDiHandler = (e) => {
    setUserDi(userDi_input.current.value);

    const idRegExp = /^[a-z0-9]{4,12}$/;
    // 중복 아이디인지 확인
    const checkUserDi = users.find((user) => user.userDi === userDi);

    if (!idRegExp.test(userDi)) {
      userDi_msg.current.innerText =
        "4~12자의 영문 소문자, 숫자로 작성 가능합니다.";
      userDi_msg.current.style = "display:block";
      userDi_input.current.focus();
      return false;
    } else if (checkUserDi !== undefined) {
      userDi_msg.current.innerText =
        "이미 등록된 아이디입니다. 다시 입력해주세요.";
      userDi_msg.current.style = "display:block";
      userDi_input.current.focus();
      return false;
    } else {
      userDi_msg.current.innerText = "사용 가능한 아이디입니다.";
      userDi_msg.current.style = "display:block; color:green";
      return true;
    }
  };

  const onChangeUserPwHandler = () => {
    setUserPw(userPw_input.current.value);

    const pwRegExp = /^(?=.*[a-zA-Z])(?=.*[0-9]).{8,20}$/;

    if (!pwRegExp.test(userPw)) {
      userPw_msg.current.innerText =
        "비밀번호는 8~20자의 소문자 및 숫자로 작성 가능합니다.";
      userPw_msg.current.style = "display:block";
      userPw_input.current.focus();
      return false;
    } else {
      userPw_msg.current.innerText = "올바른 비밀번호 형식입니다.";
      userPw_msg.current.style = "display:block; color:green";
      return true;
    }
  };

  const onChangeUserPwCheckHandler = () => {
    setCheckUserPw(checkUserPw_input.current.value);

    if (userPw !== checkUserPw) {
      checkUserPw_msg.current.innerText = "비밀번호가 일치하지 않습니다.";
      checkUserPw_msg.current.style = "display:block";
      checkUserPw_input.current.focus();
      return false;
    } else if (userPw === checkUserPw) {
      checkUserPw_msg.current.innerText = "비밀번호가 일치합니다.";
      checkUserPw_msg.current.style = "display:block; color:green";
      return true;
    }
  };

  const onChangeUserNameHandler = () => {
    setUserName(userName_input.current.value);
  };

  // [회원가입] 버튼 클릭 시 작동
  const addHandler = (e) => {
    const newUser = {
      id: uuidv4(),
      userDi,
      userPw,
      userName,
    };

    // 유효성 검사 조건 충족 및 input이 빈 칸이 아닐 때
    const isAllValid =
      onChangeUserDiHandler() === true &&
      onChangeUserPwHandler() === true &&
      onChangeUserPwCheckHandler() === true &&
      userDi &&
      userPw &&
      userName;

    // 회원가입 성공
    if (isAllValid) {
      e.preventDefault();

      dispatch(__signUp(newUser));
      navigate("/");

      // 회원가입 성공하면 따로 로그인할 필요 없이 바로 로그인됨
      localStorage.clear();
      localStorage.setItem("id", newUser.id);
    } else {
      e.preventDefault();
    }

    // 아이디를 입력하지 않았을 때
    if (!userDi) {
      e.preventDefault();
      userDi_msg.current.innerText = "아이디를 입력하세요.";
      userDi_msg.current.style = "display:block";
      userDi_input.current.focus();
      return false;
    }

    // 비밀번호를 입력하지 않았을 때
    if (!userPw) {
      e.preventDefault();
      userPw_msg.current.innerText = "비밀번호를 입력하세요.";
      userPw_msg.current.style = "display:block";
      userPw_input.current.focus();
      return false;
    }

    // 닉네임을 입력하지 않았을 때
    if (!userName) {
      e.preventDefault();
      userName_msg.current.innerText = "닉네임을 입력하세요.";
      userName_msg.current.style = "display:block";
      userName_input.current.focus();
      return false;
    }
  };

  if (error) {
    return <div>{error.massage}</div>;
  }

  return (
    <Wrap>
      <SignUpContainer>
        <h2>회원가입</h2>
        <SignUpForm>
          <InputBox>
            <h4>아이디*</h4>
            <input
              type="text"
              id="userDi"
              name="userDi"
              method="post"
              ref={userDi_input}
              value={userDi}
              onChange={onChangeUserDiHandler}
            />
            <CheckMsg ref={userDi_msg} />
          </InputBox>
          <InputBox>
            <h4>비밀번호*</h4>
            <input
              type="password"
              id="userPw"
              name="userPw"
              method="post"
              ref={userPw_input}
              value={userPw}
              onChange={onChangeUserPwHandler}
            />
            <CheckMsg ref={userPw_msg} />
          </InputBox>
          <InputBox>
            <h4>비밀번호 확인*</h4>
            <input
              type="password"
              id="checkUserPw"
              name="checkUserPw"
              method="post"
              ref={checkUserPw_input}
              value={checkUserPw}
              onChange={onChangeUserPwCheckHandler}
            />
            <CheckMsg ref={checkUserPw_msg} />
          </InputBox>
          <InputBox>
            <h4>닉네임*</h4>
            <input
              type="text"
              id="userName"
              name="userName"
              method="post"
              ref={userName_input}
              value={userName}
              onChange={onChangeUserNameHandler}
            />
            <CheckMsg ref={userName_msg} />
          </InputBox>
          <ButtonBox>
            <Button ref={singUpBtn} onClick={addHandler}>
              가입하기
            </Button>
          </ButtonBox>
          <SwitchText>
            이미 회원이신가요?
            <span onClick={() => navigate("/login")}>로그인</span>
          </SwitchText>
        </SignUpForm>
      </SignUpContainer>
    </Wrap>
  );
};

export default SignUpPage;

 

회원가입 작동 구조

const newUser = {
      id: uuidv4(),
      userDi,
      userPw,
      userName,
};

일단 가입할 때 유저의 데이터를 DB에 저장한다는 구조는 간단하다. 근데 아이디나 비밀번호 유효성 검사라든지, 중복된 아이디를 검사한다든지 회원가입에 성공할 수 있는 기능이 모두 충족되었을 때만 가입할 수 있도록 해야 하는데 모든 조건을 충족했다는 걸 어떻게 코드로 구현할 수 있을까를 많이 고민했다.

 

 

로그인 여부에 따라 URL 접근 제한

// 로그인했을 경우 메인으로 이동
useEffect(() => {
    const currentUserDi = localStorage.getItem("id");
    if (currentUserDi !== null) {
      navigate("/");
    }
});

사용자가 로그인을 했는데도 URL을 입력해서 회원가입 또는 로그인 페이지에 들어가려 한다면 바로 메인페이지로 이동하도록 제한을 두었다.

 

 

회원가입 시 아이디 유효성 검사

// 아이디 입력 시 유효성 검사 및 중복 아이디 확인
const onChangeUserDiHandler = (e) => {
    setUserDi(userDi_input.current.value);

    const idRegExp = /^[a-z0-9]{4,12}$/;
    // 중복 아이디인지 확인
    const checkUserDi = users.find((user) => user.userDi === userDi);

    if (!idRegExp.test(userDi)) {
      userDi_msg.current.innerText =
        "4~12자의 영문 소문자, 숫자로 작성 가능합니다.";
      userDi_msg.current.style = "display:block";
      userDi_input.current.focus();
      return false;
    } else if (checkUserDi !== undefined) {
      userDi_msg.current.innerText =
        "이미 등록된 아이디입니다. 다시 입력해주세요.";
      userDi_msg.current.style = "display:block";
      userDi_input.current.focus();
      return false;
    } else {
      userDi_msg.current.innerText = "사용 가능한 아이디입니다.";
      userDi_msg.current.style = "display:block; color:green";
      return true;
    }
};

일단 userDi는 userId를 뜻한다는 것을 밝혀둔다. test 메서드를 이용해서 input에 입력한 문자열이 정규표현식을 만족하지 못했을 경우 false를 반환하는데 이를 이용해서 !idRegExp.test(userDi)인 경우 아이디 입력 input 하단에 "4~12자의 영문 소문자, 숫자로 작성 가능합니다."라는 메시지를 띄운다.

 

그리고 find 메서드를 이용해서 현재 users DB에 input에 입력한 아이디와와 동일한 값을 가진 userDi가 없다면, 즉 find 메서드가 일치하는 요소를 찾지 못했다면 undefined를 반환한다. 이를 이용해서 checkUserDi가 undefined가 아닐 때(DB에 동일한 아이디가 있을 때) "이미 등록된 아이디입니다. 다시 입력해 주세요."라는 메시지를 띄운다.

 

else문을 이용해서 위의 두 가지 경우에 해당하지 않을 경우에 "사용 가능한 아이디닙니다"라는 메시지가 뜨고 true를 반환한다. 

 

 

input에 입력된 값 실시간으로 반영하기

const [userPw, setUserPw] = useState("");

useEffect(() => {
// 첫 렌더링 시 form help text 띄우지 않도록 설정
if (!userPw) return;
    onChangeUserPwHandler();
}, [userPw]);

form help text를 구현하는데 input에 입력한 값을 한 글자씩 뒤늦게 인식하는 문제가 있어서 추가한 부분이다. 만약에 이미 가입된 아이디가 "abcd"라면 "abcd"까지 입력했을 때는 경고 메시지가 뜨지 않고 "abcde"라고 한 글자를 더 입력해야 "중복된 아이디입니다."라는 텍스트를 띄운다.

 

그래서 useEffect의 의존성 배열을 이용해서 input의 value에 연결되어 있는 userPw 값이 바뀔 때마다 비밀번호 유휴성 검사를 실행해서 form help text를 띄우는 함수가 실행되도록 했다.

 

 

회원가입이 성공하는 조건

 // [회원가입] 버튼 클릭 시 작동
  const addHandler = (e) => {
    const newUser = {
      id: uuidv4(),
      userDi,
      userPw,
      userName,
    };

    // 유효성 검사 조건 충족 및 input이 빈 칸이 아닐 때
    const isAllValid =
      onChangeUserDiHandler() === true &&
      onChangeUserPwHandler() === true &&
      onChangeUserPwCheckHandler() === true &&
      userDi &&
      userPw &&
      userName;

    // 회원가입 성공
    if (isAllValid) {
      e.preventDefault();

      dispatch(__signUp(newUser));
      navigate("/");

      // 회원가입 성공하면 따로 로그인할 필요 없이 바로 로그인됨
      localStorage.clear();
      localStorage.setItem("id", newUser.id);
    } else {
      e.preventDefault();
    }
    ...생략
 };

isAllValid라는 변수를 만들어서 아이디와 비밀번호 유효성 검사 및 비밀번호 확인까지 통과해서 true를 반환하는 경우 + 아모든 input에 입력을 했을 때 if문으로 회원가입이 성공하도록 구현했다. isAllValid가 true라서 회원가입에 성공하면 DB에 새로운 유저의 정보를 저장하고 메인페이지로 이동한다. 또한 회원가입하면 바로 로그인될 수 있도록 회원가입 시에 가입한 아이디를  localStorage에 저장해서 로그인되도록 했다.

 


로그인

 

LoginPage.jsx 전체 코드

더보기
import { useEffect, useRef } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import { __getUsers } from "../../redux/modules/usersSlice";
import useInput from "../../hooks/useInput";
import {
  Wrap,
  SignUpContainer,
  SignUpForm,
  InputBox,
  CheckMsg,
  Button,
  ButtonBox,
  SwitchText,
} from "./style";

const LoginPage = () => {
  const dispatch = useDispatch();
  const navigate = useNavigate();

  useEffect(() => {
    dispatch(__getUsers());
  }, [dispatch]);

  // 로그인했을 경우 메인으로 이동
  useEffect(() => {
    const currentUserDi = localStorage.getItem("id");
    if ( currentUserDi !== null ) {
      navigate("/")
    }
  });

  const { error, users } = useSelector((state) => state.users);

  const [userDi, setUserDi, onChangeUserDiHandler] = useInput();
  const [userPw, setUserPw, onChangeUserPwHandler] = useInput();

  const userDi_input = useRef();
  const userPw_input = useRef();
  const loginMsg = useRef();

  const logInHandler = (e) => {
    e.preventDefault();

    // 아이디를 입력하지 않았을 때
    if (!userDi) {
      e.preventDefault();
      loginMsg.current.innerText = "아이디를 입력하세요.";
      loginMsg.current.style = "display:block";
      userDi_input.current.focus();
      return false;
    }

    // 비밀번호를 입력하지 않았을 때
    if (!userPw) {
      e.preventDefault();
      loginMsg.current.innerText = "비밀번호를 입력하세요.";
      loginMsg.current.style = "display:block";
      userPw_input.current.focus();
      return false;
    }

    // users에서 특정 유저의 userDi와 userPw가 모두 일치하는지 확인한다.
    const user = users.find(
      (user) => user.userDi === userDi && user.userPw === userPw
    );

    // 일치하지 않을 때
    if (user === undefined) {
      loginMsg.current.innerText = "아이디 또는 비밀번호를 잘못 입력했습니다.";
      loginMsg.current.style = "display:block";
      return false;

      // 모두 일치할 때
    } else {
      navigate("/");

    // 로그인한 특정 유저의 id를 localStorage에 저장함
    // 저장하는 이유는 어느 페이지에 가든 현재 로그인한 유저의 id를 불러오기 위해서
    localStorage.clear();
    localStorage.setItem("id", user.id);
    }
  };

  if (error) {
    return <div>{error.massage}</div>;
  }

  return (
    <Wrap>
      <SignUpContainer>
        <h2>로그인</h2>
        <SignUpForm>
          <InputBox>
            <h4>아이디*</h4>
            <input
              type="text"
              id="userDi"
              name="userDi"
              method="post"
              ref={userDi_input}
              value={userDi}
              onChange={onChangeUserDiHandler}
            />
          </InputBox>
          <InputBox >
            <h4>비밀번호*</h4>
            <input
              type="password"
              id="userPw"
              name="userPw"
              method="post"
              ref={userPw_input}
              value={userPw}
              onChange={onChangeUserPwHandler}
            />
            <CheckMsg ref={loginMsg} />
          </InputBox>
          <ButtonBox>
            <Button onClick={logInHandler}>로그인</Button>
          </ButtonBox>
          <SwitchText>
            처음 방문하셨나요?
            <span onClick={() => navigate("/signUp")}>회원가입</span>
          </SwitchText>
        </SignUpForm>
      </SignUpContainer>
    </Wrap>
  );
};

export default LoginPage;

 

로그인 기능 작동 로직

const logInHandler = (e) => {
    e.preventDefault();
	...생략...

    // users에서 특정 유저의 userDi와 userPw가 모두 일치하는지 확인한다.
    const user = users.find(
      (user) => user.userDi === userDi && user.userPw === userPw
    );

    // 일치하지 않을 때
    if (user === undefined) {
      loginMsg.current.innerText = "아이디 또는 비밀번호를 잘못 입력했습니다.";
      loginMsg.current.style = "display:block";
      return false;

      // 모두 일치할 때
    } else {
      navigate("/");

    // 로그인한 특정 유저의 id를 localStorage에 저장함
    // 저장하는 이유는 어느 페이지에 가든 현재 로그인한 유저의 id를 불러오기 위해서
    localStorage.clear();
    localStorage.setItem("id", user.id);
    }
  };

find 메서드를 이용해서 유저 아이디와 비밀번호가 모두 일치하는 데이터가 있는지 확인한다. 일ㅊ히나는 데이터를 찾지 못했을 때, 즉 find 메서드가 undefined를 반환했을 때는 "아이디 또는 비밀번호를 잘못 입력했습니다."라는 메시지를 띄운다.

 

만약 find 메서드가 undefined를 반환하지 않았다면, 즉 아이디와 비밀번호가 모두 일치하는 데이터를 찾았다면 loaclSotrage에 유저의 id(uuid)를 저장하고 메인페이지로 이동한다.

 

 

localStorage에 유저의 uuid를 저장하는 이유

const currentUserDi = localStorage.getItem("id");

localStorage에 저장한 유저의 id(uuid)를 이용해서 localStorage에서 "id"를 가져올 수 있다면 로그인한 상태로 판단, 만약 "id"를 가져오지 못하고 null을 반환한다면 로그인하지 않은 것으로 판단한다. 로그인 여부를 판단해 헤더를 변경한다든지,  글이나 댓글을 작성할 때 localStorage에서 가져온 "id"를 DB에 함께 넣는다든지, 마이페이지에 그 "id"를 가진 유저의 데이터를 보여주는 등의 기능을 할 수 있다.

 

 

로그아웃 기능

// 로그아웃 버튼 핸들러
const logoutHandler = (e) => {
    e.preventDefault();
    localStorage.clear();
    navigate("/");
};

로그아웃은 localStorage에 저장된 해당 유저의 "id"를 지우는 방법으로 구현했다.

 


참고 링크

https://ldrerin.tistory.com/341

https://www.justinmind.com/blog/20-inspiring-examples-of-signup-form-pages/

https://velog.io/@94lfnv/React-회원가입-구현하기


아쉬운 점

  • useRef를 너무 많이 사용한 거 같다. 특히 form help text는 모든 경우의 수에 .current.style을 지정해줘야 해서 코드가 길어졌는데 useRef를 이렇게 많이 사용하지 않고 구현할 수 있는 방법은 없었을지 아쉽다.
  • 중복되는 코드가 너무 많아서 이걸 custom hook으로 줄일 수 없을까 고민을 해봤지만 너무 복잡할 거 같아서 시도하지 않았다.
  • 회원가입 시에 비밀번호를 해쉬화하지 못한 것. 검색해 봤지만 너무 백엔드 깊이 들어가는 거 같아서 시도하지 않았다.
  • 기본적으로 [가입하기] 버튼을 disabled로 설정해 놓고, 회원가입 할 수 있는 조건이 모두 충족되었을 때만 [가입하기] 버튼을 클릭할 수 있게 하고 싶어서 시도해 봤지만 실패했다.

 

 

댓글