본문으로 바로가기

MongoDB - 문서의 갱신/쿼리

category software engineering/backend 2023. 6. 24. 14:34
728x90

문서의 갱신

문서의 치환

갱신에는 여러가지 방법이 있는데 첫 번째 방법으로는 문서의 치환이다. 대대적인 스키마 변경에 유용하며 find와 같이 문서를 가져오고 변경한 후 한 번에 치환하는 방법이다.

var joe = db.users.findOne({"name": "joe"});
joe.relationship = {"friends": joe.friends, "enemies": joe.enemies};
db.users.update({"name": joe}, joe);

제한자 사용하기

보통은 치환보다는 특정 부분만 갱신하는 경우가 많다. 제한자를 사용하면 문서의 치환과 달리 _id값은 변경시킬 수 없다. 제한자도 여러가지 제한자가 존재하는데 페이지를 방문하면 카운터를 증가하는 경우, 증가와 감소 제한자인 $inc를 사용할 수 있다.

db.analytics.update({"url", "www.example.com"}, ...{"$inc": {"pageViews": 1}})
db.games.update({"game", "pinball"}, ...{"$inc": {"score": 50}})

일반적으로 스키마를 갱신하거나 존재하지 않는다면 사용자 정의 키를 추가할 때 편리하게 쓸 수 있는 제한자는 $set이 있다. 만약 키와 값을 모두 제거 하고 싶다면 $unset 제한자를 사용할 수 있다.

db.blog.posts.update({"author.name": "joe"}, ...{"$set": {"author.name": "joe schmoe"}})
db.users.update({"name": "joe"}, ...{"$set": {"favorite book": ["Cat's Cradle"]}})
db.users.update({"name": "joe"}, ...{"$unset": {"favorite book": 1}})

요소를 추가하고 싶다면 $push 제한자를 사용할 수 있다. 지정된 키가 이미 존재하면 배열의 끝에 요소를 추가하고 그렇지 않으면 새로운 배열을 생성해서 추가한다.

db.blog.posts.update({"title": "A blog post"}, ... {"$push": {"comments": ... {"name": "joe", "email": "joe@examplle.com"}}})

db.blog.posts.findOne()
{
    "_id": ...,
    "title": "A blog post",
    "comments": [{"name": "joe", "email": "joe@examplle.com"}]
}

한번에 여러 개의 값을 추가하고 싶다면 $each 제한자를 사용하는게 적합하다. 특정 길이의 배열로 막고 싶다면 $push와 결합하여 $slice를 사용할 수 있다. 하위 객체를 추가하는 동안 정돈하기 전에 $sort 또한 수행할 수 있다. ($sort와 $slice를 사용할 땐 반드시 $each가 존재해야 한다.)

db.stock.ticker.update({"_id": "GOOG"},
   ...{"$push": {"hourly": {"$each": [562.111, 124.444, 532.322]}}})

db.movies.find("genre", "horror", {
   "$push": {"top10": {
      "$each": ["Nigtmare on Elm Street", "Saw"],
      "$slice": -10, "$sort": {"rating": -1}}
  }}) // rating 필드로 배열의 모든 요소를 정렬한 후 10개의 요소로 제한한다.

배열을 추가하더라도 존재하지 않는 경우에만 추가하고 싶은 경우 $ne를 사용하여 수행할 수 있다. 예를 들어 인용 목록에 저자가 존재하지 않을 때만 해당 저자를 추가해야하는 경우가 다음과 같다. 또한 $addToSet으로도 중복을 막으면서 추가할 때 사용할 수 있는데 $ne가 작동하지 않는 경우나 가독성이 더 낫다고 생각되는 경우 사용할 수 있다.

db.parpaers.update({"authors cited": {"$ne": "Richie"}}, {"$push": {"authors cited": "Richie"}})

