본문 바로가기
카테고리 없음

[DDIA] 07장. 트랜잭션

by soro.k 2026. 1. 28.

0. 들어가기 전에

[개념 사전]

단어 설명
선형성(linearizability) 모든 연산이 하나의 순서대로 즉시 적용된 것처럼 보이도록 보장하는 특성
트랜잭션 시맨틱(transaction semantics) 트랜잭션이 어떻게 동작해야 하는지에 대한 규칙과 의미를 정의하는 개념 (트랜잭션이 실행될 때 보장해야 할 동작, 일관성 규칙, 격리 수준 등을 의미)
다중 버전 동시성 제어(multi-version concurrency control, MVCC) 데이터베이스가 객체의 여러 버전을 함께 유지하는 기법
갱신 손실(lost update) 두 트랜잭션이 변경 작업을 동시에 하면 두 번째 쓰기 작업이 첫 번째 변경을 포함하지 않으므로 변경 중 하나가 손실되는 현상
커서 안정성(cursor stability) 갱신이 적용될 때까지 다른 트랜잭션에서 그 객체를 읽지 못함
가환 원자적 연산(Commutative Atomic Operation) 연산의 실행 순서가 달라져도 최종 결과가 동일한 원자적 연산

 

 

1. 새롭게 알게 된 점(New)

1) 트랜잭션

1️⃣ 단일 객체 연산과 다중 객체 연산

  • 관계형 데이터베이스에서는 클라이언트와 데이터베이스 서버 사이의 TCP 연결을 기반으로 동일한 트랜잭션에 속하는 연산을 묶으나 비관계형 데이터베이스는 묶는 방법이 없는 경우가 많다.
  • 다중 put 연산과 같은 다중 객체 API가 있더라도 반드시 트랜잭션 시맨틱(Transaction Semantics)을 뜻하지 않는다. 어떤 키에 대한 연산은 성공하고 나머지 키에 대한 연산은 실패해서 데이터베이스가 부분적으로 갱신된 상태가 될 수 있다.

 

2️⃣ 오류와 어보트 처리

트랜잭션의 핵심 기능은 오류가 생기면 어보트되고 안전하게 재시도할 수 있다는 것이고,
ACID 데이터베이스는 이 철학을 바탕으로 한다.

  • 하지만 모든 시스템이 이 철학을 따르지는 않는다.
  • 레일스(Rails)의 액티브 레코드나 장고(Django)같은 인기 있는 객체 관계형 매핑 프레임워크들은 어보트된 트랜잭션을 재시도하지 않는다.
  • 어보트된 트랜잭션을 재시도하는 것은 간단하고 효과적인 오류 처리 메커니즘이지만 완벽하지는 않다.

 

2) 완화된 격리 수준

금융 데이터를 다룬다면 ACID 데이터베이스를 사용하라?
보통 ACID라고 생각하는 인기 있는 관계형 데이터베이스 시스템조차 완화된 격리성을 사용하는 경우가 많아 이런 버그가 발생하는 것을 반드시 막아주지는 못한다.

 

[1] 스냅숏 격리 구현

  • 커밋 후 읽기 격리만 제공하면 된다면 커밋된 버전과 덮어 쓰여졌지만 아직 커밋되지 않은 버전만 있으면 된다.
  • 보통, 스냅숏 격리를 지원하는 저장소 엔진은 커밋 후 읽기 격리를 위해서도 MVCC를 사용한다.
    • 커밋 후 읽기 : 질의마다 독립된 스냅숏 사용
    • 스냅숏 격리 : 트랜잭션에 대해 동일한 스냅숏 사용

 

🤔 다중 버전 데이터베이스에서 색인은 어떻게 동작할까?
DB PostgreSQL  카우치DB, 데이토믹, LMDB
동작 방식 동일한 객체의 다른 버전들이 같은 페이지에 저장될 수 있다면 색인 갱신 회피 쓰기 시 새로운 B 트리 루트 생성 및 특정 루트가 일관된 스냅숏 역할 (추가 전용 B 트리 사용 & 쓰기 시 복사)

 

🤔 반복 읽기가 무슨 뜻인지 실제로 아는 사람은 아무도 없다?

  • 스냅숏 격리 수준을 오라클에서는 직렬성, PostgreSQL과 MySQL에서는 반복 읽기(repeatable read)라고 한다.
  • 다만, SQL 표준에서는 스냅숏 격리의 개념이 없고 비슷해 보이는 반복 읽기가 정의돼 있다.
  • 여러 데이터베이스가 반복 읽기를 구현하지만 제공하는 보장에는 커다란 차이가 있으므로 격리 수준 정의에 결함이 존재한다.

 

[2] 갱신 손실 방지

1️⃣ 원자적 쓰기 연산

  • 여러 데이터베이스에서 원자적 연산을 제공한다.
    • 몽고 DB : JSON 문서의 일부를 지역적으로 변경하는 연산 제공
    • 레디스 : 우선순위 큐(priority queue) 같은 데이터 구조를 변경하는 연산 제공
  • 객체 관계형 매핑 프레임워크를 사용하면 뜻하지 않게 데이터베이스가 제공하는 원자적 연산을 사용하는 대신 불안전한 read-modify-write 주기를 실행하는 코드를 작성하기 쉽다.

 

