Computer Science/데이터베이스

MySQL 트랜잭션 격리 수준

나는 유찌 2025. 3. 24. 20:21

1. 트랜잭션이란?

  • 데이터베이스에서 하나 이상의 SQL 작업을 묶어서 하나의 단위로 처리하는 것
  • 모든 작업이 성공하면 COMMIT, 하나라도 실패하면 ROLLBACK을 수행하여 변경 사항 취소
  • 데이터의 정합성을 보장하는 것이 목적

 

✔️ 트랜잭션이 필요한 이유 ✔️

  • 데이터 정합성 보장
    • 트랜잭션이 없으면 일부 쿼리만 성공했을 때 데이터 불일치 발생
  • 장애 발생 시 안전한 복구
    • ROLLBACK을 통해 안전하게 복구 가능
  • 동시성 제어
    • 여러 사용자가 동시에 같은 데이터를 수정할 때 충돌 방지
  • 원자적 실행 보장
    • 트랜잭션이 적용된 연산은 모두 성공하거나, 모두 실패

 

2. 트랜잭션 4대 특성 ACID란?

  1. Atomicity (원자성)
    • 모든 연산이 성공하거나, 하나라도 실패하면 전체 취소
  2. Consistency (일관성)
    • 트랜잭션 실행 후에도 데이터 무결성 유지
  3. Isolation (고립성)
    • 동시에 실행되는 트랜잭션이 서로 영향을 주지 않음
  4. Durability (지속성)
    • 트랜잭션이 COMMIT 되면 데이터가 영구적으로 저장

 

3. 격리 수준이 필요한 이유는?

적절한 격리 수준을 조절하지 않고 서비스 운영 중 동시에 여러 사용자가 같은 데이터를 조회하거나 수정하는 상황이 온다면 아래와 같은 문제들이 발생할 수 있다.

  1. Dirty Read
  2. Non-Repeatable Read
  3. Phantom Read

각 문제들에 대해서 간단하게 설명해보겠다.

 

Dirty Read란?

다른 트랜잭션이 아직 커밋하지 않은 변경 데이터를 읽는 것을 말한다.

더러운 값을 읽었다고 해서 Dirty Read라고 부른다고 한다.

 

계좌 이체 중인 상황을 예시로 들어보자.

  • 트랜잭션 A (보내는 쪽)
START TRANSACTION;
 -- 100원 출금
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 아직 COMMIT 안함
  • 트랜잭션 B (읽는 쪽)
SELECT balance FROM accounts WHERE id = 1;
-- Dirty Read 발생 : 이미 출금된 걸로 보임

 

위 상황에서 만일 트랜잭션 A가 ROLLBACK을 한다면 트랜잭션 B는 존재하지 않는 값을 읽은게 된다.

 

✅ Non-Repeatable Read란?

같은 트랜잭션 안에서 같은 데이터를 두 번 읽었을 때 값이 다르게 나오는 현상을 말한다.

 

계좌 잔액을 조회해서 비교하는 로직을 예시로 들어보자.

  • 트랜잭션 A (잔액 비교 로직)
START TRANSACTION;
-- 첫 번째 읽기
SELECT balance FROM accounts WHERE id = 1; -- 결과: 1000
  • 트랜잭션 B (다른 곳에서 수정)
START TRANSACTION;
UPDATE accounts SET balance = 500 WHERE id = 1;
COMMIT;
  • 트랜잭션 A (두 번째 읽기)
-- 두 번째 읽기
SELECT balance FROM accounts WHERE id = 1; -- 결과: 500

 

트랜잭션 A가 첫 번째 읽기 후 두 번째 읽기로 가는 과정에서 트랜잭션 B가 업데이트 후 COMMIT을 하면서 두 번째 읽기의 결과값이 달라지게 되었다.

 

✅ Phantom Read란?

