트랜잭션과 격리 수준
한 계좌에서 돈을 빼고 다른 계좌에 넣는 도중 서버가 죽으면 어떻게 될까요. 앞 계좌에서는 돈이 빠졌는데 뒤 계좌에는 들어가지 않았다면 데이터는 즉시 망가집니다.
트랜잭션은 여러 연산을 하나의 논리적인 작업 단위로 묶어 전부 성공하거나 전부 실패하게 만드는 장치이고, 이런 성질을 원자성·일관성·격리성·지속성이라는 ACID로 정리합니다.
그런데 여러 요청이 동시에 실행되기 시작하면 문제가 하나 더 생깁니다. 한 트랜잭션이 아직 끝나지 않은 다른 트랜잭션의 변경을 볼 수 있는지, 같은 데이터를 두 번 읽었을 때 같은 값이 보여야 하는지, 중간에 새로 추가된 행까지 보여야 하는지를 정해야 합니다. 이것이 격리 수준(Isolation Level)의 문제입니다.
여기서 복잡한 점은 SQL 표준의 격리 수준 이름과 실제 데이터베이스가 이를 구현하는 방식이 정확히 일치하지 않는다는 것입니다. Lock을 사용하는지, MVCC와 snapshot을 사용하는지에 따라 같은 이름의 격리 수준에서도 실제 동작이 달라질 수 있습니다.
트랜잭션이 실패했을 때 변경을 어떻게 되돌리는지부터 시작해, 여러 트랜잭션을 동시에 실행하면서도 일관성을 유지하는 방법을 살펴봅니다. 이어서 격리 수준을 높일수록 어떤 이상 현상을 막을 수 있고 그 대신 동시성과 비용에서 무엇을 지불하는지까지 봅니다. 마지막에는 MVCC와 snapshot이 어떻게 읽기와 쓰기를 동시에 진행하게 만드는지도 연결해서 살펴봅니다.
1장. 원자성이 필요한 이유
계좌 이체를 생각해 봅니다.
1. A 계좌에서 1만원 차감
2. B 계좌에 1만원 추가1번과 2번 사이에 정전이 나면 돈이 사라집니다. 둘은 하나로 묶여야 합니다. 전부 되거나 전부 안 되거나, 이것이 트랜잭션입니다.
이런 성질을 정리한 것이 ACID인데, 정의를 나열하는 것보다 어기면 무슨 일이 생기는지로 보는 편이 낫습니다.
| 뜻 | 어기면 | |
|---|---|---|
| Atomicity 원자성 | 전부 되거나 전부 안 됨 | 돈이 사라짐 |
| Consistency 일관성 | 제약 조건이 유지됨 | 잔액이 음수가 됨 |
| Isolation 격리성 | 동시 실행이 순차 실행처럼 보임 | 남의 미완성 작업을 봄 |
| Durability 지속성 | 커밋되면 장애가 나도 남음 | 커밋했는데 사라짐 |
2장. WAL, 로그 우선 기록
데이터 파일을 직접 고치면 문제가 생깁니다. 여러 페이지를 고치는 중에 죽으면 반쯤 고쳐진 상태가 남습니다.
WAL의 규칙은 한 줄입니다.
데이터 파일을 고치기 전에, 무엇을 고칠지 로그에 먼저 쓴다.
순서는 이렇습니다.
1. "A를 100에서 90으로 바꾼다"를 로그에 기록 → fsync
2. 커밋 기록을 로그에 남김 → 여기서 커밋 완료
3. 실제 데이터 파일 반영은 나중에 → 체크포인트개념적으로는 commit에 필요한 WAL record가 durable하게 기록되면 data page 자체는 나중에 flush할 수 있고, crash recovery에서 WAL을 이용해 필요한 변경을 재현합니다. 다만 DB마다 recovery 구조는 다릅니다. 모든 WAL DB가 같은 로그에서 redo와 undo를 똑같이 수행한다고 일반화하면 안 됩니다. PostgreSQL은 MVCC와 WAL을 결합하고, 다른 DB는 별도의 undo/rollback 구조를 둘 수 있습니다.
빨라지는 이유. 로그를 한 번 더 쓰는데 왜 빨라지느냐는 의문이 자연스럽습니다. 답은 쓰기의 성격에 있습니다.
데이터 파일 갱신 여기저기 흩어진 페이지를 건드림 → 랜덤 쓰기
로그 기록 파일 끝에 이어 붙임 → 순차 쓰기디스크에서 순차 쓰기가 훨씬 빠릅니다. 쓰기 횟수가 늘어도 전체가 빨라집니다.
3장. 격리 수준
동시 트랜잭션을 serial execution과 동등하게 보이도록 강하게 제한하면 anomaly는 줄지만 lock 대기나 abort/retry 같은 비용이 생길 수 있습니다. 그래서 DB는 서로 다른 isolation level을 제공합니다.
먼저 이상 현상 세 가지입니다.
| 현상 | 상황 |
|---|---|
| Dirty Read | 커밋 안 된 남의 변경을 읽음 |
| Non-Repeatable Read | 같은 행을 두 번 읽었는데 값이 달라짐 |
| Phantom Read | 같은 조건으로 두 번 조회했는데 행 수가 달라짐 |
두 번째와 세 번째의 차이가 헷갈립니다. 값이 바뀌는 것이 Non-Repeatable이고 행 수가 바뀌는 것이 Phantom입니다.
그리고 표준 격리 수준 네 단계입니다.
| 수준 | Dirty | Non-Repeatable | Phantom |
|---|---|---|---|
| READ UNCOMMITTED | 발생 | 발생 | 발생 |
| READ COMMITTED | 방지 | 발생 | 발생 |
| REPEATABLE READ | 방지 | 방지 | 발생 (주1) |
| SERIALIZABLE | 방지 | 방지 | 방지 |
이 표는 SQL 표준이 요구하는 최소 보장입니다. 실제 DB는 더 강하게 동작할 수 있습니다. 예를 들어 PostgreSQL의 REPEATABLE READ는 snapshot isolation 계열 구현이라 phantom read도 발생하지 않지만 serialization anomaly는 여전히 가능합니다. MySQL InnoDB 역시 MVCC와 locking read/gap lock 동작 때문에 표준 표만 보고 실제 현상을 단정하면 안 됩니다.
함정이 되는 지점. 격리 수준을 이야기할 때는 표준상 허용되는 현상인지, 특정 DB 구현에서 실제 발생하는 현상인지를 구분해야 합니다.
기본값도 다릅니다.
PostgreSQL, Oracle READ COMMITTED
MySQL InnoDB REPEATABLE READPostgreSQL에서는 READ UNCOMMITTED를 요청해도 READ COMMITTED처럼 동작하고, REPEATABLE READ는 표준 최소 보장보다 강합니다. SERIALIZABLE은 serialization anomaly를 막기 위해 충돌하는 transaction을 실패시킬 수 있으므로 애플리케이션이 transaction 전체 retry를 준비해야 합니다.
4장. MVCC
격리를 락으로만 구현하면 읽는 동안 아무도 못 씁니다. 읽기가 많은 시스템에서는 치명적입니다.
MVCC는 데이터를 덮어쓰지 않고 새 버전을 만듭니다. 각 트랜잭션은 자기가 시작한 시점의 스냅샷을 봅니다.
T1 시작 (스냅샷 시점 100)
│
│ T2가 값을 수정하고 커밋 (버전 101 생성)
│
T1이 읽음 → 여전히 버전 100을 봄MVCC에서는 일반적인 snapshot read가 writer와 직접 충돌하는 일을 크게 줄입니다. 다만 DB와 query 종류에 따라 lock을 전혀 쓰지 않는다는 뜻은 아닙니다. PostgreSQL, MySQL InnoDB, Oracle 모두 여러 버전을 이용하는 concurrency-control 기법을 사용합니다.
대가는 오래된 버전을 정리해야 한다는 것입니다. PostgreSQL의 VACUUM이 그 일을 합니다.
5장. 색인 중 검색이 되는 이유
SQLite의 기본 rollback-journal 모드에서는 읽기와 쓰기가 WAL 모드보다 더 강하게 서로 제약할 수 있습니다. 긴 write transaction과 lock 상태에 따라 검색이 지연될 수 있습니다.
WAL 모드를 켜면 달라집니다.
PRAGMA journal_mode = WAL;
쓰기 WAL 파일에 append. 원본 DB 파일은 그대로
읽기 원본 DB + WAL을 조합해서 봄
읽기가 쓰기를 막지 않고, 쓰기가 읽기를 막지 않는다
다만 쓰기는 여전히 하나만 가능하다온디바이스 검색에서 이것이 결정적입니다. 사용자가 폴더를 색인하는 동안에도 이미 색인된 문서는 검색할 수 있습니다. 색인이 끝날 때까지 앱이 먹통이 되지 않습니다.
증분 인덱싱과 원자성. 전체 재색인은 비쌉니다. 그래서 바뀐 것만 다시 처리합니다.
파일 해시 비교
├─ 같음 → 건너뜀
├─ 다름 → 재색인
└─ 없음 → 삭제 (orphan 정리)여기서 트랜잭션이 필요합니다. 청크 삭제와 재삽입이 원자적이어야 중간에 죽었을 때 반쪽짜리 문서가 남지 않습니다.
BEGIN;
DELETE FROM chunks WHERE doc_id = ?;
INSERT INTO chunks ...;
UPDATE docs SET hash = ?, indexed_at = ?;
COMMIT;
이것을 묶지 않으면 삭제만 되고 삽입 전에 죽은 문서가 검색에서 통째로 사라집니다. 재색인을 돌리기 전까지 알아채기도 어렵습니다.
벡터 DB와 트랜잭션. 전용 벡터 DB의 consistency와 atomic operation 범위는 제품마다 다릅니다. 모두를 '최종 일관성'으로 묶으면 부정확합니다. 관계형 DB 확장으로 vector를 저장하면 같은 transaction 안에서 metadata와 vector row를 함께 갱신하기 쉽다는 장점이 있지만, 전용 vector DB도 write ordering이나 consistency level을 제공하는 제품이 있습니다.
따라서 선택 기준은 단순히 관계형 = 일관성, 벡터 DB = eventual이 아니라 어떤 변경 단위를 atomic하게 묶어야 하는지, 읽기 직후 새 데이터가 보여야 하는지, 처리량과 규모가 어느 정도인지입니다.
6장. 정리
WAL의 핵심은 복구에 필요한 로그를 data page보다 먼저 durable하게 남겨 crash recovery와 commit을 분리하는 것입니다.
격리 수준은 SQL 표준의 최소 보장과 실제 DB 구현을 함께 봐야 합니다. PostgreSQL REPEATABLE READ처럼 표준상 허용되는 phantom을 실제 구현에서는 막는 경우가 대표적입니다.
MVCC는 여러 version으로 reader/writer 충돌을 줄이고, SQLite WAL mode에서는 reader와 writer가 상당 부분 동시에 진행할 수 있습니다. 다만 SQLite는 WAL mode에서도 writer가 동시에 하나뿐이고 긴 reader는 checkpoint 진행을 막아 WAL이 커지는 checkpoint starvation을 만들 수 있습니다.
용어 정리
| 용어 | 한 줄 뜻 |
|---|---|
| 트랜잭션 | 전부 되거나 전부 안 되어야 하는 작업 묶음 |
| ACID | 원자성, 일관성, 격리성, 지속성 |
| WAL | 데이터 변경 전에 로그를 먼저 쓰는 기법 |
| fsync | 버퍼 내용을 디스크에 실제로 기록하도록 강제하는 호출 |
| redo / undo | 커밋된 변경 재적용 / 미완 변경 되돌리기 |
| 체크포인트 | 로그 내용을 데이터 파일에 반영하는 시점 |
| Dirty Read | 커밋 안 된 데이터를 읽는 것 |
| Non-Repeatable Read | 같은 행을 두 번 읽었는데 값이 달라지는 것 |
| Phantom Read | 같은 조건 조회의 결과 행 수가 달라지는 것 |
| MVCC | 여러 버전을 유지해 읽기 락을 없애는 방식 |
| 갭 락 | 인덱스 레코드 사이 구간을 잠가 팬텀을 막는 락 |
| VACUUM | PostgreSQL에서 오래된 버전을 정리하는 작업 |
| Serialization Anomaly | 동시 실행 결과를 어떤 순차 실행 순서로도 설명할 수 없는 현상 |
참고자료
'programming' 카테고리의 다른 글
| 프로세스와 스레드 그리고 GIL (0) | 2023.05.17 |
|---|---|
| HTTP와 스트리밍 (SSE) (0) | 2023.05.10 |
| IO 모델과 비동기 (0) | 2023.03.09 |
| [PY] line_profiler (@profile) (0) | 2023.03.04 |
| [PY] Python 메모리 이모저모 - 1 (0) | 2022.12.17 |
댓글