Transaction

RDBMS Transaction에 대해서 알아보겠습니다.

차례 (23)

계좌 A에서 B로 50달러를 송금하려면 A의 잔액을 차감하고 B의 잔액을 늘려야 합니다. A만 차감한 시점에 서버가 멈추면 두 계좌의 합계가 줄어듭니다. 트랜잭션은 이 연산들을 하나의 논리적 작업 단위로 묶습니다.

송금 연산과 ACID

read(A)
A := A - 50
write(A)
read(B)
B := B + 50
write(B)

read는 데이터 항목을 트랜잭션의 지역 변수로 읽고, write는 계산한 값을 데이터 항목에 반영하는 의사코드입니다. 초기 잔액을 A=200, B=100으로 두면 송금 후에는 A=150, B=150이 됩니다. 합계 300을 유지해야 한다는 조건이 이 예시의 불변식입니다.

DBMS가 다뤄야 할 문제는 장애와 동시 실행입니다. 하드웨어 고장이나 프로세스 종료가 연산을 끊을 수 있고, 다른 트랜잭션이 아직 끝나지 않은 변경을 읽을 수도 있습니다.

Atomicity — 전부 반영하거나 전부 취소합니다

송금이 끝나면 커밋하고, 중간에 실패하면 되돌립니다위는 A와 B의 변경을 함께 확정하고, 아래는 A의 차감 후 실패해 원래 잔액으로 돌아갑니다. 원은 송금 작업의 진행을 나타내며, 잔액은 쓰기 시점에 바뀝니다.
커밋$50계좌 A$200계좌 B$100시작 · A $200 + B $100 = $300중간 실패$50계좌 A$200계좌 B$100시작 · A $200 + B $100 = $300

write(A) 이후 write(B) 이전에 실패했다면 A의 차감만 남겨서는 안 됩니다. 원자성은 트랜잭션의 변경을 모두 반영하거나 하나도 반영하지 않는 성질입니다. 복구 시스템은 실패한 트랜잭션의 부분 변경이 최종 상태에 남지 않도록 처리합니다.

Consistency — 업무의 불변식을 유지합니다

송금 전후에 유지할 것은 A와 B 각각의 값이 아니라 합계입니다. 트랜잭션의 내부 실행 중에는 A만 차감된 중간 상태가 생길 수 있지만, 성공한 작업은 정해진 무결성 조건을 만족해야 합니다.

DBMS는 선언된 기본 키, 외래 키, 검사 제약 등을 검사합니다. 개발자는 송금 양쪽 금액이 같은지와 같은 업무 규칙을 트랜잭션 로직으로 구현해야 합니다. 잘못 작성한 송금 로직까지 DBMS가 자동으로 바로잡지는 못합니다. 중간 상태를 다른 트랜잭션에 얼마나 노출할지는 격리성의 문제입니다.

Isolation — 동시 실행의 간섭을 제어합니다

송금 도중 다른 트랜잭션이 A=150, B=100을 읽으면 합계가 250으로 보입니다. 격리성은 동시 실행 사이에 이런 간섭이 발생하는 범위를 제어하는 성질입니다. 가장 강한 SQL 격리 수준인 Serializable에서는 성공한 트랜잭션들의 실행이 어떤 직렬 실행과 동등해야 합니다.

실행 순서를 바꿔도 결과가 같아야 한다는 뜻은 아닙니다. A에서 50을 옮기는 작업과 A의 10%를 옮기는 작업은 순서에 따라 최종 잔액이 달라집니다. 두 순서 모두 합계는 유지할 수 있습니다.

Durability — 커밋한 변경을 보존합니다

커밋을 성공으로 알린 변경은 시스템 장애 후에도 남아야 합니다. 실제 보장 범위는 로그 기록과 동기화 설정, 저장 장치의 장애 모델에 달려 있습니다.

이미 커밋한 송금을 같은 트랜잭션의 ROLLBACK으로 취소할 수는 없습니다. 업무상 취소가 필요하면 반대 방향의 송금처럼 보상하는 새 트랜잭션을 실행합니다.

비일관성이 나타나는 범위

원문은 문제를 속성, 튜플, 릴레이션 범위로 나눕니다. 이는 아래 격리 수준과 일대일로 대응하는 분류가 아닙니다.

속성 — 갱신 유실

