데이터 엔지니어링

[데이터 중심 애플리케이션 설계] 7장 트랜잭션 - 트랜잭션과 격리 수준

* 이 글은 [데이터 중심 애플리케이션 설계]를 공부하며 기록을 남긴 것입니다.


트랜잭션

  • 다수의 읽기와 쓰기(다중 객체에 대한 다중 연산)하나의 논리적 단위로 묶는 방법 → 전체가 성공하거나 실패함
  • 실패나 오류를 처리하기 쉬워짐: 안전성 보장(ACID)
  • 애플리케이션에서 DB에 접근하는 프로그래밍을 단순화하기 위한 목적
  • 트랜잭선의 개념은 모호해졌고, 이점과 한계가 명확하다!

 

1. ACID

  1. 원자성 Atomicity
    • 여러 작업을 하나의 트랜잭션으로 묶어 처리하는 것
    • 오류가 생기면 전체를 abort하고 안전하게 재시도한다. 트랜잭션의 핵심 기능!
      • 여담으로.. Django는 이런 abort 트랜잭션을 재시도 하지 않아서, 오류가 생기면 요청은 사라지고 사용자는 오류만 받게된다. Django로 작업할 때 유독 에러 때문에 힘들었는데 그래서 그랬구나..
      • 하지만 네트워크, 과부하, 일시적인 오류 등의 상황에서는 이런 재시도 매커니즘도 좋지 않을 수 있다
  2. 일관성 Consistency
    • 데이터에는 항상 만족해야 하는 불변식이 있으며, 이를 위반하는 데이터는 저장될 수 없다는 것
    • 하지만 이는 데이터베이스에서 보장할 수 없는 애플리케이션 단의 문제이다. 따라서 실제로는 ACID에 속할 필요가 없다는 의견도 있다
  3. 격리성 Isolation (=직렬성)
    • 여러 트랜잭션이 동시에 실행되더라도 순차적으로 실행된 것과 같은 결과를 보장하는 것
    • 직렬성 격리는 성능 손해를 유발하기 때문에 실제로는 스냅숏 격리를 사용
  4. 지속성 Durability
    • 트랜잭션에서 기록한 모든 데이터는 손실되지 않음을 보장하는 것
    • 복제를 통해 보장하지만 완벽한, 유일한 방법은 없다

 

2. 다중 객체 트랜잭션

  • 단일 객체 연산: 대부분의 저장소 엔진은 한 노드에 존재하는 단일 객체 수준에서 원자성/격리성을 보장하는 것을 목표로 한다. 이는 동시에 같은 객체에 쓰는 문제 등의 해결에 유용하지만, 일반적인 의미의 트랜잭션은 아니다.
  • 다중 객체 연산: 한번에 여러 객체(로우, 문서, 레코드, 색인 등)를 변경하는 것
  • 하지만 구현하기 어렵고 가용성과 성능에 방해될 수 있어 많은 분산 데이터베이스에서는 다중 객체 트랜잭션을 지원 하지않는다
    • 개념적으로는 분산 DB에서 트랜잭션을 막는 것이 없다? → 9장: 분산 트랜잭션의 구현
  • 그럼 정말 필요한 것일까?? 단일 객체만으로도 충분한 경우도 있지만, 아닌경우 오류처리나 동시성에 문제가 생길 수 있다

 

 

완화된 격리 수준

  • 동시성 문제(경쟁 조건): 두 트랜잭션이 동시에 데이터를 변경하려고 하거나, 동시에 변경한 데이터를 읽으려고 할 때 나타난다
  • 테스트로 발견하기 어려움, 재현하기 어려움, 어느 부분에서 문제인지도 추론하기 어려움
  • 트랜잭션 격리를 통해 해결
  • 대부분 완전한 직렬성 격리보다는 완화된 격리 수준(=비직렬성)을 사용한다
  • 도구에 의존하기 보다 발생할 수 있는 동시성 문제를 잘 이해하고 방지하는 방법을 익힐 필요가 있다!

 

1. 커밋 후 읽기

  • 더티 읽기/쓰기 방지 → 커밋된 데이터만 읽을 수 있고, 커밋된 데이터만 덮어쓸 수 있다.
  • 구현
    • 더티 쓰기 방지: 로우(객체) 수준 쓰기 잠금
    • 더티 읽기 방지(main): 과거의 데이터와 쓰기 잠금을 가지고 있는 트랜잭션에서 새로 쓴 값을 모두 기억하고, 그 사이에 데이터를 읽으려는 작업은 과거의 데이터를 읽도록 한다
  • 비반복 읽기(읽기 스큐) 문제 발생 가능 → 2. 스냅숏 격리
  • 순차적인 카운터 증가 상황과 같은 갱신 손실 문제 발생 → 3. 갱신 손실 방지

 

2. 스냅숏 격리

