본문 바로가기

Front/React

[React] mongoose를 이용한 MongoDB 연동 실습2

6. MongoDB Compass의 설치 및 사용

  • MongoDB Compass는 MongoDB를 위한 GUI 프로그램. 데이터베이스를 쉽게 조회하고 수정할 수 있다.
  • Window의 경우 MongoDB설치할 때 자동 설치

7. 데이터 생성과 조회

7.1. 데이터 생성

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

import Post from '../../models/post';

export const write = ctx => {};

export const list = ctx => {};

export const read = ctx => {};

export const remove = ctx => {};

export const update = ctx => {};
  • 기존 PUT 메서드에 연결했던 replace는 구현하지 않을 것이므로 src/api/posts/index.js 에서 제거하세요

[src/api/posts/index.js]

import Router from 'koa-router';
import * as postCtrl from './post.ctrl';

const posts = new Router();

posts.get('/', postCtrl.list);
posts.post('/', postCtrl.write);
posts.get('/:id', postCtrl.read);
posts.delete('/:id', postCtrl.remove);
posts.patch('/:id', postCtrl.update);

export default posts;

 

먼저 블로그 포스트를 작성하는 API의 write를 구현한다.

 

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

import Post from '../../models/post';

/*
    POST /api/posts
    {
        title: '제목',
        body: '내용',
        tags: ['태그1', '태그2]
    }
*/

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);
  }
};
  • 포스트의 인스턴스를 만들 때는 new 키워들르 사용한다.
  • 그리고생성자 함수의 파라미터에 정보를 지닌 객체를 널는다.
  • save() 함수를 실행시켜야 비로소 데이터베이스에 저장된다.
  • 이 함수의 반환값은 Promise이므로 async/await 문법으로 데이터베이스 저장 요청을 완료할 때까지 await를 사용하여 대기할 수 있다.

 

[결과]

 

 

7.2. 데이터 조회

조회시에는 모델 인스턴스의 find() 함수를 사용한다.

 

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

/*
    GET /api/posts
*/
export const list = async (ctx) => {
  try {
    const posts = await Post.find().exec();
    ctx.body = posts;
  } catch (e) {
    ctx.throw(500, e);
  }
};
  • find() 함수를 호출한 후에는 exec()를 붙여 주어야 서버에 쿼리를 요청한다.
  • 데이터를 조회할 때 특정 조건을 설정하고, 불러오는 제한도 설정할 수 있는데 이 부분은 추후 페이지네이션 기능을 구현할 때 알아보자
  • 서버를 재시작한 뒤 Postman으로 요청을 보내보자

 

[결과]

 

7.3. 특정 포스트 조회

  • read 함수를 통해 특정 포스트를 id로 찾아서 조회하는 기능을 구현해보자
  • 특정 id를 가진 데이터를 조회할 때는 findById() 함수를 사용한다.

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

/*
    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);
  }
};

 

[결과]

 

 

8. 데이터 삭제와 수정

8.1. 데이터 삭제

  • 데이터를 삭제할 때는 여러 종류의 함수를 사용할 수 있다.
    • remove() : 특정 조건을 만족하는 데이터를 모두 지운다.
    • findByIdAndRemove(): id를 찾아서 지운다.
    • fingOneAndRemove(): 특정 조건을 만족하는 데이터 하나를 찾아서 제거한다.

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

/*
    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);
  }
};

 

[결과]

 

 

8.2. 데이터 수정

데이터를 업데이트할 때는 findByIdAndUodate() 함수를 사용한다.

이 함수를 사용할 때는 세 가지 파라미터를 넣어 주어야 한다.

첫번째 : id, 두번째 : 업데이트 내용, 세번째: 업데이트 옵션

 

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

/*
    PATCH /api/posts/:id
    {
        title: '수정',
        body: '수정 내용',
        tags: ['수정', '태그']
    }
*/
export const update = async (ctx) => {
  const { id } = ctx.params;
  try {
    const post = await Post.findByIdAndUpdate(id, ctx.request.body, {
      new: true, // 이 값을 설정하면 업데이트된 데이터를 반환한다.
      // false일 때는 업데이트되기 전의 데이터를 반환한다.
    }).exec();
    if (!post) {
      ctx.status = 404;
      return;
    }
    ctx.body = post;
  } catch (e) {
    ctx.throw(500, e);
  }
};

 

