데이터베이스 트랜잭션 격리 수준 쉽게 이해하기

트랜잭션의 기본 개념

여러 테이블을 조작하는 개발 작업에서는 데이터 일관성을 보장하기 위해 트랜잭션과 그 특성을 반드시 고려해야 합니다. 트랜잭션은 원자성과 데이터 무결성을 보장하는 핵심 메커니즘입니다. 이 글에서는 실제 시스템 결제 로직에서 발생할 수 있는 동시성 문제를 통해 트랜잭션 격리 수준에 대해 심도 있게 탐구해 보겠습니다.

ACID 특성

  • 원자성 (Atomicity): 트랜잭션 내의 모든 작업은 하나의 단위로 처리됩니다. 즉, 모든 작업이 성공적으로 완료되거나, 하나라도 실패하면 전체 작업이 취소(rollback)되어 트랜잭션이 시작되기 전 상태로 되돌아갑니다.
  • 일관성 (Consistency): 트랜잭션이 성공적으로 완료되면 데이터베이스는 항상 일관된 상태를 유지해야 합니다. 이는 데이터의 정확성, 무결성 규칙을 준수하는 것을 의미합니다.
  • 격리성 (Isolation): 두 개 이상의 트랜잭션이 동시에 데이터베이스의 동일한 데이터에 접근할 때, 각 트랜잭션이 다른 트랜잭션의 작업에 영향을 받지 않도록 격리됩니다. 격리 수준은 읽기 미완료(Read Uncommitted), 읽기 완료(Read Committed), 반복 읽기(Repeatable Read), 직렬화(Serializable) 등 여러 단계로 나뉩니다.
  • 내구성 (Durability): 트랜잭션이 성공적으로 커밋되면, 해당 변경 사항은 영구적으로 데이터베이스에 저장되어 시스템 장애가 발생해도 유지됩니다.

네 가지 트랜잭션 격리 수준

  • 읽기 미완료 (Read Uncommitted): 가장 낮은 격리 수준입니다. 트랜잭션이 다른 트랜잭션에서 아직 커밋되지 않은 변경 내용(더티 데이터)을 읽을 수 있습니다.
  • 읽기 완료 (Read Committed): 트랜잭션이 다른 트랜잭션에서 커밋된 데이터만 읽을 수 있도록 보장합니다. 하지만 한 트랜잭션 내에서 동일한 데이터를 두 번 조회했을 때 결과가 다를 수 있습니다(반복 불가능한 읽기).
  • 반복 읽기 (Repeatable Read): 트랜잭션 내에서 동일한 데이터를 여러 번 조회해도 항상 동일한 결과를 반환합니다. 하지만 새로운 행이 삽입되는 경우(팬텀 리드)는 방지하지 못할 수 있습니다.
  • 직렬화 (Serializable): 가장 높은 격리 수준입니다. 트랜잭션을 순차적으로 실행하는 것과 동일한 결과를 보장하여 모든 동시성 문제(더티 리드, 반복 불가능한 읽기, 팬텀 리드)를 방지합니다.

주요 읽기 현상 (Anomalies)

  • 더티 리드 (Dirty Read): 트랜잭션이 아직 커밋되지 않은 다른 트랜잭션의 변경 내용을 읽는 현상입니다.
  • 반복 불가능한 읽기 (Non-repeatable Read): 한 트랜잭션 내에서 동일한 데이터를 두 번 조회했을 때, 중간에 다른 트랜잭션이 해당 데이터를 수정하고 커밋하여 결과가 달라지는 현상입니다.
  • 팬텀 리드 (Phantom Read): 한 트랜잭션 내에서 동일한 쿼리를 두 번 실행했을 때, 중간에 다른 트랜잭션이 새로운 데이터를 삽입하여 두 번째 쿼리 결과에 처음에는 없던 새로운 행이 나타나는 현상입니다.

실제 비즈니스 로직: 사용자 출금 처리

사용자가 자신의 계좌에서 돈을 출금하는 간단한 비즈니스 로직을 통해 격리 수준의 중요성을 살펴보겠습니다. 이 로직은 다음과 같은 단계를 포함합니다.

  1. 현재 계좌 잔액 조회
  2. 출금 기록 저장
  3. 계좌 상세 내역 저장
  4. 계좌 잔액 업데이트

이러한 다중 테이블 작업은 원자성을 보장하기 위해 하나의 트랜잭션으로 묶어야 합니다. 데이터베이스를 분산하여 분산 트랜잭션을 피하기 위해, 동일 사용자의 데이터는 동일 데이터베이스에 저장하는 전략(사용자 ID 기준 분산)을 사용할 수 있습니다.

초기 구현 및 문제점

아래는 `SERIALIZABLE` 격리 수준을 설정한 Java 코드입니다. 이 코드는 동시에 여러 사용자가 출금을 시도할 때 예상치 못한 결과를 초래할 수 있습니다.