같은 수량을 읽고 쓰면 한쪽 변경이 사라질 수 있습니다설명용 실행 순서입니다. QTY=10에서 T1은 1, T2는 2를 차감합니다. 직렬 실행 결과는 7이지만 마지막 쓰기로 8이 남습니다.
T1read 10—write 9—commitT2—read 10—write 8commitQTY1010988

수량이 10일 때 T1이 1을, T2가 2를 차감한다고 가정합니다. 두 트랜잭션이 모두 10을 읽은 뒤 각각 9와 8을 쓰면 마지막 값은 8입니다. 순서대로 차감했을 때의 7과 다릅니다. 애플리케이션이 읽은 값으로 새 값을 계산해 덮어쓰는 상황을 모델링한 예시이며, 실제 SQL의 잠금 동작은 엔진과 질의에 따라 달라집니다.

튜플 — 같은 행의 속성 사이 관계

T1이 수량을 바꾸고 T2가 그 수량에 의존하는 다른 속성을 계산한다면, T2가 어느 버전의 수량을 읽었는지가 결과를 결정합니다. 같은 행의 서로 다른 열을 갱신한다는 사실만으로 안전성을 판단할 수는 없습니다. 연산 사이의 의존 관계를 확인해야 합니다.

릴레이션 — 조건에 맞는 행 집합

T1이 가격을 800,000에서 400,000으로 바꾸는 동안 T2가 가격 조건으로 할인 대상을 고른다고 가정합니다. T2가 변경 전 가격을 기준으로 이미 지나간 행은 변경 후 조건에 들어오더라도 처리하지 않을 수 있습니다. 한 행의 값뿐 아니라 조건에 맞는 행의 집합과 읽기 시점도 문제가 됩니다.

트랜잭션의 시작과 종료

START TRANSACTION;
UPDATE account SET balance = balance - 500 WHERE account_id = 3209;
UPDATE account SET balance = balance + 500 WHERE account_id = 3208;
COMMIT;

위 SQL은 MySQL 8.4의 트랜잭션 지원 테이블을 가정한 경계 표시용 예시입니다. 실제 송금에는 계좌 존재 여부, 잔액 조건, 실패 처리와 거래 기록이 더 필요합니다. 원문의 journal 기록도 양쪽 잔액 변경과 같은 트랜잭션에 포함할 대상입니다.

COMMIT은 변경을 확정하고 ROLLBACK은 아직 커밋하지 않은 변경을 취소합니다. Autocommit 모드에서는 보통 SQL 문장 하나가 트랜잭션 하나가 되므로, 두 UPDATE를 묶으려면 명시적 경계가 필요합니다. COMMIT WORK의 WORK는 선택적 키워드입니다.

DDL을 모두 같은 방식으로 취급해서는 안 됩니다. MySQL 8.4에서는 CREATE TABLE, ALTER TABLE 등 여러 DDL이 암묵적 커밋을 일으킵니다. 문장별 예외는 암묵적 커밋을 발생시키는 문장 목록에 명시되어 있습니다.

장애 복구와 저장 장치

로그 기반 복구에서는 이미 확정한 변경을 다시 적용하는 REDO와 미완료 변경을 취소하는 UNDO를 구분합니다. DBMS마다 복구 방식은 다르지만, 장애 후 어떤 변경을 남기고 어떤 변경을 버릴지 판정할 기록이 필요하다는 점은 같습니다.

WAL(Write-Ahead Logging)은 변경된 데이터 페이지를 영구 저장소에 쓰기 전에 그 변경을 설명하는 로그를 먼저 영속화하는 규칙입니다. 로그로 복원할 수 있으므로 매 커밋마다 모든 데이터 페이지를 디스크에 쓸 필요는 없습니다. 메모리 값을 바꾸기 전마다 디스크에 로그를 동기화해야 한다는 뜻과는 구분해야 합니다. PostgreSQL 18 WAL 설명은 이 기록 순서와 REDO 복구를 설명합니다.

RAID의 중복성과 한계

RAID는 여러 디스크에 데이터를 배치하는 방식입니다. 스트라이핑은 데이터 블록을 여러 디스크에 나누고, 미러링은 같은 데이터를 복제합니다. 원문의 RAID 0, 1, 5, 10을 같은 용량의 디스크 기준으로 비교하면 다음과 같습니다.

