배경
지난 글에서 Lock에 대해 알아보았습니다. 그 글에서 Lock은 트랜잭션 격리수준을 구현하는 방법중 하나라고 했습니다. 그럼 트랜잭션 격리수준은 뭘까요? 이에 대해 알아봅시다
내용
[ Transaction ]
먼저 Transaction에 대해 알아보죠. 왜 트랜잭션을 쓸까요? 우리는 DB 데이터를 수정하고 조회하는 일을 빈번하게 하고 있습니다. 도중에 예외가 발생하게 되면 이전이 상태로 되돌아가야 하는데 이렇게 작업을 진행하다가 문제가 생겼을 경우 이전 상태로 롤백하기 위해 사용되는 것이 트랜잭션입니다. 또한 트랜잭션은 더이상 쪼갤 수 없는 최소 작업단위를 의미합니다. 보통 commit과 rollback으로 관리가 되어지죠. 이런 트랜잭션을 쓰는 가장 큰 이유 중 하나는 돈과 관련된 문제가 크기 때문입니다. 예를들어서 요즘 간편하게 app을 통해서 돈을 주고받는데 트랜잭션이 없다면 어떻게 될까요? 장애가 터지게 되면 돈이 증발해버리는 일이 빈번하게 발생할 수도 있겠죠. 트랜잭션에 대한 자세한 이야기는 다음 글에서 해보도록 합시다.
[ 트랜잭션 격리 수준(Isolation Level) ]
보통 하나의 트랜잭션으로 서비스가 이루어지는 일은 거의 없겠죠? 대부분 엄청나게 많은 요청으로 인해 수많은 트랜잭션이 생길겁니다. 이런 트랜잭션들이 오고가는데 당연히 교통정리같은 부분이 필요할겁니다. 이를 하는 것이 트린잭션 격리 수준을 적용하는 것입니다. 트랜잭션의 격리수준은 여러 트랜잭션이 동시에 처리될 때, 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있게 허용할지, 막아놓을지 등을 결정하는 것이죠. 격리 수준은 높은 순서대로 4가지 정도 존재합니다. 모두 자동 커밋(MySQL에서의 AUTO COMMIT)이 false인 상태에서만 발생합니다.
- SERIALIZABLE
- REPEATABLE READ
- READ COMMITTED
- READ UNCOMMITTED
< SERIALIZABLE >
SERIALIZABLE은 가장 엄격한 격리 수준이고 트랜잭션을 순차적으로 진행시킵니다. 동일한 레코드에 동시에 접근이 불가능하기 때문에 처리 성능이 매우 떨어지게 되죠. 순수 SELECT 작업에서도 대상 레코드에 넥스트키 락을 읽기 잠금(공유락)으로 걸어버려서 다른 트랜잭션에서는 절대로 추가/수정/삭제가 불가능합니다. 성능이 매우 떨어지기 때문에 일반적으로는 잘 사용하지 않는다고 합니다.
< REPEATABLE READ >
일반적인 RDBMS는 변경 전에 레코드를 Undo(언두) 공간에 백업을 해둡니다. 이렇게 되면 변경 전/후 데이터가 모두 존재해서 하나의 데이터에 여러 버전의 데이터가 존재한다고 하여 MVCC(Multi-Version Concurrency Control)이라고도 부르죠. 우리가 트랜잭션을 이용하여 DB에 접근할 때는 각 트랜잭션마다 고유한 번호가 존재합니다. 이를 통해 백업 레코드에는 어느 트랜잭션에 의해 백업 되었는지 트랜잭션 번호도 함께 저장하게 되죠. 그리고 불필요해진다고 판단되는 시점에 주기적으로 백그라운드 쓰레드를 통해 삭제를 진행합니다.
REPEATABLE READ는 MVCC를 이용해 한 트랜잭션 내에서 동일한 결과를 보장하지만, 새로운 레코드가 추가되는 경우에 부정합이 발생될 수 있습니다. 먼저 REPEATABLE READ 동작 방식에 대해 살펴보죠.
사용자 B의 트랜잭션(T-ID=10)은 사용자 A의 트랜잭션(T-ID=12)이 시작하시 전이 이미 시작된 상태입니다. 테이블이 자신보다 이후에 실행된 트랜잭션 데이터가 존재한다면 언두 로그를 참고해서 조회합니다. 따라서 사용자 A의 트랜잭션이 시작되고 커밋 되었지만, 사용자 B의 트랜잭션보다 늦게 시작되었기 때문에 사용자 B의 트랜잭션은 언두 로그를 참고해서 동일한 데이터를 얻게 됩니다.
REPEATABLE READ는 레코드의 추가까지는 막지 않습니다. 하지만 조회는 동일한 결과를 가져오죠. 그럼 언제 문제가 될까요? 바로 베타적 잠금(비관적 잠금)을 건 경우입니다. 이때는 유령읽기(Phantom Read)라는 현상이 발생할 수 있죠. 이유는 잠금이 있는 읽기는 데이터 조회가 언두 로그가 아닌 테이블에서 수행되기 때문입니다.
위 그림을 보시면 사용자 B 트랜잭션에서 처음 조회 시 1건만 존재되었지만, 도중에 사용자 A 트랜잭션에서 commit이 된 후 다시 사용자 B 트랜잭션에서 조회 시 2건을 가져오게 됩니다. 이렇듯 다른 트랜잭션에 의해 다른 트랜젹션에서 수행한 작업에 의해 레코드가 안보였다 보였다 하는 현상을 Phantom Read(유령 읽기)라고 합니다.
하지만 MySQL DB에서는 이런 상황이 발생하지 않는데요, 이는 Mysql에 갭 락이 존재하기 때문입니다. 예를들어서
사용자 B가 SELECT FOR UPDATE로 데이터를 조회한 경우에 MySQL은 id가 50인 레코드에는 레코드 락, id가 50보다 큰 범위에는 갭 락으로 넥스트 키 락을 겁니다. 따라서 사용자 A가 id가 51인 member를 INSERT 시도한다면, B의 트랜잭션이 종료(커밋 또는 롤백)될 때 까지 기다리다가, 대기를 지나치게 오래 하면 락 타임아웃이 발생 하도록 하는거죠.
REPEATABLE READ를 보시면 트랜잭션 내에서 실행되는 SELECT와 트랜잭션 없이 실행되는 SELECT의 차이도 조금씩 느껴지실 겁니다. 트랜잭션이 있어도 정합성이 깨지는 경우가 있는데, 없으면 정합성이 깨지게 되는 현상이 빈번하겠죠?
< READ COMMITTED >
READ COMMITTED는 커밋된 데이터만 조회 할 수 있습니다. 이 경우엔 유령 읽기에 더해 반복 읽기 불가능(Non-Repeatable Read)문제까지 발생할 수 있습니다.
위 그림의 상황을 보게 되면 사용자 B가 트랜잭션을 시작하고 name = “Minkyu”인 레코드를 조회했다고 합시다. 해당 조건을 만족하는 레코드는 아직 존재하지 않으므로 아무 것도 반환되지 않습니다. 이후 사용자 A가 update를 통하여 해당 조건을 만족하는 레코드가 생기고 다시 B쪽에서 동일한 조건으로 조회를 하게 되면 COMMIT된 결과를 볼 수 있으므로 1건을 가지고 올 수 있게 됩니다. 이렇게 되면 사용자 B입장에서는 동일한 트랜잭션이지만 다른 트랜잭션의 커밋 여부에 따라 결과가 달라지죠. 이러한 데이터 부정합 문제를 반복 읽기 불가능(Non-Repeatable Read)라고 합니다.
< READ UNCOMMITTED >
READ UNCOMMITTED는 커밋하지 않은 데이터 조차도 접근할 수 있는 격리 수준입니다.
위 그림을 보게 되면 COMMIT을 치지 않았는데도 테이블의 수정이 일어난 것을 조회하여 가져오는 현상이 있습니다. 이렇게 어떤 트랜잭션 작업이 완료되지 않았는데도 다른 트랜잭션에서 볼 수 있는 부정합 문제를 오손 읽기(Dirty Read)라고 합니다. 따라서 READ UNCOMMITTED는 RDBMS 표준에서 인정하지 않을 정도로 정합성에 문제가 많은 격리 수준입니다. MySQL을 사용한다면 최소한 READ COMMITTED 이상의 격리 수준 사용을 추천합니다.
정리
앞서 본 트랜잭션 격리 수준을 정리한 내용입니다.
격리 수준이 높아질 수록 MySQL의 처리 성능이 많이 떨어질 것 같지만, SERIALIZABLE이 아니라면 크게 성능 저하는 발생하지 않는다고 합니다. 결국 언두 로그를 통해 레코드를 참조하는 과정이 거의 동일하기 때문이죠. 그래서 Mysql은 REPEATABLE READ를 사용하고 있습니다.
좋은 공부가 된 것 같아요. 감사합니다!
참고
https://mangkyu.tistory.com/299