NextJS 회원가입 기능 만들기

김은호·2023년 1월 31일
4

Intro

NextJS를 이용하여 커뮤니티를 만드는 토이프로젝트를 진행하던중 Auth 개발을 한 과정을 포스팅하려고 한다.

기본 세팅

.env.local 세팅하기

JWT_SECRET=my_private_secret

NEXT_PUBLIC_API_URL = http://localhost:3000

.env.local을 토대로 axios 설정하기

// lib/api/index.ts

import Axios from 'axios';

const axios = Axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL,
});

export default axios;

API Call 작성하기

// lib/api/auth.ts

import { UserType } from '../type';
import axios from '.';

interface SignUpAPIBody {
  email: string;
  name: string;
  password: string;
  birthday: string;
}

// UserType: 유저에게 정보 전달하는 타입, 패스워드를 뺌
export const signupAPI = (body: SignUpAPIBody) =>
  axios.post<UserType>('/api/auth/signup', body);

회원가입 API 만들기

API를 만드는 과정은 다음과 같다.

  1. method === POST인지 확인
  2. request body에 필요한 값 모두 있는지 확인
  3. email 중복 확인
  4. 패스워드 암호화
  5. 유저정보 추가
  6. 추가된 유저의 정보와 토큰 전달

User Data를 다루는 method 만들기

// lib/data/user.ts

import { readFileSync, writeFileSync } from 'fs';
import { StoredUserType } from '../type';

// user.json 불러오기
const getList = () => {
  const usersBuffer = readFileSync('data/user.json');
  const usersString = usersBuffer.toString();
  if (!usersString) {
    return [];
  }
  const users: StoredUserType[] = JSON.parse(usersString);
  return users;
};

// 이메일 중복 확인
const exist = ({ email }: { email: string }) => {
  const users = getList();
  return users.some((user) => user.email === email);
};

// 유저 리스트 저장
const write = async (users: StoredUserType[]) => {
  writeFileSync('data/user.json', JSON.stringify(users));
};

export default { getList, exist, write };

회원가입 API 만들기

// POST: /api/auth/signup

export default async (req: NextApiRequest, res: NextApiResponse) => {
  if (req.method === 'POST') { // (1)
    const { email, name, password, birthday } = req.body;
    if (!email || !name || !password || !birthday) {
      res.statusCode = 400;
      return res.send('필요한 데이터가 없습니다.');
    } // (2)
    const userExist = Data.user.exist({ email });
    if (userExist) {
      res.statusCode = 405;
      return res.send('이미 가입된 이메일입니다.');
    } // (3)
    
    // (4), password를 hash password로 만듦
    const hashedPassword = bcrypt.hashSync(password, 8);
    
    // (5)
    const users = Data.user.getList();
    let userId;
    if (users.length === 0) {
      userId = 1;
    } else {
      userId = users[users.length - 1].id + 1;
    }
    const newUser: StoredUserType = {
      id: userId,
      email,
      name,
      password: hashedPassword,
      birthday,
      userImage: '/default_user.png',
    };
    Data.user.write([...users, newUser]);

    // (6-1)사용자 인증 토큰 만들기
    const token = jwt.sign(String(newUser.id), process.env.JWT_SECRET!);

    // (6-2) 토큰을 쿠키에 저장하기

    res.setHeader(
      'Set-Cookie',
      `access_token=${token}; Path=/; Expires=${new Date(
        Date.now() + 60 * 60 * 24 * 1000 * 3,
      ).toUTCString()}; HttpOnly`,
    );

    // TS 유틸리티 모듈
    const newUserWithoutPassword: Partial<Pick<StoredUserType, 'password'>> =
      newUser;
    delete newUserWithoutPassword.password;
    res.statusCode = 200;
    // (6-3) User에게 반환할 때 패스워드는 빼고 반환함
    return res.send(newUser);
  }
};

Submit을 통해 서버로 POST 요청하기

form 작성을 위해 react-hook-form을 이용하였다.
또한 회원가입을 하면 유저 정보를 recoil을 사용하여 전역에 저장하도록 하였다.

// components/signup.tsx

