개발놀이터

데이터베이스 격리수준 (isolation level) 본문

CS 지식/데이터베이스

데이터베이스 격리수준 (isolation level)

마늘냄새폴폴 2022. 9. 26. 00:05

우선 데이터베이스 격리수준을 들어가기 전에 우리는 트랜잭션에 대해서 간단한 이해가 필요합니다.

 

트랜잭션

 

트랜잭션은 데이터의 정합성을 보장하기 위한 기능입니다. 트랜잭션은 꼭 여러개의 변경 작업을 수행하는 쿼리가 조합됐을때만 의미있는 개념은 아닙니다. 트랜잭션은 논리적인 작업 셋 자체가 100% 적용되거나 또는 아무것도 적용되지 않아야 함을 보장해 주는 것입니다. 

 

이러한 트랜잭션의 특징을 잘 나타내는 4가지 키워드가 있습니다. 

 

바로 ACID 라고 하는 원자성 (Atomicity), 일관성 (Consistency), 격리성 (isolation), 지속성 (Durability)를 보장해야 합니다. ACID에 대한 자세한 설명은 아래와 같습니다.

 

  • 원자성 (Atomicity) : 트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인 것처럼 모두 성공하거나 혹은 모두 실패해야 합니다.
  • 일관성 (Consistency) : 모든 트랜잭션은 일관성있는 데이터베이스 상태를 유지해야 합니다. 예를 들면 데이터베이스에서 정한 무결성 제약 조건을 항상 만족해야 합니다.
  • 격리성 (Isolation) : 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리해야 합니다. 예를 들면 동시에 같은 데이터를 수정하지 못하도록 해야 합니다. 격리성은 동시성과 관련된 성능 이슈로 인해 격리 수준을 선택할 수 있습니다.
  • 지속성 (Durability) : 트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록되어야 합니다. 중간에 시스템에 문제가 발생하더라도 데이터베이스 로그 등을 사용해서 성공한 트랜잭션 내용을 복구해야 합니다. 

트랜잭션은 원자성, 일관성, 지속성을 보장하는데 문제는 격리성입니다. 트랜잭션간에 격리성을 완벽히 보장하려면 동시에 처리되는 트랜잭션을 거의 차례대로 실행을 해야 합니다. 하지만 이렇게 처리를 하면 처리 성능이 매우 나빠지게 됩니다. 이러한 문제로 인해 ANSI 표준은 트랜잭션의 격리 수준을 4단계로 나누어 정의하고 있습니다.

 

 

격리 수준

트랜잭션의 격리 수준이란 동시에 여러 트랜잭션이 처리될 때, 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있도록 허용할지 말지를 결정하는 것입니다. 

 

격리 수준은 다음과 같이 4가지로 정의할 수 있습니다.

 

  • READ UNCOMMITTED (커밋되지 않은 읽기)
  • READ COMMITTED (커밋된 읽기)
  • REPEATABLE READ (반복 가능한 읽기)
  • SERIALIZABLE (직렬화 가능)

 

순서대로 READ UNCOMMITTED의 격리 수준이 가장 낮고 SERIALIZABLE의 격리 수준이 가장 높습니다. 격리 수준이 높아질수록 데이터베이스 서버의 처리 성능이 많이 떨어질 것으로 생각하는 사용자가 많은데, 사실 그렇지는 않습니다. 

 

SERIALIZABLE 격리 수준이 아니라면 크게 성능의 개선이나 저하는 발생하지 않습니다. 

 

"DIRTY READ"라고도 하는 "READ UNCOMMITTED" 는 일반적인 데이터베이스에서는 거의 사용하지 않고, SERIALIZABLE 역시 동시성이 중요한 데이터베이스에서는 거의 사용되지 않습니다.

 

격리 수준이 낮으면 낮을수록 더 많은 문제가 발생합니다. 

 

이제 격리 수준에 대해 하나씩 알아보고 격리 수준에 따라 발생하는 문제점에 대해서 알아보겠습니다. 

 

 

READ UNCOMMITTED

 

READ UNCOMMITTED 격리 수준에서는 위의 그림처럼 각 트랜잭션에서의 변경 내용이 COMMIT이나 ROLLBACK여부에 상관 없이 다른 트랜잭션에서 보여지게 됩니다.

 

위 그림은 다른 트랜잭션이 사용자 B가 실행하는 select 쿼리의 결과에 어떠한 영향을 미치는지 보여주는 예시입니다. 

 

