Introduction

부트캠프에서 이런 질문을 받았다. "데이터베이스 동시성 제어가 여러 사용자의 작업 충돌을 방지하는 방식을 설명하시오." 여러 사용자가 같은 계좌, 같은 재고, 같은 좌석을 동시에 건드리는 일은 서비스에서 일상이고, 데이터베이스는 그 충돌을 어떻게든 "순서대로 실행한 것과 같은 결과"로 정리해 내야 한다. 이 글은 트랜잭션과 ACID에서 출발해, 동시성이 만들어 내는 이상 현상들, 그리고 그것을 막는 세 가지 전략 — 비관적 제어(락과 2PL), MVCC, 낙관적 제어 — 를 차례로 따라간다.

트랜잭션(Transaction)

트랜잭션이란?

-- 트랜잭션 시작
START TRANSACTION;

-- void의 계좌에서 10만원 차감
UPDATE account
SET balance = balance - 100000
WHERE username = 'void';

-- yaro의 계좌에 10만원 추가
UPDATE account
SET balance = balance + 100000
WHERE username = 'yaro';

-- 작업이 모두 성공했으므로 커밋
COMMIT;

-- 변경된 계좌 잔액 확인
SELECT * FROM account;

트랜잭션은 DBMS(Database Management System, 데이터베이스 관리 시스템)에서 상호작용이 이루어지는 단위다. 하나의 트랜잭션은 하나의 동작이며, 원칙적으로 한 번의 트랜잭션은 한 번의 상호작용이다. 그래서 트랜잭션은 전부 실행되거나 전부 실패해야 한다. 위의 송금 예제에서 차감만 되고 추가가 안 되는 중간 상태는 존재해서는 안 되는 것이다. 트랜잭션은 시스템 내부에서 생성되어 소멸될 때까지 엄격하게 정의된 상태들을 통과하며 데이터베이스의 신뢰성을 보장한다.

ACID

트랜잭션은 하나의 상호작용으로 정의되기 때문에, 실제로는 여러 연산으로 이루어져 있더라도 사용자의 관점에서는 쪼개질 수 없는 하나의 단위가 되어야 한다. 이를 위해 트랜잭션이 가져야 하는 네 가지 속성을 ACID(Atomicity · Consistency · Isolation · Durability)라 부른다.

Atomicity

트랜잭션은 쪼개질 수 없는 하나의 작업으로 처리되어야 한다. 그래서 만약 트랜잭션이 실행 도중에 실패한다면, 이유를 불문하고 트랜잭션이 이루어지기 전의 상태로 돌아가야 한다. 이 속성을 원자성atomicity이라 부른다.

DBMS는 이를 구현하기 위해 트랜잭션의 모든 변경 사항을 Undo Log1(또는 Rollback Segment)에 기록한다. 시스템 장애가 발생하거나 사용자가 명시적으로 롤백(rollback)을 요청하면, DBMS는 이 로그를 역순으로 참조하여 데이터베이스를 트랜잭션 시작 전의 상태로 원상 복구한다.

Consistency

트랜잭션이 성공적으로 완료되면, 데이터베이스는 기존에 정의된 모든 규칙(무결성 제약 조건, 트리거, 비즈니스 규칙 등)을 만족하는 유효한 상태를 유지해야 한다. 이 속성을 일관성consistency이라 부른다. 일관성은 DBMS의 제약 조건 검사 기능(FOREIGN KEY, UNIQUE 등)과 애플리케이션 개발자의 올바른 트랜잭션 로직 구현이 결합되어 달성된다.

Isolation

트랜잭션은 쪼개질 수 없는 하나의 작업이기 때문에, 트랜잭션에 포함되지 않는 다른 작업은 트랜잭션에 개입할 수 없어야 한다. 이 속성을 독립성isolation이라 부른다.

트랜잭션이 제대로 분리된다면 여러 트랜잭션을 동시에 수행하더라도 순서대로serial 수행한 것과 동일한 결과를 논리적으로 보장해야 하는데, 이를 직렬성serializability이라 부른다. 다만 격리 수준을 높여 완벽한 직렬성을 추구하면 락 경합이 심해져 동시 처리량throughput이 급격히 감소하고, 반대로 격리 수준을 낮추면 성능은 향상되지만 데이터 무결성이 위협받는다. 그래서 현대 DBMS는 이 사이의 균형을 위해 다양한 격리 수준을 제공한다.

