[Real MySQL 8.0 (1권)] 5장(2) - 트랜잭션과 잠금 - 이상현상
Real MySQL 8.0 1권 5장의 내용을 정리합니다.
이상격리 수준별 이상 현상 재현 테스트
MySQL(InnoDB) 환경에서 격리 수준(Isolation Level)에 따라 발생하는 세 가지 주요 이상 현상(Dirty Read, Non-Repeatable Read, Phantom Read)을 재현하는 테스트 시나리오를 정리하였습니다.
두 개의 터미널(세션 A, 세션 B)을 열어 순서대로 실행해 보시면 명확하게 이해하실 수 있습니다.
테스트 환경 준비 (공통)
먼저 테스트를 위한 테이블과 데이터를 생성합니다.
1
2
3
4
5
6
7
8
9
10
-- 테이블 생성
CREATE TABLE isolation_test (
id INT PRIMARY KEY,
name VARCHAR(20),
amount INT
) ENGINE=InnoDB;
-- 초기 데이터 삽입
INSERT INTO isolation_test VALUES (1, 'UserA', 100);
COMMIT;
1. Dirty Read 재현
- 발생 격리 수준:
READ UNCOMMITTED - 현상: 다른 트랜잭션에서 커밋하지 않은 데이터를 읽습니다.
- 위험성: 데이터 정합성이 깨지고, 롤백될 데이터를 비즈니스 로직에 사용하여 치명적인 오류를 유발할 수 있습니다.
| 순서 | 세션 A (Writer) | 세션 B (Reader) | 설명 |
|---|---|---|---|
| 1 | SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; | SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; | 격리 수준 설정 |
| 2 | START TRANSACTION; | START TRANSACTION; | 트랜잭션 시작 |
| 3 | UPDATE isolation_test SET amount = 500 WHERE id = 1; | A가 값 변경 (아직 커밋 X) | |
| 4 | SELECT * FROM isolation_test WHERE id = 1; | [결과: 500] |
커밋되지 않은 값을 B가 읽음 (Dirty Read 발생) | ||||
| 5 | ROLLBACK; | A가 문제 발생으로 롤백함 | ||
| 6 | SELECT * FROM isolation_test WHERE id = 1; | [결과: 100] |
B는 방금 500을 보고 로직을 수행했을 텐데, 다시 조회하니 100임. 데이터 불일치. |
2. Non-Repeatable Read 재현
- 발생 격리 수준:
READ COMMITTED - 현상: 하나의 트랜잭션 내에서 같은 쿼리를 두 번 실행했는데 결과가 다르게 나옵니다.
- 원인: 조회 사이에 다른 트랜잭션이 데이터를 변경하고 커밋했기 때문입니다.
| 순서 | 세션 A (Writer) | 세션 B (Reader) | 설명 |
|---|---|---|---|
| 1 | SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; | SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; | 격리 수준 설정 |
| 2 | START TRANSACTION; | ||
| 3 | SELECT * FROM isolation_test WHERE id = 1; | [결과: 100] 첫 번째 조회 | |
| 4 | START TRANSACTION; | ||
| 5 | UPDATE isolation_test SET amount = 200 WHERE id = 1; | A가 값 변경 | |
| 6 | COMMIT; | A가 커밋 완료 | |
| 7 | SELECT * FROM isolation_test WHERE id = 1; | [결과: 200] 두 번째 조회 |
B 트랜잭션은 끝나지 않았는데 값이 바뀜 (Non-Repeatable Read 발생) |
참고: InnoDB의 기본값인
REPEATABLE READ에서 위 과정을 동일하게 수행하면, 순서 7번에서 결과는 여전히 100이 조회됩니다. (Undo 로그를 통한 MVCC 덕분)
3. Phantom Read 재현
- 발생 격리 수준:
READ COMMITTED(InnoDBREPEATABLE READ에서는 대부분 방지됨) - 현상: 동일한 조건으로 조회했을 때, 이전에 없던 새로운 레코드(유령)가 등장하거나 있던 레코드가 사라집니다.
3-1. 일반적인 Phantom Read (READ COMMITTED 환경)
| 순서 | 세션 A (Writer) | 세션 B (Reader) | 설명 |
|---|---|---|---|
| 1 | SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; | ||
| 2 | SELECT * FROM isolation_test WHERE id > 0; | [결과: 1건] (id:1) | |
| 3 | INSERT INTO isolation_test VALUES (2, 'UserB', 300); COMMIT; | A가 새로운 데이터 추가 및 커밋 | |
| 4 | SELECT * FROM isolation_test WHERE id > 0; | [결과: 2건] (id:1, id:2) |
갑자기 데이터가 늘어남 (Phantom Read 발생) |
3-2. InnoDB REPEATABLE READ에서의 Phantom Read (예외 케이스)
InnoDB는 REPEATABLE READ에서도 갭 락(Gap Lock)과 MVCC 덕분에 일반적인 SELECT에서는 Phantom Read가 발생하지 않습니다. 하지만 잠금을 동반한 읽기(SELECT ... FOR UPDATE) 등에서는 발생할 수 있습니다.
| 순서 | 세션 A (Writer) | 세션 B (Reader) | 설명 |
|---|---|---|---|
| 1 | SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; | ||
| 2 | SELECT * FROM isolation_test; | [결과: 1건] (id:1) | |
| 3 | INSERT INTO isolation_test VALUES (2, 'UserB', 300); COMMIT; | A가 데이터 추가 후 커밋. |
(B가 락을 안 걸었기에 A는 인서트 성공) | ||||
| 4 | SELECT * FROM isolation_test; | [결과: 1건] |
MVCC 덕분에 여전히 1건만 보임 (Phantom Read 방어 성공) | ||||
| 5 | UPDATE isolation_test SET amount = 0 WHERE id = 2; | [결과: 1 row affected] |
중요! B에는 안 보였던 id=2를 업데이트함. 업데이트는 쓰기 잠금이 필요해 실제 데이터(최신)를 바라봄. | ||||
| 6 | SELECT * FROM isolation_test; | [결과: 2건] |
본인이 업데이트를 수행했으므로 이제 id=2가 보임. (Phantom Read 발생) |
분석: 순서 4번까지는 완벽하게 격리되었으나, 순서 5번의
UPDATE쿼리는 Undo 영역이 아닌 실제 레코드에 락을 걸고 수행되므로, A가 커밋한 id=2를 인지하게 됩니다. 그 후 6번 조회 시 본인의 트랜잭션 ID가 반영된 최신 버전(id=2)을 읽어오게 됩니다.
4. Spring Boot & JPA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
@Service
public class PaymentService {
// 1. 일반적인 조회 위주 (InnoDB 기본값)
// Phantom Read가 방지되며, Dirty Read도 없음
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void checkBalance(Long userId) {
// ...
}
// 2. 데이터 정합성보다 성능이 최우선이거나,
// 로직상 Non-Repeatable Read가 허용되는 경우 (외부 API 단순 집계 등)
@Transactional(isolation = Isolation.READ_COMMITTED)
public void simpleReport() {
// ...
}
// 3. 절대적인 정합성 필요 (티켓팅 재고 차감 등 - 성능 저하 감수)
// 데드락 위험이 매우 높으므로 주의
@Transactional(isolation = Isolation.SERIALIZABLE)
public void decreaseTicketStock() {
// ...
}
}
This post is licensed under CC BY 4.0 by the author.