같은 조건으로 데이터를 두 번 조회했는데, 첫 번째에는 없던 새로운 행이 두 번째에서 나타나는 현상을 말한다.

 

나이가 30살 이상인 고객 수를 조회하는 상황을 예시로 들어보자.

  • 트랜잭션 A (데이터 조회 중)
START TRANSACTION;
-- 첫 번째 조회
SELECT COUNT(*) FROM users WHERE age >= 30; -- 결과: 5
  • 트랜잭션 B (새로운 유저 추가)
START TRANSACTION;
INSERT INTO users (name, age) VALUES ('Alice', 35);
COMMIT;
  • 트랜잭션 A (재조회)
-- 두 번째 조회 (같은 조건)
SELECT COUNT(*) FROM users WHERE age >= 30; -- 결과: 6

 

트랜잭션 A가 처음 조회하고 재조회를 하는 사이에 트랜잭션 B가 새로운 유저를 INSERT 하고 COMMIT 하면서 A가 재조회 했을 때 결과값에 차이가 생기게 되었다.

 

위와 같은 문제들은 결과적으로 데이터의 신뢰성과 정합성을 깨뜨리게 된다.

이 문제들을 해결하기 위해 트랜잭션의 격리 수준 (Isolation Level) 개념을 적용한다.

 

 

4. MySQL에서 지원하는 격리 수준 4가지

✅ READ UNCOMMITTED (읽기 미확정)

  • 가장 낮은 격리 수준 (성능 최적화, 데이터 정합성 낮음)
  • Dirty Read 발생 가능
  • Non-Repeatable Read 발생 가능
  • Phantom Read 발생 가능
-- 트랜잭션 1 (수정, 아직 COMMIT 안함)
START TRANSACTION;
UPDATE accounts SET balance = 500 WHERE id = 1;

-- 트랜잭션 2 (수정된 값 조회 가능)
SELECT balance FROM accounts WHERE id = 1; -- 결과: 500, 하지만 아직 COMMIT 되지 않음

 

 

✅ READ COMMITTED (읽기 확정)

  • 트랜잭션이 COMMIT 한 데이터만 읽을 수 있음
  • Dirty Read 방지
  • Non-Repeatable Read 발생 가능
  • Phantom Read 발생 가능
  • 일반적인 웹 애플리케이션에 추천
-- 트랜잭션 1 (첫 번재 조회)
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1; -- 결과: 1000

-- 트랜잭션 2 (값 수정 후 COMMIT)
UPDATE accounts SET balance = 500 WHERE id = 1;
COMMIT;

-- 트랜잭션 1 (두 번째 조회)
SELECT balance FROM accounts WHERE id = 1; -- 결과: 500 (첫 번째 조회 결과와 다름)

 

 

✅ REPEATABLE READ (반복 가능한 읽기)

  • 트랜잭션이 시작된 시점의 데이터를 계속 읽을 수 있음
  • Dirty Read 방지
  • Non-Repeatable Read 방지
  • Phantom Read 발생 가능

💡 MySQL의 기본 값이며 InnoDB에서는 MVCC (다중 버전 동시성 제어)와 Next-Key Locking 덕분에 해당 격리 수준에서도 Phantom Read도 사실상 방지 가능하다.

💡 즉, MySQL의 InnoDB 기준 Dirty Read, Non-Repeatable Read, Phantom Read 모두 방지 가능하다.

 

-- 트랜잭션 1 (고객 수 조회)
START TRANSACTION;
SELECT COUNT(*) FROM users WHERE age > 30; -- 결과: 5

-- 트랜잭션 2 (새로운 고객 추가)
INSERT INTO users (name, age) VALUES ('Alice', 35);
COMMIT;

-- 트랜잭션 1 (같은 조건으로 다시 조회)
SELECT COUNT(*) FROM users WHERE age > 30; -- 결과: 6 (이전과 다른 결과)

 

 