Durability

DBMS가 트랜잭션을 성공적으로 완료시키더라도, 시스템 장애나 전원 손실, 하드웨어 오류로 인해 그 결과가 사라진다면 소용이 없다. 따라서 성공적으로 완료된 트랜잭션의 결과는 외부 요인에도 불구하고 영구적으로 보존되어야 하는데, 이 속성을 지속성durability이라 부른다.

구현에는 주로 WAL(Write-Ahead Logging, 로그 선행 기입) 기법을 사용한다. 데이터 파일에 변경 사항을 기록하기 전에 로그 파일에 먼저 기록하며, 커밋 시점에는 로그가 디스크에 안전하게 저장되었음을 보장(fsync)2함으로써 지속성을 달성한다.

트랜잭션에서의 동시성

동시성(Concurrency)

그림 1. A를 실행했다가 B, C를 실행하고 다시 A로 돌아와도 시간대가 겹치기 때문에 세 작업 A, B, C는 동시적이다.

동시성은 작업의 시작과 종료에 대한 개념이다. 두 작업 A와 B에 대하여 A의 시작과 끝, 그리고 B의 시작과 끝이 겹치면 두 작업은 동시적이다. 중요한 것은 이 정의가 실제로 두 작업이 같은 순간에 실행되는 구간이 있는가와는 무관하다는 것이다. 그저 시간대가 겹치기만 해도 된다.

경쟁 조건(Race Condition)

트랜잭션에 동시성을 도입하면서 발생하는 가장 대표적인 문제는 경쟁 조건(race condition)이다. 다음 상황을 보자.

그림 2. 트랜잭션 T1은 계좌 A에서 계좌 B로 $50를 송금하고, T2는 계좌 A의 잔액 중 10%를 계좌 B로 송금한다.

그림 3. 두 트랜잭션이 동기적으로 이루어진다면 T1 → T2 혹은 T2 → T1 순으로 진행된다.

그림 4. 순차 실행은 컴퓨팅 능력을 비효율적으로 사용하므로, 일반적으로는 이렇게 두 트랜잭션을 동시에 수행한다. 결과는 그림 3과 동일하지만 훨씬 빠르다.

그림 5. 하지만 트랜잭션을 제대로 분리하지 못하면 이렇게 일관성이 깨지는 결과가 우리를 기다린다.

트랜잭션을 제대로 분리한다면 여러 트랜잭션을 동시에 수행해도 순서대로 수행한 것과 동일한 결과가 보장되어야 한다. 그 보장이 깨질 때 나타나는 증상들이 바로 다음의 데이터 이상 현상이다.

데이터 이상 현상

Dirty Read : 다른 트랜잭션이 아직 커밋하지 않은 데이터를 읽는 현상이다. A 트랜잭션이 데이터를 수정했지만 커밋하지 않은 상태에서 B 트랜잭션이 이를 읽으면 발생한다.

Non-Repeatable Read : 동일 트랜잭션 내에서 동일 데이터를 다시 읽을 때 값이 달라지는 현상이다. A 트랜잭션이 데이터를 읽은 뒤 B 트랜잭션이 이를 수정하고 커밋하면, A가 같은 데이터를 다시 읽었을 때 값이 변해 있다.

Phantom Read : 동일 트랜잭션 내에서 동일 조건으로 범위를 조회할 때 결과 집합이 달라지는 현상이다. A 트랜잭션이 WHERE age > 20 조건으로 조회한 사이에 B 트랜잭션이 조건에 맞는 새 행을 삽입하면, A의 두 번째 조회에는 없던 행(유령)이 나타난다.

Lost Update : 두 개 이상의 트랜잭션이 동일한 데이터를 동시에 읽고 수정할 때, 나중에 완료된 트랜잭션이 먼저 완료된 트랜잭션의 갱신 내용을 덮어쓰는 현상이다. 트랜잭션 A와 B가 상품 재고 10개를 각각 읽은 뒤 A가 1개를 판매해 재고를 9로, B가 2개를 판매해 재고를 8로 갱신하면, 최종 재고는 8이 되어 A의 판매가 사라진다(정상이라면 7이어야 한다).

