on1ystar 머릿속을 정리, 기록하는 곳

Node.js/Express Mongoose(몽구스)-CRUD-Model/Queries



의문점이나 지적 등의 관심 및 조언을 위한 댓글이나 메일은 언제나 환영이고 감사합니다.


본격적으로 몽구스를 이용해 몽고DB를 제어하기 위해서는 가장 먼저 스키마(schema)를 만드는 것 부터 시작이다.

각 스키마는 MongoDB 컬렉션에 매핑되고 해당 컬렉션 내에서 문서의 모양을 정의한다.

사실 NoSQL인 MongoDB는 문서 내에 스키마를 정의할 필요가 없다. 때문에 어떤 데이터를 넣어도 에러가 나지 않는데, 이는 장점이자 단점이다. 오타를 낸 데이터나 실수로 같은 필드에 다른 타입의 데이터를 넣는 등의 실수를 범할 수 있다.

몽구스는 이러한 문제를 막기 위해 사용자가 스키마를 정의할 수 있게 하여, DB에 데이터를 넣기 전에 먼저 검사를 한다. 때문에 필요에 따라 매우 유용하게 스키마를 활용할 수 있다.

그렇다고 스키마를 정의하는 게 귀찮거나 까다롭지 않다. Mongoose를 이용하면 JavaScript 문법으로, 게다가 거의 대부분의 JavaScript의 자료형까지 그대로 필드의 데이터 타입으로 사용할 수 있다.


📌 몽구스 스키마 타입

  • mongoose.Schema.Types.String
  • mongoose.Schema.Types.Number
  • mongoose.Schema.Types.Date
  • mongoose.Schema.Types.Buffer
  • mongoose.Schema.Types.Boolean
  • mongoose.Schema.Types.Mixed
  • mongoose.Schema.Types.ObjectId (or, equivalently, mongoose.ObjectId)
  • mongoose.Schema.Types.Array
  • mongoose.Schema.Types.Decimal128
  • mongoose.Schema.Types.Map
// https://kb.objectrocket.com/mongo-db/mongoose-schema-types-1418
const schema = new Schema({
                name : String, //String
                age : Number, //Number
                dateOfBirth: Date, //Date
                Id: mongoose.ObjectIds //ObjectIds
                onSite: true, //Boolean
                locations: [String], //Array
                Data: Buffer, //Buffer
                others : Schema.Types.Mixed, //Mixed
                supervisorOf : {
                    type: map //map
                    of:  String
                }
            })


실습을 위한 디렉토리 구조(이전 포스팅들 참고)

/* Working Directory
┌──node_modules
├─┬src
│ ├─┬controllers
│ │ ├──userController.js
│ │ └──videoController.js
│ ├─┬routers
│ │ ├──globalRouter.js
│ │ ├──userRouter.js
│ │ └──videoRouter.js
│ ├─┬views   
│ │ ├─┬partial
│ │ │ └──footer.pug  
│ │ ├──base.pug
│ │ ├──home.pug
│ │ ├──videoEdit.pug
│ │ └──watch.pug
│ └─┬models
│   └──Video.js  <----- 추가
├──server.js
├──init.js   <----- 추가
└──pakage.json
*/


☄ Create Model



먼저 Video 모델을 만들기 위해 Video.js 파일에 스키마부터 정의한다. 모델을 만들 때는 그 대상의 생김새, 특징들을 최대한 자세하게 나타내는게 중요하다. 어느 정도 추상화가 끝났으면 그 특징들을 데이터의 형식으로 정의한다.

// models/Video.js
import mongoose from "mongoose";

const videoSchema = new mongoose.Schema({
  title: String,
  description: String,
  createdAt: Date,
  hashtags: [String],
  meta: {
    views: Number,
    rating: Number,
  },
});

const Video = mongoose.model("Video", videoSchema);

export default Video;

mongoose.Schema 객체를 먼저 정의한다. JavaScript Object 형태로 스키마의 형태를 정의하면, 추후 사용되는 DB 컬렉션에 매핑되어 문서 모양이 정의된다.

이제 mongoose.model 메소드를 호출해 모델 명 Video과 스키마 videoSchema를 인자로 넣어주면 DB에 매핑할 수 있는 스키마 모델이 생성된다.

마지막으로 이 모델을 DB에 알리기 위해서 import 해 줘야 하는데, 그 위치는 DB를 세팅한 파일을 import한 다음 위치여야 한다.

// server.js
import "./db";
import "./models/Video";
// ... 생략

그래야 server가 시작된 후 DB가 세팅되고, 그 세팅된 DB에 생성해 놓은 model을 컴파일 시키는 흐름이 된다.

참고로 여기서 server.js의 일부분을 init.js로 옮길건데, 그 이유는 위에서 처럼 db와 관련된 import들이 많아질 수 밖에 없고, 사실 그런 부분들은 server의 configuration과는 좀 무관한 느낌이기 때문이다.

