DB 트랜잭션과 격리수준, 격리수준을 구현하기 위한 방법들

데이터시스템에는 여러가지 문제가 생길 수 있다. 데이터베이스로의 쓰기 연산 실패, 데이터 조작 중 어플리케이션 다운, 애플리케이션과 데이터베이스 간 네트워크 통신 장애, 데이터베이스로의 동시 쓰기 등 여러가지 결함이 발생할 수 있다. 이러한 결함이 발생할 때면 데이터베이스 내 데이터들의 일관성은 무자비하게 박살난다.

신뢰성 있는 시스템을 구축하기 위해서는 이러한 결함을 처리해 시스템의 장애로 이어지는 것을 막아야 한다. 트랜잭션은 이러한 문제들을 단순화하는 메커니즘이다. 몇 가지 작업(읽기, 쓰기 모두)을 하나의 논리적 단위로 묶어 모두 성공시키거나 모두 실패시키는 방법으로 부분 실패를 걱정할 필요가 없다.

다시말해, 트랜잭션은 안정성을 보장하는 역할을 한다. 복잡한 문제 풀이과정을 단순화한 덕분에 어플리케이션에서는 어느 정도 잠재적인 오류와 동시성 문제를 무시할 수 있다.

실제로 어플리케이션에서 트랜잭션과 격리수준을 통해 데이터베이스에 쉽게 접근가능하지만 데이터베이스 내부적으로는 여러가지 문제점들이 발생한다. 때문에 데이터베이스는 트랜잭션 보장에 필요한 여러가지 알고리즘들을 사용한다.(어떤 알고리즘을 어떻게 사용하느냐에 따라 많은 트랜잭션이 수행되는 시스템에서의 성능과 가용성에 큰 영향을 미친다.)

이러한 트래잭션 보장을 위한 여러가지 방법들은 분산 시스템에서의 트랜잭션(분산 트랜잭션)에서도 비슷한 매커니즘으로 동작하지 않을까? 지금은 추측이지만 우선은 그 구현방법들에 대해 자세하게 파보도록 하자.

트랜잭션의 특징

트랜잭션이 제공하는 안정성 보장은 ACID로 잘 알려져 있다.

원자성(Atomicity)은 일련의 과정이 모두 성공하거나 모두 실패함을 보장한다. 이를 통해 데이터의 일관성을 맞출 수 있다. 원자성이 없다면 실행 도중 생긴 문제를 어플리케이션 수준에서 롤백시키는 로직을 구현해야한다. 상상만해도 끔찍하다.

일관성(Consistency)는 항상 진실이어야 하는 어떠한 선언(불변식)이 있으며 이 선언이 깨지지 않음을 보장한다. 예를 들어, DDL 등에 정의한 데이터 제약조건이 올바르게 동작한다는 것을 보장한다. 하지만 대부분의 트랜잭션에서 다중 객체의 수정에 있어 일관성을 보장하기 위해서 어플리케이션 수준에서의 노력이 필요하다. 때문에 일관성은 ACID라는 약자를 맞추기 위해 끼어들었다는 소문이 있기도 하다.

격리성(Isolcation)은 서로 다른 트랜잭션간 배타적으로 동작함을 보장한다. 다시말해, 같은 리소스에 접근하여 생기는 동시성 문제가 발생하지 않는다. 때문에 격리성은 직렬성이라는 용어로 공식화되기도 한다. 하지만 격리성(직렬성)은 성능에 큰 영향을 미쳐 완화된 격리수준을 사용한다. 완화된 격리수준에서는 몇 가지 동시성 문제가 발생하는데 이는 이후에 이어 설명한다.

지속성(Durability)는 한 번 저장된 데이터는 손실되지 않음을 보장한다. 트랜잭션이 성공적으로 커밋되었다면 하드웨어 결함이 발생하거나 데이터베이스가 죽어도 데이터가 손실되지 않는다고 보장한다. 완벽한 지속성을 보장하려면 트랜잭션이 커밋되었다고 어플리케이션으로 보고하기 전 디스크로 쓰기를 하거나 DB 레플리케이션을 사용 중이라면 복제가 완료될 때까지 기다려야 한다.

