‘데이터 중심 애플리케이션 설계’(by Martin Kleppmann)의 7장(트랜잭션) 일부 내용을 정리한 글입니다. 부족하다고 생각되는 부분은 내용을 덧붙였으며 제 개인적인 생각이 중간중간 첨가되어 있습니다.
이번 글에서는 다음과 같은 내용을 설명합니다.
- 트랜잭션의 의미
- ACID 와 격리성(Isolation)
- 여러 단계로 약해진 격리 수준
- 각 격리 수준의 특징과 그 구현
잔인한 현실 세계에서 우리 애플리케이션에는 다양한 문제가 발생할 수 있습니다. 가령 하드웨어는 언제든 고장날 수 있고, 애플리케이션은 언제라도 죽을 수 있으며, 네트워크는 갑자기 단절될 수 있고, 클라이언트 사이의 경쟁 조건은 예측하지 못한 버그를 유발하거나 데이터베이스에 비정상적인 상태를 만들어낼 수 있죠.
이런 잔혹한 상황속에서 신뢰성을 지닌 애플리케이션을 개발하기란 너무 어려웠습니다. 잘못될 수 있는 모든 것에 대해 신중하게 고민하고 테스트 해야하기 때문입니다. 이 문제를 해결하기 위해 개발자들은 심플한 메커니즘 하나를 고안해냈는데, 그것이 ‘트랜잭션’ 입니다.
트랜잭션의 정의
트랜잭션이란 애플리케이션에서 ‘몇 개의 읽기와 쓰기를 하나의 논리적 단위로 묶는 방법’ 입니다. 한 트랜잭션은 전체가 성공(커밋)하거나 실패(어보트, 롤백)해야합니다.
당연한 말이지만 트랜잭션은 자연 법칙 같은게 아닙니다. 데이터베이스에 접속하는 애플리케이션에서 프로그래밍 모델을 단순화 하려는 목적으로 만든 개념이죠.
하지만 트랜잭션도 다른 모든 기술적 선택과 마찬가지로 그 이점과 한계가 명확합니다. 이 트레이드오프를 이해하여 우리 상황에 맞는 적절한 보장을 선택해야합니다.
ACID
ACID 는 트랜잭션이 제공해야하는 네가지 안전성 보장을 의미하는 약어입니다. 원자성(Atomicity)과 일관성(Consistency), 격리성(Isolation)과 지속성(Durability) 이 그것인데요, 이번 글은 격리에 관한 이야기이므로 격리성에 대해서만 짧게 다루고 넘어가겠습니다.
격리성(Isolation)
ACID 에서 격리성은 동시에 실행되는 트랜잭션은 서로 격리된다는 것을 의미합니다. 고전적인 데이터베이스 교과서에서는 격리성을 직렬성(Serializable)이라는 용어로 공식화합니다. 데이터베이스에 실제로는 여러 트랜잭션이 동시에 실행되었더라도 커밋의 결과는 트랜잭션들이 순차적으로 실행되었을 때와 동일함을 보장하죠.
하지만 직렬성 격리(Serializable Isolation)는 큰 성능 손해를 동반하므로 현실에서는 잘 사용되지 않습니다.
약해진 격리 수준
동시성 문제(경쟁 조건)은 한 트랜잭션이 다른 트랜잭션에서 변경한 데이터를 읽거나 두 트랜잭션이 동시에 같은 데이터를 변경하려고 할 때 발생합니다. 이에 데이터베이스는 트랜잭션 격리를 제공함으로써 애플리케이션에 동시성 문제를 감추려 했습니다. 한 번에 하나의 트랜잭션만 실행 가능한 직렬성 격리를 제공함으로써 동시성 문제를 차단할 수 있었죠.
하지만 큰 힘에는 큰 책임이 따른다고 했던가요? 직렬성 격리는 그 힘에 대한 책임(비용)이 크기에 많은 데이터베이스들은 그 비용을 온전히 감당하기를 꺼려했습니다. 그래서 직렬성 격리보다 완화된 격리 수준을 사용하는 시스템들이 생겨났습니다. 직렬성보다 이해하기 어렵고 미묘한 버그를 유발할 수 있음에도 말이죠.
직렬성보다 약해진 여러 격리 수준, 각 수준이 보장해주는 것과 보장해주지 못하는 것을 이해하고 우리가 개발하는 애플리케이션에 적합한 격리 수준을 선택할 수 있는 안목을 길러봅시다.
READ COMMITTED : 커밋 후 읽기
가장 기본적인 수준의 트랜잭션 격리는 Read Committed(격리 후 읽기)입니다.(Read Uncommitted 는 제외) 이 수준은 이름 그대로 다른 트랜잭션이 커밋한 이후의 데이터만 읽을 수 있는 격리 수준입니다. 크게 보면 두 가지를 보장해주죠.
- 데이터베이스에서 읽을 때 커밋된 데이터만 보게 된다.
- 데이터베이스에 쓸 때 커밋된 데이터만 덮어쓰게 된다.
이미 커밋된 데이터만 읽어오므로 아직 커밋되지 않은 데이터를 읽는 현상을 일컫는 Dirty Read 현상은 발생하지 않습니다. 또한, 아직 커밋되지 않은 갚을 덮어쓰는 Drity Write 현상도 발생하지 않습니다. 이는 보통 먼저 쓴 트랜잭션이 커밋되거나 어보트될 때까지 나머지 쓰기 트랜잭션을 지연시키는 방법을 사용합니다.
READ COMMITTED 구현
Read Committed 격리 수준을 구현하는 가장 간단한 방법은 모든 읽기에 동일한 락을 요구하는겁니다. 그러면 객체에 아직 커밋되지 않은 변경이 있을 때 읽기가 실행되지 않도록 보장할 수 있죠. 하지만 이 방법은 너무 비효율적이기 때문에 Read Committed 격리 수준에서는 잘 사용되지 않습니다.
그래서 대부분의 데이터베이스는 현재 쓰기를 하고 있는 트랜잭션에서 쓴 값과 과거에 가장 최근 커밋된 값을 모두 기억하고 지금 쓰기를 진행중인 트랜잭션을 제외한 나머지 트랜잭션은 과거의 값을 읽게하는 방법을 사용합니다.
REPEATABLE READ : 반복 읽기
Read Committed 격리 수준에서는 현재 트랜잭션이 진행중인데 다른 트랜잭션이 쓰기 커밋해버린다면 그 변경결과가 바로 반영됩니다. 동일한 쿼리를 여러번 수행했을 때 그 결과가 계속 달라질 수 있는거죠. 이 현상을 Non-Repeatable Read 라고 부릅니다. 이 현상은 어떤 비즈니스에서는 충분히 감내 가능하다고 볼 수 있지만 또 어떤 경우에는 감내하기 힘든 치명적인 문제로 간주할 수도 있습니다.
Repeatable Read 격리 수준에서는 이 현상이 나타나지 않습니다. 한 트랜잭션에서 실행한 쿼리는 아무리 다시 실행해도 같은 값을 반환함을 보장합니다.
REPEATABLE READ 구현
Repeatable Read 는 보통 스냅숏 격리 방식으로 구현합니다. 각 트랜잭션은 데이터베이스의 일관된 스냅숏으로 부터 읽는거죠. 그래서 다른 트랜잭션이 아무리 실제 데이터를 바꾸더라도 각 트랜잭션은 특정 과거 시점의 데이터를 볼 뿐인겁니다.
스냅숏 격리는 위에서 설명했던 Read Committed 에서 사용한 메커니즘을 조금 더 심화한 방법을 사용합니다. 데이터베이스는 객체마다 커밋된 버전 여러개를 유지하는겁니다. 진행 중인 수많은 트랜잭션의 서로 다른 시점의 데이터베이스 상태를 봐야하기 때문이죠. 이처럼 데이터베이스가 객체의 여러 버전을 함께 유지하므로 이 기법은 MVCC(Multi-Version Concurrency Control, 다중 버번 동시성 제어)라고 부릅니다.
이말은 곧 데이터베이스가 Read Committed 격리만 제공한다면 객체마다 버전 두개씩만 유지하면 충분하다는 뜻이겠죠. (가장 최근에 커밋된 버전과 쓰기가 이루어졌지만 아직 커밋되지 않은 버전)
MVCC 기반 스냅숏 격리는 트랜잭션마다 계속 증가하는 고유한 트랜잭션 ID(txid)를 부여하는 방식으로 구현합니다. 간단히 설명하면 현재 트랜잭션의 txid 보다 낮은 txid 가 쓴 값만 읽게하는 방식입니다.
Lost Update : 갱신 손실
Repeatable Read 또한 막지 못하는 경쟁 조건이 여러가지 있습니다. 그 중 하나가 갱신 손실(lost update)입니다. 갱신 손실이란 말그대로 갱신이 손실되는 것을 말하는데, 경쟁 조건에서 한쪽의 갱신이 다른 한쪽의 갱신을 덮어씌워버리는 것입니다. 이와 관련한 가장 간단한 예시로 아래와 같은 카운터 증가 연산을 들 수 있겠네요.
User 1 의 증가연산은 User 2 의 증가연산에 의해 손실(lost)되었습니다. MVCC 기반의 Repeatable Read 격리 수준은 이 현상을 방지할 수 없습니다.
직렬성으로 격리 수준을 올리지 않으면서 갱신 손실 현상을 막는데는 여러가지 방법이 존재합니다.
- 원자적 쓰기 연산
대부분의 데이터베이스는 다음처럼 read-modify-write 주기를 구현한 원자적 쓰기를 제공합니다.
UPDATE conters SETR value = value + 1 WHERE key = 'foo';
모든 쓰기가 위처럼 쉽게 원자적 연산으로 표현되는 않겠지만 이를 사용할 수 있는 상황에서는 보통 이것이 최선의 선택이죠.
- 명시적인 잠금
또 다른 선택지는 애플리케이션에서 갱신할 객체를 명시적으로 잠그는 것입니다. 한 트랜잭션에서 read-modify-write 주기에 들어가면 다른 트랜잭션은 그 주기가 끝날 때 까지 해당 객체를 읽을 수 없게 하는겁니다.
- 갱신 손실 자동 감지
위처럼 하지않고 병렬 실행을 허용하되, 트랜잭션 관리자가 갱신 손실을 발견하면 해당 트랜잭션을 어보트시키고 read-modify-write 주기를 재시도하는 방법도 있습니다. 실제로 Postgresql의 Repeatable Read, 오라클의 Serializable 격리 수준은 갱신 손실이 발생하면 이를 감지하고 트랜잭션을 어보트시킵니다. 하지만 MySQL/InnoDB 의 Repeatable Read 격리 수준은 이를 감지하는 기능을 제공하지 않습니다.😭
SERIALIZABLE : 직렬성
Serializable 격리 수준에서는 이름이 나타내듯이 여러 트랜잭션이 병렬로 실행되었다한들 최종 결과는 각 트랜잭션이 직렬로 차례차례 수행되었을 때와 같음을 보장합니다. 이는 보통 가장 강력한 격리 수준으로 여겨지며 데이터베이스에서 발생할 수 있는 모든 경쟁조건을 막아줍니다.
Serializable 격리 수준은 어떻게 구현할 수 있을까요? 제일 먼저 떠오르는 생각은 모든 트랜잭션을 진짜 순서대로 실행하는 것입니다. 물론 이 방법이 유리한 경우도 있겠으나 이는 다소 비효율적입니다. 직렬성을 구현함에 있어서 선택지는 여러가지가 존재합니다.
- 말 그대로 트랜잭션을 들어온 순서대로 실행하기
- 2단계 잠금(2PL)
- 비관적/낙관적 동시성 제어 기법(SSI: 직렬성 스냅숏 격리)
위 각각의 직렬성 격리 수준 구현 방식에 대한 상세한 내용은 별도의 글로 따로 작성할 예정입니다.😵
정리
트랜잭션은 우리가 개발할 애플리케이션이 수많은 소프트웨어/하드웨어 문제를 다소 단순하고 쉽게 해결할 수 있는 방법을 제시합니다. 셀 수 없을정도로 다양한 종류의 오류에 대한 대처가 단 하나의 트랜잭션 어보트로 해결됩니다. 애플리케이션은 그저 재시도만 하면 되죠.
이번 글에서 트랜잭션의 여러 격리 수준의 특징과 각 격리 수준이 막아줄 수 있는 것과 없는 것에 대해 살펴봤습니다. 직렬성 격리만 사용하면 모든 문제를 해결해주겠지만 그만큼의 희생이 따르기에 약한 격리 수준을 사용하고 동시성에 취약적인 부분만 애플리케이션이 명시적인 잠금으로 해결해주는 방법을 사용하기도 했습니다.
많은 데이터베이스들이 트랜잭션과 여러가지 격리수준을 제공해준다고 외치고 있지만 각각의 속내는 달랐습니다. 동일한 격리 수준을 제공하는 두 가지 데이터베이스라도 한 데이터베이스에서는 일어나지않던 문제가 다른 데이터베이스에서는 일어날 수 있습니다.
트랜잭션의 의미와 각 격리수준을 이해하고 자신이 개발할 비즈니스와 애플리케이션 요구사항에 맞는 적합한 데이터베이스와 그 격리 수준을 선택할 수 있었으면 좋겠습니다. 😌