본문 바로가기

Front/React

[React] JWT를 통한 회원 인증 시스템 구현

1. JWT의 이해

  • jwt은 JSON Web Token의 약자로, 데이터가 JSON으로 이루어져 있는 토큰을 의미한다.
  • 두 개체가 서로 안전하게 정보를 주고받을 수 있도록 웹 표준으로 정의된 기술

1.1. 세션 기반 인증과 토큰 기반 인증의 차이

[세션 기반 인증]

  • 서버가 사용자가 로그인 중임을 기억하고 있다는 뜻
  • 서버는 세션 저장소에 사용자의 정보를 조회하고 세션id를 발급한다.
  • 발급된 id는 주로 브라우저의 쿠키에 저장
  • 그 다음 사용자가 다른 요청을 보낼 때마다 서버는 세션 저장소에서 세션을 조회한 후 로그인 여부를 결정하여 작업을 처리하고 응답한다.
  • 세션 저장소는 주로 메모리, 디스크, 데이터베이스 등을 사용한다.
  • 단점
    • 서버를 확장하기 번거로워질 수 있다.
    • 만약 서버의 인스턴스가 여러 개 된다면, 모든 서버끼리 같은 세션을 공유해야 하므로 세션 전용 데이터베이스를 만들어야 할 뿐 아니라 신경써야 할 것도 많다.

[토큰 기반 인증]

  • 토큰은 로그인 이후 서버가 만들어 주는 문자열이다.
  • 해당 문자열 안에는 사용자의 로그인 정보가 들어있고, 해당 정보가 서버에서 발급되었음을 증명하는 서명이 들어있다.
  • 서명 데이터는 해싱 알고리즘을 통해 만들어지는데, 주로 HMAC SHA256 혹인 RSA SHA256알고리즘이 사용된다.

  • 서버에서 만들어 준 토큰은 서명이 있기 때문에 무결성이 보장된다.(무결성이란 : 정보가 변경되거나 위조되지 않았음을 의미하는 성질)
  • 사용자가 로그인을 하면 서버에서 사용자에게 해당 사용자의 정보를 지니고 있는 토큰을 발급해 주고, 추후 사용자가 다른 API를 요청하게 될 때 발급받은 토큰과 함께 요청하게 된다.
  • 그러면 서버는 해당 토큰이 유효한지 검사하고, 결과에 따라 작업을 처리하고 응답한다.
  • 장점
    • 서버에서 사용자 로그인 정보를 기억하기 위해 사용하는 리소스가 적다
    • 사용자 쪽에서 로그인 상태를 지닌 토큰을 가지고 있으므로 서버의 확장성이 매우 높다.
    • 서버의 인스턴스가 여러 개로 늘어나도 서버끼리 사용자의 로그인 상태를 공유하고 있을 필요가 없다.

2. User 스키마/모델 만들기

  • User스키마와 모델을 작성하여 사용자의 정보를 MongoDB에 담고 조회해보자
  • 앞으로 만들 사용자 스키마에는 사용자 계정명과 비밀번호가 필요하다.
  • 비밀번호를 데이터베이스에 저장할 때 플레인 텍스트로 저장하면 보안상 매우 위험하다.
  • 따라서 단방향 해싱 함수를 지원해 주는 bcrypt라는 라이브러리를 사용하여 비밀번호를 안정하게 저장한다.

[src/modules/user.js]

import mongoose, { Schema } from 'mongoose';

const UserSchema = new Schema({
  username: String,
  hashedPassword: String,
});

const User = mongoose.model('User', UserSchema);
export default User;

 

$ yarn add bcrypt

 

2.1. 모델 메서드 만들기

모델에서 사용할 수 있는 함수를 의미하며, 두 가지 종류가 있다.

1. 인스턴스 메서드 : 모델을 통해 만든 문서 인스턴스에서 사용할 수 있는 함수를 의미

const user = new User({username: 'velopert'});
user.setPassword('mypass123');

2. 스태틱 메서드 : 모델에서 바로 사용할 수 있는 함수

const user = User.findByUsername('velopert');

 

  • 두 개의 인스턴스 메서드를 만들어 보겠다.
  • 첫 번째 메서드는 setPassword이다.
    • 비밀번호를 파라미터로 받아서 계정의 hashedPassword값을 설정해 준다.
  • 두 번째 메서드는 checkPassword이다.
    • 파라마터로 받은 비밀번호가 해당 계정의 비밀번호와 일치라는지 검증해준다.