구성데이터 배치사용 가능 용량디스크 장애 허용 범위
RAID 0여러 디스크에 스트라이핑전체 용량중복성이 없어 한 대 고장도 데이터 손실로 이어질 수 있음
RAID 1두 디스크에 동일한 내용 복제두 대 구성에서 50%한 대
RAID 5데이터와 패리티를 분산N대 중 N−1대 용량한 대
RAID 10미러 쌍 사이에 스트라이핑두 벌 복제에서 50%각 미러 쌍에 한 대 이상 살아 있어야 함

RAID 5는 최소 세 대로 구성하며, 한 디스크의 데이터가 사라졌을 때 나머지 데이터와 패리티로 복원합니다. 비트 단위 예시로 1 XOR 0 = 1이라면, 첫 번째 비트를 잃어도 0 XOR 1 = 1로 구할 수 있습니다. 두 디스크를 동시에 잃었을 때 복구하는 보장은 없습니다.

RAID 0의 처리량이 항상 두 배가 되거나 대부분의 DBMS가 RAID 10을 쓴다고 일반화할 근거는 없습니다. 실제 처리량과 장애 내성은 구성과 워크로드에 따라 다릅니다. RAID를 갖춰도 부분적인 송금을 되돌리는 트랜잭션 복구는 따로 필요합니다. Linux MD도 dirty 상태이면서 degraded인 RAID 5/6의 데이터 손상 가능성을 명시합니다.

트랜잭션의 상태

마지막 명령의 실행과 커밋은 다른 상태입니다정상 완료 경로와 실패 후 롤백 경로를 구분합니다. 재시도는 새 실행으로 시작합니다.
Active명령 실행 중Partiallycommitted · 명령 완료Committed성공적으로 완료Failed실행 지속 불가Aborted롤백 완료
  • 서비스와 작업
  1. 마지막 명령을 실행한 뒤 커밋을 완료해야 성공을 확정합니다.
  2. 실행 도중 실패하면 변경을 롤백하고 Aborted 상태로 끝납니다.
  3. 마지막 명령을 실행했더라도 커밋 전에 실패할 수 있습니다.

Active는 명령을 실행하는 상태이고 Partially committed는 마지막 명령까지 실행한 상태입니다. 이 시점에도 실패할 수 있습니다. 성공적으로 커밋한 뒤에 Committed가 됩니다.

더 이상 정상 실행을 계속할 수 없으면 Failed로, 롤백을 마치면 Aborted로 이동합니다. 중단 원인이 재시도로 해결할 수 있는 문제라면 새 실행을 시작할 수 있습니다. 이 재실행과 복구 로그를 재적용하는 REDO는 다른 동작입니다.

동시 실행과 스케줄

한 트랜잭션이 디스크를 기다리는 동안 다른 트랜잭션이 CPU를 사용할 수 있습니다. 짧은 작업이 긴 작업 전체를 기다리지 않도록 연산을 섞을 수도 있습니다. 이런 동시 실행은 단일 코어에서도 가능하고, 여러 코어에서 실제로 동시에 실행하는 병렬성과 구분합니다.

스케줄은 여러 트랜잭션의 연산을 실행 순서대로 나열한 것입니다. 각 트랜잭션 내부의 연산 순서는 보존해야 합니다. 성공한 실행은 commit, 실패한 실행은 abort로 끝난다고 가정합니다.

직렬 실행의 순서가 결과를 바꿉니다

T1은 A에서 B로 50을 옮기고, T2는 읽은 시점의 A 잔액 중 10%를 B로 옮깁니다. 초기값은 앞과 같이 A=200, B=100입니다.

Schedule 1: T1 → T2
(200, 100) → (150, 150) → (135, 165)

Schedule 2: T2 → T1
(200, 100) → (180, 120) → (130, 170)

두 스케줄 모두 합계 300을 유지합니다. 결과가 서로 같을 필요는 없습니다. 다만 업무가 특정 선후 관계를 요구한다면 애플리케이션이 그 순서를 별도로 보장해야 합니다.

연산을 섞어도 직렬 실행과 동등할 수 있습니다

아래부터 r1(A)는 T1의 A 읽기, w2(B)는 T2의 B 쓰기, c1은 T1의 커밋을 뜻합니다. 지역 변수 계산은 생략합니다.

Schedule 3:
r1(A) w1(A) r2(A) w2(A) r1(B) w1(B) c1 r2(B) w2(B) c2

