과정을 즐기자

MVCC 만으로 팬텀 리드를 막을 수 있을까? 본문

Database

MVCC 만으로 팬텀 리드를 막을 수 있을까?

320Hwany 2024. 10. 27. 17:27

이번 글에서는 RDBMS의 MVCC가 팬텀 리드를 막을 수 있는지 알아보겠습니다.

먼저 MVCC, 팬텀 리드에 대한 용어부터 정리해보겠습니다.

 

MVCC는 Multi Version Concurrency Control의 약자로 레코드 값을 여러 버전으로 관리한다는 것을 의미합니다.

MVCC를 사용함으로써 잠금 없는 읽기를 제공하면서 여러 트랜잭션의 동시 처리 성능을 높였습니다.

 

팬텀 리드는 같은 트랜잭션 내부에서 같은 쿼리를 실행했을 때 새로운 레코드가 보일 수 있는 경우를 말합니다.

같은 레코드의 데이터 자체는 바뀌지 않지만 새로운 데이터가 추가되거나 삭제된 경우는 쿼리의 결과값이 동일하게

나오지 않을 수 있습니다.

📕 예제 만들어보기

여기서 한가지 의문점이 들었습니다.

DB 격리 레벨에서 Serializable을 제외한 나머지는 모두 팬텀 리드가 발생할 수 있다는 것입니다.

MVCC로 각 레코드 데이터를 버전별로 관리하고 해당 트랜잭션의 버전에 맞게 새로운 데이터가 추가되거나 삭제된 경우는

보여주지 않으면 될 것 같은데 왜 팬텀 리드가 발생하는 것일까요?

 

트랜잭션 2개를 실행해보면서 알아보겠습니다. (일반적인 RDBMS의 MVCC를 사용하는 Repeatable Read 수준이라고 가정)

-- Transaction 1
BEGIN;
SELECT COUNT(*) FROM member WHERE age BETWEEN 20 AND 30;  -- 결과: 10

-- Transaction 2 (동시 실행)
BEGIN;
INSERT INTO member (name, age) VALUES ('홍길동', 25);
COMMIT;

-- Transaction 1
SELECT COUNT(*) FROM member WHERE age BETWEEN 20 AND 30;  -- 결과: 10
COMMIT;

 

위 쿼리에서는 Transaction1 에서 같은 쿼리를 2번 실행하였는데 모두 결과가 10 이 나왔습니다.

Transaction2의 커밋을 통해 새로운 레코드가 추가되었지만 그 결과가 Transaction1에 영향을 주지는 않았습니다.

 

Transaction1의 시작 시점의 버전을 1이라고 하고 Transaction2의 시작 시점을 버전 2라고 하면

Transacion1에서 두 번째 쿼리를 시작할 때 시작 시점의 버전인 1 버전에 대한 데이터를 읽어옵니다.

따라서 새롭게 추가된 ("홍길동", 25) 회원의 정보는 조회하지 않아 결과가 그대로 10이 나오게 되는 것입니다.

 

위 경우에는 팬텀 리드가 발생하지 않은 것입니다. 그러면 MVCC가 팬텀 리드를 막을 수 있는 것일까요?

📘 문제는 Lock을 사용할 때 발생

위와 같이 Lock을 사용하지 않고 SELECT, SELECT 쿼리를 실행한다면 항상 트랜잭션의 시작 시점의 데이터를 읽어오기 때문에

MVCC 만으로도 팬텀 리드를 막을 수 있습니다. 문제는 Lock을 사용할 때 발생합니다.

위 예시를 조금 변형해서 SELECT 쿼리에 write lock을 사용해 보겠습니다.

 

-- Transaction 1 시작
BEGIN;
SELECT COUNT(*) FROM member WHERE age BETWEEN 20 AND 30 FOR UPDATE;  -- 결과: 10

-- Transaction 2 시작 (동시 실행)
BEGIN;
INSERT INTO member (name, age) VALUES ('홍길동', 25);
COMMIT;

-- Transaction 1 
SELECT COUNT(*) FROM member WHERE age BETWEEN 20 AND 30 FOR UPDATE;  -- 결과: 11
COMMIT;

 

이번에는 Transaction1 에서 같은 쿼리를 2번 실행했지만 결과가 다르게 나왔습니다. 

마찬가지로 Transaction1의 시작 시점의 버전을 1이라고 하고 Transaction2의 시작 시점을 버전 2라고 하면

Transacion1에서 두 번째 쿼리를 시작할 때 1 버전에 대한 데이터가 아니라 해당 레코드의 가장 최신 버전에 대한 데이터를

읽어오게 됩니다. 따라서 새롭게 삽입된 ("홍길동", 25) 회원의 정보를 추가로 조회하여 결과가 11이 나오게 됩니다.

 

즉 MVCC를 사용하더라도 Lock을 사용한다면 트랜잭션 시작 버전이 아닌 가장 최신 버전을 가져오기 때문에

팬텀 리드가 발생할 수 있게 됩니다.

📗 MySQL의 갭락 (Gap Lock)

따라서 대부분의 RDBMS의 격리 레벨 Repeatable Read 수준에서는 MVCC를 사용하더라도 팬텀 리드가 발생할 수 있습니다.

하지만 MySQL의 경우에는 대부분의 경우 팬텀 리드가 발생하지 않습니다.

MySQL은 MVCC를 제공하면서도 갭락이라는 Lock 또한 제공하기 때문입니다.

 

MySQL은 갭락을 이용해서 레코드와 레코드의 사이의 간격을 잠금으로써 레코드의 생성, 수정, 삭제를 제어합니다.

