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

최용혁·2025년 12월 10일

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

들어가며 🚀

이전 글에서 Non-Repeatable Read를 해결했다. 이번에는 REPEATABLE_READ 격리 수준마저도 막지 못할 수 있는 Phantom Read 문제를 Spring Boot에서 재현하고 해결해보려고 한다.


사전 지식: 데이터베이스 잠금의 종류 🔒

잠금(Lock)이란?

데이터베이스는 동시성을 제어하기 위해 다양한 종류의 잠금을 사용한다.

1. 레코드 잠금 (Record Lock)

특정 하나의 행(Row)에만 거는 잠금

사용 시점: UPDATE, DELETE
역할: Non-Repeatable Read 방지

2. 갭 잠금 (Gap Lock)

데이터 행과 행 사이의 '간격'에 거는 잠금

역할: 간격에 새로운 데이터 INSERT 방지

3. 넥스트 키 잠금 (Next-Key Lock)

레코드 잠금 + 갭 잠금

특징: 특정 행과 그 이전 간격까지 함께 잠금
사용: MySQL InnoDB의 REPEATABLE_READ

4. 범위 잠금 (Range Lock / Predicate Lock)

WHERE 절의 조건(범위) 전체를 잠금

특징: 가장 강력한 잠금
사용: SERIALIZABLE 격리 수준

💡 핵심: 잠금의 범위가 넓을수록 안전하지만 성능은 떨어진다!


Part 1. Repository 및 서비스 코드 작성 🛠️

ProductRepository에 범위 조회 메서드 추가

public interface ProductRepository extends JpaRepository<Product, Long> {
    
    /**
     * 재고가 특정 값보다 큰 상품 조회
     */
    List<Product> findAllByStockGreaterThan(int stock);
}


Phantom Read의 특징

하나의 트랜잭션 내에서:
1차 조회: stock > 5인 상품 2개
   ↓
다른 트랜잭션이 새로운 상품 추가 (stock = 20)
   ↓
2차 조회: stock > 5인 상품 3개 (?)

→ 없던 데이터(유령)가 나타남!

1. Phantom Read 재현 메서드 (REPEATABLE_READ)

@Service
@RequiredArgsConstructor
public class ProductIsolationService {

    private final ProductRepository productRepository;

