코딩/React

NextJS와 MongoDB로 유저 로그인 세션 구현하기 2편

드리프트 2021. 8. 22. 20:05
728x170

 

 

안녕하세요?

 

지난 시간에 이어 NextJS와 MongoDB를 이용한 유저 로그인 세션 구현하기 2편을 계속 이어 나가도록 하겠습니다.

 

1편: https://cpro95.tistory.com/463

 

NextJS와 MongoDB로 유저 로그인 세션 구현하기 1편

안녕하세요? 오늘부터 새로운 NextJS 강좌를 시작해 볼까 합니다. 일반 ReactJS 앱이 아닌 최근 들어 ReactJS 프로트엔드에서 가장 각광받고 있는 NextJS를 이용할 예정인데요. NextJS는 Serverless 앱 구현에

cpro95.tistory.com

 

 

이제 본격적으로 NextJS 소스 코드를 수정하기 전에 Next.js 버전을 10.0.3으로 다운예정입니다.

 

왜냐하면, 앞으로 쓰일 패키지가 아직 Next.js 11 버전을 지원하지 않기 때문입니다.

 

다음과 같이 입력하시면 됩니다.

 

npm uninstall next

npm install next@10.0.3 --force

 

package.json 에서 next 가 10.0.3인지 확인하시면 됩니다.

 

 

 

index.js 작성하기

 

먼저 NextJS가 처음 읽는 파일은 pages 란 폴더의 index.js 파일입니다.

 

이 파일을 다음과 같이 수정해 볼까요?

 

import React from "react";
import Link from "next/link";
import { useCurrentUser } from "../hooks/index";

export default function IndexPage() {
  const [user] = useCurrentUser();

  return (
    <div className="px-4 py-5 my-5 text-center">
      <h1 className="display-5 fw-bold">
        {user ? user.name : "stranger"} 님 반갑습니다. 
      </h1>
      <div className="col-lg-6 mx-auto">
        <p className="lead mt-4 mb-4 fw-normal">
          NextJS와 MongoDB를 이용한 로그인 세션 구현 샘플입니다.
          <br />
          계정이 있으시면 아래 로그인 버튼을 누르시고, 
          <br />
          없으시면 가입하기 버튼을 눌러 계정을 만드십시요.
        </p>
        <div className="d-grid gap-2 d-sm-flex justify-content-sm-center">
          <button type="button" className="btn btn-primary btn px-4 gap-3">
            <Link href="/login">로그인</Link>
          </button>
          <button
            type="button"
            className="btn btn-outline-secondary btn px-4"
          >
            <Link href="/signup">가입하기</Link>
          </button>
        </div>
      </div>
    </div>
  );
}

 

일단 UI 부분은 Bootstrap의 Heros example에서 차용했습니다.

 

일단 코드는 기본적인 랜딩페이지 UI에 user 부분만 코드를 적용했습니다.

 

h1 태그에 보시면 user가 있으면 user.name을 나타내고 없으면 stranger이란 이름을 나타내게 됩니다.

 

그리고 밑에 로그인 버튼과 가입하기 버튼은 next/link를 이용해서 각각 /login, /signup 이란 경로로 이동하게끔 API 라우팅을 설정하기로 했습니다.

 

그럼 위 코드에서 아직 구현 안된게 뭐가 있을까요?

 

바로 useCurrentUser란 Custom React Hook입니다.

 

이 Hook을 만든 이유는 아무 때나 현재 사용자에 대한 정보를 가져오기 쉽게 만들기 위해서죠.

 

그럼 이 코드를 보겠습니다.

 

먼저 hooks란 폴더를 만들고 index.js를 만듭니다.

 

export * from './user';

user.js를 읽어들여 export 해주는 파일입니다.

 

그럼 hooks 폴더 밑의 user.js 파일을 만들어 봅시다.

 

import useSWR from 'swr';
import fetcher from '@/lib/fetch';

export function useCurrentUser() {
  const { data, mutate } = useSWR('/api/user', fetcher);
  const user = data?.user;
  return [user, { mutate }];
}

 

일단 코드를 보시면 swr 모듈이 필요합니다.

 

swr 모듈은 NextJS 가 제공하는 fetch Hook인데요.

 

