한 트랜잭션이 주문 상태를 바꾸는 동안 다른 트랜잭션이 같은 행을 읽는다고 하자. 읽을 때마다 쓰기가 끝날 때까지 기다리게 만들면 일관된 값은 얻기 쉽지만 조회가 자주 막힌다.
MVCC(Multi-Version Concurrency Control: 같은 데이터의 여러 버전을 유지해 트랜잭션마다 자신에게 보이는 버전을 고르는 동시성 제어 방식)는 읽기와 쓰기가 서로 덜 기다리게 한다. 단순히 복사본을 많이 만드는 기능이 아니라 어떤 버전이 누구에게 보이는지를 정하는 규칙이다.
UPDATE는 기존 값을 곧바로 없애지 않을 수 있다
주문 7의 상태가 pending에서 paid로 바뀌는 모습을 단순화해 보자. MVCC를 사용하는 데이터베이스는 이전 버전을 남기고 새 버전을 만든 뒤 각 버전에 트랜잭션 정보를 붙일 수 있다.
버전 V1: status = pending, 만든 트랜잭션 = 10, 끝낸 트랜잭션 = 20
버전 V2: status = paid, 만든 트랜잭션 = 20, 끝낸 트랜잭션 = 없음
트랜잭션 20이 아직 커밋되지 않았다면 다른 트랜잭션에는 V2가 보이지 않을 수 있다. 이미 이전 시점의 읽기 기준을 가진 트랜잭션은 20이 커밋한 뒤에도 V1을 볼 수 있다.
행을 물리적으로 어떻게 저장하고 삭제 정보를 어디에 두는지는 데이터베이스마다 다르다. 핵심은 논리적 한 행에 시간에 따른 버전이 있고 가시성 규칙이 읽을 버전을 고른다는 점이다.
스냅샷은 이번 읽기가 볼 수 있는 변경 범위를 정한다
스냅샷(snapshot: 한 트랜잭션이나 문장이 읽을 수 있는 커밋 상태의 기준)은 보통 현재까지 완료된 트랜잭션과 아직 진행 중인 트랜잭션 정보를 담는다.
읽기 스냅샷 생성 시점
이미 커밋: 1~19
진행 중: 20, 21
행 버전 V2를 만든 트랜잭션 20이 아직 진행 중
→ V2는 보이지 않음
→ 이전에 커밋된 V1을 읽음
같은 시점의 스냅샷을 유지하면 긴 조회가 중간에 커밋된 새 값 때문에 앞뒤가 달라지는 일을 줄일 수 있다. 반면 문장마다 새 스냅샷을 얻는 격리 수준에서는 다음 SELECT가 더 최신 커밋을 볼 수 있다.
MVCC라는 이름만으로 정확한 읽기 결과를 정할 수 없다. 데이터베이스의 격리 수준과 스냅샷 생성 시점을 함께 알아야 한다.
읽기가 쓰기를 막지 않아도 쓰기끼리는 충돌한다
이전 버전을 읽을 수 있으므로 일반 조회가 쓰기 잠금을 기다리지 않을 수 있다. 그러나 두 트랜잭션이 같은 행을 동시에 바꾸면 새 최종 상태를 하나로 정해야 한다.
트랜잭션 A: 재고 10을 읽고 9로 변경
트랜잭션 B: 재고 10을 읽고 9로 변경
둘 다 성공하면 실제 두 번 감소해야 할 재고가 한 번만 감소하는 갱신 손실이 생길 수 있다. 데이터베이스는 쓰기 잠금, 버전 충돌 감지, 직렬화 실패 같은 방식으로 한쪽을 기다리게 하거나 실패시킬 수 있다.
MVCC는 잠금을 없애는 기술이 아니다. 읽기와 쓰기의 충돌 일부를 버전 선택으로 바꾸고 실제 쓰기 충돌은 다른 규칙으로 처리한다.
격리 수준에 따라 같은 트랜잭션의 읽기도 달라진다
Read Committed(읽기 확정: 각 문장이 시작할 때까지 커밋된 데이터만 읽도록 하는 격리 수준)에서는 같은 트랜잭션 안의 두 SELECT가 서로 다른 최신 커밋을 볼 수 있다.
Repeatable Read(반복 읽기: 트랜잭션 동안 같은 읽기 기준을 유지해 이미 읽은 값이 바뀌어 보이지 않게 하는 격리 수준)는 더 오래 같은 스냅샷을 사용할 수 있다.
Serializable(직렬화 가능: 동시에 실행된 결과가 어떤 순서로 하나씩 실행한 결과와 같도록 만드는 가장 강한 격리 목표)은 MVCC만으로 자동 보장되지 않을 수 있다. 위험한 읽기·쓰기 관계를 추적해 한 트랜잭션을 실패시키기도 한다.
이 이름들의 정확한 동작은 데이터베이스마다 차이가 있다. 문서의 이름만 외우기보다 스냅샷이 언제 생기고 어떤 충돌을 대기 또는 실패로 처리하는지 확인한다.
오래 열린 트랜잭션은 오래된 버전 정리를 막는다
어떤 트랜잭션이 오래된 스냅샷을 계속 사용하면 그 트랜잭션이 볼 수 있는 이전 버전을 지울 수 없다. 짧은 UPDATE가 많이 일어나는 동안 긴 조회나 방치된 트랜잭션이 있으면 오래된 버전이 쌓인다.
긴 트랜잭션 T1: 오래된 스냅샷 유지
그동안 V2, V3, V4 ... 생성
T1이 끝날 때까지 V1을 보존해야 할 수 있음
vacuum(정리 작업: 더 이상 어떤 스냅샷에서도 필요하지 않은 행 버전을 찾아 공간을 재사용하거나 정리하는 과정)은 가시성 경계를 확인한 뒤 오래된 버전을 치운다. 단순히 시간이 오래됐다고 삭제할 수 없다.
버전 누적은 저장 공간뿐 아니라 조회해야 할 버전 수와 인덱스 효율에도 영향을 줄 수 있다. 운영에서는 가장 오래 열린 트랜잭션과 정리 진행 상태를 함께 본다.
스냅샷 읽기는 외부 세계까지 묶어 주지 않는다
데이터베이스 트랜잭션 안에서 일관된 스냅샷을 읽어도 외부 API 호출, 파일, 메시지 큐의 상태까지 같은 시점으로 고정되는 것은 아니다.
예를 들어 데이터베이스에서 주문 상태를 읽은 뒤 외부 결제 시스템을 호출하는 동안 주문이 취소될 수 있다. 데이터베이스 내부 가시성과 시스템 전체의 업무 상태를 구분하고 상태 전이 검증이나 이벤트 기록을 추가해야 한다.
이해 확인 질문과 답변
MVCC를 사용하면 읽기는 항상 잠금을 기다리지 않을까?
일반적인 스냅샷 읽기는 이전 커밋 버전을 골라 쓰기와 덜 충돌할 수 있다. 하지만 명시적 잠금 읽기, 스키마 변경, 필요한 버전 정리 같은 다른 이유로 기다릴 수 있다. 데이터베이스별 규칙을 확인해야 한다.
UPDATE가 이전 행을 바로 지우지 않는 이유는 무엇일까?
이미 시작한 스냅샷이 이전 값을 읽어야 할 수 있기 때문이다. 새 버전을 만들고 가시성 규칙으로 선택하면 읽기와 쓰기가 같은 물리 값을 두고 덜 기다린다.
MVCC가 있으면 쓰기 충돌도 자동으로 해결될까?
아니다. 같은 행을 바꾸는 두 작업은 최종 상태를 정해야 한다. 잠금, 버전 조건, 직렬화 실패와 재시도 같은 규칙이 여전히 필요하다.
오래 열린 읽기 전용 트랜잭션도 문제가 될 수 있을까?
그럴 수 있다. 오래된 스냅샷이 이전 행 버전을 계속 필요로 하면 정리 작업이 회수하지 못한다. 버전 누적과 저장 공간, 조회 성능에 영향을 줄 수 있다.
AI에게 이렇게 요청할 수 있다
주문 상태가 pending에서 paid로 바뀔 때 V1과 V2 행 버전, 두 트랜잭션의 스냅샷, 커밋 시점을 시간표로 보여 줘. Read Committed와 Repeatable Read에서 두 번째 SELECT가 무엇을 보는지, 오래 열린 트랜잭션이 vacuum을 막는 경우도 설명해 줘.
MVCC는 잠금을 없애는 기능이 아니다. 읽을 수 있는 과거 버전을 남기고 스냅샷마다 가시성을 판단해 읽기와 쓰기의 충돌 일부를 버전 선택으로 바꾼다.
'배움과 성장 > 백엔드·데이터' 카테고리의 다른 글
| 같은 요청이 다시 와도 결과가 한 번만 바뀌게 하려면? 멱등성과 멱등성 키 (0) | 2026.09.20 |
|---|---|
| LSM 트리는 쓰기를 어떻게 모아 디스크에 정리할까? SSTable과 컴팩션 (0) | 2026.09.19 |
| WAL은 데이터 페이지보다 로그를 왜 먼저 쓸까? 변경 기록과 체크포인트 (0) | 2026.09.17 |
| 여러 복제본은 언제 같은 값을 보여야 할까? 복제와 일관성 (0) | 2026.09.09 |
| 메시지 큐와 재시도는 오래 걸리는 작업을 어떻게 나눌까? 확인 응답과 중복 처리 (0) | 2026.09.08 |
댓글