트랜잭션 격리 수준 실습 - Dirty Read, Non-Repeatable Read, Phantom Read 재현

최용혁·2025년 12월 9일

트랜잭션 격리 수준 실습 - Dirty Read, Non-Repeatable Read, Phantom Read 재현

들어가며 🚀

이전 글에서 트랜잭션 격리 수준 4가지를 정리했다. 이번에는 실제로 MySQL에서 각 문제를 재현해보면서 격리 수준이 어떻게 동작하는지 확인해보려고 한다.


실습 환경 준비 🛠️

테이블 생성

-- 실습용 테이블 생성
CREATE TABLE products (
    id INT PRIMARY KEY,
    name VARCHAR(50),
    stock INT
);

-- 초기 데이터 삽입
INSERT INTO products (id, name, stock) VALUES (1, '한정판 운동화', 10);
INSERT INTO products (id, name, stock) VALUES (2, '싱싱한 고등어', 25);

-- 데이터 확인
SELECT * FROM products;

터미널 2개 준비 💻

실습을 위해 MySQL 터미널을 2개 열어둔다.

  • 🖥️ 터미널 1: 트랜잭션 A
  • 🖥️ 터미널 2: 트랜잭션 B

1. Dirty Read 재현 🧼

Dirty Read란?

커밋되지 않은 "더러운" 데이터를 다른 트랜잭션이 읽는 현상

예를 들어:
1. 트랜잭션 A가 재고를 10개에서 5개로 수정 (아직 커밋 전)
2. 트랜잭션 B가 재고를 조회 → 5개로 보임 😱
3. 트랜잭션 A가 롤백 → 재고는 원래대로 10개
4. 트랜잭션 B는 존재하지 않았던 '5개'를 읽은 것 👻


Read Uncommitted에서 재현 ❌

1단계: 격리 수준 설정

두 터미널 모두 실행:

SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

[캡쳐 자리: 격리 수준 설정 결과]


2단계: 트랜잭션 A 시작

🖥️ 터미널 1:

-- 트랜잭션 시작
START TRANSACTION;

-- 재고 수정 (커밋 안 함)
UPDATE products SET stock = 5 WHERE id = 1;

-- 현재 값 확인
SELECT stock FROM products WHERE id = 1;

[캡쳐 자리: 트랜잭션 A에서 수정 후 조회 결과 - 5]


3단계: 트랜잭션 B에서 읽기

🖥️ 터미널 2:

-- 트랜잭션 시작
START TRANSACTION;

-- 재고 조회 (커밋 안 된 데이터 읽음)
SELECT stock FROM products WHERE id = 1;

[캡쳐 자리: 트랜잭션 B에서 조회 결과 - 5 (Dirty Read 발생!)]

💥 Dirty Read 발생! 커밋되지 않은 데이터를 읽었다!


4단계: 트랜잭션 A 롤백

🖥️ 터미널 1:

-- 롤백
ROLLBACK;

5단계: 최종 확인

🖥️ 터미널 2:

-- 다시 조회
SELECT stock FROM products WHERE id = 1;

롤백 후:

👻 아까 읽은 5는 유령 데이터였다! 실제로는 10개였던 것!


Read Committed로 방지 ✅

1단계: 격리 수준 변경

두 터미널 모두 실행:

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

2단계: 트랜잭션 A 시작

🖥️ 터미널 1:

START TRANSACTION;
UPDATE products SET stock = 5 WHERE id = 1;

3단계: 트랜잭션 B에서 읽기

🖥️ 터미널 2:

START TRANSACTION;
SELECT stock FROM products WHERE id = 1;

커밋 전이어서 터미널2(console_1)에서 확인했을 때 stock이 10이 나온 것을 확인할 수 있다!


4단계: 커밋 후 확인

🖥️ 터미널 1:

COMMIT;

🖥️ 터미널 2:

SELECT stock FROM products WHERE id = 1;

터미널1(console)을 커밋 후 데이터가 5로 바뀐 것을 확인할 수 있다!

🎯 Dirty Read 방지 성공!


