본문 바로가기
DataBase/mongoDB

mongoose) 몽구스 사용하기 - 스키마 사용, 실전 프로젝트

by 박채니 2023. 7. 4.
안녕하세요, 코린이의 코딩 학습기 채니 입니다.
[Node.js 교과서]의 책을 참고하여 포스팅한 개인 공부 내용입니다.

 

mongoose ODM

 

ODM (Object Doumment Mapping)이란?

- object와 documment를 1대 1로 짝지어 매핑

 

mongoose 사용하는 이유?

- 몽고디비에 없어 불편한 기능들을 보완

- 테이블과 유사한 기능, JOIN 기능을 추가

(mySQL과 유사해지기 때문에 mongoDB를 사용하는 이유(확장성과 자유로움, 가용성)를 생각하면 모순적이긴 함)

 

 

프로젝트 생성

package.json

{
    "name": "learn-mongoose",
    "version": "0.0.1",
    "description": "learn mongoose",
    "main": "app.ts",
    "scripts": {
        "start": "ts-node-dev --respawn --transpile-only app.ts"
    },
    "author": "chany",
    "license": "MIT",
    "dependencies": {
        "express": "^4.18.2",
        "mongoose": "^7.3.1",
        "morgan": "^1.10.0",
        "nunjucks": "^3.2.4"
    }
}

 

패키지 설치

$ yarn add express morgan nunjucks mongoose
$ yarn add --dev ts-node-dev

mySQL처럼 mongodb와 node를 연결해주는 드라이버가 존재하지만, mongoose는 내장되어 있으므로 별도의 설치는 필요 없습니다.

 

폴더 구조

public: 공통적으로 사용되는 파일 모음 (현재는 views에서 사용될 js 파일을 담고 있음)

routes: route 파일 모음

schemas: schema 파일 모음

views: view 파일 모음

 

views 

 

mongoose.html (프론트단 코드이므로 중요하지 않음)

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>몽구스 서버</title>
        <style>
            table {
                border: 1px solid black;
                border-collapse: collapse;
            }
            table th,
            table td {
                border: 1px solid black;
            }
        </style>
    </head>
    <body>
        <div>
            <form id="user-form">
                <fieldset>
                    <legend>사용자 등록</legend>
                    <div><input id="username" type="text" placeholder="이름" /></div>
                    <div><input id="age" type="number" placeholder="나이" /></div>
                    <div><input id="married" type="checkbox" /><label for="married">결혼 여부</label></div>
                    <button type="submit">등록</button>
                </fieldset>
            </form>
        </div>
        <br />
        <table id="user-list">
            <thead>
                <tr>
                    <th>아이디</th>
                    <th>이름</th>
                    <th>나이</th>
                    <th>결혼여부</th>
                </tr>
            </thead>
            <tbody>
                {% for user in users %}
                <tr>
                    <td>{{user.id}}</td>
                    <td>{{user.name}}</td>
                    <td>{{user.age}}</td>
                    <td>{{ '기혼' if user.married else '미혼'}}</td>
                </tr>
                {% endfor %}
            </tbody>
        </table>
        <br />
        <div>
            <form id="comment-form">
                <fieldset>
                    <legend>댓글 등록</legend>
                    <div><input id="userid" type="text" placeholder="사용자 아이디" /></div>
                    <div><input id="comment" type="text" placeholder="댓글" /></div>
                    <button type="submit">등록</button>
                </fieldset>
            </form>
        </div>
        <br />
        <table id="comment-list">
            <thead>
                <tr>
                    <th>아이디</th>
                    <th>작성자</th>
                    <th>댓글</th>
                    <th>수정</th>
                    <th>삭제</th>
                </tr>
            </thead>
            <tbody></tbody>
        </table>
        <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
        <script src="/mongoose.js"></script>
    </body>
</html>

 

error.html (프론트단 코드이므로 중요하지 않음)

<h1>{{message}}</h1>
<h2>{{error.status}}</h2>
<pre>{{error.stack}}</pre>

 

public

 

mongoose.js. (프론트단 코드이므로 중요하지 않음)