[결과]

 

9. 요청 검증

9.1. ObjectId 검증

  • 앞서 read API를 실행할 때, id가 올바른 ObjectId 형식이 아니면 500 오류가 발생했다.
  • 500 오류는 보통 서버에서 처리하지 않아 내부적으로 문제가 생겼을 때 발생한다.
  • 잘못된 id를 전달했다면 클라이언트가 요청을 잘못 보낸 것이니 400 Bad Request 오류를 띄워주는 것이 맞다.
  • 그럴려면 id 값이 올바른 ObjectId인지 확인해야 한다.
  • 이를 검증하는 방법은 다음과 같다.
import mongoose from 'mongoose';

const {ObjectId} = mongoose.Types;
ObjectId.isValid(id);
  • 지금 ObjectId를 검증해야 하는 API는 read, remove, update 이렇게 세 가지이다.
  • 모든 함수에서 이를 검증하기 위해 검증 코드를 각 함수 내부에 일일이 값입한다면 똑같은 코드가 중복된다.
  • 코드를 중복해 넣지 않고, 한 번만 구현한 다음 여러 라우트에 쉽게 적용하는 방법이 있다.
  • 바로 미들웨어를 만드는 것이다.
  • posts.ctrl.js의 코드 상단에 미들웨어를 작성해준다.

 

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

import Post from '../../models/post';
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();
};
(...)

 

그리고 src/api/posts/index.js 에서 ObjectId검증이 필요한 부분에 방금 만든 미들웨어를 추가한다.

 

[src/api/posts/index.js]

import Router from 'koa-router';
import * as postCtrl from './post.ctrl';

const posts = new Router();

posts.get('/', postCtrl.list);
posts.post('/', postCtrl.write);
posts.get('/:id', postCtrl.checkObjectId, postCtrl.read);
posts.delete('/:id', postCtrl.checkObjectId, postCtrl.remove);
posts.patch('/:id', postCtrl.checkObjectId, postCtrl.update);

export default posts;

 

이것을 한 번 더 리팩토링하면 다음과 같다.

 

[src/api/posts/index.js]

import Router from 'koa-router';
import * as postsCtrl from './post.ctrl';

const posts = new Router();

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

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());

export default posts;
  • /api/posts/:id 경로를 위한 라우터를 새로 만들고, posts에 해당 라우터를 등록해 주었다.
  • 이렇게 하면 중복되는 코드가 별로 없어서 깔끔하지만, 라우트 경로들이 한눈에 들어오지 않으므로 취향에 따라서는 불편하게 느낄 수도 있다.

 

[결과]

 

9.2. Request Body 검증

  • 이제 write, update API에서 전달받은 요청 내용을 검증하는 방법을 알아보겠다.
  • 포스트를 작성할 때 서버는 title, body, tags 값을 모두 전달받아야 한다.
  • 그리고 클라이언트가 값을 빼먹었을 때는 400오류가 발생해야한다.
  • 지금은 따로 처리하지 않았기 때문에 요청 내용을 비운 상태에서 write API를 실행하도 요청이 성공하여 비어있는 포스트가 등록된다.
  • 객체를 검증하기 위해 각 값을 if문으로 비교하는 방법도 있지만, 여기서는 이를 수월하게 해 주는 라이브러리인 Joi를 설치하여 사용하겠다.

 

$ yarn add @hapi/joi

 

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

import Post from '../../models/post';
import mongoose from 'mongoose';
import Joi from '@hapi/joi';

(...)

