포스트

데이터 중심 애플리케이션 설계07


트랜잭션

트랜잭션이란 무엇인가

트랜잭션은 여러 데이터베이스 작업을 하나의 논리적 단위로 묶어서 처리하는 메커니즘이다. 시스템에 오류가 발생하더라도 데이터베이스가 일관된 상태를 유지할 수 있도록 보장한다.

ACID 속성

트랜잭션의 안전 보장은 ACID라는 네 가지 속성으로 설명된다:

원자성 (Atomicity)

트랜잭션 내의 모든 작업이 전부 성공하거나 전부 실패해야 한다. 중간 상태는 허용되지 않는다.

예시: 은행 계좌 이체에서 A 계좌에서 돈을 빼는 것과 B 계좌에 돈을 넣는 것이 모두 성공하거나 모두 실패해야 한다. 한쪽만 성공하면 안 된다.

일관성 (Consistency)

데이터베이스가 트랜잭션 전후에 모두 유효한 상태를 유지해야 한다. 이는 애플리케이션이 정의한 불변 조건들이 지켜져야 함을 의미한다.

예시: 복식부기에서 차변과 대변의 합이 항상 0이 되어야 한다는 규칙이 트랜잭션 전후에 모두 유지되어야 한다.

고립성 (Isolation)

동시에 실행되는 트랜잭션들이 서로 간섭하지 않아야 한다. 각 트랜잭션은 마치 혼자 실행되는 것처럼 동작해야 한다.

예시: 두 사용자가 동시에 같은 상품의 재고를 조회하고 주문할 때, 서로의 작업이 간섭받지 않아야 한다.

지속성 (Durability)

트랜잭션이 성공적으로 커밋되면, 시스템 장애가 발생하더라도 해당 데이터가 영구적으로 저장되어야 한다.

CAP 정리와 ACID
CAP 정리는 분산 시스템에서 네트워크 분할(Partition)이 발생했을 때, 일관성(Consistency)과 가용성(Availability) 중 하나만 선택할 수 있다는 이론이다. 즉, 네트워크 분할 상황에서는 CP 또는 AP만 가능하다:
일관성 (Consistency): 모든 노드가 동시에 같은 데이터를 보는 것 (선형화 가능성)
가용성 (Availability): 시스템이 항상 요청에 응답하는 것
분할 내성 (Partition tolerance): 네트워크 분할이 발생해도 시스템이 계속 동작하는 것

단일 객체 vs 다중 객체 연산

다중 객체 연산의 필요성

실제 애플리케이션에서는 여러 객체를 동시에 수정해야 하는 경우가 많다.

예시 - 이메일 애플리케이션:

  • 새 메시지가 도착하면 메시지 테이블에 삽입
  • 동시에 읽지 않은 메시지 카운터를 증가

고립성이 없다면 사용자가 새 이메일은 보이지만 카운터는 아직 0으로 표시되는 모순된 상태를 볼 수 있다. 원자성이 없다면 메시지는 삽입되었지만 카운터 업데이트는 실패하는 상황이 발생할 수 있다.

격리 수준과 동시성 문제

1. Read Committed

가장 기본적인 격리 수준으로 두 가지를 보장한다:

더티 읽기 방지

다른 트랜잭션이 아직 커밋하지 않은 데이터를 읽을 수 없다.

예시: 사용자 A가 계좌 잔액을 1000원에서 500원으로 변경하는 중이라면, 사용자 B는 A가 커밋할 때까지 여전히 1000원을 조회한다.

더티 쓰기 방지

아직 커밋되지 않은 쓰기 작업 위에 다른 트랜잭션이 덮어쓸 수 없다.

예시 - 자동차 판매 웹사이트:

  1. 앨리스와 밥이 동시에 같은 차를 구매하려 함
  2. 앨리스가 차량 소유자를 자신으로 변경 (아직 커밋 안 함)
  3. 밥의 트랜잭션은 앨리스의 트랜잭션이 완료될 때까지 대기
  4. 이를 통해 판매 목록과 송장 간의 불일치를 방지

READ Committed 의 한계

갱신 손실이나 쓰기 스큐와 같은 복잡한 동시성 문제는 해결하지 못한다.

2. Snapshot Isolation

postgresql 과 같은 db 에서는 repeatable read 라고 불린다. 트래잭셔 내에서, 똑같은 read 쿼리는 다른 트래잭션의 커밋 결과와 상관없이 항상 같은 결과를 반환한다.

트랜잭션이 시작 시점의 일관된 데이터베이스 스냅샷을 보도록 보장한다.

