이전 글에서 SQL로 Dirty Read를 재현해봤다. 이번에는 실제 Spring Boot 프로젝트에서 @Transactional의 isolation 속성을 사용해서 Dirty Read를 재현하고 해결하는 방법을 알아보려고 한다.
Spring @Transactional이 어떻게 격리 수준을 보장하는지 이해하려면 MVCC(Multi-Version Concurrency Control)와 Undo Log를 알아야 한다.
현대적인 데이터베이스(MySQL InnoDB, PostgreSQL)가 동시성 문제를 해결하기 위해 사용하는 기술이다.
데이터가 UPDATE될 때:
1. 기존 데이터를 바로 덮어쓰지 않음
2. 변경 전 데이터를 'Undo Log'에 기록
3. 일종의 '버전 관리' 또는 '변경 이력'
트랜잭션이 데이터를 읽을 때:
1. Undo Log를 활용
2. 특정 시점의 일관된 데이터 버전 제공
💡 핵심: MVCC는 데이터를 여러 버전으로 관리해서 동시성과 일관성을 동시에 확보한다!
src/main/java/com/example/demo/
├── domain/
│ └── Product.java
├── repository/
│ └── ProductRepository.java
├── service/
│ └── ProductIsolationService.java
└── test/
└── ProductIsolationServiceTest.java
@Entity
@Getter
@Setter
@NoArgsConstructor
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private int stock;
public Product(String name, int stock) {
this.name = name;
this.stock = stock;
}
}

