데이터베이스를 다루면서 자주 등장하기도 하고, 필수적으로 이해하고 있어야 하는 개념인 트랜잭션에 대해 정리해보겠습니다.
MySql, InnoDB engine 사용
사용되는 Table
CREATE TABLE student (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(30) UNIQUE,
age INT NOT NULL,
CHECK ( age > 0 )
) ENGINE=INNODB
데이터베이스의 상태를 변화시키는 하나의 논리적 기능을 수행하기 위한 작업의 단위
짧게 정의했지만, 가장 중요한 개념은 작업의 단위입니다.
일반적으로 Commit(적용)과 Rollback(복구)를 통해 한 단위의 작업을 수행하는 것을 보장하는데요.
InnoDB Engine
에선 기본적으로 AutoCommit 이라는 기능이 활성화 되어 있어 있습니다.
- START TRANSCATION;
- COMMIT;
- ROLLBACK;
위 명령어로 직접 트랜잭션을 설정할 수도 있습니다.
잘 알려진 트랜잭션의 4가지 특징에 대해 살펴보겠습니다.
ACID 속성은 워낙 잘 알려져있고, 자료도 방대하니 간단한 예제와 함께 짚고 넘어가겠습니다.
트랜잭션 작업은 전부 처리되거나 아예 하나도 처리되지 않아야 한다.
INSERT INTO student (name, age) VALUES ('Royce', 20); INSERT INTO student (name, age) VALUES ('Tom', 20), ('Royce', 20); -- 옳바른 값인 ('Tom', 20)도 저장되지 않음 SELECT * FROM student;
트랜잭션이 성공해도 데이터베이스가 무결하여야 한다.
트랜잭션의 결과가 데이터베이스의 제약사항들을 위반하면 안된다는 의미입니다.
동시에 실행되는 트랜잭션들이 서로 영향을 미치지 않도록 격리되어야 한다.
아래에서 더 자세히 다루겠습니다.
트랜잭션이 성공한 뒤 반영된 결과는 데이터베이스에 저장되어야 한다.
트랜잭션의 격리성에 대해서 조금 더 깊게 보도록 하겠습니다.
여러 트랜잭션이 동시에 공유되는 테이블에 접근하는 상황에서 트랜잭션들은 어떻게 격리 될 수 있을까요?
격리를 엄격하게 수행할 수 있지만, 성능이 많이 낮아집니다. 반대로 느슨한 격리수준으로 설정한다면 여러 트랜잭션을 동시에 처리할 수 있지만, 데이터에 대한 신뢰도를 잃을 수도 있습니다.
이런 특징을 고려하여 트랜잭션에서의 격리 수준들을 구분하여 제공합니다. 개발자는 데이터의 성격에 따라 선택적으로 사용할 수 있습니다.
다음과 같은 4가지 레벨이 존재합니다.
이름에서 쉽게 파악할 수 있듯이, commit 되지 않은 데이터를 읽은 수 있는 격리 수준입니다.
제공하는 Isolation level 중에 격리성이 가장 느슨한 level입니다.
두가지 트랜잭션이 경합하는 상황을 예시로 살펴보겠습니다.
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
------------------T1------------------ ------------------T2------------------
START TRANSACTION;
START TRANSACTION;
UPDATE student SET age = 21 WHERE name = 'Royce';
SELECT * FROM student WHERE age = 21;// #1
COMMIT;
COMMIT; // #2
이때, #1의 결과는 다음과 같습니다.
T2(트랜잭션2)가 완료되지 않았지만, 해당 트랜잭션에서 발생한 변경사항이 T1에도 영향을 미치고 있습니다.
이런 격리수준에서는 여러 트랜잭션을 동시에 처리하는데 유용하지만, 데이터 정합성이 크게 떨어집니다.
#2가 커밋이 아니라 ROLLBACK
이었더라도 지금의 격리수준으로는 T1의 결과가 변함없이 같은 결과를 보여주기 때문입니다.
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
------------------T1------------------ ------------------T2------------------
START TRANSACTION;
START TRANSACTION;
UPDATE student SET age = 21 WHERE name = 'Royce';
SELECT * FROM student WHERE age = 21;
COMMIT;
ROLLBACK;
데이터베이스에는 존재하지 않는 age = 21
의 결과를 보여주게 됩니다.
이러한 현상을 Dirty read
라고 합니다.
위에서 발생한 Dirty read는 Isolation level만 READ COMMITTED
로 변경해도 발생하지 않습니다.
READ COMMITTED
level에서는 트랜잭션의 변경 사항을 undo 영역에 저장하고, 다른 트랜잭션이 조회 시, undo 영역에 데이터를 조회하여 commit되기 이전의 값을 읽지 않도록 합니다.
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
------------------T1------------------ ------------------T2------------------
START TRANSACTION;
START TRANSACTION;
UPDATE student SET age = 21 WHERE name = 'Royce';
SELECT * FROM student WHERE age = 21; #1
COMMIT;
COMMIT;
#1 의 결과는 아무런 데이터도 조회되지 않습니다.
그렇다고 해서 아무런 문제가 없는 것은 아닙니다.
위에서 사용한 operation의 순서만 조금 변경해도 문제를 발견 할 수 있습니다.
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
------------------T1------------------ ------------------T2------------------
START TRANSACTION;
SELECT * FROM student WHERE name = 'Royce'; # 1
START TRANSACTION;
UPDATE student SET age = 21 WHERE name = 'Royce';
COMMIT;
SELECT * FROM student WHERE name = 'Royce'; # 2
COMMIT;
#1의 결과는 아래와 같습니다.
그렇지만 #2의 결과는 T2에서 커밋한 결과를 읽어 와 같은 결과를 얻습니다.
동일한 로직(#2)을 한 트랜잭션(T1)에서 수행하였는데 다른 결과가 나온다 문제점을 발견할 수 있습니다. 이러한 문제를 non-repeatable read
라고 합니다.
REPEATABLE READ
level에선 non-repeatable read
문제가 발생하지 않습니다.
트랜잭션 안에서 반복해서 SELECT를 수행하더라도 읽어 들이는 값이 변화하지 않음을 보장합니다.
이전에 문제가 되었던 트랜잭션의 경합을 레벨을 변경하고 수행한 결과는 아래와 같습니다.
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
------------------T1------------------ ------------------T2------------------
START TRANSACTION;
SELECT * FROM student WHERE name = 'Royce'; # 1
START TRANSACTION;
UPDATE student SET age = 21 WHERE name = 'Royce';
COMMIT;
SELECT * FROM student WHERE name = 'Royce'; # 2
COMMIT;
#1, #2 의 결과는 아래 결과로 동일합니다.
REPEATABLE READ
level에선 non-repeatable read
문제는 발생하지 않습니다.
하지만, 아래와 같은 상황에서는 또다시 문제를 발견할 수 있습니다.
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
------------------T1------------------ ------------------T2------------------
START TRANSACTION;
START TRANSACTION;
UPDATE student SET age = 21 WHERE name = 'Royce';
COMMIT;
SELECT * FROM student WHERE age = 21; # 1
UPDATE student SET name = 'new' WHERE age = 21; # 2
SELECT * FROM student WHERE age = 21; # 3
COMMIT;
T2에서 age
의 UPDATE가 발생했다고 하더라도, 격리 레벨이 REPEATABLE READ 이다 보니 T1에서 #1의 결과(age = 21
)에는 아무런 데이터가 조회되지 않습니다.
age = 21
의 조회 결과가 없었지만,
#2 쿼리로 age = 21
인 데이터를 수정을 한 뒤.
#3 으로 age = 21
인 데이터를 조회하면 결과가 발생합니다.
하나의 트랜잭션에서 저장하지 않았던 데이터가 갑자기 등장하였는데, 이를 phantom read
라고 합니다.
읽는 작업에 대해선 트랜잭션의 특정 시점을 기록하여 일관된 결과를 보장하였지만, 다른 트랜잭션의 쓰기 작업에 영향을 받고 있기 때문에 해당 문제가 발생하게 되었습니다.
MySQL에서는 MVCC를 통해 어느정도의
phantom read
가 방지되긴 합니다.
Serializable
은 트랜잭션들을 줄을 세워 작동하는 것처럼, 가장 엄격한 격리 수준을 보장합니다. 위에서 읽기와 관련하여 발생했던 문제들은 발생하지 않습니다.
이와 상응하게 성능도 느려집니다.
Lock을 통해 제공하는데, 쉽게 dead lock에 빠질 수 있습니다.
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
------------------T1------------------ ------------------T2------------------
START TRANSACTION;
SELECT * FROM student WHERE age = 20;
SELECT * FROM student WHERE age = 20;
UPDATE student SET age = 21 WHERE age = 20; # 1
UPDATE student SET age = 21 WHERE age = 20;
COMMIT;
위 상황에서는 Deadlock에 빠지게 됩니다.
두 트랜잭션이 하나의 row에 대해 서로 S-lock을 걸고 있으면서, UPDATE를 하며 X-lock을 시도하기 때문입니다.
이처럼, 성능적인 단점 뿐만 아니라 lock을 통해 dead lock의 위험도도 증가할 수 있습니다.
source: http://www.contrib.andrew.cmu.edu/~shadow/sql/sql1992.txt -> SQL-transactions
위에서 언급한 발생할 수 있는 문제점들과 격리 수준과의 테이블을 그리면 아래와 같습니다.
Isolation Level | Dirty Read | Non-Reapeatable Read | Phantom Read |
---|---|---|---|
Read Uncommitted | O | O | O |
Read committed | - | O | O |
Repeatable Read | - | - | O |
Serializable | - | - | - |
트랜잭션에 대한 간단한 개념과 격리 수준에 대해 살펴보았습니다.
여러 트랜잭션이 경쟁하는 상황에서, 성능과 데이터 정합성을 두고 개발자가 trade를 할 수 있는데요.
내가 선택한 transcation level의 이점과 발생할 수 있는 문제점들을 인지하고 최적의 선택을 하는 것이 트랜잭션 학습의 목적이라 생각합니다.
더 다양한 문제 상황들과 이를 해결하기 위한 더 많은 isolation level이 존재하는데요. 이번 글에서 다룬 내용들은 굉장히 보편적으로 알려져있는 트랜잭션에 대한 기본적인 내용들이니 숙지하고 있으면 추후 공부에도 도움이 될 것 같습니다.
MySQL에서는 기본적으로 REPEATABLE READ
level을 제공한다고 합니다!