[MongoDB in Action] CH7. 업데이트, 원자적 연산, 삭제

이번장에서는 몽고DB를 업데이트하는 방법에 대해서 알아본다.

  • 업데이트를 고려한 모델링
  • 까다로운 동시성 문제 고려
  • 새로운 업데이트 연산자
  • 업데이트 연산의 원자성을 이용한 기법들
  • findAndModify

도큐먼트 업데이트

MongoDB를 업데이트 하는 방법은 두가지가 있다.

  1. 도큐먼트 전체를 replace
  2. 도큐먼트 내의 특정 필드를 수정하기 위해 업데이트 연산자를 사용

replace에 의한 수정 (replace)

db.users.update({_id : '123'}, {document 데이터})

사용자 컬렉션에서 주어진 _id로 도큐먼트를 찾아서 새로이 제공하는 도큐먼트로 대치하는 명령어이다. 이러한 update 연산은 전체 도큐먼트를 replace 한다는 것이다.

연산자에 의한 수정 (target)

$set 연산자로 특정 필드만 수정할수도 있다.

db.users.update({_id : '123'}, {$set: {email: 'test@test.com'}})

몽고DB 문법 Note: update 구문과 query 구문은 명백하게 구분될 수 있다.

// update
db.products.update({}, {$addToSet: {tags: 'Green'}})

// query selector
db.products.update({price: {$lte: 10}}, {$addToSet: {tags: 'cheap'}})

위의 예시를 보면 update 구문 같은 경우에는 동사로 쓰이며, 접두사 표기법을 사용한다. 즉, 연산자가 앞에 나온다.

하지만 쿼리 셀렉터를 보면, 중위 표기법을 사용한다. 즉, 뒤에 나온다.

replace vs target 연산자

그렇다면 replace 나 연산자 중에 어떤 방식을 통해 udpate 하는 것이 옳을까?

객체 매퍼를 그대로 업데이트 하는 경우에는 replace 방식이 합리적인 선택이 될 수 있다. (쉬우니까)

반면에 타깃 방식의 수정은 아래와 같은 이유로 좀 더 나은 성능을 갖는다.

  • 수정할 도큐먼트를 가져오기 위해 서버에 도큐먼트 요청을 할 필요가 없다.
  • 업데이트를 지정하는 도큐먼트의 크기가 일반적으로 작다.
    • projection 을 통해 특정 필드만 쿼리 해온 상황을 가정했을 때, replace를 한다면 projection 을 쓸 수 없다. 정보의 유실이 되기 때문이다. 따라서, projection을 한 뒤, 연산자를 쓰면, 조금 더 작은 크기의 도큐먼트를 시리얼라이즈하고 전송하게 된다.

또한 타깃 방식은 도큐먼트를 원자적으로 업데이트하는 데 적합하다.

원자성 : 다른작업이 방해하지 않음을 보당한 상태에서 안전하게 도큐먼트를 검색 및 업데이트하는 기능이다.

예를 들어, 리뷰 수를 $inc 하는 경우, replace를 할 경우에 아래와 같이 해야 한다.

product_id = ObjectId("4c4b1476238d3b4dd5003982")
doc = db.products.findOne({_id: product_id})
doc['total_reviews'] += 1       // add 1 to the value in total_reviews
db.products.update({_id: product_id}, doc)

여기서 3 line과 4line 을 통과하는 시점에 변경이 발생한다면 어떻게 될까? 잘못된 값으로 업데이트 될 것이다. 이를 원자적으로 수행할 수 있는 유일한 방법은 optimistic locking을 통해서다. 하지만 연산자를 통한 타깃 업데이트는 $inc를 사용해서 카운터를 자동으로 증가할 수 있다. 이것은 대량의 동시적 업데이트일지라도 각각의 $inc는 고립적으로 적용되어 증가되거나 증가되지 않거나 둘 중의 한가지만 가능하다.

몽고에서 “원자적 업데이트”는 “타깃 방식의 업데이트”와 의미가 같다. 왜냐하면 코어 서버에 명령되는 모든 업데이트는 원자적이고 각 document에 대해 고립적이기 때문에, 연산자를 통한 단일 작업은 원자적이라고 볼 수 있기 때문이다.

낙관적 잠금이란, 레코드를 잠그지 않고도 업데이트 연산이 제대로 수행되는 것을 보장하기 위한 기술이다. 쉽게 생각해봤을 때 jira로 들 수 있을 것 같다. 사용자가 글을 수정한 후에 업데이트하려고 할 때, 업데이트에 타임스탬프가 포함된다. 그 타임스탬프가 페이지의 최종 저장시간보다 더 이전이면 업데이트를 할 수 없다.

e커머스 업데이트

책에서 제공하는 예시들로 여러종류의 업데이트 연산자를 알아보고, 이전 장에서 설계했던 스키마를 업데이트 관점에서 평가해본다.

