최근에 Real MySQL 책을 읽었는데 아무리 읽어도 이해가 되지않았다. 나중에 다시 읽으면서 정리해나갈 예정이지만, 트랜잭션 격리 수준 만큼은 미리 정리해두고자 한다. 책에서도 나오는 개념이고, 이 개념을 알아야 책을 다시 읽을 때 - 발생 가능한 문제점들을 떠올릴 수 있을 거라 생각했기 때문이다. 일단 1차적으로 정리해두고 필요하면 2차로 다시 작성해서 정리해보자!
우선 이 글의 메인 주제인 트랜잭션 격리 수준이 무엇인지를 알아보자.
데이터베이스에서는 여러 개의 트랜잭션을 동시에 수행하고, 각 작업 모두 동일한 데이터에 접근하는 경우가 있다. 트랜잭션은 데이터를 일관되게 처리하기 위한 것임에도 동시에 같은 데이터에 접근을 하는 경우에는 문제가 발생할 수 있다. 이를 해결하기 위해 트랜잭션 격리 수준이라는 기능을 제공한다.
(트랜잭션 격리 수준의 등장 배경을 보면서 떠올렸던 것은 바로 접근 제한자의 역할이다. 전혀 관계가 없지만, 접근에 대한 제한을 걸어두는 행위가 비슷해보였다 ㅎㅎ 😅)
내가 참고한 레퍼런스의 트랜잭션 격리 수준에 대한 정의를 다시 살펴보자.
트랜잭션 격리 수준이란 동시에 여러 트랜잭션이 처리될 때, 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있도록 허용할지 말지를 결정하는 것이다. 격리 수준은 크게 4가지가 있다.
동시성 접근 제어의 수준은 SERIALIZABLE로 갈 수록 강해지고 동시 처리 성능은 낮아진다. 접근 제어를 강하게 한다는 의미는 트랜잭션이 끝나기 전까지 해당 데이터를 잠그고 다른 트랜잭션에서 참조하지 못하게 한다는 의미다.
READ UNCOMMITTED와 SERIALIZABLE은 일반적인 데이터베이스에서는 사용하지 않고, READ COMMITTED와 REPEATABLE READ 전략을 많이 사용한다고 한다.
READ UNCOMMITTED 격리 수준은 각 트랜잭션의 변경 내용이 COMMIT이나 ROLLBACK 여부와 상관없이 다른 트랜잭션에서 보여지게 되는 전략이다.
이미지에서 확인할 수 있듯이 - 사용자 A의 트랜잭션에서 새로 넣은 데이터가 커밋이 되지도 않았음에도 다른 사용자 B가 해당 데이터를 볼 수 있게 된다는 것이다.
READ UNCOMMITTED에서는 DIRTY READ 현상이 발생하는 문제가 있다.
DIRTY READ는 사용자 A가 트랜잭션 작업 중에 문제가 발생하여 새로 넣은 데이터를 ROLLBACK 하더라도 사용자 B는 정상적인 데이터라 판단하고 계속해서 처리하는 현상을 의미한다.
이처럼 데이터가 존재했다 존재하지 않았다하는 DIRTY READ 현상은 개발자와 사용자를 혼란스럽게 하며, 데이터 정합성에 문제가 많은 격리 수준이기에 최소 READ COMMITTED 이상의 격리 수준을 사용하는 것을 권장한다고 한다.
READ COMMITTED는 말 그대로 커밋이 완료된 데이터만 조회할 수 있는 격리 수준이다. 가장 많이 사용하는 전략이며, DIRTY READ와 같은 문제가 발생하지 않는다.
즉, 사용자 A가 데이터를 수정할 때 Undo 영역으로 기존의 값을 백업하고, 사용자 B가 데이터를 읽을 때 Undo 영역에 백업된 기존의 값을 읽어온다는 것이다. 수정 내용이 커밋되면, 그때부터는 백업된 Undo 영역을 읽는 것이 아닌 새롭게 수정된 내용을 참조하게 된다.
💡 Undo 영역?
나중에 책을 다시 읽으면서 정리할 예정이니 짧게만 정리하자면, Undo 영역은 UPDATE, DELETE 문장으로 데이터를 변경했을 때 변경되기 전의 데이터를 보관하는 곳을 의미한다.
READ COMMITTED에서는 NON-REPEATABLE READ라는 부정합 문제가 발생한다.
전체적인 흐름에는 문제가 없어보이나, 사용자 B의 트랜잭션이 진행중이라는 것을 생각하면 말이 달라진다. 이미지처럼 사용자 B의 하나의 트랜잭션 작업에서 두 번의 동일한 조회가 이루어지는데, 각 조회마다 결과가 달라진다는 부분에서 문제가 있다는 것이다.
즉, 동일한 조회 쿼리를 실행했는데도 항상 같은 결과를 보장해야 한다는 REPEATABLE READ 정합성에 어긋나게 된다. 일반적인 경우라면 해당 격리 수준을 이용해도 큰 문제는 없지만, 하나의 트랜잭션에서 동일한 데이터를 여러번 읽고 변경하는 작업이 빈번한 금전적인 처리같은 경우에는 문제가 될 수 있다.
예를 들어, 입금과 출금 처리가 하나의 트랜잭션에서 동작하고 있고 그 과정중에 총 금액을 조회한다고 했을 때를 생각하면 된다. (입금 후 조회한 금액과 출금 후 조회한 금액은 모두 다르니까!)
REPEATABLE READ는 InnoDB 스토리지 엔진에서 기본적으로 사용되는 격리 수준이다. 이 격리 수준에서는 NON-REPEATABLE READ 부정합 문제가 발생하지 않는다.
InnoDB 스토리지 엔진은 트랜잭션이 ROLLBACK될 가능성에 대비해 변경되기 전 레코드를 Undo 영역에 백업해두고 실제 레코드 값을 변경하는 과정으로 이루어지며, 이를 MVCC(Multi Version Concurrency Control)라고 한다. 그리고 REPEATABLE READ는 이 MVCC를 위해 Undo 영역에 백업된 이전 데이터를 통해 동일한 트랜잭션 내에서는 동일한 결과를 보장해준다.
InnoDB에서는 각 트랜잭션에는 순차적으로 고유한 번호가 매겨지고 Undo 영역에 백업된 모든 데이터에는 변경을 발생시킨 트랜잭션의 번호가 포함되어있으며, Undo 영역의 백업된 데이터는 InnoDB 스토리지 엔진이 불필요하다 판단하는 시점에 주기적으로 삭제된다. 여기서의 핵심은 MVCC를 보장하기 위해 실행중인 트랜잭션 가운데 가장 오래된 트랜잭션 번호보다 더 앞선 Undo 영역의 데이터는 삭제할 수 없다는 점이다.
즉, 각 트랜잭션의 아이디를 이용하여 트랜잭션 내에서 변경이 일어나기 전과 후에 대한 조회를 시도했을 때, 항상 동일한 결과를 가져오도록 하는 전략이다. 이미지를 보면서 이해를 하자면, 사용자 B의 트랜잭션은 10번이라는 고유 번호를 가지고 있고, 하나의 트랜잭션에서 두 번의 조회를 시도했을 때 자신의 트랜잭션인 10번 보다 작은 트랜잭션 번호에서 변경한 것만 보도록 하는 것이다.
PHANTOM READ는 SELECT FOR UPDATE
와 같은 쓰기 잠금을 거는 경우 다른 트랜잭션에서 수행한 변경 작업에 의해 데이터가 보였다가 안보였다 하는 현상을 의미한다.
💡
SELECT FOR UPDATE
쿼리?이 쿼리를 실행할 경우 특정 세션이 데이터에 대해 수정을 할 때까지 잠금이 걸려 다른 세션이 데이터에 접근할 수 없다. 즉, 이 쿼리문이 관여하는 데이터에 잠금이 걸려 해당 데이터에 접근할 수 없게 된다고 이해하면 된다.
원래라면 한 트랜잭션 내에서 실행되는 동일한 쿼리의 결과가 같아야하지만, SELECT FOR UPDATE
쿼리문을 사용하면 SELECT 하는 데이터에 쓰기 잠금을 걸어야하지만, Undo 영역에는 잠금을 걸 수 없기 때문에 SELECT FOR UPDATE
,SELECT LOCK IN SHARE MODE
로 조회되는 데이터는 Undo 영역의 변경 전 데이터를 가져오는 것이 아닌 현재의 값을 가져오게 된다.
(다행히 InnoDB에서는 MVCC 다중 버전 제어에 의해 REPEATABLE READ 격리 수준에도 PHANTOM READ 현상이 발생하지 않는다고 한다! 👍)
추가적으로 REPEATABLE READ에서는 성능 저하가 발생할 수도 있다. 위에서는 하나의 데이터만 Undo 영역에 백업되도록 되어있는데, 실제로는 여러 데이터가 Undo 영역에 기록이 된다.
만약 특정 트랜잭션이 오랜 시간동안 종료되지 않을 경우, Undo 영역의 백업된 데이터가 무한히 커지게 되고, 백업된 데이터가 많아지므로서 성능 저하가 발생할 수 있다.
SERIALIZABLE은 가장 엄격한 격리 수준으로, 가장 강력한 정합성을 보장함과 동시에 그 어떠한 부정합 문제가 발생하지 않지만 다른 격리 수준에 비해 동시 처리 성능이 떨어져 권장하지 않는 편이다. SERIALIZABLE 격리로 구성되어있을 경우에는 단순 읽기 작업에도 잠금을 획득해야 한다. 결국, 다른 트랜잭션에서 실행중인 트랜잭션에 절대 접근할 수 없다는 의미다.
내가 이해한 바로 요약하자면 다음과 같다.
READ UNCOMMITTED
"다른 트랜잭션에서 접근했을 때 데이터가 존재했다 존재하지 않았다 한다!"
READ COMMITTED
"한 트랜잭션내에서 실행되는 동일한 쿼리문은 항상 같은 결과가 나와야 하는데 다르게 나온다!
REPEATABLE READ
SELECT FOR UPDATE, SELECT LOCK IN SHARE MODE 일 때 데이터 부정합 발생!
InnoDB에서는 MVCC로 인해 PHANTOM READ가 발생하지 않는다!
SERIALIZABLE
애플리케이션 단에서 발생하는 동시성 이슈에 대해서는 한 번 생각해본적은 있지만, 데이터베이스에서 일어날 수 있는 동시성 문제에 대해서는 크게 신경쓰지 않았던 것 같다.
내가 참고한 레퍼런스에서는 사용중인 트랜잭션의 격리 수준에 의해 실행되는 쿼리문이 어떠한 결과를 가져오는지 정확하게 예측하고 있어야 한다고 했다. 단순히 "엔진이 알아서 결과를 출력해주겠지!" 라는 마인드를 버려야겠다.. 🫠