트랜잭션과 ACID: 원자성과 격리성, 갱신 유실
들어가며
재고가 1개 남은 상품에 주문 두 건이 거의 동시에 들어왔는데, 두 건 모두 성공 처리된 적이 있으신가요? 코드를 열어 보면 분명 트랜잭션으로 감싸 두었는데도 이런 일이 생깁니다.
트랜잭션이 무엇을 보장하고 무엇을 보장하지 않는지 구분하면 원인이 보입니다. 이번 글에서는 ACID 네 가지를 짚습니다. 특히 자주 섞어 쓰는 원자성과 격리성의 차이, 그리고 트랜잭션만으로는 막히지 않는 갱신 유실을 정리합니다.
1. 트랜잭션: 전부 되거나, 하나도 안 되거나
트랜잭션은 여러 작업을 하나로 묶어, 전부 반영되거나 전부 취소되게 하는 단위입니다. 대표적인 예가 계좌 이체입니다. 출금만 되고 입금이 안 된 상태가 남으면 안 됩니다.
트랜잭션은 '둘 다 아니면 아무것도'인 묶음 택배예요.
BEGIN;
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
UPDATE accounts SET balance = balance + 1000 WHERE id = 2;
COMMIT; -- 중간에 실패하면 ROLLBACK 으로 둘 다 취소COMMIT 전까지의 변경은 확정되지 않은 상태입니다. 중간에 오류가 나거나 서버가 죽으면 데이터베이스는 그 트랜잭션의 변경을 모두 되돌립니다.
2. ACID 네 가지
트랜잭션이 지켜야 하는 성질을 앞 글자를 따서 ACID 라고 부릅니다.
- 원자성(Atomicity): 전부 반영되거나 전부 취소됩니다. 반쪽 상태가 남지 않습니다.
- 일관성(Consistency): 트랜잭션 전후로 제약 조건 같은 규칙이 깨지지 않습니다.
- 격리성(Isolation): 동시에 도는 트랜잭션끼리 서로의 중간 상태를 얼마나 볼지 정합니다.
- 지속성(Durability): 커밋된 결과는 장애가 나도 사라지지 않습니다.
지속성은 로그로 지킵니다. 커밋할 때 변경 내용을 먼저 로그(PostgreSQL 의 WAL, MySQL InnoDB 의 redo log)에 디스크까지 기록한 뒤 성공을 돌려줍니다. 장애가 나면 그 로그를 다시 재생해 복구합니다.
3. 원자성과 격리성은 무엇이 다른가
둘 다 "중간 상태를 막는다"처럼 들려서 자주 섞입니다. 하지만 보는 대상이 다릅니다. 원자성은 내 작업이 반쪽이 되지 않는가, 격리성은 동시에 도는 남의 작업과 어떻게 섞이는가의 문제입니다.
격리성은 옆 손님이 내 주문서를 언제부터 보느냐예요.
같은 이체를 두 세션에서 보면 차이가 드러납니다.
-- 세션 A: 원자성 (둘 다 반영 또는 둘 다 취소)
BEGIN;
UPDATE acct SET bal = bal - 100 WHERE id = 1;
UPDATE acct SET bal = bal + 100 WHERE id = 2;
COMMIT;
-- 세션 B: 격리성 (READ COMMITTED)
-- A 가 커밋하기 전에 읽으면 이체 전 값, 커밋 후에야 바뀐 값이 보인다
SELECT bal FROM acct WHERE id = 1;격리 수준
격리성은 켜고 끄는 것이 아니라 단계로 조절합니다. SQL 표준은 네 단계를 정의합니다. 높을수록 막아 주는 이상 현상이 늘어나는 대신 대기와 충돌이 많아집니다.
| 격리 수준 | Dirty Read | Non-repeatable Read | Phantom Read |
|---|---|---|---|
| READ UNCOMMITTED | 생길 수 있음 | 생길 수 있음 | 생길 수 있음 |
| READ COMMITTED | 막음 | 생길 수 있음 | 생길 수 있음 |
| REPEATABLE READ | 막음 | 막음 | 생길 수 있음 |
| SERIALIZABLE | 막음 | 막음 | 막음 |
위 표는 표준이 허용하는 최소 기준이고, 실제 동작은 제품마다 조금씩 다릅니다. 기본값도 PostgreSQL 은 READ COMMITTED, MySQL InnoDB 는 REPEATABLE READ 입니다. PostgreSQL 의 REPEATABLE READ 는 표준보다 강해서 phantom read 도 막습니다.
4. 트랜잭션으로 감싸도 생기는 갱신 유실
들어가며의 재고 문제로 돌아가 보겠습니다. 트랜잭션은 자기 작업을 원자적으로 묶을 뿐, 다른 트랜잭션과의 순서를 정해 주지 않습니다. READ COMMITTED 에서는 두 트랜잭션이 같은 "재고 1" 을 읽고, 각자 판매 가능하다고 판단해 둘 다 커밋해 버립니다.
한쪽의 판단이 다른 쪽의 결과를 덮어써 사라지는 이 현상을 갱신 유실(lost update) 이라고 합니다.
동시 주문은 진열대 마지막 빵 하나를 두 손님이 동시에 보는 일이에요.
둘 다 같은 '재고 1'을 읽고 각자 커밋한다. 갱신 유실이다.
B는 A가 커밋할 때까지 기다린 뒤 바뀐 재고를 읽는다.
원인은 "읽고, 애플리케이션이 판단하고, 쓰는" 사이에 틈이 있다는 점입니다. 막는 방법은 크게 두 가지입니다.
-- 문제: 읽고 → 앱이 판단 → 쓰기 (둘 다 1 을 읽고 통과)
SELECT stock FROM items WHERE id = 7;
UPDATE items SET stock = 0 WHERE id = 7;
-- 해결 1: 판단을 DB 한 문장에 넣는 조건부 갱신
UPDATE items SET stock = stock - 1
WHERE id = 7 AND stock > 0;
-- 갱신된 행이 0 이면 품절 처리
-- 해결 2: 먼저 잠그고 읽기 (비관적 잠금)
SELECT stock FROM items WHERE id = 7 FOR UPDATE;FOR UPDATE 로 잠그면 뒤에 온 트랜잭션은 앞 트랜잭션이 끝날 때까지 기다립니다. 확실하지만 요청이 몰리면 대기가 길어집니다. 한 행에 요청이 몰리는 상황이라면 잠금 구간이 짧은 조건부 갱신으로 푸는 일이 많습니다.
헷갈리기 쉬운 점
- 트랜잭션으로 감싸면 동시 요청도 안전하다는 오해. 원자성은 반쪽 반영만 막습니다. 동시 갱신은 격리 수준과 잠금이 따로 다룹니다.
- 격리성은 트랜잭션이 완전히 따로 돈다는 뜻이라는 오해. 격리 수준에 따라 일부 이상 현상은 허용됩니다.
- 격리 수준은 높을수록 좋다는 오해. 높일수록 대기와 직렬화 오류가 늘어 동시 처리량이 줄어듭니다.
면접에서 자주 받는 질문
READ COMMITTED 에서 같은 SELECT 를 두 번 하면 값이 달라질 수 있나요?
네. 두 읽기 사이에 다른 트랜잭션이 커밋하면 두 번째 읽기는 새 값을 봅니다. 이것이 반복 불가능 읽기(non-repeatable read)이고, REPEATABLE READ 이상에서 막습니다.
MVCC 는 무엇인가요?
행의 여러 버전을 두는 동시성 제어 방식입니다. 읽는 쪽은 자기 시점의 버전을 보기 때문에, 읽기와 쓰기가 서로를 기다리지 않습니다. PostgreSQL 과 MySQL InnoDB 모두 MVCC 를 씁니다.
비관적 잠금과 낙관적 잠금은 언제 고르나요?
비관적 잠금은 먼저 잠그고 작업하는 방식으로, 충돌이 잦을 때 맞습니다. 대신 대기와 데드락 위험이 있습니다. 낙관적 잠금은 버전 번호를 두고 쓸 때만 바뀌었는지 확인하는 방식으로, 충돌이 드물 때 유리하고 충돌하면 재시도합니다.
격리 수준을 SERIALIZABLE 로 올리면 갱신 유실이 해결되나요?
PostgreSQL 에서는 충돌한 쪽을 직렬화 오류로 실패시켜 막아 줍니다. 대신 애플리케이션이 그 오류를 받아 재시도해야 하고, 충돌이 많으면 처리량이 줄어듭니다.
정리
- 트랜잭션은 여러 작업을 묶어 전부 반영하거나 전부 취소합니다. ACID 는 원자성, 일관성, 격리성, 지속성입니다.
- 원자성은 내 작업이 반쪽이 되지 않는 것, 격리성은 동시에 도는 남의 작업과 섞이는 정도입니다.
- 트랜잭션만으로는 갱신 유실을 막지 못합니다. 조건부 갱신이나 잠금을 함께 씁니다.
다 되거나 하나도 안 되거나, 트랜잭션이 주는 것은 거기까지입니다. 원자성은 나 혼자의 반쪽, 격리성은 남과의 섞임이라고 기억하면 헷갈리지 않습니다.