상품과 카테고리

  • 상품에 대한 평점 평균을 계산 상황가정은 이렇다. 사용자는 상품 리스트를 상품의 평점을 도큐먼트에 저장하고, 리뷰가 추가되거나 삭제될 때마다 평점을 업데이트한다.
product_id = ObjectId("4c4b1476238d3b4dd5003982")
count = 0
total = 0
db.reviews.find({product_id: product_id}, {rating: 1}).forEach(
  function(review) {
    total += review.rating
    count++
  })
average = total / count
db.products.update({_id: product_id},
  {$set: {total_reviews: count, average_review: average}})

// result: WriteResult({ "nMatched" : 1, "nUpserted" : 0, "nModified" : 1 })

자바스크립트로 forEach 문으로 실제 count와 total 점수를 집계해서 평균을 냈다. 성능을 민감하게 생각하는 사용자라면 각 업데이트에 대해 상품의 모든 리뷰를 왜 굳이 재집계를 해야 할까? 라고 의문을 가질 것이다.

이때 읽기와 쓰기의 중요성에 대해 고민을 해보면 된다. 여기서는 더 많은 사용자가 리뷰를 작성하는 것보다 제품 리뷰를 볼 가능성이 높으므로 쓰기에 대해 (평점을 집계하는것) 집중하는 것이다.

위처럼 계산을 수기로 하지 않으려면, 평점의 총합을 보관하는 필드를 추가하여 점진적으로 평균을 계산하게 할 수 도 있다.

db.products.update({_id: product_id},
  {
    $set: {
      average_review: average,
      ratings_total: total
    },
    $inc: {
      total_reviews: 1
    }
  })

실제 데이터를 가지고 시스템에서 벤치마킹을 해봐야만 어떤 방식이 좋은지 알 수 있다.

  • 카테고리 계층 구조 우리가 이전장에서 계속 봐왔단 카테고리는 해당 카테고리가 조상 카테고리의 리스트를 가지고 있으므로 읽기에 최적화되어 있다. 한가지 까다로운 요구사항은 모든 조상 카테고리를 최신으로 가지고 있는 것이다. 이걸 어떻게 구현해야 할까?
// category hierarchy
var generate_ancestors = function(_id, parent_id) {
  ancestor_list = []
  var cursor = db.categories.find({_id: parent_id})
  while(cursor.size() > 0) {
    parent = cursor.next()
    ancestor_list.push(parent)
    parent_id = parent.parent_id
    cursor = db.categories.find({_id: parent_id})
  }
  db.categories.update({_id: _id}, {$set: {ancestors: ancestor_list}})
}

카테고리 계층 구조를 거슬러 올라가면서 루트 노드에 도달할 때까지 각 노드의 parent_id 속성을 연달아 query 하여 조상 리스트를 구한다. (ancestor_list)

그리고 $set을 이용해서 최신으로 query 된 ancestors를 업데이트 한다.

예를 들어 ASIS 카테고리 구조를 TOBE 카테고리로 해서, 채소 카테고리가 마트에 추가된다고 하자.

화면 캡처 2021-11-10 233115

그러면 우리는 위의 함수를 이용해서 아래와 같이 할 수 있을 것이다.

category = {
  parent_id: parent_id, // 마트의 카테고리 id
  slug: "채소",
  name: "채소",
  description: "채소"
}
db.categories.save(category)
generate_ancestors(category._id, parent_id)

하지만 만약에 소스 카테고리를 채소 카테고리 안으로 넣는다고 할 땐 어떻게 될까? 여러 카테고리에 대해서 그 조상 리스트를 변경해야 하므로 복잡해질 가능성이 많다.

  • ‘소스’카테고리의 parent_id 값을 ‘채소’ 카테고리의 id로 변경한다.
  • ‘소스’카테고리의 모든 child 노드들은 유효하지 않은 조상리스트를 갖게 되므로, 조상리스트에 ‘소스’를 가지고 있는 모든 카테고리를 query 한 뒤, 재계산 후 수정한다.

이것은, 배열에 대한 질의를 할 수 있으므로 아래와 같이 작성할 수 있다.

db.categories.find({'ancestors.id': outdoors_id}).forEach(
  function(category) {
    generate_ancestors(category._id, outdoors_id)
  })

하지만 이제 ‘소스’ 카테고리를 ‘매운 소스’라는 이름으로 수정한다면 어떻게 될까? 그렇다면 조상 카테고리 리스트에 있는 모든 ‘소스’ 카테고리를 찾아서 변경 해야 할 것이다.

봤지? 비정규화를 하면 나타나는 문제점이 바로 이런 것들이지

라고 생각할 수 있지만, 아래와 같이 update문을 작성하면 모든 조상리스트를 다시 계산할 필요 없이 이 업데이트를 수행할 수 있다.