NextJS에 원리에 맞게 구현된 fetch를 쉽게 이용할 수 있게 해주는 모듈입니다.

 

이 모듈을 쓰기 전에 "npm install swr" 이란 명령어로 설치해주는 거는 당연합니다.

 

그리고 fetcher 를 import 했는데요.

 

이 fetcher 또한 NextJS에서 권장하고 있는 사용자 fetch 함수입니다.

 

useSWR에 fetch 할 수 있는 함수를 넣어야 되는데 그때 필요한 fetcher를 import 하고 있습니다.

 

전체적으로 보면 useCurrentUser() 훅은 "/api/user" 라는 NextJS API 라우팅으로 data를 받으면 그 값을 리턴해주는 커스텀 훅입니다.

 

그리고 mutate 라는 함수로 리턴해주고 있는데 나중에 이 mutate 함수로 user의 값을 수정할 수 도 있으니 참고 바랍니다.

 

여기서는 일단은 이 mutate라는 함수는 신경 쓰지 말기 바랍니다.

 

그러면 우리는 "api/user"라는 NextJS의 서버사이드 부분 로직을 작성해야 합니다.

 

일단, "/api/user" 부분을 보기 전에 @/lib/fetch 라는 파일을 만들어야겠죠.

 

NextJS 폴더에서 lib 폴더를 만들고 fetch.js 파일을 만듭시다.

 

export default function fetcher(url) {
  return fetch(url).then((r) => r.json());
}

 

fetch.js 파일은 간단합니다. 

 

axios 같은 외부 모듈을 사용하지 않고 최신 브라우저에서 기본으로 지원하는 fetch 함수를 사용했습니다.

 

여기서 @/lib/fetch라는 경로를 썼는데요, NodeJS에서 javascript 컴파일 옵션을 쓰면 이렇게 사용할 수 있습니다.

 

상대 경로가 헷갈린다면 @/lib/fetch라는 경로가 훨씬 사용하기 쉽습니다.

 

javascript 컴파일 옵션은 NextJS 최상단 폴더에 jsconfig.json 이란 파일은 만들면 됩니다.

 

{
    "compilerOptions": {
      "baseUrl": ".",
      "paths": {
        "@/components/*": ["components/*"],
        "@/lib/*": ["lib/*"],
        "@/middlewares/*": ["middlewares/*"],
        "@/hooks/*": ["hooks/*"],
        "@/db/*": ["db/*"]
      }
    }
  }

일단 나중에 사용할 여러 가지를 정의했으니 지나가도록 하겠습니다.

 

 

 

/api/user 라우팅 구현하기

 

NextJS의 라우팅 방법은 pages란 폴더에 있는 파일을 기본으로 하고 있습니다.

 

즉, 우리 주소가 "http://localhost:3000/" 이면 pages/index.js 란 파일에 해당되며,

 

"http://localhost:3000/about" 이면 pages/about.js 란 파일에 해당됩니다.

 

React의 Browser Routing 보다 더 쉽게 라우팅이 가능한데요.

 

단, 여기서 주의해야 할 점은 NextJS에서 라우팅 할 때 Link 모듈은 'next/link' 모듈을 import 해야 합니다.

 

React의 Link를 import 하여 사용하시면 안 됩니다.

 

그럼 "/api/user" 라우팅은 무슨 뜻일까요?

 

NextJS는 pages 폴더 밑에 api라는 특별한 폴더를 사용하고 있는데요.

 

이 api 폴더 밑에 있는 파일이 서버사이드 쪽 루틴을 구현해 줍니다.

 

그래서 우리의 "/api/user"라는 라우팅은 pages/api/user.js 에 해당되며,

 

우리가 브라우저에서 http://localhost:3000/api/user"라고 입력하면 리퀘스트되는 페이지이며, 보통 우리는 여기에서 json data를 리턴하게 됩니다.

 

예를 들어 https://jsonplaceholder.typicode.com/todos/1처럼 REST API를 구현할 수 있게 해 준다는 뜻입니다.

 

이 점이 바로 NextJS의 장점인데요.

 

REST API를 구현하려면 ExpressJS를 이용해서 백엔드에서 서버를 구축해야 하는데 NextJS에서는 간단하게 이를 구현할 수 있습니다.

 