[src/module/user.js]

import mongoose, { Schema } from 'mongoose';
import bcrypt from 'bcrypt';

const UserSchema = new Schema({
  username: String,
  hashedPassword: String,
});

UserSchema.methods.setPassword = async function (password) {
  const hash = await bcrypt.hash(password, 10);
  this.hashedPassword = hash;
};

UserSchema.methods.checkPassword = async function (password) {
  const result = await bcrypt.compare(password, this.hashedPassword);
  return result; // true / false
};

const User = mongoose.model('User', UserSchema);
export default User;
  • 인스턴스 메서드를 작성할 때는 화살표 함수가 아닌 function 키워드를 사용하여 구현해야 한다.
  • 함수 내부에서 this에 접근해야 하기 때문에
  • 여기서 this는 문서 인스턴스를 가리킨다.
  • 화살표 함수를 사용하면 this는 문서 인스턴스를 가리키지 못하게 된다.

 

2.2. 스태틱 메서드 만들기

findByUsername이라는 메서드를 작성함으로써 username으로 데이터를 찾을 수 있게 해준다.

 

[src/module/user.js]

import mongoose, { Schema } from 'mongoose';
import bcrypt from 'bcrypt';

const UserSchema = new Schema({
  username: String,
  hashedPassword: String,
});

UserSchema.methods.setPassword = async function (password) {
  const hash = await bcrypt.hash(password, 10);
  this.hashedPassword = hash;
};

UserSchema.methods.checkPassword = async function (password) {
  const result = await bcrypt.compare(password, this.hashedPassword);
  return result; // true / false
};

UserSchema.statics.findByUsername = function (username) {
  return this.findOne({ username });
};

const User = mongoose.model('User', UserSchema);
export default User;
  • 스태틱 함수에서의 this는 모델을 가리킨다.(User)

3. 회원 인증 API 만들기

새로운 라우트 정의

 

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

export const register = async (ctx) => {
  // 회원가입
};

export const login = async (ctx) => {
  // 로그인
};

export const check = async (ctx) => {
  // 로그인 상태 확인
};

export const logout = async (ctx) => {
  // 로그아웃
};

 

auth 디렉터리에 index.js 파일을 만들어 auth 라우터를 생성한다.

 

[src/api/auth/index.js]

import Router from 'koa-router';
import * as authCtrl from './auth.ctrl';

const auth = new Router();

auth.post('/register', authCtrl.register);
auth.post('/login', authCtrl.login);
auth.get('/check', authCtrl.check);
auth.post('/logout', authCtrl.logout);

export default auth;

 

그 다음 auth 라우터를 api 라우터에 적용시킨다.

 

[src/api/index.js]

import Router from 'koa-router';
import posts from './posts';
import auth from './auth';

const api = new Router();

api.use('/posts', posts.routes());
api.use('/auth', auth.routes());

// 라우터를 내보낸다.
export default api;

 

3.1. 회원가입 구현하기

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

import Joi from '@hapi/joi';
import User from '../../models/user';

/*
    POST /api/auth/register
    {
        username: 'velopert',
        password: 'mypass123'
    }
*/
export const register = async (ctx) => {
  // Request Body 검증하기
  const schema = Joi.object().keys({
    username: Joi.string().alphanum().min(3).max(20).required(),
    password: Joi.string().required(),
  });
  const result = schema.validate(ctx.request.body);
  if (result.error) {
    ctx.status = 400;
    ctx.body = result.error;
  }

  const { username, password } = ctx.request.body;
  try {
    // username이 이미 존재하는지 확인
    const exists = await User.findByUsername(username);
    if (exists) {
      ctx.status = 409; // Conflict
      return;
    }

    const user = new User({
      username,
    });
    await user.setPassword(password); // 비밀번호 설정
    await user.save(); // 데이터베이스에 저장

    // 응답할 데이터에서 hashedPassword 필드 제거
    const data = user.toJSON();
    delete data.hashedPassword;
    ctx.body = data;
  } catch (e) {
    ctx.throw(500, e);
  }
};
  • 회원가입 할 때 중복되는 계정이 생기지 않도록 기존에 해당 username이 존재하는지 확인(findByUsername)
  • 비밀번호를 설정하는 과정에서 setPassword 인스턴스 함수를 사용
  • 스태틱 또는 인스턴스 함수에서 해야 하는 작업들은 이 API 함수 내부에서 직접 구현해도 상관없지만, 이렇게 메서드들을 만들어서 사용하면 가독성도 좋고 추후 유지 보수를 할 때도 도움이 된다.
  • 함수의 마지막 부분에서 hashedPassword필드가 응답되지 않도록 데이터를 JSON으로 변환한 후 delete를 통해 해당 필드를 지워주었다. 앞으로 비슷한 작업을 자주 하게 될 것이다. 따라서 이 작업을 selialize라는 인스턴스 함수로 따로 만들어 주겠다.

