데이터베이스 트랜잭션 심화 — MVCC·2PL·데드락·WAL·실무 패턴

이 글의 핵심

ACID와 격리 수준을 넘어 MVCC·2PL·데드락 탐지·WAL·운영 패턴까지: 트랜잭션이 실제로 어떻게 직렬 가능성을 보장하고 장애 후에도 복구되는지 정리합니다.

들어가며

트랜잭션은 여러 읽기와 쓰기를 하나의 논리 단위로 묶어 원자성(Atomicity), 일관성(Consistency), 격리성(Isolation), 지속성(Durability), 즉 ACID를 보장하려는 데이터베이스의 핵심 추상화입니다. 애플리케이션에서는 BEGIN과 COMMIT 사이에 SQL을 넣는 것으로 끝나 보이지만, 스토리지 엔진은 동시에 실행되는 다른 트랜잭션과의 간섭을 막고, 장애가 나도 커밋된 결과를 잃지 않기 위해 MVCC, 락 프로토콜, 로그 구조를 조합합니다.

이 글은 ORM 사용법이 아니라 엔진 내부의 동작과 트레이드오프를 다룹니다. 운영 중에 만나는 갱신 손실, 데드락, 테이블 팽창, 커밋 지연 같은 현상은 이 모델을 알면 원인을 훨씬 빨리 좁힐 수 있습니다. 예시는 주로 PostgreSQL과 MySQL InnoDB를 기준으로 합니다.


격리 수준과 이상 현상

SQL 표준은 격리 수준을 “어떤 이상 현상을 허용하는가”로 정의합니다.

이상 현상의미
더티 읽기(dirty read)아직 커밋되지 않은 다른 트랜잭션의 쓰기를 읽음
비반복 읽기(non-repeatable read)같은 행을 두 번 읽었는데 그 사이 다른 트랜잭션의 커밋으로 값이 바뀜
팬텀 읽기(phantom read)같은 조건으로 두 번 조회했는데 행 집합이 달라짐

Read Uncommitted, Read Committed, Repeatable Read, Serializable 순으로 허용하는 이상 현상이 줄어듭니다. 하지만 이 표만으로는 실무에서 가장 자주 문제가 되는 두 가지가 드러나지 않습니다.

하나는 갱신 손실(lost update)입니다. 두 트랜잭션이 같은 재고 값 10을 읽고 각자 1을 빼서 9를 쓰면, 두 번 팔렸는데 재고는 한 번만 줄어듭니다. 다른 하나는 쓰기 왜곡(write skew)입니다. “당직 의사는 최소 한 명 남아야 한다”는 규칙에서, 두 의사가 동시에 “다른 사람이 당직이니 나는 빠져도 된다”고 확인한 뒤 각자 자기 행만 갱신하면, 서로 다른 행을 썼기 때문에 충돌이 감지되지 않고 규칙이 깨집니다. 쓰기 왜곡은 스냅샷 기반 Repeatable Read에서도 막히지 않고, 진짜 Serializable에서만 막힙니다.

제품별 기본값과 실제 의미도 다릅니다. PostgreSQL, Oracle, SQL Server의 기본값은 Read Committed이고, MySQL InnoDB의 기본값은 Repeatable Read입니다. PostgreSQL의 Repeatable Read는 스냅샷 격리라서 표준이 허용하는 팬텀 읽기도 일어나지 않지만, 동시에 갱신된 행을 다시 갱신하려 하면 직렬화 실패(SQLSTATE 40001)로 트랜잭션을 중단합니다. InnoDB의 Repeatable Read는 일반 SELECT는 스냅샷으로 읽고, FOR UPDATE 같은 잠금 읽기와 UPDATE는 최신 커밋 값을 읽으면서 갭 락으로 범위를 잠급니다. 같은 이름의 격리 수준이라도 동작이 이렇게 다르므로, 사용하는 DB의 매뉴얼을 기준으로 판단해야 합니다.


MVCC: 여러 버전으로 읽기와 쓰기를 분리하기

