ObjectId를 실제 데이터로! populate() 에 대해서 알아보자!

RDBMS의 JOIN 문과 유사한, mongoose의 .populate()에 대해서 짚고 넘어갑시다.

Featured image

이번 글에서는, 저번 mongoose 글에서 나중에 다루겠다는, mongoose의 populate기능에 대해서 알아보겠습니다.

들어가며 : RDBMS의 JOIN이란?

지난 글에서도 언급을 했지만, 저는 MySQL 같은, RDMBS에 완전히는 아니지만, 어느정도 익숙한 경험이 있기에, NoSQL인 MongoDB와 Mongoose 공부를 하면서도, 해당 개념이 RDBMS의 어느 개념에 해당하는지 비교를 하면서 공부를 쭉 해왔습니다. 하지만, 이 글을 읽는 분들이 전부 RDBMS에 익숙하리라하고 예단하는것도 말도 안되는 것이기에, 본격적인 설명에 앞서서 이번에 설명할 populate기능과 비교될 RDBMS의 JOIN 기능에 대해서 설명을 먼저 해보려고 합니다.

이 게시물은 RDMBS의 JOIN을 설명하고자 하는 글이 아니기 때문에 정말 간단하게만 짚고 넘어가겠습니다. JOIN 연산은 두개 이상의 데이터베이스나 테이블을 연결해서 데이터를 검색하는 연산을 말합니다. 쉬운 이해를 위해서, 쇼핑몰에 사용되는 DB를 생각해 봅시다. 하나의 테이블에는, 고객의 id와, 물건을 배송받을 주소지가 있습니다. 그리고 다른 테이블에는, 이때까지 고객들이 주문을 한 상품 정보와, 해당 삼품을 주문한 고객의 id가 저장되어 있습니다.

이제 배송을 위해서, 고객들이 주문한 상품 정보와, 고객의 주소지를 모두 함께 알 필요가 있습니다. 애초에 한 테이블에 싹다 몰아넣거나, 아니면 두 테이블 모두에 주소지 데이터를 넣어 버리면 되지 않을까 하는 생각이 들 수 있습니다. 안되는것은 아니지만, 테이블의 컬럼이 너무 많아지면 보기도 좋지 않고, 속도에도 영향을 미칩니다.(출처) 그리고 두 테이블 모두에 주소지 데이터를 넣어버린다면 현재의 문제는 해결이 되겠지만, 주소지가 아닌 다른 정보를 참조할때에도 컬럼을 추가하다 보면, 비슷한 문제가 생길 것이고, 중복된 데이터가 테이블에 있다면 갱신 등을 하는데에도 상당히 불편함을 감수해야 할 것입니다.

이럴때 사용하는 연산이 JOIN 연산입니다. 하나 이상의 칼럼이 공유(이 상황에서는 id)되어 있을 때, 다른 테이블의 데이터 검색을 할 수 있게 되어 하나의 테이블처럼 검색을 할 수 있게 해주는 쿼리 문법입니다. JOIN 문의 종류는 INNER JOIN, OUTER JOIN… 뭐 이런식으로 있는데, 그 부분까지는 이 글에서 다루고자 하는 부분과 멀어지기 떄문에, 대강 JOIN 문이 이런 것이구나 하는 느낌 정도만 여기서 가져가시면 좋을 것 같습니다.

1. mongoose.Schema.Types.ObjectId

지난 글에서 예시로 들었던 스키마를 만드는 코드를 한번 더 살펴봅시다.

import mongoose from "mongoose";

const videoSchema = new mongoose.Schema({
  title: { type: String, required: true, trim: true, maxLength: 80 },
  description: { type: String, required: true, trim: true, minLength: 20 },
  createdAt: { type: Date, required: true, default: Date.now },
  hashtags: [{ type: String, trim: true }],
  meta: {
    views: { type: Number, default: 0, required: true },
    rating: { type: Number, default: 0, required: true },
  },
  owner: {
    type: mongoose.Schema.Types.ObjectId, //_id에 대한 이해가 필요함
    required: true,
    ref: "User", //.populate 설명에서 더욱 자세히 다룰 것
  },
});