/* .. */
const onSubmit = async (data: IForm) => {
    if (data.password !== data.checkPassword) {
      return setError(
        'checkPassword',
        { message: '비밀번호가 일치하지 않습니다.' },
        {
          shouldFocus: true,
        },
      );
    }

    if (
      data.password.includes(data.name) ||
      data.password.includes(data.email.split('@')[0])
    ) {
      return setError(
        'password',
        { message: '비밀번호에 이름 또는 이메일이 포함되어있습니다.' },
        {
          shouldFocus: true,
        },
      );
    }

    try {
      const signUpBody = {
        email: data.email,
        name: data.name,
        password: data.password,
        birthday: new Date(
          `${data.yar}-${data.month.replace('월', '')}-${data.day}`,
        ).toISOString(),
      };
      const res = await signupAPI(signUpBody);
      // Recoil store에 회원가입한 유저 정보 저장
      setLogged({ ...res.data, isLogged: true });
      closeModal();
      window.location.reload();
    } catch (error) {
      console.log(error);
    }
  };

/* .. */

커스텀 Hook인 useModal을 Signup.tsx의 부모 컴포넌트에서 정의하여 Modal을 구현하였다. 그리고 Modal을 닫기 위해 Signup.tsx 내에서 closeModal을 불러와 사용하였는데 되지 않았다. 아마 Portal을 최초로 사용한 곳에서 closeModal을 props로 전달하여 사용해야 하는 것 같다.

회원가입을 하면 새로고침을 하도록 하였는데, recoil을 이용하여 전역에만 저장하면 새로고침 후 그 상태가 초기화된다. 그래서 SessionStorage를 저장하고, 회원가입이 끝나면 새로고침을 통해 로그인된 상태로 이루어지게 하였다.

SessionStorage는 탭을 닫으면 데이터가 삭제된다. localStroage는 탭을 닫아도 여전히 유지된다.

Recoil state를 SessionStorage에 저장하고 값 불러오기

Recoil state를 sessionStorage에 저장하기

먼저 라이브러리를 설치해야한다.

$ npm i recoil-persist
// atom.ts

const sessionStorage =
  typeof window !== 'undefined' ? window.sessionStorage : undefined;

// 따로 설정을 안하면 default 값인 localStroage에 저장이된다.
const { persistAtom } = recoilPersist({
  key: 'userSession',
  storage: sessionStorage,
});

// user recoil state
export const initialState = atom<UserState>({
  key: 'user',
  default: {
    id: 0,
    email: '',
    name: '',
    birthday: '',
    isLogged: false,
    userImage: '',
  },
  effects_UNSTABLE: [persistAtom],
});

sessionStraoge에 있는 값 불러오기

가장 애를 먹은 부분이다. 단순히 저장한 후에 useRecoilValue로 사용하면 될 줄 알았는데, hydration fail이 발생했다.

SSR(server-side rendering)방식으로 서버에서 렌더링 시키고 브라우저단 CSR(client-side rendering)에서 렌더링된 것 과 일치하지 않아서 발생하는 에러

sessionStorage는 브라우저(클라이언트)에 존재하는 저장소인데, SSR 방식으로 렌더링을 하는 pre-rendering 과정에서 sessionStorage가 존재하지 않으므로 에러가 발생한다.

해결 방법

Stroage가 pre-rendering 과정에서 실행되지 않도록 하면 된다.
클라이언트의 useEffect은 SSR 끝난 상태에서 실행되므로, useEffect에서 sessionStorage의 값을 가져오도록 하자.

 const [userState, setUserState] = useState<any>({});

  useEffect(() => {
    const sessionState = JSON.parse(
      sessionStorage.getItem('userSession') as string,
    );
    setUserState(sessionState || {});
  }, []);
  const { isLogged } = userState.user || false;
  const { userImage } = userState.user || '';

마치며

recoil로 값을 저장했는데 다시 꺼내와 useState에 값을 저장하고 있어서 메모리 낭비가 있을 것 같다. 해결하는 방법은 나중에 또 공부해야겠다.

또한 아직 TS type을 지정하는 실력이 미숙한 것 같다. 노력해야겠다.

0개의 댓글