MVCC(Multi-Version Concurrency Control)는 한 논리 행에 대해 여러 버전을 유지해서, 읽는 트랜잭션이 쓰는 트랜잭션을 기다리지 않게 하는 방식입니다. 쓰기는 기존 값을 덮어쓰는 대신 새 버전을 만들고, 읽기는 자기 스냅샷 시점에 보이는 버전을 고릅니다.

스냅샷과 가시성

구현 방식은 엔진마다 다릅니다. PostgreSQL은 각 행 버전(튜플)에 그 버전을 만든 트랜잭션 ID(xmin)와 삭제하거나 갱신한 트랜잭션 ID(xmax)를 기록하고, UPDATE는 새 튜플을 테이블에 추가합니다. InnoDB는 테이블에 최신 버전만 두고, 이전 버전은 undo 로그를 따라 거슬러 올라가 재구성합니다.

어느 쪽이든 읽는 트랜잭션은 스냅샷(스냅샷을 찍은 시점에 진행 중이던 트랜잭션 목록 등)을 기준으로 각 버전을 판정합니다. 버전을 만든 트랜잭션이 스냅샷 이전에 커밋되었고, 그 버전을 지운 트랜잭션이 없거나 아직 스냅샷에 보이지 않으면 그 버전이 보입니다. Read Committed는 문장(statement)마다 새 스냅샷을 찍고, Repeatable Read는 트랜잭션의 첫 문장에서 찍은 스냅샷을 끝까지 씁니다. 그래서 Read Committed에서는 한 트랜잭션 안에서도 같은 행을 두 번 읽으면 값이 달라질 수 있습니다.

PostgreSQL의 인덱스 엔트리에는 가시성 정보가 없습니다. 인덱스로 행을 찾더라도 테이블의 튜플을 확인해야 보이는지 알 수 있고, 인덱스만으로 결과를 만드는 index-only scan은 해당 페이지의 모든 튜플이 보인다고 표시된 가시성 맵(visibility map) 덕분에 가능합니다. VACUUM이 늦어져 가시성 맵이 갱신되지 않으면 index-only scan이 테이블을 계속 방문하게 되어 느려집니다.

정리(VACUUM, purge)와 장시간 트랜잭션

오래된 버전은 더 이상 어떤 스냅샷에도 보이지 않게 된 뒤에야 지울 수 있습니다. PostgreSQL은 VACUUM(보통 autovacuum)이 죽은 튜플을 회수하고, InnoDB는 purge 스레드가 더 이상 필요 없는 undo 로그를 지웁니다.

여기서 운영 문제가 생깁니다. 트랜잭션 하나가 몇 시간 동안 열려 있으면, 그 트랜잭션의 스냅샷이 볼 수 있는 모든 버전을 지울 수 없습니다. 그 사이 다른 테이블에서 일어난 갱신의 죽은 튜플도 쌓이므로, 전혀 관계없어 보이는 테이블이 팽창하고 쿼리가 느려집니다. 배치 작업이 연결을 연 채로 BEGIN만 하고 멈춰 있거나, 애플리케이션이 트랜잭션 안에서 사용자 입력을 기다리는 경우가 흔한 원인입니다. PostgreSQL에서는 pg_stat_activity에서 state = 'idle in transaction'이고 xact_start가 오래된 세션을 찾고, idle_in_transaction_session_timeout으로 이런 세션을 자동으로 끊을 수 있습니다.

MVCC만으로 해결되지 않는 것

MVCC는 읽기와 쓰기가 서로 막지 않게 할 뿐, 쓰기끼리의 충돌은 여전히 락으로 처리합니다. 두 트랜잭션이 같은 행을 UPDATE하면 두 번째 트랜잭션은 첫 번째가 커밋하거나 롤백할 때까지 기다립니다. 유니크 제약 검사, SELECT ... FOR UPDATE, 외래 키 검사도 락을 사용합니다. “MVCC라서 락이 없다”고 생각하면 데드락이나 대기 시간 문제를 잘못 진단하기 쉽습니다.

Serializable Snapshot Isolation