트랜잭션 처리 중 중간에 읽기를 수행하면, 일부는 처리되고 일부는 처리되지 않은 것 처럼 보일 수 있다. 이런 현상을 읽기 스큐(read skew)라고 한다. 여기서 skew는 '핫스팟이 생긴 불균형적인 작업부하'가 아닌 '시간적인 이상 현상'을 의미한다. 이 문제의 일반적인 해결책으로 스냅숏 격리를 사용한다.

 

  • 트랜잭션은 작업을 시작할 때 데이터베이스의 특정 스냅숏으로부터 데이터를 읽는다. 따라서 특정 시점에 고정된, 일관된 데이터만을 읽어올 수 있게 된다.
  • 핵심 원리: 읽는 작업과 쓰는 작업은 서로를 결고 차단하지 않는다 → 쓰기 작업은 쓰기 작업만 차단하면 된다
  • 구현: 쓰기 잠금 + 다중 버전 객체를 사용한 다중 버전 동시성 제어(MVCC)
    • 여러 트랜잭션에서 다른 시점의 데이터베이스를 읽어야 할 수 있으므로, 객체마다 커밋된 버전 여러개를 모두 저장해둔다.  생성/삭제는 여러 버전일 때 충돌 할 수 있는데, created_by/deleted_by column을 사용해 해결함
    • 시점에 따라 데이터베이스에서 읽을 수 있는 데이터가 달라지는데 스냅숏 시점 이전에 커밋된 데이터/삭제 커밋이 완료되지 않은 데이터가 질의 대상이 된다.
  • 다중 버전 DB에서의 색인은?

 

3. 갱신 손실 방지

갱신 손실: 카운터 증가(read-modify-write)와 같은 여러 쓰기가 동시에 수행되는 경우, 시점에 따라 일부 변경사항이 손실될 수 있다. 흔한 문제라 다양한 해결책이 있다.

 

1. 원자적 쓰기 연산

데이터베이스에 내장된 원자적 갱신 연산을 사용. RDB에서는 다음과 같이 사용한다.

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

NoSQL에서는 문서를 지역적으로 변경하는 연산, Redis에서는 우선순위 큐같은 데이터를 변경하는 연산을 제공한다.

일반적으로 내부는 잠금을 사용하여 구현한다.

 

2. 명시적인 잠금

애플리케이션 코드에서 명시적으로 객체를 잠근다. 로직을 신중하게 짜야한다.

BEGIN TRANSACTION;

SELECT * FROM figures
    WHERE ...
    FOR UPDATE; /*WHERE 질의에 해당하는 모든 row에 잠금을 건다*/
    
UPDATE figures SET ...; /*새로운 상태로 갱신한다*/

COMMIT;

 

3. 갱신 손실 자동 감지

잠금 없이, 트랜잭션을 병렬로 실행하다가 손실이 감지되면 트랜잭션 관리자가 read-modify-write 주기를 재시도하는 방법이다.

스냅숏 격리와 함께 효율적으로 사용할 수 있고, 애플리케이션 코드에서 데이터베이스 기능을 사용할 필요가 없어진다.

 

4. Compare-and-set

Compare-and-set 연산은 마지막으로 읽은 후로 변경되지 않았을 때만 갱신을 허용한다. 하지만 스냅숏에 따라 마지막 데이터가 일정하지 않을 수 있으므로 안전하지 않을 수 있다.

UPDATE wiki SET content = 'new content'
	WHERE id = 1234 AND content = 'old content' /*내용이 이미 갱신되어 old content가 아니라면, new content 갱신은 적용되지 않는다.*/

 

5. 충돌 해소와 복제

복제가 적용되어 여러 노드에 데이터의 복사본이 있으면, 다른 노드에서도 변경이 일어날 수 있다.

이런 경우 일반적으로는, 여러 sibling을 생성하도록 하고 사후에 적절히 병합한다.

 

→ 더 자세히는?

 

4. 쓰기 스큐와 팬텀

두 트랜잭션이 두 개의 다른 객체를 동시에 갱신할 때, 공통된 경쟁 조건을 위배하게 되는 상황을 쓰기 스큐(write skew)라고 한다. 책에서는 두 의사의 대기 명단 갱신을 예로 들었다. 같은 객체가 아니므로 더티 쓰기나 갱신 손실에 해당되지 않는다.

 

  • 회의실 예약 시스템, 다중플레이어 게임, 사용자명 획득, 이중사용 방지
/* 호출대기 예시. 다른 row들을 잠금하여 해결 */
BEGIN TRANSACTION;

SELECT * FROM doctors
	WHERE on_call = true
    AND shift_id = 1234 FOR UPDATE;
    
UPDATE doctors
	SET on_call = false
    WHERE name = 'Alice'
    AND shift_id = 1234;

COMMIT;

/* 회의실 예시. row가 없기 때문에 잠금을 못함 */
BEGIN TRANSACTION;

SELECT COUNT(*) FROM bookings
	WHERE room_id = 123 AND
    	end_time > A AND start_time < B
        
INSERT INTO bookings
	(room_id, start_time, end_time, user_id)
    VALUES (123, A, B, 666);
    
COMMIT;

 

팬텀

  • 한 트랜잭션의 쓰기가 다른 트랜잭션의 질의 결과를 바꾸는 효과(위와 같이 없던 row를 추가하는 등)
  • 잠글 수 있는 객체가 없다면 데이터베이스에 잠금 객체를 추가하자!
  • 하지만 동시성 제어 매커니즘이 모델에 포함되는 것은 좋지 않고
  • 대부분은 직렬성을 사용하는 것이 훨씬 좋다!