Schedule 4:
r1(A) r2(A) w2(A) w1(A) r2(B) r1(B) w1(B) c1 w2(B) c2

Schedule 3에서는 A와 B 모두 T1의 변경을 T2가 이어받습니다. T1과 T2가 섞여 있지만 T1 다음 T2를 실행한 결과인 A=135, B=165가 나옵니다.

Schedule 4에서는 T2가 A=200을 읽고 180을 쓰지만 T1이 150으로 덮습니다. B에서는 두 트랜잭션이 모두 100을 읽고, T1이 150을 쓴 뒤 T2가 120으로 덮습니다. 최종 합계는 270이므로 불변식을 깨뜨립니다.

충돌 직렬 가능성

각 트랜잭션은 단독 실행하면 일관성을 보존한다고 가정합니다. 이때 동시 실행 스케줄이 어떤 직렬 실행과 동등하면 직렬 가능(serializable)하다고 부릅니다. 동등성을 정의하는 기준에 따라 충돌 직렬 가능성과 뷰 직렬 가능성으로 나뉩니다. 여기서는 읽기와 쓰기의 충돌 순서를 기준으로 판단합니다.

충돌하는 연산

서로 다른 트랜잭션이 같은 데이터 항목에 접근하고, 적어도 하나가 쓰기이면 두 연산은 충돌합니다.

먼저 실행한 연산뒤에 실행한 연산충돌 여부
read(Q)read(Q)없음
read(Q)write(Q)있음
write(Q)read(Q)있음
write(Q)write(Q)있음

read(B)와 write(A)처럼 대상이 다르면 충돌하지 않습니다. 서로 다른 트랜잭션의 인접한 비충돌 연산은 순서를 바꿀 수 있습니다. 충돌이 있다는 사실만으로 스케줄이 잘못된 것은 아닙니다. 충돌하는 연산들의 선후 관계가 하나의 직렬 순서와 양립하는지를 봐야 합니다.

비충돌 연산을 교환해 S를 S′로 바꿀 수 있으면 두 스케줄은 conflict equivalent입니다. S를 이런 교환만으로 직렬 스케줄로 바꿀 수 있으면 S는 conflict serializable입니다. Schedule 3에서는 A에 대한 T2의 연산과 B에 대한 T1의 연산 순서를 교환해 T1 다음 T2의 직렬 스케줄을 만들 수 있습니다.

최종값이 같다는 사실만으로 충돌 동등성을 증명할 수는 없습니다. 어떤 값을 누가 읽고 썼는지, 충돌 연산의 순서가 보존되는지도 확인해야 합니다.

선행 그래프로 검사합니다

트랜잭션을 정점으로 놓고, Ti의 연산이 Tj의 연산보다 먼저 나타나면서 둘이 충돌하면 Ti → Tj 간선을 추가합니다. 선행 그래프(precedence graph)에 사이클이 없을 때, 그리고 그때만 충돌 직렬 가능합니다. 사이클이 없으면 위상 정렬로 동등한 직렬 순서를 구할 수 있습니다.

서로 반대인 선행 조건은 사이클을 만듭니다간선은 충돌 연산이 강제하는 순서입니다. 실행 중인 잠금 대기 그래프와는 구분합니다.
A: w1 → r2B: r2 → w1T1T2
  • 서비스와 작업
  1. A에 대한 w1(A), r2(A)는 T1이 T2보다 앞서야 한다는 조건을 만듭니다.
  2. B에 대한 r2(B), w1(B)는 반대 순서를 요구합니다. 두 조건을 만족하는 직렬 순서가 없습니다.

그림은 w1(A) r2(A) r2(B) w1(B)라는 별도 스케줄의 충돌을 보여줍니다. A에서는 T1이 앞서야 하고 B에서는 T2가 앞서야 하므로 직렬 순서를 만들 수 없습니다. 이 판정과 상태 정의는 Database System Concepts 7판, 17장 강의 자료의 Transaction State, Conflict Serializability, Testing for Serializability 절을 대조했습니다.

회복 가능한 스케줄과 연쇄 롤백

직렬 가능성이 연산의 순서를 다룬다면, 회복 가능성은 다른 트랜잭션의 변경을 읽은 뒤 언제 커밋해도 되는지를 다룹니다.