// 사용자 이름 눌렀을 때 댓글 로딩
document.querySelectorAll("#user-list tr").forEach((el) => {
    el.addEventListener("click", function () {
        const id = el.querySelector("td").textContent;
        getComment(id);
    });
});
// 사용자 로딩
async function getUser() {
    try {
        const res = await axios.get("/users");
        const users = res.data;
        console.log(users);
        const tbody = document.querySelector("#user-list tbody");
        tbody.innerHTML = "";
        users.map(function (user) {
            const row = document.createElement("tr");
            row.addEventListener("click", () => {
                getComment(user._id);
            });
            // 로우 셀 추가
            let td = document.createElement("td");
            td.textContent = user._id;
            row.appendChild(td);
            td = document.createElement("td");
            td.textContent = user.name;
            row.appendChild(td);
            td = document.createElement("td");
            td.textContent = user.age;
            row.appendChild(td);
            td = document.createElement("td");
            td.textContent = user.married ? "기혼" : "미혼";
            row.appendChild(td);
            tbody.appendChild(row);
        });
    } catch (err) {
        console.error(err);
    }
}
// 댓글 로딩
async function getComment(id) {
    try {
        const res = await axios.get(`/users/${id}/comments`);
        const comments = res.data;
        const tbody = document.querySelector("#comment-list tbody");
        tbody.innerHTML = "";
        comments.map(function (comment) {
            // 로우 셀 추가
            const row = document.createElement("tr");
            let td = document.createElement("td");
            td.textContent = comment._id;
            row.appendChild(td);
            td = document.createElement("td");
            td.textContent = comment.commenter.name;
            row.appendChild(td);
            td = document.createElement("td");
            td.textContent = comment.comment;
            row.appendChild(td);
            const edit = document.createElement("button");
            edit.textContent = "수정";
            edit.addEventListener("click", async () => {
                // 수정 클릭 시
                const newComment = prompt("바꿀 내용을 입력하세요");
                if (!newComment) {
                    return alert("내용을 반드시 입력하셔야 합니다");
                }
                try {
                    await axios.patch(`/comments/${comment._id}`, { comment: newComment });
                    getComment(id);
                } catch (err) {
                    console.error(err);
                }
            });
            const remove = document.createElement("button");
            remove.textContent = "삭제";
            remove.addEventListener("click", async () => {
                // 삭제 클릭 시
                try {
                    await axios.delete(`/comments/${comment._id}`);
                    getComment(id);
                } catch (err) {
                    console.error(err);
                }
            });
            // 버튼 추가
            td = document.createElement("td");
            td.appendChild(edit);
            row.appendChild(td);
            td = document.createElement("td");
            td.appendChild(remove);
            row.appendChild(td);
            tbody.appendChild(row);
        });
    } catch (err) {
        console.error(err);
    }
}
// 사용자 등록 시
document.getElementById("user-form").addEventListener("submit", async (e) => {
    e.preventDefault();
    const name = e.target.username.value;
    const age = e.target.age.value;
    const married = e.target.married.checked;
    if (!name) {
        return alert("이름을 입력하세요");
    }
    if (!age) {
        return alert("나이를 입력하세요");
    }
    try {
        await axios.post("/users", { name, age, married });
        getUser();
    } catch (err) {
        console.error(err);
    }
    e.target.username.value = "";
    e.target.age.value = "";
    e.target.married.checked = false;
});
// 댓글 등록 시
document.getElementById("comment-form").addEventListener("submit", async (e) => {
    e.preventDefault();
    const id = e.target.userid.value;
    const comment = e.target.comment.value;
    if (!id) {
        return alert("아이디를 입력하세요");
    }
    if (!comment) {
        return alert("댓글을 입력하세요");
    }
    try {
        await axios.post("/comments", { id, comment });
        getComment(id);
    } catch (err) {
        console.error(err);
    }
    e.target.userid.value = "";
    e.target.comment.value = "";
});

 

schemas

 

user.ts

import mongoose from "mongoose";

const { Schema } = mongoose;
const userSchema = new Schema({
    name: {
        type: String,
        require: true,
        unique: true,
    },
    age: {
        type: Number,
        require: true,
    },
    married: {
        type: Boolean,
        require: true,
    },
    comment: String, // type만 정의할 경우 생략 가능 -> require: false
    createdAt: {
        type: Date,
        default: Date.now,
    },
});