Write Skew : 두 트랜잭션이 서로 다른 데이터를 읽고, 그 읽은 값을 바탕으로 서로 겹치지 않는 데이터를 갱신할 때 발생하는 현상이다. 각 트랜잭션 내부적으로는 일관성이 유지되는 것처럼 보이고 갱신 대상도 겹치지 않아 일반적인 락킹으로는 감지하기 어렵지만, 두 트랜잭션이 동시에 커밋되면 시스템 전체의 제약 조건이 위반될 수 있다. 예를 들어 "최소 한 명의 의사는 대기해야 한다"는 규칙이 있는 병원 당직 시스템에서, 대기 중인 의사 A와 B가 동시에 대기 인원(2명)을 조회하고 "내가 퇴근해도 1명이 남는다"고 판단해 각자 퇴근을 신청하면, 두 트랜잭션은 서로 다른 행을 갱신하므로 락 충돌 없이 커밋되지만 대기 의사는 0명이 되어 규칙이 깨진다.

동시성 제어 전략

트랜잭션 상태 모델

그림 6. DBMS 안에서 트랜잭션이 통과하는 상태들.

트랜잭션에는 상태라는 개념이 있는데, 이를 통해 트랜잭션이 이루어지는 과정을 상태가 바뀌는 과정으로 설명할 수 있다.

상태의 종류

  • Active(활동): START TRANSACTION;과 함께 트랜잭션이 시작된 직후의 초기 상태로, 읽기 및 쓰기 연산이 수행된다. 이 상태에서 이루어지는 모든 데이터 변경은 디스크에 직접 반영되지 않고 메인 메모리 내의 휘발성 버퍼나 전용 로컬 작업 공간 안에서 이루어지므로, 시스템이 중간에 꺼지면 수행했던 작업은 즉시 소실된다. 활동 상태는 트랜잭션이 정상적으로 마지막 연산을 마칠 때까지 유지되며, 실행 중 오류가 발생하면 즉시 실패 상태로 전이된다.
  • Partially Committed(부분 완료): 트랜잭션 내의 마지막 연산이 실행된 직후를 의미한다. 트랜잭션의 로직은 모두 끝났지만 그 결과가 아직 메인 메모리의 버퍼에 머물러 있을 수 있다는 뜻이다. 이 단계에서 DBMS는 지속성을 보장하기 위해 로그 버퍼의 내용을 강제로 디스크의 로그 파일에 기록하며, 이 물리적 쓰기가 성공해야 트랜잭션이 완료될 수 있다. 이 과정에서 디스크 오류나 시스템 장애가 발생하면 트랜잭션은 실패 상태가 된다.
  • Committed(완료): 로그 기록이 디스크의 안정적인 저장소에 안전하게 저장되었음을 의미한다. 데이터 자체가 디스크에 기록되었다는 뜻은 아니다. 트랜잭션이 완료되면 점유하고 있던 시스템 자원을 반납한다.
  • Failed(실패): 활동 상태나 부분 완료 상태에서 하드웨어 고장, 소프트웨어 오류, 교착 상태 탐지, 또는 명시적인 롤백 요청 등으로 더 이상 정상적인 진행이 불가능할 때 트랜잭션은 실패 상태가 된다. 실패 상태는 데이터베이스의 일관성을 해칠 수 있는 변경 사항을 남겼을 가능성이 크기 때문에 반드시 복구 절차가 수반되어야 한다.
  • Aborted(철회): 실패한 트랜잭션이 수행한 모든 변경 사항을 취소하고 데이터베이스를 트랜잭션 시작 전의 일관된 상태로 되돌린(rollback) 후의 상태다.
  • Terminated(종료): 롤백이 완료된 후 트랜잭션은 시스템 정책에 따라 재시작되거나 강제 종료되면서 생명주기를 마감하고 종료 상태가 된다.

상태 변화 과정

현재 상태이벤트/조건다음 상태설명
Active마지막 연산 성공Partially Committed모든 로직 수행 완료, 로그 기록을 위해 대기
Active오류 발생 (교착 상태 등)Failed정상 진행 불가 판정
Partially Committed로그 기록(I/O) 성공Committed영속성 보장 완료
Partially Committed로그 기록 실패Failed하드웨어 오류 등으로 기록 실패
Failed롤백(rollback) 수행Aborted변경 사항 원상 복구 완료
Committed자원 해제Terminated트랜잭션 컨텍스트 정리
Aborted재시작 또는 종료Terminated트랜잭션 컨텍스트 정리

