Document Versioning과 Pessimistic/Optimistic locking

woo94·2023년 2월 7일
0

mongoose

목록 보기
2/2

Mongoose schema를 만들때에는 여러가지 option들을 줄 수 있다. 그 중 vesionKey라는 option에 대해서 다루어보려고 한다.

versionKey option

versionKey란 Mongoose에 의해서 document가 생성되면 설정되는 property 이다. 이는 document의 revision number를 나타낸다. versionKey option은 이러한 versioning에 사용될 path를 나타내는 string이다. 기본값은 __v 이다.
이 property의 이름을 바꾸기 위해서(customized versionKey) 존재하는 것이 versionKey option이다.

// default version key
const schema = new Schema({ name: 'string' });
const DefaultModel = mongoose.model('Default', schema)
const defaultDoc = new DefaultModel({ name: 'woo' })
await defaultDoc.save() // { __v: 0, name: 'woo' }

// customized version key
const customizedSchema = new Schema({ name: 'string' }, { versionKey: 'customizedVersionKey' })
const CustomizedModel = mongoose.model('Customized', customizedSchema)
const customizedDoc = new CustomizedModel({ name: 'woo' })
await customizedDoc.save() // { customizedVersionKey: 0, name: 'woo' }

하지만 Mongoose versionKey에 대해서 이해하기 위해서는 우선 Pessimistic/Optimistic locking에 대해 알아야 한다.

Pessimistic Locking

Record를 독점적으로 사용하기 위해서 record에 lock을 거는 것이다. 이 방식은 optimistic locking 보다 더 나은 무결성(integrity)를 보장해준다. Pessimistic locking을 사용하기 위해서는 database에 직접 connection을 하거나 connection에 독립적이면서 외부에서 사용이 가능한 transaction ID를 가지고 있어야 한다.
후자의 경우라면 transaction을 열고 그 transaction ID를 가지고 reconnect를 할 수 있다. 이것은 분산환경에서 two-phase commit을 가능하게 해준다.

Example

Alice가 account = { id: 1, amount: 50 } 에 대해서 shared lock(or read lock)을 가져가서 Bob이 이것을 바꾸지 못하게 하는 것이다.
상대방이 read lock을 release하기 전에는 update를 할 수 없다. Write operation를 하기 위해서 write/exclusive lock을 획득해야하는데, shared/read lock은 이것을 방해하기 때문이다.

Optimistic Locking

Record를 읽고, version number를 기억하고 있다가 record를 수정할 때 이 version number를 같이 제출하는 방식이다. 만약 update가 수행되는 시점에 version가 record를 읽어온 시점의 version과 다르다면(record is dirty) error를 뱉어준다.
이러한 방식은 대다수의 high-volume system과 three-tier architeucture(session에 대한 database connection을 유지하지 않아도 되는)에서 사용된다.

Example

version column이 추가되었다. version column은 매 UPDATEDELETE가 실행될때마다 증가한다. 그리고 이것은 UPDATEDELETE statement의 WHERE clause에도 사용된다.
이 방법을 사용하기 위해서는 read를 통해서 version을 먼저 읽어와야 한다.

Application-level transactions

이전에는 database system에 connect하는 수단이 terminal을 통해서였다. 그렇기 때문에 요즘에 와서도 session setting 과 같은 용어들이 존재한다.
하지만 요즘은 더이상 동일한 database transaction을 통해서 read와 write를 진행하지 않는다.

Optimistic locking과 함께라면 위와 같은 Lost Update(*)가 발생할 수 없다.

*Lost Update

다수의 transaction이 동시에 같은 데이터에 접근했을 때 모두 정상 작동 했음에도 한 transaction이 갱신한 값이 사라지는 문제(overwrite)

Versioning

http://aaronheckmann.blogspot.com/2012/06/mongoose-v3-part-1-versioning.html
블로그 글이 대체할수 없을정도로 좋아서 번역 및 발췌한다.

Blog post schema가 있고 comments라는 sub-document들의 array를 가지고 있는 경우를 떠올려 보자:

var commentSchema = new Schema({ 
    body: String
  , user: Schema.ObjectId
  , created: { type: Date, default: Date.now }
});
var postSchema = new Schema({ comments: [commentSchema] });
var Post = mongoose.model('Post', postSchema);

당신이 쓴 어떤 blog post가 엄청난 인기를 얻어서 많은 comment들이 달리고 있는 상황이다. 새로운 comment를 추가하는 코드는 어렵지 않다:

Post.findById(postId, function(err, post) {
  // handle errors ..
  post.comments.push({ body: someText, user: userId });
  post.save();
})

Commenter 중에 한명이 완전히 잘못된 내용을 올렸다는 것을 알아차리고 이것을 수정하고 싶어한다:

Post.findById(postId, function(err, post) {
  // handle errors ..
  var comment = post.comments.id(commentId);
  comment.body = updatedText;
  post.save(callback);
});

이것이 왜 문제가 되는지 알기 위해서는 comment를 update하기 위한 underlying operation을 봐야 한다. post.save()를 실행하면, MongoDB에는 다음과 같은 update가 발행된다:

posts.update({ _id: postId }, { $set: { 'comments.3.body': updatedText }})

comments.3.body와 같은 것을 positional notation이라고 한다. 이것은 index position 3에 있는 comment의 body를 set 하도록 MongoDB에 말한다.

여기에서 발생할 수 있는 문제로는, 만약 다른 commenter가 document를 find해오고 save(update)하는 사이에 자신의 comment를 지우면 잘못된 sub-document를 지목할 수 있다는 점이다. 더 일반적으로 말하자면 comments array에 수행하는 연산이 어느 comment의 index를 바꾸는 원인이 된다면 문제상황이 발생할 수 있다는 것이다.

이러한 문제를 완화시키기 위해서, Mongoose v3는 각각의 document에 schema-configurable version key를 추가했다. 이 value는 array에 대한 수정이 잠재적으로 array element의 position을 바꾼다면 이를 atomic하게 증가시킨다. 이 값은 positional notation을 사용하는 update의 where clause에는 언제나 사용된다. 만약 where clause에서 여전히 내가 update하려는 document와 match가 된다면, 다른 여느 연산이 array element position을 변경하지 않았다고 확신해주고 positional syntax를 사용할 수 있게 해준다.

우리의 이전 예제로 돌아가면, v3에서 우리의 update command는 아래와 같다:

posts.update({ _id: postId, __v: versionNumber }, { $set: { 'comments.3.body': updatedText }})

만약 version이 matching되지 않거나 document가 collection으로부터 제거되었다면 다음과 같은 error가 나오고 이것을 application에서 처리해주면 된다:

post.save(func(err) { 
	console.log(err); // Error: No Matching document found. 
});
$pull, $pullAll $pop $set operation에서 versionKey의 증가가 발생한다.

중간정리

위의 내용들을 종합해보면 versionKey 옵션은 optimistic locking 기법을 사용하는 Mongoose의 update 처리 방식이다.
하지만 array 그 중에서도 index가 바뀌는 경우에만 versionKey가 사용되기 때문에 완전한 optimistic locking이라고 볼수는 없다. 이를 위해서 Mongoose의 schema에는 optimisticConcurrency 라는 option이 있다.

optimisticConcurrency option

Optimistic concurrency란 위에서 설명한 optimistic locking과 같은 내용이다. 이는 document가 findfindOne로 document를 찾고 save를 사용하는 사이에 update시키려는 document가 수정되지 않았음을 보장해주는 기법이다.

예를들어 House model은 photos라는 list와, status로 이 집이 search에 보이는지 아닌지를 알려주는 property들이 있는 상황이다. statusAPPROVED가 된 집은 반드시 최소한 2개이상의 photos를 가지고 이써야 한다. House document를 approving하는 로직을 수행하는 다음과 같은 함수가 있다:

async function markApproved(id) {
  const house = await House.findOne({ _id });
  if(house.photos.length < 2) {
    throw new Error('House must have at least two photos!');
  }
  
  house.status = 'APPROVED';
  await house.save();
}

이렇게 보아서는 markApproved 함수가 올바른 isolation을 가지고 있는것으로 보일수 있지만, 잠정적인 위험성이 존재한다: 누군가 findOnesave 사이에 photos를 삭제한다면?

const house = await House.findOne({ _id });
if(house.photos.length < 2) {
  throw new Error('House must have at least two photos!');
}

const house2 = await House.findOne({ _id });
house2.photos = [];
await house2.save();

// Marks the house as 'APPROVED' even though it ahs 0 photos!
house.status = 'APPROVED';
await house.save();

왜냐하면 위에서 설명한 $push, $pullAll, $pop, $set의 operation을 사용하지 않고서 array를 수정했기 때문에 versionKey는 증가하지 않는다. 그렇기 때문에 house에서 status를 수정해서 save를 할 때 error가 발생하지 않았다.

만약 optimisticConcurrency option을 설정했다면, 위의 script는 error를 throw 한다.

const House = mongoose.model('House', Schema({
  status: String,
  photos: [String]
}, { optimisticConcurrency: true }));

...

// Throws 'VersionError': No matching document found for id "..." version 0'
house.status = 'APPROVED';
await house.save();

출처

https://mongoosejs.com/docs/guide.html#versionKey
https://mongoosejs.com/docs/guide.html#optimisticConcurrency
https://stackoverflow.com/questions/129329/optimistic-vs-pessimistic-locking

profile
SwiftUI, node.js와 지독하게 엮인 사이입니다.

0개의 댓글