이 점이 바로 최근 각광받고 있는 NextJS의 Serverless App 구현 방식입니다.

 

이 Serverless App 부분에서 MongoDB와 같은 Cloud DB 로직 부분을 작성하면 NextJS앱에서 쉽게 DB를 연결할 수 있는 거죠.

 

설명이 길었는데 실제 코드를 작성해 보겠습니다.

 

pages 폴더 밑에 api란 폴더가 기본으로 만들어져 있을 겁니다.

 

그리고 거기에는 아마 hello.js란 파일이 있는데요.

 

이 hello.js란 파일은 create-next-app이 제공하는 API 라우팅 예제입니다.

 

이 파일은 다음과 같습니다.

 

// Next.js API route support: https://nextjs.org/docs/api-routes/introduction

export default function handler(req, res) {
  res.status(200).json({ name: 'John Doe' })
}

이 파일은 단순하게 Json 형식으로 name을 리턴해 줍니다.

 

브라우저에서 확인해 볼까요?

 

이 예제에서 볼 수 있듯이 NextJS에서 간단하게 REST API를 구현할 수 있는 겁니다.

 

그럼, 우리는 "/api/user"에 필요한 user.js란 파일을 만들어 보도록 하겠습니다.

 

우리는 user라는 API 라우팅을 나중에 확장하기 위해 pages/api 폴더 밑에 user라는 폴더를 만들고 그 밑에 index.js 파일을 만들도록 하겠습니다.

 

/pages/api/user/index.js

import nc from 'next-connect';
import { all } from '@/middlewares/index';

const handler = nc();

handler.use(all);

handler.get(async (req, res) => {
  // Filter out password
  if (!req.user) return res.json({ user: null });
  const { password, ...u } = req.user;
  res.json({ user: u });
});

export default handler;

 

뭔가 코드가 더 어려워졌습니다.

 

쉽진 않으니 천천히 뜯어보도록 하겠습니다.

 

next-connect 모듈은 NextJS에서 api 라우팅을 쉽게 해주는 모듈입니다.

 

당연히 npm install next-connect 명령어로 설치해 줘야 합니다.

 

위 코드에서는 nc라는 이름으로 import 하고 그리고 nc()라고 초기화해서 얻은 next-connect 모듈을 handler란 이름으로 지정했습니다.

 

그리고 handler 객체를 이용해 REST API를 구현하면 됩니다.

 

즉, handler.get 은 GET Method에 해당하고 handler.put은 PUT Method에 해당됩니다.

 

우리는 "http://localhost:3000/api/user"라는 GET Method 이기 때문에 handler.get을 이용했습니다.

 

hander.get 함수를 async 형식으로 지정했는데요.

 

(req, res)를 받아와서 req.user 부분에서 password 부분만 빼고 나머지 부분을 u 에 저장해서 다시 json 형식으로 그 부분을 리턴하는 형식입니다.

 

자바스크립트 디스트럭쳐링 기법인데요. 최신 EMCA Script에서 지원하는 문법입니다.

 

단순하게 DB에서 user 부분을 password 부분만 빼고 json 형태로 리턴해준다는 뜻입니다.

 

그럼, (req, res) 부분은 어디서 처리되는 걸까요?

 

바로 middlewares 부분입니다.

 

handler.use(all); 이란 코드가 바로 미들웨어를 처리하는 코드인데요. 

 

이제 이 미들웨어에 대해 알아보겠습니다.

 

 

 

Middleware 정의

 

/middlewares 폴더 밑에 all.js란 파일을 만들어 봅시다.

 

import nc from 'next-connect';
import passport from 'middlewares/passport';
import database from './database';
import session from './session';

const all = nc();

all.use(database).use(session).use(passport.initialize()).use(passport.session());

export default all;

이 코드를 보시면 next-connect 모듈을 이용해 미들웨어를 설정하고 다시 리턴해주는 형식입니다.

 

여기서 우리가 사용할 미들웨어는 database.js 와 session.js 와 passport.js 파일입니다.

 

그러면 각각 파일에 대해 알아보겠습니다.

 

먼저 database.js입니다.

 