부분 완료(partially committed)에서 완료(committed) 상태로 넘어가는 과정은 동기적인 디스크 입출력을 수반하는데, 이것이 트랜잭션 처리 성능의 가장 큰 병목 구간이다.

격리 수준(Isolation Levels)

SQL 표준은 이상 현상anomaly을 어디까지 허용할 것인지를 기준으로 격리 수준을 4단계로 정의한다.

격리 수준Dirty ReadNon-Repeatable ReadPhantom ReadWrite Skew
Read Uncommitted허용허용허용허용
Read Committed방지허용허용허용
Repeatable Read방지방지벤더별 상이허용
Serializable방지방지방지방지

Read Uncommitted

가장 낮은 격리 수준으로, 트랜잭션은 다른 트랜잭션이 아직 커밋하지 않은 변경 사항(dirty data)까지 읽을 수 있다. Dirty Read, Non-Repeatable Read, Phantom Read가 모두 발생할 수 있다.

Read Committed

트랜잭션은 확정된(committed) 데이터만 읽을 수 있다. Dirty Read는 방지하지만 Non-Repeatable Read와 Phantom Read는 발생할 수 있다. 락 기반 구현에서는 데이터를 읽을 때 공유 락을 획득하고 읽기가 끝나자마자 즉시 해제한다. MVCC 기반 구현에서는 쿼리가 실행되는 시점마다 새로운 스냅샷을 참조하여 최신의 커밋된 데이터를 읽는데, 그래서 하나의 트랜잭션 안에서도 반복 수행되는 쿼리의 결과가 달라질 수 있다.

Repeatable Read

트랜잭션이 시작된 후 처음 읽은 데이터는 트랜잭션이 종료될 때까지 변경되지 않음을 보장한다. Dirty Read와 Non-Repeatable Read는 확실히 방지되고, 구현에 따라 Phantom Read까지 방지하기도 한다. 락 기반 구현에서는 데이터를 읽을 때 획득한 공유 락을 트랜잭션 종료 시까지 유지해 다른 트랜잭션의 수정을 차단하고, MVCC 기반 구현에서는 트랜잭션 시작 시점(또는 첫 번째 읽기 시점)에 생성된 스냅샷을 트랜잭션 내내 유지한다. 다른 트랜잭션이 데이터를 수정하고 커밋하더라도 현재 트랜잭션은 자신의 스냅샷에 있는 과거 데이터를 계속 보는 것이다. 다만 구현 방식에 따라 Write Skew는 여전히 발생할 수 있다.

Serializable

가장 높은 격리 수준으로, 트랜잭션들이 병렬적으로 실행되더라도 그 결과는 순차적으로 실행된 것과 논리적으로 동일함을 보장한다. 구현은 크게 두 갈래다. Strict 2PL 계열은 범위 락(range lock)을 포함한 엄격한 락킹으로 다른 트랜잭션의 접근을 차단하고, SSI(Serializable Snapshot Isolation, 직렬화 가능 스냅샷 격리)는 PostgreSQL 등에서 사용하는 방식으로 락을 걸지 않고 낙관적으로 실행한 뒤 커밋 시점에 위험한 충돌 구조가 감지되면 롤백시킨다. 모든 이상 현상을 방지할 수 있지만, 동시성이 가장 낮고 트랜잭션의 롤백 빈도가 높다.

비관적 동시성 제어(Pessimistic Concurrency Control)

비관적 동시성 제어pessimistic concurrency control는 "데이터 충돌이 빈번하게 발생할 것"이라고 비관적으로 가정하고, 데이터에 접근하기 전에 미리 잠금을 걸어 다른 트랜잭션의 간섭을 원천적으로 차단하는 방식이다. 데이터 무결성을 가장 확실하게 보장하지만, 락 대기로 인한 성능 저하와 교착 상태의 위험을 수반한다.

