트랜잭션은 여러 연산을 한 단위로 묶고, MVCC는 데이터 버전을 나눠 읽기와 쓰기의 충돌을 줄입니다.
트랜잭션(Transaction)은 데이터베이스에서 하나의 논리적 작업 단위를 이루는 연산들의 집합입니다. 은행 이체의 출금과 입금은 함께 성공하거나 함께 실패해야 합니다. 하나만 반영되면 잔고가 맞지 않으므로 데이터베이스는 두 연산을 한 단위로 안전하게 처리해야 합니다.
트랜잭션과 ACID
트랜잭션이 보장해야 하는 네 가지 성질을 ACID라고 부릅니다. 각 성질은 데이터베이스 내부의 서로 다른 메커니즘으로 구현됩니다.
| 속성 | 영문 | 설명 | 보장 메커니즘 |
|---|---|---|---|
| 원자성 | Atomicity | 모든 연산이 전부 수행되거나 전혀 수행되지 않음 | Undo Log(롤백) |
| 일관성 | Consistency | 트랜잭션 전후로 일관된 상태 유지 | 제약 조건, 트리거 |
| 격리성 | Isolation | 동시 실행 트랜잭션이 서로 간섭하지 않음 | 락, MVCC |
| 지속성 | Durability | 커밋된 결과는 장애에도 보존됨 | WAL(Write-Ahead Log) |
원자성을 가장 직관적으로 보여주는 예가 계좌 이체입니다. 두 번의 갱신을 하나의 트랜잭션으로 묶으면, 중간 실패 시 첫 갱신까지 되돌릴 수 있습니다.
BEGIN;
UPDATE accounts SET balance = balance - 100000 WHERE id = 'A'; -- 출금
UPDATE accounts SET balance = balance + 100000 WHERE id = 'B'; -- 입금
COMMIT;
-- 두 번째 UPDATE에서 오류가 나면 ROLLBACK 으로 첫 UPDATE도 취소됩니다.커밋(Commit)은 모든 연산을 확정합니다. 롤백(Rollback)은 거래 이전 상태로 되돌립니다. 데이터베이스는 갱신 전 값을 Undo Log에 기록하므로 롤백할 때 이 값으로 복구할 수 있습니다.
데이터베이스는 원자성과 지속성을 위한 장치를 제공하지만, 일관성에는 올바른 제약 조건과 애플리케이션 로직도 필요합니다. 격리성은 동시성과 직접 맞물립니다. 직렬화 가능 격리는 강한 보장을 주는 대신 충돌한 트랜잭션의 재시도가 늘 수 있습니다.
동시성이 만드는 읽기 이상
여러 트랜잭션이 같은 데이터를 동시에 다루면 읽기 결과가 어긋날 수 있습니다. 대표적으로 세 가지 이상 현상이 있습니다.
| 문제 | 설명 | 발생 상황 |
|---|---|---|
| Dirty Read | 커밋되지 않은 데이터를 읽음 | TX1이 수정 중인 값을 TX2가 읽은 뒤 TX1이 롤백 |
| Non-Repeatable Read | 같은 행을 두 번 읽었더니 값이 달라짐 | 두 읽기 사이에 다른 트랜잭션이 수정 후 커밋 |
| Phantom Read | 같은 조건의 행 집합이 달라짐 | 범위 조회 사이에 다른 트랜잭션이 새 행을 삽입 후 커밋 |
세 현상은 다른 트랜잭션이 데이터를 변경하는 동안 현재 트랜잭션이 실행될 때 발생합니다. Dirty Read는 커밋하지 않은 값을 읽는 현상입니다. 나머지 둘은 두 번째 읽기에서 데이터가 달라지는 현상입니다. 격리 수준은 이 중 어떤 현상을 허용할지 정합니다.
격리 수준 네 단계
SQL 표준(ISO/IEC 9075)은 격리 수준을 네 단계로 정의하고, 각 단계에서 어떤 이상 현상까지 허용할지를 규정합니다. 위로 갈수록 동시성이 높고, 아래로 갈수록 정합성이 강해집니다.
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
| Read Uncommitted | 가능 | 가능 | 가능 |
| Read Committed | 방지 | 가능 | 가능 |
| Repeatable Read | 방지 | 방지 | 가능 |
| Serializable | 방지 | 방지 | 방지 |
표는 표준이 보장하는 최소 기준이고, 실제 동작은 데이터베이스마다 다릅니다. 기본값과 구현 차이를 알아 두는 편이 실무에서 더 중요합니다.
- Read Uncommitted는 정합성을 거의 보장하지 못해 실무에서 잘 쓰지 않습니다.
- Read Committed는 PostgreSQL의 기본값이며, 각 쿼리 실행 시점의 커밋된 데이터만 봅니다(PostgreSQL 공식 문서 기준).
- Repeatable Read는 MySQL InnoDB의 기본값이며, 첫 일관 읽기에서 만든 스냅샷을 유지합니다(MySQL 8.4 공식 문서).
- Serializable은 직렬 실행처럼 동작하지만 비용이 크므로, 꼭 필요한 핵심 로직에만 씁니다.
한 가지 주의할 점은 표준과 구현의 어긋남입니다. MySQL InnoDB는 Repeatable Read에서 갭 락(Gap Lock)을 함께 써서, 표준이 허용하는 Phantom Read까지 상당 부분 막습니다.
락 기반 제어
격리성을 지키는 고전적 방법은 락(Lock)입니다. 락은 데이터에 대한 접근 권한을 트랜잭션에 부여하고, 충돌하는 접근은 대기시킵니다.
| 락 유형 | 획득 시점 | 호환성 |
|---|---|---|
| 공유 락(S-Lock, Shared) | 읽기 시 획득 | 다른 공유 락과 호환 |
| 배타 락(X-Lock, Exclusive) | 쓰기 시 획득 | 어떤 락과도 비호환 |
여러 트랜잭션이 같은 행을 동시에 읽는 것은 안전하므로 공유 락끼리는 함께 걸 수 있습니다. 반면 쓰기는 배타 락을 잡아 다른 모든 접근을 막습니다. 락을 거는 단위(범위)도 동시성에 직접 영향을 줍니다.
- 행 레벨 락은 특정 행만 잠가 동시성이 높고, InnoDB와 PostgreSQL의 기본입니다.
- 테이블 레벨 락은 테이블 전체를 잠가 동시성이 낮으며, MyISAM 같은 레거시 엔진에서 씁니다.
- 갭 락은 인덱스 레코드 사이의 간격을 잠가, 범위 조회 중의 삽입을 막습니다.
락을 쓰면 서로의 락을 기다리며 멈추는 교착 상태(Deadlock)가 생길 수 있습니다. 두 트랜잭션이 상대가 쥔 락을 요청하며 동시에 대기하면 어느 쪽도 진행하지 못합니다.
flowchart LR
TX1["트랜잭션 1<br/>행 A 잠금 보유"] -->|"행 B 요청, 대기"| TX2["트랜잭션 2<br/>행 B 잠금 보유"]
TX2 -->|"행 A 요청, 대기"| TX1
교착 상태는 보통 세 가지 방식으로 처리합니다. 타임아웃이 지나면 한쪽 트랜잭션을 롤백할 수 있습니다. 대기 그래프(Wait-for Graph)에서 사이클을 찾아 한쪽을 강제 종료할 수도 있습니다. 예방하려면 항상 기본 키 오름차순으로 접근하는 등 락 획득 순서를 통일합니다.
MVCC
읽기마다 락을 걸면 읽기와 쓰기가 서로를 기다립니다. MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어)는 이 대기를 없애려고 데이터의 여러 버전을 함께 보관합니다.
MVCC의 일반 읽기는 쓰기와 충돌하는 락을 잡지 않고, 스냅샷에 보이는 행 버전을 고릅니다. 스냅샷의 수명은 격리 수준에 따라 다릅니다. PostgreSQL Read Committed는 문장마다 새 스냅샷을 얻습니다.
Repeatable Read는 첫 비트랜잭션 제어 문장이 시작될 때의 스냅샷을 이어서 사용합니다(PostgreSQL 공식 문서).
PostgreSQL은 각 행에 생성한 트랜잭션 번호(xmin)와 무효화한 트랜잭션 번호(xmax)를 기록해 버전을 구분합니다. 트랜잭션은 자신의 스냅샷에서 보이는 버전만 읽습니다.
| xmin | xmax | data | 설명 |
|---|---|---|---|
| 100 | (없음) | name='Kim' | 트랜잭션 100이 삽입한 버전, 현재 유효 |
| 100 | 200 | name='Kim' | 트랜잭션 200이 수정해 무효화한 버전 |
| 200 | (없음) | name='Park' | 트랜잭션 200이 만든 새 버전 |
예시에서 트랜잭션 150의 스냅샷이 100의 커밋을 포함하고 200을 진행 중으로 기록했다면 name='Kim' 버전이 보입니다. 실제 가시성은 트랜잭션 번호의 단순한 대소 비교로 정하지 않습니다. 스냅샷 경계와 진행 중 트랜잭션 집합, 각 트랜잭션의 커밋 상태를 함께 확인합니다.
MySQL InnoDB는 버전을 행에 직접 붙이지 않고 Undo Log로 관리합니다. 현재 행이 스냅샷 이후에 수정됐다면 Undo Log를 거슬러 올라가 보이는 버전을 복원합니다. PostgreSQL과 InnoDB 모두 일반적인 일관 읽기가 쓰기를 막지 않지만, SELECT FOR UPDATE 같은 잠금 읽기는 예외입니다.
| 특성 | MVCC | 락 기반 |
|---|---|---|
| 읽기-쓰기 충돌 | 없음(버전 분리) | 대기 발생 |
| 동시성 | 높음 | 낮음 |
| 저장 공간 | 더 많음(다중 버전) | 적음 |
| 가비지 컬렉션 | 필요(예: PostgreSQL VACUUM) | 불필요 |
| 구현 복잡도 | 높음 | 낮음 |
MVCC는 동시성을 높이지만 오래된 버전이 쌓입니다. PostgreSQL은 더 이상 보이지 않는 죽은 버전을 VACUUM으로 정리해 저장 공간과 성능을 유지합니다. 읽기 대기를 줄이는 대신 버전 정리 작업이 필요합니다.
정리
트랜잭션은 ACID로 여러 연산을 한 단위로 묶어 부분 실패가 데이터를 어긋나게 두지 않도록 막습니다. 격리 수준은 동시 실행에서 허용할 이상 현상과 재시도 비용을 결정합니다. 락은 충돌하는 접근을 대기시키지만 교착 상태를 만들 수 있습니다.
MVCC는 스냅샷에 보이는 행 버전을 골라 일반 읽기와 쓰기의 충돌을 줄입니다. PostgreSQL Read Committed는 문장마다, Repeatable Read는 트랜잭션의 첫 비제어 문장부터 스냅샷을 유지합니다. 버전을 보관한 대가는 VACUUM이나 Undo Log 정리 같은 운영 작업으로 나타납니다.