2. Non-Repeatable Read 재현 🔄

Non-Repeatable Read란?

한 트랜잭션 내에서 같은 데이터를 두 번 읽었는데 값이 다른 현상

예를 들어:
1. 트랜잭션 A가 재고 조회 → 10개
2. 트랜잭션 B가 재고를 5개로 수정하고 커밋 🔧
3. 트랜잭션 A가 다시 조회 → 5개 😵


데이터 초기화 🔄

UPDATE products SET stock = 10 WHERE id = 1;
COMMIT;
SELECT * FROM products;


Read Committed에서 재현 ❌

1단계: 격리 수준 설정

두 터미널 모두 실행:

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

2단계: 트랜잭션 A - 첫 번째 조회

🖥️ 터미널 1:

START TRANSACTION;
SELECT stock FROM products WHERE id = 1;

📊 초기화된 15가 잘 나오고 있다.


3단계: 트랜잭션 B - 수정 및 커밋

🖥️ 터미널 2:

START TRANSACTION;
UPDATE products SET stock = 5 WHERE id = 1;
COMMIT;

🔧 터미널2(console_1)에서 업데이트 후 커밋을 했더니 데이터가 5로 바뀐 것을 확인할 수 있었다.


4단계: 트랜잭션 A - 두 번째 조회

🖥️ 터미널 1:

SELECT stock FROM products WHERE id = 1;

💥 Non-Repeatable Read 발생! 터미널1에서 select를 했을 때 터미널2의 변경 내역이 그대로 반영되었고, 터미널1의 처음 select 했을 때와 두 번째 select 했을 때의 값이 변경되어 Non-Repeatable 문제가 발생했다.

COMMIT;

Repeatable Read로 방지 ✅

1단계: 격리 수준 변경

두 터미널 모두 실행:

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

2단계: 트랜잭션 A - 첫 번째 조회

🖥️ 터미널 1:

START TRANSACTION;
SELECT stock FROM products WHERE id = 1;

📊 동일하게 처음 조회는 15가 나오는 걸 확인할 수 있다.


3단계: 트랜잭션 B - 수정 및 커밋

🖥️ 터미널 2:

START TRANSACTION;
UPDATE products SET stock = 5 WHERE id = 1;
COMMIT;

🔧 터미널2에서 업데이트 + 커밋을 진행하였고, 1번 데이터의 stock가 5로 바뀐 것을 확인할 수 있었다.


4단계: 트랜잭션 A - 두 번째 조회

🖥️ 터미널 1:

SELECT stock FROM products WHERE id = 1;

스냅샷 덕분에 값이 유지됐다! 하지만 처음 터미널1로 돌아가서 select를 실행하면 트랜잭션이 시작될 때와 동일하게 15를 보여주고 있는 것을 확인할 수 있었다.

COMMIT;

🎯 Non-Repeatable Read 방지 성공!


3. Phantom Read 재현 👻

Phantom Read란?

한 트랜잭션 내에서 같은 조건으로 조회했는데 없던 데이터가 나타나는 현상

예를 들어:
1. 트랜잭션 A가 stock > 10 조회 → 2개
2. 트랜잭션 B가 새 상품 추가 (stock = 15)하고 커밋 ➕
3. 트랜잭션 A가 다시 조회 → 3개 👻


데이터 초기화 🔄

DELETE FROM products WHERE id = 3;
UPDATE products SET stock = 15 WHERE id IN (1, 2);
COMMIT;
SELECT * FROM products WHERE stock > 10;


Repeatable Read에서 재현 🤔

1단계: 격리 수준 설정

두 터미널 모두 실행:

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

2단계: 트랜잭션 A - 첫 번째 조회

🖥️ 터미널 1:

START TRANSACTION;
SELECT * FROM products WHERE stock > 10;

📊 2개의 행이 조회됐다.


3단계: 트랜잭션 B - 새 데이터 추가

🖥️ 터미널 2:

START TRANSACTION;
INSERT INTO products (id, name, stock) VALUES (3, '유령 신상품', 15);
COMMIT;

