Front/React

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

happyso 2021. 1. 26. 22:11

1. 소개하기

  • 관계형 데이터베이스 한계
    • 데이터 스키마 고정적
    • 확장성(RDBMS는 처리해야 할 데이터양이 늘어나면 여러 컴퓨터에 분산시키는 것이 아니라, 해당 데이터베이스 서버의 성능을 업그레이트 해야 한다.)
  • MongoDB는 이런 한계를 극복한 문서 지향적 NoSQL 데이터베이스
    • 유동적 스키마
    • 확장성(여러 컴퓨터로 분산하여 처리 가능하도록 쉽게 설계)
  • MongoDB가 무조건 좋은 것은 하니다.
    • 상황별로 적합한 데이터베이스가 다르다.
    • 까다로운 조건으로 데이터를 필터링 또는 ACID특성을 지켜야 한다면 RDBMS가 유리

1.1. 문서란?

  • 여기서 말하는 문서(document)는 RDBMS의 레코드와 개념이 비슷하다.
  • 문서의 데이터 구조는 한 개 이상의 키-값 쌍으로 되어 있다.

문서의 예시

{
   "id": ObjectId("47832749dsd"),
   "username": "velopert",
   "name": {first: "M.J.", last: "Kim"}
}
  • 문서는 BSON(바이너리 형태의 JSON)형태로 저장된다.
  • 그렇기 때문에 나중에 JSON형태의 객체를 데이터베이스에 저장할 때, 큰 공수를 들이지 않고도 데이터를 데이터베이스에 등록할 수 있어 매우 편하다.
  • 새로운 문서를 만들면 _id라는 고육값을 자동으로 생성, 이 값은 시간, 머신 아디이, 프로세스 아이디, 순차 번호로 되어 있어 값의 고유함을 보장한다.
  • 여러 문서가 들어있는 곳을 컬렉션이라 한다.
  • 기존 RDBMS에서는 테이블 개념을 사용하므로 각 테이블마다 같은 스키마를 가지고 있어야 한다.
  • 반면 MongoDB는 다른 스키마를 가지고 있는 문서들이 한 컬렉션에서 공존할 수 있다.
{
   "id": ObjectId("47832749dsd"),
   "username": "velopert",
},
{
   "id": ObjectId("98sdas49dsd"),
   "username": "velopert",
   "phone": "010-1234-1234"
}
  • 처음에 데이터에 전화번호가 필요 없었는데, 나중에는 필요해졌다고 가정해보자
  • MongoDB에서는 컬렉션 안의 데이터가 같은 스키마를 가질 필요가 없으므로 그냥 넣어주면 된다.

1.2. MongoDB구조

1.3. 스키마 디자인

  • MongoDB에서 스키마를 디자인하는 방식은 기존 RDBMS에서 스키마를 디자인하는 방식과 완전히 다르다.
  • RDBMS에서 블로그용 데이터 스키마를 설계한다면 각 포스트, 댓글마다 테이블을 만들어 필요에 따라 JOIN에서 사용하는 것이 일반적
  • 하지만 NoSQL에서는 그냥 모든 것을 문서 하나에 넣는다.
{
   _id: ObjectId,
   title: String,
   body: String,
   username: String,
   createDate: Date,
   comments: [
      {
         _id: ObjectId,
         text: String,
         createdDate: Date,
      },
   ],
};
  • 이런 상황에서 보통 MongoDB는 댓글을 포스트 문서 내부에 넣는다.
  • 문서 내부에 또 다른 문서가 위치할 수 있는데, 이를 서브다큐먼트라고 한다.
  • 서브다큐먼트 또한 일반 문서를 다루는 것처럼 쿼리할 수 있다.
  • 문서 하나에는 최대 16MB만큼 데이터를 넣을 수 있다.
    • 100자 댓글 데이터라면 대략 0.24KB를 차지한다.
    • 16MB는 16,384KB이니 문서 하나에 댓글 데이터를 약 68,000개 넣을 수 있다.
    • 서브다큐먼트에서 이 용량을 초과할 가능성이 있다면 컬렉션을 분리시키는 것이 좋다.

2. MongoDB 서버 준비

2.1. 설치

Windows

  • https://www.mongodb.com/download-center/community 에서 인스톨러를 내려받아 설치한다.

2.2. MongoDB 작동 확인

 

