Spring Boot에서 Dirty Read 재현하고 해결하기

최용혁·2025년 12월 10일

Spring Boot에서 Dirty Read 재현하고 해결하기 💻

들어가며 🚀

이전 글에서 SQL로 Dirty Read를 재현해봤다. 이번에는 실제 Spring Boot 프로젝트에서 @Transactionalisolation 속성을 사용해서 Dirty Read를 재현하고 해결하는 방법을 알아보려고 한다.


사전 지식: MVCC와 Undo Log 📚

Spring @Transactional이 어떻게 격리 수준을 보장하는지 이해하려면 MVCC(Multi-Version Concurrency Control)Undo Log를 알아야 한다.

MVCC란?

현대적인 데이터베이스(MySQL InnoDB, PostgreSQL)가 동시성 문제를 해결하기 위해 사용하는 기술이다.

Undo Log 📝

데이터가 UPDATE될 때:
1. 기존 데이터를 바로 덮어쓰지 않음
2. 변경 전 데이터를 'Undo Log'에 기록
3. 일종의 '버전 관리' 또는 '변경 이력'

스냅샷 (Snapshot) 📸

트랜잭션이 데이터를 읽을 때:
1. Undo Log를 활용
2. 특정 시점의 일관된 데이터 버전 제공

💡 핵심: MVCC는 데이터를 여러 버전으로 관리해서 동시성과 일관성을 동시에 확보한다!


프로젝트 구조 📁

src/main/java/com/example/demo/
├── domain/
│   └── Product.java
├── repository/
│   └── ProductRepository.java
├── service/
│   └── ProductIsolationService.java
└── test/
    └── ProductIsolationServiceTest.java

Part 1. 엔티티 및 리포지토리 작성 ⚙️

Product 엔티티

@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;
    }
}

간단한 상품 엔티티 완성!


ProductRepository

public interface ProductRepository extends JpaRepository<Product, Long> {
}

JPA 기본 기능만으로 충분!


Part 2. 서비스 계층 코드 작성 🛠️

1. 재고 변경 및 강제 롤백 메서드

이 메서드는 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은 안 한 상태!


2. 재고 조회 메서드 (READ_UNCOMMITTED)

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 발생!


3. 재고 조회 메서드 (READ_COMMITTED)

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 방지!


Part 3. 🤢 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());
    }
}


테스트 실행 및 결과 확인 🔍

1️⃣ Thread A 시작 - 재고 변경

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


2️⃣ Thread B - Dirty Read 발생!

💥 UPDATE문 실행 후 Product 1 has stock 10 after Reading → 변경된 값(10)이 조회됨!

🚨 이것이 Dirty Read! 커밋되지 않은 데이터를 읽었다!


3️⃣ Thread A - 롤백 및 테스트 종료

롤백 완료 → 재고가 다시 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)를 읽었다! 👻

Part 4. ✅ Dirty Read 문제 해결

이 문제를 해결하기 위해서는 Service 함수에서 읽는 함수의 IsolationREAD_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());
}

테스트 실행 및 결과 확인 🔍

1️⃣ READ_COMMITTED로 조회

productService.getStockWithReadCommitted 함수 실행!

  • Isolation = Isolation.READ_COMMITTED 설정

2️⃣ Dirty Read 방지 확인!

🎉 성공!

  • Thread6에서 10으로 UPDATE 했지만
  • Thread7에서 Product 1 has stock 20 after Reading 출력
  • Dirty Read가 방지되었다!

커밋되지 않은 데이터(10)를 읽지 않고, 원래 값(20)을 읽었다!


READ_COMMITTED 내부 동작 🔍

1단계: Thread A의 UPDATE 🔧

DB 내부 동작:
1. product 테이블의 stock을 10으로 변경
2. 변경 전 값(20)을 Undo Log에 기록 📝
3. Undo Log에 "이 변경은 Thread A에 의한 것, 아직 미커밋" 정보 포함

[개념도]

실제 데이터:        Undo Log:
+--------+          +--------+
| stock  |          | stock  |
| = 10   |   <---   | = 20   |
+--------+          +--------+
(미커밋)            (커밋됨) ✅

💡 핵심: 변경 전 데이터를 Undo Log에 백업!


2단계: Thread B의 SELECT 🔍

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 덕분에 커밋된 버전을 찾아서 반환!


3단계: Thread A의 ROLLBACK 🔄

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는 정확한 데이터를 읽었다! 🎉

정리 📝

Dirty Read 문제 ❌

READ_UNCOMMITTED:
❌ 커밋 안 된 데이터도 읽음
❌ 롤백되면 유령 데이터 문제 👻
❌ 데이터 정합성 엉망
❌ 실무에서 절대 사용 금지 🚫

Dirty Read 해결 ✅

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 재현 및 해결 👻
  • 실제 프로젝트에 적용해보기 🚀
  • 성능 테스트 및 비교 📊

참고 자료 📚


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

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

시리즈 다음 편에서 만나요! 👋

profile
안녕하세용

0개의 댓글