구현: MVCC (다중 버전 동시성 제어)

  • 각 쓰기 작업은 기존 값을 덮어쓰지 않고 새 버전을 생성
  • 트랜잭션은 시작 시점의 트랜잭션 ID를 기준으로 적절한 버전을 조회
  • 읽기 작업이 쓰기 작업을 차단하지 않음

주요 동시성 문제들

갱신 손실 (Lost Updates)

읽기-수정-쓰기 패턴에서 발생하는 문제

예시 - 카운터 증가:

  1. 트랜잭션 A가 카운터 값 2를 읽음
  2. 트랜잭션 B도 카운터 값 2를 읽음
  3. A가 3으로 업데이트
  4. B도 3으로 업데이트
  5. 결과적으로 두 번의 증가가 일어났지만 최종 값은 3 (하나의 업데이트가 손실됨)

해결책:

  • 원자적 연산 사용 (UPDATE counter SET value = value + 1)
  • 비교-후-설정 연산 사용
  • 명시적 잠금 사용
  • 시스템이 갱신 손실을 자동 감지하여 트랜잭션 재시도
쓰기 스큐 (Write Skew)

트랜잭션이 데이터를 읽고 그 결과를 바탕으로 결정을 내리지만, 다른 트랜잭션이 그 전제를 변경하는 경우

예시 1 - 당직 의사 시스템:

  1. 현재 앨리스와 밥이 당직 중
  2. 앨리스가 당직을 빠지려고 함 → 다른 의사(밥) 확인 → 1명 있음 → 당직 해제
  3. 동시에 밥도 당직을 빠지려고 함 → 다른 의사(앨리스) 확인 → 1명 있음 → 당직 해제
  4. 결과: 당직 의사가 아무도 없게 됨

예시 2 - 회의실 예약:

  1. 오후 2-3시 회의실 예약 시도
  2. 두 사용자가 동시에 해당 시간대 충돌 예약 확인 → 없음
  3. 둘 다 예약 진행
  4. 결과: 같은 시간에 이중 예약

유령 (Phantoms): 쓰기 스큐의 특별한 형태로, 한 트랜잭션의 쓰기가 다른 트랜잭션의 이전 읽기 쿼리 결과에 영향을 미치는 경우. 쿼리를 다시 실행하면 이전에 없던 행이 나타난다.

3. Serializable

가장 강력한 격리 수준으로, 트랜잭션들이 병렬로 실행되더라도 결과가 순차 실행과 동일함을 보장한다.

구현 방법

실행 순차 실행

모든 트랜잭션을 단일 스레드에서 하나씩 실행

장점: 동시성 문제가 완전히 제거됨 단점:

  • 데이터셋이 메모리에 완전히 들어가야 함
  • 스토어드 프로시저 형태로만 트랜잭션 제출 가능
  • 성능 제약

실제 구현 사례 - Redis: Redis는 단일 스레드 이벤트 루프를 사용하여 모든 명령을 순차적으로 처리한다. 이를 통해 복잡한 락 메커니즘 없이도 완벽한 일관성을 보장한다.

Redis의 성능 최적화 전략:

  • 메모리 기반 처리: 모든 데이터를 메모리에 유지하여 디스크 I/O 제거
  • Lua 스크립트: 복잡한 로직을 서버 사이드에서 원자적으로 실행
  • 파이프라이닝: 네트워크 왕복 횟수 최소화
  • 배치 처리: 여러 명령을 한 번에 처리

스토어드 프로시저의 이점:

  • 네트워크 왕복 횟수 감소
  • 원자성 보장 (스크립트 전체가 하나의 트랜잭션)
  • 서버 사이드에서 조건부 로직 실행 가능

제약사항:

  • CPU 집약적인 작업 시 전체 시스템 블록킹 위험
  • 수평 확장의 어려움 (샤딩 필요)
  • 스크립트 작성의 복잡성

다른 예시:

  • VoltDB: 메모리 기반 OLTP에서 단일 스레드 실행 사용
  • H-Store: 학술 연구용 메모리 데이터베이스
2단계 잠금 (Two-Phase Locking, 2PL)

비관적 동시성 제어 방식

특징:

  • 쓰기가 읽기를 차단하고, 읽기도 쓰기를 차단
  • 잠재적 충돌 시 미리 대기

장점: 모든 경쟁 조건 방지 단점: 낮은 동시성, 교착 상태 위험

직렬화 가능 스냅샷 고립 (SSI)

낙관적 동시성 제어 방식으로, 스냅샷 고립의 성능상 이점을 유지하면서 직렬화 가능성을 보장하는 기법이다.

기본 아이디어: 스냅샷 고립은 읽기 전용 쿼리에 대해서는 완벽한 직렬화 가능성을 제공하지만, 읽기-쓰기 트랜잭션에서 쓰기 스큐가 발생할 수 있다. SSI는 이러한 직렬화 가능성 위반을 감지하여 해당 트랜잭션을 중단한다.