새로운 상품 추가 완료!


4단계: 트랜잭션 A - 두 번째 조회

🖥️ 터미널 1:

SELECT * FROM products WHERE stock > 10;

COMMIT;

🤔 어라? 여기 보면 업데이트된 행까지 3개의 행이 나와야 되는데 2개의 행만 나온 것을 확인할 수 있다.

💡 강사님께서 이거는 MySQL의 최신 버전에서는 REPEATABLE READ 단계에서도 스냅샷을 지원해줘서 트랜잭션이 시작될 때 스냅샷했던 데이터를 불러와서 잘 나오는 거라고 한다. 하지만 다른 DB에서는 지원하지 않기 때문에 알고 있어야 한다고 한다.


Serializable로 방지 ✅

1단계: 데이터 재초기화

DELETE FROM products WHERE id = 3;
COMMIT;

2단계: 격리 수준 변경

두 터미널 모두 실행:

SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

3단계: 트랜잭션 A - 조회

🖥️ 터미널 1:

START TRANSACTION;
SELECT * FROM products WHERE stock > 10;


4단계: 트랜잭션 B - 추가 시도

🖥️ 터미널 2:

START TRANSACTION;
INSERT INTO products (id, name, stock) VALUES (3, '유령 신상품', 15);


사진을 보면 터미널2(console_1)이 DB에 들어가지 않고 계속 대기 중으로 돌고 있는 것을 확인할 수 있다.

🔒 범위 잠금(Range Lock)이 걸려서 대기 중!


5단계: 트랜잭션 A 커밋

🖥️ 터미널 1:

COMMIT;

🖥️ 터미널 2에서 대기하던 INSERT가 실행됨:

-- 자동으로 실행됨
COMMIT;

터미널 1이 커밋이 완료된 시점으로 터미널 2의 대기가 끝나고 커밋된 것을 확인할 수 있다.

🎯 Phantom Read 방지 성공!


정리 📝

실습 결과 요약

격리 수준Dirty ReadNon-Repeatable ReadPhantom Read
Read Uncommitted❌ 발생❌ 발생❌ 발생
Read Committed✅ 방지❌ 발생❌ 발생
Repeatable Read✅ 방지✅ 방지❌ 발생
Serializable✅ 방지✅ 방지✅ 방지

배운 점 💡

Read Uncommitted:

  • 커밋 안 된 데이터도 읽힘 👻
  • 실무에서 절대 사용하면 안 됨 🚫

Read Committed:

  • 커밋된 데이터만 읽음 ✅
  • 하지만 같은 데이터를 두 번 읽으면 값이 바뀔 수 있음 🔄
  • 대부분의 DB 기본값 ⭐

Repeatable Read:

  • 트랜잭션 시작 시점의 스냅샷으로 조회 📸
  • 값은 안 바뀌지만 새로운 행은 나타날 수 있음 (이론상)
  • MySQL 기본값 ⭐
  • MySQL 최신 버전에서는 Phantom Read도 방지 🎉

Serializable:

  • 범위 전체를 잠금 🔒
  • 완벽한 격리, 하지만 성능 희생 ⚠️
  • 금융 시스템 일부만 사용 💰

느낀 점 🤔

직접 재현해보니 각 격리 수준의 차이가 명확히 이해됐다. 특히 Repeatable Read에서도 Phantom Read가 발생하는 걸 보고 놀랐다 (MySQL에서는 방지되지만!).

실무에서는 대부분 Read Committed를 쓰고, 필요하면 애플리케이션 레벨에서 락을 거는 게 나을 것 같다.


다음 단계 🎯

  • 애플리케이션 레벨 락 구현 (비관적 락, 낙관적 락) 🔐
  • Redis를 활용한 분산 락 📦
  • 실제 프로젝트에 적용해보기 🚀

긴 글 읽어주셔서 감사합니다! 😊

궁금한 점이나 경험 있으시면 댓글로 공유해주세요! 💬

profile
안녕하세용

0개의 댓글