관리 메뉴

거니의 velog

(25) JWT를 통한 회원 인증 시스템 구현하기 5 본문

React_백엔드 프로그래밍

(25) JWT를 통한 회원 인증 시스템 구현하기 5

Unlimited00 2024. 2. 21. 16:48

5. posts API에 회원 인증 시스템 도입하기

* 이번에는 기존에 구현했던 posts API에 회원 인증 시스템을 도입해 보겠다. 새 포스트는 이제 로그인해야만 작성할 수 있고, 삭제와 수정은 작성자만 할 수 있도록 구현할 것이다.

* 각각의 함수를 직접 수정해서 이 기능을 구현해도 되지만, 여기서는 미들웨어를 만들어서 관리해 보겠다. 또한, 각 포스트를 어떤 사용자가 작성했는지 알아야 하기 때문에 기존의 Post 스키마를 수정해 주어야 한다.


(1) 스키마 수정하기

* 스키마에 사용자 정보를 넣어 주자. 보통 MariaDB, PostgreSQL 같은 관계형 데이터베이스에서는 데이터의 id만 관계 있는 데이터에 넣어 주는 반면, MongoDB에서는 필요한 데이터를 통째로 집어 넣는다.

* 여기서는 Post 스키마 안에 사용자의 id와 username을 전부 넣어 주어야 한다.

* post 모델 파일을 열어서 다음과 같이 수정해 주자.

[src/models/post.js]

import mongoose, { Schema } from 'mongoose';

const PostSchema = new Schema({
  title: String,
  body: String,
  tags: [String], // 문자열로 이루어진 배열
  publishedDate: {
    type: Date,
    default: Date.now, // 현재 날짜를 기본 값으로 지정
  },
  user: {
    _id: mongoose.Types.ObjectId,
    username: String,
  },
});

const Post = mongoose.model('Post', PostSchema);
export default Post;

(2) posts 컬렉션 비우기

* 이제 포스트 데이터에는 사용자 정보가 필요하다. 우리가 이전에 생성한 데이터들은 더 이상 유효하지 않으므로 모두 삭제해 주자. Compass를 열어서 좌측 컬렉션 리스트를 보면 posts 컬렉션이 있다. 오른쪽의 휴지통 아이콘을 누르자. 컬렉션을 삭제하려면 컬렉션 이름을 한번 더 입력해야 한다.

posts 컬렉션 비우기


(3) 로그인했을 때만 API를 사용할 수 있게 하기

* checkLoggedIn이라는 미들웨어를 만들어서 로그인해야만 글쓰기, 수정, 삭제를 할 수 있도록 구현해 보자.

* lib 디렉터리에 checkLoggedIn.js 파일을 생성하고 다음 미들웨어를 작성해 보자.

* 이 미들웨어를 lib 디렉터리에 저장하는 이유는 다른 라우트에서도 사용될 가능성이 있기 때문이다. 물론 이 프로젝트에서 auth를 제외한 라우트는 posts가 유일하기 때문에 auth.ctrl.js에서 구현해도 상관 없지만, 로그인 상태 확인 작업은 자주 사용하는 기능이므로 더 쉽게 재사용할 수 있도록 lib 디렉터리에 작성하는 것이다.

const checkLoggedIn = (ctx, next) => {
  if (!ctx.state.user) {
    ctx.status = 401; // Unauthorized
    return;
  }
  return next();
};

export default checkLoggedIn;

* 정말 짧고 간단하다. 이 미들웨어는 로그인 상태가 아니라면 401 HTTP Status를 반환하고, 그렇지 않으면 그다음 미들웨어들을 실행한다.

* 이제 이 미들웨어를 posts 라우터에서 사용해 보자.

[src/api/posts/index.js]

import Router from 'koa-router';
import * as postsCtrl from './posts.ctrl.js';
import checkLoggedIn from '../../lib/checkLoggedIn';

const posts = new Router();

posts.get('/', postsCtrl.list);
posts.post('/', checkLoggedIn, postsCtrl.write);

const post = new Router(); // /api/posts/:id
post.get('/', postsCtrl.read);
post.delete('/', checkLoggedIn, postsCtrl.remove);
post.patch('/', checkLoggedIn, postsCtrl.update);

posts.use('/:id', postsCtrl.checkObjectId, post.routes());

export default posts;

* 어떤가? 미들웨어를 만드니 로직을 재사용하기 참 편리해 졌다.