위 그림에서 사용자 A는 emp_no = 50000, first_name = "JuBal" 인 새로운 사원을 INSERT 하고 있습니다. 그리고 사용자 B는 변경된 내용을 커밋하기도 전에 emp_no = 50000 인 사원을 검색하고 있습니다. 하지만 사용자 B는 사용자 A가 INSERT한 사원의 정보를 커밋되지 않은 상태에서도 조회를 할 수 있습니다. 

 

여기서 문제는 만약 사용자 A가 작업 도중 문제가 발생하여 INSERT된 내용을 롤백해버린다 하더라도 사용자 B는 JuBal이 정상적인 사원이라고 판단하고 계속해서 처리하게 되는 것입니다. 

 

이처럼 어떠한 트랜잭션에서 처리한 작업이 완료되지 않았음에도 불구하고 다른 트랜잭션에서 볼 수 있게 되는 현상을 더티 리드 (Dirty Read)라 하고, 더티 리드가 허용되는 격리 수준이 READ UNCOMMITTED 입니다. 더티 리드 현상은 데이터가 나타났다가 사라졌다 하는 현상을 초래할 수 있으므로 개발자와 사용자를 상당히 혼란스럽게 만들 것입니다. 

 

또한 더티 리드를 유발하는 READ UNCOMMITTED 격리 수준은 RDBMS 표준에서는 트랜잭션의 격리 수준으로 인정하지 않을 정도로 정합성에 문제가 많은 격리 수준입니다. 따라서 RDBMS를 사용한다면 최소 READ COMMITTED 이상의 격리 수준을 사용할 것을 권장합니다. 

 

 

 

READ COMMITTED

READ COMMITTED 격리 수준은 오라클 DBMS에서 기본적으로 사용되고 있으며 올나인 서비스에서 가장 많이 선택되는 격리 수준입니다. 이 레벨에서는 위 READ UNCOMMITTED 수준에서 발생할 수 있는 더티 리드와 같은 현상은 발생하지 않습니다. 

 

어떠한 트랜잭션에서 데이터를 변경하더라도 COMMIT이 완료된 데이터만 다른 트랜잭션에서 조회할 수 있기 때문입니다.

 

위 그림을 통해 READ COMMITTED 격리 수준에서 사용자 A가 변경한 내용이 사용자 B에게 어떻게 조회되는 지 확인할 수 있습니다. 

 

사용자 A는 emp_no = 50000인 사원의 first_name을 "JuBal"에서 "Toto"로 수정했는데, 이 때 새로운 값인 "Toto"는 employee 테이블에 즉시 기록되고 이전 값인 "JuBal"은 Undo 영역으로 백업이 됩니다. 

 

만약 사용자 A가 이러한 변경 내역을 커밋하기전에 사용자 B가 emp_no = 50000인 사원을 조회하면 결과값은 "Toto"가 아닌 이전 값인 "JuBal"이 조회가 됩니다. 여기서 사용자 B의 SELECT 쿼리 결과는 employees 테이블이 아닌 Undo 영역의 백업된 레코드에서 가져온 결과입니다. 

 

READ COMMITTED 격리 수준에서는 어떤 트랜잭션에서 변경한 내용이 커밋되기 전까지는 다른 트랜잭션에서 그러한 변경 내역을 조회할 수 없기 때문입니다. 

 

최종적으로 사용자 A가 변경된 내용을 커밋하면 그때부터는 다른 트랜잭션에서도 백업된 Undo 영역의 데이터인 "JuBal"이 아닌 새롭게 변겨오딘 "Toto"라는 값을 참조할 수 있습니다. 

 

 

READ COMMITTED 격리 수준에서 발생할 수 있는 문제점 (NON-REPEATABLE READ)

READ COMMITTED 격리 수준에서도 "NON-REPEATABLE READ" 라는 부정합 문제가 존재합니다. 아래 그림을 살펴보도록 하겠습니다. 

 

위 그림에서 사용자 B가 BEGIN 명령으로 트랜잭션을 시작하고 first_name = "Toto"인 사원을 조회하면 일치하는 데이터 값이 존재하지 않습니다. 

 

하지만 이후에 사용자 A가 emp_no = 50000인 사원의 이름을 "Toto"로 수정하고 커밋한 후 사용자 B는 동일한 쿼리로 조회하면 이번에는 결과가 1건이 조회가 됩니다. 

 

