• JWT (Json Web Token)
  • 데이터가 JSON으로 이루어져 있는 토큰

세션 기반 인증 vs 토큰 기반 인증

  • 사용자의 로그인 상태를 서버에서 처리
    • 세션 기반 인증
    • 토큰 기반 인증

세션 기반 인증

  • 서버가 사용자의 로그인 상태를 기억
  • 사용자 > 로그인 > 서버 > 정보저장 > 세션 id 발급 > 요청 > 세션 조회 > 응답
  • 세션 id는 브라우저의 쿠키에 저장
  • 세션 저장소: 메모리, 디스크, 데이터베이스
  • 단점
    • 서버를 확장하기가 어려움
      • 서버의 인스턴스가 여러개가 되면 세션 공유가 필요해 별도의 데이터베이스를 만들어야 한다

토큰 기반 인증

  • 로그인 이후 서버가 만들어주는 문자열
    • 문자열에는 로그인 정보가 들어있고, 해당 정보가 서버에서 발급되었음을 증명하는 서명
  • 사용자 > 로그인 > 서버 > 토큰 발급 > 토큰과 함께 요청 > 토큰 유효성 검사 > 응답
  • 서명 데이터는 해싱 알고리즘을 통해 생성
    • HMAC SHA256, RSA, SHA256 알고리즘
  • 서버에서 만들어주기때문에 무결성이 보장
    • 정보가 변경되거나 위조되지 않음
  • API 요청할때마다 토큰을 함께 요청
  • 장점
    • 로그인 정보를 기억하기 위해 사용하는 리소스가 적다는 것
    • 확장성이 높다. (인스턴스끼리 로그인 상태를 공유할 필요가 없음)
  • 포스트 목록을 볼때 한 페이지에 보이는 포스트의 개수는 적당히 보여줘야 한다.
  • 전체 포스트를 가져온다면 network I/O가 상당할것이다
  • 포스트 내용도 동일하게 전부가 아닌 일부만 보여주는게 I/O를 줄이는데 도움이 될것!
