데이터 일관성을 위한 트랜잭션 격리 수준 (Isloation Level) 알아보기
여러 사용자가 하나의 데이터에 동시에 접근할 때는 우리 눈에 보이지 않는 작은 전쟁이 벌어지고 있습니다.
데이터의 일관성과 무결성을 지키면서, 다른 한편으로는 높은 성능을 유지해야 하는 이 어려운 균형을 잡기 위해 데이터베이스에는 트랜잭션이라는 개념이 존재합니다.
트랜잭션이 데이터를 읽고 쓰는 수많은 요청들이 서로 충돌하지 않도록, 그리고 한 트랜잭션 작업이 다른 트랜잭션에 영향을 주지 않도록 하는 메커니즘을 트랜잭션의 격리 수준 (Isolation - Level) 이라고 합니다.
# 00. About Transaction
트랜잭션의 격리 수준을 알아보기 전에 먼저 트랜잭션의 정의를 살펴봅시다.
데이터베이스 트랜잭션(Database Transaction)은 데이터베이스 관리 시스템 또는 유사한 시스템에서 상호작용의 단위이다. 여기서 유사한 시스템이란 트랜잭션이 성공과 실패가 분명하고 상호 독립적이며, 일관되고 믿을 수 있는 시스템을 의미한다.
예시를 통해 트랜잭션의 개념을 더 명확하게 이해해 봅시다.
가령 도서 쇼핑몰에서 '클린 아키텍처'라는 책을 구매한다고 하겠습니다.
사용자 입장에서는 책을 구매하는 행위가 클릭 두세 번으로 마무리될 수 있지만, 실제 시스템에서는 데이터베이스와 많은 상호작용을 하고 있습니다.
그 절차를 아주 간략하게 축소하여 아래와 같다고 하겠습니다.
1. 주문 생성
2. 결제 요청
3. 재고 차감
4. 주문 성공
이와 같이 한 번의 책 구매 요청은 여러 단계로 이루어진 하나의 트랜잭션으로 묶입니다.
이 과정 중 어느 한 단계에서라도 문제가 발생하면 전체 작업이 취소되고, 데이터베이스는 변경 이전 상태로 되돌아가야 합니다.
주문 생성은 성공했지만 결제 요청 단계에서 오류가 발생한다면, 주문 생성이 되기 전 상태로 되돌아가야 합니다.
또한 동시에 다른 사용자가 '클린 아키텍처'를 구매하는 경우에도 이 작업의 성공 실패 여부 및 트랜잭션의 결과는 독립적이어야 합니다.
트랜잭션의 성공 실패 명확성과 상호 독립적인 특징을 예시를 통해 이해해 봤습니다.
# 02. COMMIT과 ROLLBACK
위에서 트랜잭션이란 무엇인지 알아봤습니다.
하지만 본격적으로 트랜잭션의 격리 수준을 알아보기 전에 트랜잭션의 COMMIT과 ROLLBACK 동작에 대해 이해해야 합니다.
이 두 용어는 트랜잭션의 각 격리 수준에 대해 설명을 하면서 자주 등장하게 될 용어이기 때문에 간단하고 확실하게 정리해 보도록 하겠습니다.
COMMIT
• 모든 작업들을 정상적으로 처리하겠다고 확정하는 동작
• 변경사항을 DB에 저장
• COMMIT을 수행한다는 것은 하나의 트랜잭션 과정 종료를 의미
COMMIT의 경우 위와 같이 정리할 수 있습니다.
'클린 아키텍처' 도서를 구매하는 예시 상황의 네 단계에서 오류가 발생하지 않고 정상적으로 주문에 성공했을 때 트랜잭션은 COMMIT 되며 사용자가 '클린 아키텍처' 도서를 구매했다는 사실이 DB에 반영이 되는 것입니다.
ROLLBACK
• 트랜잭션을 구성하는 단계 중 문제가 발생되어 변경사항을 취소하는 동작
• 트랜잭션이 시작되기 이전의 상태로 되돌림
• 마지막 COMMIT을 완료한 시점으로 돌아감
ROLLBACK의 경우 위와 같이 정리할 수 있습니다.
'클린 아키텍처' 도서 구매 동작을 처리하는 중 결제 요청 단계의 코드상에 문제 혹은 의존하고 있는 외부 세계의 문제 때문에 오류가 발생한다면 애초에 주문 생성조차 되지 않은 시점으로 되돌아가야 합니다.
이렇게 실패한 트랜잭션 단위를 '없던 일'로 만드는 동작을 ROLLBACK이라고 합니다.
지금부터는 트랜잭션이 어떻게 모든 작업을 독립적으로 다룰 수 있는지 총 네 종류의 트랜잭션 격리 수준을 알아보면서 이해해 봅시다.
# 03. Read Uncommitted
Read Uncommitted는 Isolation Level에서 가장 낮은 수준의 독립성을 보장합니다.
이름에서 드러나듯 Read Uncommitted는 COMMIT 되지 않은 데이터라도 다른 트랜잭션에서 읽을 수 있는 격리 수준을 의미합니다.
COMMIT 되지 않은 데이터에 대해 읽기가 가능하기 때문에 성능은 좋을 수 있습니다.
하지만 데이터 정합성에 문제가 생길 수 있는 Dirty Read 현상이 발생할 수 있습니다.
Read Uncommitted 레벨에서 발생할 수 있는 Dirty Read, Non-Repeatable Read, Phantom Read 문제 중 Read Uncommitted 레벨에서만 발생하는 Dirty Read에 대해 알아보겠습니다.
Dirty Read?
Dirty Read란, 한 트랜잭션이 다른 트랜잭션이 아직 COMMIT하지 않은 데이터를 읽는 현상입니다.
읽은 데이터가 최종적으로 반영되지 않을 수 있기 때문에 해당 트랜잭션이 롤백되면 잘못된 정보를 읽게 된 것입니다.
예시 상황을 통해 이해해 봅시다.
-- A 세션
INSERT INTO accounts (id, balance) VALUES (1, 1000);
START TRANSACTION;
UPDATE accounts SET balance = 500 WHERE id = 1;
최초 계좌 테이블 id가 1인 레코드에 잔고가 1000원이 있습니다.
트랜잭션이 시작되고 id가 1인 레코드의 잔고를 500원으로 업데이트한 후 아직 COMMIT 하지 않은 상태입니다.
-- B 세션
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
START TRANSACTION;
SELECT * FROM accounts WHERE id = 1; -- 500원
이때 격리 수준이 READ UNCOMMITTED인 다른 세션에서 id 1 레코드를 조회하고 결과는 잔고 500원으로 보입니다.
-- A 세션
ROLLBACK;
이때 다시 A 세션에서 ROLLBACK을 하면 실제 DB에는 잔고가 1,000원으로 남아있지만 B세션은 조회한 대로 잔고가 500원이 남아있는 것으로 알게 되며 데이터 정합성을 위배하게 됩니다.
# 04. Read Committed
Read Committed는 Read Uncommitted와 반대로 트랜잭션이 COMMIT 된 데이터만 읽을 수 있는 격리 수준입니다.
Read Uncommitted에서는 발생했던 Dirty Read 문제가 발생하지 않습니다.
다만, 해당 격리 수준에서는 Repeatable Read에서 발생하는 Phantom Read와 더불어 반복 읽기 불가능 (Non-Repeatable Read) 문제까지 발생합니다.
Phantom Read에 대해서는 #04. Repeatable Read에서 알아보는 것으로 하고, Non-Repeatable Read 문제에 대해 알아보겠습니다.
Non-Repeatable Read?
Non-Repeatable Read는 한 트랜잭션 내에서 동일한 데이터를 두 번 이상 읽었을 때, 그 사이에 다른 트랜잭션에 의해 데이터가 수정되어 두 번의 읽기 결과가 달라지는 현상을 말합니다.
마찬가지로 예시 상황으로 이해해 보겠습니다.
-- A 세션
INSERT INTO accounts (id, balance) VALUES (1, 1000);
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1; -- 1,000원
id가 1인 계좌 레코드에 초기 잔고는 1,000원이고 조회 결과 역시 1,000원입니다.
-- B 세션
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
UPDATE accounts SET balance = 900 WHERE id = 1;
COMMIT;
하지만 이때 다른 세션에서 id가 1인 레코드의 잔고를 900원으로 바꿔버린다면
-- A 세션
SELECT balance FROM accounts WHERE id = 1; -- 900원
헉! 다시 A 세션에서 조회했을 때 결과가 900원이 되어버립니다.
이렇게 하나의 트랜잭션에서 같은 데이터에 대해 중복 조회를 했을때 다른 결과를 읽는 것을 Non-Repeatable Read라고 합니다.
# 05. Repeatable Read
Non-Repeatable 문제를 해결하고자 이번에는 Repeatable Read 격리 수준을 알아보겠습니다.
이 격리 수준을 이해하기 위해 저희는 먼저 MVCC ( Multi-Version Concurrency Control, 다중 버전 동시성 제어 ) 에 대해 알고 있어야 합니다.
MVCC?
MVCC는 변경 중인 데이터와 원본의 데이터를 동시에 유지하는 방식으로, 데이터베이스가 여러 사용자의 작업을 처리할 때 각 사용자에게 자신만의 독립적인 데이터 스냅샷을 제공하는 메커니즘입니다.
Repeatable Read는 이 MVCC를 이용하여 트랜잭션 내에서 처음 읽은 시점의 데이터 스냅샷을 지속적으로 제공하여 동일한 결과를 반환합니다.
때문에 한 번 읽은 데이터의 값은 그 트랜잭션이 종료될 때까지 변경되지 않은 것처럼 보장되기 때문에 Non-Repeatable 문제가 발생하지 않는데요
하지만 해당 격리 수준에서는 또 다른 문제인 Phantom Read가 발생할 수 있습니다.
Phantom Read?
Phantom Read는 한 트랜잭션이 동일한 조건으로 여러 번 쿼리를 실행할 때, 이전에는 존재하지 않았던 새로운 레코드를 볼 수 있는 현상을 말합니다.
Repeatable Read는 얘기했다시피 트랜잭션이 시작된 시점의 스냅샷을 사용해 이미 존재하는 행에 대해서 일관된 값을 보장하지만, 범위 조건을 이용한 쿼리에서는 트랜잭션 이후 추가된 행(또는 삭제된 행)에 대해 보장하지 않을 수 있습니다.
이 때문에 보이지 않던 데이터가 보이고, 보이던 데이터가 보이지 않게 되는 현상이 생기며 이를 Phantom Read라고 합니다.
-- 세션 A
INSERT INTO accounts (id, balance) VALUES (1, 50), (2, 200);
START TRANSACTION;
SELECT * FROM accounts WHERE balance > 100; -- 결과: id=2, balance = 200
위와 같이 두 개의 레코드가 존재한다고 합시다.
잔고가 100원 이상인 계좌는 id가 2인 계좌 하나입니다.
-- 세션 B
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
INSERT INTO accounts (id,balance) values (3, 150)
COMMIT;
이때 다른 세션에서 잔고가 100 이상인 범위에 만족하는 새로운 레코드를 추가한다면,
SELECT * FROM orders WHERE amount > 100;
-- 결과: Phantom Read 발생 - 기존 행(id=2)과 새로 삽입된 행(id=3, amount=150)이 모두 반환됨
COMMIT;
기존에는 보이지 않았던 id가 3인 계좌 레코드에 대한 결과가 반환되게 됩니다.
# 06. Serializable
Dirty Read, Non-Repeatable Read, Phantom Read 이 모든 문제를 해결하는 격리 수준이 바로 Serializable입니다.
이름 그대로 모든 트랜잭션을 순차적으로 실행하는 격리 수준입니다.
어떠한 문제도 발생하지 않지만, 트랜잭션을 병렬로 처리할 수 없기 때문에 성능이 많이 안 좋을 수밖에 없습니다.
이렇게 트랜잭션의 네 종류의 격리 수준에 대해 알아보았습니다.
다음에는 기회가 된다면 열심히 공부해서 스프링의 트랜잭션 전파 속성에 대해서도 글을 작성해 보도록 하겠습니다.