[MySQL] Transaction Isolation Level

글쓴이 Engineer Myoa 날짜

들어가며

JPA로 비즈니스 로직을 작성하던 중, 동시성 문제를 해결하기 위해 Transaction Isolation 에 대해 좀 더 찾아보게 되었습니다.

다양한 DBMS가 있지만 접근하기 쉬운 MySQL의 Transaction Isolation Level 에 대해 정리하고, 이를 JPA 에서 어떻게 활용할 수 있는지 알아보겠습니다.

 

Transaction Isolation Level

먼저 Transaction Isolation Level 이란, 이름 그대로 여러 트랜잭션간 격리 수준을 어느 정도까지로 지정할 것인가 에 대한 설정입니다.

이 격리 수준에 따라서 3가지 문제가 발생합니다.

  • Dirty Read – 다른 트랜잭션에서 데이터를 조작중이고, 조작된 내역이 Commit 되지 않았음에도 해당 데이터를 읽게 됩니다.
  • Non-Repeatable Read – 한 트랜잭션 내에서 SELECT 조회가 매번 달라질 수 있습니다.
  • Phantom Read – 기존 SELECT 조회 결과 대비, 없던 데이터가 SELECT 조회 결과에 나타납니다.

 

어떻게 보면 비슷한 설명같지만 조금씩 다릅니다.  아래 Transaction Isolation Level 을 종류별로 확인해보면서 설명하겠습니다.

 

 

MySQL이나 SQL Server 등에서는 다음과 같은 4가지 수준의 Transaction Isolation Level 을 제공합니다.

  • READ UNCOMMITTED
  • READ COMMITTED
  • REPEATABLE READ
  • SERIALIZABLE

 

다음과 같은 test 테이블이 있고,  이 곳에서 테스트를 수행해보겠습니다.

+--------+-------------+
| seq_no | value       |
+--------+-------------+
|      1 | NOT CHANGED |
|      2 | NOT CHANGED |
|      3 | NOT CHANGED |
|      4 | NOT CHANGED |
|      5 | NOT CHANGED |
+--------+-------------+

 

READ UNCOMMITTED

READ UNCOMMITTED 는 가장 Free 한 트랜잭션 격리 수준을 제공해줍니다.

다시 말하자면 존재하는 트랜잭션들이 무질서속에서 수행됩니다.

 

READ UNCOMMITTED로 session 설정하고, 2개의 원격 접속 세션에서 각각 트랜잭션을 시작시킵니다.

-- SECTION 1
MariaDB [TRANSACTION_ISOLATION_TEST]> \
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

-- SECTION 2
MariaDB <1> > START TRANSACTION;
MariaDB <2> > START TRANSACTION;

-- SECTION 3
MariaDB <1> > SELECT * FROM test;
+--------+-------------+
| seq_no | value       |
+--------+-------------+
|      1 | NOT CHANGED |
|      2 | NOT CHANGED |
|      3 | NOT CHANGED |
|      4 | NOT CHANGED |
|      5 | NOT CHANGED |
+--------+-------------+

-- SECTION 4
MariaDB <2> > UPDATE test SET value = 'CHANGED' WHERE seq_no = 1;
MariaDB <2> > INSERT

-- SECTION 5
MariaDB <1> > SELECT * FROM test;
+--------+-------------+
| seq_no | value       |
+--------+-------------+
|      1 | CHANGED     |
|      2 | NOT CHANGED |
|      3 | NOT CHANGED |
|      4 | NOT CHANGED |
|      5 | NOT CHANGED |
|      6 | NEW ROW     |
+--------+-------------+

 

SECTION 3까지는 문제가 없습니다.

하지만 SECTION 4와 5부분을 보면,

  • 2번 트랜잭션에서 seq_no 가 1인 row의 value를 CHANGED로 변경했습니다.
    아직 Commit 전임에도 불구하고, 1번 트랜잭션에서 해당 변경사항이 읽어졌습니다.
  • 2번 트랜잭션에서 seq_no 가 6인 새로운 레코드를 삽입했습니다.
    아직 Commit 전임에도 불구하고, 1번 트랜잭션에서 해당 변경사항이 읽어졌습니다.

 