[src/modules/user.js] - serialize

UserSchema.methods.serialize = function () {
  const data = this.toJSON();
  delete data.hashedPassword;
  return data;
};

 

이제 기존의 코드를 serialize()로 대체시킨다.

 

[src/api/auth/auth.ctrl.js] - register

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

    const user = new User({
      username,
    });
    await user.setPassword(password); // 비밀번호 설정
    await user.save(); // 데이터베이스에 저장

    // 응답할 데이터에서 hashedPassword 필드 제거
    ctx.body = user.serialize();
  } catch (e) {
    ctx.throw(500, e);
  }
};

 

[결과]

 

 

3.2. 로그인 구현하기

[src/api/auth/auth.ctrl.js] - login

/*
    POST /api/auth/login
    {
        username: 'velopert',
        password: 'mypass123'
    }
*/
export const login = async (ctx) => {
  const { username, password } = ctx.request.body;

  // username, password가 없으면 에러 처리
  if (!username || !password) {
    ctx.status = 401; // Unauthorized
    return;
  }

  try {
    const user = await User.findByUsername(username);
    // 계정이 존재하지 않으면 에러 처리
    if (!user) {
      ctx.status = 401;
      return;
    }
    const valid = await user.checkPassword(password);
    console.log(valid);
    // 잘못된 비밀번호
    if (!valid) {
      ctx.status = 401;
      return;
    }
    ctx.body = user.serialize();
  } catch (e) {
    ctx.throw(500, e);
  }
};
  • 이 API에서는 username, password 값이 제대로 전달되지 않으면 에러로 처리한다.
  • 그리고 findByUsername을 통해 사용자 데이터를 찾고, 만약 사용자 데이터가 없으면 역시 에러로 처리한다.
  • 계정이 유효하다면 checkPassword를 통해 비밀번호를 검사하고 성공했을 때는 계정 정보를 응답한다.

[결과]

 

4. 토큰 발급 및 검증하기

JWT 토큰을 만들기 위해 jsonwebtoken이라는 모듈을 설치해야 한다.

 

$ yarn add jsonwebtoken

4.1. 비밀키 설정

[.env]

PORT=4000
MONGO_URI=mongodb://localhost:27017/blog
JWT_SECRET=qwer1234
  • 설정한 비밀키는 외부에 공개되면 절대 안된다.
  • 비밀키가 공개되는 순간, 누구든지 마음대로 JWT토큰을 발급할 수 있기 때문

4.2. 토큰 발급하기

[src/modules/user.js] - generateToken

import mongoose, { Schema } from 'mongoose';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';

(...)

UserSchema.methods.generateToken = function () {
  const token = jwt.sign(
    // 첫 번째 파라미터에는 토큰 안에 집어넣고 싶은 데이터를 넣는다.
    {
      _id: this.id,
      username: this.username,
    },
    process.env.JWT_SECRET, // 두 번째 파라미터에는 JWT 암호를 넣는다.
    {
      expiresIn: '7d', // 7일 동안 유효함
    },
  );
  return token;
};
  • 회원가입과 로그인에 성공했을 때 토큰을 사용자에게 전달해 준다.
  • 사용자가 브라우저에서 토큰을 사용할 때는 주로 두 가지 방법을 사용한다.
  • 첫 번째 : 브라우저의 localStorage혹은 sessionStorage에 담아서 사용하는 방법
    • 사용하기 매우 편리하고 구현하기 쉽다
    • 하지만 만약 누군가가 페이지에 악성 스크립트를 삽입한다면 쉽게 토큰을 탈취할 수 있다.(XSS공격)
  • 두 번째 : 브라우저의 쿠키에 담아서 사용하는 방법
    • 쿠키에 담아도 같은 문제가 발생할 수 있다.
    • 하지만 httpOnly라는 속성을 활성화하면 자바스크립트를 통해 쿠리를 조회할 수 없으므로 악성 스크립트로부터 안전하다
    • 대신 CSRF라는 공격에 취약해질 수 있다.
    • 이 공격은 토큰을 쿠키에 담으면 사용자가 서버로 요청을 할 때마다 무조건 토큰이 함께 전달되는 점을 이용해서 사용자가 모르게 원하지 않는 API요청을 하게 만든다.
    • 예를들어 사용자가 자신도 모르는 상황에서 어떠한 글을 작성하거나 삭제하거나 또는 탈퇴하게 만들 수 있다.
    • 단, CSRF는 CSRF토큰 사용 및 Refer 검증 등의 방식으로 제대로 막을 수 있는 반면, XSS는 보안장치를 적용해 놓아도 개발자가 놓칠 수 있는 다양한 취약점을 통해 공격을 받을 수 있다.
  • 여기서는 사용자 토큰을 쿠키에 담아서 사용하겠다.

 

