이 글은 Martin Kleppmann의 데이터 중심 애플리케이션 설계를 읽고 기억하고자 적는 게시글입니다.
7. 트랜잭션
이번 장에서는 트랜잭션에 대한 공부를 하고 있습니다. 데이터베이스를 사용하게 되면 주요한 이슈로 데이터의 신뢰성, 장애에 대처하기 위한 내결함성과 같은 문제를 해결하기 위한 방안은 항상 생각해야합니다. 그리고 이러한 작업을 데이터베이스 내에서 보장 할 수 있도록 만든 개념이 트랜잭션입니다.
트랜잭션은 애플리케이션에서 몇 개의 읽기 혹은 쓰기를 하나의 논리적 단위로 묶어 놓은 것입니다.
ACID라는 4가지 특징을 보장하면서 애플리케이션에서 사용되는 데이터를 안정적으로 운용할 수 있도록 도와줍니다. 하지만, 최근에 등장한 분산 데이터 환경 혹은 NoSQL, 컬럼 기반 데이터베이스 등은 이러한 트랜잭션을 사용하지 않는 경우도 있습니다.
그 이유는 기본적으로 트랜잭션은 사용하는데에 어느정도의 오버헤드가 발생하기 때문입니다.
이정도만 알아두고 먼저 ACID를 알아보겠습니다.
Atomicity ( 원자성 )
원자성은 트랜잭션으로 묶인 동작은 전부 실행(Commit)되거나 실행되지 않아야(Rollback) 한다. 입니다.
기본적인 이해가 있다는 가정하에 추가적으로 알아야 할 부분은 일전에 포스트에서 나왔던 WAL ( Write Ahead log ) 쓰기 전 로그 파일입니다.
B트리를 만들면서 우리는 많은 데이터를 저장 할 때 페이지를 분리하는 과정, 혹은 다양한 상황에서 장애 발생시 발생하는 문제를 알고 있습니다. 쓰기 전 로그를 별도로 작성함으로써, 문제가 발생했을 때 redo를 실행함으로써 데이터를 정확하게 저장 할 수 있습니다.
여기 redo를 동작시키기 위해서는 어느 구간에 대한 체크포인트 ( 바로 이전 커밋 )의 위치로 돌아가야 하는데, 이 때 원자성을 통해 Rollback 된다고 생각하면 될 것 같습니다.
혹은 굳이 redo가 아니더라도 예외를 발생시키는 상황에서도 결국 이전의 최종 상태로 되돌아가야 합니다.
Consistency ( 일관성 )
적절한 데이터가 입력하도록 도와주는 것입니다.
도메인에 맞는 정보 ( 스키마 설정에 따른 )에 적합한 데이터를 입력하게 하던지, 혹은 Unique(PK 포함), 참조(Ref) 등의 성격에 맞는 데이터만 입력하도록 도와줍니다.
Isolation ( 격리성 )
동시에 실행되는 트랜잭션은 서로 격리되는 것을 의미합니다.
대부분의 데이터베이스는 실시간, 혹은 배치나 이벤트에 따라 많은 데이터를 입력받는 일이 생깁니다. 이때 동일한 테이블 혹은 동일한 레코드(Row)일 가능성도 있습니다.
만약 격리되지 않는다면 어떤 액션이 먼저 이루어지는지에 따라 다른 결과를 얻게 됩니다. UPDATE의 연산을 두 트랜잭션이 동시에 하나의 레코드에 접근하여 값을 읽고 난 뒤 변환하는 작업을 수행했을 때, 아직 하나의 트랜잭션이 읽고 변환하기 전에 다른 트랜잭션이 해당 값을 읽는다면, 결국 두 트랜잭션 중 어느 하나의 변환은 적용되지 않게 됩니다.
격리성은 격리 수준 및 Lock의 차이에 따라 큰 성능차이를 보이기 떄문에 사용 용도에 따라 적절하게 수정해야 합니다.
이러한 성능 문제로 인해 스냅숏 격리를 한다고 하는데, 이 부분은 천천히 알아보겠습니다.
Durability ( 지속성 )
데이터를 메모리가 아닌 디스크에 저장하는 것으로 영구적인 데이터 보존을 위함입니다. 최근에는 서버간의 복제도 하나의 데이터 보존방법으로 떠오르면서 고려 할 사항이 많이 늘어났다고 합니다.
다중 객체 연산과 단일 객체 연산
책에서는 메일을 저장하고 읽지 않은 메일을 표시하는 서비스에 대해서 예시를 들고 있습니다.
A라는 사람이 B 사람에서 메일을 보냈을 경우 B 사람의 메일함에 메시지를 추가하고 B 사람의 정보에 읽지 않는 메시지 수를 증가 시키는 작업을 할 경우라고 예시를 생각해봅시다.
메일함에 메일을 추가했는데, 읽지 않는 메일 수가 아직 증가되지 않은 상태일 때 유저가 보는 메일함에는 해당 메일을 포함한 읽지 않은 메일의 수가 표시되지 않습니다. ( 적어도 Read Commited 격리수준 이상 일 때 )
이 것은 데이터 애플리케이션이 잘못 동작한 것이 아니라 격리성으로 인해 삽입 및 갱신 된 메일을 모두 보거나 모두 보지 않는 상태로 만들어 준 것입니다. 즉 일관성이 깨진 중간 과정은 볼 수 없습니다.
보통 RDB에서는 다중 객체 연산을 할 때 Transaction으로 설정 된 곳이 명시적으로 표현되지만 NoSQL와 같은 애플리케이션은 여러 키를 갱신하는 다중 put을 지원하더라도 트랜잭셔널하게 동작하지 않을 수 있어서 어떤 연산은 성공하고 어떤 연산을 실패하는 문제가 발생 할 수 있습니다.
단일 객체 쓰기
저장소 엔진은 기본적으로 한 노드에 존재하는 단이 객체 수준의 원자성과 격리성을 제공하는 것을 목표로 한다고 합니다.
간단히 말하면, 장애 복구는 쓰기 전 로그 파일을 이용하여 복구하고, Lock을 이용하여 한 스레드만 해당 데이터에 접근하도록 만듭니다.
이러한 동작도 하나의 트랜잭션이지만, 일반적으로 우리가 선언하는 트랜잭션(BEGIN TRANSACTION)을 만들때는 다중 객체의 다중 연산을 전제로 합니다.
다중 객체 트랜잭션의 필요성
많은 분산 데이터베이스는 다중 객체 트랜잭션 지원을 포기했지만, 구현의 어려움이 있을 뿐 근본적으로 불가능 한 일은 아니며, 필요성이 있다고 말합니다.
RDB의 경우에는 참조되는 데이터, 그래프 형 데이터베이스는 적절한 참조 형태를 유지하는데에 도움이 될 수 있습니다.
문서형 데이터베이스의 경우에도 비정규화하여 설계 할 수 있는 가능성이 높기 때문에 한번에 여러 문서을 수정 할 수 있습니다.
키-값 형식의 데이터베이스는 일단 인덱스때문이라도 적절한 수정이 함꼐 이루어져야 합니다.
오류 / 어보트 처리
Abort의 취지는 안전한 재입력 실행이라는 점을 강조합니다. 하지만 재입력만으로 해결되지 않는 부분이 있기 때문에 예외적인 상황을 잘 알아야 한다고 합니다.
- 트랜잭션이 정상적으로 실행되고, 커밋 성공을 알리려는 도 중 네트워크가 끊기면 트랜잭션은 실패로 생각하고 총 2번 실행된다.
- 과부하 때문이면 결국 재입력은 실행되지 않는다.
- 영구적인 오류 ( 제약 조건 위반 등 )의 경우에는 결국 재입력은 안된다.
- 트랜잭션에 외부 동작이 있다면 ( 메일링 서비스 등 ) 이러한 동작이 N번 일어난다.
- 클라이언트가 재입력 중 죽으면 데이터가 소실된다.
완화된 격리 수준 ( 격리성의 진실 )
우리가 생각하는 격리성의 내용은 여러 트랜잭션이 한 객체에 동시에 접근하려는 동시성의 문제를 해결해준다고 알고 있습니다. 하지만, 이 문제를 완벽하게 해결하기에는 상당한 부하를 갖게 되고 어려운 일이라고 합니다.
그래서 많은 데이터베이스 ( 많은 RDB 또한 )가 이러한 문제를 어느정도 포기하고 완화된 격리 수준을 선택한다고합니다.
완전한 격리를 하자니 성능 부하가 크고, 아예 포기하기에는 치명적이며 심지어는 동시성 문제를 파악하거나 발생 테스트조차 힘들기 때문에 어느정도의 동시성 문제를 해결 할 수 있는 방법으로 완화된 격리 수준을 제공하는겁니다.
어떤 말인가 했더니 데이터베이스의 격리 수준에 대한 내용이였습니다.
격리수준
1. 커밋 후 읽기 ( read committed )
다음의 두 가지 특징을 가지고 있습니다.
- 데이터베이스에서 읽을 때 커밋된 데이터만 보게 된다.
- 데이터베이스에 쓸 때 커밋된 데이터만 덮어쓰게 된다.
더티 읽기 방지
너무 당연한 이야기라서 간단히 적겠습니다. 사용자가 커밋되지 않은 데이터를 읽게 된다면 트랜잭션 실행 중 중간의 변동사항을 읽고, 트랜잭션이 어보트 되게 되면 이후 읽기의 결과가 다를 수 있습니다.
더티 쓰기 방지
A라는 사람이 B에게 돈을 보내는 트랜잭션이 실행 될 때 B가 A에게 돈을 보낼 경우 서로의 잔액을 읽는 시점이 동시에 이루어 질 경우 어느 한 쪽의 동작은 비정상적인 결과를 갖게 됩니다.
2. 스냅숏 격리 ( snapshot Isolation ) - MVCC - Repeatable Read
커밋 후 읽기의 단점은 트랜잭션이 동작하는 중간에 어느 트랜잭션이 커밋되게 된다면 시점에 따라 읽는 데이터가 다를 수 있다는 점 입니다. 이러한 문제는 대체적으로 크리티컬한 문제가 아닐 수 있습니다.
왜냐하면 일단 커밋이 되었다면, 적절한 변경사항이 동작했기 때문에 데이터 자체의 유실이나 잘못된 입력은 없을 수 있지만, 사용자의 입장에서는 다를 수 있습니다.
일반적으로 수 초, 길면 수십초에 해당하는 데이터 지연은 사용자에게 안좋은 경험을 줄 수 있기 때문에 방지 할 필요가 있습니다.
이러한 문제의 해결방안으로 스냅숏 격리가 있습니다. 간단히 말하면 트랜잭션이 완전히 실행되기 전에 동작한 트랜잭션은 동일한 버전의 데이터만을 바라보도록 강제하는 방법입니다.
이 방법의 장점은 락이 없다는 점입니다. 그에 따른 부하도 줄어들게 됩니다. 그저 실행시점이 늦은 트랜잭션은 이전 버전의 데이터를 바라 볼 뿐 어떤 락의 영향을 받지 않습니다. 또한 갱신의 경우 값을 교체하지 않고 값이 바뀔때마다 새로운 버전을 생성하는 방법으로 오버헤드를 줄입니다.
일반적으로 분석이나 백업과 같은 읽기만 실행하는 질의에 요긴하게 사용된다고 합니다.
추가적으로 알아 본 결과 몇 가지 문제점은 있습니다. 하나의 데이터에 대해 여러 버전의 데이터를 허용하기 때문에 버전이 충돌 할 수 있으며, UNDO 블록 I/O, CR COPY 생성, CR 블록 캐싱과 같은 오버헤드가 추가적으로 발생한다고 합니다.
스냅숏 격리수준을 이용 할 때 다음의 조건을 만족하는 데이터만을 볼 수 있습니다.
- 읽기를 수행하는 트랜잭션이 시작하는 시점이 대상 객체를 생성한 트랜잭션이 이미 커밋 되었을 때
- 읽기 대상이 되는 객체가 삭제되지 않았거나 삭제 되었지만 그 시점에 삭제 요청 트랜잭션이 커밋되지 않았을 때
갱신 손실 ( Update lost )
추가적으로 갱신 손실이라는 문제가 발생 할 수 있습니다. 이 문제는 두 트랜잭션이 동시에 한 객체에 대해 작업을 하던 도중 한 트랜잭션이 커밋하여 데이터가 업데이트 되면 다른 트랜잭션이 이러한 업데이트를 알아채지 못하여 자신의 업데이트 내용만 적는 문제입니다.
대부분의 RDB의 스냅숏 격리 수준에서 이러한 문제를 감지해준다고 하지만 하필 MYSQL에서는 제공하지 않는다고 합니다.
compare and set
쉽게 말하면 갱신하기 전에 값을 한 번 비교하는 작업입니다. 하지만, 현재 버전의 값이 이전 버전의 값과 일치하지 않으면 Read-Modify-Write 연산을 다시 한 번 수행해야 합니다.
하지만 이 과정에서 오래된 스냅숏부터 읽는게 가능하면, 갱신 손실이 일어 날 수 있으니 안전한지 확인하라고 합니다.
갱신 손실 방지 방법
- 원자적 쓰기 : 개별 객체의 Lock을 흭득해서 원자적 연산으로 수행
- 불안전한 read-modify-write를 할 수 있으며, 테스트로 문제를 찾기 어려울 수 있음
- 명시적인 잠금 : FOR UPDATE 키워드를 추가해서 해당 로우에 락을 거는 법
- 필요한 락을 누락하거나 할 시 경쟁조건이 유발 될 가능성이 높다.
- 갱신 손실 자동 감지 : 자동으로 갱신 손실을 감지하여 read-modify-write를 자동 재실행
- MySQL에서 지원 안함
- compare-and-set : 변경 될 가능성이 있는 값을 WHERE 절에 포함해서 업데이트
- 변경된 다양한 버전의 변화를 병합 : 어떤 갱신이든 버전을 병합하면 갱신 손실은 방지
쓰기 스큐 ( write skew )
쓰기 스큐는 위의 Compare-and-Set 문제에서 명확하게 알 수 있습니다. 책에서 매우 명확한 예제를 들고 있기에 유사하게 예를들면 다음과 같이 이야기 할 수 있습니다.
적어도 하나의 빵을 남겨야 하는 상황에서 빵이 2개 남았고, 두 사람이 빵을 먹고자 할 때 Compare 시점에서 두 사람 모두 빵 2개를 발견하고, Set 시점에서 각각 하나씩 집어들면 결국 적어도 하나의 빵을 남겨야 한다는 조건이 충족되지 못하게 되는 것 입니다.
두 사람의 트랜잭션이 모두 정상적으로 동작했기 때문에 갱신이상은 아니고, 마찬가지로 정상적으로 커밋된 정보만을 읽었기에 Dirty Read도 아닙니다.
이러한 상황을 쓰기 스큐라고 합니다.
팬텀
팬텀이 발생 할 수 있는 상황은 업데이트 중에 락을 걸 수 있는 객체가 없을 때 발생 할 수 있습니다. 갱신 손실 방법에서 로우에 락을 거는 방법을 사용 할 때 결국 SELECT로 해당되는 데이터를 읽어야 하는데, 그 때 로우가 없으면 락을 흭득 할 수 없게 됩니다.
이 경우에는 갱신 손실, 혹은 쓰기 스큐가 발생 할 가능성이 높습니다.
해결방안은 다음과 같이 있습니다.
- 충동 구체화 : 락을 걸 수 있는 로우를 미리 생성해놓는 방법
- 직렬성 활용 : 동시성 없이 한 번에 하나씩 직렬로 실행
- 실제적인 직렬 실행 : 단일 스레드로 한 번에 하나씩 트랜잭션을 실행, 락을 코디네이션하는 과정에 제외되서 꽤 빠른 편 ( OLTP 트랜잭션이 보통 읽기와 쓰기가 적기 때문에 사용 할 만함 )
- 파티셔닝 : 데이터를 파티셔닝하여 각 트랜잭션이 단일 파티션에서만 데이터를 읽고 쓰도록하는 방법으로 CPU 코어에 맞게 확장가능 ( 하지만 파티션을 코디네이션하는 과정에서 복잡성이 올라가고 성능이 급감 )
- 트랜잭션을 스토어드 프로시저 안에 캡슐화 : 요청 내 트랜잭션들을 하나의 작업으로 묶어서 실행하면 네트워크 IO, 디스크 IO를 줄일 수 있음 ( 하지만 프로시저 언어 및 데이터베이스 성능 측면에서 문제를 인지해야 함 )
transaction-stored.png
2단계 잠금 ( 2PL )
2단계 잠금은 다음의 특징을 가집니다.
- A 트랜잭션이 객체를 하나 읽고 B 트랜잭션이 해당 객체에 쓰기를 원한다면, A 객체가 커밋되거나 어보트 될 때까지 B는 기다립니다.
- A 트랜잭션이 객체에 쓰고 있고 B 트랜잭션이 해당 객체를 읽기 원한다면 마찬가지로 A 객체가 커밋되거나 어보트 될 때까지 B는 기다립니다.
이러한 방식을 구현하기 위해서는 공유 잠금과 독점 잠금을 별도로 구현하여 읽기에는 공유 잠금을 사용해 한 객체를 여러 트랜잭션이 읽을 수 있도록하고, 독점 잠금은 공유 잠금이든 독점 잠금이든 끝날때까지 기다려서 얻는 방식으로 동작시켜야 합니다.
성능 상 거의 쓰이지 않습니다.
직렬성 스냅숏 격리 ( SSI )
상당히 유망한 직렬성 관리로 연구중인 분야라고 합니다.
트랜잭션을 수행 할 때 전제로 가정했던 부분 ( 빵이 몇 개 있는가 와 같은 SELECT )이 기존에는 참이였지만, 거짓이 될 수 있기에 이러한 전제의 변화를 감지하여 트랜잭션을 커밋 혹은 어보트 시키는 방법입니다.
- 오래된 MVCC 읽기 감지하기
스냅숏을 관리 할 때는 여러 버전을 동시에 가지고 있기 때문에 읽을 때 가장 최근의 버전을 확인하는 것은 하나의 방법이 될 수 있습니다. 만약 읽을 당시의 버전보다 높은 버전이 있을 경우에는 어보트 할 수 있습니다.
- 과거의 읽기에 영향을 미치는 쓰기 감지하기
과거의 읽기의 대상이 되는 객체들에 대해서 다른 트랜잭션에서 쓰기를 수행했었다면, 이 쓰기만 감지하면 기존 전제가 변경 될 가능성이 있다는 점을 생각할 수 있습니다.
3. 직렬성 ( Serialize )
동시성을 허용하지 않는 방식입니다. 각 트랜잭션을 하나씩 실행하기 때문에 동시성 문제는 발생하지 않습니다.
하지만 락으로 인해 성능이 매우 안좋아집니다.