읽기와 커밋의 순서가 회복 가능성을 가릅니다r2(A)는 T1이 쓴 A를 읽습니다. 위 행은 회복 불가, 가운데는 회복 가능, 아래는 연쇄 롤백을 방지하는 순서입니다.
회복 불가w1(A)r2(A)commit 2abort 1회복 가능w1(A)r2(A)commit 1commit 2연쇄 방지w1(A)commit 1r2(A)commit 2

T2가 T1이 쓴 값을 읽었다면, 회복 가능한 스케줄에서는 T1의 커밋이 T2의 커밋보다 앞서야 합니다. T2가 읽기 전에 T1이 커밋해야 한다는 조건은 이보다 강합니다.

회복 불가: w1(A) r2(A) c2 abort1
회복 가능: w1(A) r2(A) c1 c2
연쇄 방지: w1(A) c1 r2(A) c2

첫 번째 순서에서는 T2가 이미 커밋했는데 T1이 중단됩니다. T2가 읽은 근거를 취소해야 해도 T2를 같은 트랜잭션으로 롤백할 수 없습니다.

두 번째 순서에서는 T2가 커밋하기 전에 T1의 성공을 확인합니다. 하지만 T1이 커밋 대신 중단했다면 아직 미완료인 T2도 롤백해야 합니다. T2가 쓴 값을 T3가 읽었다면 T3까지 취소가 이어질 수 있고, 이를 연쇄 롤백(cascading rollback)이라고 부릅니다.

세 번째 순서는 T1이 커밋한 뒤에만 T2가 그 값을 읽습니다. 이런 cascadeless schedule은 미커밋 데이터 읽기 때문에 생기는 연쇄 롤백을 막습니다. 모든 cascadeless schedule은 recoverable하지만 그 역은 성립하지 않습니다.

SQL 격리 수준

Serializable보다 약한 격리 수준은 일부 이상 현상을 허용합니다. 허용 범위가 넓다고 특정 워크로드의 처리량이 반드시 높아지는 것은 아닙니다. 읽기 전용 트랜잭션도 여러 질의 사이의 일관된 상태가 필요할 수 있으므로, 읽기만 한다는 이유로 요구 조건을 생략할 수는 없습니다.

Dirty read, nonrepeatable read, phantom read

Dirty read는 다른 트랜잭션이 아직 커밋하지 않은 변경을 읽는 현상입니다. 그 변경이 롤백되면 존재하지 않는 값을 근거로 계산한 셈이 됩니다.

Nonrepeatable read는 같은 행을 다시 읽었을 때 다른 트랜잭션의 커밋 때문에 값이 달라지는 현상입니다. T2의 첫 조회 → T1의 변경과 커밋 → T2의 두 번째 조회 순서에서 발생할 수 있습니다.

Phantom read는 같은 검색 조건을 다시 적용했을 때 조건에 맞는 행 집합이 바뀌는 현상입니다. 새 행의 삽입뿐 아니라 삭제나 조건 열의 갱신도 집합을 바꿀 수 있습니다.

표준의 허용 범위와 구현 차이

격리 수준Dirty readNonrepeatable readPhantom read직렬화 이상
Read Uncommitted허용 가능허용 가능허용 가능허용 가능
Read Committed방지허용 가능허용 가능허용 가능
Repeatable Read방지방지허용 가능허용 가능
Serializable방지방지방지방지

Read Committed가 다른 트랜잭션의 실행 자체를 막는다는 뜻은 아닙니다. MVCC 구현은 변경 중인 행의 이전 커밋 버전을 읽을 수 있습니다. Repeatable Read에서 팬텀을 허용할 수 있다는 표준의 범위와 실제 엔진이 팬텀을 노출하는지도 구분해야 합니다.

예를 들어 PostgreSQL 18의 기본값은 Read Committed이고, Read Uncommitted를 요청해도 Read Committed로 동작합니다. PostgreSQL의 Repeatable Read는 팬텀도 방지하지만 직렬화 이상까지 모두 방지하지는 않습니다. 표준의 현상별 구분과 이 구현 차이는 PostgreSQL 18 트랜잭션 격리 문서에 명시되어 있습니다. 사용할 DBMS의 기본값과 스냅샷·잠금 동작은 격리 수준 이름과 함께 확인해야 합니다.

공유

관련 글

Query Processing

SQL이 실행 계획으로 바뀌는 과정과 디스크 I/O 비용 모델을 바탕으로 선택·외부 정렬·조인 알고리즘의 비용을 비교합니다.