트랜잭션 격리 수준(Transaction Isolation Level)을 학습하던 도중 의문점이 생겼다. 왜 트랜잭션 격리 수준을 나눴을까? java.sql 패키지에 있는 Connection 인터페이스 안에서 아래와 같은 코드를 확인할 수 있었다.
public interface Connection extends Wrapper, AutoCloseable {
...
int TRANSACTION_NONE = 0;
int TRANSACTION_READ_UNCOMMITTED = 1;
int TRANSACTION_READ_COMMITTED = 2;
int TRANSACTION_REPEATABLE_READ = 4;
int TRANSACTION_SERIALIZABLE = 8;
void setTransactionIsolation(int level) throws SQLException;
...
}
인터페이스로 제공된다는 뜻은 어떤 구현체에 의존하지 않는다는 뜻이다. 찾아보니 ANSI/ISO SQL-92에 격리 수준에 대한 정의가 있었다. 표준이 존재하다는 것은 데이터베이스에서 공통적으로 발생하는 현상이 있다는 뜻이고 그러한 이유가 있을 것이다. 이 글은 발생하는 이유들과 왜 격리 수준을 나눴는가에 대해서 찾아보고 정리하였다.
1. 트랜잭션이란 무엇인가?
트랜잭션은 여러 연산의 집합으로 이루어진 하나의 논리적인 작업 단위이다. 예를 들어, '계좌이체'라는 작업은 '보낸 계좌의 잔액을 갱신한다'와 '받는 계좌의 잔액을 갱신한다'라는 2개 연산으로 구성된 하나의 트랜잭션이다. 그렇다면 트랜잭션은 왜 데이터베이스에서 필요할까? 데이터베이스는 여러 사람들이 공유할 목적으로 사용하는 데이터의 집합이다. 그리고 이것을 효율적으로 관리하도록 도와주는 시스템을 데이터베이스 관리 시스템(DBMS)라고 한다. 다양한 사람들이 접근하고 수정하고 추가하고 있는데 갑자기 정전이 발생한다면? 특정 단위가 없다면 복구하기 힘들 것이다. 여러 사람들이 수정하고 추가할 때 오직 한 사람만 추가할 수 있다면? 아무도 그 데이터베이스를 사용하지 않을 것이다.
효율적인 관리를 위해서는 여러 동작들이 하나의 단위로 표현돼야 한다. DBMS는 이것을 트랜잭션으로 관리한다. 그렇다면 이 트랜잭션을 관리할 때 어떠한 기준으로 관리해야 할까? 흔히 ACID라 불리는 원자성(Atomicity), 일관성(Consistency), 고립성(Isolation), 지속성(Durability)이라는 기준을 가진다.
원자성이란 트랜잭션의 모든 연산이 정상적으로 끝나거나 아니면 어떠한 작업도 수행되지 않아야 하는 것을 뜻한다. 이 조건은 매우 까다로운 조건인데, 데이터베이스의 변경 사항이 일부는 메모리에 존재하고 일부는 하드 디스크에 존재할 수도 있기 때문이다. (mysql innodb)
고립성이란 효율적인 관리를 위해서 여러 트랜잭션들이 동작할 수 있는데 하나의 트랜잭션이 다른 트랜잭션에게 영향을 주지 않도록 수행되어야 하는 것을 뜻한다. 즉 1번과 2번 트랜잭션은 서로가 동시에 수행되고 있는지를 알 수 없도록 하는 것이다. (서로 영향이 없으니 알 필요가 없다.)
지속성이란 트랜잭션이 성공적으로 반영이 된다면 변경된 데이터는 영구적으로 반영되어야 하는 것을 뜻한다. 우리가 요청한 작업은 시스템 장애로 언제든지 사라질 수 있다. 결국 트랜잭션의 변경 사항을 잊는 경우도 생기는데 장애가 발생해도 영구적으로 반영이 되어야 한다.
일관성이란 트랜잭션이 종료된 후에도 데이터베이스의 일관성을 보존해야 하는 것을 뜻한다. 이건 단지 무결성 제약 조건만을 뜻하는 것이 아니다. 응용 프로그램에서도 일관성을 보장해야하며 이 제약 조건을 지키는 것은 트랜잭션을 만드는 프로그래머의 책임이다.
2. 효율적인 트랜잭션 처리를 위해서 어떻게 해야할까?
'효율적'은 무엇일까? 사전에서는 '낭비 없이 자원 배분이 잘 이루어진'이라고 한다. 효율적인 트랜잭션 처리는 낭비하는 컴퓨팅 자원 없이 잘 처리하는 것을 뜻한다. CPU가 오직 하나의 트랜잭션만 처리한다면 I/O 시간이 긴 작업 동안에 놀게 될 것이고 이건 효율적이지 못한다. 그 시간에 다른 트랜잭션을 처리하고 I/O가 끝난 시점에 다시 원래 작업을 처리하는 것이 효율적일 것이다. 근데 트랜잭션은 고립성을 지켜야 한다. 도중에 다른 트랜잭션을 처리하다가 원래 작업에 영향을 준다면? 트랜잭션의 기준을 어기게 되는 것이다. 즉 여러 트랜잭션을 동시 처리하면서 서로 영향이 없도록 처리할 방법이 필요하다. 다행히도 이미 다른 시스템에서도 비슷한 문제가 있었고 해결책을 제시했다. 그냥 잠금을 걸어서 다른 작업 말고 지금 작업을 처리하도록 강제하는 것이다. 당연하게도 이것은 성능에 나쁜 영향을 준다. 빠른 성능이 필요하다면 적절한 락의 트레이드오프(trade-off)와 다른 방법이 필요할 것이다. 그리고 이것이 위에 나온 트랜잭션 고립 수준이라는 기준으로 제공이 된다.
2.1 직렬 가능 스케줄
CPU는 하나의 일만 할 수 있고 해야 할 일은 여러 개가 있다. 그리고 우린 이것을 동시에 처리해야 한다면 어떻게 해야 할까? 일단 여러 작업의 단위인 트랜잭션을 더 작게 나눠서 처리해야 할 것이다. 그럼 이것을 나눌 기준을 잡아야 할 텐데 어떤 기준으로 나눠야 할까?
일단 가장 단순한 처리 순서는 '트랜잭션 하나씩 순서대로 처리하기'일 것이다. T1이 실행 후 T2가 실행되고 혹은 반대로도 가능할 것이다. 이 둘은 서로 영향을 주지도 않아 ACID도 만족하며 로직도 간단하다. 이러한 실행 순서를 스케줄이라고 한다. 그렇다면 다른 순서도 가능하지 않을까? A, B데이터를 읽고 쓰는 연산들끼리 나눠서 구성해보자.
위 그림을 보면 알 수 있듯이 트랜잭션은 연산들의 집합이기 때문에 스케줄은 쪼갤 수가 있다. 스케줄 2는 ACID를 지키면서 스케줄 1과 동등한 연산을 진행하며 작은 단위로 쪼갤 수 있었다. 이런 스케줄 2를 '순차적으로 수행하는 스케줄과 동등한 스케줄', '직렬 가능(Serializable)'이라고 한다.
SQL은 다양한 연산들이 존재한다. 위 그림에서도 보이듯이 데이터에 '쓰기 연산'이 진행될 때 문제가 발생할 수 있다. 즉, 쓰기 연산을 중점으로 직렬 가능성을 파악하는 것도 좋은 방법이다. 동일한 데이터에 접근하는 연산 중 최소 한 개가 write라면 '충돌'이라고 표현한다. 그리고 이런 '충돌하는 연산'의 순서를 바꾸는 것으로 동일한 효과가 나오나 '충돌'이 발생하지 않는 스케줄을 '충돌 직렬 가능성(Conflict Serializablity)'라고 한다. 충돌 직렬 가능성은 DBMS가 트랜잭션을 나눌 때 좋은 도구로 사용된다고 한다.
3. 트랜잭션 동시성 문제는 무엇일까?
직렬 가능성은 DBMS가 알아서 분석하고 나눠준다. 프로그래머는 트랜잭션을 작성할 때 동시성을 고려할 필요가 없어진 것이다. 근데 직렬 가능한 스케줄을 만들기 위해서는 강한 규약이 적용될 수가 있다. 예를 들면 스케줄 2를 만족하기 위해서 데이터 A에 대한 작업이 끝날 때까지 락을 걸어서 어떤 트랜잭션도 A를 읽을 수가 없도록 할 수 있다. 만약 굳이 필요하지 않는다면? 이런 문제를 위해서 SQL 표준에는 프로그래머가 선택할 수 있는 고립성 수준(Isolation Level)을 제시했다.
선택을 할 수 있다는 것은 알겠는데, 그럼 낮은 수준을 선택한다면 어떤 문제가 생길까? 그리고 고립성이 높아질수록 어떤 문제들이 해결되는 것일까?
3.1 Dirty Read
Dirty Read는 커밋이 쓰기 작업이 이루어진 데이터에 대해서 커밋이 안된 상태로 읽기가 가능한 것을 뜻한다. 처음 SELECT 연산으로 20을 얻었을 때, UPDATE 연산으로 20 -> 21로 바뀌었고 다시 SELECT를 진행하면 21로 읽게 될 것이다. 아래에 언급하겠지만 언뜻 '반복된 읽기로 발생하는 문제'로 보인다. 하지만 Dirty Read는 커밋이 반드시 필요한 것이 아니다. 즉, UPDATE 연산이 롤백이 되어 SELECT가 다른 결과를 읽을 수 있다는 것이다.
3.2 Non - repeatable Read
Non-repeatable Read는 쓰기가 된 데이터를 다시 읽었을 때 처음 읽었던 값과 나중에 읽었던 값이 다르게 나오는 것이다. 위 Dirty Read를 방지하기 위해서 COMMIT 된 이후에 읽기를 허용했는데, 커밋된 이후에 읽었더니 값이 다른 값을 읽는 문제가 발생한 것이다. 미묘한 차이지만 COMMIT을 기준으로 테이블의 버전을 나눠 서로 읽는 데이터 값이 다르지 않도록 할 수 있다. 이것은 아래에 언급할 MVCC의 방식을 뜻한다.
3.3 Phantom Read
Phantom Read는 새로운 레코드가 기록되거나 삭제가 되었을 때, 다른 트랜잭션에서 이를 읽어서 전혀 다른 레코드를 읽게 되는 것을 뜻한다. 예를 들어 10~30살 사이에 사용자들을 읽었는데, 나중에 새로운 레코드를 삽입하거나 삭제할 경우 다시 읽었을 때 10~30살 사이의 사용자 수가 다르게 나타나게 된다. Non-repeatable read을 방지하기 위해 단일 레코드 쓰기 연산에 대해서 락 혹은 MVCC를 적용할 수 있으나 SELECT ... BETWEEN 연산은 범위 검색이기 때문에 이를 제한하기 위한 Range Lock이 필요하다.
MySQL InnoDB는 이러한 문제를 Gap lock이라는 기능을 이용해서 레코드와 레코드 사이에 새로운 데이터가 기록되는 것을 방지한다. 이는 Repeatable read 모드에서 구현이 되어 있어 MySQL은 Serializable 수준까지 올리지 않아도 Phantom Read가 발생하지 않는다.
4. 동시성 문제를 해결하는 방법들은 무엇이 있을까?
왜 고립 수준이 필요하고 수준마다 발생하는 문제까지 알게 되었다. 이제 수준에 맞춰 기능을 구현하면 된다. 아래 4가지는 대표적인 해결 방안들이다.
4.1 Lock
이미 많은 시스템에서 활용하고 있는 방법이다. 락을 획득한 작업 말고 다른 작업들은 데이터에 접근할 수 없다. 잠금 기법 중에서 2PLP(two-phase locking protocol)라는 규약이 있다. 이는 각 트랜잭션이 잠금과 해제를 2단계로 나눠 진행하는 것이다. 증가 단계에서는 트랜잭션이 잠금을 얻을 수 있으나 잠금을 해제할 수 없는 단계이다. 이후 감소 단계에서는 잠금을 해제할 수 있으나 다시 잠금을 얻을 수가 없는 단계로 나눠 진행한다.
4.2 타임스탬프
시스템에 있는 각 트랜잭션마다 유일하게 고정된 타임스탬프를 지정할 수 있다. 타임스탬프를 바탕으로 사전에 미리 트랜잭션들 순서를 정할 수가 있는데 이러한 기법은 타임스탬프라 한다.
4.3 검증
트랜잭션 대부분이 read-only일 경우 트랜잭션 간의 충돌률이 비교적 낮다고 한다. 그렇지만 충돌이 아예 없다고는 할 수 없기에 검증을 통해서 부담이 적은 다른 기법으로 처리하는 것도 바람직할 것이다. 사전에 어떤 트랜잭션이 충돌을 일으킬지 모니터링하는 기법을 검증이라 한다.
4.4 MVCC
앞에 소개된 방법들은 모두 실행하는 트랜잭션 연산을 지연, 중단시킴으로써 직렬 가능성을 보장했다. 하지만 write 연산이 발생하면 새로운 테이블과 기존 테이블로 나눠 읽기 연산에 각각 다른 테이블을 읽도록 처리한다면 연산을 중단, 지연시키지 않고 더 빠르게 트랜잭션을 처리할 수 있을 것이다. 이러한 방법을 다중 버전 동시성 제어(Multi-Version Concurrency Control)이라고 한다.
4.5 실제 DBMS에서는 위 방법들을 어떻게 사용할까?
MySQL InnoDB 스토리지 엔진은 ROLLBACK이 될 가능성을 대비해 변경되기 전 레코드를 Undo 공간에 백업해두고 실제 레코드 값을 변경한다. 각 트랜잭션마다 고유의 트랜잭션 번호가 부여가 되는데 이러한 번호를 Undo 공간에 레코드와 함께 기록해두어 관리가 된다. Repeatable Read 고립 단계는 MVCC를 위해 Undo 영역의 백업 데이터를 이용해서 동일 트랜잭션 내에서는 같은 결과를 볼 수 있도록 보장한다.
5. 왜 격리 수준이 존재할까? (결론)
드디어 원래 질문으로 돌아왔다. 위에서 이미 언급했다시피 필요에 따라 선택하기 위함이다. 우리가 설계한 애플리케이션에서 사용하는 DBMS, 사용하는 트랜잭션, 설계한 테이블 등 다양한 상황에 맞춰 적절한 것을 고르면 된다.
더 나아가기
3에서 데이터베이스는 어떻게 직렬 가능한 스케줄을 파악하는지 언급하지 않았다. 충돌 직렬 가능성, 뷰 직렬 가능성에 대해 검색하면 답을 알 수 있을 것이다.
4.1 잠금 기법에 대한 자세한 내용은 '데이터베이스 시스템' 18장 p765 ~ 789에 자세히 설명되어 있다. 또한 이 영상에서 2PLP에 대한 자세한 설명이 슬라이드와 함께 제공된다. 그 외 동시성 제어 챕터(18장)에 각 기법마다 상세한 설명들이 있다.
참고자료
Isolation (database system), wikipedia, https://en.wikipedia.org/wiki/Isolation_(database_systems)
Abraham Silberschatz, Henry F. Korth, S. Sudarshan, 데이터베이스 시스템, 정연돈, 권준호 역 외 3명, (한빛아카데미, 2021)
백은빈, 이성욱 저, Real MySQL 1권 개발자와 DBA를 위한 MySQL 실전 가이드, (위키북스, 2021)