[데이터중심 어플리케이션 설계] 7장 정리
현실에서 데이터 시스템은 여러 문제가 생길수 있다
- 데이터베이스 소프트웨어, 하드웨어는 언제라도 실패할 수 있다
- 어플리케이션은 언제라도 죽을 수 있다
- 네트워크 순단으로 연결이 끊기거나 통신이 안될 수 있다
- 여러 클라이언트가 동시에 쓰기를 실행해 데이터가 덮어씌어질 수 있다
- 부분적으로만 갱신된 데이터를 읽을 수 있다
- 클라이언트간 경쟁 조건은 예측하지 못한 버그를 유발할 수 있다.
시스템이 신뢰성을 지니려면 이런 결함을 처리해서 전체 시스템의 치명적 장애로 이어지는 것을 막아야 한다.
트랜잭션 은 이런 문제를 단순화하는 메커니즘으로 채택돼 왔다.
트랜잭션이란 애플리케이션에서 몇 개의 읽기와 쓰기를 하나의 논리적 단위로 묶는 방법이다.
트랜잭션은 전체가 성공(커밋)하거나 실퍠(어보트, 롤백) 한다.
ACID 의 의미
트랜잭션이 제공하는 안전성 보장은 흔히 원자성(Atomicity), 일관성(Consistency), 격리성(Isolation), 지속성(Durability) 약어인 ACID 로 알려져 있다.
원자성
원자적 이란 일반적으로 더 작은 부분으로 쪼갤 수 없는 무언가를 가리킨다.
ACID 맥락에서 원자성은 동시성과는 연관이 없다. 이 사항은 격리성에서 다룬다.
ACID 에서 원자성은 클라이언트가 쓰기 작업 몇 개를 실행할때 일부만 처리된 후 생길수 있는 일을 설명한다.
여러 쓰기 작업이 하나의 원자적인 트랜잭션으로 묶여 있을때, 중간에 결함 때문에 커밋 될수 없다면 어보트 되고 트랜잭션의 쓰기를 무시하거나 취소한다.
일관성
ACID 일관성은 데이터에 관한 항상 진실이어야 하는 상태가 있다는 개념이다.
하지만 이는 데이터베이스에서 보장할수 있는 것이 아닌, 애플리케이션의 불변식 개념에 의존한다.
원자성, 격리성, 지속성은 데이터베이스의 속성인 반면 일관성은 애플리케이션의 속성이다.
격리성
데이터베이스는 대부분 여러 클라이언트가 동시에 접속한다.
동일한 레코드에 여러 클라이언트가 접근하면 동시성 문제를 맞닥뜨린다.
ACID 에서 격리성은 동시에 실행되는 트랜잭션은 서로 격리된다는 것을 의미한다. (트랜잭션은 다른 트랜잭션을 방해할 수 없다)
지속성
데이터베이스의 목적은 데이터가 유실되지 않게 안전한 저장소를 제공하는 것이다.
지속성은 트랜잭션이 성공적으로 커밋했다면 하드웨어 결함이나 데이터베이스에 문제가 있더라도 기록한 데이터는 손실되지 않는다는 보장이다.
하지만 100퍼센트 완벽한 지속성은 존재하지 않는다.
오류와 어보트 처리
트랜잭션의 핵심 기능은 오류가 생기면 어보트되고 안전하게 재시도할 수 있다는 것이다.
어보트된 트랜잭션을 시도하는 것은 간단하고 효과적인 오류 처리 방법이지만 완벽하지는 않다.
- 트랜잭션이 실제로는 성공했지만 성공 응답을 반환하는 도중 네트워크 순단 발생. 재시도 하면 트랜잭션이 두번 실행된다.
- 과부화로 인한 오류라면 트랜잭션 재시도는 상황을 악화시킬 수 있다. 재시도 횟수를 제한하던지 지수적 백오프를 사용하거나 과부화 관련 오류는 따로 처리한다
- 제약 조건 위반 등 영구적인 오류는 재시도해도 소용이 없다
- 데이터베이스 외부의 부수 효과가 있다면, 어보트 될때 부수효과도 실행될 수 있다
동시성 문제와 격리 수준
동시성 문제는 트랜잭션이 다른 트랜잭션에서 동시에 변경한 데이터를 읽거나 같은 데이터를 변경하려고 할때 일어난다.
이런 상황은 테스트로 발견하기 어렵고 재현하기도 힘들다.
그래서 데이터베이스들은 트랜잭션 격리 를 제공함으로써 애플리케이션 개발자들에게 동시성 문제를 감추려 했다.
이론적으로 애플리케이션 개발자들은 동시성 문제를 없는 것처럼 행동할수 있다.
이런 격리는 그리 간단하지 않다. 격리 수준에 따라 어떤 동시성 이슈는 보호해주지만 어떤건 보호해주지 못하는 완화된 격리 수준을 사용하는 시스템들도 있다.
이런 격리성 수준은 직렬성 격리 보다 이해하기 어렵고 버그를 유발하지만, 성능 비용 문제로 많이 사용되고 있다.
따라서 동시성 문제의 종류와 격리 수준의 종류를 잘 이해해야 문제를 방지할 수 있다.
먼저 발생할수 있는 동시성 문제에 대해 알아보고, 격리 수준과 이 격리 수준이 보장해주는 문제는 어떤게 있는지 살펴보자
동시성 문제
Dirty read/write
한 트랜잭션이 데이터베이스에 데이터를 썼지만 아직 커밋/어보트 되지 않았을때, 다른 트랜잭션이 이 데이터를 볼수 있다면 이를 더티 읽기 라 한다.
두 트랜잭션이 데이터베이스의 동일한 레코드를 동시에 갱신하려고 하면 어떤 일이 생길까?
일반적으로 나중에 쓴 내용이 먼저 쓴 내용을 덮어쓴다고 가정한다.
먼저 쓴 내용이 아직 커밋되지 않은 트랜잭션이 쓴 것이고, 나중에 쓴 내용이 커밋되지 않은 값을 덮어써버리는 현상을 더티 쓰기 라 한다.
nonrepeatable read 비반복 읽기