PostgreSQL의 SERIALIZABLE은 스냅샷 격리 위에 SSI(Serializable Snapshot Isolation)를 얹은 구현입니다. 트랜잭션들이 서로 읽고 쓴 데이터 사이의 읽기-쓰기 의존(rw-antidependency)을 추적하다가, 직렬 실행으로는 나올 수 없는 패턴(의존의 위험한 구조)이 생기면 관련 트랜잭션 하나를 40001 오류로 중단합니다. 앞의 쓰기 왜곡을 막아 주는 대신 재시도가 늘어나므로, 이 격리 수준을 쓰는 애플리케이션은 40001을 받으면 트랜잭션 전체를 다시 실행하는 경로를 반드시 가져야 합니다. 판정은 보수적이라 실제로는 문제없는 트랜잭션이 중단되는 경우(false positive)도 있습니다.

MySQL InnoDB의 SERIALIZABLE은 방식이 다릅니다. 자동 커밋이 꺼진 상태에서 일반 SELECT를 공유 잠금 읽기(FOR SHARE)로 바꿔 실행하므로, 충돌을 사후에 감지하는 대신 락 대기로 순서를 강제합니다.


2단계 락킹(2PL)

2PL(Two-Phase Locking)은 트랜잭션의 락 사용을 두 단계로 나누는 규칙입니다. 성장 단계에서는 락을 획득만 하고, 한 번이라도 락을 해제하면 수축 단계로 넘어가 그 뒤로는 새 락을 획득할 수 없습니다. 모든 트랜잭션이 이 규칙을 지키면 실행 결과가 어떤 직렬 순서와 같다는 것(충돌 직렬 가능성)이 보장됩니다.

기본 2PL은 커밋 전에 락을 풀 수 있어서, 다른 트랜잭션이 아직 커밋되지 않은 값을 읽은 뒤 원래 트랜잭션이 롤백되면 연쇄 롤백이 필요해집니다. Strict 2PL은 배타 락을 커밋이나 롤백 때까지 유지해 이 문제를 없애고, 모든 락을 끝까지 유지하는 Strong Strict 2PL(rigorous 2PL)도 있습니다. 실제 DB의 행 잠금은 대부분 트랜잭션 종료까지 유지되는 방식입니다.

공유(S) 락과 배타(X) 락의 호환 규칙은 다음과 같습니다.

이미 S 보유이미 X 보유
S 요청허용대기
X 요청대기대기

읽을 때 S 락을 잡고 나중에 갱신하려고 X 락으로 올리는 패턴은 데드락의 전형적인 원인입니다. 두 트랜잭션이 같은 행에 S 락을 잡은 뒤 둘 다 X로 올리려 하면, 서로 상대의 S 락이 풀리기를 기다리게 됩니다. 나중에 갱신할 행이라면 처음부터 FOR UPDATE로 X 락을 잡는 편이 안전합니다.

실제 엔진은 행, 페이지, 테이블 단위의 락을 함께 쓰고, 테이블에 의도 락(intention lock)을 걸어 “이 테이블의 어떤 행에 락이 있다”는 사실을 표시해 테이블 락과의 충돌을 빠르게 판정합니다. InnoDB는 인덱스 레코드와 그 사이 간격을 함께 잠그는 넥스트키 락(next-key lock)으로 범위에 새 행이 삽입되는 것을 막습니다.

이 구조에서 나오는 실무적 교훈이 있습니다. InnoDB의 잠금은 인덱스 레코드에 걸리므로, 인덱스가 없는 조건으로 UPDATE ... WHERE status = 'pending'을 실행하면 전체 테이블을 스캔하면서 훑은 모든 행을 잠그게 됩니다. 결과적으로 한 행만 바꾸려던 문장이 테이블 전체를 막습니다. 갱신 조건에 쓰는 컬럼에 인덱스가 있는지는 성능뿐 아니라 동시성 문제이기도 합니다.


데드락 탐지와 해결

데드락은 트랜잭션들이 서로가 가진 락을 기다리는 순환 대기 상태입니다. MVCC를 쓰는 DB에서도 쓰기 락, 갭 락, 외래 키 검사에서 발생합니다.

