한양과학기술고등학교

에듀테크 프로젝트 대시보드

데이터베이스 트랜잭션과 ACID

"돈을 옮기는 도중에 정전이 나면?" — 트랜잭션이 답이다

은행 이체는 "내 계좌에서 빼기"와 "상대 계좌에 더하기" 두 작업으로 이뤄진다. 둘 중 하나만 성공하면 돈이 사라지거나 복사된다. 트랜잭션(transaction)은 여러 작업을 "전부 되거나, 전부 안 되거나(all-or-nothing)" 하나의 단위로 묶는 장치다. 게다가 수천 명이 동시에 같은 데이터를 건드리면 또 다른 문제가 생긴다. 아래에서 직접 두 트랜잭션을 겹쳐 실행시켜 보자.

1 트랜잭션이 지켜주는 4가지 — ACID

A 원자성 Atomicity

트랜잭션 안의 모든 연산은 전부 반영되거나(commit) 전부 취소된다(rollback). 중간에 실패하면 시작 전 상태로 되돌린다. "빼기"만 되고 "더하기"가 안 되는 일은 없다.

C 일관성 Consistency

트랜잭션 전후로 DB는 항상 정해진 규칙(제약조건)을 만족한다. 예: "잔액은 음수가 될 수 없다", "이체 전후 총합은 그대로"가 깨지지 않는다.

I 격리성 Isolation

동시에 도는 트랜잭션들은 서로의 중간 상태를 보지 못한다. 결과적으로 하나씩 차례로 실행한 것과 같아야 한다. 이 데모의 핵심 주제.

D 지속성 Durability

commit이 끝난 데이터는 정전·크래시가 나도 사라지지 않는다. 쓰기 전 로그(WAL)를 디스크에 먼저 남겨두고, 재시작 시 로그로 복구한다.

2 동시성 시뮬레이터 — Lost Update 직접 만들기

공유 잔액은 1000원. T1은 +100(입금), T2는 −200(출금). 각 트랜잭션은 읽기 → 계산 → 쓰기 → 커밋 순. 올바른 최종 잔액은 900원이어야 한다.

T1 · 입금 +100실행 중
DB · balance
1000
잠금 없음
t1=– t2=–
T2 · 출금 −200실행 중
아직 실행 전입니다. 다음 단계 또는 자동 실행을 눌러보세요.

💡 잠금 없이 인터리브로 실행하면 T1이 쓴 1100을 T2가 덮어써서 최종 800원 → T1의 입금 100원이 증발(Lost Update). 잠금을 켜면 T2의 읽기가 T1 커밋까지 대기해서 결과가 900원으로 바로잡힌다.

3 격리가 약할 때 생기는 이상 현상
현상무슨 일이 일어나나
Dirty Read
오손 읽기
다른 트랜잭션이 아직 커밋하지 않은 값을 읽는다. 그 트랜잭션이 롤백되면 존재한 적 없는 값을 읽은 셈.
Non-repeatable Read
반복 불가능 읽기
같은 행을 두 번 읽었는데 그 사이 다른 트랜잭션이 수정·커밋해서 값이 달라진다.
Phantom Read
유령 읽기
같은 조건으로 범위 조회를 두 번 했는데, 그 사이 삽입된 행이 새로 나타난다(또는 사라진다).
Lost Update
갱신 손실
두 트랜잭션이 같은 값을 읽고 각자 계산해 쓰면, 나중에 쓴 쪽이 먼저 쓴 쪽을 덮어쓴다. (위 데모)
4 4가지 격리 수준 (SQL 표준)

위로 갈수록 안전하지만 동시성(속도)은 떨어진다. 대부분의 DB 기본값은 READ COMMITTED(PostgreSQL·Oracle) 또는 REPEATABLE READ(MySQL InnoDB).

격리 수준Dirty ReadNon-repeatablePhantom
READ UNCOMMITTED허용허용허용
READ COMMITTED방지허용허용
REPEATABLE READ방지방지허용*
SERIALIZABLE방지방지방지

* MySQL InnoDB는 갭 락(gap lock)으로 REPEATABLE READ에서도 팬텀을 상당 부분 막는다.

-- 이체를 하나의 트랜잭션으로 묶기 BEGIN; UPDATE account SET balance = balance - 200 WHERE id = 'A'; UPDATE account SET balance = balance + 200 WHERE id = 'B'; -- 여기서 정전이 나면? → 자동 ROLLBACK, A의 돈은 그대로 COMMIT; -- 이 순간부터 Durable -- Lost Update를 막는 방법 1: 원자적 UPDATE (읽고-쓰기를 한 문장으로) UPDATE account SET balance = balance + 100 WHERE id = 'A'; -- 방법 2: 명시적 잠금 (읽을 때부터 행을 잠금) SELECT balance FROM account WHERE id = 'A' FOR UPDATE;
5 자주 나오는 질문
💡 잠금(Lock)과 MVCC는 뭐가 다른가?

잠금 기반은 "누가 쓰는 중이면 다른 사람은 기다려" 방식이다. 안전하지만 대기(블로킹)가 생긴다. MVCC(다중 버전 동시성 제어, PostgreSQL·MySQL InnoDB·Oracle)는 데이터를 수정할 때 새 버전을 따로 만들고 각 트랜잭션에게 "시작 시점의 스냅샷"을 보여준다. 그래서 읽기는 쓰기를 막지 않고, 쓰기는 읽기를 막지 않는다. 읽기 위주 워크로드에서 훨씬 빠르다.

💡 2PL(2단계 잠금)이 뭔가?

트랜잭션을 확장 단계(잠금을 얻기만 함)와 수축 단계(잠금을 풀기만 함)로 나누는 규칙이다. 한 번 잠금을 풀기 시작하면 새 잠금을 못 얻는다. 보통은 커밋할 때까지 모든 잠금을 쥐고 있다가 (Strict 2PL) 한꺼번에 푼다. 이렇게 하면 스케줄이 항상 직렬 가능(serializable)해진다. 위 데모의 "잠금 사용"이 바로 이 방식이다.

💡 데드락이 생기면 어떻게 되나?

T1이 행 A를 잠그고 B를 기다리는데, T2가 B를 잠그고 A를 기다리면 서로 영원히 대기한다. DB는 대기 그래프에서 사이클을 감지하거나 타임아웃으로 이를 발견하고, 둘 중 한 트랜잭션을 강제로 롤백(victim)시켜 사이클을 끊는다. 애플리케이션은 롤백된 트랜잭션을 다시 시도하면 된다. (자세한 건 /deadlock 페이지 참고)

💡 SERIALIZABLE이 제일 안전한데 왜 기본값이 아닌가?

동시성이 크게 떨어지기 때문이다. 잠금 방식이면 대기가 급증하고, MVCC 방식(예: PostgreSQL의 Serializable Snapshot Isolation)이면 충돌이 감지될 때 트랜잭션이 serialization failure로 중단되어 재시도가 필요하다. 대부분의 애플리케이션은 READ COMMITTED로 충분하고, 정말 민감한 계산(재고 차감, 잔액)만 골라서 명시적 잠금이나 원자적 UPDATE로 처리한다.