위 그림에서 앨리스의 은행 계좌가 두개 있고 각 500달러씩 나누어져 있다.
계좌 중 하나에서 다른 계좌로 100달러를 송금한다.
트랜잭션이 처리되고 있는 순간에 계좌 잔고를 보면 한 계좌는 입금 전 상태, 한 계좌는 출금 후 상태를 볼수도 있다.
총 잔액이 1000달러야 하는데 900달러 인 순간이 발생하는 것이다.
이런 이상 현상을 비반복 읽기(nonrepeatable read) 나 읽기 스큐(read skew) 라 한다.
몇 초 후 새로고침 하면 해결되어 지속적인 문제는 아니지만 어떤 상황에서는 일시적인 비 일관성을 허용할수 없는 경우도 있다.
phantom read
한 트랜잭션에서 실행한 쓰기가 다른 트랜잭션의 검색 질의 결과를 바꾸는 효과를 팬텀읽기라 한다
일어날수 있는 예시 상황중 하나는 다음과 같다
- SELECT 질의가 어떤 검색 조건에 부합하는 로우를 반환후 요구사항 만족 여부를 확인한다
- 1번의 결과에 따라 애플리케이션 코드가 어떻게 진행될지 결정한다.
- 애플리케이션이 트랜잭션을 커밋한다. 이 쓰기로 인해 2번 단계를 결정한 전제 조건이 바뀐다. 이 쓰기 이후 1단게 SELECT 질의를 다시 실행하면 요구조건에 부합하지 않는다.
격리 수준
read uncommitted
데이터베이스에 따라 커밋 전 읽기를 지원하는 것도 있다.
이는 더티 쓰기는 막아주지만 더티 읽기는 막지 못한다.
read committed
가장 기본적인 수준의 격리 수준이고, 이 격리는 더티 읽기/쓰기 없음 두 가지를 보장해준다.
이 격리 수준은 많이 쓰이는 격리 수준이다.
흔한 구현 방법으로는 로우 수준 잠금을 사용해 더티 쓰기를 방지한다.
특정 객체를 변경하고 싶다면 해당 객체에 대한 잠금을 획득해야 한다.
트랜잭션이 커밋되거나 어보트될 때까지 잠금을 보유하고 있어야 하고, 오직 한 트랜잭션만 특정 객체에 대한 잠금을 보유할 수 있다.
더티 읽기는 어떻게 막을 수 있을까?
쓰기와 같이 잠금을 써서 객체를 읽기를 원하는 트랜잭션이 잠금을 획득하고 읽게 하는 방법이 있을 것이다.
하지만 이는 잘 동작하지 않는다. 읽기만 수행하는 트랜잭션이라도 쓰기 트랜잭션이 완료될 때까지 기다려야 할수 있기 떄문이다.
따라서 대부분의 데이터베이스는 현재 쓰기 잠금을 갖고 있는 트랜잭션이 쓰고 있는 값과 이전 값을 모두 기억하고,
쓰기 트랜잭션이 실행 중인 동안 다른 트랜잭션은 과거의 값을 읽게 한다.
더티 읽기 방지
트랜잭션이 쓴 내용은 커밋된 후에 다른 트랜잭션이 보이게 한다.

