반응형
격리 수준은 동시에 여러 트랜잭션이 실행될 때, 서로의 데이터를 어떻게 볼 수 있는지를 결정
- 여러 트랜잭션이 동시에 같은 데이터를 읽거나 쓸 때 충돌이 생긴다 → DBMS는 이를 해결하기 위해 4단계의 격리 수준을 제공
- 격리 수준이 높아질수록 데이터 정합성 ↑, 동시 처리 성능 ↓
1. READ UNCOMMITTED
- 아직 커밋되지 않은 데이터(Dirty Data)도 읽을 수 있음
- 발생 가능한 문제
- A 트랜잭션이 바꾼 값을 B가 읽었는데 A가 롤백하면 B가 잘못된 데이터를 본 셈
거의 안 씀 (정합성이 너무 약함)
2. READ COMMITTED (가장 많이 씀)
- 커밋된 데이터만 읽음
- 발생 가능한 문제
- Non-repeatable Read : 같은 데이터를 두 번 읽었는데, 그 사이에 다른 트랜잭션이 커밋해버리면 값이 달라짐
Oracle, SQL Server, MariaDB/MySQL(InnoDB에서 기본은 REPEATABLE_READ) 등에서 자주 기본값으로 사용
3. REPEATABLE READ
- 같은 트랜잭션 내에서 같은 데이터를 반복 조회하면 항상 같은 결과
- Non-repeatable Read 방지
- 발생 가능한 문제
- Phantom Read : Where age > 20 으로 조회했는데, 다른 트랜잭션이 새로운 row를 INSERT → 다시 조회하면 레코드 개수가 달라짐
MySQL(InnoDB)의 기본값
4. SERIALIZABLE
- 가장 강력한 격리 수준
- 트랜잭션을 직렬(순차) 실행한 것처럼 보장
- Phantom Read도 방지
- 단 동시성 ↓ (락을 많이 잡으므로 성능이 떨어짐)
격리 수준 방지하는 문제 여전히 발생 가능 성능
격리 수준 | 방지하는 문제 | 여전히 발생 가능 | |
READ UNCOMMITTED | 없음 | Dirty Read, Non-repeatable Read, Phantom Read | 가장 빠름 |
READ COMMITTED | Dirty Read | Non-repeatable Read, Phantom Read | 보통 |
REPEATABLE READ | Dirty Read, Non-repeatable Read | Phantom Read | 느림 |
SERIALIZABLE | Dirty Read, Non-repeatable Read, Phantom Read | 없음 | 가장 느림 |
스프링에서 사용 방법
@Transactional(isolation = Isolation.READ_COMMITTED)
public void transfer(...) { ... }
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void checkBalance(...) { ... }
예제 코드
service
package com.springboot.transaction.service;
import com.springboot.transaction.entity.Account;
import com.springboot.transaction.repository.AccountRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import jakarta.persistence.EntityManager;
import java.math.BigDecimal;
@Service
@RequiredArgsConstructor
public class IsolationDemoService {
@Autowired
private AccountRepository accountRepository;
@Autowired
private EntityManager em;
private static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException ignored) {}
}
// ❶ READ COMMITTED: 커밋된 값이면 두 번째 읽기에서 보임 (Non-repeatable read 가능)
@Transactional(isolation = Isolation.READ_COMMITTED, readOnly = true)
public BalancePair readTwice_RC(String owner) {
BigDecimal first = accountRepository.findByOwner(owner).orElseThrow().getBalance();
sleep(3000); // 이 사이에 다른 트랜잭션이 값 변경/커밋
em.clear(); // 1차 캐시 비워서 DB로 다시 조회
BigDecimal second = accountRepository.findByOwner(owner).orElseThrow().getBalance();
return new BalancePair(first, second);
}
// ❷ REPEATABLE READ: 같은 트랜잭션 내에서는 같은 스냅샷 유지 → 두 번째도 같은 값
@Transactional(isolation = Isolation.REPEATABLE_READ, readOnly = true)
public BalancePair readTwice_RR(String owner) {
BigDecimal first = accountRepository.findByOwner(owner).orElseThrow().getBalance();
sleep(3000);
em.clear();
BigDecimal second = accountRepository.findByOwner(owner).orElseThrow().getBalance();
return new BalancePair(first, second);
}
// ❸ 값 변경용(다른 트랜잭션에서 호출)
@Transactional
public void deposit(String owner, BigDecimal amount) {
Account a = accountRepository.findByOwner(owner).orElseThrow();
a.deposit(amount); // 커밋 시 UPDATE
}
public record BalancePair(BigDecimal first, BigDecimal second) {}
}
controller
package com.springboot.transaction.controller;
import com.springboot.transaction.service.IsolationDemoService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.Map;
@RestController
@RequiredArgsConstructor
@RequestMapping("/iso")
public class IsolationDemoController {
@Autowired
private IsolationDemoService service;
@GetMapping("/rc")
public Map<String, Object> rc(@RequestParam String owner) {
var p = service.readTwice_RC(owner);
return Map.of("first", p.first(), "second", p.second(), "isolation", "READ_COMMITTED");
}
@GetMapping("/rr")
public Map<String, Object> rr(@RequestParam String owner) {
var p = service.readTwice_RR(owner);
return Map.of("first", p.first(), "second", p.second(), "isolation", "REPEATABLE_READ");
}
@PostMapping("/deposit")
public Map<String, Object> deposit(@RequestParam String owner, @RequestParam BigDecimal amount) {
service.deposit(owner, amount);
return Map.of("ok", true);
}
}
alice 잔액이 100000으로 초기화 되어있다고 가정
READ COMMITTED
터미널 A(읽기 트랜잭션 시작 후 3초 대기)
<http://localhost:8080/iso/rc?owner=alice>
즉시 터미널 B(다른 트랜잭션 값 변경 후 커밋)
다시 터미널 A 응답 확인:
그냥 조회만 하면 다음과 같음
{
"second": 100000.00,
"isolation": "READ_COMMITTED",
"first": 100000.00
}
→ READ_COMMITTED는 커밋된 변경을 바로 보므로 Non-repeatable read 발생.
결과 값
{
"second": 101000.00,
"isolation": "READ_COMMITTED",
"first": 100000.00
}
REPEATABLE READ
터미널 A
<http://localhost:8080/iso/rr?owner=alice>
{
"second": 101000.00,
"isolation": "REPEATABLE_READ",
"first": 101000.00
}
터미널 B 이것도 중간 수행
<http://localhost:8080/iso/deposit?owner=alice&amount=1000>
결과
{
"second": 101000.00,
"isolation": "REPEATABLE_READ",
"first": 101000.00
}
단 DB에는 업데이트가 되어있다.
- B 트랜잭션의 UPDATE 자체는 DB에 반영되어 있다.
- 단지 A 트랜잭션이 자기 눈으로 못 보는 것뿐
- A가 커밋하면 스냅샷이 사라지고, 새 트랜잭션을 시작하면 업데이트된 값을 볼 수 있다
em.clear()를 넣은 이유: JPA 1차 캐시가 같은 엔티티를 재사용하지 않게 해서 DB를 다시 조회시키기 위함
반응형
'Spring Boot > 트랜잭션' 카테고리의 다른 글
Propagation (1) | 2025.08.28 |
---|---|
트랜잭션 범위 (Class, Method) (3) | 2025.08.28 |
정의(ACID) (3) | 2025.08.28 |