데이터베이스에서 동시성을 제어하는 가장 확실한 방법은 데이터에 접근할 권한 자체를 제어하는 것이다. 이런 맥락에서 탄생한 것이 바로 락(lock)이다. 락은 데이터에 접근할 권한 그 자체이며, 따라서 모든 트랜잭션은 예외 없이 락을 가지고 있어야 데이터에 접근할 수 있다. 락 기반 동시성 제어에서 트랜잭션은 데이터에 접근하기 위해 락을 요청하고, 락을 받은 다음에야 데이터를 사용하며, 그동안 다른 트랜잭션의 접근은 제한된다 (스레드 동기화 관점에서의 락 일반론은 Lock 참고).

락을 가지고 있다?

락을 통한 동시성 제어를 실현하려면 락을 관리하는 자료구조가 필요하다. 이를 락 테이블(lock table) 또는 락 관리자(lock manager)라고 부르며, 해시 테이블(hash table)을 기반으로 구현한다.

그림 7. 락 관리자를 구현한 예시. 각 데이터의 이름을 해싱 인덱스로 사용하는 해시 테이블(맨 왼쪽)이 락 관리자다.

락 관리자라는 해시 테이블의 각 항목은 그 데이터를 사용하기 위해 락을 요청한 트랜잭션의 목록을 가지고 있다. 트랜잭션은 도착한 순서대로 연결되어 있어, 하나의 데이터로만 한정하면 먼저 요청한 트랜잭션이 락을 먼저 받는다. 락 관리자는 트랜잭션으로부터 어떤 아이템에 대한 락 요청을 받으면 해당 아이템에 대한 기존 요청이 있는지 확인하고, 있으면 대기열에 넣고 없으면 바로 락을 제공한다. 트랜잭션이 락을 해제할 때는 락 관리자에게 해제 요청을 보내고, 락 관리자는 해당 아이템의 요청 목록에서 그 트랜잭션을 지운다.

"락을 가진다"는 것은 곧, 현재 아이템에 걸린 락의 모드(mode)가 트랜잭션이 보낸 요청의 모드와 호환되어 트랜잭션의 상태가 "허가됨(granted)"으로 바뀐다는 것을 뜻한다.

락의 유형

대표적인 락에는 다섯 가지가 있다. 락킹 메커니즘의 핵심은 락의 종류, 그리고 락들 사이의 호환성을 관리하는 것이다.

이름내용
Shared Lock (공유 락, S-Lock)여러 트랜잭션이 동시에 읽을 수 있지만 쓰기는 차단한다.
Exclusive Lock (독점 락, X-Lock)읽기와 쓰기를 모두 차단하여 단독 접근을 보장한다.
Intent Lock (의도 락, IS/IX)하위 자원에 락을 걸 의도가 있음을 상위 레벨에 알린다.
Update Lock (갱신 락, U-Lock)S-Lock과 X-Lock의 중간 단계로, 데이터를 읽은 후 수정할 의도가 있음을 알린다. S-Lock 간의 전환 교착 상태(conversion deadlock)를 방지하기 위해 사용되며, 다른 S-Lock과는 호환되지만 다른 U-Lock이나 X-Lock과는 호환되지 않는다.
Gap Lock (갭 락)실제 데이터가 없는 빈 공간에 새로운 데이터가 삽입되는 것을 막아 Phantom Read를 방지한다.

2단계 락킹 프로토콜(Two-Phase Locking, 2PL)

2PL(Two-Phase Locking, 2단계 락킹)은 트랜잭션의 직렬성을 보장하기 위한 표준적인 락킹 규약이다. 트랜잭션의 실행 과정을 락을 획득하는 단계와 해제하는 단계로 엄격히 분리한다.

  1. 확장 단계(growing phase): 트랜잭션은 필요한 락을 획득할 수만 있고, 해제할 수는 없다. 이 단계에서 필요한 모든 자원에 대한 권한을 확보한다.
  2. 차단 점(locked point): 트랜잭션이 필요한 모든 락을 획득한 시점이다.
  3. 축소 단계(shrinking phase): 트랜잭션은 보유한 락을 해제할 수만 있고, 새로운 락을 획득할 수는 없다.