동작 방식:

  1. 트랜잭션이 스냅샷 고립처럼 자유롭게 실행
  2. 백그라운드에서 직렬화 충돌 가능성을 추적
  3. 커밋 시점에 실제 충돌 발생 여부 확인
  4. 직렬화 가능성 위반 시 트랜잭션 중단 후 재시도

충돌 감지 메커니즘

1. 오래된 MVCC 읽기 감지 (Detecting stale MVCC reads)

상황: 트랜잭션이 다른 트랜잭션의 쓰기를 MVCC 가시성 규칙으로 인해 보지 못한 경우

예시 - 의사 당직 시나리오:

1
2
3
4
5
6
7
8
9
10
11
12
13
시간    트랜잭션 42 (앨리스)         트랜잭션 43 (밥)
1      SELECT count(*) FROM doctors
       WHERE on_call = true
       → 결과: 2명
2                                   SELECT count(*) FROM doctors
                                   WHERE on_call = true
                                   → 결과: 2명
3      UPDATE doctors SET on_call = false
       WHERE name = 'Alice'
4                                   UPDATE doctors SET on_call = false
                                   WHERE name = 'Bob'
5      COMMIT
6                                   COMMIT → 중단됨

감지 과정:

  • 트랜잭션 43이 SELECT를 실행할 때, 트랜잭션 42의 UPDATE는 아직 커밋되지 않아 보이지 않음
  • 트랜잭션 42가 먼저 커밋되면, 트랜잭션 43의 읽기가 “오래된” 것으로 판정
  • 트랜잭션 43이 커밋을 시도할 때 중단됨

2. 이전 읽기에 영향을 미치는 쓰기 감지 (Detecting writes that affect prior reads)

상황: 한 트랜잭션이 읽은 데이터를 다른 트랜잭션이 수정하는 경우

예시 - 회의실 예약 시나리오:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
시간    트랜잭션 A                    트랜잭션 B
1      SELECT * FROM bookings
       WHERE room = 101
       AND time = '14:00-15:00'
       → 결과: 충돌 없음
2                                   SELECT * FROM bookings
                                   WHERE room = 101
                                   AND time = '14:00-15:00'
                                   → 결과: 충돌 없음
3      INSERT INTO bookings
       (room, time, user) VALUES
       (101, '14:00-15:00', 'Alice')
4                                   INSERT INTO bookings
                                   (room, time, user) VALUES
                                   (101, '14:00-15:00', 'Bob')
5      COMMIT
6                                   COMMIT → 중단됨

감지 방법:

  • 인덱스 구조를 활용하여 읽기 연산이 접근한 키 범위를 추적
  • 다른 트랜잭션이 해당 범위에 쓰기를 시도하면 충돌로 표시
  • 나중에 커밋하는 트랜잭션이 중단됨

인덱스 범위 잠금 vs SSI

2PL의 인덱스 범위 잠금:

  • 읽기 시점에 즉시 공유 잠금 획득
  • 다른 트랜잭션의 쓰기를 미리 차단
  • 데드락 가능성 존재

SSI의 추적 방식:

  • 읽기 시점에는 추적만 하고 차단하지 않음
  • 쓰기 시점에 충돌 가능성을 기록
  • 커밋 시점에 실제 충돌 여부 확인

성능 특성

낙관적 제어의 장점:

  • 읽기 전용 쿼리가 쓰기를 전혀 차단하지 않음
  • 짧은 트랜잭션에서 락 오버헤드 없음
  • 데드락이 발생하지 않음

트랜잭션 중단 최소화:

  • 읽기 전용 트랜잭션은 절대 중단되지 않음
  • 실제 충돌이 있을 때만 중단 (false positive 최소화)
  • 커밋 순서를 조정하여 불필요한 중단 방지

성능 트레이드오프:

  • 동시성이 낮을 때: 2PL보다 우수한 성능
  • 동시성이 높을 때: 중단-재시도로 인한 성능 저하
  • 읽기 중심 워크로드에서 특히 효과적

실제 구현 사례

PostgreSQL: SSI를 SERIALIZABLE 격리 수준으로 구현 FoundationDB: 분산 환경에서 SSI 변형 사용 CockroachDB: 분산 SSI 구현

최적화 기법

조기 충돌 감지: 커밋 전에 명백한 충돌을 미리 감지하여 불필요한 작업 방지 그래뉼러티 조정: 행 수준이 아닌 페이지나 범위 수준에서 추적하여 오버헤드 감소 커밋 순서 최적화: 충돌하는 트랜잭션들의 커밋 순서를 조정하여 중단 횟수 최소화

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.