2️⃣ 갱신 손실 자동 감지

원자적 연산과 잠금의 대안으로 read-modify-write 주기의 병렬 실행을 허용하고 트랜잭션 관리자가 갱신 손실을 발견하면 트랜잭션을 어보트시키고 재시도하도록 강제할 수 있다.

  • 이점
    • 데이터베이스가 스냅숏 격리와 확인을 결합해 효율적으로 수행할 수 있다. (PostgreSQL, Oracle, SQL Server)
    • 잠금이나 원자적 연산을 쓰는 것을 잊어버려 버그를 유발할 수는 있지만 자동으로 갱신 손실이 감지되어 오류가 덜 발생하게 해준다.
  • MySQL의 Repeatable Read는 갱신 손실을 감지하지 않으므로 스냅숏 격리를 제공하지 않는다는 주장이 있다.

 

3️⃣ Compare-and-set

이 연산의 목적은 마지막으로 읽은 후로 값이 변경되지 않았을 때만 갱신을 허용함으로써 갱신 손실을 회피하는 것이다.

UPDATE wiki_pages SET content = 'new content'
WHERE id = 1234 AND content = 'old content'
  • 데이터베이스가 오래된 스냅숏으로부터 읽는 것을 허용한다면 갱신 손실을 막지 못할 수 있다.
  • 이 연산에 의존하기 전에 먼저 안전한지 확인하자!

 

🤔 복제 환경에서는 어떻게 갱신 손실을 방지할까?

 

최종 쓰기 승리(last write wins, LWW)

  • 여러 개의 충돌된 버전(형제(sibling))을 생성하는 것을 허용하고, 사후에 애플리케이션 코드나 특별한 데이터 구조를 사용해 충돌을 해소하고 이 버전들을 병합한다.
  • 갱신 손실이 발생하기 쉽다.
  • 많은 복제 데이터베이스의 기본 설정이다.

 

[3] 쓰기 스큐(wirte skew)

두 트랜잭션이 두개의 다른 객체를 갱신하며 특정 제약 조건을 위반하는 동시성 이상 현상

패턴
1. SELECT 질의가 어떤 검색 조건에 부합하는 로우를 검색함으로써 어떤 요구사항을 만족하는지 확인한다.
2. 1의 결과에 따라 애플리케이션 코드는 어떻게 진행할지 결정한다.
3. 애플리케이션이 계속 처리하기로 결정했다면 데이터베이스가 쓰고 트랜잭션을 커밋한다. 이때 1을 재실행하면 다른 결과를 얻게 된다.

 

쓰기 스큐를 회피하는 방법

예시 1
호출 대기인 의사 수가 2명 이상이어야 할 때 각각 의사가 대기 상태인 의사 수가 2명인 것을 확인하고 자신의 호출 대기를 끄는 상황
-- 1단계의 row를 잠금으로써 트랜잭션을 안전하게 만든다.
SELECT count(*) 
FROM doctors
WHERE on_call = true
AND shift_id = 1234
FOR UPDATE;

 

예시 2
회의실이 같고 시간대가 겹치는 예약이 있는지 확인하고 없다면 회의를 예약하는 상황
SELECT count(*)
FROM bookings
WHERE room_id = 123
AND (end_time > '2025-03-23 22:00' 
AND start_time < '2025-03-23 23:00');
  • 잠글 수 있는 객체가 없다면 인위적으로 잠금 객체를 추가하자!
  • 회의실과 시간 범위의 모든 조합에 대해 행을 미리 만들어 두고 해당 행을 조회할 때 SELECT FOR UPDATE문을 활용할 수 있다.
  • 충돌 구체화(materializing conflict) : 아직 존재하지 않는 행도 데이터베이스에서 잠금 충돌을 일으키도록 변환하는 기법

 

3) 직렬성을 제공하기 위한 세 가지 기법

기존 격리 수준의 문제점
격리 수준은 이해하기 어렵고 데이터베이스마다 구현에 일관성이 없다. 애플리케이션 코드를 보고 특정 격리 수준에서 해당 코드를 실행하는 게 안전한지 알기 어렵다. 경쟁 조건을 감지하는 데 도움이 되는 좋은 도구가 없다.

 

[1] 실제적인 직렬 실행

1️⃣ 트랜잭션을 스토어드 프로시저 안에 캡슐화하기

단일 스레드에서 트랜잭션을 순차적으로 처리하는 시스템들은 상호작용하는 다중 구문 트랜잭션을 허용하지 않고, 트랜잭션 코드 전체를 스토어드 프로시저 형태로 데이터베이스에 미리 제출한다.

  • 트랜잭션에 필요한 데이터가 모두 메모리에 있고 스토어드 프로시저는 네트워크나 디스크 I/O 대기 없이 매우 빨리 실행된다고 가정한다.

 