[src/api/auth/auth.ctrl.js] - register, login

export const register = async (ctx) => {
  (...)
    ctx.body = user.serialize();

    const token = user.generateToken();
    ctx.cookies.set('access_token', token, {
      maxAge: 1000 * 60 * 60 * 24 * 7, // 7일
      httpOnly: true,
    });
  } catch (e) {
    ctx.throw(500, e);
  }
};

export const login = async (ctx) => {
  (...)
    ctx.body = user.serialize();
    const token = user.generateToken();
    ctx.cookies.set('access_token', token, {
      maxAge: 1000 * 60 * 60 * 24 * 7, // 7일
      httpOnly: true,
    });
  } catch (e) {
    ctx.throw(500, e);
  }
};

 

4.3. 토큰 검증하기

사용자의 토큰을 확인한 후 검증하는 작업을 미들웨어를 통해 처리해본다.

 

[src/lib/jwtMiddleware.js]

import jwt from 'jsonwebtoken';

const jwtMiddleware = (ctx, next) => {
  const token = ctx.cookies.get('access_token');
  if (!token) return next(); // 토큰이 없음
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    console.log(decoded);
    return next();
  } catch (e) {
    // 토큰 검증 실패
    return next();
  }
};

export default jwtMiddleware;
  • 미들웨어를 만든 뒤 main.js에서 app에 미들웨어를 적용해본다.
  • jwtMiddleware를 적용하는 작업은 app에 router미들웨어를 적용하기 전에 이루어져야 한다.(즉, 코드가 더욱 상단에 위치해야한다.)

[src/main.js]

require('dotenv').config();
import Koa from 'koa';
import Router from 'koa-router';
import bodyParser from 'koa-bodyparser';
import mongoose from 'mongoose';

import api from './api';
import jwtMiddleware from './lib/jwtMiddleware';
(...)
const app = new Koa();
const router = new Router();

// 라우터 설정
router.use('/api', api.routes()); // api 라우터 적용

// 라우터 적용 전에 bodyParser 적용
app.use(bodyParser());
app.use(jwtMiddleware);

// app 인스턴스에 라우터 적용
app.use(router.routes()).use(router.allowedMethods());

 

 

 

이렇게 해석된 결과를 이후 미들웨어에서 사용할 수 있게 하려면 ctx의 state안에 넣어 주면 된다.

 

[src/lib/jwtMiddleware.js]

import jwt from 'jsonwebtoken';

const jwtMiddleware = (ctx, next) => {
  const token = ctx.cookies.get('access_token');
  if (!token) return next(); // 토큰이 없음
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    ctx.state.user = {
      _id: decoded._id,
      username: decoded.username,
    };
    console.log(decoded);
    return next();
  } catch (e) {
    // 토큰 검증 실패
    return next();
  }
};

export default jwtMiddleware;

 

이제 check함수를 구현한다.

 

[src/api/auth/auth.ctrl.js] - check

/*
    GET /api/auth/check
*/
export const check = async (ctx) => {
  const { user } = ctx.state;
  if (!user) {
    // 로그인 중 아님
    ctx.status = 401; // Unauthorized
    return;
  }
  ctx.body = user;
};

 

[결과]

 

4.4. 토큰 재발급하기

  • iat : 토큰이 언제 만들어졋는지 알려 주는 값
  • exp : 언제 만료되는지 알려주는 값

exp에 표현된 날짜가 3.5일 미만이라면 토큰을 재발급해 주는 기능을 구현해 보자

 

[src/lib/jwtMiddleware.js]

import jwt from 'jsonwebtoken';
import User from '../models/user';