import { MongoClient } from "mongodb";

global.mongo = global.mongo || {};

let indexesCreated = false;
export async function createIndexes(db) {
  await Promise.all([
    db.collection("users").createIndex({ email: 1 }, { unique: true }),
  ]);
  indexesCreated = true;
}

export default async function database(req, res, next) {
  if (!global.mongo.client) {
    global.mongo.client = new MongoClient(process.env.MONGODB_URI, {
      useNewUrlParser: true,
      useUnifiedTopology: true,
    });
    await global.mongo.client.connect();
  }
  req.dbClient = global.mongo.client;
  req.db = global.mongo.client.db(process.env.DB_NAME);
  if (!indexesCreated) await createIndexes(req.db);
  return next();
}

 

database.js 파일은 간단합니다.

 

MongoClinet를 이용해서 MongoDB를 초기화해서 req.db로 넘겨주는 겁니다.

 

넘겨주기 전에 DB의 Collection들이 createIndex가 됐는지 체크해서 안 됐으면 createIndex 하고 넘기는 로직입니다.

 

그러면 왜 Collection("users")를 createIndex 할까요?

 

그거는 바로 query 속도를 높이기 위해서입니다.

 

MongoDB 매뉴얼에서 추천하는 방식이라 해주는 게 좋을 듯싶습니다.

 

그리고 global.mongo를 설정한 거는 Hot Reloading 시에 에러 방지를 위해 사용되는 방식이니 참고 바랍니다.

 

두 번째, session.js 파일입니다.

 

import session from "express-session";
import MongoStore from "connect-mongo";

export default function sessionMiddleware(req, res, next) {
  const mongoStore = MongoStore.create({
    client: req.dbClient,
    stringify: false,
  });
  return session({
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    store: mongoStore,
  })(req, res, next);
}

 

express-session과 connect-mongo를 이용해서 세션을 구현하는 방식인데, 세션 구현 시에 mongoStore를 같이 넘겨주는 형식을 취하고 있습니다.

 

그래서 mongoDB를 세션에서 사용할 수 있게 해주는 코드라서 그냥 그렇다고 넘어가시면 됩니다.

 

그리고 session secret 코드는. env 파일에 적용되어 있습니다. 참고 바랍니다.

 

마지막으로, passport.js 파일입니다.

 

passportJS는 NodeJS에서 REST API 구현에 있어 로그인을 쉽게 해주는 모듈인데요. 일단 코드를 봅시다.

 

import passport from 'passport';
import bcrypt from 'bcryptjs';
import { Strategy as LocalStrategy } from 'passport-local';
import { findUserById, findUserByEmail } from '@/db/index';

passport.serializeUser((user, done) => {
  done(null, user._id);
});

// passport#160
passport.deserializeUser((req, id, done) => {
  findUserById(req.db, id).then((user) => done(null, user), (err) => done(err));
});

passport.use(
  new LocalStrategy(
    { usernameField: 'email', passReqToCallback: true },
    async (req, email, password, done) => {
      const user = await findUserByEmail(req.db, email);
      if (user && (await bcrypt.compare(password, user.password))) done(null, user);
      else done(null, false, { message: 'Email or password is incorrect' });
    },
  ),
);

export default passport;

일단 PassportJS 홈페이지에서 안내하는 코드로 작성했습니다.

 

passportJS를 좀 더 활용해 보려면 홈페이지를 참고 바랍니다.

 

자, 이제 next-connect 모듈의 미들웨어도 모두 정의했습니다.

 

그래서 우리는 npm run dev를 다시 실행해서 개발 서버를 다시 돌려 봅시다.

 

그리고 브라우저에서 "http://localhost:3000/api/user"라고 입력하면 어떻게 될까요?

 

에러가 납니다. 왜냐하면 db 부분도 없고 또 user collection 부분에 data도 없기 때문입니다.

 

그러면 여기서 우리는 user collection에 data를 추가하는 signup 라우팅을 만들어 보도록 하겠습니다.

 

 

 

signup 라우팅

 

먼저 홈페이지 상단(/)을 기억하시나요?

 

여기서 가입하기 버튼을 누르면 라우팅이 "http://localhost:3000/signup"으로 이어집니다.

 

