본문으로 건너뛰기

버그 제보

이상한 점을 알려 주시면 고칠게요. 지금 보고 있는 페이지와 브라우저 정보가 함께 담겨요.

페이지:

Computer Science

트랜잭션과 회복

작성 2026. 10. 31.추가
목차32개 항목
출처와 참고

노션 정리를 바탕으로, MVCC·잠금의 종류·비관적/낙관적 잠금은 ByteByteGo의 System Design 101 가이드 「What does ACID mean?」, 「What are Database Isolation Levels?」, 「What are the Differences Among Database Locks?」, 「Pessimistic vs Optimistic Locking」로, 버퍼 정책·로그·재시작 복구는 「DBMS는 어떻게 트랜잭션을 관리할까?」로 보강했다. 트랜잭션의 상태와 DB 교착 상태는 jbee의 「Interview Question for Beginner」 데이터베이스 문서를 바탕으로 보강했다. 스냅숏 격리와 쓰기 스큐, SSI, InnoDB와 PostgreSQL의 MVCC 비교, VACUUM, 분산 트랜잭션, 비정상 종료 시 잠금 해제는 「fru1tworld/cs-basic 모의 면접 질문 목록」의 질문을 실마리로 PostgreSQL·MySQL 공식 문서로 확인해 보강했다. 트랜잭션의 시작과 끝, 세이브포인트의 동작, 잠금 호환성 표, Oracle의 직렬화 오류와 교착 처리는 고려사이버대학교 「데이터베이스 설계구축」(2023-2학기) 강의 내용으로 보강했다. 동시 실행 문제, 스케줄과 직렬 가능성, 회복 가능한 스케줄, 2PL과 타임스탬프 순서, wait-die·wound-wait는 GitHub 자료 모음 「Subjective-studies_OS-CN-DBMS」의 DBMS 면접 정리를 바탕으로 보강했다.

트랜잭션은 데이터베이스에서 하나의 논리적 작업 단위다. 계좌 이체처럼 "출금"과 "입금"이 둘 다 되거나 둘 다 안 되어야 하는 작업을 묶는다. 트랜잭션이 지켜야 할 네 가지 성질(ACID)과, DBMS가 각 성질을 보장하는 수단을 짝지어 기억하면 이해가 쉽다.

ACID

특성의미보장 수단
원자성 (Atomicity)모두 반영되거나 전혀 반영되지 않음 (All or Nothing)Commit / Rollback, 회복
일관성 (Consistency)트랜잭션 전후로 DB가 일관된 상태(제약 조건 만족)무결성 제약, 병행 제어
격리성 (Isolation)실행 중인 트랜잭션에 다른 트랜잭션이 끼어들 수 없음고립화 수준, 로킹
지속성 (Durability)커밋된 결과는 장애가 나도 영구히 유지로그, 회복 기법

예: 송금 도중 장애가 나서 출금만 되고 입금이 안 되면 원자성이 깨진 것이다. 이때 Rollback으로 출금도 취소해야 한다.

  • ACID의 일관성과 CAP의 일관성은 다른 말이다. ACID의 C는 「트랜잭션이 끝나도 제약 조건·불변식(잔액은 음수가 아니다 등)이 지켜진다」는 뜻이고, CAP 정리의 C는 「어느 노드에서 읽어도 가장 최근에 쓴 값을 본다」는 뜻이다.
  • 격리성의 이상형은 직렬성(serializability) 이다. 모든 트랜잭션이 혼자 도는 것처럼 보이게 한다. 비용이 커서 실제로는 더 느슨한 고립화 수준을 기본값으로 쓴다.
  • 분산 환경의 지속성은 디스크 기록만으로 부족하다. 커밋한 데이터를 다른 노드에도 복제해야 한 서버가 통째로 사라져도 남는다.

트랜잭션 제어어 (TCL)

명령의미
COMMIT트랜잭션 결과를 DB에 영구 반영
ROLLBACK트랜잭션 실행 이전 상태로 되돌림
SAVEPOINT롤백할 중간 지점 지정 (ROLLBACK TO 이름)

COMMIT과 ROLLBACK은 트랜잭션을 끝내면서 그 트랜잭션이 쥔 잠금을 모두 풀고 세이브포인트를 모두 지운다. ROLLBACK TO 세이브포인트는 다르다.

항목ROLLBACKROLLBACK TO sp1
되돌리는 범위트랜잭션 전체sp1 뒤에 실행한 문장만
트랜잭션끝난다계속된다. 이어서 작업하고 나중에 COMMIT해야 한다
세이브포인트모두 사라진다sp1은 남고, 그 뒤에 만든 세이브포인트만 사라진다
잠금모두 풀린다sp1 뒤에 건 잠금만 풀린다(Oracle). 앞에서 건 잠금은 유지한다
UPDATE emp SET salary = 7000  WHERE name = 'A';
SAVEPOINT after_a;
UPDATE emp SET salary = 12000 WHERE name = 'B';
ROLLBACK TO after_a;        -- B의 변경만 취소. A의 변경과 트랜잭션은 그대로
UPDATE emp SET salary = 11000 WHERE name = 'B';
COMMIT;                     -- A=7000, B=11000 확정

트랜잭션의 상태

stateDiagram-v2
  direction LR
  active: 활동(Active)
  partial: 부분 완료(Partially Committed)
  committed: 완료(Committed)
  failed: 실패(Failed)
  aborted: 철회(Aborted)
  [*] --> active: 시작
  active --> partial: 마지막 연산 끝
  partial --> committed: 기록 성공
  active --> failed: 오류
  partial --> failed: 커밋 중 오류
  failed --> aborted: ROLLBACK
  aborted --> [*]: 재시작 또는 종료
상태뜻
활동(Active)연산을 실행하는 중
부분 완료(Partially Committed)마지막 연산까지 실행했고 커밋 처리만 남은 상태. 변경은 아직 버퍼에만 있을 수 있다
완료(Committed)커밋 기록(로그)이 디스크에 남아 결과가 확정된 상태
실패(Failed)오류로 더 진행할 수 없음을 알게 된 상태
철회(Aborted)롤백으로 시작 전 상태로 되돌린 상태. 다시 시작하거나 버린다

부분 완료와 완료의 차이는 지속성이 보장됐는가다. 부분 완료 상태에서 장애가 나면 커밋 로그를 쓰지 못한 것이므로 실패로 가서 철회된다.

잠금과 트랜잭션은 목적이 다르다. 잠금은 동시에 같은 자원을 고치지 못하게 하는 동시성 제어 수단이고, 트랜잭션은 작업 묶음이 전부 반영되거나 전혀 반영되지 않게 하는 정합성 단위다. 쿼리가 하나뿐인 작업도 트랜잭션이다.

언제 트랜잭션으로 묶나

대부분의 DBMS는 자동 커밋(autocommit) 이 기본이라, 명시적으로 묶지 않은 문장 하나하나가 각자 트랜잭션으로 실행되고 바로 커밋된다. 그래서 「트랜잭션을 건다」는 말은 여러 문장을 한 단위로 묶는다는 뜻이다.

Oracle은 서버 쪽에 자동 커밋이 없다. 첫 DML 문장에서 트랜잭션이 저절로 시작되고, COMMIT·ROLLBACK을 만날 때까지 이어진다(SQL*Plus·JDBC 같은 클라이언트가 자동 커밋을 대신 켜 주기도 한다). 명시적인 COMMIT·ROLLBACK 말고도 트랜잭션이 끝나는 경우가 있다.

