MongoDB 스터디

이 글은 MongoDB의 개념, 구조, 기본 연산, 스키마, 데이터 타입, 관계, 인덱싱, aggregation, 그리고 트랜잭션에 대한 상세한 스터디 내용을 담고 있습니다.

MongoDB ?

RDBMS 와 비교하기 쉬운 MongoDB 개념들

| RDBMS | MongoDB | | --- | --- | | Database | Database | | Table | Collections | | Instance | Documents |



소프트웨어 구조

Image




Driver

RDBMS 의 드라이버와 같이, MongoDB 의 경우에도 많은 언어들의 Driver 들을 지원한다. MongoDB Document




Basic Operations

db.dropDatabase()
db.myCollection.drop()
db.flightData.insertOne({
  aircraft: "Airbus A380",
  distance: 12000,
  departAirport: "GIMPO"
})
...
{
  acknowledged: true,
  insertedId: ObjectId("61caf9a5735da54142345461")
}

만약 커스텀 ID 값을 사용하고 싶으면, "_id" 값을 직접 지정해 줄 수 있다.

db.flightData.insertOne({
  _id: "id1",
  aircraft: "Airbus A380",
  distance: 12000,
  departAirport: "GIMPO"
})
...
{ acknowledged: true, insertedId: 'id1' }
db.flightData.insertMany([{
  aircraft: "Airbus A380",
  distance: 12000,
  departAirport: "GIMPO"
},{
  aircraft: "Airbus A381",
  distance: 12001,
  departAirport: "INCHEON"
}])
...
{
  acknowledged: true,
  insertedIds: {
    '0': ObjectId("61cb0535735da54142345463"),
    '1': ObjectId("61cb0535735da54142345464")
  }
}
// find all data
db.flightData.find()
db.flightData.find({distance: 12000}).pretty()
db.flightData.find({distance: {$gt: 10000}}).pretty()

// 모든 데이터를 모아서 Array 로 만들어 준다.
db.passengers.find().toArray()
// Application 코드에서는 다음과 같이 iteration 을 돌릴 수 있다. 모든 데이터를 한 번에 로드하지 않고, Cursor 를 통해 데이터를 불러오므로 효율적이다.
db.passengers.find().forEach((passengerData) => { ... });

...

[
  {
    _id: 'id1',
    aircraft: 'delete',
    distance: 12000,
    departAirport: 'GIMPO',
    marker: 'delete'
  },
  {
    _id: ObjectId("61cb03f9735da54142345462"),
    aircraft: 'Airbus A380',
    distance: 12000,
    departAirport: 'GIMPO'
  },
  {
    _id: ObjectId("61cb0535735da54142345463"),
    aircraft: 'Airbus A380',
    distance: 12000,
    departAirport: 'GIMPO'
  }
]
db.flightData.update({distance: 12000, {marker: "delete"}})

...

{
  marker: "delete"
}
// set 을 하기 위해서는 $set Operator 를 사용한다.
// $set 은 있으면 수정하고, 없으면 해당 key 를 추가한다.
db.flightData.updateOne({distance: 12000}, {$set: {marker: "delete"}})

...