export const list = async ctx => {
  const page = parseInt(ctx.query.page | '1', 10);

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

  try {
    const posts = await Post.find()
      .sort({ _id: -1 })
      .limit(10)
      .skip((page - 1) * 10)
      .exec();
    const postCount = await Post.countDocuments().exec();
    ctx.set('Last-Page', Math.ceil(postCount / 10));
    ctx.body = posts
  • 위에 sort는 최근에 작성한 글을 먼저 오도록
  • limit은 10개의 post만 가져오도록
  • skip은 pagination을 할때 사용
    • skip(10)은 10개 뒤로 이동
  • Last-Page의 값은 Header에 함께 포함

body에 대한 처리

    ctx.body = posts
      .map(post => post.toJSON())
      .map(post => ({
        ...post,
        body:
          post.body.length < 200 ? post.body : `${post.body.slice(0, 200)}...`,
      }));
  • 결과로 가져온 body에서 ㅅ길이를 조절하기 위해서는 다음과 같이 body를 가져와 줄이면 된다.
  • 위 결과는 서버쪽에서 처리하는 내용, db에서도 처리가 가능할까?

ObjectId 검증

  • read, remove, update의 경우 ObjectId 검증이 필요
  • 각각의 함수에 검증 중복 코드 보다는 라우트에 쉽게 적용
  • controller에 아래와 같이 작성
import mongoose from 'mongoose';

const { ObjectId } = mongoose.Types;

export const checkObjectId = (ctx, next) => {
  const { id } = ctx.params;
  if (!ObjectId.isValid(id)) {
    ctx.status = 400; // Bad Request
    return;
  }
  return next();
};

sr/api/posts/index.js

posts.get('/:id', postsCtrl.checkObjectId, postsCtrl.read);
posts.delete('/:id', postsCtrl.checkObjectId, postsCtrl.remove);
posts.patch('/:id', postsCtrl.checkObjectId, postsCtrl.update);

// 위 코드에서 공통적인 router를 아래와 같이 작성 가능
const post = new Router(); // /api/posts/:id
post.get('/', postsCtrl.read);
post.delete('/', postsCtrl.remove);
post.patch('/', postsCtrl.update);

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

Request Body 거증

  • write, update의 요청 내용을 검증
  • title, body, tags 값을 모두 전달 받았는지
  • 라이브러리 Joi
$ yarn add joi
import Joi from 'joi';
(...)
export const write = async ctx => {
  const schema = Joi.object().keys({
    title: Joi.string().required(),
    body: Joi.string().required(),
    tags: Joi.array()
      .items(Joi.string())
      .required(),
  });
  const result = Joi.validate(ctx.request.body, schema);
  if (result.error) {
    ctx.status = 400;
    ctx.body = result.error;
    return;
  }
  • 특정 필드가 반드시 존재하지 않을때는 아래와 같이 required()를 제거하고
export const update = async ctx => {
  const { id } = ctx.params;

  const schema = Joi.object().keys({
    title: Joi.string(),
    body: Joi.string(),
    tags: Joi.array().items(Joi.string()),
  });
  const result = Joi.validate(ctx.request.body, schema);
  if (result.error) {
    ctx.status = 400; // bad request
    ctx.body = result.error;
    return;
  } 
import Post from '../../models/post';

/* 포스트 작성
POST /api/posts
{ title, body }
*/
export const write = async ctx => {
  const { title, body, tags } = ctx.request.body;
  const post = new Post({
    title,
    body,
    tags,
  });
  try {
    await post.save();
    ctx.body = post;
  } catch (e) {
    ctx.throw(500, e);
  }
};

/* 포스트 목록 조회
GET /api/posts
*/
export const list = async ctx => {
  try {
    const posts = await Post.find().exec();
    ctx.body = posts;
  } catch (e) {
    ctx.throw(500, e);
  }
};

/* 특정 포스트 조회
GET /api/posts/:id
*/

export const read = async ctx => {
  const { id } = ctx.params;
  try {
    const post = await Post.findById(id).exec();
    if (!post) {
      ctx.status = 404; // Not Found
      return;
    }
    ctx.body = post;
  } catch (e) {
    ctx.throw(500, e);
  }
};

/* 특정 포스트 제거
DELETE /api/posts/:id
*/
export const remove = async ctx => {
  const { id } = ctx.params;
  try {
    await Post.findByIdAndRemove(id).exec();
    ctx.status = 204; // No Content
  } catch (e) {
    ctx.throw(500, e);
  }
};

/* 포스트 수정 (특정 필드 변경)
PATCH /api/posts/:id
{ title, body }
*/
export const update = async ctx => {
  const { id } = ctx.params;
  try {
    const post = await Post.findByIdAndUpdate(id, ctx.request.body, {
      // false: 업데이트 이전의 데이터 반환
      // true:  업데이트된 데이터를 반환
      new: true,
    }).exec();
    if (!post) {
      ctx.status = 404;
      return;
    }
    ctx.body = post;
  } catch (e) {
    ctx.throw(500, e);
  }
}; 
  • 스키마는 컬렉션에 들어가는 문서 내부의 각 필드
  • 모델은 스키마를 사용하여 만드는 인스턴스
    • 데이터베이스에서 실제 작업을 처리하는 함수들을 지니고 있는 객체

schema와 모델 생성

  • schema와 모델에 관련한 코드는 src/models에서 관리
  • 제목, 내용, 태그, 작성일
  • Schemamongoose의 모듈을 사용
  • 아래와 같이 AuthorSchema의 경우 BookSchema의 일부로도 정의가 가능하다.
import mongoose from 'mongoose';

const { Schema } = mongoose;

const AuthorSchema = new Schema({
  name: String,
  email: String,
});

const BookSchema = new Schema({
  title: String,
  description: String,
  authors: [AuthorSchema],
  meta: {
    likes: Number,
  },
  extra: Schema.Types.Mixed,
});

const PostSchema = new Schema({
  title: String,
  body: String,
  tags: [String],
  publishedDate: {
    type: Date,
    default: Date.now,
  },
});

const Post = mongoose.model('Post', PostSchema); // schema name(=posts), schema obj, <custom collection name>
export default Post;
  • mongodb에서는 model의 파라미터 첫번째로 Post를 넘기는데 컬렉션을 생성할때 @+s로 생성
  • 만약 내가 정의하고 싶은 컬렉션의 이름이 있다면 3번째 파라미터로 custom_collection_name을 넘기면 된다.
  • 자동완성을 통해 모듈을 불러오기
  • ROOT에 있는 jsconfig.json을 아래와 같이 추가하자
{
  "compilerOptions": {
    "target": "es6",
    "module": "es2015"
  },
  "include": ["src/**/*"]
}
  • node.js v12 이후부터는 ES Module이 지원
  • ES Module에서 import/export 문법을 사용 가능
  • node --version으로 버전 확인 (현재 버전은 v12.15.0)
  • package.json에 아래와 같이 추가하면 ES Module을 바로 사용
(...)
  "scripts": {
    "start": "node src esm src",
    "start:dev": "nodemon --watch src/ -r esm src/index.js"
  },
  "type": "module"
$ yarn add esm
exports.write = ctx => {
  const { title, body } = ctx.request.body;
  postId += 1;
  const post = { id: postId, title, body };
  posts.push(post);
  ctx.body = post;
}; 
  • export에 에러가 발생할텐데 .eslintrc.json에 아래와 같이 "sourceType": "module"을 추가
  "parserOptions": {
    "ecmaVersion": 2018,
    "sourceType": "module"
  },
  • 모든 .js의 파일에서 reuiqreimport/export로 변경
import Router from 'koa-router';
import posts from './posts';

const api = new Router();

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

export default api;

mongoose

  • mongoose는 node.js 환경에서 사용하는 라이브러리
  • ODM (Object Data Modeling)

dotenv

  • 환경변수들을 파일에 넣고 사용할 수 있게 하는 개발 도구
  • mongoose를 사용해서 id/password를 스크립트에 작성하지 않고 환경병수로 관리.env
yarn add mongoose dotenv

ROOT에 .env 파일 생성

PORT=4000
MONGO_URI=mongodb://localhost:27017/<database>

src/index.js

require('dotenv').config();
(...)
const { PORT } = process.env;
(...)
const port = PORT || 4000;
app.listen(port, () => {
  console.log('Listening to port %s', port);
});
  • 만약 nodedemon으로 자동 재시작을 하고 있다면, .env의 파일을 변경해도 재시작을 하지 않으니 수동으로 재시작

mongoose로 서버와 데이터베이스 연결


(...) 
const { PORT, MONGO_URI } = process.env;
(...)

 mongoose
  .connect(MONGO_URI, { useNewUrlParser: true, useFindAndModify: false })
  .then(() => {
    console.log('Connected to MongoDB');
  })
  .catch(e => {
    console.error(e);
  });
  • 화면에 Connected to MongoDB가 출력되면 연결 완료.

+ Recent posts