완화된 격리 수준

서로 다른 트랜잭션이 동일한 데이터에 접근하지 않는다면 안전하게 병렬로 실행될 수 있다. 하지만 대부분의 경우 쓰기 작업 중 읽기 작업이 실행되거나 서로 다른 트랜잭션 같은 데이터로 쓰기 작업을 동시에 실행하는 문제가 발생한다. 따라서, 동시성 문제는 필연적이라 데이터베이스는 격리성을 제공하여 어플리케이션 개발자들에게 동시성 문제를 감추었다.

하지만 직렬성 격리(트랜잭션을 직렬로 실행하는 것과 같은 결과를 보장)는 성능 비용이 존재한다. 때문에 모든 이슈를 보호해주지는 못하지만 완화된 격리 수준을 사용한다. 이러한 완화된 격리 수준은 여러가지 동시성 문제를 일으킬 수 있다.

커밋 후 읽기

커밋 후 읽기(Committed Read)는 트랜잭션 내 데이터를 읽을 때 커밋된 데이터만을 읽고 쓸 수 있다. 이 격리 수준은 두 가지를 보장해준다.

  1. Dirty Read
  2. Dirty Write

A트랜잭션이 커밋되지 않은 데이터를 읽어 해당 데이터를 사용하고, 이후 해당 데이터가 롤백이 된다면 잘못된 데이터를 이용한 처리과정이 생기는 것이다. 이러한 문제를 Dirty Read라고 부른다. 커밋 후 읽기는 커밋된 데이터만을 읽어 이 문제를 해결해 준다.

A트랜잭션과 B트랜잭션이 같은 데이터를 동시에 수정한다고 가정하자. A트랜잭션이 데이터를 수정 후 커밋하기 전 B트랜잭션이 해당 데이터를 다시금 수정한다면 데이터가 불일치가 일어난다. A트랜잭션이 이후에 커밋을 완료하였어도 결과적으로 원하는 데이터로 수정되지 않았을 것이다.

예를 들어보자. Rokwon이 차를 사고자 한다. 차를 살때 데이터베이스에 두 번의 쓰기 필요하다.

  1. 차량목록에서 해당 차량의 구매자를 바꾼다.
  2. 해당 차량의 판매송장이 구매자로 전송되어야 한다. 따라서, 송장목록에서 해당 차량의 판매송장의 주인을 구매자로 바꾼다.

이때, 동시에 KIM도 같은 차량을 구매버리면 다음과 같이 서로 다른 쓰기가 겹쳐 일관성이 깨지는 상황이 발생할 수 있다.

더티 쓰기
더티 쓰기

더티 쓰기와 같은 문제는 많은 데이터베이스에서 로우 수준 잠금을 사용해 더티 쓰기를 방지한다. 쓰기를 하기 위해서 잠금을 획득해야하는 것이다. 그리고 커밋이 끝날때까지 해당 락을 보유해야 하며, 커밋 후 반환한다. 이런 잠금은 해당 격리 수준 사용시 데이터베이스에 의해 자동으로 실행된다. (Oracle, PostgreSQL 과 같은 많은 데이터베이스에서 커밋 후 읽기 격리수준을 기본 격리수준으로 사용하고 있다.)

스냅숏 격리

커밋 후 읽기 격리수준에서 Dirty Read와 Dirty Write 같은 동시성 문제를 해결해 주었지만 여전히 몇 가지 문제가 남아있다. 대표적으로 Non-repeatable Read라고 부르는 문제가 존재한다.

Non-repeatable Read한 트랜잭션 내 값을 읽는 시점에 따라 같은 데이터의 값이 달라지는 문제이다. 트랜잭션 초기 A라는 데이터를 읽은 후 마지막에 다시 읽는 그 사이에 다른 트랜잭션이 A의 값을 바꾸게 될 경우 발생할 수 있다.