{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
db.flightData.deleteOne({ departAirport: "GIMPO" })
db.flightData.deleteOne({ _id: "my-id" })
...
{ acknowledged: true, deletedCount: 1 }
// Delete All
db.flightData.deleteMany({ })
db.flightData.deleteMany({ key: "value" })
db.passengers.find(
    // where
    {},
    {
        // name 포함시키고 싶을 때
        name: 1,
        // _id 값 제외하고 싶을 때
        _id: 0
    }
)




Embedded Document & Array

Document 안에 Nested 된 Embedded Document 를 생성할 수 있다. Embedded Document 에 수반되는 제약사항은 다음과 같다.

Structured Data 를 find 하는 방법

{
    name: "Albert Twostone",
    hobbies: ["sports", "cooking"]
}
// Array 안에 있는 값들을 탐색해서 find 해준다.
db.passengers.find({hobbies: "sports"})
/*
{
    _id: ObjectId("61cb0535735da54142345464"),
    aircraft: 'Airbus A381',
    distance: 12001,
    departAirport: 'INCHEON',
    status: { description: 'on-time' }
}
*/
// Nested Document 에 접근하기 위해서는, "**.**" 와 같이 접근해야 한다.
db.flightData.find({"status.description": "on-time"})




Schema

MongoDB 는 기본적으로 Schemaless 이지만, Document 의 유지보수성을 관리하기 위해 Schema 를 사용한다. Vanilla Javascript 를 사용해도 되지만, 일부러 strict 모드로 Typescript 를 사용하는 것과 같은 이유 같다. 어느 정도로 strict 하게 Schema 를 설정할 지는 개발자의 성향, 프로젝트의 성격에 따라 달라질 수 있을 것 같다.

{
    title: "book",
    price: 10000
},
{
    name: "book",
    itemPrice: 10000,
    description: "~~"
}


{
    title: "book",
    price: 10000
}
{
    title: "book2",
    price: 11000,
    description: "Excess Field"
}


{
    title: "book",
    price: 10000
}
{
    title: "book2",
    price: 11000
}




Data Type




Relations

{
    userName: "max",
    address: {
        street: "Second Street"
    }
}
// Customers
{
    userName: "max",
    favoriteBooks: ["bookId1", "bookId2"]
}
// Books
{
    _id: "bookId1",
    name: "Star Wars "
}


크게 위의 두 케이스를 가지고 Relation 을 설정할 수 있는데, 각각의 경우에 따라 다른 케이스를 사용할 수 있다.

| Embedded Documents | References | | --- | --- | | Group data together logically | Split data across collections | | Great for data that belongs together and is not really overlapping with other data | Great for related but shared data as well as for data which is used in relations and standalone | | Avoid super deep nesting (100+levels) or extremely long arrays (<16mb) | Allows you to overcome nesting and size limits |

db.customers.insertOne({ name: "Heejae", orders: [ {productId: ObjectId("abcde"), quantity: 1} ] })

이러한 Embedded 방식의 단점은 무엇일까? 데이터의 중복이 발생할 수 있다. 또한, 상품의 이름이 변경되면 모든 Document 를 UPDATE 해줘야 한다는 단점이 있다. 하지만 때로는 이것을 활용해서 장점으로 사용할 수 있다. 이러한 설계는 데이터의 Snapshot 을 남기는 것이므로, 영원히 해당 데이터를 저장할 수 있다.

*ManyToMany Relation (Reference) Book 과 Author 를 저장하는 예시를 생각해 보자. 만약 Embedded 형태로 데이터를 저장하는 경우, Author 의 정보가 변경되면 Book 의 데이터들을 모두 찾아가면 Author 의 정보를 저장해야 한다. 정리하자면, 데이터 접근 Frequency 가 낮다면 Embedded 형태, Frequency 가 높다면 Reference 형식으로 설계하는 것이 좋을 때가 많다.




$lookup

SQL 의 JOIN 문처럼, relation 관계인 객체의 경우 aggregate 를 통해 두 document 를 합칠 수 있다.

db.books.aggregate([
  {
    $lookup: {
      // Join 걸 테이블
      from: "authors", 
      // book 의 property 
      localField: "authors",
      // FK 로 사용될 keey
      foreignField: "_id",
    }
  }
])




Schema Validation

기본적으로 MongoDB 는 schemaless 이지만, document 의 일관성을 관리하기 위해 Schema Validation 을 설정할 수 있다.

| validationLevel | validationAction | | --- | --- | | Which documents get validated ? | What happens if validation fails ? | | strict / moderate | error / warning |

Example Collection 생성과 동시에 Schema Validation 추가

db.createCollection("posts", { 
  validator: { 
    $jsonSchema: { 
      bsonType: "object", 
      required: ["title", "text", "creator", "comments"], 
      properties: {
        title: {
          bsonType: "string",
          description: "must be a string and is required"
        },
        text: {
          bsonType: "string",
          description: "must be a string and is required"
        },
        creator: {
          bsonType: "objectId",
          description: "must be a objectId and is required"
        },
        comments: {
          bsonType: "array",
          description: "must be a array and is required",
          items: {
            bsonType: "object",
            required: ["text", "author"],
            properties: {
              text: {
                bsonType: "string",
                description: "must be a string and is required"
              },
              author: {
                bsonType: "objectId",
                description: "must be an objectId and is required"
              }
            }
          }
        }
      }
    },
    validationAction: "warn",
  } 
})

기존에 있던 Collection 에 Schema Validation 추가

db.runCommand({
  collMode: "posts",
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["title", "text", "creator", "comments"],
      properties: {
        title: {
          bsonType: "string",
          description: "must be a string and is required"
        },
        text: {
          bsonType: "string",
          description: "must be a string and is required"
        },
        creator: {
          bsonType: "objectId",
          description: "must be a objectId and is required"
        },
        comments: {
          bsonType: "array",
          description: "must be a array and is required",
          items: {
            bsonType: "object",
            required: ["text", "author"],
            properties: {
              text: {
                bsonType: "string",
                description: "must be a string and is required"
              },
              author: {
                bsonType: "objectId",
                description: "must be an objectId and is required"
              }
            }
          }
        }
      },
      validationAction: "warn"
    }
  },
})