따라서,

Type Is Occurred? Describe
Dirty Read Y Commit 되지 않은 seq_no = 1 의 row를 읽음
Non-Repeatable Read Y 한 트랜잭션 내에서 같은 SELECT * FROM test 쿼리가 다른 결과를 만듦
Phantom Read Y 조회되지 않던 seq_no = 6의 row가 새롭게 조회됨

 

 

READ COMMITTED

READ COMMITTED 는 Commit 된 사항만을 읽을 수 있습니다.

얼핏 보면 안전하지 않은가? 라고 생각할 수 있으나, 다음과 같은 문제가 있습니다.

 

-- SECTION 1
MariaDB [TRANSACTION_ISOLATION_TEST]> \
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- SECTION 2
MariaDB <1> > START TRANSACTION;
MariaDB <2> > START TRANSACTION;

-- SECTION 3
MariaDB <1> > SELECT * FROM test;
+--------+-------------+
| seq_no | value       |
+--------+-------------+
|      1 | NOT CHANGED |
|      2 | NOT CHANGED |
|      3 | NOT CHANGED |
|      4 | NOT CHANGED |
|      5 | NOT CHANGED |
+--------+-------------+

-- SECTION 4
MariaDB <2> > UPDATE test SET value = 'CHANGED' WHERE seq_no = 1;
MariaDB <2> > INSERT INTO test VALUES ( 6, "NEW ROW");

-- SECTION 5
MariaDB <1> > SELECT * FROM test;
+--------+-------------+
| seq_no | value       |
+--------+-------------+
|      1 | NOT CHANGED |
|      2 | NOT CHANGED |
|      3 | NOT CHANGED |
|      4 | NOT CHANGED |
|      5 | NOT CHANGED |
+--------+-------------+

-- SECTION 6
MariaDB <2> > COMMIT;
MariaDB <1> > SELECT * FROM test;
+--------+-------------+
| seq_no | value       |
+--------+-------------+
|      1 | CHANGED     |
|      2 | NOT CHANGED |
|      3 | NOT CHANGED |
|      4 | NOT CHANGED |
|      5 | NOT CHANGED |
|      6 | NEW ROW     |
+--------+-------------+


 

SECTION 5까지 1번 Transaction은 문제없이 동일한 결과를 출력했습니다.

하지만 SECTION 6에서 2번 Transaction 이 Commit을 했고, 아직 1번 Transaction이 진행중임에도 불구하고 2번 Transaction 의 내용이 반영된 결과가 1번 Transaction에서 조회되었습니다.

 

따라서,

Type Is Occurred? Describe
Dirty Read N Commit 되지 않은 내용을 읽어오지 않음
Non-Repeatable Read Y 한 트랜잭션 내에서 같은 SELECT * FROM test 쿼리가 다른 결과를 만듦
Phantom Read Y 조회되지 않던 seq_no = 6의 row가 새롭게 조회됨

 

 

REPEATABLE READ

REPEATABLE READ 는 SELECT 일관된 조회 결과를 반환할 수 있도록 합니다.

무슨 말인고 하면, 다른 Transaction에서 Commit 을 하든 안하든 상관없이 나의 Transaction 에서 언제 조회하든 동일한 조회 결과를 보여줍니다.

쉽게 말하면 위 표에서 Non-Repeatable Read 를 막아줍니다!

 

본 문서 이후에 다룰 포스트로, JPA 의 Persistence Context (영속 컨텍스트)가 이 개념을 가지고 ORM에서 일부 성능 최적화에 기여하고 있다.

 

-- SECTION 1
MariaDB [TRANSACTION_ISOLATION_TEST]> \
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;


-- SECTION 2
MariaDB <1> > START TRANSACTION;
MariaDB <2> > START TRANSACTION;