Read Skew 문제도 존재한다. 이 문제는 Non-repeatable Read에서 파생된 문제인데 한 트랜잭션 사이에 다른 트랜잭션의 쓰기로 인해 데이터 정합성이 맞지 않는 문제이다. Rokwon이 2개의 계좌에 따로따로 5원 씩 총 10원 저장해두었다. Rokwon이 모든 계좌에 저장된 돈을 가져와 보려고 할때, 동시에 계좌2 -> 계좌1로 1원의 송금이 발생한다고 가졍해보자. 타이밍이 좋지 않다면 아래 그림과 같이 Rokwon에게 최종적으로 보여지는 결과가 잘못될 수도 있다.

Read Skew
Read Skew

스냅숏 격리는 이러한 문제를 해결하기 위한 격리수준이다. 각 트랜잭션이 데이터베이스의 일관된 스냅숏으로부터 데이터를 읽는 방법이다.

스냅숏 격리는 객체의 여러 버전을 유지하는 다중 버전 동시성 제어(MVCC, Multi-Version Concurrency Control)를 이용한다. 트랜잭션 시작시 고유의 id를 받고 해당 트랜잭션은 본인의 id보다 낮은 버전의 객체와 이미 커밋된 객체만을 읽는다.

스냅숏 격리의 핵심은 읽는 쪽에서는 쓰는 쪽을 차단하지 않고 쓰는 쪽에서도 읽는 쪽을 차단하지 않는다는 것에 있다. 이렇게 잠금 경쟁없이 쓰기 작업이 처리되며 일관성 있는 읽기가 가능하다.

갱신 손실 문제

앞서 커밋 후 읽기와 스냅숏 격리 수준에서는 2개의 트랜잭션 중 하나는 읽기를 하나는 쓰기 작업으로 데이터에 동시 접근했을때의 문제에 대해 서술하였다.(Dirty Write 제외)

그렇다면, 두 트랜잭션이 같은 데이터에 동시에 쓰기 작업을 실행한다면 어떻게 될까? 이럴 경우 갱신 손실(lost update) 문제가 발생할 수 있다. 갱신 손실 문제는 값을 읽고 변경 후 다시 값을 쓸 때(read-modify-write 주기라고 부름) 생기는 문제로 이후 트랜잭션이 이전 트랜잭션의 쓰기를 무시하는 문제이다.

아래 그림과 같이 두 트랜잭션이 같은 값을 읽은 후 쓰기를 진행하는 과정에서 나타날 수 있다. 마지막에 커밋하는 트랜잭션이 이전 트랜잭션의 결과를 무시해버리는 문제를 볼 수 있다.

Lost Update
갱신 손실

위 문제를 해결하기 위해서 몇 가지 해결책들이 존재한다.

첫번째로 데이터베이스에서 지원해주는 원자적 쓰기 연산을 사용하는 방법이다. 읽고 쓰는 연산을 한 번의 질의로 끝낼 수 있다. 대부분의 데이터베이스에서 이 명령은 동시성 안전하다.

UPDATE counters SET value = value + 1 WHERE key = 'v';

이 방법은 데이터베이스 내부적으로 보통 exclusive lock을 사용하여 구현된다. 모든 원자적 쓰기 연산을 단일 스레드에서 실행되도록 강제하는 방법이다.

두번째로 명시적인 잠금을 이용할 수도 있다. 읽고 쓰기 전까지 잠금을 직접적으로 거는 방식이다. 대표적으로 SELECT FOR UPDATE 구문이 있다.

세번째는 갱신 손실을 자동을 감지하여 갱신 손실이 발생하면 문제가 되는 트랜잭션을 어보트 시키는 방식이다. 아쉽게도 이 방식은 MySQL은 지원해주지 않는다.

네번째는 Compare-and-set 방식이다. 과거에 본인이 읽은 데이터와 같을 경우 업데이트 하는 방법이다. DB가 WHERE 절 연산에서 오래전에 쓰여진 스냅숏으로부터 데이터를 읽게 된다면 이 방법은 갱신 손실을 막지 못할 수도 있다. 때문에 각 데이터베이스에 안전한지 확인하는 것이 필수다.