3. mongoose의 설치 및 적용

  • mongoose는 Node.js환경에서 사용하는 MongoDB 기반 ODM라이브러리이다.
  • 이 라이브러리는 데이터베이스 문서들을 자바스크립트 객체처럼 사용할 수 있게 해준다.
  • 이전에 만든 백엔드 프로젝트를 이어서 진행하겠다.
  • $ yarn add mongoose dotenv
  • dotenv는 환경변수들을 파일에 넣고 사용할 수 있게 하는 개발도구
  • mongoose를 사용하여 MongoDB에 접속할 때, 서버에 주소나 계정 및 비밀번호가 필요할 경우도 있다.
    • 이렇게 민감하거나 환경별로 달라질 수 있는 값은 코드 안에 직접 작성하지 않고, 환경변수로 설정하는 것이 좋다.
    • 프로젝트를 깃허브 등에 올릴때는 .gitignore를 작성하여 환경변수가 들어 있는 파일은 제외시켜 주어야 한다.

3.1. .env 환경변수 파일 생성

  • 환경변수에는 서버에서 사용할 포트와 MOngoDB 주소를 넣어주겠다.

[.env]

PORT=4000
MONGO_URI=mongodb://localhost:27017/blog
  • 여기서 blog는 우리가 사용할 데이터베이스 이름이다.
  • 지정한 데이터베이스가 서버에 없다면 자동으로 만들어 주므로 사전에 직접 생성할 필요는 없다.
  • 다음으로 src/index.js파일의 맨 위에 다음과 같이 dotenv를 불러와서 config() 함수를 호출해 준다.
  • Node.js에서 환경변수는 process.env값을 통해 조회할 수 있다.

[src/index.js]

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(4000, () => {
  console.log('Listening to port %d', port);
});

 

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

  • mongoose를 이용해 서버와 데이터베이스를 연결하겠다.
  • 연결할 때는 mongoose의 connect함수를 사용한다.
require('dotenv').config();
const Koa = require('koa');
const Router = require('koa-router');
const bodyParser = require('koa-bodyparser');
const mongoose = require('mongoose');

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

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

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

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

 

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

  • 기존 리액트 프로젝트에서 사용해 오던 ES모듈 import/export 문법은 Node.js에서 아직 정식으로 지원되지 않는다.
  • Node.js에 해당 기능이 구현되어 있기는 하지만 아직 실험적인 단계이디 때문에 기본 옵션으로는 사용할 수 없다.
    • 확장자를 .mjs로 사용하고 node를 실행할 때 --experimental-modules라는 옵션을 넣어 주어야 한다.
  • Node.js에서 import/export 문법을 꼭 사용해야 할 필요는 없지만, 이 문법을 사용하면 VSCOde에서 자동 완성을 통해 모듈을 자동으로 쉽게 불러올 수 있고 코드도 더욱 깔끔해진다.
  • 그래서 esm이라는 라이브러리의 도움을 받아 해당 문법을 사용해 보겠다.
  • $ yarn add esm
  • 기존 src/index.js 파일의 이름을 main.js로 변경하고, index.js 파일을 새로 생성해서 다음 코드를 작성한다.

[src/index.js]

// 이 파일에서만 no-global-assign ESLint 옵션을 비활성화한다.
/* eslint-disable no-global-assign */

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

 

package.json 코드 수정

[package.json]

(...)
  "scripts": {
    "start": "node -r esm src",
    "start:dev": "nodemon --watch src/ -r esm src/index.js"
  }
 }

 

ESLint에서 import/export 구문을 사용해도 오류로 간주하지 않도록 다음과 같이 .eslintrc.json에서 sourceType 값을 "module"로 설정해준다.

 

[.eslintrc.json]

{
    "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"
    }
}
  • 이제 프로젝트에서 import/export 구문을 자유롭게 사용할 수 있다.
  • 서버 종료 뒤 다시 구동 yarn start:dev

4.1. 기존 코드 ES Module 형태로 바꾸기

[api/posts/posts/ctrl.js]

(...)
export const write = (ctx) => {
  (...)
};

export const list = (ctx) => {
  (...)
};

export const read = (ctx) => {
  (...)
};

export const remove = (ctx) => {
  (...)
};

export const replace = (ctx) => {
  (...)
};

export const update = (ctx) => {
  (...)
};

 