사용자 2는 사용자 1의 트랜잭션이 커밋된 후에야 x 의 새 값을 볼 수 있다.
더티 읽기를 방지하는게 유용한 이유는
- 트랜잭션이 여러 객체를 갱신할때 다른 트랜잭션이 일부는 갱신된 값을, 일부는 갱신되지 않은 값을 볼 수 있다. 부분적으로 갱신된 상태는 다른 트랜잭션이 잘못된 결정을 하는 원인이 될 수 있다
- 트랜잭션이 어보트 되면 쓴 내용이 롤백되어야 하는데, 더티 읽기를 허용하면 나중에 롤백될 데이터도 볼 수 있다.
더티 쓰기 방지
보통 먼저 쓴 트랜잭션이 커밋되거나 어보트 될 때까지 다른 쓰기를 지연시키는 방법을 사용한다.
더티 쓰기를 막음으로써 몇 가지 동시성 문제를 회피할 수 있다.
스냅샷 격리, 반복읽기 (repeatable read)
read committed 는 실용적이지만, 여전히 문제는 발생할 수 있다 (위의 비반복 읽기 문제)
스냅샷 격리는 이런 문제의 흔한 해결책이고, 각 트랜잭션은 데이터베이스의 일관된 스냅샷으로부터 데이터를 읽는다.
스냅샷 격리는 더티 쓰기를 방지하기 위해 쓰기 잠금을 사용한다.
하지만 읽을때는 잠금이 필요 없다.
성능 관점에서 스냅샷격리의 핵심 원리는 읽는 쪽에서 쓰는 쪽을 차단하지 않고 쓰는 쪽에서 읽는 쪽을 차단하지 않는다 이다.
데이터베이스는 객체마다 커밋된 버전 여러 개를 유지할 수 있어야 한다.
진행중인 여러 트랜잭션에서 서로 다른 시점의 데이터베이스 상태를 봐야 할수 있기 때문이다.
여러 버전을 함께 유지하므로 이 기법은 다중 버전 동시성 제어 (multi-version concurrency control, MVCC) 라 한다
아래 그림에서 postgreSQL 에서 MVCC 기반 스냅샷 격리를 구현 했는지 볼수 있다.
트랜잭션이 시작하면 계속 증가하는 고유한 트랜잭션ID를 항당받는다.
트랜잭션이 데이터베이스에 데이터를 쓸 때마다 트랜잭션ID 가 함께 붙는다.
테이블의 각 로우는 해당 로우를 쓴/삭제한 트랜잭션ID 를 갖고 있다.

