이 글은 데이터 중심 애플리케이션 서적을 참고했습니다.
https://www.yes24.com/Product/Goods/59566585
데이터 중심 애플리케이션 설계 - 예스24
데이터는 오늘날 시스템을 설계할 때 마주치는 많은 도전 과제 중에서도 가장 중심에 있다. 확장성, 일관성, 신뢰성, 효율성, 유지보수성과 같은 해결하기 어려운 문제를 파악해야 할 뿐 아니라
www.yes24.com
보통 트랜잭션 격리 수준으로 말하는 Read Uncommitted, Read Committed, Repeatable Read, Serializable 에 대해서는 많이들 공부한다. 거기서 일어나는 Dirty Read, Unrepeatable Read, Phantom Read 와 같은 문제를 다룬다.
이것들은 어떤 쓰기 트랜잭션이 있을 때, 다른 읽기 트랜잭션이 어디까지 읽을 수 있느냐 에 관한 관점이다.
이 게시글을 서로 다른 쓰기 트랜잭션이 있을 때 일어날 수 있는 문제점(wirte-write conflict)을 다룰 것이다.
더티 쓰기 (dirty write)
- "더티" 라는 것은 아직 커밋되지 않은 것을 말한다. 즉, 더티 쓰기는 아직 커밋되지 않은 값을 덮어버리는 것을 말한다.
Read Committed 격리 수준은 더티 읽기와 더티 쓰기를 해결하는 수준이다.
즉 대부분의 데이터베이스는 적어도 Read Committed 수준의 격리 수준은 제공하므로 더티 쓰기는 대부분의 데이터베이스 격리 수준에서 발생하지 않는다.
해결책을 알아보자.
Lock(잠금)
더티 쓰기를 막기 위한 해결법으로는 Lock(잠금)을 사용한다. 단지 쓰기를 하기 전에 해당 트랜잭션에 대해 Lock을 얻고 다른 트랜잭션이 쓰지 못하게 하면 되는 것이다.
갱신 손실 (lost update)
더티 쓰기 시나리오를 생각해보자. 커밋되지 않은 값은 덮어버리면 안되는 것은 당연하다. 하지만, 커밋이 된 값이라도 덮어버리면 안될 수 있다.
어떤 트랜잭션을 A와 B가 동시에 읽어 A는 100을 빼고 B는 100을 더한 상황이라면, 값은 변하지 않아야 맞는 것이다.
하지만 실제로는 A와 B 중 일찍 커밋한 사람의 쓰기는 무시되고 나중에 쓴 것이 먼저 쓴 것을 때려눕힌다 (clobber) 고 표현한다.
- 갱신 손실은 나중에 커밋된 값이 먼저 커밋된 값의 변경점을 모르고 덮어쓰는 것이다.
잠금만으로는 해결이 안된다. 해결책을 알아보자.
원자적 쓰기 연산
SQL의 UPDATE 문 같은 연산이 대표적이다. 객체에 독점적인(exclusive) lock을 획득한다. 아예 다른 트랜잭션은 읽지도 못하게 한다. 이를 커서 안정성 (cursor stability) 라고 부르기도 한다.
하지만 모든 쓰기가 원자적 쓰기로 표현될 수는 없다.
명시적인 잠금
코드 상에서 명시적으로 잠근다. 보통 SELECT ~ FOR UPDATE 문을 통해 구현한다.
SELECT * FROM table WHERE id = 1 FOR UPDATE;
이 SQL을 실행하면 내가 해당 객체를 UPDATE 하기 전까지 객체를 읽지 못하는 exclusive lock을 획득한다.
코드에서 항상 기억하고 직접 구현해야 하기 때문에 잊기 쉽다.
갱신 손실 자동 감지
방금 두 방법은 완전히 직렬적으로 수행하는 것을 강제했다. 대안으로 병렬 실행을 허용하고 갱신 손실을 발견하면 abort 시킨 후 read-modify-write 의 read부터 재시도할 수도 있다.
이는 Repeatable Read와 잘 맞아떨어진다. 실제로 PostgreSQL, Oracle(Serializable), SQLserver 는 이 기능을 사용한다. 하지만 MySQL/InnoDB는 이를 사용하지 않는다.
Compare and set
UPDATE table SET name = 'new name' WHERE id = 1 AND name = 'old name'
위와 같이 값을 마지막으로 읽은 후로 변경되지 않았을 때만 갱신을 허용한다. 실제로 많이 사용하는 방식이라고 한다.
하지만 WHERE 절에서 읽는 값이 오래된 스냅샷에서 읽는 것을 허용한다면 갱신 손실을 막지 못한다.
복제가 적용된 데이터베이스에서는...?
이런 상황은 다른 차원의 문제다. 갱신 손실 방지에 추가 단계가 필요하다.
우선, 명시적인 잠금과 compare and set 연산은 최신 복사본이 하나만 있다고 가정하지만, 이걸 보장할 수 없다. 따라서 적용할 수 없다.
원자적 연산은 잘 동작한다. 특히 교환 법칙이 성립하는 연산이라면 (다른 복제본에 다른 순서로 연산을 적용해도 같은 결과가 나온다면) 그렇다.
반면 LWW 라는 충돌 해소 방법이 있다. 많은 분산 데이터베이스가 기본 설정으로 사용하는 이 방법은 유감스럽게도 갱신 손실이 발생하기 쉽다.
쓰기 스큐 (write skew)
갱신 손실 문제는 다른 트랜잭션이 같은 객체에 동시에 쓸 때의 문제이다. 하지만 다른 트랜잭션이 각기 다른 객체에 동시에 쓸 때도 문제가 발생할 수 있다.
예를 들어, 닉네임이 유일한 게임에서 같은 닉네임으로 동시에 바꾸려고 할 때, 서버는 두 명 모두에게 해당 닉네임이 없으니까 바꾸는 것을 허락하는 경우이다.
각자 자기 캐릭터에 갱신을 했으므로 같은 객체에 갱신을 하지는 않았지만 문제가 발생한다.
갱신 손실 문제가 일반화된 것으로 생각할 수 있다. 즉, 쓰기 스큐는 두 트랜잭션이 같은 객체를 읽어서 어떤 것을 갱신하려고 할 때 나타나는 문제이고, 그 중 하나의 객체를 갱신하려 하는 특별한 경우에 갱신 손실이나 더티 쓰기가 발생된다.
해결법
여러 객체가 관련되므로 원자적 쓰기 연산은 못 쓴다. 갱신 손실 자동 감지도 이걸 감지하지 못한다.
따라서 다음과 같은 방법을 써야 한다.
- 진짜 직렬성 격리를 사용한다.
- 제약 조건을 상세히 설정한다. 예를 들어 위의 경우 닉네임에 유니크 제약조건을 걸면 된다.
- 명시적인 잠금을 사용한다. (책에서는 이것을 직렬성 격리를 사용하지 못하는 경우의 차선책으로 설명했다. 아마도 개발자가 쿼리마다 하나하나 생각해야 하니까 관리 측면에서 별로라고 한 듯하다.)