export const write = async (ctx) => {
  const schema = Joi.object().keys({
    // 객체가 다음 필드를 가지고 있음을 검증
    title: Joi.string().required(), // required()가 있으면 필수 항목
    body: Joi.string().required(),
    tags: Joi.array().items(Joi.string()).required(), // 문자열로 이루어진 배열
  });

  // 검증결과 실패인 경우 에러 처리
  const result = schema.validate(ctx.request.body);
  if (result.error) {
    ctx.status = 400; // Bad Request
    ctx.body = result.error;
    return;
  }

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

(...)

write API를 호출할 때 Request Body에 필요한 필드가 빠져 있다면 400오류를 응답한다.

응답 내용에 에러를 함께 반환한다.

 

[결과]

 

update API도 마찬가지로 Joi를 사용하여 검증한다.

write에서 한 것과 비슷하지만, 여기서는 .required()가 없다.

 

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

/*
    PATCH /api/posts/:id
    {
        title: '수정',
        body: '수정 내용',
        tags: ['수정', '태그']
    }
*/
export const update = async (ctx) => {
  const { id } = ctx.params;
  // write에서 사용한 schema와 비슷한데, required()가 없습니다.
  const schema = Joi.object().keys({
    title: Joi.string(),
    body: Joi.string(),
    tags: Joi.array().items(Joi.string()),
  });

  // 검증하고 나서 검증 실패인 경우 에러 처리
  const result = schema.validate(ctx.request.body);
  if (result.error) {
    ctx.status = 400; // Bad Request
    ctx.body = result.error;
    return;
  }

  try {
    const post = await Post.findByIdAndUpdate(id, ctx.request.body, {
      new: true, // 이 값을 설정하면 업데이트된 데이터를 반환한다.
      // false일 때는 업데이트되기 전의 데이터를 반환한다.
    }).exec();
    if (!post) {
      ctx.status = 404;
      return;
    }
    ctx.body = post;
  } catch (e) {
    ctx.throw(500, e);
  }
};

 

[결과]

10. 페이지네이션 구현

10.1. 가짜 데이터 생성하기

  • 페이지네이션 기능을 구현하려면 우선 데이터가 충분히 있어야한다.
  • 편하게 데이터를 채우기 위해 가짜 데이터를 생성하는 스크립트를 작성해보자

 

[src/createFakeData.js]

import Post from './models/post';

export default function createFakeData() {
  // 0, 1, ... 39 로 이루어진 배열 생성 후 포스트 데이터로 변환
  const posts = [...Array(40).keys()].map((i) => ({
    title: `포스트 #${i}`,
    // https://www.lipsum.com/ 에서 복사한 200자 이상 텍스트
    body:
      'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
    tags: ['가짜', '데이터'],
  }));
  Post.insertMany(posts, (err, docs) => {
    console.log(docs);
  });
}

 

main.js에서 방금 만든 함수를 불러와 한번 호출한다.

 

[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 createFakeData from './createFakeData';

// 비구조화 할당을 통해 process.env 내부 값에 대한 레퍼런스 만들기
const { PORT, MONGO_URI } = process.env;

mongoose
  .connect(MONGO_URI, { useNewUrlParser: true, useFindAndModify: false })
  .then(() => {
    console.log('Connect to MongoDB');
    createFakeData();
  })
  .catch((e) => {
    console.error(e);
  });
  (...)

 

 

[결과]

  • 데이터가 잘 등록된 것을 확인했으면 createFakeData를 호출하는 코드를 main.js에서 지워준다.

10.2. 포스트 역순으로 불러오기

  • 블로그를 방문한 사람에게 가장 최근 작성된 포스트를 먼저 보여줘야 한다.
  • 이를 구현하려면 list API에서 exec()를 하기 전에 sort()구문을 넣으면 된다.
  • sort 함수의 파라미터는 {key : 1} 형식으로 넣는다.
  • key는 정렬할 필드를 설정하는 부분이며, 오른쪽 값을 1로 설정하면 오름차순, -1로 설정하면 내림차순이다.

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

export const list = async (ctx) => {
  try {
    const posts = await Post.find()
    .sort({_id: -1})
    .exec();
    ctx.body = posts;
  } catch (e) {
    ctx.throw(500, e);
  }
};

 

[결과]

10.3. 보이는 개수 제한

제한할 때는 limit() 함수를 사용하고, 파라미터에 제한할 숫자를 넣으면 된다.

예를들어 열 개로 제한한다면 limit(10)이라고 입력한다.

 

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

export const list = async (ctx) => {
  try {
    const posts = await Post.find().sort({ _id: -1 }).limit(10).exec();
    ctx.body = posts;
  } catch (e) {
    ctx.throw(500, e);
  }
};

10.4. 페이지 기능 구현

  • 페이지 기능을 구현하려면 앞 절에서 배운 limit 함수를 사요해야하고, 추가로 skip 함수도 사용해야한다.
  • skpi함수에 파라미터로 10을 넣어주면, 처음 10갸를 제외하고 그다음 데이터를 불러온다.
  • 20을 넣어주면 처음 20개를 제외하고 그다음 데이터 10개를 불러온다.
  • skip함수의 파라미터에는 (page - 1) * 10을 넣어 주면 된다.

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

export const list = async (ctx) => {
  // query는 문자열이기 때문에 숫자로 변환해 주어야 한다.
  // 값이 주어지지 않았다면 1을 기본으로 사용한다.
  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();
    ctx.body = posts;
  } catch (e) {
    ctx.throw(500, e);
  }
};

 

[결과]

10.5. 마지막 페이지 번호 알려주기

  • 마지막 페이지를 알 수 있다면 클라이언트가 더욱 편하다.
  • 응답 내용의 형식을 바꾸어 새로운 필드를 설정하는 방법, Response헤더 중 Link를 설정하는 방법, 커스텀 헤더를 설정하는 방법으로 이 정보를 알려줄 수 있다.
  • 우리는 커스텀 헤더를 설정하는 방법을 사용하겠다.

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

export const list = async (ctx) => {
  // query는 문자열이기 때문에 숫자로 변환해 주어야 한다.
  // 값이 주어지지 않았다면 1을 기본으로 사용한다.
  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;
  } catch (e) {
    ctx.throw(500, e);
  }
};

 

