데이터베이스 트랜잭션과 MVCC
ACID, 격리 수준, 락, 그리고 락 없이 동시성을 높이는 MVCC의 동작 원리
Contents
트랜잭션은 여러 연산을 한 단위로 묶고, 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에 남겨 두기 때문에, 롤백 시 그 값으로 복구할 수 있습니다.
ACID 중 원자성, 일관성, 지속성은 대체로 자동으로 보장됩니다. 문제는 격리성입니다. 격리성을 완벽히 지키려면 트랜잭션을 거의 순서대로 실행해야 하고, 그러면 동시 처리 성능이 크게 떨어집니다.
동시성이 만드는 읽기 이상
여러 트랜잭션이 같은 데이터를 동시에 다루면 읽기 결과가 어긋날 수 있습니다. 대표적으로 세 가지 이상 현상이 있습니다.
| 문제 | 설명 | 발생 상황 |
|---|---|---|
| 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 공식 문서 기준).
- Serializable은 직렬 실행처럼 동작하지만 비용이 크므로, 꼭 필요한 핵심 로직에만 씁니다.
한 가지 주의할 점은 표준과 구현의 어긋남입니다. MySQL InnoDB는 Repeatable Read에서 갭 락(Gap Lock)을 함께 써서, 표준이 허용하는 Phantom Read까지 상당 부분 막습니다.
락 기반 제어
격리성을 지키는 고전적 방법은 락(Lock)입니다. 락은 데이터에 대한 접근 권한을 트랜잭션에 부여하고, 충돌하는 접근은 대기시킵니다.
| 락 유형 | 획득 시점 | 호환성 |
|---|---|---|
| 공유 락(S-Lock, Shared) | 읽기 시 획득 | 다른 공유 락과 호환 |
| 배타 락(X-Lock, Exclusive) | 쓰기 시 획득 | 어떤 락과도 비호환 |
여러 트랜잭션이 같은 행을 동시에 읽는 것은 안전하므로 공유 락끼리는 함께 걸 수 있습니다. 반면 쓰기는 배타 락을 잡아 다른 모든 접근을 막습니다. 락을 거는 단위(범위)도 동시성에 직접 영향을 줍니다.
- 행 레벨 락은 특정 행만 잠가 동시성이 높고, InnoDB와 PostgreSQL의 기본입니다.
- 테이블 레벨 락은 테이블 전체를 잠가 동시성이 낮으며, MyISAM 같은 레거시 엔진에서 씁니다.
- 갭 락은 인덱스 레코드 사이의 간격을 잠가, 범위 조회 중의 삽입을 막습니다.
락을 쓰면 서로의 락을 기다리며 멈추는 교착 상태(Deadlock)가 생길 수 있습니다. 두 트랜잭션이 상대가 쥔 락을 요청하며 동시에 대기하면 어느 쪽도 진행하지 못합니다.
교착 상태는 보통 세 가지 방식으로 다룹니다. 타임아웃으로 일정 시간 뒤 한쪽을 롤백하거나, 대기 그래프(Wait-for Graph)에서 사이클을 찾아 한쪽을 강제 종료합니다. 예방책으로는 락 획득 순서를 통일해(예: 항상 기본 키 오름차순 접근) 사이클 자체를 만들지 않는 방법이 있습니다.
MVCC
읽기마다 락을 걸면 읽기와 쓰기가 서로를 기다립니다. MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어)는 이 대기를 없애려고 데이터의 여러 버전을 함께 보관합니다.
핵심 아이디어는 두 가지입니다. 읽기는 락을 걸지 않으므로 읽기와 쓰기가 충돌하지 않습니다. 각 트랜잭션은 자신이 시작한 시점의 스냅샷만 보기 때문에, 도중에 다른 트랜잭션이 값을 바꿔도 영향을 받지 않습니다.
PostgreSQL은 각 행에 이를 만든 트랜잭션 번호(xmin)와 무효화한 트랜잭션 번호(xmax)를 붙여 버전을 구분합니다. 트랜잭션은 자신의 스냅샷에서 보이는 버전만 골라 읽습니다.
| xmin | xmax | data | 설명 |
|---|---|---|---|
| 100 | (없음) | name='Kim' | 트랜잭션 100이 삽입한 버전, 현재 유효 |
| 100 | 200 | name='Kim' | 트랜잭션 200이 수정해 무효화한 버전 |
| 200 | (없음) | name='Park' | 트랜잭션 200이 만든 새 버전 |
트랜잭션 150이 이 행을 읽으면 xmin=100은 이미 커밋된 버전이고, xmax=200은 트랜잭션 150 기준으로 아직 커밋되지 않았습니다. 따라서 스냅샷에는 name='Kim' 버전이 보입니다.
MySQL InnoDB는 버전을 행에 직접 붙이지 않고 Undo Log로 관리합니다. 현재 행이 내 트랜잭션 이후에 수정됐다면, Undo Log를 거슬러 올라가 내 시점의 버전을 복원해 읽습니다. 두 방식 모두 결과적으로 읽기에 락이 필요 없다는 점은 같습니다.
| 특성 | MVCC | 락 기반 |
|---|---|---|
| 읽기-쓰기 충돌 | 없음(버전 분리) | 대기 발생 |
| 동시성 | 높음 | 낮음 |
| 저장 공간 | 더 많음(다중 버전) | 적음 |
| 가비지 컬렉션 | 필요(예: PostgreSQL VACUUM) | 불필요 |
| 구현 복잡도 | 높음 | 낮음 |
MVCC는 동시성을 크게 높이는 대신 옛 버전이 쌓이는 비용을 떠안습니다. PostgreSQL은 더 이상 보이지 않는 죽은 버전을 VACUUM으로 청소해야 저장 공간과 성능을 유지합니다. 즉 읽기 대기를 줄인 만큼 정리 작업이라는 운영 부담이 생깁니다.
정리
트랜잭션은 ACID로 여러 연산을 한 단위로 묶어, 부분 실패가 데이터를 어긋나게 두지 않도록 막습니다. 네 가지 성질 중 격리성만이 동시성과 성능을 두고 타협이 필요하며, 그래서 SQL 표준은 격리 수준을 네 단계로 나눠 어디까지 이상 현상을 허용할지 고르게 했습니다. 락은 충돌하는 접근을 대기시켜 격리성을 지키지만 교착 상태와 읽기 대기를 만듭니다. MVCC는 읽기에 락을 걸지 않고 버전을 나눠 동시성을 높이는 대신, PostgreSQL의 VACUUM 같은 정리 작업을 운영 비용으로 남깁니다. 격리 수준과 동시성 제어 방식을 데이터베이스 구현 기준으로 함께 이해하면, 정합성과 성능 사이에서 더 나은 기본값을 고를 수 있습니다.