지난 글에서 @Transactional의 동작 원리와 전파 방식을 살펴봤다.
트랜잭션이 "하나의 논리적 작업 단위"라는 건 알겠는데, 여러 트랜잭션이 동시에 실행되면 어떤 일이 벌어질까?
이번 글에서는 아래 질문들을 다룬다.
재고가 10개인 상품을 100명이 동시에 주문한다고 가정해보자.
트랜잭션 A → 재고 조회: 10개
트랜잭션 B → 재고 조회: 10개 (아직 A가 커밋 전)
트랜잭션 A → 재고 차감 후 커밋: 3개
트랜잭션 B → 재고 차감 후 커밋: 3개
두 트랜잭션 모두 재고가 충분하다고 판단했기 때문에 실제로는 재고가 7개 팔렸지만 DB엔 3개만 남는다.
이처럼 여러 트랜잭션이 같은 데이터를 동시에 읽고 쓰면서 발생하는 문제를 Race Condition(경쟁 상태) 이라고 한다.
이를 막으려면 "얼마나 엄격하게 트랜잭션을 격리할 것인가"를 결정해야 한다. 그 옵션이 바로 트랜잭션 격리 수준(Transaction Isolation Level) 이다.
격리 수준은 낮을수록 성능이 좋고, 높을수록 데이터 정합성이 강하다. 정합성과 성능은 트레이드오프 관계다.
Read Uncommitted → Read Committed → Repeatable Read → Serializable
성능 우선 정합성 우선
트랜잭션 A가 데이터를 수정했지만 아직 COMMIT하지 않은 상태에서, 트랜잭션 B가 그 변경된 값을 읽어버리는 현상이다.
트랜잭션 A: 재고 10 → 5 수정 (아직 COMMIT 전)
트랜잭션 B: 재고 조회 → 5 읽음 ← Dirty Read 발생
트랜잭션 A: ROLLBACK → 재고 다시 10으로 복원
트랜잭션 B: 존재하지 않았던 '5'를 기반으로 잘못된 판단
트랜잭션 B는 언제든 사라질 수 있는 유령 데이터를 신뢰한 셈이다.
COMMIT된 데이터만 읽도록 보장한다. 트랜잭션 A가 수정 중이라면, B는 수정 전 마지막으로 COMMIT된 값을 읽는다.
하나의 트랜잭션 안에서 같은 SELECT를 두 번 실행했을 때, 그 사이에 다른 트랜잭션이 값을 수정하고 COMMIT하여 결과가 달라지는 현상이다.
트랜잭션 A: 재고 조회 → 10개
트랜잭션 B: 재고를 5로 수정 후 COMMIT
트랜잭션 A: 재고 재조회 → 5개 ← 같은 트랜잭션인데 값이 달라짐
Read Committed는 "쿼리 실행 시점의 최신 COMMIT 데이터"를 읽기 때문에 이 문제를 막지 못한다.
트랜잭션이 시작될 때 스냅샷을 찍어두고, 트랜잭션이 끝날 때까지 그 스냅샷만 바라본다. 다른 트랜잭션이 아무리 수정하고 COMMIT해도, 내 트랜잭션은 시작 시점의 데이터를 일관되게 읽는다.
MySQL InnoDB는 MVCC(Multi-Version Concurrency Control) 기술로 이 스냅샷을 구현한다. 락 없이도 읽기 일관성을 보장할 수 있는 이유다.
하나의 트랜잭션 안에서 같은 조건으로 SELECT를 두 번 실행했을 때, 다른 트랜잭션이 새로운 행을 INSERT하고 COMMIT하여 첫 번째엔 없던 행이 두 번째 조회에서 나타나는 현상이다.
트랜잭션 A: WHERE stock > 10 조회 → 2개
트랜잭션 B: stock=15인 신상품 INSERT 후 COMMIT
트랜잭션 A: 같은 조건 재조회 → 3개 ← 유령 행 등장
Repeatable Read의 스냅샷은 기존 행의 변경은 막아주지만, 스냅샷 이후에 새로 추가된 행은 막지 못한다.
| Non-Repeatable Read | Phantom Read | |
|---|---|---|
| 문제 | 특정 행의 값이 바뀜 | 조회 결과의 행 개수가 바뀜 |
| 원인 | 다른 트랜잭션의 UPDATE/DELETE | 다른 트랜잭션의 INSERT |
SELECT 쿼리의 조건 범위 자체에 잠금을 건다. WHERE stock > 10 범위에 해당하는 INSERT 자체를 차단하여 Phantom Read를 원천적으로 막는다.
단, 동시 처리 성능이 크게 저하되므로 극히 제한적인 경우에만 사용한다.
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read | 특징 |
|---|---|---|---|---|
| Read Uncommitted | 발생 | 발생 | 발생 | 거의 사용 안 함 |
| Read Committed | 방지 | 발생 | 발생 | Oracle, PostgreSQL 기본값 |
| Repeatable Read | 방지 | 방지 | 발생 | MySQL 기본값 |
| Serializable | 방지 | 방지 | 방지 | 성능 저하, 제한적 사용 |
실무에서는 대부분 Read Committed 또는 Repeatable Read 중에서 선택한다.
격리 수준은 "얼마나 엄격하게 트랜잭션을 격리할 것인가"에 대한 선택이다. 정합성을 높이면 성능이 떨어지고, 성능을 높이면 정합성이 위험해진다.
다음 글에서는 격리 수준만으로 해결되지 않는 동시성 문제를 낙관적 락과 비관적 락으로 어떻게 해결하는지 다룬다.