2PL의 변형(Variations)

  • Basic 2PL: 위에서 설명한 기본 형태로, 교착 상태가 발생할 수 있다.
  • Strict 2PL: 모든 배타적 락(X-Lock)을 트랜잭션이 완료(commit/rollback)될 때까지 유지한다. 커밋되지 않은 데이터를 다른 트랜잭션이 읽는 것을 방지하여 연쇄 복귀cascading rollback 문제를 해결하고 회복 가능성recoverability을 보장한다.
  • Rigorous 2PL: S-Lock을 포함한 모든 락을 트랜잭션 완료 시점까지 유지한다. 구현이 단순하고 Strict 2PL보다 더 엄격하며, 데이터 일관성을 가장 강력하게 보장하므로 대부분의 상용 DBMS 엔진이 이 방식을 채택한다.
  • Conservative 2PL (Pre-claiming): 트랜잭션 시작 전에 필요한 모든 락을 미리 선언하고 한꺼번에 획득하며, 락을 얻지 못하면 시작조차 하지 않는다. 교착 상태를 원천적으로 방지하지만, 필요한 자원을 미리 알기 어렵고 동시성이 매우 낮아 실제로는 거의 사용되지 않는다.

교착 상태

교착 상태deadlock는 여러 트랜잭션이 서로가 가진 락을 기다리며 순환 대기circular waiting 상태에 빠질 때 발생한다 (운영체제 수준의 교착 상태 일반론은 Lock 참고).

그림 8. 서로가 가진 락을 기다리며 멈춰 선 트랜잭션들.

그림 9. 앞서 보았던 T1과 T2에 락을 추가해 실행한 모습.

그림 10. 하지만 사실 이것은 교착 상태로 이어질 수 있는 위험한 실행 과정이다.

그림 11. 트랜잭션의 대기 관계는 그래프로 그릴 수 있는데, T18-T19-T20처럼 순환이 발생하면 교착 상태에 빠질 가능성이 높다.

그래서 교착 상태를 예방하기 위해 트랜잭션이 시작될 때 고유한 타임스탬프를 부여해 우선순위를 정하고, 락 대기 상황이 발생하면 미리 정해진 규칙에 따라 트랜잭션을 강제 종료하거나 대기시킨다. 대표적인 규칙이 Wait-Die와 Wound-Wait다.

알고리즘접근 방식TiT_i(Old)가 TjT_j(Young) 자원 요청 시TjT_j(Young)가 TiT_i(Old) 자원 요청 시특징
Wait-Die비선점Wait (기다림)Die (TjT_j 롤백)젊은 트랜잭션이 희생됨. 불필요한 롤백 가능성 높음.
Wound-Wait선점Wound (TjT_j 롤백)Wait (기다림)늙은 트랜잭션이 우선함. 롤백 횟수 최소화.

두 규칙 모두 늙은(먼저 시작한) 트랜잭션에 우선권을 주므로 순환 대기가 생길 수 없다. 차이는 충돌 시 누가 물러나는가에 있다. Wait-Die에서는 젊은 트랜잭션이 늙은 트랜잭션의 자원을 요청할 때 스스로 죽고(die), Wound-Wait에서는 늙은 트랜잭션이 젊은 트랜잭션의 자원을 요청할 때 젊은 쪽을 강제로 롤백시킨다(wound). 롤백된 트랜잭션은 동일한 타임스탬프로 재시작하므로 언젠가는 가장 늙은 트랜잭션이 되어 반드시 완료된다.

Multi-Version Concurrency Control (MVCC)

락킹 기반의 제어는 읽기 작업도 락을 걸어야 한다는 제약 때문에 동시성이 크게 저하되는 단점이 있다. 이를 극복하기 위해 현대 DBMS(Oracle, PostgreSQL, MySQL InnoDB 등)는 MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어)를 핵심 엔진으로 채택하고 있다.

MVCC의 가장 중요한 원칙은 "읽기 작업은 쓰기 작업을 차단하지 않고, 쓰기 작업은 읽기 작업을 차단하지 않는다"는 것이다. 이를 위해 데이터베이스는 데이터를 덮어쓰는overwrite 대신, 데이터의 새로운 버전(version)을 생성하여 관리한다. 구체적인 구현은 DBMS마다 차이가 있다.