// 격리 수준: SERIALIZABLE
public Boolean processWithdrawal(WithdrawalRequest request) {
    // ①. 현재 계좌 정보 조회
    UserAccount userAccount = queryUserAccount(request.getUserId());
    
    // ②. 잔액 확인
    if (request.getAmount() > userAccount.getBalance()) {
        throw new InsufficientBalanceException("잔액이 부족합니다.");
    }
    
    // ③. 출금 기록 저장
    saveWithdrawalRecord(request);
    
    // ④. 계좌 상세 내역 저장
    saveAccountDetail(request);
    
    // ⑤. 계좌 잔액 업데이트
    updateUserAccountBalance(request.getUserId(), request.getAmount());
    
    return true;
}

문제 분석: `SERIALIZABLE` 격리 수준을 사용했음에도 불구하고, MySQL에서 `SELECT` 문은 기본적으로 `LOCK IN SHARE MODE`를 사용하여 공유 락(Shared Lock)을 획득합니다. 이는 다른 트랜잭션이 동일한 데이터를 읽는 것을 막지 않습니다. 따라서 두 개의 출금 트랜잭션이 동시에 실행되면, 둘 다 초기 잔액(예: 5000원)을 읽고 각각 3000원, 4000원을 출금하려 할 경우, 최종 잔액이 음수가 될 수 있습니다.

동시성 문제 해결 방안

이 문제를 해결하기 위해 두 가지 주요 전략을 적용할 수 있습니다.

방안 1: 업데이트 후 재조회 검증

업데이트 작업 후, 예상 잔액과 실제 데이터베이스의 잔액을 비교하여 불일치 시 트랜잭션을 롤백하는 방식입니다.

public Boolean processWithdrawal(WithdrawalRequest request) {
    // ①. 현재 계좌 정보 조회
    UserAccount userAccount = queryUserAccount(request.getUserId());
    
    // ②. 잔액 확인
    if (request.getAmount() > userAccount.getBalance()) {
        throw new InsufficientBalanceException("잔액이 부족합니다.");
    }
    
    // ③. 출금 기록 저장
    saveWithdrawalRecord(request);
    
    // ④. 계좌 상세 내역 저장
    saveAccountDetail(request);
    
    // ⑤. 계좌 잔액 업데이트
    updateUserAccountBalance(request.getUserId(), request.getAmount());
    
    // ⑥. 잔액 검증
    long expectedBalance = userAccount.getBalance() - request.getAmount();
    UserAccount updatedAccount = queryUserAccount(request.getUserId());
    long actualBalance = updatedAccount.getBalance();
    
    if (expectedBalance != actualBalance) {
        // 다른 트랜잭션에 의해 잔액이 변경된 경우, 예외 발생 및 트랜잭션 롤백
        throw new ConcurrencyException("동시에 다른 트랜잭션에서 잔액이 변경되었습니다.");
    }
    
    return true;
}

방안 2: 낙관적 락 (Optimistic Locking) / CAS (Compare-And-Set)

더 효율적인 방법은 `UPDATE` 쿼리에 현재 잔액을 조건으로 추가하는 것입니다. 이는 CAS(비교 후 설정) 패턴의 일종으로, 업데이트가 성공했는지 확인하여 실패 시 트랜잭션을 롤백합니다.

public Boolean processWithdrawal(WithdrawalRequest request) {
    // ①. 현재 계좌 정보 조회
    UserAccount userAccount = queryUserAccount(request.getUserId());
    
    // ②. 잔액 확인
    if (request.getAmount() > userAccount.getBalance()) {
        throw new InsufficientBalanceException("잔액이 부족합니다.");
    }
    
    // ③. 출금 기록 저장
    saveWithdrawalRecord(request);
    
    // ④. 계좌 상세 내역 저장
    saveAccountDetail(request);
    
    // ⑤. 계좌 잔액 업데이트 (CAS 방식)
    // SQL 예시: UPDATE accounts SET balance = :newBalance WHERE id = :id AND balance = :oldBalance
    int updatedRows = updateUserAccountBalanceWithCondition(
        request.getUserId(), 
        userAccount.getBalance() - request.getAmount(), 
        userAccount.getBalance()
    );
    
    if (updatedRows == 0) {
        // 다른 트랜잭션에 의해 잔액이 변경되어 업데이트 실패한 경우
        throw new ConcurrencyException("동시에 다른 트랜잭션에서 잔액이 변경되었습니다.");
    }
    
    return true;
}

이 방식은 데이터베이스 레벨에서 충돌을 감지하고 처리하므로, 애플리케이션 로직이 더 간결해지고 성능상 이점이 있습니다. 하지만 사용자 계좌 작업이 매우 빈번한 경우, 트랜잭션 실패 확률이 증가할 수 있습니다. 이러한 경우, 메시지 큐와 같은 비동기 처리 아키텍처를 도입하여 사용자 요청을 순차적으로 처리하는 것이 더 나은 해결책이 될 수 있습니다.

태그: 데이터베이스 트랜잭션 격리 수준 동시성 제어 MySQL

5월 23일 20:11에 게시됨