    /**
     * Phantom Read를 재현하는 메서드
     * (격리 수준: REPEATABLE_READ)
     */
    @Transactional(isolation = Isolation.REPEATABLE_READ)
    public void demonstratePhantomRead() {
        // 1. 첫 번째 범위 조회
        List<Product> products1 = productRepository.findAllByStockGreaterThan(5);
        System.out.println("Thread A - First Read: " + products1.size() + " products found.");

        // 2. 다른 트랜잭션이 데이터를 INSERT할 시간을 줌
        try {
            Thread.sleep(4000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }

        // 3. 동일 트랜잭션 내에서 다시 범위 조회
        List<Product> products2 = productRepository.findAllByStockGreaterThan(5);
        System.out.println("Thread A - Second Read: " + products2.size() + " products found.");

        if (products1.size() != products2.size()) {
            System.out.println(">>>> Phantom Read가 발생했습니다!");
        }
    }
}

코드 설명 💡

부분설명
findAllByStockGreaterThan(5)stock > 5 조건의 범위 조회
첫 번째 조회조건에 맞는 상품 개수 확인
Thread.sleep(4000)다른 트랜잭션이 INSERT할 시간 제공
두 번째 조회같은 조건으로 다시 조회
개수 비교유령 데이터 출현 여부 확인

2. Phantom Read 방지 메서드 (SERIALIZABLE)

/**
 * Phantom Read를 방지하는 메서드
 * (격리 수준: SERIALIZABLE)
 */
@Transactional(isolation = Isolation.SERIALIZABLE)
public void demonstrateSerializable() {
    // 위와 로직은 동일하지만, 격리 수준만 다름
    List<Product> products1 = productRepository.findAllByStockGreaterThan(5);
    System.out.println("Thread A - First Read: " + products1.size() + " products found.");

    try {
        Thread.sleep(4000);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }

    List<Product> products2 = productRepository.findAllByStockGreaterThan(5);
    System.out.println("Thread A - Second Read: " + products2.size() + " products found.");
    
    if (products1.size() == products2.size()) {
        System.out.println(">>>> Phantom Read가 방지되었습니다!");
    }
}


3. 신상품 추가 메서드

/**
 * 조건에 맞는 신상품을 추가하고 즉시 커밋하는 메서드
 */
@Transactional
public void createProduct(String name, Integer stock){
	log.info("Thread B: 재고("+stock+")를 가진 신상품 추가 시도");

    Category category = categoryRepository.findById(1L)
    	.orElseThrow(()->new DomainException(DomainExceptionCode.NOT_FOUND_CATEGORY));

	productRepository.save(Product.builder()
    	.category(category)
        .name(name)
        .description("")
        .stock(stock)
        .price(BigDecimal.valueOf(100))
        .build());
    log.info("Thread B: 신상품 추가 및 커밋 완료");
}


Part 2. 👻 Phantom Read 문제 재현


테스트 코드 작성

@Test
void REPEATABLE_READ에서는_Phantom_Read가_발생할_수_있다()throws InterruptedException{
	// given
    Thread threadA = new Thread(()->{
    	productService.demonstratePhantomRead();
	});

    Thread threadB = new Thread(()->{
    	try{
        	Thread.sleep(1000);
            productService.createProduct("유령 상품", 100);
		}catch(Exception e){
        	Thread.currentThread().interrupt();
		}
	});

    // when
    threadA.start();
    threadB.start();

    threadA.join();
    threadB.join();

    // then
    }


테스트 실행 및 결과 확인 🔍

1️⃣ Thread A - 첫 번째 범위 조회

첫 번째 조회: 2개 상품 발견 (기존상품A, 기존상품B)


2️⃣ Thread B - 신상품 추가 및 커밋

Thread B: stock=20인 "유령신상품" 추가 완료!


3️⃣ Thread A - 두 번째 범위 조회 (Phantom Read 발생!)

💥 두 번째 조회: 3개 상품 발견 (유령신상품 포함!)

👻 Phantom Read 발생! 없던 데이터가 나타났다!


4️⃣ 최종 확인


REPEATABLE_READ의 내부 동작 🔍

실행 흐름

🕐 Thread A 시작
   ↓
   REPEATABLE_READ 트랜잭션 시작 (Tx-A)
   ↓
   SELECT stock > 5 실행
   └─ 2개 행 발견 (id=1, id=2)
   └─ 이 2개 행에 레코드 잠금 🔒
   └─ 주변 간격에 갭 잠금 🔒
   └─ ⚠️ 하지만 범위의 '끝' 너머는 잠금 안 됨!
   └─ "First Read: 2 products found" 출력
   ↓
   4초 대기 💤

🕑 Thread B 시작 (1초 후)
   ↓
   새로운 트랜잭션 시작 (Tx-B)
   ↓
   INSERT id=4, stock=20 시도
   └─ ✅ 잠금 범위 밖에 삽입 가능!
   └─ INSERT 성공
   ↓
   COMMIT 완료 ✅
   └─ 이제 DB에는 3개 상품 존재

🕔 Thread A 재개 (4초 후)
   ↓
   SELECT stock > 5 다시 실행
   └─ 테이블 전체 스캔
   └─ ❌ 새로 추가된 id=4도 발견됨!
   └─ "Second Read: 3 products found" 출력
   └─ "Phantom Read 발생!" 출력
   ↓
   COMMIT 완료

💡 핵심: REPEATABLE_READ는 기존 행은 보호하지만, 새로운 행의 추가는 막지 못한다!


REPEATABLE_READ의 잠금 범위

[기존 데이터]
id=1, stock=10  ← 레코드 잠금 🔒
    ↕️ 갭 잠금 🔒
id=2, stock=15  ← 레코드 잠금 🔒
    ↕️ 갭 잠금 🔒
id=3, stock=3   ← 조건 불일치 (stock < 5)
    ↕️ ⚠️ 여기는 잠금 안 됨!
    
[새로 추가 가능한 공간]
id=4, stock=20  ← ✅ 여기 삽입 가능!

Phantom Read의 문제점

문제 상황:
1. 재고 집계 작업 중
2. 1차 집계: stock > 5 상품 2개 → 총 재고 25개
3. 중간에 신상품 추가 (stock = 20)
4. 2차 집계: stock > 5 상품 3개 → 총 재고 45개 (???)

→ 집계 결과 불일치!
→ 배치 작업 오류!

Part 3. ✅ Phantom Read 문제 해결

테스트 코드 작성

@Test
@DisplayName("SERIALIZABLE에서는 Phantom Read가 방지된다")
void testPhantomReadPrevented() throws InterruptedException {
    // Thread A: SERIALIZABLE로 특정 범위를 두 번 읽는 긴 트랜잭션
    Thread threadA = new Thread(() -> {
        log.info("=== Thread A 시작 ===");
        productService.demonstrateSerializable();
        log.info("=== Thread A 종료 ===");
    });

    // Thread B: 중간에 데이터를 추가하려고 시도
    Thread threadB = new Thread(() -> {
        try {
            log.info("=== Thread B 시작 ===");
            Thread.sleep(1000);
            // 이 메서드는 Thread A가 끝날 때까지 대기(block) 상태에 빠짐
            productService.insertNewProduct("유령신상품", 20);
            log.info("=== Thread B 종료 ===");
        } catch (Exception e) {
            log.error("Thread B: 락 때문에 작업 실패! {}", e.getMessage());
        }
    });

    // When: 두 스레드 동시 실행
    threadA.start();
    threadB.start();
    threadA.join();
    threadB.join();
    
    // 최종 확인
    List<Product> finalProducts = productRepository.findAllByStockGreaterThan(5);
    log.info("=== 최종 상품 개수 = {} ===", finalProducts.size());
}


테스트 실행 및 결과 확인 🔍

1️⃣ Thread B - 신상품 추가 시도 (대기 상태!)


Thread B: 범위 잠금에 막혀서 대기 중...


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

두 번째 조회: 여전히 2개 상품!
🎉 Phantom Read 방지 성공!


3️⃣ Thread A 커밋 후 Thread B 실행

Thread A 종료 후: Thread B의 INSERT가 드디어 실행됨!


4️⃣ 최종 확인

💡 중요:

  • Thread A는 2개를 계속 봤지만
  • Thread A 종료 후 Thread B가 추가
  • 최종적으로는 3개 저장됨

SERIALIZABLE의 내부 동작 🔍

실행 흐름

🕐 Thread A 시작
   ↓
   SERIALIZABLE 트랜잭션 시작 (Tx-A)
   ↓
   SELECT stock > 5 실행
   └─ 2개 행 발견 (id=1, id=2)
   └─ 🔒 이 2개 행에 레코드 잠금
   └─ 🔒🔒🔒 stock > 5 범위 전체에 범위 잠금!
   └─ 미래에 삽입될 수 있는 공간까지 모두 잠금!
   └─ "First Read: 2 products found" 출력
   ↓
   4초 대기 💤

🕑 Thread B 시작 (1초 후)
   ↓
   새로운 트랜잭션 시작 (Tx-B)
   ↓
   INSERT id=4, stock=20 시도
   └─ ❌ stock=20은 "stock > 5" 범위 안!
   └─ ❌ 범위 잠금에 막힘!
   └─ ⏳ 잠금 대기 상태 진입
   └─ "신상품 추가 시도" 로그만 출력

🕔 Thread A 재개 (4초 후)
   ↓
   SELECT stock > 5 다시 실행
   └─ 잠금 덕분에 변화 없음
   └─ "Second Read: 2 products found" 출력
   └─ "Phantom Read 방지!" 출력
   ↓
   COMMIT 완료 ✅
   └─ 범위 잠금 해제!

🕕 Thread B 재개
   ↓
   대기하던 INSERT 실행
   ↓
   COMMIT 완료 ✅

💡 핵심: SERIALIZABLE은 범위 전체를 잠가서 새로운 데이터 진입을 원천 차단!


SERIALIZABLE의 범위 잠금

[범위 잠금 설정]
stock > 5 조건
    ↓
[━━━━━━━━━━━━━━━━━]
🔒 전체 범위 잠금 🔒
[━━━━━━━━━━━━━━━━━]
    ↓
현재 존재하는 행:
- id=1, stock=10 🔒
- id=2, stock=15 🔒

미래에 삽입될 수 있는 공간:
- stock=20 🔒 (삽입 불가!)
- stock=100 🔒 (삽입 불가!)
- stock=1000 🔒 (삽입 불가!)

모든 stock > 5 공간 차단!

정리 📝

Phantom Read 문제 ❌

REPEATABLE_READ:
❌ 기존 행의 값은 보호
❌ 새로운 행의 추가는 막지 못함
❌ 범위 조회 시 개수가 달라질 수 있음
❌ 집계/통계 작업에서 문제 발생

Phantom Read 해결 ✅

SERIALIZABLE:
✅ 범위 전체를 잠금 (Range Lock)
✅ 새로운 행의 추가도 완벽 차단
✅ 범위 조회 결과 일관성 보장
✅ 완벽한 데이터 정합성
⚠️ 하지만 동시성 크게 저하

격리 수준 비교표

격리 수준Dirty ReadNon-Repeatable ReadPhantom Read성능사용 사례
READ_COMMITTED✅ 방지❌ 발생❌ 발생⭐⭐⭐⭐일반 웹 서비스
REPEATABLE_READ✅ 방지✅ 방지❌ 발생⭐⭐⭐통계/리포트
SERIALIZABLE✅ 방지✅ 방지✅ 방지금융/정산

잠금 방식 비교

REPEATABLE_READ:
"내가 보고 있는 이 데이터들은 바꾸지 마!"
→ 레코드 잠금 + 갭 잠금
→ 하지만 새로운 데이터 끼어들기 가능

SERIALIZABLE:
"내가 보고 있는 이 조건(범위)에 해당하는 곳은 아무도 건드리지 마!"
→ 범위 잠금 (Range Lock)
→ 새로운 데이터 진입 완전 차단

배운 점 💡

1. 격리 수준 선택 기준

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

// 통계/리포트: REPEATABLE_READ
@Transactional(isolation = Isolation.REPEATABLE_READ)

// 금융/정산: SERIALIZABLE (신중히 사용!)
@Transactional(isolation = Isolation.SERIALIZABLE)

2. 범위 잠금의 강력함