특성PostgreSQL (Tuple Versioning)MySQL InnoDB (Undo Log)
버전 저장 위치메인 테이블(Heap)에 구버전/신버전 공존메인 테이블엔 신버전, Undo Log에 구버전
UPDATE 처리DELETE + INSERT (새 튜플 생성)In-place Update + Undo Log 기록
공간 관리Dead Tuple 발생 (Bloat)Undo Log 크기 증가
정리 메커니즘VACUUM (필수적, 튜닝 필요)Purge Thread (자동 백그라운드)
읽기 성능버전이 테이블에 있어 바로 접근 가능과거 버전은 Undo Log를 재구성해야 함
인덱스 영향UPDATE 시 모든 인덱스 갱신 필요 (HOT 제외)Secondary Index 갱신 불필요 (대부분)

PostgreSQL

PostgreSQL은 데이터 페이지(heap) 내에 이전 버전과 최신 버전의 튜플을 모두 저장한다. 각 튜플에는 xmin(생성한 트랜잭션 ID)과 xmax(삭제/갱신한 트랜잭션 ID)라는 숨겨진 메타데이터 컬럼이 있고, 트랜잭션은 자신의 ID와 이 값들을 비교하여 해당 튜플이 자신에게 보이는지 판단한다. UPDATE는 내부적으로 DELETE(구버전 xmax 설정) 후 INSERT(새 버전 생성)로 처리된다.

이 구조 덕분에 롤백 시 별도의 복구 과정 없이 상태 플래그만 확인하면 되므로 처리가 매우 빠르고, Oracle이나 MySQL처럼 별도의 Undo 영역 크기 제한이 없어 디스크 공간만 충분하다면 매우 긴 트랜잭션도 스냅샷으로 유지할 수 있다. 하지만 업데이트가 빈번하면 더 이상 참조되지 않는 죽은 튜플(dead tuple)이 테이블에 계속 쌓여 크기가 비대해지는 Bloat 현상이 발생한다. 이를 정리하고 공간을 회수하기 위해 VACUUM이라는 정리 프로세스가 주기적으로 실행되어야 하며, 이는 시스템 자원(CPU, I/O)을 소모한다. 또한 새로운 튜플이 생성되면 그 튜플을 가리키는 모든 인덱스도 갱신되어야 하므로(HOT 업데이트3가 아닌 경우) 쓰기 비용이 상대적으로 높다.

MySQL

MySQL의 InnoDB 엔진은 최신 데이터만 데이터 페이지(clustered index)에 유지하고, 이전 버전의 데이터는 Undo Log라는 별도의 시스템 영역에 복사해 둔다. 데이터 레코드에는 DB_TRX_ID(최근 수정한 트랜잭션 ID)와 DB_ROLL_PTR(Undo Log 포인터)가 포함되며, 읽기 트랜잭션이 자신의 시점보다 최신 데이터를 만나면 DB_ROLL_PTR를 따라 Undo Log를 역추적하여 과거 버전의 데이터를 즉석에서 재구성한다.

최신 데이터가 항상 제자리에 있으므로 인덱스를 갱신할 필요가 적고(secondary index는 변경된 데이터 페이지를 그대로 가리킨다), 데이터 페이지에서는 테이블 스페이스가 불필요하게 커지는 현상이 발생하지 않는다.

스냅샷 격리(Snapshot Isolation)

현대 데이터베이스 이론에서 가장 중요한 발전 중 하나가 스냅샷 격리snapshot isolation다. 스냅샷 격리는 MVCC를 사용하여 트랜잭션이 시작된 시점의 일관된 스냅샷을 읽는 방식으로, 대부분의 DBMS에서 Repeatable Read 수준은 실제로 이 방식으로 구현된다.

읽기 작업이 락을 걸지 않아 동시성이 매우 뛰어나고 Dirty Read, Non-Repeatable Read, Phantom Read를 모두 방지한다는 것이 장점이다. 그러나 앞서 설명한 Write Skew는 방지하지 못한다. 즉 직렬성을 완벽하게 보장하지는 않는다. 스냅샷 격리의 동시성을 유지하면서 직렬성까지 보장하려는 시도가 격리 수준 절에서 언급한 SSI이고, PostgreSQL의 Serializable 수준이 이 방식으로 구현되어 있다.

낙관적 동시성 제어(Optimistic Concurrency Control)와 애플리케이션 전략

