2 minute read

커밋된 읽기(read committed)와 스냅샷 격리 (snapshot isolation) 는 동시에 쓰기를 수행할 때 읽기 전용 트랜잭션이 무엇을 보는지를 결정한다.

반면, 서로 다른 트랜잭션이 동시에 쓰기를 할 경우 더티 읽기와 갱신 손실(Lost Updates) 문제가 발생한다.

갱신 손실 시나리오

  • 조건: 두 트랜잭션이 동시에 객체를 읽고 수정을 한 다음 수정된 값을 갱신한다.
  • 결과: 두 트랜잭션 중 하나는 의도한 대로 갱신을 하지 못한다.

원자적 쓰기

많은 데이터베이스가 원자 업데이트 연산을 제공하므로 애플리케이션 코드에서 읽기-수정-쓰기 주기를 구현할 필요가 없다. 일반적으로 코드를 이러한 연산으로 표현할 수 있는 경우 가장 좋은 솔루션이다.

원자적 쓰기는 커서 안정성을 사용한다.

원자 연산은 일반적으로 객체를 읽을 때 객체에 독점 잠금을 설정하여 업데이트가 적용될 때까지 다른 트랜잭션이 객체를 읽을 수 없도록 하는 방식으로 구현된다. 이 기법은 커서 안정성이라고도 한다.

안타깝게도 객체 관계형 매핑(ORM) 프레임워크에서는 데이터베이스에서 제공하는 원자 연산을 사용하지 않고, 읽기-수정-쓰기 주기를 수행한다. 애플리케이션에서 이를 수행할 경우 테스트에서 발견하기 어려운 버그의 원인이 되기도 한다.

명시적 잠금

데이터베이스가 원자적 연산을 제공하지 않는다면 어떤 방법을 사용해야 할까? 애플리케이션이 업데이트할 개체를 명시적으로 잠그면 된다. 그러면 다른 트랜잭션이 동일한 객체를 동시에 읽으려고 시도하면 첫 번째 읽기-수정-쓰기 사이클이 완료될 때까지 강제로 대기해야 한다.

명시적 잠금의 예는 멀티 플레이어 게임이다. 멀티 플레이어 게임에서는 동시에 같은 물체를 움직이지 못한다. 게다가 데이터베이스가 기본적으로 제공하는 원자적 연산에 비해 일부 게임의 로직을 추가할 수 있다.

갱신 손실을 자동으로 감지하기

원자적 연산과 잠금은 읽기-수정-쓰기 싸이클을 순차대로 강제하여 갱신 손실을 막는 방법이다.

갱신 손실을 방지하는 또 다른 방법은 트랜잭션 매니저가 갱신 손실을 감지하여 다시 읽기-수정-쓰기 싸이클을 재시도하도록 하는 것이다. 이는 스냅샷 격리외 함께 효과적으로 검사할 수 있는 방법이다.

예를 들어, PostgreSQL의 repeatable read, Oracle의 serializable, 그리고 SQL Server의 snapshot isolation 은 갱신 손실을 자동으로 감지한다. 반면 InnoDB 기반의 MySQL의 repeatable read 는 갱신 손실을 자동으로 감지하지 않는다.

자동으로 갱신 손실을 감지하면 이점이 무엇일까? 애플리케이션 코드가 격리 수준에 맞춰 경합을 고려하지 않아도 된다. 잠금이나 원자적 연산을 미처 구현하지 못했을 때 발생하는 버그들을 막을 수 있다.

비교 후 갱신 Compare-and-set

트랜잭션을 제공하지 않는 데이터베이스는 원자적 비교-후-갱신 연산을 사용한다. 이 연산은 값을 갱신하려고 할때, 트랜잭션 시작 시 읽은 값이 변하지 않아야만 갱신을 허용해주는 연산이다.

예를 들어, 두 명의 사용자가 동시에 위키 페이지를 갱신한다고 가정하자. 이 페이지의 내용을 읽고 나서 갱신하려고 할 때 내용이 그대로여야지 갱신할 수 있다.

다음은 비교-후-갱신의 가장 일반적인 구현이다.

UPDATE wiki_pages
SET content = 'new content'
WHERE id = 1234 AND content = 'old content';

만약, 갱신을 하려는 시점에 content가 old content와 일치 하지 않는다면, 이 연산을 재시도해야한다. 하지만, 데이터베이스가 이전 스냅샷을 볼 수 있게 한다면, 이 문장은 갱신 손실을 막을 수 없을 것이다. 왜냐하면 다른 트랜잭션이 이 값을 갱신하더라도 나의 트랜잭션에는 이전 스냅샷만 볼 수 있기 때문이다. 따라서, 비교-후-갱신 연산을 수행할때, 데이터베이스의 격리 수준과 스냅샷 격리 지원 여부를 확인해야 한다.

충돌 해결과 복제

복제된 데이터베이스에서 갱신 손실을 막는 것은 또 다른 문제다. 복제 데이터베이스는 여러 노드에 데이터가 복제되어 있고, 데이터가 다른 노드에서 언제든 수정될 수 있기 때문이다.

잠금과 비교-후-갱신 연산이 복제된 데이터베이스를 최신으로 유지하도록 도와줄 수 있을 것 같다. 하지만, 멀티리더나 리더리그 복제 데이터베이스는 동시적 쓰기를 허용하며 복제를 비동기적으로 수행하므로 복제 데이터베이스 최신 상태를 유지하긴 어렵다.

복제 데이터베이스의 갱신 손실을 막는 가장 보편적인 방법은 여러 버전의 데이터를 허용하는 것이다. 이후 애플리케이션 코드나 특별한 데이터 구조에서 이를 병합하여 해소하는 것이다.

예를 들어, 카운터를 증가하거나 하나의 집합에 원소를 추가하는 것은 교환법칙에 해당한다. Riak 2.0 이러한 데이터 타입이 사용된다. 한편, 충돌을 해소하는 방법인 last write wins (LWW)은 갱신 손실을 일으킬 확률이 높다. 하지만 LWW는 대부분의 복제 데이터베이스의 기본적인 방식으로 채택되었다.