트랜잭션은 데이터베이스에서 객체를 읽을 때 트랜잭션ID 를 이용해 어떤 것을 볼수있고 없는지 결정한다.
- 각 트랜잭션을 시작할 때 그 시점에 진행중인 모든 트랜잭션의 목록을 만든다. 이 트랜잭션이 쓴 데이터는 모두 무시된다.
- 어보트된 트랜잭션이 쓴 데이터는 모두 무시한다
- 트랜잭션 ID 가 더 큰(현재 트랜잭션이 시작한 후에 시작한) 트랜잭션이 쓴 데이터는 커밋 여부에 관계없이 모두 무시된다.
- 이 외 모든 데이터는 볼 수 있다.
마지막 격리 수준인 직렬성으로 넘어가기 전에, 지금까지 위의 격리 수준들은 동시에 실행되는 쓰기 작업이 있을 때, 읽기 트랜잭션이 어떤 데이터를 볼 수 있는지에 대한 관점이었다. 두 트랜잭션이 동시에 쓰기를 실행할 때의 문제는 거의 무시했다.
동시에 실행되는 쓰기 트랜잭션이 있을때 발생할 수 있는 충돌이 몇가지 더 있다. 이 중 가장 흔한 문제는 갱신 손실 문제이다.
이에 대해 먼저 알아보자
갱신 손실 방지
갱신 손실 문제는 애플리케이션이 데이터베이스에서 값을 읽고 변경한 후 변경된 값을 다시 쓸때(read-modify-write 주기) 발생할 수 있다.
두 트랜잭션이 이 작업을 동시에 하면 변경 중 하나는 손실될 수 있다.
갱신 손실은 흔한 문제라서 다양한 해결책이 개발됐다.
원자적 쓰기 연산
보통 객체를 읽을 때 객체에 독점적인 잠금을 획득해서 구현한다.
갱신이 적용될 때까지 다른 트랜잭션에서 그 객체를 읽지 못하게 한다.
다른 방법은 모든 원자적 연산을 단일 스레드에서 실행되도록 강제하는 것이다
명시적인 잠금
애플리케이션에서 갱신할 객체를 명시적으로 잠그는 것이다.
한 트랜잭션이 read-modify-write 를 실행할때 객체를 잠그면 다른 트랜잭션은 이 주기가 완료될 때까지 강제로 기다린다.
갱신 손실 자동 감지
여러 작업의 병렬 실행을 허용하고 관리자가 갱신 손실을 발견하면 트랜잭션을 어보트 시키고 재시도 하도록 강제하는 방법이 있다.
이 기능은 애플리케이션 코드에서 특별한 데이터베이스 기능을 쓸 필요가 없게 도와준다.
compare-and-set
값을 마지막으로 읽은 후로 변경되지 않았을 때만 갱신을 허용함으로써 갱신 손실을 회피한다.
충돌 해소와 복제
복제본이 있는 데이터베이스에서 갱신 손실을 막는 것은 더 어렵다.
여러 노드에 복사본이 있고 데이터가 다른 노드들에서 변경될 수 있으므로 갱신 손실을 방지하려면 추가 단계가 필요하다.
여러개의 충돌된 버전을 생성하는 것을 허용하고 사후에 코드나 특별한 데이터 구조를 사용해 충돌을 해소하여 병합하는 방법
최종 쓰기 승리 충돌 해소 방법도 있다.
지금까지 이야기한 경쟁 조건들 중 어떤 것은 위의 격리 수준으로 방지 되지만 그렇지 않은 것도 있다.
이 외에도 여러 문제가 있다.
- 격리 수준은 이해하기 어렵고 데이터베이스마다 구현에 일관성이 없다
- 코드만 보고 특정한 격리 수준에서 실행하는게 안전한지 알기 어렵다
- 경쟁 조건을 감지하는 데 도움을 주는 좋은 도구가 없다.
이런 것들이 정말 문제라면 직렬성 격리를 사용하면 해결이 된다!
직렬성
여러 트랜잭션이 병렬로 실행되더라도 최종 결과는 동시성 없이 한 번에 하나씩 직렬로 실행될 때와 같도록 보장한다.
이는 데이터베이스에서 발생할 수 있는 모든 경쟁 조건을 막아준다.
최근 데이터베이스에서 직렬성을 제공하는 기법은 대부분 세 가지이다
- 트랜잭션을 순차적으로 실행하기
- 2단계 잠금
- 직렬성 스냅샷 격리 같은 낙관적 동시성 제어
실제적인 직렬 실행 (트랜잭션 순차적 실행)
동시성 문제를 피하는 가장 간단한 방법은 동시성을 완전히 제거하는 것이다.
한번에 트랜잭션 하나만 단일 스레드에서 실행하면 가능하다.
과거 높은 성능을 위해 다중 스레드 동시성이 필수적으로 여겨졌지만 최근에야 실현 가능하다고 결론내렸다.
- 램 가격이 저렴해져서 많은 사례에서 데이터셋 전체를 메모리에 유지할수 있을 정도가 됐다.
- OLTP 트랜잭션이 보통 짧고 실행하는 읽기와 쓰기의 개수가 적다.
단일 스레드로 실행되도록 설계된 시스템이 동시성을 지원하는 시스템보다 성능이 나을 때도 있다. (잠금 비용 오버헤드)
하지만 이들의 처리량은 CPU 코어 하나의 처리량으로 제한된다.
그리고 쓰기 처리량이 높은 상황에서는 단일 스레드 트랜잭션 처리자가 심각한 병목이 될 수 있다.
따라서 다음 상황에서는 직렬 실행이 실용적인 방법이 된다
- 모든 트랜잭션은 작고 빨라야 한다
- 활성화된 데이터셋이 메모리에 적재될 수 있어야 한다.
- 쓰기 처리량이 단일 CPU 코어에서 처리할 수 있을 정도로 충분히 낮아야 한다
2단계 잠금
2단계 잠금 (two-phase locking) 은 약 30년 동안 가장 널리 쓰인 알고리즘이다.
쓰기를 실행하는 트랜잭션이 없는 객체는 여러 트랜잭션에서 동시에 읽을 수 있고, 어떤 객체를 쓰려고 하면 독점적인 접근이 필요하다.
2PL 에서 쓰기 트랜잭션은 다른 쓰기 트랜잭션 뿐 아니라 읽기 트랜잭션도 진행하지 못하게 막는다.
2PL 은 직렬성을 제공하므로 갱신 손실을 포함한 모든 경쟁 조건으로부터 보호해준다.
잠금은 공유 모두나 독점 모드로 사용될 수 있다.
- 트랜잭션이 객체를 읽기 원한다면 공유 모드로 잠금을 획득한다. 동시에 여러 트랜잭션이 공유 모드 잠금 획득은 가능하지만, 객체에 독점 모드로 잠금 획득한 트랜잭션이 있으면 이 트랜잭션이 완료될 때까지 기다려야 한다
- 트랜잭션이 객체에 쓰기를 원한다면 독점 모드로 잠금을 획득해야 한다. 다른 어떤 트랜잭션도 동시에 잠금을 획득할 수 없다
- 트랜잭션이 객체를 읽다가 쓰기를 실행할 때는 공유 잠금을 독점 잠금으로 업그레이드 해야 한다
- 트랜잭션이 잠금을 획득한 후에는 트랜잭션이 종료(커밋/어보트)될 때까지 잠금을 갖고 있어야 한다.
잠금이 많이 사용되므로교착 상태가 발생할 수 있다.
데이터베이스는 트랜잭션 사이의 교착 상태를 자동으로 감지하고 트랜잭션 중 하나를 어보트 시켜 다른 트랜잭션이 진행할 수 있게 한다.
2단계 잠금의 가장 큰 약점은 성능이다.
완화된 격리 수준을 쓸 때보다 트랜잭션 처리량과 응답 시간이 크게 나빠진다.
원인은 잠금을 획득하고 해제하는 오버헤드, 더 중요한 원인으로는 동시성이 줄어드는 것이다.
만약 특정 한 객체가 아니라 여러 객체를 잠궈야 한다면 어떻게 할까?
개념적으로 서술 잠금이 필요한데, 서술 잠금이란 특정 객체에 속하지 않고 어떤 조건에 부합하는 모든 객체에 속한다.
서술 잠금이 접근을 제한하는 방법은 다음과 같다
- 트랜잭션 A가 질의 조건에 대한 공유 모드 서술 잠금을 획득한다. 다른 트랜잭션이 조건에 부합하는 객체에 대해 독점 잠금을 갖고 있다면 B가 해제할 때까지 기다린다
- 트랜잭션 A가 특정 객체를 삽입/갱신/삭제 하기를 원한다면 기존 값이나 새로운 값중 기존 서술 잠금에 부합하는게 있는지 확인한다. 부합하는 서술 잠금을 다른 트랜잭션이 잡고 있다면 A는 진행하기 전에 다른 트랜잭션이 커밋/어보트 될때까지 기다려야 한다
색인 범위 잠금
서술 잠금은 진행중인 트랜잭션들이 획득한 잠금이 많으면 조건에 부합하는 잠금을 확인하는데 시간이 오래 걸려 잘 동작하지 않는다.
따라서 2PL 을 지원하는 대부분 데이터베이스는 색인 범위 잠금(index-range locking, 다음 키 잠금)을 구현한다
이 방법은 서술 잠금을 간략하게 근사한 것이다.
예를 들어 정오와 오후 1시 사이 123번 방을 예약하는 것에 대한 서술 잠금은 모든 시간 범위에 123번 방을 예약하는 것에 대한 잠금으로 근사하거나,
정오와 오후 1시 사이 모든 방을 잠그는 것으로 근사할 수 있다.
직렬성 스냅샷 격리
직렬성 격리와 좋은 성능은 근본적으로 공존할수 없을까?
직렬성 스냅샷 격리라는 알고리즘이 유망하다.
비관적 동시성 제어, 낙관적 동시성 제어
2단계 잠금은 비관적 동시성 제어 매커니즘이다.
뭔가 잘못될 가능성이 있으면 뭔가를 하기 전에 상황이 다시 안전해질 때까지 기다리는게 낫다는 원칙을 기반으로 한다.
직렬성 스냅샷 격리는 낙관적 동시성 제어 기법이다.
낙관적이란 뭔가 위험한 상황이 발생할 가능성이 있을 때 트랜잭션을 막는 대신 괜찮아질 것이라는 희망을 갖고 진행한다는 뜻이다.
트랜잭션이 커밋되기를 원할 때 데이터베이스는 격리가 위반됐는지 확인하고, 위반됐다면 트랜잭션은 어보트되고 재시도해야 한다.
(???.. 위에서는 진행한다고 했는데 왜 어보트 시킨단 거지?.. 비관적은 일단 기다리고, 낙관적은 진행시키지만 뭔가 발생하면 그때 어보트 시킨다는 건가..)
낙관적 동시성 제어는 오랜 아이디어고 논의됐다.
경쟁이 심하면 (많은 트랜잭션이 같은 객체에 접근) 어보트시켜야 할 트랜잭션 비율이 높아져 성능이 떨어진다.
하지만 시스템 예비 용량이 충분하고 경쟁이 너무 심하지 않으면 낙관적 동시성 제어 기법은 비관적 동시성 제어보다 성능이 좋은 경향이 있다.
2단계 잠금과 비교할 때 큰 이점은 트랜잭션이 다른 트랜잭션들이 잡고 있는 잠금을 기다리느라 차단될 필요가 없다.
스냅샷 격리와 마찬가지로 쓰는 쪽은 읽는 쪽을 막지 않고 읽는 쪽도 쓰는 쪽을 막지 않는다.