ObjectId는 이름 그대로,다른 object의 _id 값입니다. VideoownerUser 데이터를 담고 있습니다.(ref : User를 보면 알 수 있지요.) 데이터를 말 그대로 다 담고 있는것은 아니고, 그 데이터의 id를 담고 있어서, 그 데이터를 참고할 수 있게끔 하는 것입니다. 제 db에 있는 video 정보를 한번 여기 가져와 보겠습니다. mongo shell에서 입력한 것이지만, mongoose에서 가져온것과 크게 차이는 없을것으로 생각되어 가져 왔습니다. 가독성을 위해서, 임의로 개행을 약간 하였습니다.

> db.videos.find({})
{ "_id" : ObjectId("61604bdc8c20231c18f03097"), "meta" : { "views" : 0, "rating" : 0 }, "hashtags" : [ "#hello #wow #video #doge" ],
"title" : "Hello Everyone", "description" : "Hello Hello Hello Hello Hello", "fileUrl" : "uploads/videos/9c7d5cbfb969564138d7dfefd1231670",
"thumbUrl" : "uploads/videos/3c8eecacc8790ef54e057819a6bfd661",
"createdAt" : ISODate("2021-10-08T13:47:08.593Z"), "owner" : ObjectId("615da0c6301b3828748b2b58"), "__v" : 0 }

"owner"를 자세히 보면, ObjectId로 무언가가 기록이 되어 있습니다. _id 처럼요. 이것 자체로도 그렇게 나쁘진 않지만, 해당 owner 정보를 얻기 위해서는 id를 얻어서 findById를 하던 해서 한번 더 쿼리를 날린 다음에, 합쳐야 합니다. 상당히 번거로운 일이지요.

2. populate()

populate는 이 번거로운 일을 mongoose가 알아서 처리해서, 우리의 손을 편하게 해줍니다. 스키마에서 ref로 지정된 테이블에서 정보를 얻어온 다음에, 그 정보를 ObjectId가 있던 자리에 붙여 줍니다. 제가 만든 서비스에서 populate를 사용하는 예시입니다.

videos = await Video.find({
  title: {
    $regex: new RegExp(keyword, "i"),
  },
}).populate("owner");

videosfind로 쿼리를 날린 다음에, owner 필드의 자세한 정보를 알기 위해서, populate를 하는 예입니다.

populate를 한 데이터를 한번 더 populate를 할 수도 있습니다. 아래의 코드를 봐주시기 바랍니다.

const user = await User.findById(id).populate({
  path: "videos",
  populate: {
    path: "owner",
    model: "User",
  },
});

User안에 있는 Video데이터를 populate 한 다음, 또 Video내의 User데이터를 populate 할 수도 있음을 볼 수 있습니다.

제가 공부하면서 만든 서비스에서는 쓰이지 않았지만, 특정 부분만 populate 하는 방법 또한 존재합니다.

// simple populate
User.findOne({ _id: userId }).populate("blogs", { name: 1 }); // get name only

// nested populate
User.findOne({ _id: userId }).populate({
  path: "blogs",
  populate: {
    path: "comments",
    select: { body: 1 }, // 1
  },
});

3. mongoose populate 공식 API 문서 번역

원본 문서

Document.prototype.populate() 매개변수

4. populate()는 무적이 아니다…

꽤나 유용하게 쓰이는 populate 이지만 유의해야 할 사항들이 있습니다. populate는 ObjectId로 조회를 해서 자바스크립트 단에서 합쳐주는 것입니다. DB단에서 처리해주는 JOIN문과는 대조적이죠. 남용하면 성능 문제가 생길 수 있습니다. 특히, populate가 중철되면 될수록, 성늘 문제가 생길 문제도 더욱 커지므로, 필요할 때만 쓰는 태도가 필요합니다.