(4) 포스트 작성 시 사용자 정보 넣기

* 로그인된 사용자만 포스트를 작성할 수 있게 했으니, 지금부터는 포스트를 작성할 때 사용자 정보를 넣어서 데이터베이스에 저장하도록 구현해 보겠다.

* posts.ctrl.js의 write 함수를 다음과 같이 수정해 보자.

[src/api/posts/posts.ctrl.js]

export const write = async (ctx) => {
  (...)

  const { title, body, tags } = ctx.request.body;
  const post = new Post({
    title,
    body,
    tags,
    user: ctx.state.user,
  });
  try {
    await post.save();
    ctx.body = post;
  } catch (e) {
    ctx.throw(500, e);
  }
};

* 이제 Postman으로 포스트 작성 API를 요청해 보자. 조금 전에 로그아웃 했다면, 다시 로그인하고 API를 요청하면 된다.

* 다음과 같이 API를 요청했을 때,

http://localhost:4000/api/posts

{
    "title" : "제목",
    "body" : "내용",
    "tags" : ["태그1", "태그2"]
}

* 이렇게 사용자 정보가 들어간 상태로 잘 등록되는지 응답을 확인해 보자.

{
    "tags": [
        "태그1",
        "태그2"
    ],
    "_id": "65d5b2182060101e88f5a80e",
    "title": "제목",
    "body": "내용",
    "user": {
        "_id": "65d59292f176c733b8038e00",
        "username": "bbbb7788"
    },
    "publishedDate": "2024-02-21T08:19:36.267Z",
    "__v": 0
}

(5) 포스트 수정 및 삭제 시 권한 확인하기

* 마지막으로 작성자만 포스트를 수정하거나 삭제할 수 있도록 구현해 보자. 이 작업을 미들웨어에서 처리하고 싶다면 id로 포스트를 조회하는 작업도 미들웨어로 해 주어야 한다. 따라서 기존에 만들었던 checkObjectId를 getPostById로 바꾸고, 해당 미들웨어에서 id로 포스트를 찾은 후 ctx.state에 담아 줄 것이다.

[src/api/posts/posts.ctrl.js] => 기존 checkObjectId 함수명을 getPostById로 수정

export const getPostById = async (ctx, next) => {
  const { id } = ctx.params;
  if (!ObjectId.isValid(id)) {
    ctx.status = 400; // Bad Request
    return;
  }
  try {
    const post = await Post.findById(id);
    // 포스트가 존재하지 않을 때
    if (!post) {
      ctx.status = 404; // Not Found
      return;
    }
    ctx.state.post = post;
    return next();
  } catch (e) {
    ctx.throw(500, e);
  }
};

* 미들웨어 이름과 코드를 수정한 뒤 posts 라우터에도 반영해 주자.

[src/api/posts/index.js]

(...)

posts.use('/:id', postsCtrl.getPostById, post.routes());

export default posts;

* 그 다음에는 read 함수 내부에서 id로 포스트를 찾는 코드를 간소화해 준다.

[src/api/posts/posts.ctrl.js]

/*
  GET /api/posts/:id
*/
export const read = async (ctx) => {
  ctx.body = ctx.state.post;
};

* 코드가 정말 짧아졌다.

* getPostById를 구현하고 적용했다면 이번에는 checkOwnPost라는 미들웨어를 만든다. 이 미들웨어는 id로 찾은 포스트가 로그인 중인 사용자가 작성한 포스트인지 확인해 준다. 만약 사용자의 포스트가 아니라면 403에러를 발생시킨다.

[src/api/posts/posts.ctrl.js]

export const checkOwnPost = (ctx, next) => {
  const { user, post } = ctx.state;
  if (post.user._id.toString() !== user._id) {
    ctx.status = 403;
    return;
  }
  return next();
};

* MongoDB에서 조회한 데이터와 id 값을 문자열과 비교할 때는 반드시 .toString()을 해 주어야 한다.

* 이어서 이 미들웨어를 수정 및 삭제 API에 적용하자. checkLoggedIn 다음 미들웨어로 등록해 주어야 한다.

[src/api/posts/index.js]

import Router from 'koa-router';
import * as postsCtrl from './posts.ctrl.js';
import checkLoggedIn from '../../lib/checkLoggedIn';

const posts = new Router();

posts.get('/', postsCtrl.list);
posts.post('/', checkLoggedIn, postsCtrl.write);

