본문 바로가기

react

[리액트를 다루는 기술] 22장 mongoose를 이용한 MongoDB 연동

22.1 MongoDB 소개 

 

1) 관계형 데이터베이스의 한계

 

- 데이터 스키마가 고정적

- 확장성 

  -> RDBMS는 저장하고 처리해야 할 데이터양이 늘어나면 여러 컴퓨터에 분산시키는 것이 아니라, 해당 데이터베이스 서버의 성능을 업그레이드하는 방식으로 확장해야 함

 

2) MongoDB

 

- 문서 지향적 NoSQL 데이터베이스 

- MongoDB에 등록하는 데이터들은 유동적인 스키마를 지닐 수 있음

- 데이터의 구조가 자주 바뀐다면 MongoDB가 유리 

 

 

22.1.1 문서란?

 

문서(document)

 

- RDBMS의 레코드(record)와 비슷

- 문서의 데이터 구조는 한 개 이상의 키-값 쌍으로 되어 있음 

- 문서는 BSON(바이너리 형태의 JSON)형태로 저장 

 

- 새로운 문서를 만들면 _id라는 고윳값을 자동으로 생성

  -> 시간, 머신 아이디, 프로세스 아이디, 순차 번호로 되어 있어 고유함 보장

 

컬렉션

 - 여러 문서가 들어 있는 곳

 - MongoDB는 다른 스키마를 가지고 있는 문서들이 한 컬렉션에서 공존할 수 있음 

{
    "_id":ObjectId("594948a081ad6e0ea526f3f5"),
    "username" : "velopert"
 },
 {
	"_id":ObjectId("594948a081ad6e0ea526f3f5"),
    "username" : "velopert",
    "phone" : "010-1234-1234"
 }

 

 

22.1.2 MongoDB 구조 

 

- 서버 하나에 데이터베이스를 여러 개 가지고 있을 수 있음

- 각 데이터베이스에는 여러 개의 컬렉션이 있으며, 컬렉션 내부에는 문서들이 들어 있음 

 

 

22.1.3 스키마 디자인 

 

- RDBMS에서 블로그용 데이터 스키마를 설계한다면 각 포스트, 댓글마다 테이블을 만들어 필요에 따라 JOIN해서 사용하는 것이 일반적

 

- NoSQL에서는 그냥 모든 것을 문서 하나에 넣음

{
    _id: ObjectId,
    title: String,
    body: String,
    username: String,
    createdData: Date,
    commnets: [
    	{
            _id:ObjectedId,
            text: String,
            createDate: Date,
         },
     ],
}

 

 

22.2 mongoose의 설치 및 적용 

 

moongoose

 

- Node.js 환경에서 사용하는 MongoDB 기반 ODM(Object Data Modelling) 라이브러리 

- 데이터베이스 문서들을 자바스크립트 객체처럼 사용할 수 있게 해줌

npm install mongoose

 

dotenv

 

- 환경변수들을 파일에 넣고 사용할 수 있게 하는 개발 도구 

- 민감하거나 환경별로 달라환경변수가들어질 수 있는 값은 코드안에 직접 작성하지 않고, 환경변수로 설정하는 것이 좋음 

- 프로젝트를 깃허브에 올릴때는 .gitignore를 작성하여 환경변수가 들어 있는 파일은 제외시켜 주어야 함 

npm install dotenv

 

 

22.2.1 .env 환경 변수 파일 생성 

 

- .env 파일을 만들어 환경변수에 서버에서 사용할 포트와 MongoDB 주소를 넣어줌 

 

src/index.js

- dotenv를 불러와서 config() 함수를 호출

- Node.js에서 환경변수는 process.env 값을 통해 조회할 수 있음 

require('dotenv').config();
const Koa = require('koa');
const Router = require('koa-router');
const bodyParser = require('koa-bodyparser');

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

const api = require('./api');

const app = new Koa();
const router = new Router();

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

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

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

//PORT가 지정되어 있지 않다면 4000을 사용
const port = PORT || 4000;
app.listen(port, () => {
  console.log('Listening to port %d', port);
});

 

 

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

 

src/index.js

- mongoose를 이용하여 서버와 데이터베이스를 연결

- 연결할 때는 mongoose의 connect 함수를 사용 

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

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