경우결과
CREATE·ALTER·DROP·TRUNCATE 같은 DDL 실행DDL 앞뒤로 암묵적 커밋. 앞서 하던 DML까지 확정되므로 롤백할 수 없다. MySQL도 같고, PostgreSQL은 DDL도 트랜잭션 안에서 롤백할 수 있다
클라이언트 정상 종료대개 커밋한다(도구마다 다르다)
세션 비정상 종료, 연결 끊김롤백한다
상황묶는 이유
계좌 이체, 주문 생성 + 재고 차감 + 결제 기록여러 쓰기가 전부 되거나 전부 안 되어야 한다(원자성)
읽은 값을 보고 쓰는 작업(잔액 확인 후 차감)읽기와 쓰기 사이에 다른 트랜잭션이 끼어들지 못하게 잠금·격리 수준을 함께 쓴다
여러 테이블을 읽어 한 시점의 보고서를 만든다문장마다 다른 시점을 보면 합계가 맞지 않는다. 한 트랜잭션의 스냅숏으로 읽는다

읽기만 하는 작업에도 트랜잭션이 필요할 수 있다. SELECT 한 문장은 그 자체로 한 시점의 일관된 결과를 보므로 따로 묶지 않아도 된다. 하지만 서로 맞아야 하는 SELECT를 여러 번 하면, 그 사이 커밋된 변경 때문에 결과끼리 어긋난다. 이때는 Repeatable Read 이상의 트랜잭션으로 묶어 같은 스냅숏을 읽는다.

  • 읽기 전용 트랜잭션(START TRANSACTION READ ONLY, SET TRANSACTION READ ONLY)은 쓰기를 막고, DB가 최적화할 여지를 준다. InnoDB는 읽기 전용 트랜잭션에 트랜잭션 ID를 할당하지 않는다. Spring의 @Transactional(readOnly = true)는 JPA의 변경 감지·flush를 건너뛰고, 설정에 따라 읽기 복제본으로 보내는 기준이 된다.
  • 긴 읽기 트랜잭션은 비싸다. 오래된 스냅숏을 쥐고 있는 동안 옛 버전을 지울 수 없어, InnoDB는 undo 로그가, PostgreSQL은 죽은 튜플이 쌓인다(아래 「MVCC 구현 비교」).

병행 제어 (Concurrency Control)

여러 트랜잭션이 동시에 실행될 때 결과가 순차 실행한 것과 같도록 보장한다. 순서대로 하나씩 돌리면 늘 맞지만 느리다. 연산을 섞어 돌리면서도 결과가 어긋나지 않게 하는 것이 병행 제어의 일이다.

동시 실행에서 생기는 문제

문제무슨 일이 생기나
갱신 손실(lost update)두 트랜잭션이 같은 값을 읽고 각자 고쳐 쓰면 나중에 쓴 쪽이 먼저 쓴 변경을 덮는다
더티 읽기(dirty read, 오손 읽기)아직 커밋하지 않은 변경을 읽는다. 그 트랜잭션이 롤백하면 있지도 않았던 값을 본 셈이다
반복 불가능 읽기(non-repeatable read)한 트랜잭션 안에서 같은 행을 두 번 읽었는데, 그사이 다른 트랜잭션이 고치고 커밋해 값이 다르다
팬텀 읽기(phantom read)같은 조건으로 두 번 조회했는데, 그사이 다른 트랜잭션이 삽입·삭제해 행의 개수가 다르다
잘못된 요약(incorrect summary)합계를 내는 도중 다른 트랜잭션이 일부 행을 고쳐, 고치기 전 값과 고친 뒤 값이 섞인 합계가 나온다
연쇄 복귀(cascading rollback)더티 읽기를 한 트랜잭션들이 원래 트랜잭션의 롤백을 따라 줄줄이 롤백된다

갱신 손실은 잔액 100에서 T1이 30을 빼고 T2가 50을 빼는 경우로 볼 수 있다.

T1                    T2
read(A)    -- 100
                      read(A)    -- 100
A = A - 30
write(A)   -- 70
                      A = A - 50
                      write(A)   -- 50   ← T1의 차감이 사라졌다. 맞는 값은 20

더티 읽기·반복 불가능 읽기·팬텀 읽기는 아래 고립화 수준이 어디까지 막는지의 기준이 된다.

스케줄과 직렬 가능성

여러 트랜잭션의 연산이 실제로 실행되는 순서를 스케줄(schedule) 이라 한다. 한 트랜잭션 안의 연산 순서는 바뀌지 않고, 트랜잭션 사이에서만 섞인다.

스케줄뜻
직렬 스케줄(serial)트랜잭션을 하나씩 끝까지 실행한다. 늘 일관되지만 병행성이 없다
비직렬 스케줄(non-serial)연산을 섞어 실행한다. 결과가 맞을 수도 틀릴 수도 있다
직렬 가능 스케줄(serializable)비직렬이지만 어떤 직렬 스케줄과 결과가 같다고 보장되는 스케줄. 병행 제어가 만들어 내려는 목표다

「결과가 같다」를 판정하는 기준은 둘이다. 우연히 결과 값이 같은 것(결과 동등)은 입력 값에 따라 달라져 기준으로 쓰지 않는다.

충돌 직렬 가능성(conflict serializability). 두 연산이 서로 다른 트랜잭션에 속하고, 같은 데이터에 접근하며, 적어도 하나가 쓰기이면 충돌 연산이다(읽기-쓰기, 쓰기-읽기, 쓰기-쓰기). 충돌하지 않는 연산끼리 자리를 바꿔 직렬 스케줄로 만들 수 있으면, 곧 충돌 연산의 순서를 그대로 둔 직렬 스케줄이 있으면 충돌 직렬 가능하다. 판정은 선행 그래프(precedence graph) 로 한다.

  1. 트랜잭션마다 노드를 만든다.
  2. Ti의 연산이 Tj의 충돌 연산보다 먼저 나오면 간선 Ti → Tj를 긋는다.
  3. 그래프에 사이클이 없으면 충돌 직렬 가능하고, 위상 정렬한 순서가 동등한 직렬 순서다.
S: r1(A) w2(A) w1(A)
   r1(A) → w2(A) : T1 → T2
   w2(A) → w1(A) : T2 → T1   ← 사이클. 충돌 직렬 가능하지 않다 (위의 갱신 손실과 같은 모양)

뷰 직렬 가능성(view serializability). 두 스케줄이 다음 세 조건을 지키면 뷰 동등이다. 어떤 직렬 스케줄과 뷰 동등이면 뷰 직렬 가능하다.

  • 어떤 트랜잭션이 데이터의 초깃값을 읽으면 다른 스케줄에서도 초깃값을 읽는다.
  • Ti가 Tj가 쓴 값을 읽으면 다른 스케줄에서도 Tj가 쓴 값을 읽는다.
  • 어떤 데이터에 마지막으로 쓰는 트랜잭션이 같다.