const jwtMiddleware = async (ctx, next) => {
  const token = ctx.cookies.get('access_token');
  if (!token) return next(); // 토큰이 없음
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    ctx.state.user = {
      _id: decoded._id,
      username: decoded.username,
    };

    // 토큰의 남은 유효기간이 3.5 일 미만이면 재발급
    const now = Math.floor(Date.now() / 1000);
    if (decoded.exp - now < 60 * 60 * 24 * 3.5) {
      const user = await User.findById(decoded._id);
      const token = user.generateToken();
      ctx.cookies.set('access_token', token, {
        maxAge: 1000 * 60 * 60 * 24 * 7, // 7일
        httpOnly: true,
      });
    }
    return next();
  } catch (e) {
    // 토큰 검증 실패
    return next();
  }
};

export default jwtMiddleware;

 

[src/modules/user.js] - generateToken

UserSchema.methods.generateToken = function () {
  const token = jwt.sign(
    // 첫 번째 파라미터에는 토큰 안에 집어넣고 싶은 데이터를 넣는다.
    {
      _id: this.id,
      username: this.username,
    },
    process.env.JWT_SECRET, // 두 번째 파라미터에는 JWT 암호를 넣는다.
    {
      expiresIn: '3d', // 7일 동안 유효함
    },
  );
  return token;
};

 

[결과]

토큰이 재발급된다.

재발급 된것이 확인되면 토큰 유효기간을 다시 7일로 돌린다.

4.5. 로그아웃 기능 구현하기

쿠키를 지워주면 된다. 간단하다.

 

[src/api/auth/auth.ctrl.js] - logout

/*
    POST /api/auth/logout
*/
export const logout = async (ctx) => {
  ctx.cookies.set('access_token');
  ctx.status = 204; // No Content
};

 

[결과]

access_token이 비워지는 Set-Cookie 헤더가 나타난다.

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

5.1. 스키마 수정하기

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

 

[src/models/post.js]

import mongoose from 'mongoose';

const { Schema } = 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;

5.2. posts 컬렉션 비우기

  • 이제 포스트 데이터는 사용자 정보가 필요하다.
  • 이전에 만들어뒀던 데이터를 삭제한다.

5.3. 로그인했을 때만 API를 사용할 수 있게 하기

checkLoggedId이라는 미들웨엉를 만들어서 로그인해야만 글쓰기, 수정, 삭제를 할 수 있도록 구현한다.

 

[src/lib/checkLoggedIn.js]

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 './post.ctrl';
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;

5.4. 포스트 작성 시 사용자 정보 넣기

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

 

[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);
  }
};

 

[결과]

5.5. 포스트 수정 및 삭제 시 권한 확인하기

  • 이 작업을 미들웨어에서 처리하고 싶다면 id로 포스트를 조회하는 작업도 미들웨어로 해 주어야 한다.
  • 따라서 기존에 만들었던 checkObjectId를 getPostById로 바꾸고, 해당 미들웨어에서 id로 포스트를 찾은 후 ctx.state에 담아 주겠다.

[src/api/posts/posts.ctrl.js] - getPostById (기존 checkObjectId)

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.state = 404; // Not Found
      return;
    }
    ctx.state.post = post;
    return next();
  } catch (e) {
    ctx.throw(500, e);
  }
};

 

[src/api/posts/index.js]

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

export default posts;

 

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

export const read = async (ctx) => {
  ctx.body = ctx.state.post;
};

 

이번에는 checkOwnPost라는 미들웨어를 만든다.

이 미들웨어는 id로 찾은 포스트가 로그인 중인 사용자가 작성한 포스트인지 확인해준다.

만약 사용자의 포스트가 아니라면 403에러를 발생시킨다.

 

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

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 './post.ctrl';
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;

 

새로운 계정을 만든 후 그 계정을 사용하여 다른 계정으로 작성된 포스트를 삭제해본다.

 

[결과]

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

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

먼저 조금전에 만든 계정으로 포스트를 작성한다.

포스트 목록 조회 API를 다음과 같이 수정한다.

 

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

/*
    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()
      .sort({ _id: -1 })
      .limit(10)
      .skip((page - 1) * 10)
      .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);
  }
};

 

const query = {

    ...(username ? { 'user.username': username } : {}),

    ...(tag ? { tags: tag } : {}),

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

이런 객체를 query로 사용한다면 요청을 받을 때 username이나 tag 값이 주어지지 않는다.

이 경우에는 undefined 값이 들어가게 된다.

mongoose는 특정 필드가 undefined인 데이터를 찾게 되고, 결국 데이터를 조회할 수 없다.