const post = new Router(); // /api/posts/:id
post.get('/', postsCtrl.read);
post.delete('/', checkLoggedIn, postsCtrl.checkOwnPost, postsCtrl.remove);
post.patch('/', checkLoggedIn, postsCtrl.checkOwnPost, postsCtrl.update);

posts.use('/:id', postsCtrl.getPostById, post.routes());

export default posts;

* 이제 새로운 계정을 만든 다음, 그 계정을 사용하여 다른 계정으로 작성된 포스트를 삭제해 보자. 회원가입할 때 계정 정보는 마음대로 입력해도 된다.

* 403 Forbidden 에러가 잘 나타났는가?

* 이제 posts API에 회원 인증 시스템을 도입하는 과정을 모두 마쳤다!


6. username/tags로 포스트 필터링하기

* 이번에는 특정 사용자가 작성한 포스트만 조회하거나 특정 태그만 있는 포스트만 조회하는 기능을 만들어 보자.

* 먼저 조금 전에 새로 만든 계정으로 포스트를 작성한다. GET /api/posts에 요청을 해서 두 명의 사용자가 쓴 포스트가 있는지 확인한 뒤, 포스트 목록 조회 API를 다음과 같이 수정하자.

[src/api/posts/posts.ctrl.js]

/*
  GET /api/posts
*/
export const list = async (ctx) => {
  // query 는 문자열이기 때문에 숫자로 변환해주어야합니다.
  // 값이 주어지지 않았다면 1 을 기본으로 사용합니다.
  const page = parseInt(ctx.query.page || '1', 10);

  if (page < 1) {
    ctx.status = 400;
    return;
  }

  const { tag, username } = ctx.query;
  // tag, username 값이 유효하면 객체 안에 넣고, 그렇지 않으면 넣지 않음
  const query = {
    ...(username ? { 'user.username': username } : {}),
    ...(tag ? { tags: tag } : {}),
  };

  try {
    const posts = await Post.find(query)
      .sort({ _id: -1 })
      .limit(10)
      .skip((page - 1) * 10)
      .lean()
      .exec();
    const postCount = await Post.countDocuments(query).exec();
    ctx.set('Last-Page', Math.ceil(postCount / 10));
    ctx.body = posts.map((post) => ({
      ...post,
      body:
        post.body.length < 200 ? post.body : `${post.body.slice(0, 200)}...`,
    }));
  } catch (e) {
    ctx.throw(500, e);
  }
};

* 위 코드에서 query를 선언하는 방법이 조금 생소할 것이다.

const query = {
    ...(username ? { 'user.username': username } : {}),
    ...(tag ? { tags: tag } : {}),
};

* 이 코드는 username 혹은 tag 값이 유효할 때만 객체 안에 해당 값을 넣겠다는 것을 의미한다. 다음과 같은 형식으로 query 객체를 만들면 어떨까?

{
    username,
    tags: tag
}

* 이런 객체를 query로 사용한다면 요청을 받을 때 username이나 tag 값이 주어지지 않는다. 이 경우에는 undefined 값이 들어가게 된다. mongoose는 특정 필드가 undefined인 데이터를 찾게 되고, 결국 데이터를 조회할 수 없게 된다.

* 코드를 다 작성했으면 다음과 같이 username, tag 쿼리 파라미터를 URL에 포함시켜서 요청을 해 보자.

GET http://localhost:4000/api/posts?username=bbbb1234

GET http://localhost:4000/api/posts?tag=태그

* username과 tag에는 여러분이 테스트용으로 작성한 포스트에서 사용하는 값을 넣으면 된다.


7. 정리

* 이 장에서는 회원 인증 시스템을 구현하는 방법을 알아보고, 기존의 포스트 관련 API에 회원 인증 시스템을 도입했다. 도입하는 과정에서 반복되는 코드는 대부분 미들웨어로 처리해 주었는데, 앞으로 Koa를 통해 백엔드 개발을 할 때는 이렇게 미들웨어를 자주 만들어 가면서 개발하는 방법을 추천한다. 이로써 코드의 가독성과 재사용성이 모두 높아져서 유지 보수가 쉬워질 것이다.

* 다음 장에서는 이번에 만든 서버를 기반으로 리액트를 사용하여 블로그 웹 애플리케이션을 개발해 보면서 실제 프로젝트 개발 흐름을 학습해 보겠다.