[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.put('/:id', postCtrl.replace);
posts.patch('/:id', postCtrl.update);

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

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

 

[jsconfig.json]

{
    "compilerOptions": {
        "target": "es6",
        "module": "es2015"
    },
    "include": ["src/**/*"]
}
  • 위 코드와 같이 작성해주면 나중에 자동 완성을 통해 모듈을 불러올 수 있다.

5. 데이터베이스의 스키마와 모델

  • mongoose에는 스키마와 모델이라는 개념이 있다.
    • 이 둘은 혼동하기 쉽다.
    • 스키마 : 컬렉션에 들어가는 문서 내부의 각 피드가 어떤 형식으로 되어 있는지 정의하는 객체
    • 모델 : 스키마를 사용하여 만드는 인스턴스로, 데이터베이스에서 실제 작업을 처리할 수 있는 함수들을 지니고 있는 객체

5.1. 스키마 생성

  • 모델을 만들려면 사전에 스키마를 만들어 주어야 한다.
  • 블로그 포스트에 대한 스키마를 준비할 것이다. 어떤 데이터가 필요한지 한번 생각해보자
    • 제목
    • 내용
    • 태그
    • 작성일
  • 포스트 하나에 총 네 가지 정보가 필요하다
  • 각 정보에 대한 필드 이름과 데이터 타입을 설정하여 스키마를 만든다.
필드 이름 데이터 타입 설명
title 문자열 제목
body 문자열 내용
tags 문자열 배열 태그 목록
publishedDate 날짜 작성 날짜

 

  • 스키마와 모델에 관련된 코드는 src/models 디렉터이레 작성한다.
  • 디렉터리를 따로 만들어 관리하면 나중에 류지 보수를 편하게 할 수 있다.

[src/models/posts.js]

import mongoose from 'mongoose';

const { Schema } = mongoose;

const PostSchema = new Schema({
  title: String,
  body: String,
  tags: [String], // 문자열로 이루어진 배열
  publishedDate: {
    type: Date,
    default: Date.now, // 현재 날짜를 기본값으로 지정
  },
});
  • 스키마를 만들 때는 mongoose 모듈의 Schema를 사용하여 정의한다.
  • 각 필드 이름과 필드의 데이터 타입 정보가 들어 있는 객체를 작성한다.
  • 필드의 기본값으로는 default 값을 설정해주면 된다.
  • Schema에서 지원하는 타입은 다음과 같다.
타입 설명
String 문자열
Number 숫자
Date 날짜
Buffer 파일을 담을 수 있는 버퍼
Boolean true 또는 false값
Mixed(Schema, Types, Mixed) 어떤 데이터도 넣을 수 있는 형식
ObjectId 객체 아이디. 주로 다른 객체를 참조할 때 넣음
Array 배열 형태의 값으로 [] 로 감싸서 사용

 

이 스키마를 활용하여 좀 더 복잡한 방식의 데이터도 저장할 수 있다.

 

[예시코드]

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,
});
  • 위 코드에서 authors 부분에 [AuthorSchema]를 넣어주었다.
  • 이는 Author 스키마로 이루어진 여러 개의 객체가 들어 있는 배열을 의미한다.
  • 이렇게 스키마 내부에 다른 스키마를 내장시킬 수도 있다.

5.2. 모델 생성

  • 모델을 만들 때는 mongoose.model 함수를 사용한다.

[src/models/posts.js]

import mongoose from 'mongoose';

const { Schema } = mongoose;

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

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

 

  • 모델 인스턴스를 만들고, export default 를 통해 내보내주었다.
  • 여기서 사용한 model() 함수는 기본적으로 두 개의 파라미터가 필요하다.
    • 첫 번째 파라미터 : 스키마 이름
    • 두 번째 파라미터 : 스키마 객체
  • 데이터베이스는 스키마 이름을 정해 주면 그 이름의 복수 형태로 데이터베이스에 컬렉션 이름을 만든다.
    • 예를들어 스키마 이름을 Post로 설정하면, 실제 데이터베이스에 만드는 컬렉션 이름은 posts이다.
    • BookInfo로 입력하면 bookinfos를 만든다.
  • MongoDB에서 컬렉션 이름을 만들 때 권장되는 컨벤션은 구분자를 사용하지 않고 복수 형태로 사용하는 것이다.
  • 이 컨벤션을 따르고 싶지 않다면, 다음 코드처럼 세 번째 파라미터에 원하는 이름을 입력하면 된다.
mongoose.model('Post', PostSchema, 'custom_book_collection');
  • 이 경우 첫 번째 파라미터로 넣어 준 이름은 나중에 다른 스키마에서 현재 스키마를 참조해야 하는 상황에서 사용한다.