UPDATE name SET name = 'new name'
	WHERE id = 1 AND name = 'old name';

💡 MySQL의 REPEATABLE READ 격리 수준은 Lost Update를 보장하지 못한다.
때문에 명시적인 Lock이나 다른 방법들을 사용해 문제를 해결해야한다.

쓰기 스큐 문제

갱신 손실(Lost Update) 문제 외에도 쓰기 스큐(Write Skew) 문제가 발생할 수 있다. 쓰기 스큐는 같은 집합의 다른 두 객체를 동시 수정하여 집합에 대한 요구사항이 깨지는 문제이다.

예시를 들어보자. 두 명 군인 Rokwon과 KIM은 함께 근무를 서고 있다. 누군가 한 명은 꼭 근무를 서야하기 때문에 동시에 휴가를 사용할 수는 없다. 이러한 상황에서 두 군인이 동시에 휴가를 신청한다고 가정해보자. 다음과 같은 상황이 발생할 수 있다. 서로 다른 객체를 수정함으로 인해 더티 쓰기나 갱신손실이 일어나지는 않지만 전체 집합에서 원하는 결과가 맞춰지지 않는 문제이다.

Write Skew
Write Skew

비슷한 상황으로 팬텀리드 문제도 발생할 수 있다. A트랜잭션의 실행 도중 B트랜잭션이 A트랜잭션이 보고 있는 집합에 새로운 값을 추가하여 A트랜잭션이 이후의 집합과 관련된 질의에서 처음과 다른 정보를 받을 수 있다.

중간 정리

완화된 격리수준에서 일어날 수 있는 트랜잭션 이상현상에 대해서 정리해보자. 이상현상은 크게 두 가지 상황으로 나누어 볼 수 있다.

한 트랜잭션에서는 읽기를 다른 트랜잭션에서는 쓰기 수행 시

  • Dirty Read
  • Non-Repeatable Read
  • Read Skew
  • Phantom Read - 완화된 격리수준에서 처리하지 못함

두 트랜잭션이 동시에 쓰기 작업 수행 시

  • Dirty Write
  • Lost Update - 완화된 격리수준만으로 처리하지 못하는 데이터베이스가 있음. 이 문제를 해결하기 위한 몇가지 방법이 존재한다.
    • 데이터베이스에서 제공해주는 원자적 쓰기 연산 사용
    • 명시적인 잠금
    • 몇 데이터베이스에서 제공하는 갱신 손실 자동 감지 기능
    • Compare-and-set 연산
  • Write Skew - 완화된 격리수준에서 처리하지 못함


직렬성 격리

직렬성 격리는 가장 강력한 격리 수준이다. 트랜잭션이 병렬로 실행 되어도 최종 결과는 직렬로 실행될 때와 같도록 보장한다. 다시말해, 모든 경쟁 조건을 막아줄 수 있다. 직렬성을 구현하는 방법에는 크게 3가지 기법이 있다.

  • 실제 트랜잭션을 순차적으로 실행하기
  • 2단계 잠금(2PL)
  • 직렬성 스냅숏 격리(SSI)

실제로 직렬적으로 실행시키기

트랜잭션을 실제로 직렬로 실행하는 방법도 있지만 이 방법은 CPU 코어 하나를 사용하며 트랜잭션 내 질의당 네트워크 통신시간을 가진다.(상호작용식 트랜잭션) 이 시간은 데이터베이스의 유휴 시간에 해당되므로 좋은 성능을 발휘하지 못한다.

어플리케이션이 트랜잭션 코드 전체를 스토어드 프로시저 형태로 데이터베이스에 제출하게 된다면 이러한 유휴시간을 없애고 트랜잭션을 직렬적으로 실행시킬 수 있다. 하지만 스토어드 프로시저는 몇가지 단점을 지닌다.

  • 언어가 조잡하고 낡아보이고 라이브러리 생태계가 빈약한 문제가 있다.
  • 코드를 관리하기 어렵다. 디버깅하기도 어렵고 버전관리, 배포가 불편하다.
  • 잘못 작성된 코드가 큰영향을 미친다(CPU나 메모리 사용이 많은 것들)