가입하기 페이지라는 얘기죠.

 

그러면 여기에 필요한 signup.js 파일을 pages 폴더 밑에 만들어 볼가요?

 

import React, { useState, useEffect } from "react";
import Head from "next/head";
import Router from "next/router";
import { useCurrentUser } from "@/hooks/index";

const SignupPage = () => {
  const [user, { mutate }] = useCurrentUser();
  const [errorMsg, setErrorMsg] = useState("");
  useEffect(() => {
    // redirect to home if user is authenticated
    if (user) Router.replace("/");
  }, [user]);

  const handleSubmit = async (e) => {
    e.preventDefault();
    const body = {
      email: e.currentTarget.email.value,
      name: e.currentTarget.name.value,
      password: e.currentTarget.password.value,
    };
    const res = await fetch("/api/users", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });
    if (res.status === 201) {
      const userObj = await res.json();
      mutate(userObj);
    } else {
      setErrorMsg(await res.text());
    }
  };

  return (
    <>
      <Head>
        <title>Sign up</title>
      </Head>
      <div className="px-4 py-5 my-5 text-center">
        <h1 className="display-5 fw-bold mb-5">가입하기</h1>
        <div className="col-lg-6 mx-auto">
          <form onSubmit={handleSubmit}>
            {errorMsg ? <p style={{ color: "red" }}>{errorMsg}</p> : null}
            <div className="form-floating mb-2">
              <input
                id="name"
                type="text"
                name="name"
                className="form-control"
                placeholder="이름"
              />
              <label forhtml="name">이름</label>
            </div>
            <div className="form-floating mb-2">
              <input
                id="email"
                name="email"
                type="email"
                className="form-control"
                placeholder="이메일 주소"
              />
              <label forhtml="email">이메일 주소</label>
            </div>
            <div className="form-floating mb-2">
              <input
                id="password"
                name="password"
                type="password"
                className="form-control"
                placeholder="비밀번호"
              />
              <label forhtml="password">비밀번호</label>
            </div>

            <button className="w-100 btn btn-lg btn-primary mb-2" type="submit">
              가입하기
            </button>
          </form>
          <button
            type="button"
            className="w-100 btn btn-lg btn-secondary px-4 gap-3"
            onClick={() => Router.replace("/")}
          >
            홈으로
          </button>
        </div>
      </div>
    </>
  );
};

export default SignupPage;

 

UI 부분은 Bootstrap example login 부분에서 차용했습니다.

 

깔금하고 간단한 가입하기 UI가 되었네요.

 

그러면 코드를 볼까요?

 

API 라우팅이 /signup 이면 pages 폴더의 signup.js 파일이 라우팅됩니다.

 

그리고 이 파일에서 form 컨트롤을 이용해서 POST Method 를 이용하게 되는데요.

 

이때 Reacg 코드가 바로 handleSubmit 함수입니다.

 

이 함수를 보면, body 부분에 email, name, password 을 저장해서 fetch 함수를 이용해 POST 하는데요.

 

이 때 fetch 하는 REST API 주소가 바로 "/api/users" 입니다.

 

우리가 아까전에 "/api/user" 라는 API 라우팅을 구현했는데요.

 

여기서는 "/api/users"라는 주소를 API 라우팅 구현해야 하겠네요.

 

로직은 간단합니다. body 부분을 json 형태로 "/api/users" 라우팅에 넘기고,

 

만약 res (리스폰스)가 201 일 경우 즉, HTTP 결과가 Created 됐다는 뜻입니다.

 

즉, 가입하기 작동이 잘 됐을 경우, 가입한 user 정보를 userObj에 리턴받아서 mutate함수로 현재의 사용자 정보를 업데이트하는 형식입니다.

 

앞쪽에서 살펴보았던 useCurrentUser Hook에서 리턴된 mutate 함수가 여기에서 사용됩니다.

 

res.status 가 error 일 경우 화면에 뿌려주게 됩니다.

 

그리고 useEffect 함수를 이용해서 user 가 있으면 즉, 로그인 되어 있는 상태일 경우 무조건 Router.replace("/")으로 홈으로 이동하게 만들었습니다.

 