엔진은 트랜잭션을 노드로, “A가 B의 락을 기다린다”를 간선으로 하는 대기 그래프(wait-for graph)에서 사이클을 찾아 데드락을 감지합니다. InnoDB는 락 대기가 생길 때마다 즉시 검사하고, 사이클을 찾으면 변경한 행이 적은 트랜잭션(undo 로그가 작은 쪽)을 롤백합니다. PostgreSQL은 락을 deadlock_timeout(기본 1초) 동안 기다린 뒤에야 검사를 실행하고, 검사를 실행한 트랜잭션이 사이클에 있으면 그 트랜잭션을 중단합니다. 대부분의 락 대기는 금방 풀리므로 매번 검사하는 비용을 아끼려는 설계입니다. 데드락이 아닌 단순히 긴 대기는 lock_timeout(PostgreSQL)이나 innodb_lock_wait_timeout(InnoDB, 기본 50초)으로 끊습니다.

데드락은 완전히 없앨 수 없다고 보고, 애플리케이션이 데드락 오류(PostgreSQL 40P01, MySQL 1213)를 받으면 트랜잭션 전체를 처음부터 다시 실행하도록 만드는 것이 기본입니다. 그 위에 빈도를 줄이는 방법이 있습니다. 여러 행이나 테이블을 잠글 때 항상 같은 순서(예: 계좌 이체라면 ID가 작은 계좌부터)로 잠그고, 갱신 조건에 인덱스를 두어 잠금 범위를 줄이고, 트랜잭션을 짧게 유지합니다. InnoDB에서는 SHOW ENGINE INNODB STATUS의 LATEST DETECTED DEADLOCK 항목에 마지막 데드락에 관련된 두 트랜잭션의 SQL과 잡고 있던 락이 나오므로, 원인을 찾을 때 가장 먼저 봅니다.

Redis 같은 외부 저장소로 애플리케이션 레벨 분산 락을 구현할 때는 락 만료 시간이 지나 다른 프로세스가 락을 가져간 뒤에도 원래 프로세스가 작업을 계속하는 문제가 있습니다. 락을 얻을 때마다 증가하는 펜싱 토큰(fencing token)을 받아 저장소 쓰기에 함께 보내고, 저장소가 더 오래된 토큰의 쓰기를 거부하게 해야 안전합니다.


Write-Ahead Logging(WAL)

WAL은 데이터 페이지를 디스크에 쓰기 전에, 그 변경 내용을 로그에 먼저 기록하고 그 로그를 디스크에 확실히 내려 두는 방식입니다. 커밋 시점에 필요한 것은 변경된 데이터 페이지 전체가 아니라 로그 레코드가 디스크에 기록되는 것뿐입니다. 데이터 페이지는 흩어진 위치에 무작위로 써야 하지만 로그는 파일 끝에 순차적으로 덧붙이므로, 커밋 비용이 크게 줄어듭니다. 변경된 데이터 페이지는 나중에 백그라운드에서 천천히 기록됩니다.

크래시 복구

전원이 나가면 메모리의 버퍼 풀에 있던 변경된 페이지는 사라지지만, 로그는 커밋 시점에 이미 디스크에 있습니다. 재시작하면 마지막 체크포인트부터 로그를 읽어 커밋된 변경을 다시 적용(REDO)합니다. 커밋되지 않은 트랜잭션의 처리는 엔진마다 다릅니다. InnoDB는 redo log로 페이지를 복구한 뒤 undo 로그로 미완료 트랜잭션의 변경을 되돌립니다. PostgreSQL의 WAL은 redo 전용이고 undo 로그가 없습니다. 미완료 트랜잭션이 만든 튜플은 테이블에 남지만, 커밋 로그(pg_xact)에 커밋으로 기록되지 않았으므로 MVCC 가시성 규칙에 의해 어떤 트랜잭션에도 보이지 않고, 나중에 VACUUM이 회수합니다.

체크포인트

