Database

트랜잭션 격리 수준과 동시성 제어 - 실무에서 겪는 문제들

백엔드 개발자 김승원 2026. 3. 31. 10:23

들어가며

데이터베이스 트랜잭션은 데이터 무결성의 근간입니다. 하지만 동시에 수많은 요청이 들어오는 실무 환경에서는 트랜잭션 격리 수준에 따라 예상치 못한 데이터 이상 현상이 발생합니다. 이 글에서는 4가지 트랜잭션 격리 수준, 각 수준에서 발생할 수 있는 문제, 그리고 낙관적/비관적 락과 MVCC까지 실무에서 반드시 알아야 할 동시성 제어 기법을 체계적으로 정리합니다.

1. 트랜잭션 격리 수준의 이해

SQL 표준은 4가지 격리 수준을 정의하며, 높은 격리 수준일수록 데이터 일관성은 좋아지지만 동시성(성능)은 떨어집니다.

READ UNCOMMITTED - 가장 낮은 격리 수준

다른 트랜잭션이 커밋하지 않은 데이터를 읽을 수 있습니다. Dirty Read가 발생하며, 실무에서는 거의 사용하지 않습니다.

-- 트랜잭션 A: 상품 가격을 10000 → 8000으로 변경 (아직 커밋하지 않음)
BEGIN;
UPDATE products SET price = 8000 WHERE id = 1;
-- 아직 COMMIT 안 함

-- 트랜잭션 B: READ UNCOMMITTED에서는 커밋되지 않은 8000을 읽음
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT price FROM products WHERE id = 1;
-- 결과: 8000 (Dirty Read!)

-- 트랜잭션 A가 ROLLBACK하면 트랜잭션 B는 존재하지 않는 데이터를 읽은 셈

READ COMMITTED - PostgreSQL, Oracle 기본값

커밋된 데이터만 읽을 수 있어 Dirty Read는 방지되지만, 같은 쿼리를 두 번 실행했을 때 다른 결과가 나올 수 있는 Non-Repeatable Read가 발생합니다.

-- 트랜잭션 A
BEGIN;
SELECT price FROM products WHERE id = 1; -- 결과: 10000

-- 이 시점에 트랜잭션 B가 가격을 8000으로 변경하고 커밋

SELECT price FROM products WHERE id = 1; -- 결과: 8000 (Non-Repeatable Read!)
COMMIT;

REPEATABLE READ - MySQL InnoDB 기본값

트랜잭션 시작 시점의 스냅샷을 기준으로 데이터를 읽습니다. Non-Repeatable Read는 방지되지만, Phantom Read가 발생할 수 있습니다.

-- 트랜잭션 A
BEGIN;
SELECT COUNT(*) FROM orders WHERE status = 'PENDING'; -- 결과: 5

-- 이 시점에 트랜잭션 B가 새 주문을 INSERT하고 커밋

SELECT COUNT(*) FROM orders WHERE status = 'PENDING';
-- MySQL InnoDB: 여전히 5 (MVCC 덕분에 Phantom Read 방지)
-- SQL 표준상 REPEATABLE READ: 6이 될 수 있음 (Phantom Read)
COMMIT;

참고: MySQL InnoDB는 MVCC와 Next-Key Lock을 사용하여 REPEATABLE READ에서도 Phantom Read를 대부분 방지합니다.

SERIALIZABLE - 가장 높은 격리 수준

트랜잭션을 순차적으로 실행하는 것과 같은 결과를 보장합니다. 모든 이상 현상을 방지하지만 동시성이 크게 저하됩니다.

2. 이상 현상 정리

격리 수준 Dirty Read Non-Repeatable Read Phantom Read
READ UNCOMMITTED O O O
READ COMMITTED X O O
REPEATABLE READ X X O (MySQL에서는 X)
SERIALIZABLE X X X

3. 낙관적 락 (Optimistic Lock)

낙관적 락은 충돌이 드물다고 가정하고, 데이터를 수정할 때 충돌을 감지하는 방식입니다. 실제 DB 락을 사용하지 않고 버전(version) 컬럼으로 구현합니다.