즉, 로그인 되어 있는 상태에서는 가입하기가 안된다는 뜻입니다.

 

자, 그러면 이제 "/api/users" 라우팅을 만들어 볼까요?

 

 

/api/users 라우팅

 

pages 폴더 밑에 api 폴더 밑에 users.js 파일을 만듭시다.

 

import nc from 'next-connect';
import isEmail from 'validator/lib/isEmail';
import normalizeEmail from 'validator/lib/normalizeEmail';
import bcrypt from 'bcryptjs';
import { all } from '@/middlewares/index';
import { extractUser } from '@/lib/api-helpers';
import { insertUser, findUserByEmail } from '@/db/index';

const handler = nc();

handler.use(all);

handler.post(async (req, res) => {
  const { name, password } = req.body;
  const email = normalizeEmail(req.body.email);
  if (!isEmail(email)) {
    res.status(400).send('The email you entered is invalid.');
    return;
  }
  if (!password || !name) {
    res.status(400).send('Missing field(s)');
    return;
  }
  if (await findUserByEmail(req.db, email)) {
    res.status(403).send('The email has already been used.');
    return;
  }
  const hashedPassword = await bcrypt.hash(password, 10);
  const userId = await insertUser(req.db, {
    email, password: hashedPassword, name,
  });
  const user = await findUserById(req.db, userId);
  req.logIn(user, (err) => {
    if (err) throw err;
    res.status(201).json({
      user: extractUser(req.user),
    });
  });
});

export default handler;

 

/lib/api-helpers.js

// take only needed user fields to avoid sensitive ones (such as password)
const sensitiveFields = ['password'];
export function extractUser(user) {
  if (!user) return null;
  const obj = {};
  Object.keys(user).forEach((key) => {
    if (!sensitiveFields.includes(key)) obj[key] = user[key];
  });
  return obj;
}

 

원리는 간단합니다.

 

가입하기 페이지에서 넘긴 이메일, 이름, 패스워드를 체크해서 문제가 생겼을 시 res.status(400)을 리턴하고 정상적이면 새로 가입된 user를 extractUser 훅을 이용해 넘기는 방식입니다.

 

여기서 이메일 형식이 제대로 됐는지 체크하기 위해 validator 모듈을 이용했습니다.

 

npm install validator 을 이용해서 꼭 설치해야 합니다.

 

그리고 password는 bcrypt 모듈로 해쉬된 상태로 저장해야 합니다.

 

이 또한 npm install bcryptjs 로 인스톨 해야 합니다.

 

handler.post 하기 전에 hander.use(all) 로 기존에 작성했던 모든 미들웨어를 등록하고 최종적으로 req.logIn() 함수를 이용해 로그인을 수행하게 됩니다.

 

그리고 mongoDB 헬퍼 함수를 이용해서 입력한 이메일이 등록되어 있는지 확인하기도 합니다.

 

여기 helper 파일은 db 폴더에 index.js 파일을 불러오게 됩니다.

 

/db/index.js 파일입니다.

 

export * from './user';

단순하게 user.js 파일을 읽어서 export 해주고 있습니다.

 

나중에 유용한 helper 파일을 추가하기 위해 이 같은 형식을 사용했습니다.

 

우리가 찾는 /db/user.js 파일입니다.

 

import { nanoid } from 'nanoid';
import normalizeEmail from 'validator/lib/normalizeEmail';

export async function findUserById(db, userId) {
  return db.collection('users').findOne({
    _id: userId,
  }).then((user) => user || null);
}

export async function findUserByEmail(db, email) {
  email = normalizeEmail(email);
  return db.collection('users').findOne({
    email,
  }).then((user) => user || null);
}

export async function updateUserById(db, id, update) {
  return db.collection('users').findOneAndUpdate(
    { _id: id },
    { $set: update },
    { returnOriginal: false },
  ).then(({ value }) => value);
}

export async function insertUser(db, {
  email, password, name,
}) {
  return db
    .collection('users')
    .insertOne({
      _id: nanoid(12),
      profilePicture: '',
      email,
      password,
      name,
      admin: false,
    })
    .then(({ insertedId }) => insertedId);
}

이 파일이 직접적으로 mongoDB에 연결하여 작업하는 파일입니다.

 