  • SELECT 조건의 범위 전체를 잠금
  • 현재 데이터뿐만 아니라 미래 데이터까지 차단
  • 완벽한 일관성, 하지만 동시성 희생

3. 성능 vs 정합성

  • SERIALIZABLE은 "마지막 카드"
  • 대부분은 REPEATABLE_READ로 충분
  • 꼭 필요한 경우에만 사용

느낀 점 🤔

직접 테스트해보니 범위 잠금의 개념이 명확히 이해됐다. SERIALIZABLE이 트랜잭션을 순서대로 실행하는 것처럼 만든다는 게 인상 깊었다.

특히 놀라웠던 점:

  • Thread B가 INSERT를 시도했지만
  • 범위 잠금에 막혀서 대기 상태로 진입
  • Thread A가 끝나야 비로소 실행됨

이게 바로 "Serializable"이라는 이름의 의미다. 마치 순서대로 실행하는 것처럼!

하지만 실무에서는:

  • SERIALIZABLE은 정말 필요할 때만
  • 대부분 REPEATABLE_READ로 충분
  • 애플리케이션 레벨의 락도 고려

성능과 정합성의 균형이 가장 중요하다!


실무 적용 가이드 💼

언제 SERIALIZABLE을 사용할까?

✅ 사용해야 하는 경우:
- 자금 정산 배치
- 재고 집계 작업
- 회계 마감 처리
- 단 하나의 오차도 허용 안 되는 작업

❌ 사용하면 안 되는 경우:
- 일반 웹 요청 처리
- 실시간 API
- 빈번한 조회 작업
- 동시 접속이 많은 서비스

성능 최적화 팁

1. 트랜잭션 범위 최소화
   - 필요한 작업만 트랜잭션 안에
   - 긴 작업은 트랜잭션 밖으로

2. 조건 범위 최소화
   - WHERE 조건을 구체적으로
   - 인덱스 활용

3. 배치 시간대 조정
   - 트래픽 적은 시간에 실행
   - 새벽 시간 활용

4. 대안 고려
   - 낙관적 락 (Optimistic Lock)
   - 비관적 락 (Pessimistic Lock)
   - 애플리케이션 레벨 락

다음 단계 🎯

  • 낙관적 락 vs 비관적 락 비교 🔐
  • 분산 락 (Redis) 구현 📦
  • 실제 프로젝트 적용 사례 🚀
  • 성능 테스트 및 벤치마크 📊

시리즈 전체 정리 🎓

트랜잭션 격리 수준 시리즈

✅ 1편: 이론 정리
✅ 2편: SQL로 Dirty Read 재현
✅ 3편: SQL로 Non-Repeatable Read 재현
✅ 4편: SQL로 Phantom Read 재현
✅ 5편: Spring에서 Dirty Read 해결
✅ 6편: Spring에서 Non-Repeatable Read 해결
✅ 7편: Spring에서 Phantom Read 해결 (이번 글)

핵심 요약

문제발생 격리 수준해결 격리 수준원리
Dirty ReadREAD_UNCOMMITTEDREAD_COMMITTEDUndo Log 참조
Non-Repeatable ReadREAD_COMMITTEDREPEATABLE_READ스냅샷 유지
Phantom ReadREPEATABLE_READSERIALIZABLE범위 잠금

참고 자료 📚


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

드디어 트랜잭션 격리 수준 시리즈가 완결됐습니다! 🎉

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

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

profile
안녕하세용

0개의 댓글