db.users.findOne({"_id": ObjectId("4b2d754...")})
{
  "_id": ObjectId("4b2d754..."),
  "username": "joe",
  "emails": [
     "joe@example.com",
     "joe@gmail.com",
     "joe@yahoo.com",
  ]
}
db.users.update({"_id": ObjectId("4b2d754...")}, ...{"$addToSet": {"emails": "$each": ["joe@gmail.com", "joe@hotmail.com"]}})
db.users.findOne({"_id": ObjectId("4b2d754...")})
{
  "_id": ObjectId("4b2d754..."),
  "username": "joe",
  "emails": [
     "joe@example.com",
     "joe@gmail.com", // 중복이라 추가되지 않음
     "joe@yahoo.com",
     "joe@hotmail.com",
  ]
}

배열에서 요소를 제거하는 방법에는 양 끝에서 요소를 제거하는 $pop을 사용할 수 있고 {"$pop": {"key": 1} 는 배열의 끝에서 부터 {"$pop": {"key": -1}} 는 배열의 처음부터 요소를 제거한다. 특별한 순서가 없다면 $pull을 이용할 수 있다.

db.list.insert({"todo": ["dishes", "laundary", "dry cleaning"]})
db.list.update({}, {"$pull" {"todo": "laundary"}}) // 제거되어 dishes와 dry cleaning만 남게됨

하지만 만약 [1,1,2,1] 과 같은 배열에서 $pull을 이용해서 1을 제거하면 [2]만 남게 되는 문제가 있다. 이럴 땐 $set 또는 $inc를 상황에 맞게 사용하는게 권장된다.

db.blog.update({"post": post_id}, ...{"$inc": {"comments.0.votes": 1}}) // 첫 번째 댓글의 투표수를 증가
db.blog.update(
   {"comments.author": "John"},
   ...{"$set": "comments.$.author": "Jim"}
}) // John 이라는 author가 있다면 Jim으로 변경

$inc와 같은 제한자는 제자리에서 문서를 수정해서 문서의 크기가 변경되지 않아 빠르고 효율적이다. $set 또한 크기가 변경되지 않는 경우에는 제자리에서 수정할 수 있지만 그 외에는 배열 연산자와 같이 문서의 크기를 변경하기 때문에 성능상의 제약이 있다. 문서들을 삽입하면 각각의 문서는 디스크 상의 기존에 존재하던 곳 바로 옆에 놓이게 된다. 그러다 문서가 커지면 처음 써졌을 때 공간에는 더이상 맞지 않고 결국 콜렉션의 또 다른 공간으로 이동하게 된다. 몽고디비는 빈 공간을 사용하는데 용이하지 않아 만약 사용자의 스키마가 많은 이동을 요구하거나 삽입 및 삭제를 통해 많은 뒤섞임이 발생한다면 usePowerOf2Sizes 옵션을 사용함으로써 디스크 재사용성을 향상시킬 수 있다. db.runCommand({"collMod": collectionName, "usePowerOf2Sizes": true}) 이는 모든 후속 할당을 2^n 크기의 블록으로 할당하지만 초기 공간 할당을 비효율적으로 들어 삽입 전용 또는 현재 위치에서 업데이트 전용 콜렉션에서 쓰기 작업을 느리게 만들 수 있습니다.

갱신 입력(Upsert)

갱신 조건과 일치하는 어떤 문서도 존재하지 않는다면 쿼리 문서와 갱신 문서를 합쳐서 새로운 문서를 생성하는 특수한 형태의 갱신도 있다. 만약 목록이 존재하면 조회수를 하나 증가시키고 존재하지 않는다면 새로운 문서를 생성하는 경우 if-else문을 사용해서 작업하기 보다 아래와 같이 작성할 수 있다.

db.analytics.update({"url": "/blog"}, {"$inc": {"pageviews": 1}}, true) // true

find 이후 upsert를 편하게 사용할 수 있는 셸 보조자 save도 있다.

var x = db.foo.findOne()
x.num = 42
db.foo.save(x)

다중 문서 갱신