여러가지가 있는데요.

 

findUserById, findUserByEmail, updateUserById, insertUser 함수 등  각각의 이름으로 그 기능은 충분히 유추할 수 있습니다.

 

그리고 그 로직도 쉽게 이해 할 수 있을 겁니다.

 

/api/users 라우팅에서는 insertUser 부분을 호출했는데요.

 

db.collection('users').insertOne() 함수를 이용했습니다.

 

insertOne() 함수에는 객체가 필요한데, 그 중에서 _id 는 꼭 필요한 항목으로 nanaoid(12)로 12자리 무작위 hash를 넣었습니다.

 

npm install nanoid 해서 설치하는 거 잊지 마시구요.

 

그리고 나중에 확장을 위해서 profilePicture 항목을 추가했습니다.

 

profilePicture 항목은 user 아바타 기능으로 나중에 Cloudinary 서비스를 이용해서 추가할 계획입니다.

 

db의 insertOne이 성공되면 Promise를 리턴하는데 객체로 { acknowledged : boolean, insertedId: string }을 리턴합니다.

 

그래서 insertedId를 얻은 다음 다시 그 insertedId를 이용해 findUserById 함수를 이용해 user 를 리턴하게 됩니다.

 

이제 다시 "/api/users"로 돌아와 보면 insertUser() 함수를 비동기식 실행하고 그 다음으로 req.logIn을 합니다.

 

req.logIn 함수는 passportJS 모듈에서 제공하는 logIn 함수입니다.

 

좀더 쉽게 세션에서 로그인할 수 있게 도와주는 함수죠.

 

그리고 가입이 성공됐을 때 res.status(201)로 넘길때 user 정보에는 extractUser Hook을 이용해서 불필요한 정보는 빼고 리턴하게 했습니다.

 

password같은 불필요한 정보가 전달될 필요는 없으니까요?

 

그럼 여태까지 우리는 signup 까지 구현했고 /api/users 라우팅으로 handler.post 까지 했습니다.

 

테스트 해보면 가입과 동시에 mongoDB Atlas 에 document가 업데이트 되고

 

우리 홈페이지는 다음과 같이 가입된 이름을 나타내게 될겁니다.

 

 

그림과는 다르게 코드에서는 emailVerified 는 삭제했습니다.

test란 이름으로 가입했기 때문에 "test 님 반갑습니다"가 표시 되었네요.

 

가입 로직은 잘 작동 되는거 같습니다.

 

그럼 이제 로그인 로직도 구현해야 겠죠. 아울러 로그아웃 로직도 구현해야 합니다.

 

먼저 홈페이지 index.js 에서 로그인 상태에 따라 로그아웃과 로그인이 따로따로 나타나게 만들어 보겠습니다.

 

먼저 /pages/index.js 를 수정했습니다.

 

import React from "react";
import Link from "next/link";
import { useCurrentUser } from "../hooks/index";

export default function IndexPage() {
  const [user] = useCurrentUser();
  const handleLogout = async () => {
    await fetch("/api/auth", {
      method: "DELETE",
    });
    mutate(null);
  };

  return (
    <div className="px-4 py-5 my-5 text-center">
      <h1 className="display-5 fw-bold">
        {user ? user.name : "stranger"} 님 반갑습니다.
      </h1>
      <div className="col-lg-6 mx-auto">
        <p className="lead mt-4 mb-4 fw-normal">
          NextJS와 MongoDB를 이용한 로그인 세션 구현 샘플입니다.
          <br />
          계정이 있으시면 아래 로그인 버튼을 누르시고,
          <br />
          없으시면 가입하기 버튼을 눌러 계정을 만드십시요.
        </p>
        <div className="d-grid gap-2 d-sm-flex justify-content-sm-center">
          {user ? (
            <button
              type="button"
              className="btn btn-primary btn px-4 gap-3"
              onClick={handleLogout}
            >로그아웃</button>
          ) : (
            <button type="button" className="btn btn-primary btn px-4 gap-3">
              <Link href="/login">로그인</Link>
            </button>
          )}
          <button type="button" className="btn btn-outline-secondary btn px-4">
            <Link href="/signup">가입하기</Link>
          </button>
        </div>
      </div>
    </div>
  );
}

