이전 글에서 Non-Repeatable Read를 해결했다. 이번에는 REPEATABLE_READ 격리 수준마저도 막지 못할 수 있는 Phantom Read 문제를 Spring Boot에서 재현하고 해결해보려고 한다.
데이터베이스는 동시성을 제어하기 위해 다양한 종류의 잠금을 사용한다.
특정 하나의 행(Row)에만 거는 잠금
사용 시점: UPDATE, DELETE
역할: Non-Repeatable Read 방지
데이터 행과 행 사이의 '간격'에 거는 잠금
역할: 간격에 새로운 데이터 INSERT 방지
레코드 잠금 + 갭 잠금
특징: 특정 행과 그 이전 간격까지 함께 잠금
사용: MySQL InnoDB의 REPEATABLE_READ
WHERE 절의 조건(범위) 전체를 잠금
특징: 가장 강력한 잠금
사용: SERIALIZABLE 격리 수준
💡 핵심: 잠금의 범위가 넓을수록 안전하지만 성능은 떨어진다!
public interface ProductRepository extends JpaRepository<Product, Long> {
/**
* 재고가 특정 값보다 큰 상품 조회
*/
List<Product> findAllByStockGreaterThan(int stock);
}

하나의 트랜잭션 내에서:
1차 조회: stock > 5인 상품 2개
↓
다른 트랜잭션이 새로운 상품 추가 (stock = 20)
↓
2차 조회: stock > 5인 상품 3개 (?)
→ 없던 데이터(유령)가 나타남!
@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할 시간 제공 |
| 두 번째 조회 | 같은 조건으로 다시 조회 |
| 개수 비교 | 유령 데이터 출현 여부 확인 |
/**
* 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가 방지되었습니다!");
}
}

/**
* 조건에 맞는 신상품을 추가하고 즉시 커밋하는 메서드
*/
@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: 신상품 추가 및 커밋 완료");
}

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


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


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

💥 두 번째 조회: 3개 상품 발견 (유령신상품 포함!)
👻 Phantom 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는 기존 행은 보호하지만, 새로운 행의 추가는 막지 못한다!
[기존 데이터]
id=1, stock=10 ← 레코드 잠금 🔒
↕️ 갭 잠금 🔒
id=2, stock=15 ← 레코드 잠금 🔒
↕️ 갭 잠금 🔒
id=3, stock=3 ← 조건 불일치 (stock < 5)
↕️ ⚠️ 여기는 잠금 안 됨!
[새로 추가 가능한 공간]
id=4, stock=20 ← ✅ 여기 삽입 가능!
문제 상황:
1. 재고 집계 작업 중
2. 1차 집계: stock > 5 상품 2개 → 총 재고 25개
3. 중간에 신상품 추가 (stock = 20)
4. 2차 집계: stock > 5 상품 3개 → 총 재고 45개 (???)
→ 집계 결과 불일치!
→ 배치 작업 오류!
@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());
}



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

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

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

💡 중요:
🕐 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은 범위 전체를 잠가서 새로운 데이터 진입을 원천 차단!
[범위 잠금 설정]
stock > 5 조건
↓
[━━━━━━━━━━━━━━━━━]
🔒 전체 범위 잠금 🔒
[━━━━━━━━━━━━━━━━━]
↓
현재 존재하는 행:
- id=1, stock=10 🔒
- id=2, stock=15 🔒
미래에 삽입될 수 있는 공간:
- stock=20 🔒 (삽입 불가!)
- stock=100 🔒 (삽입 불가!)
- stock=1000 🔒 (삽입 불가!)
모든 stock > 5 공간 차단!
REPEATABLE_READ:
❌ 기존 행의 값은 보호
❌ 새로운 행의 추가는 막지 못함
❌ 범위 조회 시 개수가 달라질 수 있음
❌ 집계/통계 작업에서 문제 발생
SERIALIZABLE:
✅ 범위 전체를 잠금 (Range Lock)
✅ 새로운 행의 추가도 완벽 차단
✅ 범위 조회 결과 일관성 보장
✅ 완벽한 데이터 정합성
⚠️ 하지만 동시성 크게 저하
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom 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. 범위 잠금의 강력함
3. 성능 vs 정합성
직접 테스트해보니 범위 잠금의 개념이 명확히 이해됐다. SERIALIZABLE이 트랜잭션을 순서대로 실행하는 것처럼 만든다는 게 인상 깊었다.
특히 놀라웠던 점:
이게 바로 "Serializable"이라는 이름의 의미다. 마치 순서대로 실행하는 것처럼!
하지만 실무에서는:
성능과 정합성의 균형이 가장 중요하다!
✅ 사용해야 하는 경우:
- 자금 정산 배치
- 재고 집계 작업
- 회계 마감 처리
- 단 하나의 오차도 허용 안 되는 작업
❌ 사용하면 안 되는 경우:
- 일반 웹 요청 처리
- 실시간 API
- 빈번한 조회 작업
- 동시 접속이 많은 서비스
1. 트랜잭션 범위 최소화
- 필요한 작업만 트랜잭션 안에
- 긴 작업은 트랜잭션 밖으로
2. 조건 범위 최소화
- WHERE 조건을 구체적으로
- 인덱스 활용
3. 배치 시간대 조정
- 트래픽 적은 시간에 실행
- 새벽 시간 활용
4. 대안 고려
- 낙관적 락 (Optimistic Lock)
- 비관적 락 (Pessimistic Lock)
- 애플리케이션 레벨 락
✅ 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 Read | READ_UNCOMMITTED | READ_COMMITTED | Undo Log 참조 |
| Non-Repeatable Read | READ_COMMITTED | REPEATABLE_READ | 스냅샷 유지 |
| Phantom Read | REPEATABLE_READ | SERIALIZABLE | 범위 잠금 |
긴 글 읽어주셔서 감사합니다! 😊
드디어 트랜잭션 격리 수준 시리즈가 완결됐습니다! 🎉
궁금한 점이나 경험 있으시면 댓글로 공유해주세요! 💬
다음 시리즈에서 만나요! 👋