Create

| API | Example | Description | | --- | --- | --- | | insertOne() | db.collectionName.insertOne({field: "value"}) | 하나의 Document Insert | | insertMany() | db.collectionName.insertMany([{}, {}]) | 한 개 이상의 Document Insert | | insert() | db.collectionName.insert() | 하나 이상의 Document 를 Insert 할 수 있지만, Not Recommended |

db.hobbies.insertMany([{_id: "sports", name: "Sports"}])
// ordered 는 default 로 true 이다. 이 경우 싱글스레드로 중간에 한 insert 가 실패하면, 뒤의 insert 문도 실패한다.
// ordered 가 false 로 설정되면, 병렬적으로 insertMany 의 insert 문들이 동시에 실행된다.
db.hobbies.insertMany([{_id: "sports", name: "Sports"}], {ordered: false})
// Summary : Level of Guarantee
// w: w 를 설정하게 되면, ReplicaSet 에 속한 멤버중 지정된 수만큼의 멤버에게 데이터 쓰기가 완료되었는지 확인한다. 
// j : 해당 값을 설정하면, 데이터 쓰기 작업이 디스크상의 journal 에 기록된 후 완료로 판단하는 옵션이다. 
// wtimeout: 해당 값을 설정하면, Primary 에서 Secondary 로 데이터 동기화시 timeout 값을 설정하는 옵션이다. 만약 wtimeout 의 limit 을 넘어가게 되면 실제로 데이터가 Primary에 기록되었다고 해도 error 를 리턴하게 된다.

db.persons.insertOne({name: "Heejae", age: 29}, {writeConcern: {w: 1, j: true, wtimeout: 1}})




Read

Comparison Operator

db.movies.find({runtime: {$eq: 60}})
db.movies.find({runtime: {$ne: 60}})
db.movies.find({runtime: {$lt: 60}})
db.movies.find({runtime: {$lte: 60}})
db.movies.find({runtime: {$gt: 60}})
db.movies.find({runtime: {$gte: 60}})

Embedded Fields Operator

db.movies.find({"rating.average": {$gt: 7.0}})
// 배열안에 "Drama" 가 들어있는 Document 들도 찾아준다. ex) ["Drama", "Comedy"]
db.movies.find({genres: "Drama"})
// genre 가 "Drama" 만 속해 있는 Document 들만 찾아준다. ex) ["Drama"]
db.movies.find({genres: ["Drama"]})

$in and $nin

db.movies.find({runtime: {$in: [30, 42]}})
db.movies.find({runtime: {$nin: [30, 42]}})

$or and $nor