-- SECTION 3
MariaDB <1> > SELECT * FROM test;
+--------+-------------+
| seq_no | value       |
+--------+-------------+
|      1 | NOT CHANGED |
|      2 | NOT CHANGED |
|      3 | NOT CHANGED |
|      4 | NOT CHANGED |
|      5 | NOT CHANGED |
+--------+-------------+



-- SECTION 4
MariaDB <2> > UPDATE test SET value = 'CHANGED' WHERE seq_no = 1;
MariaDB <2> > INSERT INTO test VALUES ( 6, "NEW ROW");
MariaDB <2> > COMMIT;


-- SECTION 5
MariaDB <1> > SELECT * FROM test;
+--------+-------------+
| seq_no | value       |
+--------+-------------+
|      1 | NOT CHANGED |
|      2 | NOT CHANGED |
|      3 | NOT CHANGED |
|      4 | NOT CHANGED |
|      5 | NOT CHANGED |
+--------+-------------+


-- SECTION 6
MariaDB <2> > SELECT * FROM test;
+--------+-------------+
| seq_no | value       |
+--------+-------------+
|      1 | CHANGED     |
|      2 | NOT CHANGED |
|      3 | NOT CHANGED |
|      4 | NOT CHANGED |
|      5 | NOT CHANGED |
|      6 | NEW ROW     |
+--------+-------------+

 

READ COMMITTED 과 비교 시, SECTION4 처럼 Commit 이 진행됐음 에도 불구하고 1번 Transaction 에서 SELECT 조회 시 이전과 같은 결과가 반환됐습니다.

반대로 2번 Transaction 에서는 자신이 변경한 내역이 반영돼 조회가 됐습니다.

 

따라서,

Type Is Occurred? Describe
Dirty Read N Commit 되지 않은 내용을 읽어오지 않음
Non-Repeatable Read N 한 트랜잭션 내에서 같은 SELECT * FROM test 쿼리가 동일한 결과를 보여줌
Phantom Read N 한 트랜잭션 내에서 새롭게 나타난 row 가 없음

 

위와 같은 결과가 나오는 이유는 snapshot 때문입니다.

 

만약 SELECT 로 무엇을 질의했다면, 질의한 결과가 바로 반환되는 것이 아닙니다.

성능 최적화면에서 같은 질의가 다시 들어올 수 있기 때문에 메모리에 VIEW 형태로 해당 질의에 대한 결과를 보관하고 있습니다.

REPEATABLE READ 격리 수준의 Transaction 에서는 이런 VIEW 처럼 각 Transaction 이 snapshot 을 가지고 있고, 해당 snapshot 안에서 데이터를 manipulation 하게됩니다. 그리고 최종적으로 Commit 하여 변경된 내역을 DB 에 영속하게 됩니다.

 

REPEATABLE READ 는 MySQL 의 default Transaction Isolation Level 입니다.

그만큼 성능면에서나 동시성 제어면에서나 뛰어나다고 볼 수 있습니다.

 

 

Exclusive Locking (번외)

그런데 지금까지 나온 Transaction Isolation Level 들은 한 가지 문제점을 가지고 있지 않을까요?

바로 2개 이상의 Transaction 이 같은 row (레코드) 를 바라보고 있고, 각 Transaction 에서 수정한 후 Commit 을 했을 경우 갱신 분실이 발생할 것 같습니다.

 

예를 들어보겠습니다.

지금부터 1번 Transaction 에서는 seq_no = 1 인 row 의 value 에 ‘!’ 를 추가할 것입니다.  ( CHANGED -> CHANGED! )

다음으로 2번 Transaction 에서는 seq_no = 1 인 row 의 value 에 ‘?’ 를 추가할 것입니다. (CHANGED! -> CHANGED!?)

 

