[2026] 데이터베이스 트랜잭션 심화 — MVCC·2PL·데드락·WAL·실무 패턴
이 글의 핵심
ACID와 격리 수준을 넘어 MVCC·2PL·데드락 탐지·WAL·운영 패턴까지: 트랜잭션이 실제로 어떻게 직렬 가능성을 보장하고 장애 후에도 복구되는지 정리합니다.
들어가며
트랜잭션(transaction)은 여러 읽기·쓰기를 하나의 논리 단위로 묶어 원자성(atomicity), 일관성(consistency), 격리성(isolation), 지속성(durability)—즉 ACID—를 만족시키려는 DB의 핵심 추상화입니다. 애플리케이션에서는 BEGIN/COMMIT/ROLLBACK 한 줄로 끝나 보이지만, 스토리지 엔진은 동시에 실행되는 다른 트랜잭션과의 간섭, 장애 시 복구, 영속성 보장을 위해 MVCC, 락 프로토콜, 로그 구조를 조합합니다.
이 글은 ORM 사용법이 아니라 엔진이 내부적으로 어떤 트레이드오프를 두는지를 짚습니다. 운영 중 겪는 유령 읽기·반복 불가 읽기·데드락·복제 지연 같은 현상을 모델로 연결하면, 인덱스 설계와 쿼리 순서만으로는 설명되지 않는 문제를 줄일 수 있습니다.
선행 개념: 격리 수준과 이상 현상
SQL 표준은 격리 수준(isolation level)을 통해 허용되는 이상 현상(anomaly)을 정의합니다. 실무에서 자주 쓰는 이름은 다음과 같습니다.
| 이상 현상 | 의미 |
|---|---|
| 더티 읽기(dirty read) | 아직 커밋되지 않은 다른 트랜잭션의 쓰기를 읽음 |
| 비반복 읽기(non-repeatable read) | 같은 행을 두 번 읽었을 때 값이 바뀜 |
| 유령 읽기(phantom read) | 같은 범위를 두 번 읽었을 때 행 집합이 달라짐 |
Serializable에 가까울수록 동시 실행이 직렬 스케줄(serial schedule)과 동등한 결과에 가깝게 제한됩니다. 반대로 격리를 느슨하게 하면 처리량은 나아질 수 있지만 애플리케이션 불변 조건(invariant)을 코드나 제약으로 보강해야 합니다.
아래에서 MVCC와 2PL은 이 격리·일관성을 구현하는 대표적인 두 축—버전 관리와 락 프로토콜—입니다.
MVCC(Multi-Version Concurrency Control)
MVCC는 한 논리 행(row)에 대해 여러 버전의 물리 표현을 유지하여, 읽기 트랜잭션이 쓰기와 불필요하게 블로킹되지 않도록 하는 동시성 제어 방식입니다. “현재 커밋된 스냅샷”이 아닌 시점의 일관된 읽기를 제공하는 데 유리합니다.
스냅샷과 트랜잭션 ID
대표적인 구현(예: PostgreSQL, InnoDB)은 트랜잭션 ID(xid) 또는 시스템 변경 번호(SCN 등)로 어떤 버전이 “내게 보이는가”를 판정합니다. 읽기는 보통 특정 스냅샷 기준으로 가장 최신의 가시 버전을 고릅니다. 쓰기는 새 버전을 추가하고, 이전 버전은 정리(vacuum / purge)될 때까지 남을 수 있습니다.
왜 중요한가: 장시간 읽기 보고서 쿼리가 쓰기를 막지 않도록 설계할 수 있지만, 오래된 스냅샷을 유지하면 정리가 지연되어 디스크·버전 체인 부담이 커질 수 있습니다.
가시성 규칙과 “과거의 행”
MVCC에서 한 트랜잭션이 버전 (v)을 볼 수 있는지는 대략 다음을 포함합니다(제품·격리 수준에 따라 세부 규칙이 다름).
- 버전을 만든 트랜잭션이 커밋되었는가
- 그 커밋이 내 스냅샷 기준으로 가시적인가
- 삭제·갱신으로 무효화된 버전은 아닌가
이 판정이 인덱스만 스캔해도 발생하므로, 같은 PK를 여러 번 읽어도 값이 바뀌는 것처럼 보이는 현상은 격리 수준·스냅샷 경계와 함께 이해해야 합니다.
정리(VACUUM / Purge)와 테이블 팽창
버전이 쌓이면 Dead tuple, undo log 체인, 인덱스 엔트리가 증가합니다. PostgreSQL에서는 VACUUM, InnoDB에서는 purge 스레드가 오래된 버전을 제거합니다. 장시간 트랜잭션(열린 읽기, 방치된 세션)은 정리를 막아 저장 공간을 고정적으로 잡아먹는 원인이 됩니다.
MVCC만으로 해결되지 않는 것
MVCC는 읽기·쓰기 경합을 완화하지만, 동일 행에 대한 동시 갱신, 유니크 제약 검증, SELECT ... FOR UPDATE 같은 명시적 잠금 구간에서는 락 또는 충돌 검사가 필요합니다. “MVCC라서 락이 없다”로 이해하면 데드락이나 직렬화 실패를 잘못 진단하기 쉽습니다.
Serializable Snapshot Isolation(SSI)에 대해
PostgreSQL의 SERIALIZABLE은 스냅샷만으로는 부족한 쓰기 간 충돌을 의존 그래프( rw-dependency )로 추적해, 커밋 시점에 직렬화 불가로 판단하는 SSI 계열 구현을 사용합니다. 그 결과 성공한 트랜잭션은 “직렬 스케줄과 동등한 결과”에 더 가깝게 제한되지만, 재시도가 늘 수 있습니다. 제품마다 SERIALIZABLE의 엄격함·오류 코드·재시도 권장이 다르므로 해당 DB 매뉴얼의 격리 수준 장을 함께 보는 것이 좋습니다.
2단계 락킹(Two-Phase Locking, 2PL)
2PL은 트랜잭션이 락을 획득하는 구간과 해제하는 구간을 두 단계로 나누는 규칙입니다.
- 성장 단계(growing phase): 필요한 공유(S) / 배타(X) 락 등을 획득만 할 수 있고 해제하지 않음
- 수축 단계(shrinking phase): 해제만 할 수 있고 새로 획득하지 않음
Strict 2PL은 커밋/롤백 시점까지 배타 락을 유지하는 변형으로, 스케줄이 직렬 가능(serializable)함을 보장하는 전통적인 증명 경로와 연결됩니다.
공유(S) / 배타(X) 락의 전형적인 호환 규칙은 다음과 같습니다(개념 모델).
| 이미 S | 이미 X | |
|---|---|---|
| S 요청 | 호환 | 대기 |
| X 요청 | 대기 | 대기 |
읽기만 필요하면 S로 경합을 줄이고, 갱신이 확정되면 X로 승격하는 패턴이 흔합니다. 실제 엔진은 행·페이지·갭 단위로 세분화하고, 의도 락(intent lock)으로 계층 간 충돌을 줄입니다.
격리 수준과 락의 대응(개념)
실제 DB는 MVCC와 락을 혼합합니다. 예를 들어 Repeatable Read에서는 같은 읽기가 반복되도록 스냅샷을 고정하고, 쓰기 충돌이나 갭(gap)에는 Next-Key / Gap Lock(InnoDB) 같은 메커니즘이 개입합니다. Serializable은 스냅샷만으로 부족한 경우 직렬화 실패를 유발하거나 더 강한 락을 사용하기도 합니다.
실무에서의 함의
- 락 순서를 통일하면 데드락 확률을 줄일 수 있습니다(예: 항상
user→order행 순). - 인덱스가 없는 조건으로 갱신하면 범위 락이 커져 동시성이 급격히 나빠질 수 있습니다.
- 짧은 트랜잭션이 2PL·MVCC 모두에서 정석인 이유는 락 보유 시간과 버전·undo 유지 시간을 줄이기 위해서입니다.
데드락 탐지 및 해결
데드락(deadlock)은 트랜잭션들이 서로가 보유한 락을 기다리는 순환 대기가 생긴 상태입니다. MVCC가 있어도 쓰기 락·갭 락·DDL 등에서 발생합니다.
탐지: 대기 그래프와 타임아웃
- 대기 그래프(wait-for graph): 트랜잭션을 노드, “A가 B가 가진 자원을 기다림”을 간선으로 두고 사이클이 있으면 데드락으로 판단합니다.
- 타임아웃: 구현이 단순하지만 정상적으로 오래 걸리는 작업까지 끊을 위험이 있어 보조적으로 쓰는 경우가 많습니다.
해결: 희생자(victim) 선택과 롤백
엔진은 한 트랜잭션을 강제 중단(rollback)하여 사이클을 깹니다. 선택 기준은 작업량, 우선순위, 락 개수 등 제품별 휴리스틱입니다. 애플리케이션은 deadlock detected류 오류를 재시도할 수 있게 설계하는 것이 일반적입니다.
예방 패턴
- 같은 순서로 여러 테이블·행에 락을 잡기
- 불필요한
SELECT ... FOR UPDATE제거, 필요한 인덱스로 락 범위 최소화 - 배치 작업을 피크 시간과 분리
- 애플리케이션 레벨 락(Redis 등)을 쓸 때는 만료·펜스 토큰으로 좀비 락을 방지
Write-Ahead Logging(WAL)
WAL은 데이터 파일을 디스크에 쓰기 전에, 변경 내역을 순차 로그에 먼저 내구성 있게 기록하는 방식입니다. 핵심 이유는 장애 복구(crash recovery)와 그룹 커밋을 통한 처리량입니다.
로그 선행 기록이 필요한 이유
메모리 버퍼에만 있는 페이지는 전원 손실 시 사라집니다. WAL이 LSN(log sequence number) 순서로 안정 저장되면, 재시작 시 REDO로 커밋된 변경을 재적용하고, UNDO로 미완료 트랜잭션을 되돌릴 수 있습니다(엔진별로 UNDO는 undo 세그먼트·rollback 등으로 구현).
체크포인트(checkpoint)
WAL이 무한정 길어지면 재시작 시 복구 시간(RTO)이 늘어납니다. 체크포인트는 더티 페이지를 데이터 파일에 반영하고 재생 시작점을 앞당겨 WAL을 순환·아카이브할 수 있게 합니다.
fsync, 배치, 그룹 커밋
COMMIT은 결국 로그가 안전 매체에 기록되었음을 의미해야 합니다. fsync 비용이 크기 때문에 DB는 그룹 커밋으로 여러 트랜잭션의 로그 flush를 묶습니다. 반대로 동기 복제나 강한 내구성 설정은 지연을 늘릴 수 있어 SLA와 맞춰야 합니다.
백업·PITR과의 관계
PostgreSQL의 아카이브 WAL처럼 WAL을 보존하면 특정 시점 복구(PITR)가 가능합니다. 운영에서는 보관 정책, 복제 슬롯, 디스크 용량을 함께 설계합니다.
프로덕션 트랜잭션 패턴
아래는 애플리케이션에서 격리·잠금을 드러내는 예시입니다(문법은 DB별로 다를 수 있음).
-- 세션 단위로 격리 수준을 올릴 때(필요할 때만)
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- 잔액 검증 후 갱신: 동일 행에 배타 락이 잡힌 상태에서 수행
UPDATE accounts SET balance = balance - 100 WHERE id = 1 AND balance >= 100;
COMMIT;
FOR UPDATE는 다른 트랜잭션이 같은 행을 동시에 갱신하는 경쟁을 줄이지만, 데드락 가능성은 남습니다. 조건부 단일 UPDATE(WHERE ... AND balance >= ?)만으로도 불변 조건을 지킬 수 있으면 락 범위를 줄일 수 있습니다.
짧고 명확한 경계
네트워크 호출·대용량 처리·외부 API는 트랜잭션 밖으로 빼고, DB 안에서는 필요한 행만 빠르게 갱신합니다. 긴 트랜잭션은 락·버전·복제 지연을 동시에 악화시킵니다.
멱등성(idempotency)과 재시도
네트워크 타임아웃 후 같은 요청이 두 번 올 수 있습니다. 자연 키·idempotency key·UNIQUE 제약으로 “한 번만 반영”을 보장하고, 데드락·직렬화 실패는 지수 백오프 재시도를 적용합니다.
SELECT ... FOR UPDATE와 업무 규칙
잔액·재고처럼 읽은 값에 의존한 갱신은 스냅샷만으로는 경쟁 조건이 남을 수 있습니다. 같은 행을 잠근 뒤 갱신하거나, 단일 SQL(UPDATE ... WHERE quantity >= ?)로 조건을 한 번에 검증하는 패턴이 안전합니다.
격리 수준 선택
Read Committed가 기본인 경우가 많습니다. 보고서·집계에서 Repeatable Read / Serializable이 필요하면 명시적으로 올리고, 직렬화 실패(40001 등) 처리 경로를 마련합니다.
분산 환경에서의 한계
DB 트랜잭션은 보통 단일 클러스터 경계에서 강합니다. 마이크로서비스 간 일관성은 Saga, Outbox, 이벤트 소싱 등 패턴과 함께 설계합니다.
트러블슈팅 체크리스트
| 증상 | 점검 |
|---|---|
| 데드락 빈번 | 락 순서, 인덱스 부재, 불필요한 범위 잠금 |
| 유령 읽기 의심 | 격리 수준, 스냅샷 경계, 동일 트랜잭션 내 반복 조건 |
| 디스크 급증 | 장시간 트랜잭션, vacuum/purge 지연, WAL 보존 |
| 커밋 지연 | 동기 복제·fsync 설정, 디스크 I/O 병목 |
| 복제 지연 | 대량 쓰기, 핫 로우, 단일 스레드 적용 병목 |
마무리
MVCC는 읽기 일관성과 처리량을, 2PL과 그 변형은 직렬성·충돌 제어를, WAL은 내구성과 복구를 담당합니다. 데드락은 이들이 한 워크로드에서 만날 때 발생하며, 완화책은 짧은 트랜잭션·일관된 락 순서·적절한 인덱스·재시도 가능한 애플리케이션에 있습니다.
프로덕션에서는 “기본 격리 + 명시적 상위 격리”, “짧은 트랜잭션 + 멱등 키”, “WAL·체크포인트·복제 지연 모니터링”을 함께 두고, 장애 시 어떤 불변 조건을 DB가 보장하고 어떤 것을 애플리케이션이 보강하는지를 팀 합의로 남기는 것이 안전합니다.
심화 부록: 구현·운영 관점
이 부록은 앞선 본문에서 다룬 주제(「[2026] 데이터베이스 트랜잭션 심화 — MVCC·2PL·데드락·WAL·실무 패턴」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.
내부 동작과 핵심 메커니즘
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
sequenceDiagram participant C as 클라이언트/호출자 participant B as 경계(런타임·게이트웨이·프로세스) participant D as 의존성(API·DB·큐·파일) C->>B: 요청/이벤트 B->>D: 조회·쓰기·RPC D-->>B: 지연·부분 실패·재시도 가능 B-->>C: 응답 또는 오류(코드·상관 ID)
- 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
- 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
- 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
- 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.
프로덕션 운영 패턴
| 영역 | 운영 관점 질문 |
|---|---|
| 관측성 | 요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가 |
| 안전성 | 입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가 |
| 신뢰성 | 재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가 |
| 성능 | 캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가 |
| 배포 | 롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가 |
| 용량 | 피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가 |
스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.
확장 예시: 엔드투엔드 미니 시나리오
앞선 본문 주제(「[2026] 데이터베이스 트랜잭션 심화 — MVCC·2PL·데드락·WAL·실무 패턴」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.
- 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
- 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
- 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
- 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
- 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
ctx = newCorrelationId()
validated = validateSchema(request)
authorize(validated, ctx)
result = domainCore(validated)
persistOrEmit(result, idempotentKey)
recordMetrics(ctx, latency, outcome)
return result
문제 해결(Troubleshooting)
| 증상 | 가능 원인 | 조치 |
|---|---|---|
| 간헐적 실패 | 레이스, 타임아웃, 외부 의존성, DNS | 최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검 |
| 성능 저하 | N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스 | 프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거 |
| 메모리 증가 | 캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납 | 상한·TTL·힙/FD 스냅샷 비교 |
| 빌드·배포만 실패 | 환경 변수, 권한, 플랫폼 차이, lockfile | CI 로그와 로컬 diff, 런타임·이미지 버전 핀 |
| 설정 불일치 | 프로필·시크릿·기본값, 리전 | 스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화 |
| 데이터 불일치 | 비멱등 재시도, 부분 쓰기, 캐시 무효화 누락 | 멱등 키·아웃박스·트랜잭션 경계 재검토 |
권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.
배포 전에는 git add → git commit → git push 후 npm run deploy 순서를 권장합니다.