다른 RDBMS에서 막을 수 없었던 팬텀 리드를 어떠한 방식으로 막을 수 있는지 이전 예제를 다시 살펴보겠습니다.

-- Transaction 1 시작
BEGIN;
SELECT COUNT(*) FROM member WHERE age BETWEEN 20 AND 30 FOR UPDATE;  -- 결과: 10

-- Transaction 2 시작 (동시 실행)
BEGIN;
INSERT INTO member (name, age) VALUES ('홍길동', 25); 
-- Transaction 2는 BLOCKING

-- Transaction 1 
SELECT COUNT(*) FROM member WHERE age BETWEEN 20 AND 30 FOR UPDATE;  -- 결과: 10
COMMIT;

-- Transaction 1이 COMMIT된 후에야
-- Transaction 2의 INSERT가 실행됨
-- (Transaction 2의 COMMIT 실행)
COMMIT;

 

 

Transaction1의 첫 번째 쿼리를 실행할 때 age 20 ~ 30 범위의 기본 레코드에 대한 락 뿐만 아니라 그 사이의

빈 공간에 대한 락을 획득합니다.

이때 Transaction 2가 age 25라는 새로운 데이터를 추가하려고 하지만 이미 Transaction 1이 age 20 ~ 30 범위에는

갭락을 걸어둔 상태입니다. 따라서 해당 insert 문은 Transaction 1이 커밋되거나 롤백될 때까지 Blocking 됩니다.

결과적으로 Transaction1의 두 번째 쿼리의 결과도 마찬가지로 10이라는 결과를 반환하게 됩니다.

📒 갭락으로도 막을 수 없는 팬텀 리드의 발생 상황

지금까지 같은 트랜잭션 내부에서 SELECT, SELECT를 실행한 경우 SELECT FOR UPDATE, SELECT FOR UPDATE를

실행한 2가지 경우를 살펴보았습니다. 기존 내용을 다시 한번 정리하면서 추가적으로 조금 더 다양한 케이스를 살펴보겠습니다. 

편의상 Transaction1, Transaction2를 각각 T1, T2 라고 표현하며 마찬가지로 Repeatable Read 격리 레벨에 대한 케이스입니다.

 

1. SELECT (T1)  -> INSERT (T2) -> SELECT (T1)

 

일반적인 RDBMS 라면 MVCC를 사용해서 팬텀 리드를 막을 수 있었습니다.

트랜잭션 시작 시점의 버전에 대한 데이터를 일관적으로 읽어올 수 있기 때문입니다.

 

2. SELECT FOR UPDATE (T1) -> INSERT (T2) ->  SELECT FOR UPDATE (T1) 

 

이 경우에는 Lock을 사용하여 최신 버전의 데이터를 읽어 오기 때문에 MVCC로만으로는 팬텀 리드를 막을 수 없습니다.

특별히 MySQL에서만 갭락을 사용하여 팬텀 리드를 막을 수 있습니다.

 

3. SELECT FOR UPDATE (T1) -> INSERT (T2) -> SELECT (T1)

 

마찬가지로 MVCC 만으로는 팬텀리드를 막을 수 없고 T1의 첫 번째 쿼리를 실행할 때 갭락이 걸려 T1의 두 번째 쿼리도

같은 결과를 반환할 수 있도록 해줍니다.

 

4. SELECT (T1) -> INSERT (T2) -> SELECT FOR UPDATE (T1)

 

바로 4번의 경우가 MySQL을 사용하더라도 문제가 발생할 수 있는 경우입니다.

T1의 첫 번째 쿼리를 실행할 때 갭락이 사용되지 않았는데 T1의 두 번째 쿼리를 실행할 때는 최신 데이터를 읽어오게 됩니다.

따라서 T1에서의 쿼리 결과가 달라지는 경우가 발생합니다.

📚 정리

이번 글에서는 MVCC, 팬텀 리드에 대해 정리해보고 MVCC 만으로 팬텀 리드를 막을 수 없는 이유에 대해 알아보았습니다.

MVCC 만으로는 막을 수 없었지만 MySQL은 갭락을 이용하여 대부분의 경우 팬텀 리드를 막을 수 있다는 것을

여러 가지 케이스로 나눠서 살펴보았습니다.

 

다른 여러 RDBMS는 대부분 Read Commited을 기본 격리 레벨로 설정하는데 MySQL InnoDB 엔진은 기본적으로

Repeatable Read를 기본 격리 레벨로 사용합니다.

 

왜 Repeatable Read를 기본 레벨로 설정했을까를 생각해보면 MySQL의 갭락이 큰 이유라고 생각합니다.

갭락을 사용하면서 MVCC 만으로는 막을 수 없었던 팬텀 리드의 대부분의 경우를 막을 수 있게 되었습니다.

또한 Read Commited 과 비교하여 조금 더 관리하는 버전이 많아져 언두 로그의 데이터가 더 많이 쌓일 수도 있고

갭락으로 인해 데드락이 발생할 수 있는 가능성이 많아졌지만 성능상 큰 차이점이 없다고 알려진 것으로 보아

"성능도 챙기고 데이터 일관성을 더 챙길 수 있는 Repeatable Read 수준을 채택하지 않았을까" 라는 생각을 해봅니다.

 

 

출처

 

[MySQL] 스토리지 엔진 수준의 락의 종류(레코드 락, 갭 락, 넥스트 키 락, 자동 증가 락)

이번에는 스토리지 엔진 수준의 락의 종류에 대해 알아보도록 하겠습니다. 아래의 내용은 RealMySQL과 MySQL 공식 문서 등을 참고하여 작성하였으며, 모든 내용은 InnoDB를 기준으로 설명합니다. 1. 스

mangkyu.tistory.com