Post

[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)설명
1SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;격리 수준 설정
2START TRANSACTION;START TRANSACTION;트랜잭션 시작
3UPDATE isolation_test SET amount = 500 WHERE id = 1; A가 값 변경 (아직 커밋 X)
4 SELECT * FROM isolation_test WHERE id = 1;[결과: 500]

커밋되지 않은 값을 B가 읽음 (Dirty Read 발생)
    
 5ROLLBACK; A가 문제 발생으로 롤백함
 6 SELECT * FROM isolation_test WHERE id = 1;[결과: 100]

B는 방금 500을 보고 로직을 수행했을 텐데, 다시 조회하니 100임. 데이터 불일치.

2. Non-Repeatable Read 재현

  • 발생 격리 수준: READ COMMITTED
  • 현상: 하나의 트랜잭션 내에서 같은 쿼리를 두 번 실행했는데 결과가 다르게 나옵니다.
  • 원인: 조회 사이에 다른 트랜잭션이 데이터를 변경하고 커밋했기 때문입니다.
순서세션 A (Writer)세션 B (Reader)설명
1SET 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] 첫 번째 조회
4START TRANSACTION;  
5UPDATE isolation_test SET amount = 200 WHERE id = 1; A가 값 변경
6COMMIT; 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 (InnoDB REPEATABLE 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)
3INSERT 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)
3INSERT 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.