이전 글에서 Dirty Read를 해결했다. 이번에는 한 트랜잭션 내에서 같은 데이터를 두 번 읽었는데 값이 다른 Non-Repeatable Read 문제를 Spring Boot에서 재현하고 해결해보려고 한다.
우리가 @Transactional이 붙은 메서드를 호출할 때, 실제로는 우리가 만든 Service 객체를 직접 호출하는 게 아니다. Spring은 AOP(관점 지향 프로그래밍) 기술로 Service를 감싸는 프록시(Proxy) 객체를 만든다.
테스트 코드
↓
ProductService 프록시 객체
↓
실제 ProductService 객체
1. 메서드 호출 전 (Before):
- 트랜잭션 시작
- 데이터베이스 커넥션 가져오기
- setAutoCommit(false) 설정
- 격리 수준 설정 (SET TRANSACTION ISOLATION LEVEL ...)
2. 메서드 실행:
- 실제 Service 메서드 호출
3. 메서드 호출 후 (After):
- 성공 시: COMMIT
- 예외 발생 시: ROLLBACK
💡 핵심: 프록시가 자동으로 트랜잭션을 관리해준다!
하나의 트랜잭션 내에서:
1차 조회: 재고 20
↓
다른 트랜잭션이 데이터 변경 + 커밋
↓
2차 조회: 재고 5 (?)
→ 같은 트랜잭션인데 값이 바뀜!
@Service
@RequiredArgsConstructor
public class ProductIsolationService {
private final ProductRepository productRepository;
/**
* Non-Repeatable Read를 재현하는 메서드
* (격리 수준: READ_COMMITTED)
*/
@Transactional(isolation = Isolation.READ_COMMITTED)
public void demonstrateNonRepeatableRead(Long productId) {
// 1. 첫 번째 데이터 조회
Product product1 = productRepository.findById(productId).orElseThrow();
System.out.println("Thread A - First Read: Stock = " + product1.getStock());
// 2. 다른 트랜잭션이 데이터를 변경할 시간을 줌
try {
Thread.sleep(4000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 3. 동일 트랜잭션 내에서 데이터 다시 조회
Product product2 = productRepository.findById(productId).orElseThrow();
System.out.println("Thread A - Second Read: Stock = " + product2.getStock());
// 4. 두 조회 결과가 다른지 확인
if (product1.getStock() != product2.getStock()) {
System.out.println(">>>> Non-Repeatable Read가 발생했습니다!");
}
}
}

| 부분 | 설명 |
|---|---|
@Transactional(isolation = READ_COMMITTED) | READ_COMMITTED 격리 수준 설정 |
| 첫 번째 조회 | 트랜잭션 시작 시점의 데이터 조회 |
Thread.sleep(4000) | 다른 트랜잭션이 데이터를 변경할 시간 제공 |
| 두 번째 조회 | 같은 트랜잭션 내에서 다시 조회 |
/**
* Non-Repeatable Read를 방지하는 메서드
* (격리 수준: REPEATABLE_READ)
*/
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void demonstrateRepeatableRead(Long productId) {
// 위와 로직은 동일하지만, 격리 수준만 다름
Product product1 = productRepository.findById(productId).orElseThrow();
System.out.println("Thread A - First Read: Stock = " + product1.getStock());
try {
Thread.sleep(4000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
Product product2 = productRepository.findById(productId).orElseThrow();
System.out.println("Thread A - Second Read: Stock = " + product2.getStock());
if (product1.getStock() == product2.getStock()) {
System.out.println(">>>> Repeatable Read가 보장되었습니다!");
}
}

/**
* 재고를 업데이트하고 즉시 커밋하는 간단한 트랜잭션 메서드
*/
@Transactional
public void updateStock(Long productId, int newStock) {
Product product = productRepository.findById(productId).orElseThrow();
System.out.println("Thread B: 재고를 " + newStock + "으로 변경하고 커밋합니다.");
product.setStock(newStock);
// 메서드가 종료되면서 변경 사항이 COMMIT됨
}

@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_COMMITTED에서는 Non-Repeatable Read가 발생한다")
void testNonRepeatableReadAllowed() throws InterruptedException {
// Given: 초기 재고는 20
Long productId = 1L;
// Thread A: 데이터를 두 번 읽는 긴 트랜잭션
Thread threadA = new Thread(() -> {
log.info("=== Thread A 시작 ===");
productService.demonstrateNonRepeatableRead(productId);
log.info("=== Thread A 종료 ===");
});
// Thread B: 중간에 데이터를 수정하는 짧은 트랜잭션
Thread threadB = new Thread(() -> {
try {
log.info("=== Thread B 시작 ===");
Thread.sleep(1000); // Thread A가 첫 번째 읽기를 수행할 때까지 대기
productService.updateStock(productId, 5);
log.info("=== Thread B 종료 ===");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
// When: 두 스레드 동시 실행
threadA.start();
threadB.start();
threadA.join();
threadB.join();
// Then: 최종 재고는 5가 되어야 함
Product finalProduct = productRepository.findById(productId).orElseThrow();
log.info("=== 최종 실제 재고 = {} ===", finalProduct.getStock());
Assertions.assertEquals(5, finalProduct.getStock());
}
}


✅ 첫 번째 조회: 재고 = 20

🔧 Thread B: 재고를 5로 변경하고 커밋 완료!

💥 두 번째 조회: 재고 = 5 (값이 바뀌었다!)

🚨 Non-Repeatable Read 발생! 같은 트랜잭션 내에서 값이 20 → 5로 변경됨!
🕐 Thread A 시작
↓
READ_COMMITTED 트랜잭션 시작 (Tx-A)
↓
첫 번째 SELECT 실행
└─ 현재 커밋된 최신 값(20) 반환
└─ "First Read: Stock = 20" 출력
↓
4초 대기 💤
🕑 Thread B 시작 (1초 후)
↓
새로운 트랜잭션 시작 (Tx-B)
↓
UPDATE stock = 5 실행
↓
COMMIT 완료 ✅
└─ 이제 DB의 최신 값은 5
🕔 Thread A 재개 (4초 후)
↓
두 번째 SELECT 실행
└─ 현재 커밋된 최신 값(5) 반환
└─ "Second Read: Stock = 5" 출력
└─ "Non-Repeatable Read 발생!" 출력
↓
COMMIT 완료
💡 핵심: READ_COMMITTED는 쿼리 실행 시점의 최신 커밋 데이터를 읽는다!
문제 상황:
1. 통계 계산 중 (평균 = (1차 조회 + 2차 조회) / 2)
2. 1차 조회: 20
3. 다른 트랜잭션이 5로 변경
4. 2차 조회: 5
5. 평균 = (20 + 5) / 2 = 12.5 (???)
→ 잘못된 통계!
@Test
@DisplayName("REPEATABLE_READ에서는 Non-Repeatable Read가 방지된다")
void testNonRepeatableReadPrevented() throws InterruptedException {
// Given: 초기 재고는 20
Long productId = 1L;
// Thread A: REPEATABLE_READ로 데이터를 두 번 읽는 긴 트랜잭션
Thread threadA = new Thread(() -> {
log.info("=== Thread A 시작 ===");
productService.demonstrateRepeatableRead(productId);
log.info("=== Thread A 종료 ===");
});
// Thread B: 중간에 데이터를 수정하는 짧은 트랜잭션
Thread threadB = new Thread(() -> {
try {
log.info("=== Thread B 시작 ===");
Thread.sleep(1000);
productService.updateStock(productId, 5);
log.info("=== Thread B 종료 ===");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
// When: 두 스레드 동시 실행
threadA.start();
threadB.start();
threadA.join();
threadB.join();
// Then: 최종 재고는 5가 되어야 함
Product finalProduct = productRepository.findById(productId).orElseThrow();
log.info("=== 최종 실제 재고 = {} ===", finalProduct.getStock());
Assertions.assertEquals(5, finalProduct.getStock());
}


✅ 첫 번째 조회: 재고 = 20
📸 스냅샷 생성: 이 시점의 데이터를 기억!

🔧 Thread B: 실제 DB의 재고를 5로 변경하고 커밋!

✅ 두 번째 조회: 재고 = 20 (여전히 20!)
🎉 Repeatable Read 성공! 같은 트랜잭션 내에서 값이 유지됨!

💡 중요:
🕐 Thread A 시작
↓
REPEATABLE_READ 트랜잭션 시작 (Tx-A)
└─ 📸 이 시점의 데이터베이스 스냅샷 생성!
(스냅샷에는 stock = 20)
↓
첫 번째 SELECT 실행
└─ 스냅샷 참조 → stock = 20 반환
└─ "First Read: Stock = 20" 출력
↓
4초 대기 💤
🕑 Thread B 시작 (1초 후)
↓
새로운 트랜잭션 시작 (Tx-B)
↓
UPDATE stock = 5 실행
↓
COMMIT 완료 ✅
└─ 실제 DB의 값은 이제 5
└─ 하지만 Tx-A의 스냅샷에는 영향 없음!
🕔 Thread A 재개 (4초 후)
↓
두 번째 SELECT 실행
└─ 실제 DB를 보지 않음
└─ 자신의 스냅샷 참조 → stock = 20 반환
└─ "Second Read: Stock = 20" 출력
└─ "Repeatable Read 보장!" 출력
↓
COMMIT 완료
💡 핵심: REPEATABLE_READ는 트랜잭션 시작 시점의 스냅샷을 계속 사용한다!
[트랜잭션 시작]
↓
[📸 스냅샷 생성]
stock = 20
↓
[1차 조회] → 스냅샷 보기 → 20 반환
↓
[다른 트랜잭션이 5로 변경]
↓
실제 DB: stock = 5
스냅샷: stock = 20 (변경 안 됨!)
↓
[2차 조회] → 스냅샷 보기 → 20 반환 ✅
READ_COMMITTED:
❌ 쿼리마다 최신 커밋 데이터 읽음
❌ 같은 트랜잭션 내에서 값이 바뀔 수 있음
❌ 통계/집계 작업에서 문제 발생
REPEATABLE_READ:
✅ 트랜잭션 시작 시점의 스냅샷 사용
✅ 같은 트랜잭션 내에서 값 유지
✅ 데이터 일관성 보장
✅ MySQL의 기본값 ⭐
| 격리 수준 | Dirty Read | Non-Repeatable Read | 성능 | 사용 사례 |
|---|---|---|---|---|
| READ_COMMITTED | ✅ 방지 | ❌ 발생 | ⭐⭐⭐⭐ | 일반 웹 서비스 |
| REPEATABLE_READ | ✅ 방지 | ✅ 방지 | ⭐⭐⭐ | 통계/집계 작업 |
1. 격리 수준 선택
// 일반 조회: READ_COMMITTED
@Transactional(isolation = Isolation.READ_COMMITTED)
// 통계/집계: REPEATABLE_READ
@Transactional(isolation = Isolation.REPEATABLE_READ)
2. 스냅샷의 힘
3. 사용 사례
직접 테스트해보니 스냅샷이라는 개념이 명확히 이해됐다. REPEATABLE_READ가 트랜잭션 시작 시점의 데이터를 계속 유지한다는 게 신기했다.
특히 인상 깊었던 건:
이게 바로 MVCC의 마법이다. 성능과 일관성을 동시에 잡는 기술!
긴 글 읽어주셔서 감사합니다! 😊
궁금한 점이나 경험 있으시면 댓글로 공유해주세요! 💬
다음 편에서 만나요! 👋