Flow Control (유량제어)

외부 서비스 의존성이 높은 시스템에서 유량 제어 문제를 해결하기 위해 다양한 Rate Limit 알고리즘과 Redis 활용법을 소개합니다.

Contents


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()

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 공식문서


이것도 읽어보세요