Spring Boot에서 Non-Repeatable Read 재현하고 해결하기

최용혁·2025년 12월 10일
post-thumbnail

Spring Boot에서 Non-Repeatable Read 재현하고 해결하기 🔄

들어가며 🚀

이전 글에서 Dirty Read를 해결했다. 이번에는 한 트랜잭션 내에서 같은 데이터를 두 번 읽었는데 값이 다른 Non-Repeatable Read 문제를 Spring Boot에서 재현하고 해결해보려고 한다.


사전 지식: Spring AOP와 트랜잭션 프록시 🎯

@Transactional의 동작 원리

우리가 @Transactional이 붙은 메서드를 호출할 때, 실제로는 우리가 만든 Service 객체를 직접 호출하는 게 아니다. Spring은 AOP(관점 지향 프로그래밍) 기술로 Service를 감싸는 프록시(Proxy) 객체를 만든다.

호출 흐름

테스트 코드
    ↓
ProductService 프록시 객체
    ↓
실제 ProductService 객체

프록시가 하는 일

1. 메서드 호출 전 (Before):
   - 트랜잭션 시작
   - 데이터베이스 커넥션 가져오기
   - setAutoCommit(false) 설정
   - 격리 수준 설정 (SET TRANSACTION ISOLATION LEVEL ...)

2. 메서드 실행:
   - 실제 Service 메서드 호출

3. 메서드 호출 후 (After):
   - 성공 시: COMMIT
   - 예외 발생 시: ROLLBACK

💡 핵심: 프록시가 자동으로 트랜잭션을 관리해준다!


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

Non-Repeatable Read의 특징

하나의 트랜잭션 내에서:
1차 조회: 재고 20
   ↓
다른 트랜잭션이 데이터 변경 + 커밋
   ↓
2차 조회: 재고 5 (?)

→ 같은 트랜잭션인데 값이 바뀜!

1. Non-Repeatable Read 재현 메서드 (READ_COMMITTED)

@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)다른 트랜잭션이 데이터를 변경할 시간 제공
두 번째 조회같은 트랜잭션 내에서 다시 조회

2. Non-Repeatable Read 방지 메서드 (REPEATABLE_READ)

/**
 * 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가 보장되었습니다!");
    }
}


3. 재고 업데이트 메서드

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


Part 2. 😵 Non-Repeatable 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_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());
    }
}


테스트 실행 및 결과 확인 🔍

1️⃣ Thread A - 첫 번째 조회

첫 번째 조회: 재고 = 20


2️⃣ Thread B - 데이터 변경 및 커밋

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


3️⃣ Thread A - 두 번째 조회 (Non-Repeatable Read 발생!)

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

🚨 Non-Repeatable Read 발생! 같은 트랜잭션 내에서 값이 20 → 5로 변경됨!


READ_COMMITTED의 내부 동작 🔍

실행 흐름

🕐 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는 쿼리 실행 시점의 최신 커밋 데이터를 읽는다!


READ_COMMITTED의 문제점

문제 상황:
1. 통계 계산 중 (평균 = (1차 조회 + 2차 조회) / 2)
2. 1차 조회: 20
3. 다른 트랜잭션이 5로 변경
4. 2차 조회: 5
5. 평균 = (20 + 5) / 2 = 12.5 (???)

→ 잘못된 통계!

Part 3. ✅ Non-Repeatable Read 문제 해결

테스트 코드 작성

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


테스트 실행 및 결과 확인 🔍

1️⃣ Thread A - 첫 번째 조회

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


2️⃣ Thread B - 데이터 변경 및 커밋

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


3️⃣ Thread A - 두 번째 조회 (값 유지!)

두 번째 조회: 재고 = 20 (여전히 20!)

🎉 Repeatable Read 성공! 같은 트랜잭션 내에서 값이 유지됨!


4️⃣ 최종 확인

💡 중요:

  • Thread A는 스냅샷을 보고 20을 읽었지만
  • 실제 DB에는 5가 저장되어 있음

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는 트랜잭션 시작 시점의 스냅샷을 계속 사용한다!


REPEATABLE_READ 스냅샷 개념도

[트랜잭션 시작]
     ↓
[📸 스냅샷 생성]
stock = 20
     ↓
[1차 조회] → 스냅샷 보기 → 20 반환
     ↓
[다른 트랜잭션이 5로 변경]
     ↓
실제 DB: stock = 5
스냅샷: stock = 20 (변경 안 됨!)
     ↓
[2차 조회] → 스냅샷 보기 → 20 반환 ✅

정리 📝

Non-Repeatable Read 문제 ❌

READ_COMMITTED:
❌ 쿼리마다 최신 커밋 데이터 읽음
❌ 같은 트랜잭션 내에서 값이 바뀔 수 있음
❌ 통계/집계 작업에서 문제 발생

Non-Repeatable Read 해결 ✅

REPEATABLE_READ:
✅ 트랜잭션 시작 시점의 스냅샷 사용
✅ 같은 트랜잭션 내에서 값 유지
✅ 데이터 일관성 보장
✅ MySQL의 기본값 ⭐

비교표

격리 수준Dirty ReadNon-Repeatable Read성능사용 사례
READ_COMMITTED✅ 방지❌ 발생⭐⭐⭐⭐일반 웹 서비스
REPEATABLE_READ✅ 방지✅ 방지⭐⭐⭐통계/집계 작업

배운 점 💡

1. 격리 수준 선택

// 일반 조회: READ_COMMITTED
@Transactional(isolation = Isolation.READ_COMMITTED)

// 통계/집계: REPEATABLE_READ
@Transactional(isolation = Isolation.REPEATABLE_READ)

2. 스냅샷의 힘

  • 트랜잭션 시작 시점의 데이터 보존
  • 다른 트랜잭션의 변경에 영향 받지 않음
  • MVCC로 구현

3. 사용 사례

  • READ_COMMITTED: 일반 CRUD 작업
  • REPEATABLE_READ: 보고서, 통계, 배치 작업

느낀 점 🤔

직접 테스트해보니 스냅샷이라는 개념이 명확히 이해됐다. REPEATABLE_READ가 트랜잭션 시작 시점의 데이터를 계속 유지한다는 게 신기했다.

특히 인상 깊었던 건:

  • Thread A는 20을 읽었지만
  • 실제 DB에는 5가 저장되어 있음
  • 둘 다 맞는 데이터!

이게 바로 MVCC의 마법이다. 성능과 일관성을 동시에 잡는 기술!


다음 단계 🎯

  • Phantom Read 재현 및 해결 👻
  • 격리 수준별 성능 비교 📊
  • 실제 프로젝트 적용 사례 🚀

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

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

다음 편에서 만나요! 👋

profile
안녕하세용

0개의 댓글