ACID, 격리 수준, Redo/Undo — DB 트랜잭션 정리 (DB 총정리 2/4)
DB 총정리 둘째 편은 트랜잭션이다. ACID, 격리 수준, 복구(Redo/Undo)는 따로 외우면 셋이지만 사실 한 묶음이다. ACID는 트랜잭션이 지켜야 할 약속이고, 격리 수준은 그중 I(격리성)를 성능과 맞바꾸는 다이얼이며, Redo/Undo는 A(원자성)와 D(지속성)를 장애 속에서도 지키는 장치다.
ACID: 트랜잭션의 네 가지 약속
트랜잭션은 논리적으로 하나인 작업 단위다. 계좌 이체가 늘 예제로 나오는 데는 이유가 있다. A 잔고를 빼는 일과 B 잔고를 늘리는 일은 둘 다 되거나 둘 다 안 되어야 한다.
START TRANSACTION;UPDATE account SET balance = balance - 200000 WHERE id = 'A';UPDATE account SET balance = balance + 200000 WHERE id = 'B';COMMIT; -- 여기까지 오기 전에는 언제든 ROLLBACK으로 전부 취소할 수 있다A · Atomicity 원자성
전부 되거나 전부 안 된다. 중간에 죽으면 한 일을 모두 되돌린다. DBMS가 Undo 로그로 지킨다.
C · Consistency 일관성
트랜잭션 앞뒤로 제약 조건과 비즈니스 규칙이 깨지지 않는다. 무엇이 규칙인지는 개발자가 정한다.
I · Isolation 격리성
동시에 돌아도 서로 없는 것처럼 동작한다. 완벽하게 하면 느려서 격리 수준으로 타협한다.
D · Durability 지속성
커밋한 것은 정전이 나도 남는다. DBMS가 Redo 로그(WAL)로 지킨다.
A와 D는 DBMS가 알아서 해 준다. 하지만 C는 무엇이 일관된 상태인지 개발자가 제약 조건과 검증 로직으로 정의해야 하고, I는 개발자가 격리 수준을 골라야 한다. “트랜잭션으로 감쌌으니 안전하겠지”가 통하지 않는 곳이 바로 C와 I다.
격리 수준: 이상 현상 세 가지와 다이얼 네 단계
격리성을 완벽하게, 즉 트랜잭션을 하나씩 차례로 실행한 것과 똑같이 보장하면 동시성이 사라진다. 그래서 SQL 표준은 어떤 이상 현상까지 참을지를 네 단계로 정해 두었다. 이상 현상부터 보자.
- Dirty Read: 다른 트랜잭션이 아직 커밋하지 않은 값을 읽는다. 그 트랜잭션이 롤백하면 나는 존재한 적 없는 값을 읽은 셈이다.
- Non-repeatable Read: 한 트랜잭션 안에서 같은 row를 두 번 읽었는데 값이 다르다. 그 사이에 다른 트랜잭션이 UPDATE하고 커밋했다.
- Phantom Read: 같은 조건으로 두 번 조회했는데 처음엔 없던 row가 나타난다. 그 사이에 다른 트랜잭션이 INSERT하고 커밋했다.
기본값이 DBMS마다 다르다는 게 은근한 함정이다. MySQL(InnoDB)은 REPEATABLE READ, PostgreSQL과 Oracle은 READ COMMITTED가 기본이다. 같은 코드도 DB를 바꾸면 다르게 동작할 수 있다.
표의 별표도 DBMS마다 사정이 다르다. InnoDB의 REPEATABLE READ는 일반 SELECT에는 스냅샷을, FOR UPDATE 같은 잠금 읽기에는 갭 락(next-key lock)을 써서 phantom을 대부분 막는다. PostgreSQL의 REPEATABLE READ도 스냅샷 격리라 phantom이 보이지 않는다.
요즘 DBMS는 이 문제를 락만으로 풀지 않는다. 주력은 MVCC(다중 버전 동시성 제어)로, 트랜잭션마다 자기 시점의 스냅샷을 보여 준다. 읽기가 쓰기를 막지 않고 쓰기가 읽기를 막지 않는 이유가 이것이다.
🧪 직접 해보기
세션 두 개로 Non-repeatable Read 보기
MySQL 콘솔을 두 개 열고 번호 순서대로 실행한다. 지금 격리 수준은 SELECT @@transaction_isolation;로 확인한다(PostgreSQL은 SHOW transaction_isolation;).
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;START TRANSACTION;SELECT balance FROM account WHERE id = 'A'; -- ① 1000000
SELECT balance FROM account WHERE id = 'A'; -- ③ 800000, 같은 트랜잭션인데 값이 바뀌었다COMMIT;UPDATE account SET balance = balance - 200000 WHERE id = 'A'; -- ② autocommit 으로 바로 커밋세션 A를 REPEATABLE READ로 바꿔 다시 해 보면 ③에서도 ①과 같은 값이 나온다. 첫 읽기 때 잡은 스냅샷을 트랜잭션이 끝날 때까지 계속 보기 때문이다.
복구: 정전이 나도 커밋은 살아남는다
DB는 데이터를 바꿀 때 디스크에 바로 쓰지 않는다. 성능 때문에 메모리(버퍼 풀)에서 고치고, 디스크에는 나중에 몰아서 반영한다. 그 사이에 서버가 죽으면 어떻게 될까. 이 틈을 로그가 메운다.
원칙은 WAL(Write-Ahead Logging), 데이터보다 로그를 먼저 디스크에 쓰는 것이다. 로그에는 두 가지 정보가 담긴다.
- Redo 로그: 바꾼 뒤의 값. 커밋했지만 아직 디스크에 반영되지 않은 변경을 다시 재생하는 데 쓴다.
- Undo 로그: 바꾸기 전의 값. 커밋하지 못하고 죽은 트랜잭션의 변경을 되돌리는 데 쓴다. InnoDB와 Oracle에서는 MVCC가 보여 주는 예전 버전도 여기서 나온다. PostgreSQL은 예전 버전을 테이블 안에 그대로 남기는 방식이라 Undo 로그가 따로 없다.
체크포인트는 주기적으로 버퍼의 변경을 디스크에 반영하고 표시를 남기는 일이다. 덕분에 재시작할 때 로그를 처음부터가 아니라 마지막 체크포인트부터만 재생하면 된다. 재시작하면 먼저 Redo로 장애 직전 상태를 다시 만들고(커밋한 건 반드시 살린다, 지속성), 그다음 커밋하지 못한 트랜잭션을 Undo로 되돌린다(흔적을 남기지 않는다, 원자성).
그래서 COMMIT이 성공했다는 건 데이터가 디스크에 다 써졌다는 뜻이 아니라 Redo 로그가 디스크에 안전하게 남았다는 뜻이다. 그거면 충분하다. 언제 죽어도 로그로 다시 만들 수 있다. 커밋 응답이 로그 fsync 한 번으로 끝나니 빠르기까지 하다. WAL이 진지한 DB는 물론 Kafka 같은 시스템의 심장이기도 한 이유다.
단, 이 보장은 설정에 달려 있다. MySQL의 innodb_flush_log_at_trx_commit이 기본값 1이면 커밋마다 로그를 fsync한다. 빨라지자고 0으로 바꾸면 MySQL이 죽기만 해도, 2로 바꾸면 OS가 죽거나 전원이 나갈 때 최근 약 1초치 커밋을 잃을 수 있다. PostgreSQL의 synchronous_commit = off도 같은 거래다.
정리
- ACID에서 A와 D는 DBMS가 로그로, C와 I는 개발자가 챙긴다.
- 격리 수준은 이상 현상을 어디까지 허용할지 고르는 다이얼이다. 내 DB의 기본값부터 확인한다 (MySQL은 REPEATABLE READ, PostgreSQL은 READ COMMITTED).
- 돈이 걸린 구간은 격리 수준이 아니라 명시적 락으로 지킨다.
- WAL은 로그를 먼저, 데이터를 나중에 쓴다. 장애 뒤에는 커밋한 것은 Redo, 못 한 것은 Undo.
다음 편은 설계 이야기다. 정규화를 왜 하고 어디서 멈출지, 비즈니스 로직을 ORM에 둘지 Stored Procedure에 둘지를 다룬다.