Spring Boot/트랜잭션

Isolation Level

재윤 2025. 8. 28. 15:03
반응형

격리 수준은 동시에 여러 트랜잭션이 실행될 때, 서로의 데이터를 어떻게 볼 수 있는지를 결정

  • 여러 트랜잭션이 동시에 같은 데이터를 읽거나 쓸 때 충돌이 생긴다 → 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