로그가 계속 쌓이면 재시작 시 다시 적용해야 할 양이 늘어나 복구 시간이 길어집니다. 체크포인트는 그 시점까지의 변경된 페이지를 모두 데이터 파일에 기록하고, 복구를 시작할 위치를 앞당깁니다. 그 이전의 WAL은 복구에 필요 없으므로 재사용하거나 아카이브할 수 있습니다. 체크포인트 간격을 길게 잡으면 쓰기 부하가 줄어드는 대신 복구 시간이 늘어나고, 짧게 잡으면 반대입니다. PostgreSQL은 체크포인트 이후 처음 수정되는 페이지 전체를 WAL에 기록(full page writes)해 페이지 쓰기 도중 전원이 나가 페이지가 반만 기록되는 문제를 막는데, 체크포인트가 잦으면 이 전체 페이지 기록이 늘어나 WAL 양이 커집니다.

fsync와 그룹 커밋

커밋이 성공했다고 응답하려면 로그가 운영체제 캐시가 아니라 저장 장치에 기록되었다는 보장, 즉 fsync가 필요합니다. fsync는 저장 장치에 따라 비용이 크므로, DB는 비슷한 시점에 커밋하는 여러 트랜잭션의 로그를 모아 한 번에 fsync하는 그룹 커밋으로 처리량을 높입니다. 반대로 내구성을 일부 포기하고 지연을 줄이는 설정도 있습니다. PostgreSQL의 synchronous_commit = off는 커밋 응답을 먼저 보내고 WAL 기록은 잠시 뒤에 하므로, 크래시 시 마지막 몇 백 밀리초 분량의 커밋을 잃을 수 있지만 데이터 파일이 깨지지는 않습니다. InnoDB의 innodb_flush_log_at_trx_commit = 2도 비슷한 트레이드오프입니다. 동기 복제를 켜면 복제본이 로그를 받았다는 응답까지 기다리므로 커밋 지연에 네트워크 왕복 시간이 더해집니다.

백업과 시점 복구

PostgreSQL에서 기본 백업(base backup)과 그 이후의 WAL 파일을 모두 보관하면, 기본 백업을 복원한 뒤 WAL을 원하는 시점까지 재적용해 특정 시점 복구(PITR)를 할 수 있습니다. 복제 슬롯을 쓰면 복제본이 아직 받지 않은 WAL을 서버가 지우지 않는데, 복제본이 멈춘 채로 슬롯이 남아 있으면 WAL이 끝없이 쌓여 디스크를 채웁니다. 사용하지 않는 슬롯은 pg_replication_slots에서 확인해 정리해야 합니다.


애플리케이션에서 쓰는 패턴

갱신 손실 막기

잔액이나 재고처럼 읽은 값을 기준으로 갱신하는 로직에서 가장 흔한 실수는, 값을 SELECT로 읽고 애플리케이션에서 계산한 뒤 UPDATE로 덮어쓰는 것입니다. Read Committed에서는 두 트랜잭션이 같은 값을 읽고 각자 덮어써 갱신 손실이 생깁니다. 가장 간단하고 확실한 해결은 조건 검사와 갱신을 하나의 SQL 문으로 합치는 것입니다.

-- 조건 검사와 갱신을 한 문장으로
UPDATE accounts
SET balance = balance - 100
WHERE id = 1 AND balance >= 100;
-- 영향받은 행 수가 0이면 잔액 부족

UPDATE는 해당 행에 배타 락을 잡은 상태에서 최신 커밋 값을 기준으로 조건을 다시 평가하므로, 동시에 실행되어도 잔액이 음수가 되지 않습니다. 애플리케이션은 영향받은 행 수로 성공 여부를 판단합니다.

계산이 복잡해 SQL 한 문장으로 표현하기 어렵다면, 행을 먼저 잠그고 읽습니다.

BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;   -- 배타 락 획득
-- 애플리케이션에서 검증과 계산
UPDATE accounts SET balance = :new_balance WHERE id = 1;
COMMIT;

같은 행을 FOR UPDATE로 읽으려는 다른 트랜잭션은 이 트랜잭션이 끝날 때까지 기다립니다. 이 방식을 PostgreSQL의 Repeatable Read 이상에서 쓰면, 잠그려는 행이 스냅샷 이후에 다른 트랜잭션에 의해 갱신된 경우 기다린 끝에 40001 오류가 나므로 재시도 경로가 필요합니다. Read Committed에서는 기다린 뒤 최신 값을 읽고 계속 진행합니다.

