에듀테크 프로젝트 대시보드
데이터베이스 트랜잭션과 ACID
은행 이체는 "내 계좌에서 빼기"와 "상대 계좌에 더하기" 두 작업으로 이뤄진다. 둘 중 하나만 성공하면 돈이 사라지거나 복사된다. 트랜잭션(transaction)은 여러 작업을 "전부 되거나, 전부 안 되거나(all-or-nothing)" 하나의 단위로 묶는 장치다. 게다가 수천 명이 동시에 같은 데이터를 건드리면 또 다른 문제가 생긴다. 아래에서 직접 두 트랜잭션을 겹쳐 실행시켜 보자.
트랜잭션 안의 모든 연산은 전부 반영되거나(commit) 전부 취소된다(rollback). 중간에 실패하면 시작 전 상태로 되돌린다. "빼기"만 되고 "더하기"가 안 되는 일은 없다.
트랜잭션 전후로 DB는 항상 정해진 규칙(제약조건)을 만족한다. 예: "잔액은 음수가 될 수 없다", "이체 전후 총합은 그대로"가 깨지지 않는다.
동시에 도는 트랜잭션들은 서로의 중간 상태를 보지 못한다. 결과적으로 하나씩 차례로 실행한 것과 같아야 한다. 이 데모의 핵심 주제.
commit이 끝난 데이터는 정전·크래시가 나도 사라지지 않는다. 쓰기 전 로그(WAL)를 디스크에 먼저 남겨두고, 재시작 시 로그로 복구한다.
공유 잔액은 1000원. T1은 +100(입금),
T2는 −200(출금). 각 트랜잭션은 읽기 → 계산 → 쓰기 → 커밋 순.
올바른 최종 잔액은 900원이어야 한다.
💡 잠금 없이 인터리브로 실행하면 T1이 쓴 1100을 T2가 덮어써서 최종 800원 → T1의 입금 100원이 증발(Lost Update). 잠금을 켜면 T2의 읽기가 T1 커밋까지 대기해서 결과가 900원으로 바로잡힌다.
| 현상 | 무슨 일이 일어나나 |
|---|---|
| Dirty Read 오손 읽기 |
다른 트랜잭션이 아직 커밋하지 않은 값을 읽는다. 그 트랜잭션이 롤백되면 존재한 적 없는 값을 읽은 셈. |
| Non-repeatable Read 반복 불가능 읽기 |
같은 행을 두 번 읽었는데 그 사이 다른 트랜잭션이 수정·커밋해서 값이 달라진다. |
| Phantom Read 유령 읽기 |
같은 조건으로 범위 조회를 두 번 했는데, 그 사이 삽입된 행이 새로 나타난다(또는 사라진다). |
| Lost Update 갱신 손실 |
두 트랜잭션이 같은 값을 읽고 각자 계산해 쓰면, 나중에 쓴 쪽이 먼저 쓴 쪽을 덮어쓴다. (위 데모) |
위로 갈수록 안전하지만 동시성(속도)은 떨어진다. 대부분의 DB 기본값은 READ COMMITTED(PostgreSQL·Oracle) 또는 REPEATABLE READ(MySQL InnoDB).
| 격리 수준 | Dirty Read | Non-repeatable | Phantom |
|---|---|---|---|
| READ UNCOMMITTED | 허용 | 허용 | 허용 |
| READ COMMITTED | 방지 | 허용 | 허용 |
| REPEATABLE READ | 방지 | 방지 | 허용* |
| SERIALIZABLE | 방지 | 방지 | 방지 |
* MySQL InnoDB는 갭 락(gap lock)으로 REPEATABLE READ에서도 팬텀을 상당 부분 막는다.
잠금 기반은 "누가 쓰는 중이면 다른 사람은 기다려" 방식이다. 안전하지만 대기(블로킹)가 생긴다.
MVCC(다중 버전 동시성 제어, PostgreSQL·MySQL InnoDB·Oracle)는 데이터를 수정할 때 새 버전을 따로 만들고
각 트랜잭션에게 "시작 시점의 스냅샷"을 보여준다. 그래서 읽기는 쓰기를 막지 않고, 쓰기는 읽기를 막지 않는다.
읽기 위주 워크로드에서 훨씬 빠르다.
트랜잭션을 확장 단계(잠금을 얻기만 함)와 수축 단계(잠금을 풀기만 함)로 나누는 규칙이다. 한 번 잠금을 풀기 시작하면 새 잠금을 못 얻는다. 보통은 커밋할 때까지 모든 잠금을 쥐고 있다가 (Strict 2PL) 한꺼번에 푼다. 이렇게 하면 스케줄이 항상 직렬 가능(serializable)해진다. 위 데모의 "잠금 사용"이 바로 이 방식이다.
T1이 행 A를 잠그고 B를 기다리는데, T2가 B를 잠그고 A를 기다리면 서로 영원히 대기한다.
DB는 대기 그래프에서 사이클을 감지하거나 타임아웃으로 이를 발견하고,
둘 중 한 트랜잭션을 강제로 롤백(victim)시켜 사이클을 끊는다. 애플리케이션은 롤백된 트랜잭션을
다시 시도하면 된다. (자세한 건 /deadlock 페이지 참고)
동시성이 크게 떨어지기 때문이다. 잠금 방식이면 대기가 급증하고, MVCC 방식(예: PostgreSQL의
Serializable Snapshot Isolation)이면 충돌이 감지될 때 트랜잭션이 serialization failure로
중단되어 재시도가 필요하다. 대부분의 애플리케이션은 READ COMMITTED로 충분하고, 정말 민감한
계산(재고 차감, 잔액)만 골라서 명시적 잠금이나 원자적 UPDATE로 처리한다.