//connect 함수로 서버와 데이터베이스 연결
mongoose.connect(MONGO_URI)
        .then(() => {
          console.log('Connected to MongoDB');
        })
        .catch(e => {
          console.error(e);
        });
        
const api = require('./api');

const app = new Koa();
const router = new Router();

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

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

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

//PORT가 지정되어 있지 않다면 4000을 사용
const port = PORT || 4000;
app.listen(port, () => {
  console.log('Listening to port %d', port);
});

 

후,, 2시간만에 됨 ㅎ..

 

 

22.3 esm으로 ES 모듈 import/export 문법 사용하기 

 

- 기존 리액트 프로젝트에서 사용해 오던 ES 모듈 import/export 문법은 Node.js에서 아직 정식으로 지원되지 않음 

- Node.js에 해당 기능이 구현되어 있기는 하지만 아직 실험적인 단계이기 때문에 기본 옵션으로는 사용할 수 없으며, 확장자를 .mjs로 사용하고 node를 실행할 때 --experimental-modules라는 옵션을 넣어 주어야 함

 

- esm 라이브러리 

npm install esm

 

src/index.js

require = require('esm')(module);
module.exports = require('./main.js');

 

 

.eslintrc.json

- ESLint에서 import/export 구문을 사용해도 오류로 간주하지 않도록 sourceType 값을 "module"로 설정

{
    "env": {
      "commonjs": true,
      "es6": true,
      "node": true
    },
    "extends": ["eslint:recommended", "prettier"],
    "globals": {
      "Atomics": "readonly",
      "SharedArrayBuffer": "readonly"
    },
    "parserOptions": {
      "ecmaVersion": 2018,
      "sourceType": "module"
    },
    "rules": {
      "no-unused-vars": "warn",
      "no-console": "off"
    }
  }

 

 

22.3.1 기존 코드 ES Module 형태로 바꾸기 

 

api/posts/posts.ctrl.js

- exports 코드를 export const로 모두 변환 

let postId = 1; //id의 초깃값

//posts 배열 초기 데이터 
const posts = [
    {
        id:1,
        title:'제목',
        body:'내용',
    },
];

/* 포스트 작성
POST /api/posts
{title,body}
*/
export const write = ctx => {
    //REST API의 Request Body는 ctx.request.body에서 조회할 수 있음
    const {title,body} = ctx.request.body;
    postId += 1; //기존 postId 값에 1을 더함 
    const post = {id:postId, title, body};
    posts.push(post);
    ctx.body = post;
};

/* 포스트 목록 조회
GET/api/posts
*/
export const list = ctx => {
    ctx.body=posts;
};

/* 특젇 포스트 조회 
GET/api/posts/:id
*/
export const read = ctx => {
    const {id} = ctx.params;

    //주어진 id값으로 포스트를 찾음
    //파라미터로 받아 온 값은 문자열 형식이므로 파라미터를 숫자로 변환하거나
    //비교할 p.id 값을 문자열로 변경해야 함 
    const post = posts.find(p=>p.id.toString() === id);
    //포스트가 없으면 오류를 반환
    if(!post) {
        ctx.status = 404;
        ctx.body = {
            message:'포스트가 존재하지 않습니다.',
        };
        return;
    }
    ctx.body = post;
};

/* 특정 포스트 제거
DELETE /api/posts/:id
*/
export const remove = ctx => {
    const {id} = ctx.params;
    //해당 id를 가진 post가 몇 번째인지 확인
    const index = posts.findIndex(p=>p.id.toString() === id);
    //포스트가 없으면 오류를 반환
    if(index === -1) {
        ctx.status = 404;
        ctx.body = {
            message:'포스트가 존재하지 않습니다.',
        };
        return;
    }
    //index번째 아이템을 제거
    posts.splice(index,1);
    ctx.status = 204; //No Content
};

/* 포스트 수정(교체)
PUT/ api/posts/:id
{title, body}
*/
export const replace = ctx => {
    //put 메서드는 전체 포스트 정보를 입력하여 데이터를 통째로 교체할 때 사용
    const {id} = ctx.params;
    //해당 id를 가진 post가 몇 번째인지 확인
    const index = posts.findIndex(p=>p.id.toString()===id);
    //포스트가 없으면 오류를 반환
    if(index === -1){
        ctx.status = 404;
        ctx.body = {
            message:'포스트가 존재하지 않습니다',
        };
        return;
    }

    //전체 객체를 덮어 씌움
    //따라서 id를 제외한 기존 정보를 날리고, 객체를 새로 만듬
    posts[index] = {
        id,
        ...ctx.request.body,
    };
    ctx.body = posts[index];
};