이는 별다른 문제는 없어보이나 사용자 B가 하나의 트랜잭션내에서 동일한 SELECT 쿼리를 실행했을 때 항상 같은 결과를 보장해야 한다는 "REPEATABLE READ" 정합정에 어긋나게 됩니다. 

 

이러한 부정합 현상은 일반적인 웹 어플리케이션에서는 크게 문제가 되지 않지만, 하나의 트랜잭션에서 동일한 데이터를 여러 번 읽고 변경하는 작업이 금전적인 처리와 연결되면 문제가 될 수 있습니다. 

 

예를 들어, 다른 트랜잭션에서 입금과 출금 처리가 계속 진행되고 있을 때 다른 트랜잭션에서 오늘 입금된 금액의 총합을 조회한다고 가정해보겠습니다. "REPEATABLE READ" 가 보장되지 않기 때문에 총합을 계산하는 SELECT 쿼리를 실행할 때마다 다른 결과를 가져올 것입니다. 

 

중요한 것은 사용중인 트랜잭션의 격리 수준에 의해 실행되는 SQL 문장이 어떠한 결과를 가져오게 되는지 정확히 예측할 수 있어야 합니다. 그리고 이를 위해서는 각 트랜잭션의 격리 수준이 어떻게 작동하는지 알고 있어야 합니다. 

 

 

REPEATABLE READ

REPEATABLE READ는 MySQL의 InnoDB 스토리지 엔진에서 기본적으로 사용되는 격리 수준입니다. 이 격리 수준에서는 READ COMMITTED 격리 수준에서 발생하는 "NON-REPEATABLE READ" 부정합이 발생하지 않습니다. 

 

InnoDB 스토리지 엔진은 트랜잭션이 ROLLBACK 될 가능성에 대비해 변경되기 전 레코드를 언두(Undo) 영역에 백업해두고 실제 레코드 값을 변경합니다. 이러한 변경 방식을 MVCC (Multi Version Concurrency Control)이라고 합니다. 

 

REPEATABLE READ는 이 MVCC를 위해 언두 영역에 백업된 이전 데이터를 통해 동일한 트랜잭션 내에서는 동일한 결과를 보여줄 수 있도록 보장합니다. 

 

READ COMMITTED 격리 수준 또한 MVCC를 이용해 COMMIT되기 전의 데이터를 보여주나 REPEATABLE READ와 READ COMMITTED의 차이는 언두 영역에 백업된 레코드의 여러 버전 가운데 몇 번째 이전의 버전까지 찾아 들어가야 하는지에 있습니다. 

 

모든 InnoDB의 트랜잭션은 고유한 트랜잭션 번호 (순차적으로 증가하는 값)을 가지며, 언두 영역에 백업된 모든 레코드에는 변경을 발생시킨 트랜잭션의 번호가 포함되어 있습니다. 언두 영역의 백업된 데이터는 InnoDB 스토리지 엔진이 불필요하다고 판단하는 시점에 주기적으로 삭제합니다. 

 

REPEATABLE READ 격리 수준에서는 MVCC를 보장하기 위해 실행중인 트랜잭션 가운데 가장 오래된 트랜잭션 번호보다 트랜잭션 번호가 앞선 언두 영역의 데이터는 삭제할 수 없습니다. 

 

위 그림은 REPEATABLE READ 격리 수준이 작동하는 방식을 나타내고 있습니다. 

 

먼저 employees 테이블은 번호가 6인 트랜잭션에 의해 INSERT가 되었다고 가정하겠습니다.

 

사용자 A가 emp_no = 50000인 사원의 이름을 변경하는 과정에서 사용자 B가 emp_no = 50000인 사원을 SELECT 할 때 어떠한 과정을 거쳐서 처리되는지 보여줍니다. 

 

사용자 A의 트랜잭션 번호는 12이고, 사용자 B의 트랜잭션 번호는 10입니다. 이때 사용자 A는 사원의 이름을 "Toto"로 변경하고 커밋을 수행합니다. 그런데 사용자 B는 emp_no = 50000인 사원을 A 트랜잭션이 변경을 실행하기 전과 실행한 후 각각 조회를 했지만, 데이터는 항상 동일한 "JuBal"이라는 값을 SELECT 합니다. 

 

사용자 B가 BEGIN 명령으로 트랜잭션을 시작하면서 10번이라는 트랜잭션 번호를 부여받았는데, 그때부터 사용자 B의 10번 트랜잭션 안에서 실행되는 모든 SELECT 쿼리는 자신의 트랜잭션인 10번보다 작은 트랜잭션 번호에서 변경한 것만 보게 됩니다. 

 