2단계 잠금(2PL)

앞서 더티쓰기를 막기위해 로우수준의 잠금을 자주 사용한다고 살펴봤다. 2PL은 더 강력한 잠금을 지원한다. 쓰기 실행이 없는 데이터는 여러 트랜잭션에서 동시에 읽을 수 있지만 누군가 데이터를 쓰려고 하면 독립적인 잠금을 필수로 가지고 있어야 한다.

2PL은 공유 잠금(읽기 잠금, 어떤 객체에 이 락이 걸려있디면 쓰기가 불가능)과 독점 잠금(걸려있는 객체에 읽기, 쓰기 모두 불가능)을 동시에 사용한다.

  1. 읽기를 원한다면 먼저 공유 모드로 잠금을 획득한다. 여러 트랜잭션이 한 객체에 공유 잠금을 획득할 수 있지만 해당 객체에 독점 잠금을 획득한 트랜잭션이 있다면 다른 트랜잭션은 이를 기다려야 한다.
  2. 트랜잭션이 쓰기를 원한다면 독점 잠금을 획득해야한다. 독점 잠금은 한 트랜잭션만 획득할 수 있다.
    • 공유 잠금을 가지고 있다가 쓰기가 필요할때 읽기 잠금에서 독점 잠금으로 업그레이드 가능하다.
  3. 트랜잭션이 종료될때까지 획득한 잠금을 갖고 있어야 한다.(2단계 잠금의 의미는 실행되는 동안 락을 획득하고 트랜잭션의 끝에 모든 잠금을 해제한다고 해서 2단계 잠금이라고 한다.)

위 상황에서 서로의 잠금 해제를 기다리는 데드락이 발생할 수 있는데 데이터베이스는 이를 자동으로 감지하여 특정 트랜잭션을 어보트 시켜야한다.

2단계 잠금을 통한 직렬성 격리는 갱신 손실(Lost Update)의 문제를 방어해주지만 아직 쓰기 스큐와 팬텀 리드의 문제를 해결해주지 못한다.(아직까지 접근하는 객체에 대한 잠금만 걸기 때문에) 두 문제를 해결위한 몇 가지 방법이 있다. 재미있게도 모든 해결 방법들은 범위 잠금에 해당된다.

첫 번째 방법은 서술 잠금이다. 서술 잠금은 검색하는 범위에 해당하는 모든 데이터객체에 잠금을 거는 방식이다. 다음과 같은 쿼리를 통해 해당 범위의 로우에 공유 잠금을 설정한다.

SELECT * FROM tickets
	WHERE show_id = 2023 AND
	 created_at > '2023-11-13 18:00' AND created_at < '2023-11-13 19:00' ;

이 잠금은 흥미로운 점은 아직 데이터가 존재하지 않지만 미래에 추가될 수 있는 객체에도 적용할 수 있다는 것이다. 티켓의 갯수가 최대 50개로 정해진 상황에서 티켓 테이블의 범위에 50개가 초과되는 로우가 들어가면 안된다. 이 상황에서 팬텀리드가 발생하면 치명적이다. 위와 같은 질의로 새롭게 추가될 가능성이 있는 미래의 데이터에 잠금을 걸어 문제를 해결할 수 있다.

두 번째 방법은 색인 범위 잠금이 존재한다. 서술 잠금은 획득한 잠금이 많아 잠금을 확인하는데 많은 시간이 걸린다. 때문에 2PL을 지원하는 대부분의 데이터베이스들이 색인 범위 잠금을 사용한다. 색인 범위 잠금은 말 그대로 검색 조건에 사용된 컬럼에 해당되는 인덱스에 잠금을 거는 방법이다. 서술 잠금만큼 정밀하게 잠금을 걸지 못하고 보다 광범위하게 잠금이 걸리지만 잠금을 확인하는 절차가 축소되어 오버헤드가 낮다.