[결과]

10.6. 내용 길이 제한

  • body의 길이가 200자 이상이면 뒤에 ...을 붙히고 문자열을 자르는 기능을 구현하겠다.
  • find() 를 통해 조회한 데이터는 mongoose 문서 인스턴스 형태이므로 데이터를 바로 변형할 수 없다.
  • 그 대신 toJSON() 함수를 실행하여 JSON 형태로 변환한 뒤 필요한 변형을 일으켜 주어야 한다.

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

export const list = async (ctx) => {
  // query는 문자열이기 때문에 숫자로 변환해 주어야 한다.
  // 값이 주어지지 않았다면 1을 기본으로 사용한다.
  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
      .map((post) => post.toJSON())
      .map((post) => ({
        ...post,
        body:
          post.body.length < 200 ? post.body : `${post.body.slice(0, 200)}...`,
      }));
  } catch (e) {
    ctx.throw(500, e);
  }
};

 

또 다른 방법으로 데이터를 조회할 때 lean() 함수를 사용하는 방법이 있다.

이 함수를 사용하면 데이터를 처음부터 JSON 형태로 조회할 수 있다.

 

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

export const list = async (ctx) => {
  // query는 문자열이기 때문에 숫자로 변환해 주어야 한다.
  // 값이 주어지지 않았다면 1을 기본으로 사용한다.
  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.map((post) => ({
      ...post,
      body:
        post.body.length < 200 ? post.body : `${post.body.slice(0, 200)}...`,
    }));
  } catch (e) {
    ctx.throw(500, e);
  }
};

 

[결과]