충돌 직렬 가능한 스케줄은 모두 뷰 직렬 가능하지만 반대는 아니다. 차이는 읽지 않고 덮어쓰는 맹목 쓰기(blind write) 에서 난다. r1(A) w2(A) w1(A) w3(A)는 선행 그래프에 사이클이 있지만, 마지막에 T3이 덮어써서 T1 → T2 → T3 직렬 실행과 뷰 동등하다. 뷰 직렬 가능성 판정은 NP-완전이라 실제 DBMS는 판정이 쉬운 충돌 직렬 가능성을 목표로 프로토콜을 짠다.

회복 가능한 스케줄

직렬 가능성은 「모두 커밋한다면 결과가 맞는가」만 본다. 중간에 누군가 롤백할 때도 안전한지는 따로 본다.

스케줄조건결과
회복 불가능(irrecoverable)T2가 T1이 쓴 값을 읽고(더티 읽기) T1보다 먼저 커밋한다T1이 롤백하면 이미 커밋한 T2를 되돌릴 수 없다. 허용하면 안 된다
회복 가능(recoverable)읽은 값을 쓴 트랜잭션이 끝날 때까지 커밋을 미룬다롤백은 가능하지만 연쇄 복귀가 생길 수 있다
비연쇄(cascadeless)커밋되지 않은 값은 읽지 않는다연쇄 복귀가 없다
엄격(strict)커밋되지 않은 값은 읽지도 덮어쓰지도 않는다롤백할 때 쓰기 전 값(before image)만 되돌리면 된다. 회복이 가장 단순하다

엄격 ⊂ 비연쇄 ⊂ 회복 가능 순서로 포함된다. 직렬 스케줄은 넷 다 만족하지만, 직렬 가능하다고 회복 가능한 것은 아니다. 두 성질은 따로 지켜야 한다.

병행 제어 기법

기법방식
로킹(Locking)접근할 데이터에 잠금을 걸어 연산이 끝날 때까지 다른 트랜잭션의 접근을 막는다(상호 배제). 잠금 단위가 크면 관리가 쉽지만 병행성이 떨어지고, 작으면 그 반대다
타임스탬프 순서트랜잭션이 시작한 순서대로 충돌 연산의 순서를 정한다. 잠금을 쓰지 않는다
낙관적 검증읽기 단계에서 사본으로 일하고, 커밋 직전 검증 단계에서 충돌을 검사해 통과하면 쓰기 단계에서 반영한다
다중 버전 동시성 제어(MVCC)데이터의 여러 버전을 두어 읽기와 쓰기가 서로 막지 않게 한다(아래 「MVCC가 스냅숏으로 읽는 방식」)

2단계 잠금(2PL, Two-Phase Locking). 잠금을 쓴다고 저절로 직렬 가능해지지는 않는다. 쓰자마자 잠금을 풀면 다른 트랜잭션이 사이에 끼어든다. 2PL은 트랜잭션의 잠금 동작을 두 단계로 나눈다.

잠금 수
  ▲        ┌──── 잠금 지점(lock point)
  │      ╱  ╲
  │    ╱      ╲
  │  ╱          ╲
  └──────────────────▶ 시간
   확장 단계   축소 단계
   잠금만 얻는다  잠금만 푼다 (새 잠금 불가)

모든 트랜잭션이 2PL을 지키면 스케줄은 충돌 직렬 가능하다. 잠금 지점 순서가 곧 동등한 직렬 순서다. 공유 잠금(S)을 쥐고 있다가 배타 잠금(X)으로 올리는 잠금 승격은 확장 단계에서만 할 수 있다. 기본 2PL은 교착 상태를 막지 못하고, 축소 단계에서 푼 X 잠금 뒤의 값을 남이 읽을 수 있어 연쇄 복귀도 생긴다. 그래서 변형을 쓴다.

변형규칙얻는 것
기본 2PL첫 잠금 해제 뒤 새 잠금 금지충돌 직렬 가능
보수적 2PL(conservative, 사전 선점)시작 전에 필요한 잠금을 모두 얻는다. 하나라도 못 얻으면 아무것도 쥐지 않고 기다린다교착 상태가 생기지 않는다. 필요한 데이터를 미리 알아야 해 실제로는 드물다
엄격 2PL(strict)X 잠금을 커밋·롤백할 때까지 쥔다엄격 스케줄. 연쇄 복귀가 없다
강한 2PL(rigorous)모든 잠금을 커밋·롤백할 때까지 쥔다커밋 순서가 곧 직렬 순서다

잠금 기반 DBMS의 쓰기는 대개 엄격 2PL 모양이다. InnoDB도 행에 건 X 잠금을 트랜잭션이 끝날 때 한꺼번에 푼다. 교착 상태는 2PL로 막지 못하므로 아래 「DB의 교착 상태」처럼 감지하거나 예방한다.

타임스탬프 순서(timestamp ordering). 트랜잭션 Ti는 시작할 때 타임스탬프 TS(Ti)를 받는다(시스템 시계나 논리 카운터). 데이터 X마다 마지막으로 읽은 트랜잭션의 타임스탬프 R-TS(X)와 마지막으로 쓴 트랜잭션의 W-TS(X)를 기록하고, 충돌 연산이 타임스탬프 순서와 어긋나면 연산을 거절하고 Ti를 롤백한다.