user 상태에 따라 로그아웃과 로그인 button 을 보여주는데요.

 

여기서 중요한 거는 로그아웃이 apt fetch 방식이라는 점입니다.

 

그래서 handleLogout 함수를 실행하면 fetch("/api/auth")를 호출합니다. method는 DELETE로 말입니다.

 

그럼 /pages/api/auth.js 파일을 만들어 볼까요?

 

import nc from 'next-connect';
import { all } from '@/middlewares/index';
import passport from 'middlewares/passport';
import { extractUser } from '@/lib/api-helpers';

const handler = nc();

handler.use(all);

handler.post(passport.authenticate('local'), (req, res) => {
  res.json({ user: extractUser(req.user) });
});

handler.delete((req, res) => {
  req.logOut();
  res.status(204).end();
});

export default handler;

handler의 delete 메써드를 이용해서 req.logOut() 했습니다. 

 

당연히 logOut 함수는 passportJS에서 제공하는 겁니다.

 

이제 홈에서 Logout 버튼을 클릭해 볼까요?

 

다시 홈에는 다음과 같이 나옵니다.

 

 

 

 

로그아웃이 제대로 됐다는 얘기입니다.

 

 

로그인 구현

 

이제 로그인 라우팅인 /login 을 구현해 보도록 하겠습니다.

 

지금까지 따라오셨다면 쉬울겁니다.

 

먼저 pages 폴더밑에 login.js 파일을 만듭니다.

 

import React, { useState, useEffect } from "react";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { useCurrentUser } from "@/hooks/index";

const LoginPage = () => {
  const router = useRouter();
  const [errorMsg, setErrorMsg] = useState("");
  const [user, { mutate }] = useCurrentUser();
  useEffect(() => {
    // redirect to home if user is authenticated
    if (user) router.push("/");
  }, [user]);

  async function onSubmit(e) {
    e.preventDefault();
    const body = {
      email: e.currentTarget.email.value,
      password: e.currentTarget.password.value,
    };
    const res = await fetch("/api/auth", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });
    if (res.status === 200) {
      const userObj = await res.json();
      mutate(userObj);
    } else {
      setErrorMsg("Incorrect username or password. Try again!");
    }
  }

  return (
    <>
      <Head>
        <title>로그인</title>
      </Head>
      <div className="px-4 py-5 my-5 text-center">
        <h1 className="display-5 fw-bold mb-5">로그인</h1>
        <div className="col-lg-6 mx-auto">
          <form onSubmit={onSubmit}>
            {errorMsg ? <p style={{ color: "red" }}>{errorMsg}</p> : null}
            <div className="form-floating mb-2">
              <input
                id="email"
                name="email"
                type="email"
                className="form-control"
                placeholder="이메일 주소"
              />
              <label forhtml="email">이메일 주소</label>
            </div>
            <div className="form-floating mb-2">
              <input
                id="password"
                name="password"
                type="password"
                className="form-control"
                placeholder="비밀번호"
              />
              <label forhtml="password">비밀번호</label>
            </div>

            <button className="w-100 btn btn-lg btn-primary mb-2" type="submit">
              로그인
            </button>
          </form>
          <button
            type="button"
            className="w-100 btn btn-lg btn-secondary px-4 gap-3"
            onClick={() => Router.replace("/")}
          >
            홈으로
          </button>
          <Link href="/forget-password">
            <a>Forget password</a>
          </Link>
        </div>
      </div>
    </>
  );
};

export default LoginPage;

기본적으로 signup.js 랑 비슷합니다.

 

대신 onSubmit 함수에서 /api/auth 로 라우팅하는데 그때 body 부분에 우리가 로그인할 때 필요한 email과 password만 전달하는 방식입니다.

 

지금까지 NextJS와 MongoDB를 이용한 로그인 구현에 대해 알아봤는데요.

 

다음시간에는 토큰을 이용한 유저 정보 업데이트 및 패스워드 변경 등에 대해 알아 보겠습니다.

 

그리고 아래는 pages, middlewares, db, lib, hooks 폴더 아래의 트리 구조입니다. 참고바랍니다.

그리드형