@Entity
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private int price;
    private int stockQuantity;

    @Version  // JPA 낙관적 락 - 수정 시마다 version 자동 증가
    private Long version;

    public void decreaseStock(int quantity) {
        if (this.stockQuantity < quantity) {
            throw new InsufficientStockException(
                "재고 부족: 현재 " + stockQuantity + ", 요청 " + quantity);
        }
        this.stockQuantity -= quantity;
    }
}

@Service
@RequiredArgsConstructor
public class OrderService {
    private final ProductRepository productRepository;

    // 낙관적 락을 활용한 재고 차감
    @Transactional
    public void order(Long productId, int quantity) {
        Product product = productRepository.findById(productId)
            .orElseThrow();

        product.decreaseStock(quantity);
        productRepository.save(product);
        // version이 변경되었으면 OptimisticLockingFailureException 발생
    }

    // 재시도 로직 포함
    @Retryable(
        value = OptimisticLockingFailureException.class,
        maxAttempts = 3,
        backoff = @Backoff(delay = 100)
    )
    @Transactional
    public void orderWithRetry(Long productId, int quantity) {
        Product product = productRepository.findById(productId)
            .orElseThrow();
        product.decreaseStock(quantity);
        productRepository.save(product);
    }
}

낙관적 락이 실행하는 SQL은 다음과 같습니다:

UPDATE products
SET stock_quantity = ?, price = ?, version = version + 1
WHERE id = ? AND version = ?
-- version이 일치하지 않으면 0행 업데이트 → 예외 발생

4. 비관적 락 (Pessimistic Lock)

비관적 락은 충돌이 빈번하다고 가정하고, 데이터를 읽는 시점에 락을 거는 방식입니다. 실제 DB의 행 수준 락(Row-Level Lock)을 사용합니다.

public interface ProductRepository extends JpaRepository<Product, Long> {

    // 비관적 쓰기 락 - SELECT ... FOR UPDATE
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findByIdWithPessimisticLock(@Param("id") Long id);

    // 비관적 읽기 락 - SELECT ... FOR SHARE
    @Lock(LockModeType.PESSIMISTIC_READ)
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findByIdWithSharedLock(@Param("id") Long id);
}

@Service
@RequiredArgsConstructor
public class StockService {
    private final ProductRepository productRepository;

    // 비관적 락을 활용한 동시성 제어 - 충돌이 잦은 재고 관리에 적합
    @Transactional
    public void decreaseStock(Long productId, int quantity) {
        // SELECT ... FOR UPDATE로 행 락 획득
        Product product = productRepository
            .findByIdWithPessimisticLock(productId)
            .orElseThrow(() -> new ProductNotFoundException(productId));

        product.decreaseStock(quantity);
        // 트랜잭션 종료 시 락 자동 해제
    }
}

낙관적 vs 비관적 락 선택 기준

기준 낙관적 락 비관적 락
충돌 빈도 낮을 때 유리 높을 때 유리
성능 읽기 성능 우수 락 대기로 인한 지연
구현 @Version 어노테이션 SELECT FOR UPDATE
적합한 사례 게시글 수정, 프로필 변경 재고 차감, 좌석 예약
데드락 위험 없음 있음 (타임아웃 설정 필요)

5. MVCC (Multi-Version Concurrency Control)

MVCC는 데이터의 여러 버전을 유지하여 읽기와 쓰기가 서로를 블로킹하지 않도록 하는 기법입니다. MySQL InnoDB, PostgreSQL 등 대부분의 RDBMS가 채택하고 있습니다.

-- MVCC 동작 원리 (MySQL InnoDB)

-- 트랜잭션 A (시작: trx_id=100)
BEGIN;
SELECT * FROM accounts WHERE id = 1;
-- MVCC: trx_id=100 시점의 스냅샷을 읽음
-- Undo Log에서 해당 시점의 데이터를 재구성

-- 트랜잭션 B (trx_id=101)가 동시에 UPDATE하고 COMMIT
-- → 새로운 버전이 생성되지만, 트랜잭션 A에는 영향 없음

SELECT * FROM accounts WHERE id = 1;
-- 여전히 trx_id=100 시점의 데이터를 읽음 (REPEATABLE READ)
COMMIT;