따라서 server.js에는 server의 configuration과 같은 import 및 middleware 함수들을 작성하고, init.js에는 server를 시작하고, 그러는 동시에 DB와 model들을 import 해서 연동시키는 코드들을 작성한다.

//init.js
import "./db";
import "./models/Video";
import app from "./server"; // server.js에서 export default app을 해줘야 함

const PORT = 4000;

app.listen(PORT, () =>
  console.warn(`✅ Server listening on http://127.0.0.1:${PORT}/`)
);


☄ Mongoose Queries



Mongoose 모델은 CRUD 작업을 위한 쿼리 개체들을 반환하는 몇 가지 함수들을 제공한다.

queries list

이 쿼리들은 크게 2가지 방법으로 실행되는데, callback 함수와 promise다.

callback function

find 쿼리를 사용해 위 두 방법의 차이를 알아보겠다.

find(filter: FilterQuery<Document<any, {}>>, callback?: (err: any, docs: Document<any, {}>[])filter에 매칭되는 documents 리스트를 반환한다.

// controllers/videoController.js
import Video from "../models/Video";

export const home = (req, res) => {
  Video.find({}, (err, videos) => {
    console.log("error: ", err);
    console.log("videos:", videos);
  });
  res.render("home", { pageTitle: "Home", videos: {} });
};

find에서 호출되는 callback 함수는 2개의 시그니처 파라미터를 가지고 있는데 첫 번째에는 에러가 담겨 있고, 두 번째에는 조회한 문서 리스트들이 담겨 있다. 이를 로그로 찍어보면 아래와 같은 결과가 출력되는데, 이는 실제 DB와 잘 연동되어 쿼리를 수행했다는 것을 알 수 있다.

그런데 자세히 보면 이상한 점을 발견할 수 있다. morgan이 찍은 로그 정보가 쿼리 수행의 결과 로그보다 먼저 출력되는 것을 알 수 있는데, 이는 다시 말하면 res.render('home', { pageTitle: 'Home', videos: {} }); 코드가 뒤에 있음에도 먼저 수행되어 http request에 렌더링이 끝난 response를 한 후 쿼리 수행이 완료 됐다는 것이다.

이렇게 실행 흐름이 역전된 이유가 Mongoose는 쿼리를 비 동기적(asynchronously)으로 실행시키기 때문이다.

쿼리는 node 서버에서 모두 처리되는 것이 아니라 잠시 DB에 접속해 데이터를 받아오는 데 아무리 짧은 시간이라도 지연될 수 밖에 없지만 다른 코드들은 그 js 파일에서 실행과 완료가 이뤄진다. 따라서 callback은 데이터가 잘 도착하든, 어떤 에러가 발생하든, 쿼리의 수행이 모두 완료된 후에 호출되야 하므로 위와 같은 결과가 발생한 것이다.

이런 경우 데이터가 도착하기도 전에 렌더링된 페이지가 응답되는 문제가 발생하므로 callback 함수 안에서 실행 흐름에 맞게 해줘야 한다.

// controllers/videoController.js
import Video from "../models/Video";

export const home = (req, res) => {
  Video.find({}, (err, videos) => {
    console.log("error: ", err);
    console.log("videos:", videos);
    res.render("home", { pageTitle: "Home", videos: {} }); // 수정 후
  });
};

promise (async/await)

(promise, async/await에 대한 문법적인 설명은 추후 포스팅에서)

몽구스 쿼리는 프로미스 객체를 반환할 수 있기 때문에 async/await를 사용해서 callback을 사용한는 것 보다 좀 더 가독성 있는 코드로 바꿀 수 있다.

// controllers/videoController.js
export const home = async (req, res) => {
  try {
    const videos = await Video.find();
    console.log(videos);
    return res.render("home", { pageTitle: "Home", videos: {} });
  } catch (err) {
    console.log("Video.find error: ".err);
    return res.end();
  }
};

Video.find()await 키워드 덕분에 callback 함수없이 쿼리 수행을 기다렸다가, 끝나면 videos 변수에 결과 데이터(documents)를 담는다.

위 두 방법 모두 쿼리 수행이 다 끝날 때 까지 기다리기 위한 JavaScript의 처리 방식이지만 개발자의 입장에서 callback은 함수 안에 여러 로직들을 작성하다 보면 비 동기처리에 익숙하지 않은 개발자들은 실행 흐름을 읽기 어려워 실수할 우려가 많고, 익숙한 개발자더라도 코드 읽기가 불편하다.

하지만 promise 방식은 async 키워드가 달린 함수가 비 동기식으로 처리되고 있다는 걸 인식할 수 있고, 그 함수 내에서 await 키워드가 달린 코드가 실행 흐름을 처리하는 데 중요한 포인트라는 걸 바로 캐치할 수 있다.

error 처리는 위와 같이 try~catch 문을 사용하면 된다.

다음 포스팅에 이어서…


Reference

https://nomadcoders.co/wetube

https://mongoosejs.com/docs/guide.html