일반적으로 update는 조건과 일치하는 문서가 많더라도 나머지 문서는 갱신하지 않고 첫 번째 문서만 갱신한다. 모든 문서를 갱신하기 위해서는 네 번째 매개변수를 true로 지정해야한다. 언제 사용하냐면 예를 들어, 특정 날짜에 생일을 맞이하는 모든 사용자에게 선물을 준다고 가정하자.

db.users.update({"birthday": "10/13/2000"}, ...{"$set": {"gift": "Happy Birthday!"}}, false, true)
db.rundCommand({getLastError: 1}) // 최종 연산의 정보를 얻어오는 명령어로 "n"키는 갱신한 문서수를 포함한다.
{
   "err": null,
   "updatedExisting": true, // 존재하는 문서들이 갱신 되었다는 뜻
   "n": 5, // 5개가 갱신되었다는 뜻
   "ok": true
}

갱신한 문서 반환하기

var cursor = db.processes.find({"status": "READY"})
ps = cursor.sort({"priority": -1}).limit(1).next()
db.processes.update({"_id": ps.id}, {"$set": {"status": "RUNNING"}})
do_something(ps)
db.processes.update({"_id": ps.id}, {"$set": {"status": "DONE"}})

위 알고리즘은 경쟁상태를 만들기 떄문에 좋지 않다. 실행 중인 두 스레드가 있다고 가정하자. 스레드 A가 미처 status를 "RUNNING"으로 갱신하기 전에 다른 스레드 B가 같은 문서를 받게 되면 두 스레드 모두 같은 프로세스를 실행하게 되어 문제가 된다. 이럴 경우에는 findAnydModify를 통해 한 번의 연산으로 항목을 반환하고 갱신할 수 있다.

ps = db.rundCommand({"findAndModify": "processes",
..."query": {"status": "READY"},
..."sort": {"priority": -1},
..."update": {"$set": {"status": "RUNNING"}}}) // update뿐만 아니라 remove도 가능하다.

쿼리하기

일반적으로 쿼리는 다음과 같이 할 수 있다.

db.users.find(
    {}, // where
    {"username": 1, "email": 1} // 반환 받을 키 (하지만 _id는 특별히 지정하지 않더라도 항상 반환한다.)
)
db.users.find({},{"fatal_weakness": 0, _id: 0}) // _id, fatal_weakness를 제외하고 싶을 땐 다음과 같이 작성할 수 있다.

배열에서의 쿼리는 약간 특별한데, db.food.find({fruit: ["apple", "banana", "peach"]}) 로 작성하면 순서까지 완벽하게 일치하는 음식을 찾을 때 사용할 수 있고 만약 배열 중 하나라도 속하는 경우를 원하는 경우에는 $all 연산자를 db.food.find({fruit: {$all: ["apple", "banana", "peach"]}}) 다음과 같이 사용할 수 있다.