outdoors_id = ObjectId("4c4b1476238d3b4dd5003982")
doc = db.categories.findOne({_id: outdoors_id})
doc.name = "The Great Outdoors"
db.categories.update({_id: outdoors_id}, doc)
db.categories.update(
  {'ancestors._id': outdoors_id},
  {$set: {'ancestors.$': doc}},
  {multi: true})
  • ‘소스’ 카테고리를 찾아서 이름을 바꿔준다.
  • ancestor 배열에 id를 ‘소스’ 카테고리를 가지고 있는 애들을 찾아서 위치 연산자를 이용해, 찾은 그 원소의 객체를 doc 으로 업데이트한다.
  • multi:true 연산자는, 여러개의 문서를 찾았다면 다중으로 업데이트 해주는 속성이다.

리뷰

리뷰 도큐먼트에 추천수와, 추천자의 id를 추가한다고 생각해보자. 만약 사용자가 해당 리뷰에 추천을 눌렀다면 그 사용자 id를 $push 하고, 총 추천수를 $inc 하면 된다. update 구문 안에, 여러개를 써서 동시에 수행할 수도 있다.

db.reviews.update({_id: ObjectId("4c4b1476238d3b4dd5000041")}, {
    $push: {
      voter_ids: ObjectId("4c4b1476238d3b4dd5000001")
    },
    $inc: {
      helpful_votes: 1
    }
  })

자, 여기서 조금 더 나아가서 추천자가 추천한 적이 없는 경우에만 수행된다고 제한을 한다면, query 를 먼저 해서 이 사람이 추천한적이 없는 지 필터한 뒤 업데이트를 해야 할 것이다.

query_selector = {
  _id: ObjectId("4c4b1476238d3b4dd5000041"),
  voter_ids: {
    $ne: ObjectId("4c4b1476238d3b4dd5000001")
  }
}

db.reviews.update(query_selector, {
    $push: {
      voter_ids: ObjectId("4c4b1476238d3b4dd5000001")
    },
    $inc : {
      helpful_votes: 1
    }
  })

이 경우, 추천 업데이트는 원자적이고 효율적이다. 왜냐하면 동일한 쿼리에서 선택 및, 업데이트가 발생하기 때문에 높은 동시성 환경에 대해서도 안전하다고 판단할 수 있다.

위의 <상품 및="" 카테고리=""> 에서도 알아봤듯이 replace 방법을 쓰면, race condition 이 발생할 수도 있다. 즉 replace를 하려면 수정하려는 도큐먼트에 대한 질의를 먼저 한뒤, update를 해야하는데, 그사이에 누군가가 수정할수도 있기 때문이다.

주문

주문 도큐먼트에는 upsert 옵션을 넣어서 udpate를 해보자. upsert는 오라클에서는 merge into로 생각하면 된다. 도큐먼트가 존재하지 않을 경우에는 인서트, 존재한다면 update 한다.

주문 같은 경우, 주문이 존재하지 않을경우 새로 생성하고, 존재한다면 아이템을 추가해주는 쿼리를 작성해본다.

다음과 같은 cart_item 도큐먼트가 있다고 가정해보자

cart_item = {
  _id:  ObjectId("4c4b1476238d3b4dd5003981"),
  slug: "wheel-barrow-9092",
  sku:  "9092",
  name: "Extra Large Wheel Barrow",
  pricing: {
    retail: 5897,
    sale:   4897
  }
}

사용자가 장바구니에 상품을 추가했을 경우, 총합은 sub_total 에 $inc 한다.

selector = {
  user_id: ObjectId("4c4b1476238d3b4dd5000001"),
  state: 'CART'
}

update = {
  $inc: {
    sub_total: cart_item['pricing']['sale']  
  }
}

db.orders.update(selector, update, {upsert: true})

위와 같이 update 문의 세번째 파라미터로 {upsert: true}를 넣어주면 된다.

주문 도큐먼트 생성을 위한 초기 업서트

장바구니에 A상품을 넣지 않았으면 장바구니에 넣는 쿼리이다.

selector = {user_id: ObjectId("4c4b1476238d3b4dd5000001"),
    state: 'CART',
    'line_items._id':
        {'$ne': cart_item._id}
    }

update = {'$push': {'line_items': cart_item}}
db.orders.update(selector, update)

만약, 이미 카트에 넣은 아이템에 대해 다시 카트에 넣었다면, 수량만 올려주면 그만이다.

selector = {
  user_id: ObjectId("4c4b1476238d3b4dd5000001"),
  state: 'CART',
  'line_items._id': ObjectId("4c4b1476238d3b4dd5003981")
}
update = {
  $inc: {
    'line_items.$.quantity': 1
  }
}
db.orders.update(selector, update)

이번에도 $ 연산자를 사용해서 배열의 원소에 접근한 것을 주목하자.

댓글남기기