위 그림에서 언두 영역에 백업된 데이터가 하나만 있는 것으로 표현했지만, 실제로는 하나의 레코드에 대해 백업이 하나 이상 얼마든지 존재할 수 있습니다. 만약 한 사용자가 BEGIN 명령으로 트랜잭션을 시작한 후 장시간동안 트랜잭션을 종료하지 않으면 언두 영역이 백업된 데이터로 무한정 커질 수 있고, 이렇게 언두 영역에 백업된 레코드가 많아질수록 MySQL서버의 처리 성능이 떨어질 수 있습니다. 

 

 

REPEATABLE READ 격리 수준에서 발생할 수 있는 문제점 (PHANTOM READ)

REPEATABLE 격리 수준에서도 다음과 같은 부정합이 발생할 수 있습니다. 

 

아래의 그림은 사용자 A가 employees 테이블에 INSERT 를 실행하는 도중에 사용자 B가 SELECT ... FOR UPDATE 쿼리로 employess 테이블을 조회했을 때의 결과입니다. 

 

위 그림에서 사용자 B는 트랜잭션 시작 후 SELECT 쿼리를 수행하고 있습니다. 따라서 이전 REPEATABLE READ 격리 수준에서 설명처럼 동일한 트랜잭션 내에서는 결과가 동일해야 합니다. 

 

하지만 위 그림에서 사용자 B가 실행하는 두번의 SELECT ... FOR UPDATE 쿼리 결과는 서로 다릅니다. 이렇게 다른 트랜잭션에서 수행한 변경 작업에 의해 레코드가 보였다 안보였다 하는 현상을 PHANTOM READ 라고 합니다. 

 

SELECT ... FOR UPDATE 쿼리는 SELECT하는 레코드에 쓰기 잠금을 걸어야 하는데, 언두 레코드에는 잠금을 걸 수가 없습니다. 

 

따라서 위와 같은 쿼리는 언두 영역의 변경 전 데이터를 가져오는 것이 아니라 현재 레코드의 값을 가져오게 됩니다. 

 

※ 하지만 InnoDB에서는 독특한 특성 때문에 REPEATABLE READ 격리 수준에서도 PHANTOM READ가 발생하지 않는다고 합니다. 따라서 MySQL의 InnoDB 스토리지 엔진에서는 주로 REPEATABLE READ격리 수준을 사용하는게 아닐까 하는 생각입니다. 

 

여기서 말하는 독특한 특성이란 MVCC 다중 버전 제어입니다. 즉, MySQL-InnoDB는 MVCC 다중 버전 제어에 의해 PHANTOM READ를 해결하고 있습니다.

 

 

SERIALIZABLE

가장 단순한 격리 수준이면서 가장 엄격한 격리 수준입니다. 또한 동시 처리 성능도 다른 트랜잭션 격리 수준보다 현저히 떨어집니다. 

 

트랜잭션의 격리 수준이 SERIALIZABLE로 설정되면 읽기 작업도 공유 잠금을 획득해야 하며, 동시에 다른 트랜잭션은 그러한 레코드를 변경할 수 없습니다. 즉, 한 트랜잭션에서 읽고 쓰는 레코드를 다른 트랜잭션에서는 절대 접근할 수 없습니다. 

 

SERIALIZABLE 격리 수준에서는 일반적인 DBMS에서 발생하는 "PHANTOM READ" 문제가 발생하지 않지만, 위에서 설명드렸듯이 InnoDB 스토리지 엔진에서는 REPEATABLE READ격리 수준에서도 "PHANTOM READ" 가 발생하지 않기 때문에 굳이 SERIALIZABLE 격리 수준을 사용할 필요성은 없어 보입니다. 

 

 

Reference

https://zzang9ha.tistory.com/381

 

[MySQL] - 트랜잭션의 격리 수준(Isolation level)

📎 글또 6기 포스팅 1. 미치도록 더웠던 7월의 회고 2. 사용자가 게시물을 작성할 때의 트랜잭션 처리 3. Spring AOP - (1) 프록시 패턴, 데코레이터 패턴 4. [MySQL] - 트랜잭션의 격리 수준(Isolati

zzang9ha.tistory.com

 

책) 자바 ORM 표준 JPA 프로그래밍