격리 수준을 트랜잭션별로 바꾸는 문법도 DB마다 다릅니다. PostgreSQL은 BEGIN 직후 첫 문장으로 SET TRANSACTION ISOLATION LEVEL ...을 실행해야 하고, MySQL은 SET TRANSACTION ISOLATION LEVEL ...이 다음 트랜잭션에 적용되므로 START TRANSACTION 전에 실행해야 합니다. MySQL에서 트랜잭션을 시작한 뒤 실행하면 오류가 납니다.

트랜잭션은 짧게

외부 API 호출, 파일 처리, 사용자 입력 대기는 트랜잭션 밖에서 합니다. 트랜잭션 안에서 결제 API를 호출하고 응답을 기다리는 동안에는 잡아 둔 행 락이 유지되어 같은 행을 쓰는 다른 요청이 모두 줄을 서고, 앞에서 본 것처럼 VACUUM도 막힙니다. 외부 호출의 결과를 트랜잭션과 함께 반영해야 한다면, 먼저 “처리 중” 상태를 기록해 커밋하고, 외부 호출 후 결과를 별도 트랜잭션으로 반영하는 식으로 나눕니다.

멱등성과 재시도

데드락이나 직렬화 실패로 재시도할 때, 그리고 네트워크 타임아웃으로 클라이언트가 같은 요청을 다시 보낼 때 같은 변경이 두 번 반영되면 안 됩니다. 요청마다 고유한 멱등 키를 받아 유니크 제약이 있는 컬럼에 기록하면, 두 번째 요청은 제약 위반으로 실패하므로 중복 반영을 DB가 막아 줍니다. 재시도는 무한히 하지 않고 횟수 제한과 지수 백오프를 둡니다. 재시도는 실패한 SQL 한 문장이 아니라 트랜잭션 전체를 처음부터 다시 실행해야 한다는 점도 중요합니다. 중단된 트랜잭션에서 읽은 값을 그대로 쓰면 의미가 없기 때문입니다.

분산 환경의 한계

DB 트랜잭션은 하나의 데이터베이스 안에서만 ACID를 보장합니다. 주문 서비스와 결제 서비스가 각자 DB를 가진 구조에서는 두 DB에 걸친 원자성을 기본으로 얻을 수 없습니다. 이 경우 로컬 트랜잭션 안에서 이벤트를 아웃박스 테이블에 함께 기록하고 별도 프로세스가 이를 발행하는 Outbox 패턴이나, 각 단계에 보상 트랜잭션을 두는 Saga 패턴으로 최종 일관성을 설계합니다.


증상별로 먼저 볼 곳

데드락이 자주 나면 락 획득 순서가 일정한지, 갱신 조건에 인덱스가 있는지, 불필요한 FOR UPDATE가 있는지를 봅니다. 디스크 사용량이 이유 없이 늘어나면 오래 열린 트랜잭션, autovacuum이 따라가지 못하는 테이블, 남아 있는 복제 슬롯과 WAL 보관량을 확인합니다. 커밋 지연이 크면 fsync 대기와 동기 복제 설정, 로그 디스크의 I/O 상태를 봅니다. 복제본이 뒤처지면 대량 갱신 배치나 복제본에서 오래 실행되는 쿼리가 WAL 적용을 막고 있지 않은지 확인합니다.


자주 묻는 질문 (FAQ)

Q. 잔액이나 재고처럼 읽은 값을 기준으로 갱신하는 로직은 어떻게 작성해야 하나요?

A. 값을 SELECT로 읽고 애플리케이션에서 계산한 뒤 UPDATE하면, MVCC 스냅샷 아래에서도 두 트랜잭션이 같은 값을 읽고 각자 덮어쓰는 갱신 손실이 생길 수 있습니다. SELECT … FOR UPDATE로 해당 행을 먼저 잠그고 갱신하거나, UPDATE … WHERE quantity >= ?처럼 조건 검증과 갱신을 하나의 SQL로 처리한 뒤 영향받은 행 수를 확인하는 방식이 안전합니다. 격리 수준을 올린다면 직렬화 실패 시 재시도하는 경로도 함께 마련해야 합니다.


같이 보면 좋은 글