✅ 간단한 상품 엔티티 완성!
public interface ProductRepository extends JpaRepository<Product, Long> {
}
✅ JPA 기본 기능만으로 충분!
이 메서드는 Thread A가 데이터를 변경한 후, Thread B가 접근할 수 있도록 잠시 대기했다가 롤백하는 상황을 시뮬레이션한다.
@Slf4j
@Service
@RequiredArgsConstructor
public class ProductIsolationService {
private final ProductRepository productRepository;
@Transactional
public void updateStockAndForceRollback(Long productId, int newStock) {
// 1. 상품을 조회하고 재고를 변경
Product product = productRepository.findById(productId)
.orElseThrow(() -> new RuntimeException("상품을 찾을 수 없습니다"));
log.info("Thread A: 재고를 {}에서 {}으로 변경 시도",
product.getStock(), newStock);
product.setStock(newStock);
// 2. DB에 변경사항을 즉시 반영 (flush)
// COMMIT은 아니지만, UPDATE 쿼리를 DB로 전송
productRepository.saveAndFlush(product);
log.info("Thread A: DB에 변경사항 flush 완료");
// 3. 다른 트랜잭션이 'COMMIT되지 않은' 데이터를 읽을 시간을 줌
try {
log.info("Thread A: 5초 대기 시작...");
Thread.sleep(5000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 4. 의도적으로 트랜잭션을 롤백
log.info("Thread A: 작업을 롤백합니다");
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
}
| 어노테이션/메서드 | 역할 |
|---|---|
@Transactional | 메서드를 하나의 트랜잭션으로 실행 |
saveAndFlush() | 영속성 컨텍스트 → DB 즉시 동기화 |
Thread.sleep(5000) | Dirty Read 발생 시간 확보 |
setRollbackOnly() | 강제 롤백 설정 |
⚠️ 핵심: saveAndFlush()로 UPDATE는 했지만 아직 COMMIT은 안 한 상태!
Dirty Read를 허용하는 메서드다.
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
public int getStockWithDirtyRead(Long productId) {
log.info("Thread B: READ_UNCOMMITTED 트랜잭션에서 재고 조회 시도");
Product product = productRepository.findById(productId)
.orElseThrow(() -> new RuntimeException("상품을 찾을 수 없습니다"));
int stock = product.getStock();
log.info("Thread B: 조회된 재고 = {}", stock);
return stock;
}

1. Spring이 격리 수준을 READ_UNCOMMITTED로 설정
↓
2. findById() 실행
↓
3. 다른 트랜잭션의 COMMIT 여부를 신경 쓰지 않음
↓
4. 테이블의 가장 최신 버전 데이터를 그대로 읽음
↓
5. Thread A가 flush한 값(10)을 그대로 반환 💥
🚨 위험: 커밋되지 않은 데이터를 읽기 때문에 Dirty Read 발생!
Dirty Read를 방지하는 메서드다.
@Transactional(isolation = Isolation.READ_COMMITTED)
public int getStockWithReadCommitted(Long productId) {
log.info("Thread B: READ_COMMITTED 트랜잭션에서 재고 조회 시도");
Product product = productRepository.findById(productId)
.orElseThrow(() -> new RuntimeException("상품을 찾을 수 없습니다"));
int stock = product.getStock();
log.info("Thread B: 조회된 재고 = {}", stock);
return stock;
}

1. Spring이 격리 수준을 READ_COMMITTED로 설정
↓
2. findById() 실행
↓
3. "커밋되지 않은 데이터는 읽을 수 없음" 규칙 적용
↓
4. Undo Log를 참조해서 커밋된 버전 찾기
↓
5. 원래 값(20) 반환 ✅
✅ 안전: 커밋된 데이터만 읽기 때문에 Dirty Read 방지!
@SpringBootTest
@Slf4j
class ProductIsolationServiceTest {
@Autowired
private ProductIsolationService productService;
@Autowired
private ProductRepository productRepository;
@BeforeEach
void setUp() {
// 테스트 데이터 초기화
productRepository.deleteAll();
Product product = new Product("테스트 상품", 20);
productRepository.save(product);
log.info("테스트 데이터 초기화 완료: 재고 = 20");
}
@Test
@DisplayName("READ_UNCOMMITTED에서는 Dirty Read가 발생한다")
void testDirtyReadAllowed() throws InterruptedException {
// Given: 초기 재고는 20
Long productId = 1L;
// Thread A: 재고를 10으로 바꾸고 롤백 예정
Thread threadA = new Thread(() -> {
log.info("=== Thread A 시작 ===");
productService.updateStockAndForceRollback(productId, 10);
log.info("=== Thread A 종료 ===");
});
// Thread B: Thread A가 작업하는 도중에 재고 조회
Thread threadB = new Thread(() -> {
try {
log.info("=== Thread B 시작 ===");
// Thread A가 재고를 변경할 시간을 줌
Thread.sleep(1000);
int stock = productService.getStockWithDirtyRead(productId);
log.info("=== Dirty Read 발생: 읽은 재고 = {} ===", stock);
Assertions.assertEquals(10, stock);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
// When: 두 스레드 동시 실행
threadA.start();
threadB.start();
threadA.join();
threadB.join();
// Then: Thread A가 롤백되었으므로 최종 재고는 원상 복구
Product finalProduct = productRepository.findById(productId).orElseThrow();
log.info("=== 최종 실제 재고 = {} ===", finalProduct.getStock());
Assertions.assertEquals(20, finalProduct.getStock());
}
}


✅ Thread6(Thread A): 재고를 20에서 10으로 변경 시도 로그 발견!

💥 UPDATE문 실행 후 Product 1 has stock 10 after Reading → 변경된 값(10)이 조회됨!
🚨 이것이 Dirty Read! 커밋되지 않은 데이터를 읽었다!

✅ 롤백 완료 → 재고가 다시 20으로 복구됨!
👻 Thread B는 유령 데이터(10)를 읽었던 것!
🕐 시작: Thread A 실행
└─ stock을 10으로 UPDATE
└─ DB에 flush (⚠️ 아직 COMMIT 안 함!)
└─ 5초 대기 시작 💤
🕑 1초 후: Thread B 실행
└─ READ_UNCOMMITTED 격리 수준 설정
└─ 커밋 안 된 데이터(10)를 그대로 읽음 💥
└─ "Dirty Read 발생: 읽은 재고 = 10" 출력
🕔 5초 후: Thread A 종료
└─ 트랜잭션 롤백 🔄
└─ stock이 원래 값 20으로 복구
✅ 최종 확인
└─ DB의 실제 재고 = 20
└─ Thread B는 유령 데이터(10)를 읽었다! 👻
이 문제를 해결하기 위해서는 Service 함수에서 읽는 함수의 Isolation을 READ_UNCOMMITTED에서 READ_COMMITTED로 변경해주면 된다!
@Test
@DisplayName("READ_COMMITTED에서는 Dirty Read가 방지된다")
void testDirtyReadPrevented() throws InterruptedException {
// Given: 초기 재고는 20
Long productId = 1L;
// Thread A: 재고를 10으로 바꾸고 롤백 예정
Thread threadA = new Thread(() -> {
log.info("=== Thread A 시작 ===");
productService.updateStockAndForceRollback(productId, 10);
log.info("=== Thread A 종료 ===");
});
// Thread B: Thread A가 작업하는 도중에 재고 조회
Thread threadB = new Thread(() -> {
try {
log.info("=== Thread B 시작 ===");
// Thread A가 재고를 변경할 시간을 줌
Thread.sleep(1000);
int stock = productService.getStockWithReadCommitted(productId);
log.info("=== Read Committed: 읽은 재고 = {} ===", stock);
Assertions.assertEquals(20, stock); // 원래 데이터를 읽음 ✅
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
// When: 두 스레드 동시 실행
threadA.start();
threadB.start();
threadA.join();
threadB.join();
// Then: 최종 재고는 당연히 원상 복구
Product finalProduct = productRepository.findById(productId).orElseThrow();
log.info("=== 최종 실제 재고 = {} ===", finalProduct.getStock());
Assertions.assertEquals(20, finalProduct.getStock());
}

✅ productService.getStockWithReadCommitted 함수 실행!
Isolation = Isolation.READ_COMMITTED 설정
🎉 성공!
Product 1 has stock 20 after Reading 출력✅ 커밋되지 않은 데이터(10)를 읽지 않고, 원래 값(20)을 읽었다!
DB 내부 동작:
1. product 테이블의 stock을 10으로 변경
2. 변경 전 값(20)을 Undo Log에 기록 📝
3. Undo Log에 "이 변경은 Thread A에 의한 것, 아직 미커밋" 정보 포함
[개념도]
실제 데이터: Undo Log:
+--------+ +--------+
| stock | | stock |
| = 10 | <--- | = 20 |
+--------+ +--------+
(미커밋) (커밋됨) ✅
💡 핵심: 변경 전 데이터를 Undo Log에 백업!
DB 내부 동작:
1. Spring이 격리 수준을 READ_COMMITTED로 설정
2. Thread B가 stock 읽기 시도
3. DB가 "이 데이터는 아직 커밋 안 됨" 인지 ⚠️
4. Undo Log를 참조해서 커밋된 버전 찾기 🔎
5. 원래 값 20 반환 ✅
[흐름도]
Thread A (UPDATE) Thread B (SELECT)
| |
v v
UPDATE stock=10 READ_COMMITTED 설정
| |
v v
Undo Log 생성 커밋 여부 확인 🔍
(원래값: 20) 📝 |
| v
| Undo Log 참조 🔎
| |
| v
v 값 20 반환 ✅
ROLLBACK 🔄
|
v
Undo Log로 복구
(stock = 20)
💡 핵심: Undo Log 덕분에 커밋된 버전을 찾아서 반환!
DB 내부 동작:
1. Undo Log의 원래 값(20)으로 복구
2. stock을 10으로 변경했던 기록 무효화
✅ 결과: 데이터 정합성 유지!
🕐 시작: Thread A 실행
└─ stock을 10으로 UPDATE
└─ Undo Log에 원래 값(20) 기록 📝
└─ DB에 flush (⚠️ 아직 COMMIT 안 함!)
└─ 5초 대기 시작 💤
🕑 1초 후: Thread B 실행
└─ READ_COMMITTED 격리 수준 설정 ✅
└─ "커밋 안 된 데이터는 읽을 수 없음" 규칙 적용
└─ Undo Log 참조해서 커밋된 버전(20) 찾기 🔎
└─ "Read Committed: 읽은 재고 = 20" 출력 ✅
🕔 5초 후: Thread A 종료
└─ 트랜잭션 롤백 🔄
└─ stock이 원래 값 20으로 복구
✅ 최종 확인
└─ DB의 실제 재고 = 20
└─ Thread B는 정확한 데이터를 읽었다! 🎉
READ_UNCOMMITTED:
❌ 커밋 안 된 데이터도 읽음
❌ 롤백되면 유령 데이터 문제 👻
❌ 데이터 정합성 엉망
❌ 실무에서 절대 사용 금지 🚫
READ_COMMITTED:
✅ 커밋된 데이터만 읽음
✅ Undo Log 활용해서 안전하게 조회
✅ MVCC로 성능 유지
✅ 대부분 DB의 기본값 ⭐
비교표
격리 수준 Dirty Read 성능 안전성 실무 사용 READ_UNCOMMITTED ❌ 발생 ⭐⭐⭐⭐⭐ ⭐ 🚫 사용 금지 READ_COMMITTED ✅ 방지 ⭐⭐⭐⭐ ⭐⭐⭐⭐ ✅ 추천
배운 점 💡
1. @Transactional의 isolation 속성
// Dirty Read 허용 (위험!)
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
// Dirty Read 방지 (안전!)
@Transactional(isolation = Isolation.READ_COMMITTED)
2. MVCC와 Undo Log
- 변경 전 데이터를 Undo Log에 기록 📝
- 격리 수준에 따라 적절한 버전 반환 🔎
- 성능과 정합성의 균형 ⚖️
3. 멀티스레드 테스트
Thread.sleep()으로 타이밍 조절 ⏰
Thread.join()으로 완료 대기 ⏳
- 실제 동시성 문제 재현 가능 🎯
느낀 점 🤔
직접 Spring Boot에서 격리 수준을 설정하고 테스트해보니 이론으로만 배웠던 내용이 확실히 이해됐다. 특히 Undo Log를 참조해서 커밋된 버전을 찾는 과정이 인상 깊었다.
실무에서는 대부분 READ_COMMITTED를 사용하면 충분할 것 같다. 하지만 특수한 상황에서는 격리 수준을 조정할 필요가 있다는 걸 알게 됐다.
가장 중요한 건 "왜 이 격리 수준을 사용하는가?"를 이해하는 것이다. 단순히 설정하는 것이 아니라, 각 격리 수준의 장단점을 이해하고 상황에 맞게 선택할 수 있어야 한다.
다음 단계 🎯
- Non-Repeatable Read 재현 및 해결 🔄
- Phantom Read 재현 및 해결 👻
- 실제 프로젝트에 적용해보기 🚀
- 성능 테스트 및 비교 📊
참고 자료 📚
긴 글 읽어주셔서 감사합니다! 😊
궁금한 점이나 경험 있으시면 댓글로 공유해주세요! 💬
시리즈 다음 편에서 만나요! 👋