/* 포스트 수정(특정 필드 변경)
PATCH /api/posts/:id
{title,body}
*/
export const update = ctx => {
    //PATCH 메서드는 주어진 필드만 교체 
    const {id} = ctx.params;
    //해당 id를 가진 post가 몇 번째인지 확인
    const index = posts.findIndex(p => p.id.toString() === id);
    //포스트가 없으면 오류를 반환
    if(index === -1 ) {
        ctx.status = 404;
        ctx.body = {
            message: '포스트가 존재하지 않습니다.',
        };
        return;
    }
    //기존 값에 정보를 덮어 씌움
    posts[index] = {
        ...posts[index],
        ...ctx.request.body,
    };
    ctx.body = posts[index];
}

 

 

src/api/posts/index.js

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

const posts = new Router();
...
export default posts;

 

src/api/index.js

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

const api = new Router();

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

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

 

 

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';

...

 

 

 

22.4 데이터베이스의 스키마와 모델

 

스키마(schema)

- 컬렉션에 들어가는 문서 내부의 각 필드가 어떤 형식으로 되어 있는지 정의하는 객체 

- 각 정보에 대한 필드 이름과 데이터 타입을 설정하여 스키마 만듬

 

모델(model)

- 스키마를 사용하여 만드는 인스턴스로, 데이터베이스에서 실제 작업을 처리할 수 있는 함수들을 지니고 있는 객체 

 

 

22.4.1 스키마 생성

 

- 블로그 포스트 스키마

필드 이름 데이터 타입 설명
title 문자열 제목
body 문자열  내용
tags 문자열 배열 태그 목록
publishedDate 날짜  작성 날짜 

 

 

src/model/posts.js

 

- 스키마를 만들 때는 mongoose 모듈의 Schema를 사용하여 정의 

import mongoose from "mongoose";

const { Schema } = mongoose;

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

 

 

22.4.2 모델 생성 

 

src/models/posts.js

 

- 모델을 만들 때는 mongoose.model 함수를 사용 

- 모델 인스턴스를 만들고, export default를 통해 내보내 줌 

- model() 함수의 첫번째 파라미터는 스키마 이름, 두 번째 파라미터는 스키마 객체 

- 데이터베이스는 스키마 이름을 정해 주면 그 이름의 복수 형태로 데이터베이스의 컬렉션 이름을 만듬

...
//모델 생성
const Post = mongoose.model('Post', PostSchema);
export default Post;

 

 

22.5 데이터 생성과 조회 

 

22.5.1 데이터 생성

 

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

 

- 포스트의 인스턴스를 만들 때는 new 키워드를 사용

- 생성자 함수의 파라미터에 정보를 지닌 객체를 넣음

- 인스턴스를 만들면 바로 데이터베이스에 저장되는 것은 아님. save() 함수를 실행시켜야 비로소 데이터베이스에 저장됨

- save() 함수의 반환 값은 Promise이므로 async/await 문법으로 데이터베이스 저장 요청을 완료할 때까지 await를 사용하여 대기할 수 있음

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

 

 

22.5.2 데이터 조회 

 

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

 

- 데이터를 조회할 때는 모델 인스턴스의 find() 함수를 사용 

- find() 함수를 호출한 후에는 exec()를 붙여 주어야 서버에 쿼리를 요청함 

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

 

 

22.5.3 특정 포스트 조회 

 

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

 

- read 함수를 통해 특정 포스트를 id로 찾아서 조회하는 기능

- 특정 id를 가진 데이터를 조회할 때는 findById() 함수를 사용 

 