-- SECTION 1
MariaDB <1> > SELECT * FROM test;
+--------+-------------+
| seq_no | value       |
+--------+-------------+
|      1 | CHANGED     |
|      2 | NOT CHANGED |
|      3 | NOT CHANGED |
|      4 | NOT CHANGED |
|      5 | NOT CHANGED |
|      6 | NEW ROW     |
+--------+-------------+


-- SECTION 2
MariaDB <1> > START TRANSACTION;
MariaDB <2> > START TRANSACTION;

-- SECTION 3
MariaDB <1> > UPDATE test SET value = CONCAT(value, '!')  WHERE seq_no = 1;
MariaDB <1> > SELECT * FROM test;
+--------+-------------+
| seq_no | value       |
+--------+-------------+
|      1 | CHANGED!    |
|      2 | NOT CHANGED |
|      3 | NOT CHANGED |
|      4 | NOT CHANGED |
|      5 | NOT CHANGED |
|      6 | NEW ROW     |
+--------+-------------+

-- SECTION 4
MariaDB <2> > UPDATE test SET value = CONCAT(value, '?')  WHERE seq_no = 1;

. . .

ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

 

하지만 예상과는 다르게 Exclusive Locking 이 적용되어 있습니다!

같은 내용을 수정시에는, 해당 Transaction 이 Commit 되어야만 수정할 수 있게 됩니다. (그렇지 않으면 갱신분실이 발생할 테니까요)

 

이런 Exclusive Locking 은 REPEATABLE READ 뿐 아니라 READ COMMITTED, READ UNCOMMITTED 에 관계없이 적용됩니다!

 

 

SERIALIZABLE

SERIALIZABLE 은 동시성을 포기하고 높은 격리수준을 제공합니다.

모든 SELECT 문을 SELECT … LOCK IN SHARE MODE 로 변환하여 질의를 날리게 됩니다.

 

 

-- SECTION 1
MariaDB [TRANSACTION_ISOLATION_TEST]> \
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- SECTION 2
MariaDB <1> > START TRANSACTION;
MariaDB <2> > START TRANSACTION;

-- SECTION 3
MariaDB <2> > UPDATE test SET value = 'LOCKED' WHERE seq_no = 5;
MariaDB <2> > SELECT * FROM test;
+--------+-------------+
| seq_no | value       |
+--------+-------------+
|      1 | NOT CHANGED |
|      2 | NOT CHANGED |
|      3 | NOT CHANGED |
|      4 | NOT CHANGED |
|      5 | LOCKING     |
+--------+-------------+

-- SECTION 4
MariaDB <1> > SELECT * FROM test;
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction<br>

테스트 해본 결과, 특정 Transaction 이 수정을 하게 되면 Read Lock 까지 걸어버립니다.

 

따라서,

Type Is Occurred? Describe
Dirty Read N Commit 되지 않은 내용을 읽어오지 않음
Non-Repeatable Read N 한 트랜잭션 내에서 같은 SELECT * FROM test 쿼리가 동일한 결과를 보여줌
Phantom Read N 한 트랜잭션 내에서 새롭게 나타난 row 가 없음

의 결과를 가져오지만 높은 격리성으로 인해 해당 row 범위에 Read Lock 이 걸리는 것을 볼 수 있었습니다.

다시 말하면 SERIALIZABLE 의 격리수준은 데이터의 준 절대적인 영속성을 보장하지만, 동시성의 대부분을 포기하게 됩니다.

 

 

 

마치며

REPEATABLE READ 가 본 글의 대다수 분량을 차지하는 데에는 이유가 있습니다.

가장 자주 사용하는 Transaction Isolation Level 이며, 동시성과 영속성 사이에서 훌륭한 Trade Off를 제공해주고 있습니다.

 

JPA 에서 이런 Transaction Isolation Level을 어떻게 활용하는지까지 기술하려 하였으나, 읽는 분들의 호흡을 위해서라도 다음 포스트로 미루기로 하였습니다.

 

 

References

 

 

 

 


3개의 댓글

답글 남기기

Avatar placeholder

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다