2️⃣ 파티셔닝

  • 쓰기 처리량이 단일 CPU 코어에서 처리할 수 있을 정도로 충분히 낮지 않을 때, 여러 파티션에 걸친 코디네이션이 필요하지 않도록 트랜잭션을 파티셔닝해야 한다.
  • 여러 파티션에 걸친 트랜잭션도 쓸 수 있지만 성능 저하를 감수할 수 있을 경우에만 사용해야 한다.

 

[2] 2단계 잠금(2PS, two-phase locking)

쓰기 트랜잭션은 다른 쓰기 트랜잭션뿐만 아니라 읽기 트랜잭션도 진행하지 못하게 막고, 그 역도 성립한다.

 

1️⃣ 구현

종류 공유 모드(shared mode) 독점 모드(exclusive mode)
특징 - 읽기 트랜잭션에서 사용한다.
- 동시에 여러 트랜잭션이 공유 모드로 잠금을 획득하는 것이 허용된다.
- 쓰기 트랜잭션에서 사용한다.
- 객체를 읽다가 쓰기를 원할 때는 공유 잠금에서 해당 잠금으로 업그레이드해야 한다.

 

 

2️⃣ 서술 잠금(predicate lock)

테이블 내의 한 행에 속하지 않고 어떤 검색 조건에 부합하는 모든 객체에 접근을 제한한다.

  • 데이터베이스에 아직 존재하지 않지만 미래에 추가될 수 있는 객체에도 적용할 수 있다.
  • 획득한 잠금이 많으면 조건에 부합하는 잠금을 확인하는 데 시간이 오래 걸리기에 잘 사용하지 않는다.

 

3️⃣ 색인 범위 잠금(index-rage locking, = 다음 키 잠금(next-key locking))

더 많은 객체가 부합하도록 서술 조건을 간략화하여 안전하게 한다.

  • 예를 들어, 정오와 오후 1시 사이에 123번 방을 예약하는 것에 대한 서술 잠금은 모든 시간 범위에 123번 방을 예약하는 것에 대한 잠금으로 근사한다.
  • 직렬성을 유지하기 위해 필요한 것보다 큰 범위를 잠글 수 있지만, 오버헤드는 훨씬 낮아 좋은 타협안이다.

 

[3] 직렬성 스냅숏 격리(SSI, serializable snapshot isolation)

🤔 직렬성 격리와 좋은 성능은 공존할 수 없는 것일까?
그렇지 않다. SSI를 사용하면 완전한 직렬성은 제공하지만 스냅숏 격리에 비해 약간의 성능 손해만 있을 뿐이다.

  • 다른 동시성 제어 메커니즘에 비해 역사가 짧기 때문에 아직 현장에서 성능을 증명하는 중이다.

1️⃣ 데이터베이스가 질의 결과가 바뀐 것을 알아내는 방법

🤔 직렬성 격리를 제공하려면 데이터베이스는 트랜잭션이 뒤처진 전제를 기반으로 동작하는 상황을 감지하고 트랜잭션을 어보트시켜야 한다. 데이터베이스가 어떻게 질의 결과가 바뀌었는지 알 수 있을까?

 

➀ 오래된 MVCC 읽기 감지하기

  • 트랜잭션 43이 커밋하려고 할 때, 무시된 쓰기 중에 커밋된 게 있는지 확인한 후에 트랜잭션 42의 커밋 사항을 확인하고 43을 어보트시킨다.

 

➁ 과거의 읽기에 영향을 미치는 쓰기 감지하기

  • shift_id에 색인이 있으면 데이터베이스가 트랜잭션 42와 43이 데이터를 읽었다는 사실을 기록할 수 있다. 없다면 테이블 수준에서 추적 가능하다.
  • 트랜잭션이 데이터베이스에 쓸 때 영향받는 데이터를 최근에 읽은 트랜잭션이 있는지 색인에서 확인하지만, 읽는 쪽에서 커밋될 때까지 차단하지 않는다. 다만, 트랜잭션이 읽은 데이터가 더 이상 최신이 아니라고 트랜잭션에게 알려준다.

 

2️⃣ 성능

  • 데이터베이스가 각 트랜잭션의 동작을 상세하게 추적할수록 어보트돼어야 하는 트랜잭션을 정확히 판별할 수 있지만, 기록 오버헤드가 심해질 수 있다.
  • PostgreSQL에서는 때로는 데이터가 덮어쓰여졌음에도 실행 결과가 직렬적이라는 것을 증명하는 게 가능하다는 이론을 사용해서 불필요한 어보트 개수를 줄인다.
  • 2PS과 비교해, 스냅숏 격리하에서 쓰는 쪽은 읽는 쪽을 막지 않고 읽는 쪽은 쓰는 쪽을 막지 않아 질의 지연 시간 예측이 쉽고 변동이 적게 만든다.
  • 순차 실행과 비교해, 데이터가 여러 장비에 걸쳐져 파티셔닝돼 있더라도 트랜잭션은 직렬성 격리를 보장하면서 여러 파티션으로부터 읽고 쓸 수 있다.