세번째 방법은 해당 데이터 집합 자체에 잠금을 거는 방법이다. 테이블 수준의 잠금이라고 할 수 있다. 티켓의 예시에서 tickets 테이블(티켓을 구매 정보가 저장된 테이블) 자체를 잠금을 건다면 여러 공연(show)에서의 티켓구매가 직렬적으로 실행(색인 범위 잠금에서는 한 공연에 대해서만 직렬적으로 실행됨)되어 성능이 좋지는 않지만 가장 안전한 방식이다.

💡 MySQL(Inno DB)의 Serializable 격리 수준
해당 격리수준은 스냅숏 격리에서 설명한 MVCC를 기반으로 2PL(2단계 잠금)을 이용하여 Serializable 격리수준을 구현한다. 하지만 잠금(Lock)의 특성상 데드락이 발생할 수 있어 데드락 감지 및 해소 기능을 갖추어 데드락 발생시 트랜잭션 중 하나를 롤백하여 데드락을 해결하려고 시도한다.

직렬성 스냅숏 격리(SSI)

직렬성 스냅숏 격리(SSI)는 다른 직렬성 격리와는 다르게 낙관적 동시성 제어 기법이다. 충돌이 나지 않을 것이라 생각하고 모든 트랜잭션을 병렬로 처리하고 만약 이상현상이 발생한다면 발생한 트랜잭션을 어보트 시킨다. 데이터베이스에서는 락을 사용하지도 않고 무결성을 지키며, 문제가 발생한다면 어보트해 어플리케이션에게 책임을 위임하는 방식이다.

트랜잭션이 병렬로 실행되어 성능을 지키면서 커밋 전 이상현상을 감지하면 어보트를 시켜 무결성을 지킬 수 있다는 것이 SSI의 장점이다. 이러한 SSI의 핵심은 이상현상의 감지 방법이다.

SSI는 기본적으로 스냅숏 격리를 사용한다. 즉, MVCC를 기반으로 동작한다. SSI는 로우, 혹은 인덱스에 접근 시마다 접근 기록을 남기며 직렬성의 원칙을 지키위해서는 다음과 같은 두 상황 확인 시 어보트 시킨다.

  • MVCC는 커밋된 데이터만 읽는다. 따라서, 트랜잭션이 읽기전에 커밋되지 않은 쓰기가 발생했을 수도 있다.
    • 트랜잭션 커밋 전에 해당 쓰기가 커밋되었는지 확인해야한다. 만약 확인되었다면 트랜잭션을 어보트 시켜 팬텀리드와 쓰기 스큐를 방지한다.
  • 혹은 트랜잭션이 읽은 후에 다른 트랜잭션에 쓰기 작업이 발생했을 수도 있다.
    • 마찬가지로 본인이 읽은 후 해당 데이터에 쓰기가 일어나고 그 트랜잭션이 커밋되었다면 트랜잭션을 어보트 시킨다.

SSI의 잠금을 사용하지 않아 병렬적으로 실행된다는 장점이 있지만, 접근 기록을 추적하는 시간과 어보트 되는 비율이 성능을 큰 영향을 미친다.

💡 PostgreSQL의 Serializable 격리 수준
해당 격리수준은 SSI를 통해 구현되어 있다. 즉, 가장 먼저 된 트랜잭션 커밋을 먼저 처리하고 나머지 트랜잭션은 롤백되는 방식으로 동작한다.

마무리

어떻게 보면 관계형 데이터베이스에서 트랜잭션 격리수준의 구현을 깊게 알아보고 격리수준에서의 한계를 알 수 있게 되었다. 현대의 데이터베이스들은 그 속도가 충분히 빠르기 때문에 동시성 문제를 발견하기 힘들고, 발견하더라도 쉽게 재현하기 어려운 문제다. 하지만 각 데이터베이스의 격리수준의 특성과 발생가능한 문제들을 미리 알고 있다면 관련된 상황이 발생했을때 발빠르게 확인하고 대처할 수 있지 않을까?

참고

카테고리:

업데이트:

댓글남기기