만약, 2번째 요소가 peach인 경우를 찾고 싶다면 db.food.find({"fruit.2": "peach"}) 와 같이 사용하고 문서의 크기를 통해 쿼리를 하고 싶다면 $size를 일반적으로 추가하고 업데이트를 할 때 $inc를 통해 같이 사이즈를 증가/감소 시켜준다면 $gt와 같이 비교 연산자를 사용할 수도 있다. 내장 문서에 쿼리할 때는 {"name": {"first": "Joe", "last": "Schmoe"}, "age": 45} 를 예를 들어 보면 db.people.find({"name": {"first": "Joe", "last": "Schemoe}}) 로 작성하게 된다면 문서의 키의 순서가 변하거나 middle을 추가하게 되는 경우 모두 일치하지 않아 원하는 쿼리를 하지 못해 이런 경우에는 db.people.find({"name.first": "Joe", "name.last": "Schmoe"})와 같이 쿼리할 수 있다.

더 복잡한 쿼리 문서의 경우는 $elemMatch를 사용하면 모든 키를 지정하지 않고도 조건을 db.blog.find({"comments": {"$elemMatch": {"author": "joe", "score": {"$gte": 5}}}}) 와 같이 정확하게 묶을 수 있다.

키/값 쌍으로 다양한 쿼리를 할 수 있지만 복잡한 경우에 자바스크립트를 쿼리의 일부분으로 실행할 수 있는 $where 절을 이용할 수 있다. 일반 쿼리와 비교하여 BSON에서 자바스크립트 객체로 변환되어야 하고 인덱스도 없어 훨씬 느리기 때문에 반드시 필요한 경우가 아니라면 사용을 지양해야 한다.

db.foo.find({"$where": function () {
    for(var current in this) {
        ...
        if(...) return true;
        return false;
    }
}})

커서

일반적으로 페이지네이션과 같이 클라이언트에 강력한 제어권이 필요한 경우는 커서를 사용할 수 있다. var cursor = db.collection.find();와 같이 정해두고 cursor.hasNext() 를 통해 다음 쿼리가 있는지 확인하고 cursor.next()를 통해 getMore 쿼리를 받아볼 수 있다. 일반적으로 limit, skip, sort와 같이 세 개의 메서드를 조합하여 사용할 수 있지만 문서수가 많은 경우에는 skip의 사용이 권장되지 않는다.
skip은 모든 생략된 결과물을 발견해야하고 폐기해야 되기 때문에 결과가 많이 느릴 수 있다.

// ❎
var page1 = db.foo.find(criteria).limit(100);
var page2 = db.foo.find(criteria).skip(100).limit(100);
var page3 = db.foo.find(criteria).skip(200).liimit(100);

// 🅾️
var page1 = db.foo.find().sort({"date": -1}).limit(100);
var latest = null;
while(page1.hasNext()) {
   latest = page1.next();
   display(latest);
}

var page2 = db.foo.find({"date"}: {"$lt": latest.date});
page2.sort({"date": -1}).limit(100);

랜덤으로 문서를 찾고 싶을 때는 skip(Math.floor(Math.random()*total) 과 같이 사용하기 보다는 랜덤 키를 추가하는게 효율적이다.

db.foo.find().snapshot()을 통해 snapshot을 찾는 경우가 있는데 쿼리를 느리게 하기 때문에 필요한 순간에만 스냅샷으로 된 쿼리를 사용해야 하는데 대용량으로 커서를 읽고 저장하는 프로세스가 있는 경우에 만약 문서의 크기가 증가할 때 기존에 할당해 둔 추가 공간에 맞지 않으면 재배치가 되는 문제가 있을 때 한 번에 하나의 문서만을 반환하는 _id 인덱스를 탐색하여 실행하여 해결할 수 있다.

위의 클라이언트 측면의 커서와 달리 서버 측면에서 커서를 보면 커서는 메모리와 자원을 점유한다. 커서과 결과를 가져왔거나 클라이언트가 끝내라는 요청을 한다면 다른 작업에 자원을 사용할 수 있어 신속하게 해제 해야한다. 서버 커서를 종료하기 위한 조건이 있는데 1. 조건과 일치하는 겨로가를 모두 살펴 본 후 스스로 종료한다. 2. 클라이언트 측에서 유효 영역 바깥으로 나갈 경우 드라이버는 데이터베이스에 특별한 메시지를 보내 커서를 종료해도 된다고 알린다. 3. 10분 동안 활동이 없으면 데이터베이스 커서는 자동으로 죽는다. (만약 타임아웃을 못하게 하기 위해서는 immortal 이라는 함수를 사용해야 한다.)

'software engineering > backend' 카테고리의 다른 글

MongoDB - 어플리케이션 설계  (0) 2023.06.25
MongoDB - 인덱싱  (0) 2023.06.24
MongoDB - 집계  (0) 2023.06.18
Locking - Optimistic concurrency control (OCC)  (0) 2022.08.31
Rest? gRPC? GraphQL?  (0) 2022.08.31