요청조건처리
Ti가 read(X)TS(Ti) < W-TS(X): 더 젊은 트랜잭션이 이미 썼다거절, Ti 롤백
Ti가 read(X)그 밖실행. R-TS(X) = max(R-TS(X), TS(Ti))
Ti가 write(X)TS(Ti) < R-TS(X): 더 젊은 트랜잭션이 이미 옛 값을 읽었다거절, Ti 롤백
Ti가 write(X)TS(Ti) < W-TS(X): 더 젊은 트랜잭션이 이미 썼다거절, Ti 롤백
Ti가 write(X)그 밖실행. W-TS(X) = TS(Ti)
  • 토마스 쓰기 규칙(Thomas' write rule): write의 두 번째 경우(이미 더 젊은 쓰기가 있다)에는 롤백하지 않고 낡은 쓰기를 무시한다. 어차피 더 젊은 값으로 덮일 쓰기이기 때문이다. 충돌 직렬 가능하지 않지만 뷰 직렬 가능한 스케줄까지 허용해 롤백이 준다.
  • 기다리는 일이 없어 교착 상태가 생기지 않는다. 대신 롤백과 재시작이 잦고, 커밋 전 값을 읽게 두면 회복 불가능한 스케줄이 생길 수 있어 쓰기를 커밋 때까지 미루는 장치를 함께 쓴다.
  • MVCC의 많은 구현이 트랜잭션 ID를 타임스탬프처럼 써서 「어느 버전이 내게 보이나」를 정한다. 타임스탬프 순서를 다중 버전으로 확장한 셈이다.

고립화 수준 (Isolation Level)

격리를 얼마나 엄격하게 할지 고르는 설정이다. 아래로 갈수록 엄격하고 성능 비용이 크다. 세 현상의 뜻은 위 「동시 실행에서 생기는 문제」에 있다.

수준Dirty ReadNon-repeatable ReadPhantom Read
Read Uncommitted발생발생발생
Read Committed방지발생발생
Repeatable Read방지방지발생
Serializable방지방지방지

위 표는 SQL 표준의 정의다. 표준은 「이 수준에서 막아야 할 현상」만 정하고 구현 방법은 정하지 않는다. 그래서 모든 DBMS가 네 수준을 그대로 구현하지는 않는다. 잠금 기반이 아니라 MVCC로 만든 DB는 더러운 읽기를 허용할 이유가 없고(옛 버전을 읽으면 되므로), 표준보다 강한 수준으로 올려 처리한다.

DBMS지원 수준과 실제 동작기본값
MySQL InnoDB네 수준 모두. Repeatable Read는 일반 읽기를 스냅숏으로 하고, 잠금 읽기·쓰기에는 넥스트 키 잠금을 걸어 팬텀을 대부분 막는다. Serializable은 자동 커밋이 아닐 때 일반 SELECT를 SELECT ... FOR SHARE로 바꾼다Repeatable Read
PostgreSQL네 수준을 받지만 Read Uncommitted는 Read Committed로 동작한다. Repeatable Read는 스냅숏 격리라 팬텀도 막는다. Serializable은 SSIRead Committed
OracleRead Committed와 Serializable 두 가지(와 Read Only)만 있다. Read Committed는 문장 단위, Serializable은 트랜잭션 단위로 읽기 일관성을 보장한다(undo로 옛 블록을 복원해 읽는다). Serializable에서 내가 시작한 뒤 다른 트랜잭션이 고치고 커밋한 행을 고치려 하면 ORA-08177: can't serialize access로 실패한다. 실제로는 스냅숏 격리라 쓰기 스큐가 생긴다Read Committed
SQL Server네 수준 + SNAPSHOT. READ_COMMITTED_SNAPSHOT 옵션을 켜면 Read Committed도 잠금 대신 버전으로 읽는다Read Committed

MVCC가 스냅숏으로 읽는 방식

MVCC는 행을 덮어쓰지 않고 새 버전을 만들고 옛 버전을 남겨, 읽는 트랜잭션이 자기에게 보여야 할 버전을 고르게 한다. MySQL InnoDB를 예로 들면 다음과 같다.

  • 각 행에는 숨은 컬럼이 있다. 마지막으로 고친 트랜잭션 ID(DB_TRX_ID)와, undo 로그의 이전 버전을 가리키는 롤 포인터(DB_ROLL_PTR)다. 버전들이 포인터로 이어진 체인이 된다.
  • 읽는 트랜잭션은 읽기 뷰(read view) 를 만든다. 「이 시점에 커밋되지 않았던 트랜잭션 ID 목록」이다. 행의 트랜잭션 ID가 그 목록에 있거나 더 나중이면 보이지 않으므로, 롤 포인터를 따라 보이는 버전까지 거슬러 올라간다.
flowchart LR
  subgraph chain["balance 행의 버전 체인"]
    v201["trx 201, balance=200"] -->|"roll_ptr"| v200["trx 200, balance=100<br/>(커밋됨)"]
  end
  ta["트랜잭션 A가 수정 중(미커밋)"] --> v201
  rb["트랜잭션 B의 읽기 뷰"] -.->|"201은 미커밋 → 건너뛰고"| v201
  rb -->|"trx 200 버전을 읽는다 → 100"| v200

A가 커밋한 뒤에도 B(Repeatable Read)는 처음 만든 읽기 뷰를 계속 써서 100을 본다.

수준읽기 뷰를 만드는 때결과
Read Committed문장마다 새로 만든다같은 트랜잭션 안에서도 다른 트랜잭션이 커밋한 새 값이 보인다
Repeatable Read트랜잭션의 첫 읽기 때 한 번 만든다트랜잭션 내내 같은 스냅숏을 본다

MVCC는 읽기의 충돌을 없앨 뿐이고, 같은 행을 동시에 쓰는 충돌은 여전히 잠금으로 막는다.

InnoDB의 undo와 redo

InnoDB는 변경 하나에 두 종류의 로그를 남긴다. 이름이 비슷하지만 목적이 반대다.

구분redo 로그undo 로그
담는 것페이지를 어떻게 바꿨나(변경 후 정보, 물리적)바꾸기 전 값(되돌리는 데 필요한 정보, 논리적)
목적지속성: 커밋됐지만 데이터 파일에 아직 안 쓰인 변경을 재시작 때 다시 적용원자성: 롤백. 그리고 MVCC: 다른 트랜잭션에 옛 버전을 보여 준다
위치고정 크기의 순환 파일. 8.0.30부터 innodb_redo_log_capacity로 크기를 정하고 #innodb_redo 디렉터리에 둔다undo 테이블스페이스(기본 2개) 안의 롤백 세그먼트
지우는 때체크포인트로 해당 페이지가 디스크에 내려가면 그 구간을 재사용한다커밋 뒤에도 그 버전을 볼 읽기 뷰가 남아 있으면 유지한다. 아무도 안 보게 되면 퍼지(purge) 스레드가 지운다

삭제도 바로 지우지 않고 「지움 표시」만 한 뒤 퍼지가 정리한다. 오래 열린 트랜잭션이 있으면 퍼지가 멈춰 undo가 쌓이고(SHOW ENGINE INNODB STATUS의 History list length가 늘어난다), 버전 체인이 길어져 읽기도 느려진다.

MVCC 구현 비교: InnoDB와 PostgreSQL

항목MySQL InnoDBPostgreSQL
옛 버전이 있는 곳undo 로그. 테이블에는 최신 버전만 있다테이블(힙) 안. UPDATE는 새 튜플을 추가하고 옛 튜플에 xmax를 적는다
버전 판단행의 DB_TRX_ID와 읽기 뷰튜플의 xmin(만든 트랜잭션)·xmax(지운 트랜잭션)와 스냅숏
옛 버전 정리퍼지 스레드VACUUM
인덱스보조 인덱스는 기본 키를 가리켜 행이 바뀌어도 그대로인 경우가 많다인덱스가 튜플 위치를 가리키므로 UPDATE마다 인덱스 항목도 새로 생길 수 있다. 인덱스 컬럼이 안 바뀌고 같은 페이지에 자리가 있으면 HOT(Heap-Only Tuple) 갱신으로 인덱스를 건드리지 않는다
롤백undo를 적용해 되돌린다되돌릴 것이 없다. 중단된 트랜잭션의 튜플은 커밋 기록(pg_xact)상 중단으로 보여 그냥 안 보인다
약점긴 트랜잭션이 undo를 키운다갱신이 잦은 테이블이 죽은 튜플로 부푼다(bloat)

VACUUM과 autovacuum

PostgreSQL에서 UPDATE·DELETE가 남긴 옛 튜플은 어떤 스냅숏에서도 보이지 않게 되면 죽은 튜플(dead tuple) 이 된다. VACUUM이 하는 일은 넷이다.

일이유
죽은 튜플 공간을 재사용 가능으로 표시공간을 비워 테이블이 끝없이 커지지 않게 한다. 일반 VACUUM은 파일을 줄이지 않고 그 안을 재사용한다
가시성 맵(visibility map) 갱신모든 튜플이 보이는 페이지를 표시해 인덱스 전용 스캔이 힙을 읽지 않게 한다
트랜잭션 ID 동결(freeze)트랜잭션 ID는 32비트라 한 바퀴 돌면 과거가 미래처럼 보인다(wraparound). 오래된 튜플을 「언제나 과거」로 표시해 막는다
통계 갱신(VACUUM ANALYZE)옵티마이저가 쓰는 통계를 새로 만든다

VACUUM FULL은 테이블을 새로 써서 파일 크기를 줄이지만 그동안 배타 잠금을 잡아 읽기·쓰기를 모두 막는다. 운영 중에는 pg_repack 같은 도구를 쓴다.

autovacuum은 이 일을 자동으로 한다. 런처 프로세스가 테이블을 살피다가 죽은 튜플 수가 autovacuum_vacuum_threshold(기본 50) + autovacuum_vacuum_scale_factor(기본 0.2) × 행 수를 넘으면 워커가 VACUUM을, 비슷한 기준으로 ANALYZE를 돌린다.

문제 상황원인대응
큰 테이블이 부푼다기준이 행 수의 20%라 1억 행 테이블은 2천만 개가 쌓여야 돈다그 테이블만 scale_factor를 낮춘다(ALTER TABLE ... SET (autovacuum_vacuum_scale_factor = 0.01))
VACUUM이 돌아도 줄지 않는다오래 열린 트랜잭션, 쓰이지 않는 복제 슬롯, 남은 준비된 트랜잭션이 옛 스냅숏을 붙잡고 있다pg_stat_activity의 xact_start로 긴 트랜잭션을 찾고 idle_in_transaction_session_timeout을 건다
갑자기 I/O가 튄다VACUUM이 많은 페이지를 읽고 쓴다. 비용 제한(autovacuum_vacuum_cost_limit)이 낮으면 반대로 따라잡지 못한다비용 한도와 워커 수를 부하에 맞춘다
강제 VACUUM이 끝나지 않는다wraparound가 가까우면 꺼 둔 테이블에도 공격적인 동결 VACUUM이 돈다. 끝까지 못 하면 DB가 새 트랜잭션 ID 발급을 거부한다평소에 동결이 따라가도록 autovacuum을 끄지 않는다

스냅숏 격리와 쓰기 스큐

스냅숏 격리(Snapshot Isolation, SI) 는 트랜잭션이 시작한 시점의 커밋된 데이터만 보는 격리 방식이다. 읽기는 스냅숏에서 하므로 잠금을 걸지 않고, 쓰기 충돌은 먼저 커밋한 쪽이 이긴다(first-committer-wins). 같은 행을 두 트랜잭션이 고치면 뒤에 커밋하려는 쪽이 실패한다. SQL 표준의 네 수준에는 없는 이름이고, PostgreSQL의 Repeatable Read가 이것이다.

비교Read Committed스냅숏 격리
스냅숏을 잡는 때문장마다트랜잭션의 첫 읽기 때 한 번
같은 트랜잭션에서 다시 읽기그사이 커밋된 값이 보인다(반복 불가능한 읽기)처음 본 값을 계속 본다
같은 행 동시 갱신먼저 쓴 쪽의 커밋을 기다렸다가 최신 값에 다시 적용한다. 갱신 손실이 생길 수 있다뒤에 커밋하는 쪽이 직렬화 오류로 실패한다(PostgreSQL)
쓰기 스큐생긴다생긴다

InnoDB의 Repeatable Read는 순수한 스냅숏 격리와 다르다. 일반 SELECT는 스냅숏을 읽지만 UPDATE·SELECT ... FOR UPDATE는 가장 최신 커밋 버전(current read) 을 읽고 고친다. 그래서 충돌로 실패하지 않는 대신, 스냅숏으로 읽은 값을 보고 계산해 쓰면 갱신 손실이 날 수 있다.

쓰기 스큐(write skew) 는 두 트랜잭션이 같은 조건을 읽고, 서로 다른 행을 고쳐서 둘이 합쳐지면 조건이 깨지는 현상이다. 고친 행이 다르니 쓰기 충돌 검사에 걸리지 않는다.

sequenceDiagram
  participant T1 as T1(A)
  participant DB as oncall
  participant T2 as T2(B)
  Note over T1,T2: 규칙: 당직 의사는 최소 1명 (현재 A, B 두 명이 당직)
  T1->>DB: SELECT count(*) FROM oncall WHERE on_duty
  DB-->>T1: 2
  T2->>DB: SELECT count(*) ...
  DB-->>T2: 2
  Note over T1: 2명이니 빠져도 된다
  T1->>DB: UPDATE oncall SET on_duty=false WHERE doctor='A'
  T2->>DB: UPDATE ... WHERE doctor='B'
  T1->>DB: COMMIT
  T2->>DB: COMMIT
  Note over T1,T2: 당직 0명. 둘 다 각자의 스냅숏에서는 옳았다

막는 방법은 세 가지다. 판단 근거가 된 행을 SELECT ... FOR UPDATE로 잠그거나, 충돌을 표현하는 행(당직표 한 행 같은 잠금용 행)을 두어 실제 쓰기 충돌로 바꾸거나, Serializable 격리를 쓴다.

SSI: 직렬화 가능한 스냅숏 격리

PostgreSQL의 Serializable(9.1부터)은 SSI(Serializable Snapshot Isolation) 다. 스냅숏 격리 위에 「트랜잭션 사이의 읽기-쓰기 의존」을 추적하는 장치를 더해, 직렬 실행으로는 나올 수 없는 결과가 될 위험이 보이면 한쪽을 중단시킨다.

항목스냅숏 격리SSI
읽기스냅숏, 잠금 없음스냅숏 + 읽은 범위를 SIREAD 잠금(술어 잠금)으로 기록. 이 잠금은 아무도 막지 않고 의존 관계를 찾는 데만 쓴다
감지같은 행을 쓴 충돌만내가 읽은 것을 남이 고치는 읽기-쓰기 의존(rw-conflict)이 순환을 만들 수 있는 모양(연속된 두 rw 의존)이 보이면 중단
쓰기 스큐생긴다막는다. 한 트랜잭션이 could not serialize access(SQLSTATE 40001)로 실패한다
비용낮다추적 비용과 오탐에 의한 중단이 있다. 애플리케이션이 재시도해야 한다

잠금으로 Serializable을 만드는 방식(2PL)은 읽기도 다른 쓰기를 막지만, SSI는 아무것도 막지 않고 낙관적으로 진행하다 위험할 때만 중단한다. 읽기 전용 트랜잭션은 READ ONLY DEFERRABLE로 선언하면 중단될 일이 없는 스냅숏을 기다렸다가 받는다.

잠금의 종류와 단위

잠금 모드

잠금허용다른 트랜잭션
공유 잠금(S lock)읽기다른 S 잠금은 함께 걸 수 있다. X 잠금은 기다린다
배타 잠금(X lock)읽기·쓰기어떤 잠금도 함께 걸 수 없다
갱신 잠금(U lock)「곧 고칠 것」이라는 예약. 읽는 동안 S처럼 동작하다 쓸 때 X로 바뀐다두 트랜잭션이 S를 들고 서로 X로 올리려다 생기는 교착을 막는다(SQL Server)
의도 잠금(intention lock)「아래 행 어딘가에 잠금을 걸었다」는 표시를 테이블에 남긴다테이블 잠금을 걸려는 쪽이 모든 행을 뒤지지 않고 충돌을 판단한다
스키마 잠금테이블 구조 변경(DDL) 중 보호구조를 바꾸는 동안 쿼리가 기다린다
대량 갱신 잠금(BU lock)대량 삽입 중 테이블 단위 잠금 하나로 처리잠금 수를 줄여 적재가 빨라진다(SQL Server)
키 범위 잠금인덱스의 범위(빈 틈 포함)를 잠근다이미 읽은 범위에 새 행이 끼어드는 팬텀 읽기를 막는다. InnoDB의 갭 잠금·넥스트 키 잠금이 같은 역할이다

잠금끼리 함께 걸 수 있는지는 호환성 표로 정해진다. 아래는 SQL Server 기준이고, 의도 잠금을 쓰는 다른 DBMS도 뼈대는 같다(IS: 의도 공유, IX: 의도 배타, SIX: 공유 + 의도 배타).

요청/보유     IS   S    U    IX   SIX  X
   IS         ○    ○    ○    ○    ○    ✕
   S          ○    ○    ○    ✕    ✕    ✕
   U          ○    ○    ✕    ✕    ✕    ✕
   IX         ○    ✕    ✕    ○    ✕    ✕
   SIX        ○    ✕    ✕    ✕    ✕    ✕
   X          ✕    ✕    ✕    ✕    ✕    ✕
  • S끼리는 함께 걸려 여러 트랜잭션이 동시에 읽는다. X는 무엇과도 함께 걸리지 않는다.
  • IX끼리 호환되는 것이 핵심이다. 두 트랜잭션이 같은 테이블의 서로 다른 행을 고칠 때 테이블에는 IX가 둘 걸리지만 막히지 않고, 충돌 판정은 행 단위 X 잠금에서 한다.
  • U끼리는 호환되지 않는다. 그래서 「읽고 나서 고치는」 두 트랜잭션이 동시에 S를 X로 올리려다 생기는 교착이 처음부터 줄을 서는 대기로 바뀐다.

잠금 단위

단위장점단점
행다른 행은 동시에 쓸 수 있어 병행성이 높다잠금 수가 많아 관리 비용이 크다
페이지고정 크기 블록 단위. 중간같은 페이지의 무관한 행까지 막는다
테이블구현이 단순하고 비용이 작다병행성이 크게 떨어진다

잠금이 너무 많아지면 DB가 행 잠금을 테이블 잠금으로 합치기도 한다(lock escalation).

비관적 잠금과 낙관적 잠금

항목비관적 잠금(pessimistic)낙관적 잠금(optimistic)
가정충돌이 자주 난다충돌은 드물다
방법고치기 전에 먼저 잠근다. 다른 트랜잭션은 잠금이 풀릴 때까지 기다린다잠그지 않고 진행하고, 커밋할 때 그사이 누가 바꿨는지 검사한다. 바뀌었으면 실패시키고 다시 시도한다
구현SELECT ... FOR UPDATE버전 컬럼이나 수정 시각을 조건에 넣는다
장점충돌이 많아도 정합성이 확실하다. 재시도가 없다잠금 대기가 없어 처리량이 높다
단점대기·교착 상태, 처리량 저하충돌이 잦으면 재시도가 폭증한다
맞는 곳재고 차감, 좌석 예약처럼 같은 행에 경쟁이 몰릴 때게시글 수정, 설정 변경처럼 충돌이 드물 때
-- 비관적: 읽으면서 잠근다
BEGIN;
SELECT stock FROM product WHERE id = 1 FOR UPDATE;
UPDATE product SET stock = stock - 1 WHERE id = 1;
COMMIT;
 
-- 낙관적: 읽은 버전이 그대로일 때만 쓴다
UPDATE product SET stock = 9, version = version + 1
WHERE id = 1 AND version = 3;   -- 영향받은 행이 0이면 누군가 먼저 고쳤다 → 다시 읽고 재시도

DB의 교착 상태

두 트랜잭션이 각자 잠금을 쥔 채 상대의 잠금을 기다리면 교착 상태(deadlock) 다. InnoDB는 쓰기(INSERT·UPDATE·DELETE)에 행 잠금을 걸므로 테이블 접근 순서만 엇갈려도 생긴다.

-- T1                               -- T2
BEGIN; INSERT INTO b VALUES (1);    BEGIN; INSERT INTO a VALUES (1);
INSERT INTO a VALUES (1);  -- T2의 잠금 대기
                                    INSERT INTO b VALUES (1);  -- T1의 잠금 대기
-- ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

대부분의 DBMS는 대기 그래프(wait-for graph)로 교착을 스스로 감지해 한쪽(보통 되돌릴 작업이 적은 쪽)을 롤백한다. MySQL은 innodb_deadlock_detect(기본 켜짐)와 SHOW ENGINE INNODB STATUS의 LATEST DETECTED DEADLOCK, PostgreSQL은 deadlock_timeout(기본 1초) 뒤 검사로 알린다. 감지는 피해를 줄일 뿐이므로 애플리케이션은 오류 코드를 보고 트랜잭션 전체를 재시도해야 한다.

감지 대신 예방하는 방법도 있다. 트랜잭션의 타임스탬프(나이)로 누가 기다리고 누가 물러날지 미리 정해, 순환 대기가 생길 수 없게 한다. Ti가 Tj가 쥔 잠금을 요청했을 때의 처리다.

기법Ti가 더 늙었다 (TS(Ti) < TS(Tj))Ti가 더 젊다
기다리거나 죽기(wait-die)Ti가 기다린다Ti가 죽는다(롤백)
상처 입히거나 기다리기(wound-wait)Tj를 롤백시키고 잠금을 뺏는다Ti가 기다린다

두 기법 모두 롤백되는 쪽은 늘 젊은 트랜잭션이다. wait-die는 비선점(늙은 쪽이 기다린다), wound-wait는 선점(늙은 쪽이 빼앗는다)이다. 롤백된 트랜잭션은 원래 타임스탬프를 유지한 채 다시 시작하므로 시간이 지나면 가장 늙은 트랜잭션이 되어 결국 끝난다(기아 방지). 쓸데없는 롤백이 많아 범용 DBMS는 대기 그래프 감지를 주로 쓰고, 이 기법은 분산 DB처럼 전역 대기 그래프를 만들기 어려운 곳에서 쓴다(Google Spanner는 wound-wait를 쓴다).

희생자를 얼마나 되돌리는지는 DBMS마다 다르다. InnoDB는 희생자 트랜잭션 전체를 롤백하고, PostgreSQL은 트랜잭션을 실패 상태로 만들어 ROLLBACK만 받는다. Oracle은 ORA-00060: deadlock detected를 받은 쪽의 교착을 일으킨 문장 하나만 롤백하고 트랜잭션과 그 앞의 변경·잠금은 남긴다. 그래서 Oracle 애플리케이션은 오류를 받은 뒤 직접 ROLLBACK(또는 남은 작업을 정리하고 COMMIT)해야 상대 트랜잭션이 풀린다.

빈도를 낮추는 방법이유
모든 트랜잭션이 같은 순서로 테이블·행에 접근한다순환 대기가 생기지 않는다(여러 행이면 기본 키 순으로 정렬해 갱신)
트랜잭션을 짧게, 자주 커밋한다잠금을 쥔 시간이 줄어 겹칠 확률이 준다
필요 없는 SELECT ... FOR UPDATE를 줄인다읽기에도 쓰기 잠금이 걸린다
조건 컬럼에 인덱스를 둔다인덱스가 없으면 InnoDB가 훑은 행 전체에 잠금을 걸어 범위가 커진다
경쟁이 심하면 테이블 잠금 등으로 직렬화한다동시성은 떨어지지만 교착은 사라진다

잠금을 쓸 때의 원칙은 공통이다.

  • 잠금은 가능한 짧게 잡는다. 트랜잭션 안에서 외부 API 호출처럼 오래 걸리는 일을 하지 않는다.
  • 잠금은 가능한 작은 단위(테이블보다 행)로 건다.
  • 충돌로 실패한 트랜잭션은 재시도 로직을 둔다.
  • 트랜잭션 범위도 꼭 필요한 코드로 좁힌다. 트랜잭션이 길면 DB 커넥션을 오래 붙잡아 커넥션 풀이 바닥나고 다른 요청이 커넥션을 기다린다.

잠금을 쥔 클라이언트가 사라지면

DB의 잠금은 트랜잭션에 묶여 있고, 트랜잭션은 세션(연결)에 묶여 있다. 그래서 잠금이 영원히 남는 일은 대개 일어나지 않는다.

상황DB의 처리
애플리케이션 프로세스가 죽었다OS가 TCP 연결을 닫는다. 서버는 세션을 끝내며 열린 트랜잭션을 롤백하고 잠금을 푼다
네트워크가 조용히 끊겼다서버는 연결이 살아 있는 줄 안다. TCP keepalive(PostgreSQL tcp_keepalives_idle 등)나 유휴 연결 타임아웃(MySQL wait_timeout)이 끊긴 연결을 찾아낸 뒤 롤백한다. 그때까지 잠금이 남는다
클라이언트는 살아 있는데 트랜잭션을 연 채 멈췄다DB 입장에서는 정상이라 잠금이 계속 남는다. 가장 위험한 경우다

마지막 경우는 시간 제한으로 막는다.

장치효과
잠금 대기 시간 제한 (MySQL innodb_lock_wait_timeout 기본 50초, PostgreSQL lock_timeout)기다리는 쪽이 포기하게 한다. 잠금을 쥔 쪽은 그대로다
트랜잭션 안 유휴 시간 제한 (PostgreSQL idle_in_transaction_session_timeout)트랜잭션을 연 채 놀고 있는 세션을 끊어 잠금을 푼다
문장 실행 시간 제한 (PostgreSQL statement_timeout, MySQL max_execution_time은 SELECT만)오래 도는 쿼리를 끊는다
수동 정리잠금을 쥔 세션을 찾아(MySQL performance_schema.data_locks·information_schema.innodb_trx, PostgreSQL pg_locks·pg_stat_activity) KILL이나 pg_terminate_backend()로 끊는다

DB 밖에서 만든 분산 락(Redis 키 등)은 세션이 없으므로 만료 시간(TTL)을 반드시 둔다. 만료 뒤에 옛 주인이 늦게 쓰는 일을 막으려면 락을 얻을 때마다 커지는 번호(펜싱 토큰)를 함께 쓴다.

분산 트랜잭션

한 DB의 ACID는 그 서버 안에서만 성립한다. 주문 DB와 결제 DB처럼 여러 저장소에 걸친 작업을 하나로 묶으려면 다른 장치가 필요하다.

2단계 커밋 (2PC)

코디네이터가 참여자(각 DB)에게 두 단계로 묻는다.

sequenceDiagram
  participant C as 코디네이터
  participant P as 참여자들
  Note over C,P: 1단계 준비(prepare)
  C->>P: 커밋할 수 있나?
  Note over P: 변경을 로그에 남기고 잠금을 쥔 채 투표
  P-->>C: 예/아니오
  Note over C,P: 2단계 확정(commit)
  alt 모두 "예"
    Note over C: 커밋 결정을 기록
    C->>P: 커밋
  else 하나라도 "아니오"·응답 없음
    C->>P: 롤백
  end
  • 준비에 「예」라고 답한 참여자는 혼자 결정할 수 없다. 코디네이터가 2단계 직전에 죽으면 참여자는 잠금을 쥔 채 결과를 기다린다(in-doubt). 2PC가 블로킹 프로토콜이라 불리는 이유다.
  • 모든 참여자를 기다리므로 가장 느린 참여자가 전체 지연을 정하고, 하나만 죽어도 전체가 실패한다.
  • 표준 인터페이스는 X/Open XA다. MySQL은 XA START ... XA PREPARE ... XA COMMIT, PostgreSQL은 PREPARE TRANSACTION(기본값 max_prepared_transactions = 0이라 켜야 쓴다)을 제공한다. 남겨진 준비된 트랜잭션은 잠금과 VACUUM을 막으므로 감시한다.
  • Spanner·CockroachDB 같은 분산 SQL도 내부적으로 2PC를 쓰되, 참여자 각각을 합의(Raft·Paxos)로 복제해 코디네이터 장애에도 결정이 사라지지 않게 한다.

사가와 최종 일관성

마이크로서비스처럼 참여자가 서로 다른 팀·기술의 서비스면 2PC의 잠금과 결합을 견디기 어렵다. 그래서 하나의 큰 트랜잭션 대신 로컬 트랜잭션의 연쇄로 나누고, 중간에 실패하면 앞 단계를 취소하는 보상 트랜잭션을 실행하는 사가(saga) 를 쓴다. 격리성을 포기하는 대신 가용성을 얻는다. 사가의 구현(오케스트레이션·코레오그래피)과 아웃박스 패턴은 데이터 관리 패턴에 있다.

비교2PC사가
일관성강한 일관성(원자적 커밋)최종 일관성. 중간 상태가 남에게 보인다
잠금준비부터 확정까지 쥔다단계마다 로컬 트랜잭션으로 짧게
실패 처리롤백보상 트랜잭션(환불, 재고 복구)을 직접 설계
맞는 곳한 조직이 관리하는 소수의 DB서비스 경계를 넘는 긴 업무 흐름

회복 기법 (Recovery)

장애가 났을 때 DB를 일관된 상태로 되돌린다. 로그에 기록된 정보로 REDO(커밋된 작업을 다시 반영)와 UNDO(커밋 안 된 작업을 취소)를 수행한다.

기법방식회복 시
지연(연기) 갱신커밋될 때까지 DB 반영을 미룸REDO만
즉시 갱신변경을 즉시 DB에 반영REDO와 UNDO 모두
그림자 페이징페이지 복사본(그림자)을 두고 실패 시 복사본으로 교체로그 불필요
검사점(Checkpoint)주기적으로 검사점을 로그에 기록최근 검사점 이후만 회복 → 시간 단축
미디어 회복주기적으로 DB를 다른 저장 장치에 덤프디스크 손상 대비

버퍼 관리 정책이 UNDO·REDO를 정한다

DBMS는 데이터를 고정 크기 페이지 단위로 읽고 쓰며, 메모리의 페이지는 버퍼 관리자가 관리한다. 수정된 페이지를 언제 디스크에 내리느냐에 따라 필요한 회복이 달라진다.

정책뜻결과
STEAL커밋 전이라도 수정된 페이지를 디스크에 쓸 수 있다미완료 변경이 디스크에 남을 수 있으므로 UNDO 필요
¬STEAL트랜잭션이 끝날 때까지 수정 페이지를 버퍼에 붙잡는다UNDO 불필요. 대신 버퍼가 커야 한다
FORCE커밋 시점에 수정 페이지를 모두 디스크에 쓴다REDO 불필요. 커밋이 느리다
¬FORCE커밋 시점에 데이터 페이지는 쓰지 않고 로그만 쓴다커밋된 변경이 디스크에 없을 수 있으므로 REDO 필요

대부분의 DBMS는 버퍼를 자유롭게 쓰고 커밋을 빠르게 하려고 STEAL + ¬FORCE를 고른다. 그래서 UNDO와 REDO가 둘 다 필요하고, 그 정보를 로그에 남긴다.

로그와 WAL

로그는 갱신을 순서대로 덧붙여 쓰는 기록이고, 레코드마다 단조 증가하는 LSN(Log Sequence Number) 이 붙는다.

로깅 방식기록하는 것특징
물리적 상태 로깅갱신 전 이미지와 후 이미지가장 흔하다. 이미지를 덮어쓰기만 하면 되므로 복구가 멱등이다
물리적 전이 로깅두 이미지의 XOR 차이로그가 작다
논리적 전이 로깅수행한 연산 자체(a = a + 1)로그가 작고, 페이지 분할·병합으로 위치가 바뀌는 B+트리 같은 구조에 적합하다. 다시 실행하면 결과가 달라질 수 있어 멱등성을 따로 챙긴다

실제 제품은 이 방식들을 섞어 쓴다. 로그 쓰기에는 두 규칙이 있다.

  1. WAL(Write-Ahead Logging): 데이터 페이지가 디스크에 쓰이기 전에 그 변경의 UNDO 정보가 먼저 로그에 쓰여야 한다.
  2. 커밋이 끝나기 전에 그 트랜잭션의 REDO 정보가 로그 파일에 쓰여야 한다.

PostgreSQL의 WAL은 pg_wal 디렉터리에 16MB 세그먼트 파일로 쌓인다. PostgreSQL은 undo 로그가 없으므로(옛 버전이 힙에 남는다) WAL은 REDO 정보만 담는다.

역할동작
크래시 복구마지막 체크포인트(checkpoint_timeout 기본 5분, max_wal_size 기본 1GB 중 먼저 닿는 쪽)의 시작 지점부터 WAL을 다시 적용한다. 체크포인트 뒤 페이지를 처음 고칠 때는 페이지 전체를 WAL에 남겨(full_page_writes) 반쯤 쓰인 페이지(torn page)도 복구한다
물리 복제스트리밍 복제는 WAL 레코드를 그대로 대기 서버로 보내 다시 적용한다. synchronous_standby_names를 정하면 커밋이 대기 서버의 확인을 기다린다
논리 복제 · CDCwal_level = logical이면 WAL을 행 단위 변경으로 해석(논리 디코딩)해 다른 DB나 Debezium 같은 CDC 도구로 보낸다
시점 복구(PITR)베이스 백업 + 보관(archive)한 WAL을 원하는 시각까지 적용한다

복제 슬롯이 WAL을 붙잡고 있는데 소비자가 멈추면 pg_wal이 디스크를 채운다. 쓰지 않는 슬롯은 지운다.

커밋 비용 줄이기

커밋 시간의 대부분은 로그를 쓰고 fsync로 디스크 반영을 기다리는 시간이다.

기법방식대가
그룹 커밋잠깐 기다렸다가 여러 트랜잭션의 커밋 로그를 한 번의 fsync로 내린다대기 시간을 길게 잡으면 응답이 느려진다
느슨한 플러시커밋마다가 아니라 주기적으로 fsync한다 (MySQL InnoDB의 innodb_flush_log_at_trx_commit 값 0·2)장애 시 최근 1초 안팎의 커밋을 잃을 수 있다
비동기 커밋로그 버퍼에만 쓰고 커밋을 끝낸다 (PostgreSQL synchronous_commit = off)지속성을 일부 포기한다

재시작 복구의 세 단계

장애 뒤 재시작할 때 ARIES 계열 DBMS는 다음 순서로 복구한다.

flowchart TB
  a["1. 분석<br/>마지막 검사점부터 로그 끝까지 훑어 복구 시작점과 미완료 트랜잭션을 찾는다"]
  r["2. REDO<br/>시작점부터 장애 직전까지 모든 로그를 다시 적용한다 (실패한 트랜잭션 것도)<br/>→ #quot;역사를 반복#quot;해 장애 시점 상태를 그대로 재현한다"]
  u["3. UNDO<br/>로그를 거꾸로 훑으며 미완료 트랜잭션을 철회한다"]
  a --> r --> u
  • 페이지마다 마지막으로 반영된 로그의 page LSN을 적어 두어, 이미 반영된 로그는 건너뛴다.
  • UNDO를 할 때마다 보상 로그 레코드(CLR, Compensation Log Record) 를 남긴다. 복구 중에 또 장애가 나도 이미 되돌린 작업을 다시 되돌리지 않는다.
  • 트랜잭션 하나를 철회할 때도 로그를 역방향으로 따라가며 같은 방식으로 UNDO한다.
  • 운영 중에 뜨는 퍼지 백업(fuzzy backup) 은 미완료 변경이 섞인 스냅숏이다. 백업과 그 뒤의 로그를 함께 적용(roll-forward)해 일관된 시점으로 되살린다. 로그가 남아 있으면 원하는 시점까지 복원할 수 있다(PITR).

장애 대응 지표

  • RTO(Recovery Time Objective, 목표 복구 시간): 장애 발생부터 업무가 정상화될 때까지 허용되는 시간
  • RPO(Recovery Point Objective, 목표 복구 시점): 데이터를 어느 시점까지 되살려야 하는지(허용 가능한 데이터 손실량)

핵심 정리

  • 원자성·지속성 ↔ 회복, 격리성 ↔ 고립화 수준·로킹, 일관성 ↔ 무결성 제약·병행 제어.
  • 병행 제어의 목표는 직렬 가능한 스케줄이다. 판정은 선행 그래프의 사이클로 하고, 2PL을 지키면 충돌 직렬 가능해진다. 연쇄 복귀까지 막으려면 엄격 2PL로 X 잠금을 커밋까지 쥔다.
  • 지연 갱신은 REDO만, 즉시 갱신은 REDO + UNDO. 실제 DBMS는 STEAL + ¬FORCE라서 WAL 로그로 둘 다 한다.
  • MVCC는 버전을 남겨 읽기와 쓰기가 서로 막지 않게 하고, 쓰기끼리의 충돌은 잠금으로 막는다.
  • 경쟁이 몰리는 데이터는 비관적 잠금, 충돌이 드문 데이터는 낙관적 잠금이 맞다.
  • 스냅숏 격리도 쓰기 스큐를 막지 못한다. 막으려면 판단 근거 행을 잠그거나 SSI(PostgreSQL Serializable)를 쓴다.
  • 여러 DB에 걸친 작업은 2PC(강한 일관성, 블로킹) 또는 사가(보상 트랜잭션, 최종 일관성)로 묶는다.

로킹으로 상호 배제를 구현하는 발상은 운영체제의 프로세스 동기화와 같고, 트랜잭션끼리 서로의 잠금을 기다리면 교착 상태가 생길 수 있다. SQL이 실행될 때 잠금 관리자가 어디에 끼는지는 DB 내부 구조: 쿼리 실행과 인덱스 자료 구조에 있다. Oracle이 undo·redo 로그와 로그 기록 프로세스(LGWR)로 커밋을 처리하는 구조는 DB 내부 구조에 있다.

원본 기록11

댓글

GitHub 계정으로 댓글을 남길 수 있어요.