export default mongoose.model("User", userSchema);

comment처럼 type만 지정할 경우에는 중괄호 및 type: 을 생략할 수 있습니다.

나머지 값들은 모두 기본 값으로 세팅되므로, require: false가 될 것 입니다.

 

mongoose.model() 메소드를 이용하여 생성한 userSchema를 가지고 User라는 모델을 생성해주었습니다.

 

comment.ts

import mongoose from "mongoose";

const { Schema } = mongoose;
// nested object 혹은 populator 사용
// nested object는 update, delete 시에 번거롭고, populator는 join 비용이 많이 발생(속도도 느림)
const commentSchema = new Schema({
    commenter: {
        type: Schema.Types.ObjectId,
        require: true,
        ref: "User",
    },
    comment: {
        type: String,
        require: true,
    },
    createdAt: {
        type: Date,
        default: Date.now,
    },
});

export default mongoose.model("Comment", commentSchema);

commenter는 User의 id값을 가지고 있게 됩니다. mySQL로 따지자면 foreign key가 됩니다.

NoSQL에는 관계 정의가 없지만, moogose는 비슷한 역할을 할 수 있도록 지원해주며, ref를 통해 설정해줄 수 있습니다.

 

✅ populator 방식

→ type이 ObjectId이며, User의 _id값이 commenter의 값이 됨

→ 상대적으로 join시 비용이 많이 발생하고, 속도가 느림

→ update, delete 시 수월하다는 장점이 있음

 

nested object 방식

→ commenter에 User 객체를 정의해줌

→ join 시 비용이 적게 들고, 속도가 빠름

→ update, delete 시 번거로움

 

위 방식은 populator 방식을 사용하였는데, 방식마다 장단점이 존재합니다.

 

index.ts

import mongoose from "mongoose";

// connect 함수 실행 시 mongoose 연결
const connect = () => {
    if (process.env.NODE_ENV !== "production") {
        mongoose.set("debug", true); // 콘솔에서 쿼리 확인
    }

    try {
        // id:password@localhost~ 로그인을 위한 DB
        mongoose.connect("mongodb://root:비밀번호@localhost:27017/admin", {
            dbName: "nodejs", // 실제로 데이터 저장할 DB
            autoIndex: true,
        });
    } catch (err: any) {
        if (err) {
            console.log("mongodb connect error", err);
        } else {
            console.log("mongodb connect success");
        }
    }
};

mongoose.connection.on("error", (err: any) => {
    console.error("mongodb connect error", err);
});

mongoose.connection.on("disconnection", () => {
    console.log("mongodb disconnection");
    connect();
});

export default connect;

mongoDB에 mongoose를 연결해줍니다.

 

routes

 

index.ts

import express, { NextFunction, Request, Response } from "express";
import User from "../schemas/user";

const router = express.Router();

router.get("/", async (req: Request, res: Response, next: NextFunction) => {
    try {
        const users = await User.find({});
        res.render("mongoose", { users });
    } catch (err: any) {
        console.error(err);
        next(err);
    }
});

export default router;

모든 User를 가져와서 mongoose.html에 값을 전달해줍니다.

 

users.ts

import express, { NextFunction, Request, Response } from "express";
import User from "../schemas/user";
import Comment from "../schemas/comment";

const router = express.Router();

// 모든 User 조회
router.get("/", async (req: Request, res: Response, next: NextFunction) => {
    try {
        const users = await User.find({});
        res.status(200).json(users);
    } catch (err: any) {
        console.error(err);
        next(err);
    }
});

// User 생성
router.post("/", async (req: Request, res: Response, next: NextFunction) => {
    try {
        const user = await User.create({
            name: req.body.name,
            age: req.body.age,
            married: req.body.married,
        });
        console.log("create user", user);
        res.status(201).json(user);
    } catch (err: any) {
        console.error(err);
        next(err);
    }
});