✅ SERIALIZABLE (직렬화)

  • 모든 트랜잭션이 순차적으로 실행되는 것처럼 동작
  • 읽기조차도 잠금을 걸어 다른 트랜잭션이 해당 범위에 INSERT / UPDATE를 못하게 함
  • Dirty Read 방지
  • Non-Repeatable Read 방지
  • Phantom Read 방지
  • 모든 SELECT 문에도 공유 잠금이 걸리므로 성능 저하가 매우 클 수 있음
-- 트랜잭션 A
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
SELECT * FROM users WHERE age > 30; -- 이 시점에 Shared Lock이 걸림

-- 트랜잭션 B
INSERT INTO users (name, age) VALUES ('Tom', 35); -- 대기하거나 롤백됨

 

 

5. 예제 코드 (🔨 Kotlin + Spring Boot + Spring Data JPA 🔨)

✅ READ UNCOMMITTED (읽기 미확정)

  • Dirty Read 발생 가능하지만, 큰 문제가 되지 않는 경우
  • ex) 로그 데이터 저장 시 (정확한 데이터보다는 빠른 성능이 중요할 때 사용)
@Service
class LogService(
  private val logRepository: LongRepository
) {
  @Transactional(isolation = Isolation.READ_UNCOMMITTED)
  fun saveLog(
    message: String
  ) {
      val log = LogEntity(
        message = message
      )
      logRepository.save(log)
    }
}

 

 

✅ READ COMMITTED (읽기 확정)

  • Dirty Read를 방지하고, Non-Repeatable Read는 허용 가능
  • ex) 주문 조회
@Service
class OrderService(
  private val orderRepository: OrderRepository
) {
  @Transactional(isolation = Isolation.READ_COMMITTED)
  fun getOrder(
    orderId: Long
  ): Order {
    return orderRepository.findById(orderId)
        .orElseThrow { NoSuchElementException("Order not found") }
  }
}

 

 

✅ REPEATABLE READ (반복 가능한 읽기)

  • 금융 서비스, 결제 시스템 등 한 번 조회한 값이 동일해야 할 때
  • MySQL의 기본 수준이며 InnoDB는 Phantom Read 또한 방지 가능
  • ex) 계좌 잔액 조회 후 업데이트
@Service
class BankService(
  private val accountRepository: AccountRepository
) {
  @Transactional(isolation = Isolation.REPEATABLE_READ)
  fun transfer(
    senderId: Long,
    receiverId: Long,
    amount: Int
  ) {
    val sender = accountRepository.findById(senderId)
        .orElseThrow { NoSuchElementException("Sender not found") }
    
    if (sender.balance < amount) {
      throw IllegalStateException("Insufficient balance")
    }
    
    sender.balance -= amount
    
    val receiver = accountRepository.findById(receiverId)
        .orElseThrow { NoSuchElementException("Receiver not found") }
    
    receiver.balance += amount
    
    accountRepository.save(sender)
    accountRepository.save(receiver)
  }
}

 

 

✅ SERIALIZABLE (직렬화)

  • 은행 시스템, 항공권 예약, 재고 관리 등 가장 높은 정합성이 필요할 때 사용
  • ex) 항공권 예약
@Service
class BookingService(
  private val seatRepository: SeatRepository
) {
  @Transactional(isolation = Isolation.SERIALIZABLE)
  fun bookSeat(
    seatId: Long,
    userId: Long
  ) {
    val seat = seatRepository.findById(seatId)
        .orElseThrow { NoSuchElementException("Seat not found") }
    
    if (seat.isBooked) {
      throw IllegalStateException("Seat already booked")
    }
    
    seat.isBooked = true
    seat.userId = userId
    
    seatRepository.save(seat)
  }
}

 

 

✔️ MySQL는 REPEATABLE READ가 기본이며 InnoDB의 경우 Phantom Read 까지 방지 가능

✔️ 대부분 REPEATABLE READ 격리 수준이 적절