모든 동시성 제어를 DBMS 엔진에만 위임하는 것이 항상 최선은 아니다. 특히 웹 애플리케이션과 같은 무상태stateless 환경이나 사용자의 생각하는 시간(user think time)이 포함되는 긴 비즈니스 트랜잭션에서는 애플리케이션 레벨의 OCC(Optimistic Concurrency Control, 낙관적 동시성 제어)가 효과적이다.

낙관적 동시성 제어

낙관적 제어는 "충돌은 드물 것이다"라고 가정하고 락 없이 트랜잭션을 진행한 뒤, 마지막에 검증한다.

  1. 읽기 단계(read phase): 트랜잭션은 데이터를 읽고 로컬 메모리에서 연산을 수행한다. 실제 DB에는 반영하지 않는다.
  2. 검증 단계(validation phase): 커밋 요청 시, 트랜잭션 수행 기간 동안 다른 트랜잭션이 해당 데이터를 변경했는지 확인한다.
  3. 쓰기 단계(write phase): 검증을 통과하면 변경 사항을 DB에 반영하고, 실패하면 트랜잭션을 중단(abort)하고 재시도한다.

애플리케이션 레벨의 낙관적 전략: Versioning

DBMS의 락을 오랫동안 점유하는 것은 시스템 성능에 치명적이다. 대신 데이터 테이블에 version 또는 timestamp 컬럼을 추가하여 논리적인 락을 구현할 수 있다. 이 전략에서는 업데이트를 할 때만 version 컬럼을 확인하므로, 데이터베이스 커넥션을 점유하지 않고도 긴 트랜잭션의 동시성을 제어할 수 있다.

과정은 간단하다. 먼저 SELECT id, data, version FROM table WHERE id = 1로 데이터를 조회하면서 version = 5임을 확인하고, 사용자는 이 데이터로 필요한 작업을 한다. 업데이트할 때는 읽었던 버전을 조건에 포함시킨다.

UPDATE table
SET data = 'new_value', version = version + 1
WHERE id = 1 AND version = 5;

UPDATE 쿼리의 영향을 받은 행이 하나면 성공이다. 만약 영향을 받은 행이 없다면, 그 사이에 누군가 데이터를 수정하여 버전이 6으로 바뀐 것이므로 충돌로 간주하고 사용자에게 알리거나 재조회를 유도한다.

결론

데이터베이스가 여러 사용자의 작업 충돌을 방지하는 방식은 결국 하나의 목표로 수렴한다. 동시에 실행된 트랜잭션들의 결과가 순서대로 실행한 것과 논리적으로 같도록, 즉 직렬성을 보장하는 것이다. 이를 위해 비관적 제어는 락으로 접근 자체를 통제하고(그 대가로 락 경합과 교착 상태를 관리해야 한다), MVCC는 데이터의 버전을 분리해 읽기와 쓰기가 서로를 차단하지 않게 하며, 낙관적 제어는 충돌이 드물다는 가정 아래 커밋 시점에 검증한다. 현대 DBMS는 이 전략들을 격리 수준이라는 다이얼로 노출하고, 개발자는 서비스의 충돌 빈도와 트랜잭션 길이에 따라 무결성과 동시성 사이의 균형점을 선택하게 된다. 여기에 애플리케이션 레벨의 Versioning까지 더하면, 동시성 제어는 데이터베이스 엔진만의 일이 아니라 시스템 설계 전체의 일이 된다.


출처

*[DBMS]: Database Management System *[MVCC]: Multi-Version Concurrency Control *[2PL]: Two-Phase Locking

Footnotes

  1. Undo Log는 데이터의 변경 이전 값을 보관하는 로그다. 롤백 시 이 로그를 역순으로 적용해 원래 상태로 되돌리며, MySQL InnoDB에서는 MVCC의 과거 버전 재구성에도 사용된다.

  2. fsync는 운영체제가 버퍼에 들고 있는 파일 변경 내용을 물리 디스크까지 강제로 내려쓰게 하는 시스템 호출이다. 커밋 시점에 로그 파일을 fsync해야 직후에 전원이 꺼져도 커밋된 결과가 살아남는다.

  3. HOT(Heap-Only Tuple) 업데이트는 PostgreSQL에서 인덱스에 포함된 컬럼이 변경되지 않았을 때, 인덱스 갱신 없이 같은 데이터 페이지 안에 새 튜플을 만들어 연결하는 최적화다.