// User Id에 댓글 가져오기
router.get("/:id/comments", async (req: Request, res: Response, next: NextFunction) => {
    try {
        const comments = await Comment.find({ commenter: req.params.id }).populate("commenter");
        console.log("comments", comments);
        res.status(200).json(comments);
    } catch (err: any) {
        console.error(err);
        next(err);
    }
});

export default router;

user의 댓글 가져오는 route를 보면, Comment.find().populate 메소드를 사용한 것을 확인할 수 있습니다.

이는, commenter에 ObjectId (_id)와 매칭되는 user의 객체를 commenter 자리에 대체 시켜줍니다.

 

Network Tab

commenter에 ObjectId(_id) 값이 아니라, User의 ObjectId에 대한 객체 값이 리턴된 것을 확인할 수 있습니다.

 

위 코드에서 populate 메소드를 제거한 후 Network Tab을 살펴보니, ObjectId 값만 리턴된 것을 확인할 수 있습니다.

populate 메소드 사용 안할 경우

 

comments.ts

import express, { NextFunction, Request, Response } from "express";
import Comment from "../schemas/comment";

const router = express.Router();

// 댓글 등록
router.post("/", async (req: Request, res: Response, next: NextFunction) => {
    try {
        const comment = await Comment.create({
            commenter: req.body.id,
            comment: req.body.comment,
        });

        const result = await Comment.populate(comment, { path: "commenter" });
        console.log("createComment Result", result);
        res.status(201).json(result);
    } catch (err: any) {
        console.error(err);
        next(err);
    }
});

// 댓글 수정
router.patch("/:id", async (req: Request, res: Response, next: NextFunction) => {
    try {
        const result = await Comment.updateOne(
            {
                _id: req.params.id,
            },
            { comment: req.body.comment }
        );

        console.log("update result", result);
        res.status(201).json(result);
    } catch (err: any) {
        console.error(err);
        next(err);
    }
});

// 댓글 삭제
router.delete("/:id", async (req: Request, res: Response, next: NextFunction) => {
    try {
        const result = await Comment.deleteOne({ _id: req.params.id });
        console.log("delete result", result);

        res.status(201).json(result);
    } catch (err: any) {
        console.error(err);
        next(err);
    }
});

export default router;

댓글 등록 route를 살펴보면, Comment.create 후 populate을 통해 commenter에 User ObjectId에 대한 객체를 리턴할 수 있도록 처리하였습니다.

 

User route에서의 populate, Comment route에서의 populate 두 가지 방식이 있다는 것을 확인할 수 있습니다.

 

또한, mongoDB에서 생성은 save() 메소드를 사용했었는데 mongoose에서도 save 메소드로 document를 생성할 수 있습니다.

router.post("/", async (req: Request, res: Response, next: NextFunction) => {
    try {
        const comment = new Comment({
            commenter: req.body.id,
            comment: req.body.comment,
        });
        await comment.save();
        
        const result = await Comment.populate(comment, { path: "commenter" });
        console.log("createComment Result", result);
        res.status(201).json(result);
    } catch (err: any) {
        console.error(err);
        next(err);
    }
});

 

app.ts

 

app.ts

import express, { NextFunction, Request, Response } from "express";
import path from "path";
import morgan from "morgan";
import nunjucks from "nunjucks";

import connect from "./schemas";
import indexRouter from "./routes/index";
import usersRouter from "./routes/users";
import commentsRouter from "./routes/comments";

const app = express();
app.set("port", process.env.PORT || 3002);
app.set("view engine", "html");
nunjucks.configure("views", {
    express: app,
    watch: true,
});
connect();

app.use(morgan("dev"));
app.use(express.static(path.join(__dirname, "public")));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));

app.use("/", indexRouter);
app.use("/users", usersRouter);
app.use("/comments", commentsRouter);

app.use((req: Request, res: Response, next: NextFunction) => {
    const error = new Error(`${req.method} ${req.url} 라우터가 없습니다.`);
    error.status = 404;
    next(error);
});

app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
    res.locals.message = err.message;
    res.locals.error = process.env.NODE_ENV !== "production" ? err : {};
    res.status(err.status || 500);
    res.render("error");
});

app.listen(app.get("port"), () => {
    console.log(app.get("port"), "번 포트에서 대기 중");
});