본문 바로가기
Spring/Spring-detail

@Transactional 격리 레벨 REPEATABLE READ 예시 (MySQL 디폴트값)

by YoonJong 2025. 10. 22.
728x90
반응형

 

 

 

🎯 핵심 개념

"같은 트랜잭션 안에서는 몇 번을 조회해도 항상 같은 값이 나온다"


📝 시나리오 1: 상품 재고 관리

상황 설정

 
 
sql
-- 초기 데이터
products 테이블
+----+--------+-------+
| id | name   | stock |
+----+--------+-------+
| 1  | 아이폰  | 100   |
+----+--------+-------+

Entity 클래스

 
 
java
@Entity
@Table(name = "products")
@Getter
@NoArgsConstructor
public class Product {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    
    private Integer stock;
    
    public void decreaseStock(int quantity) {
        if (this.stock < quantity) {
            throw new IllegalStateException("재고 부족!");
        }
        this.stock -= quantity;
    }
}

🔴 문제 상황: READ COMMITTED 사용 시

 
 
java
@Service
@RequiredArgsConstructor
@Slf4j
public class ProductService {
    
    private final ProductRepository productRepository;
    
    // ❌ READ COMMITTED 사용 (문제 발생!)
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public void orderProduct(Long productId, int quantity) {
        
        // 1️⃣ 첫 번째 조회
        Product product = productRepository.findById(productId).orElseThrow();
        log.info("🔍 첫 번째 조회 - 재고: {}", product.getStock());  // 100개
        
        // 재고 검증
        if (product.getStock() < quantity) {
            throw new IllegalStateException("재고 부족!");
        }
        
        try {
            // 🕐 업무 로직 처리 중... (5초 소요)
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        
        // 2️⃣ 두 번째 조회 (같은 트랜잭션 내)
        product = productRepository.findById(productId).orElseThrow();
        log.info("🔍 두 번째 조회 - 재고: {}", product.getStock());  // ⚠️ 50개로 변경됨!
        
        // 3️⃣ 재고 차감
        product.decreaseStock(quantity);
        productRepository.save(product);
        
        log.info("✅ 주문 완료 - 최종 재고: {}", product.getStock());
    }
}

동시 실행되는 다른 트랜잭션

 
 
java
@Service
@RequiredArgsConstructor
@Slf4j
public class AdminService {
    
    private final ProductRepository productRepository;
    
    @Transactional
    public void adjustStock(Long productId, int newStock) {
        Product product = productRepository.findById(productId).orElseThrow();
        product.setStock(newStock);
        productRepository.save(product);
        log.info("⚙️ 관리자가 재고를 {}개로 변경", newStock);
    }
}

실행 시나리오

 
 
java
@SpringBootTest
@Slf4j
public class IsolationTest {
    
    @Autowired
    private ProductService productService;
    
    @Autowired
    private AdminService adminService;
    
    @Test
    void readCommittedProblem() throws InterruptedException {
        Long productId = 1L;
        
        // 트랜잭션 A: 고객 주문 (백그라운드 실행)
        new Thread(() -> {
            productService.orderProduct(productId, 10);
        }).start();
        
        // 2초 대기
        Thread.sleep(2000);
        
        // 트랜잭션 B: 관리자가 재고 변경
        adminService.adjustStock(productId, 50);
        
        Thread.sleep(10000);
    }
}
```

### 실행 결과 (READ COMMITTED)
```
[트랜잭션 A] 🔍 첫 번째 조회 - 재고: 100
[트랜잭션 A] 🕐 업무 로직 처리 중...

[트랜잭션 B] ⚙️ 관리자가 재고를 50개로 변경  ← COMMIT!

[트랜잭션 A] 🔍 두 번째 조회 - 재고: 50  ← ⚠️ 값이 바뀜! (Non-Repeatable Read)
[트랜잭션 A] ✅ 주문 완료 - 최종 재고: 40

문제점:

  • 처음엔 100개였는데 갑자기 50개로 변경됨
  • 같은 트랜잭션 내에서 일관성이 깨짐
  • 비즈니스 로직이 예상과 다르게 동작할 수 있음

✅ 해결: REPEATABLE READ 사용

 
 
java
@Service
@RequiredArgsConstructor
@Slf4j
public class ProductService {
    
    private final ProductRepository productRepository;
    
    // ✅ REPEATABLE READ 사용 (MySQL 기본값)
    @Transactional(isolation = Isolation.REPEATABLE_READ)
    public void orderProduct(Long productId, int quantity) {
        
        // 1️⃣ 첫 번째 조회
        Product product = productRepository.findById(productId).orElseThrow();
        log.info("🔍 첫 번째 조회 - 재고: {}", product.getStock());  // 100개
        
        // 재고 검증
        if (product.getStock() < quantity) {
            throw new IllegalStateException("재고 부족!");
        }
        
        try {
            // 🕐 업무 로직 처리 중... (5초 소요)
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        
        // 2️⃣ 두 번째 조회 (같은 트랜잭션 내)
        product = productRepository.findById(productId).orElseThrow();
        log.info("🔍 두 번째 조회 - 재고: {}", product.getStock());  // ✅ 여전히 100개!
        
        // 3️⃣ 재고 차감
        product.decreaseStock(quantity);
        productRepository.save(product);
        
        log.info("✅ 주문 완료 - 최종 재고: {}", product.getStock());
    }
}
```

### 실행 결과 (REPEATABLE READ)
```
[트랜잭션 A] 🔍 첫 번째 조회 - 재고: 100
[트랜잭션 A] 🕐 업무 로직 처리 중...

[트랜잭션 B] ⚙️ 관리자가 재고를 50개로 변경  ← COMMIT!
              (하지만 A는 여전히 100개로 보임!)

[트랜잭션 A] 🔍 두 번째 조회 - 재고: 100  ← ✅ 값이 그대로! (일관성 보장)
[트랜잭션 A] ✅ 주문 완료 - 최종 재고: 90
[트랜잭션 A] COMMIT!

[최종 DB 상태] 재고: 50 (B가 변경한 값)

MySQL의 MVCC 동작:

  • 트랜잭션 A 시작 시점의 스냅샷을 유지
  • B가 데이터를 변경하더라도 A는 자신의 스냅샷을 계속 봄
  • A가 커밋할 때 충돌 감지 및 처리
728x90
반응형

댓글