/*
 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.thorw(500,e);
    }
};

 

 

22.6 데이터 삭제와 수정 

 

22.6.1 데이터 삭제 

 

remove() 특정 조건을 만족하는 데이터를 모두 지움
findByIdAndRemove() id를 찾아서 지움
findOneAndRemove() 특정 조건을 만족하는 데이터 하나를 찾아서 제거 

 

 

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

 

- findByIdAndRemove()를 사용하여 데이터 제거 

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

 

 

22.6.2 데이터 수정 

 

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

 

- findByIdAndUpdate()함수를 사용하여 데이터 업데이트 

- 첫번쩨 파라미터는 id, 두 번째 파라미터는 업데이트 내용, 세 번째 파라미터는 업데이트의 옵션

 

/*
  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, //이 값을 설정하면 업데이트된 데이터를 반환 
        }).exec();
        if(!post) {
            ctx.status = 404;
            return;
        }
        ctx.body = post;
    } catch(e) {
        ctx.throw(500,e);
    }
};

 

 

22.7 요청 검증 

 

22.7.1 ObjectId 검증

 

- rest API를 실행할 때, id가 올바른 ObjectId 형식이 아니면 500 오류 발생 

- 500 오류는 보통 서버에서 처리하지 않아 내부적으로 문제가 생겼을 때 발생

- 잘못된 id를 전달했다면 클라이언트가 요청을 잘못 보낸 것이니 400 Bad Request 오류를 띄워줘야 함  

  -> id값이 올바른 ObjectId인지 확인해야 함

 

import mongoose from 'mongoose';

const {ObjectId} = mongoose.Types;
ObjectId.isValid(id);

 

src/api/posts/posts.ctrl.js

 

- ObjectId를 검증해야 하는 API는 read, remove, update

- 미들웨어 만들어서 코드 중복 줄이기 

 

import Post from "../../models/posts";
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 검증이 필요한 부분에 미들웨어 추가 

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

const posts = new Router();

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

export default posts;

 

 

22.7.2 Request Body 검증 

 

- 포스트를 작성할 때 서버는 title, body, tags 값을 모두 전달받아야 하고 클라이언트가 값을 빼먹었을 떄는 400 오류 발생해야함 

 

- 객체 검증을 위해 Joi 라이브러리 사용 

npm install joi

 

 

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

 

- write 함수에서 Joi를 사용하여 요청 내용 검증 

/*
  POST /api/posts
  {
    title: '제목',
    body: '내용',
    tags: ['태그1', '태그2']
  }
*/
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);
    }
  };

 

 

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

 

- Joi를 사용하여 ctx.request.body 검증 

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

 

 

22.8 페이지네이션 구현 

 

- list API에 페이지네이션(pagination) 기능 구현

 

22.8.1 가짜 데이터 생성하기 

 

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

 

 

22.8.2 포스트를 역순으로 불러오기 

 

- 현재 list API에서는 포스트가 작성된 순서대로 나열됨

- 블로그에 방문한 사람에게 가장 최근 작성된 포스트를 먼저 보여주도록 구현 

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

 

 

22.8.3 보이는 개수 제한 

 

- 개수를 제한할 때는 limit() 함수를 사용하고 파라미터에는 제한할 숫자를 넣으면 됨 

 

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

 

 

22.8.4 페이지 기능 구현 

 

- skip 함수의 파라미터에 (page-1)*10을 넣어주면 1페이지에는 처음 열 개를 불러오고, 2페이지에는 그 다음 열개를 불러오게 됨

- page 값은 query에서 받아오도록 설정 

 

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

 

 

22.8.5 마지막 페이지 번호 알려주기 

 

- 커스텀 헤더를 설정하는 방법을 사용

- Last-Page라는 커스텀 HTTP 헤더 설정

 

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

...
const postCount = await Post.countDocuments().exec();
ctx.set('Last-Page', Math.ceil(postCount/10));
...

 

 

22.8.6 내용 길이 제한 

 

- body의 길이가 200자 이상이면 뒤에 '...'을 붙이고 문자열을 자르는 기능 

- find()를 통해 조회한 데이터는 moongoose 문서 인스턴스의 형태이므로 데이터를 바로 변형할 수 없음

- toJson() 함수를 실행하여 JSON 형태로 변환한 뒤 필요한 변형을 일으켜 줘야 함 

 

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

 

...
ctx.body = posts 
    .map(post => post.toJson())
    .map(post => ({
    	...post,
        body:
        	post.body.length < 200 ? post.body : `${post.body.slice(0,200)}...`,
     }));
...