db.movies.find({$or: [{"rating.average": {$lt: 5}}, {"rating.average": {$gt: 9}}]})
db.movies.find({$nor: [{"rating.average": {$lt: 5}}, {"rating.average": {$gt: 9}}]})

$and

db.movies.find({$and: [{"rating.average": {$lt: 5}}, {"genres": "Drama"}]})

$not

db.movies.find({runtime: {$not: {$eq: 5}}})
// 위의 쿼리는 아래와 같은 동작을 하게 된다.
db.movies.find({runtime: {$ne: 5}})

$exists

db.users.find({age: {$exists: true, $gt: 29}})
// Valid 한 값이 들어있는지 여부는 아래와 같이 많이 확인한다.
db.users.find({age: {$exists: true, $ne: null}})

$type

// javascript 에서는 number 와 double 의 경계가 없으므로, "double" 로 검색해도 숫자값이 나올 수 있다.
db.users.find({phone: {$type: "number"}})

$regex

db.movies.findOne({summary: {$regex: /musical/ })

$expr

// volume 값이 target 보다 높은 데이터를 출력하는 쿼리
db.sales.find({$expr: {$gt: ["$volume", "$target"]}})
// volume 이 190 이상이면 volume-10, 아니면 volume 값이 target 보다 큰 값들을 조회하는 쿼리
db.sales.find({$expr: {$gt: [{$cond: {if: {$gte: ["$volume", 190]}, then: {$subtract: ["$volume", 10]}, else: "$volume"}}], "$target"}})

$size

db.users.insertOne({name: "Chris", hobbies: ["Sports", "Cooking", "Hiking"]})
db.users.find({hobbies: {$size: 3}})
// 이렇게 쿼리를 날리면, 0번째 인덱스에는 "action", 1번째 인덱스에는 "thriller" 를 가진 영화를 찾는다. (순서까지 고려)
db.movieStarts.find({genre: ["action", "thriller"]})
// 아래와 같이 쿼리를 날리면, 순서 상관없이 "action", "thriller" 를 지닌 영화들을 조회한다.
db.movieStarts.find({genre: {$all: ["action", "thriller"]}})
// frequency 가 3 이상인 hobbies 를 조회하고 싶을 때 아래와 같이 쿼리를 날리면 제대로 조회되지 않는다.
// Sports 가 아닌 hobby 도 frequency 가 3 이상이면 조회된다.
db.users.find({$and: [{"hobbies.title": "Sports"}, {"hobbies.frequency": {$gte: 3}}]})
// 서브쿼리처럼 조건을 달기 위해서는 $elemMatch 를 사용해야 한다.
db.users.find({hobbies: {$elemMatch: {title: "Sports", frequency: {$gte: 3}}}})




Cursor

MongoDB 는 기본적으로 한 번에 20개의 데이터들만 fetch 해온다. 만약 다음 값들을 얻어오려면, next() 함수를 실행시켜야 한다. 다음 커서가 존재하는지에 대한 여부는 hasNext() 함수를 통해 확인할 수 있다. 정렬은 sort() 를 통해 수행할 수 있다.

// Ascending
db.movies.find().sort({"rating.average": 1})
// Descending
db.movies.find().sort({"rating.average": -1})

// 2가지 property 를 통해 정렬하고 싶을 때 
// 평점을 기준으로 오름차순 정렬, 상영시간을 기준으로 내림차순 정렬
db.movies.find().sort({"rating.average": 1, runtime: -1})

특정한 Cursor page 를 skip하고 싶은 경우, skip() 을 사용할 수 있다.

db.movies.find().sort({"rating.average": 1, runtime: -1}).skip(100)

한 cursor 당 fetch 할 데이터의 갯수를 제한하기 위해서는 limit()을 사용할 수 있다.

db.movies.find().sort({"rating.average": 1, runtime: -1}).skip(100).limit(10)

문법적으로는 sort, skip, limit 을 순서 상관없이 사용할 수 있지만, MongoDB 에서는 sort, skip, limit 순서대로 동작하게 된다.

특정 컬럼만 fetch 하고 싶은 경우(Projection), 2번째 파라미터를 넘기면 된다. 단, _id 는 별도로 0으로 지정해주지 않는 이상 항상 fetch 된다.

db.users.find({}, {name: 1, genres: 1, runtime: 1, "rating.average": 1})
// 배열인 경우, "property.$": 1 을 입력하면 배열의 모든 속성값들을 출력한다
db.users.find({}, {name: 1, genres: 1, runtime: 1, "genres.$": 1})
// slice 문법도 사용할 수 있다
db.users.find({}, {name: 1, genres: 1, runtime: 1, "genres": {$slice: 2}})




Update

UPDATE 문은 SQL 과 같이 (1) WHERE 문 (2) SET 문으로 이루어진다. WHERE 조건은 SELECT 했을 때와 같이 동작하며, SET 은 다양한 연산자가 존재한다.

$set

특정 property 를 완전히 overwrite 할 때 사용한다.

// 만약 hobbies 라는 property 가 있다면 변경하고, 없다면 추가한다.
db.users.updateOne({_id: ObjectId("abcd"), {$set: {hobbies: [{title: "Sports"}, {title: "Cooking"}]}}})

$set 연산자를 이용해서 복수 개의 property 를 수정하는 것도 가능하다.

db.users.updateOne({_id: ObjectId("abcd")}, {$set: {age: 29, phone: "01012341234"}})

$inc & $dec

// Manuel 의 나이를 2살 증가시킨다.
db.users.updateOne({name: "Manuel"}, {$inc: {age: 2}})
// Manuel 의 나이를 2살 감소시키고, isSporty 를 false 로 설정한다.
db.users.updateOne({name: "Manuel"}, {$inc: {age: -2}, $set: {isSporty: false}})

$min & $max & $mul

// Chris 의 나이가 35세보다 클 경우에만 35세로 변경시킨다. (min 의 정의)
db.users.updateOne({name: "Chris"}, {$min: {age: 35}})
// Chris 의 나이가 31세보다 높을 경우에만 31세로 변경시킨다. (max 의 정의)
db.users.updateOne({name: "Chris"}, {$max: {age: 31}})
// Chris 의 나이에 1.1배를 곱한 값으로 설정한다.
db.users.updateOne({name: "Chris"}, {$mul: {age: 1.1}})

Delete Field

// 아래의 쿼리는 phone 필드를 삭제하지는 않고, null 로 세팅한다.
db.users.updateMany({isSporty: true, phone: null})
// 아래의 쿼리는 phone 필드를 삭제한다.
// phone: "" 의 값은 무시된다. (중요하지 않음)
db.users.updateMany({isSporty: true}, {$unset: {phone: ""}})

Rename Field

// 모든 Document 들의 age 를 totalAge 로 rename 한다.
db.users.updateMany({}, {$rename: {age: "totalAge"}})

Upsert

updateOne, updateMany 의 3번째 파라미터로 upsert 를 사용할 수 있다.

// Collection 에 없으면 INSERT 하고, 있으면 UPDATE 한다.
db.users.updateOne({name: "NotExists"}, {$set: {age: 29, hobbies: [{title: "Good food"}]}}, {upsert: true})

Update Array

$set 구문에 property.*.newProperty 문법을 사용해서 Array 의 새로운 field 를 추가할 수 있다.

// { "_id" : ObjectId("61dfbfbdcde6fbd14bd613a5"), "name" : "Max", "hobbies" : [ { "title" : "Sports", "frequency" : 3 }, { "title" : "Cooking", "frequency" : 6 } ], "phone" : 131782734 }
db.users.find({hobbies: {$elemMatch: {title: "Sports", frequency: {$gte: 3}}}})
// { "_id" : ObjectId("61dfbfbdcde6fbd14bd613a5"), "name" : "Max", "hobbies" : [ { "title" : "Sports", "frequency" : 3, "highFrequency" : true }, { "title" : "Cooking", "frequency" : 6 } ], "phone" : 131782734 }
db.users.updateMany({hobbies: {$elemMatch: {title: "Sports", frequency: {$gte: 3}}}}, {$set: {"hobbies.$.highFrequency": true}})

위의 쿼리는 특정 WHERE 조건에 해당하는 Document 들만 값이 추가된다. 만약 모든 배열의 원소 값을 변경하려면 다음과 같은 문법을 사용해야 한다.

db.users.updateMany({totalAge: {$gt: 30}}, {$inc: {"hobbies.$[].frequency": -1}})

만약 Array 에 원소를 추가하고 싶다면, $push 를 사용할 수 있다.

db.users.updateOne({name: "Maria"}, {$push: {hobbies: {title: "Sports", frequency: 2}}})
db.users.updateOne({name: "Maria"}, {$push: {hobbies: {$each: [{title: "Sports", frequency: 2}, {title: "Cooking", frequency: 2}], $sort: {frequency: -1}}}})

반대로 Array 에서 원소를 제거하고 싶다면, $pull를 사용할 수 있다.

// title 이 "Hiking" 인 hobbies 를 제거한다.
db.users.updateOne({name: "Maria"}, {$pull: {hobbies: {title: "Hiking"}}})
// 가장 마지막 hobbies 를 제거한다.
db.users.updateOne({name: "Maria"}, {$pop: {hobbies: 1}})

Delete

DELETE 에는 크게 deleteOne, deleteMany 만 주요 메소드로 정리할 수 있다.

db.users.deleteOne({name: "Heejae"})
db.users.deleteMany({age: {$lt: 29}})

만약 Collection 의 모든 데이터를 삭제하기 위해서는 다음과 같이 할 수 있다.

db.users.deleteMany({})
db.users.drop()

만약 Database 를 삭제하기 위해서는 다음과 같이 해야한다.

db.dropDatabase()

Index

Single Field Index

// dob.age 를 Ascending Index 로 설정
db.contacts.createIndex({"dob.age": 1})
// dob.age 를 Descending Index 로 설정
db.contacts.createIndex({"dob.age": -1})
// dob.age Ascending Index 삭제
db.contacts.dropIndex({"dob.age": 1})
// email 필드를 UNIQUE Constraint 로 생성
db.contacts.dropIndex({"email": 1}, {unique: true})

조심해야 할 점은, unique index 로 만들 경우, "email" 이 없는 Document 는 INSERT 할 수 없다. 만약 email 이 존재하지 않을 수 있는데, 있는 값들 중에서는 unique 하게 만들기 위해서는 다음과 같이 처리해야 한다. db.contacts.createIndex({email: 1}, {unique: true, partialFilterExpression: {email: {$exists: true}}})

Combound Index

두 가지 이상의 필드를 이용하여 생성하는 Index이다. 단일 Index 의 경우 순서(Ascending, Descending)와 무관하지만, Compound Index 의 경우 두 컬럼의 정렬 순서가 중요하다. 예를 들어 gender 를 Ascending 으로, age 를 descending 으로 인덱스를 생성한 경우, find 조건에서 gender는 Ascending으로, age 는 Descending 으로 정렬해야 Index 가 동작한다. 신기하게도, 이 예의 대우 관계 (gender 는 Descending, age 는 Ascending) 도 인덱스가 동작한다.

db.contacts.createIndex({"gender": 1, "dob.age": 1})

Default Index

// _id 값은 default index 로 지정되어 있다.
db.contacts.getIndexes()

Partial Filters

Partial Index 의 총 크기는 Compound Index 보다 작다. 예를 들어, 아래의 예시는 dob.agegender : male 인 데이터들만 색인 인덱싱하므로, dob.agegender 를 Compound Index 로 만드는 것보다 효율적이라고 할 수 있다.

db.contacts.createIndex({"dob.age": 1}, {partialFilterExpression: {gender: "male"}})
db.contacts.createIndex({"dob.age": 1}, {partialFilterExpression: {gender: {$gt: 60}}})

Time To Live

db.sessions.createIndex({createdAt: 1}, {expireAfterSeconds: 10})

Multi Key Index

배열도 인덱싱 할 수 있다. 이 경우 MongoDB 는 내부적으로 MultiKey Index 를 설정한다. Multi Key 는 배열의 모든 값들을 다 빼내어서, 각각을 고유한 값으로 인덱싱하여 저장한다.

db.contacts.createIndex({hobbies: 1})

Text Index

Text Index 도 Multi Key Index 의 일종이다. Text Index 는 문자를 각각의 어절별로 나누어서, 탐색하기 쉬운 형태로 만들어낸다. createIndex({fieldName: "text"}) 와 같이 Text Index 를 설정할 수 있다.

db.products.insertMany([{title: "A Book", description: "This is an awesome book about a young artist"},{title: "Red T-Shirt", description: "This T-shirt is Red and awesome"}])
db.products.createIndex({description: "text"})

탐색할 때에는 다음과 같이 할 수 있다.

// 하나의 컬렉션에는 하나의 Text Index 를 설정할 수 있다. (Text Index 가 무거운 작업이기 때문이다.)
db.products.find({$text: {$search: "awesome"}})

Aggregation


MongoDB 의 Aggregation 은 크게 match, group, sort 단계로 나눌 수 있다.

db.contacts.aggregate([{$match: {gender: "female"}}])
// location.state 를 GROUP BY 로 묶고, GROUP 된 사람들의 총합을 totalPersons 로 묶어준다.
db.persons.aggregate([
    {$match: {gender: "female"}},
    {$group: {_id: {state: "$location.state"}, totalPersons: {$sum: 1}}},
])    
// location.state 를 GROUP BY 로 묶고, GROUP 된 사람들의 총합을 totalPersons 로 묶어준다.
db.persons.aggregate([
    {$match: {gender: "female"}},
    {$group: {_id: {state: "$location.state"}, totalPersons: {$sum: 1}}},
    {$sort: {totalPersons: -1}}
])    

db.persons.aggregate([
    {$match: {"dob.age": {$gt: 50}}},
    {
        $group: {
            _id: {gender: "$gender"},
            numPersons: {$sum: 1},
            avgAge: {$avg: "$dob.age"}
        }
    },
    {$sort: {numPersons: -1}}
])
db.persons.aggregate([
    {$project: {_id: 0, gender: 1, fullName: {$concat: ["$name.first", " ", "$name.last"]}}}
])

db.persons.aggregate([
    {$project: {_id: 0, gender: 1, fullName: {$concat: [{$toUpper: "$name.first"}, " ", {$toUpper: "$name.last"}]}}}
])


Transaction

MongoDB 스터디 중, Transaction 에 대해 살펴보게 되었다. MongoDB Driver 에서 직접 Transaction 처리를 하는 경우와, Mongoose 에서 처리하는 경우를 살펴보았다.

MongoDB Driver (Node.js)

// For a replica set, include the replica set name and a seedlist of the members in the URI string; e.g.
// const uri = 'mongodb://mongodb0.example.com:27017,mongodb1.example.com:27017/?replicaSet=myRepl'
// For a sharded cluster, connect to the mongos instances; e.g.
// const uri = 'mongodb://mongos0.example.com:27017,mongos1.example.com:27017/'
const client = new MongoClient(uri);
await client.connect();

// Prereq: Collection 이 미리 생성되어 있어야 한다.
await client
    .db('mydb1')
    .collection('foo')
    .insertOne({
        abc: 0
    }, {
        writeConcern: {
            w: 'majority'
        }
    });

await client
    .db('mydb2')
    .collection('bar')
    .insertOne({
        xyz: 0
    }, {
        writeConcern: {
            w: 'majority'
        }
    });

// Step 1: Transaction 처리를 위한 Client Session 을 생성한다.
const session = client.startSession();

// Step 2: (Optional) Transaction 옵션을 설정한다.
const transactionOptions = {
    readPreference: 'primary',
    readConcern: {
        level: 'local'
    },
    writeConcern: {
        w: 'majority'
    }
};

// Step 3: session.withTransaction 메서드를 통해 Transaction 을 시작한다. 콜백 함수를 통해 Transaction 비즈니스 로직을 처리한다. 비즈니스 로직을 처리한 후에, Commit 혹은 Rollback 처리한다.
// 주의: withTransaction 콜백은 async 함수이거나, Promise 를 반환해야 한다.
// 주의: 콜백 함수 내에서도, DB 변경시 두 번째 인자로 session 을 넘겨주어야, Transaction 처리된다. session 을 넘겨주지 않으면, 일반 DML 과 동일하게 동작한다.
try {
    await session.withTransaction(async () => {
        const coll1 = client.db('mydb1').collection('foo');
        const coll2 = client.db('mydb2').collection('bar');

        // Important:: You must pass the session to the operations

        await coll1.insertOne({
            abc: 1
        }, {
            session
        });
        await coll2.insertOne({
            xyz: 999
        }, {
            session
        });
    }, transactionOptions);
} finally {
    await session.endSession();
    await client.close();
}


Mongoose

MongoDB Driver 에서 withTransaction 콜백 함수 내에서도 DML 시 session 객체를 항상 넘겨주어야 하는 것이 불만이었다. typeorm 에서는, Transaction 선언 시 Transaction 을 처리할 수 있는 EntityManager 가 생성되어서, 해당 EntityManager 을 통해서 처리한 DML 은 항상 Transaction 내에서 동작한다. 하지만 Mongoose 에서는 MongoDB Driver 와 같이, session 을 선언한 후에 session 객체를 DML 문에 같이 넣어주어야 한다.

const Customer = db.model('Customer', new Schema({
    name: String
}));

let session = null;
return Customer.createCollection().
then(() => db.startSession()).
then(_session => {
    session = _session;
    // Start a transaction
    session.startTransaction();
    // This `create()` is part of the transaction because of the `session`
    // option.
    return Customer.create([{
        name: 'Test'
    }], {
        session: session
    });
}).
// Transactions execute in isolation, so unless you pass a `session`
// to `findOne()` you won't see the document until the transaction
// is committed.
then(() => Customer.findOne({
    name: 'Test'
})).
then(doc => assert.ok(!doc)).
// This `findOne()` will return the doc, because passing the `session`
// means this `findOne()` will run as part of the transaction.
then(() => Customer.findOne({
    name: 'Test'
}).session(session)).
then(doc => assert.ok(doc)).
// Once the transaction is committed, the write operation becomes
// visible outside of the transaction.
then(() => session.commitTransaction()).
then(() => Customer.findOne({
    name: 'Test'
})).
then(doc => assert.ok(doc)).
then(() => session.endSession());


Mongoose 문서에서는 위와 같은 코드로 예시가 나와있는데, 개인적으로는 startTransaction() 보다는 withTransaction() 을 사용하는 것이 버그를 더 줄여줄 것 같다. 수동으로 Transaction 을 열고 닫는 것 보다는, withTransaction 을 통해 Transaction 의 상태관리는 Mongoose 에게 맡기고, 코드상으로는 비즈니스 로직에 집중하는 것이 간결하기 때문이다. (물론 session 객체는 수동으로 닫아줘야 한다는 아쉬움은 남아있다.)


Transaction 관련 참고문서

MongoDB Driver Mongoose 공식문서 Typeorm 공식문서

MongoDB ?
소프트웨어 구조
Driver
Basic Operations
Embedded Document & Array
Schema
Data Type
Relations
$lookup
Schema Validation
Create
Read
    Comparison Operator
    Embedded Fields
    $in and $nin
    $or and $nor
    $and
    $not
    $exists
    $type
    $regex
    $expr
    $size
    $elemMatch
Cursor
Update
    $set
    $inc & $dec
    $min & $max & $mul
    Delete Field
    Rename Field
    Upsert
    Update Array
Delete
Index
    Single Field Index
    Combound Index
    Default Index
    Partial Filters
    Time To Live
    Multi Key Index
    Text Index
Aggregation
Transaction

이것도 읽어보세요