MVCC 덕분에 읽기 작업은 쓰기를 블로킹하지 않고, 쓰기 작업은 읽기를 블로킹하지 않습니다. 이는 높은 동시성을 달성하는 핵심 원리입니다.

6. Spring @Transactional 전파 속성

Spring의 @Transactional 전파(Propagation) 속성은 트랜잭션 경계를 어떻게 설정할지 결정합니다.

@Service
@RequiredArgsConstructor
public class OrderProcessService {
    private final OrderRepository orderRepository;
    private final PaymentService paymentService;
    private final NotificationService notificationService;

    // REQUIRED (기본값): 기존 트랜잭션에 참여, 없으면 새로 생성
    @Transactional(propagation = Propagation.REQUIRED)
    public void createOrder(OrderRequest request) {
        Order order = Order.create(request);
        orderRepository.save(order);

        // paymentService도 같은 트랜잭션에서 실행
        paymentService.processPayment(order);
    }

    // REQUIRES_NEW: 항상 새로운 트랜잭션 생성 (기존 트랜잭션은 일시 중단)
    @Transactional(propagation = Propagation.REQUIRED)
    public void processWithNotification(OrderRequest request) {
        Order order = Order.create(request);
        orderRepository.save(order);

        // 알림 실패가 주문을 롤백시키면 안 되므로 별도 트랜잭션
        notificationService.sendNotification(order);
    }
}

@Service
public class NotificationService {

    // 주문 트랜잭션과 독립적으로 실행
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void sendNotification(Order order) {
        // 이 트랜잭션이 실패해도 주문 트랜잭션에 영향 없음
        notificationRepository.save(
            new Notification(order.getId(), "주문 완료"));
        emailSender.send(order.getUserEmail(), "주문 완료");
    }
}

@Service
public class AuditService {

    // NOT_SUPPORTED: 트랜잭션 없이 실행
    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    public void log(String action) {
        // 로깅은 트랜잭션 범위 밖에서 실행
        auditLogRepository.save(new AuditLog(action));
    }
}

주요 전파 속성 요약

전파 속성 기존 트랜잭션 있을 때 기존 트랜잭션 없을 때 사용 사례
REQUIRED 참여 새로 생성 일반적인 비즈니스 로직
REQUIRES_NEW 새로 생성 (기존 중단) 새로 생성 독립적인 로깅, 알림
NESTED 중첩 트랜잭션 새로 생성 부분 롤백이 필요한 경우
MANDATORY 참여 예외 발생 반드시 트랜잭션 내에서 호출
NOT_SUPPORTED 트랜잭션 없이 실행 트랜잭션 없이 실행 읽기 전용 대량 조회

7. 실무에서 자주 겪는 @Transactional 함정

@Service
public class CommonMistakes {

    // 함정 1: private 메서드에 @Transactional은 동작하지 않음
    // Spring AOP는 프록시 기반이므로 public 메서드에만 적용
    @Transactional // 동작하지 않음!
    private void internalMethod() { }

    // 함정 2: 같은 클래스 내부 호출은 프록시를 거치지 않음
    public void outerMethod() {
        this.innerMethod(); // @Transactional이 적용되지 않음!
    }

    @Transactional
    public void innerMethod() {
        // 외부에서 호출해야 트랜잭션 적용
    }

    // 함정 3: checked exception은 기본적으로 롤백하지 않음
    @Transactional // IOException 발생 시 롤백되지 않음!
    public void riskyMethod() throws IOException {
        // rollbackFor를 명시해야 함
    }

    // 올바른 사용
    @Transactional(rollbackFor = Exception.class)
    public void correctMethod() throws Exception {
        // 모든 예외에 대해 롤백
    }
}

정리

트랜잭션 격리 수준과 동시성 제어는 데이터 무결성과 서비스 성능을 모두 좌우하는 핵심 주제입니다. MySQL을 사용한다면 기본 REPEATABLE READ에서 MVCC가 어떻게 동작하는지 이해하고, 동시성이 높은 기능에서는 비관적/낙관적 락을 적절히 선택하세요. 그리고 Spring의 @Transactional 전파 속성과 주의사항을 숙지하면 안정적인 트랜잭션 관리가 가능합니다.