티스토리 뷰

인녕하세요. 이 글에서는 트랜잭션과 락을 활용한 동시성 제어 방법을 설명합니다. MySQL, PostgreSQL, Oracle 등 주요 RDBMS의 공식 문서와 Spring Framework 공식 문서를 기준으로 트랜잭션 격리 수준, 락 전략, 실무 적용 방법을 정리합니다.

 


 

소개 · 배경

대규모 트래픽 환경에서는 여러 사용자가 동시에 동일한 데이터를 읽고 쓰는 상황이 빈번하게 발생합니다. MySQL 공식 Reference Manual에 따르면, 트랜잭션(Transaction) 은 데이터의 일관성을 보장하기 위한 메커니즘이며, 락(Lock) 은 동시에 발생하는 데이터 접근을 제어하기 위한 메커니즘입니다.

 

트랜잭션과 락은 항상 안정성과 성능 사이의 트레이드오프를 가집니다. 격리 수준을 높이면 데이터 일관성은 강화되지만 동시성은 저하됩니다. 락을 과도하게 사용하면 데드락이나 락 대기 시간이 증가하고, 반대로 락을 사용하지 않으면 데이터 불일치가 발생할 수 있습니다.

 

실무에서는 애플리케이션의 특성, 트래픽 패턴, 비즈니스 요구사항을 고려하여 적절한 트랜잭션 격리 수준과 락 전략을 선택하는 것이 핵심입니다.

 


 

트랜잭션의 기본 개념과 ACID

ACID 원칙

MySQL 공식 Reference Manual에 따르면, 트랜잭션은 다음과 같은 ACID 원칙을 만족해야 합니다.

  • Atomicity(원자성) : 트랜잭션 내의 모든 작업은 전부 성공하거나 전부 실패해야 합니다.
  • Consistency(일관성) : 트랜잭션이 완료된 후 데이터베이스는 항상 일관된 상태를 유지해야 합니다.
  • Isolation(격리성) : 동시에 실행되는 트랜잭션은 서로 간섭하지 않아야 합니다.
  • Durability(지속성) : 트랜잭션이 커밋되면 그 결과는 시스템 장애가 발생하더라도 유지되어야 합니다.

 

Spring Framework 공식 트랜잭션 문서에 따르면, @Transactional 어노테이션은 선언적 트랜잭션 관리를 제공합니다.

@Service
public class OrderService {

    @Autowired
    private OrderRepository orderRepository;

    @Transactional
    public void processOrder(Order order) {
        orderRepository.save(order);
        updateInventory(order);
    }
}

위 메서드는 하나의 트랜잭션으로 실행되며, 내부 로직 중 하나라도 실패하면 전체가 롤백됩니다.

 

⚠️ @Transactional은 Spring AOP 프록시 기반으로 동작하므로, 동일 클래스 내부 메서드 호출(self-invocation) 에서는 적용되지 않습니다. 트랜잭션이 정상적으로 적용되려면 프록시를 통한 외부 호출이어야 합니다.

 


 

트랜잭션 경계와 범위

Spring Framework 공식 문서에 따르면, 트랜잭션 경계는 트랜잭션이 시작되고 종료되는 지점을 의미합니다. 일반적으로 @Transactional이 선언된 메서드 진입 시 트랜잭션이 시작되고, 정상 종료 시 커밋, 예외 발생 시 롤백됩니다.

 

트랜잭션 범위는 가능한 한 짧게 유지해야 합니다. 트랜잭션이 길어질수록 락 점유 시간이 증가하고, 다른 트랜잭션의 대기 시간이 늘어납니다. 특히 외부 API 호출이나 네트워크 I/O 작업은 트랜잭션 밖에서 수행하는 것이 원칙입니다.


 

트랜잭션 격리 레벨과 읽기 동시성 제어

격리 레벨 정의

MySQL 공식 Reference Manual에 따르면, SQL 표준 트랜잭션 격리 수준은 다음과 같습니다.

  • READ UNCOMMITTED : 커밋되지 않은 데이터를 읽을 수 있음
  • READ COMMITTED : 커밋된 데이터만 읽을 수 있음
  • REPEATABLE READ : 트랜잭션 내에서 동일 쿼리는 항상 동일 결과 반환
  • SERIALIZABLE : 트랜잭션을 순차 실행한 것과 동일한 효과

MySQL InnoDB의 기본 격리 수준은 REPEATABLE READ, PostgreSQL의 기본 격리 수준은 READ COMMITTED입니다. 이 차이는 각 DBMS의 MVCC 구현 방식 차이에서 비롯됩니다.

 


 

읽기 이상(Read Anomaly)

격리 수준이 낮을수록 다음과 같은 읽기 이상 현상이 발생할 수 있습니다.

  • Dirty Read : 커밋되지 않은 데이터를 읽는 현상 (READ UNCOMMITTED)
  • Non-repeatable Read : 같은 쿼리를 반복 실행했을 때 결과가 달라지는 현상 (READ COMMITTED)
  • Phantom Read : 동일 조건의 쿼리 결과에 새로운 행이 나타나는 현상

이론적으로 Phantom Read는 REPEATABLE READ에서도 발생할 수 있지만, MySQL InnoDB는 Next-Key Lock을 통해 이를 방지합니다.

 


 

MVCC 기반 읽기 동시성

PostgreSQL 공식 문서에 따르면, MVCC(Multi-Version Concurrency Control)는 여러 버전의 데이터를 유지하여 읽기와 쓰기의 충돌을 최소화합니다. 각 트랜잭션은 시작 시점의 스냅샷을 기준으로 데이터를 조회합니다.

 

MySQL InnoDB 역시 MVCC를 사용하며, 트랜잭션 ID와 Undo Log를 기반으로 읽기 일관성을 제공합니다. 이를 통해 일반 SELECT는 쓰기 트랜잭션을 블로킹하지 않고, 쓰기 역시 읽기를 블로킹하지 않습니다.

 


 

쓰기 동시성 제어와 락

Shared Lock과 Exclusive Lock

  • Shared Lock(S Lock) : 읽기용 락으로, 여러 트랜잭션이 동시에 획득 가능
  • Exclusive Lock(X Lock) : 쓰기용 락으로, 다른 트랜잭션의 쓰기 및 락 획득을 차단
SELECT * FROM orders WHERE id = 1 FOR UPDATE;

FOR UPDATE는 해당 행에 Exclusive Lock을 설정합니다. 일반 SELECT는 락을 설정하지 않으며 MVCC 기반으로 처리됩니다.

 


 

InnoDB 락 종류

MySQL 공식 문서에 따르면, InnoDB는 다음과 같은 락을 사용합니다.

  • Row Lock : 특정 행에 대한 락
  • Gap Lock : 인덱스 레코드 사이 간격에 대한 락
  • Next-Key Lock : Row Lock + Gap Lock

REPEATABLE READ 격리 수준에서 InnoDB는 Next-Key Lock을 사용하여 Phantom Read를 방지합니다.

 


 

비관적 락과 낙관적 락

비관적 락(Pessimistic Lock)

@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Order> findById(Long id);

 

데이터 접근 시점에 락을 획득하여 충돌을 사전에 방지합니다. 충돌이 빈번한 경우에 적합하지만 락 대기가 발생할 수 있습니다.

 

 

낙관적 락(Optimistic Lock)

@Entity
public class Order {
    @Id
    private Long id;

    @Version
    private Long version;
}

 

UPDATE 시 버전 조건을 검증하여 충돌을 감지합니다. 충돌이 드문 경우에 적합하며 데드락이 발생하지 않습니다.

 


 

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

  • 충돌 빈도 낮음 → 낙관적 락
  • 충돌 빈도 높음 → 비관적 락
  • 트랜잭션 길음 / 외부 API 포함 → 낙관적 락
  • 데이터 정합성이 절대적 → 비관적 락 고려

 


 

외부 API 연동 시 동시성 제어 전략

트랜잭션 범위 최소화

외부 API 호출은 트랜잭션 밖에서 수행하고, DB 반영만 트랜잭션으로 처리해야 합니다.

 

보상 트랜잭션

외부 API 실패 시, 이미 커밋된 변경사항을 되돌리기 위한 별도의 트랜잭션입니다.

 

이벤트 기반 비동기 처리 (Transactional Outbox)

Transactional Outbox 패턴은 DB 트랜잭션과 외부 시스템 연동 사이의 원자성 문제를 해결하기 위한 패턴입니다. 이벤트를 트랜잭션 내에 저장하고, 이후 비동기로 처리함으로써 트랜잭션 범위를 최소화합니다.

 


 

실무에서 자주 발생하는 오해

  • @Transactional만 붙이면 동시성 문제가 해결된다는 오해
  • 격리 레벨을 무조건 SERIALIZABLE로 설정하는 실수
  • 락을 많이 걸수록 안전하다는 오해

 


 

 

성능 병목 문제를 해결하는 방법

트랜잭션과 락은 시스템 안정성을 보장하지만, 잘못 설계되면 성능 병목의 직접적인 원인이 됩니다. 성능 문제를 해결하기 위해서는 막연한 튜닝이 아니라, 병목 지점을 정확히 식별하고 원인별로 접근해야 합니다.

 

병목 지점 식별: 어디서 느려지는가

성능 문제는 크게 다음 세 영역에서 발생합니다.

  1. DB 레벨 병목
    • 락 대기 증가
    • 트랜잭션 충돌
    • 인덱스 미사용
  2. 트랜잭션 설계 병목
    • 불필요하게 긴 트랜잭션
    • 외부 API 호출 포함
  3. 애플리케이션 레벨 병목
    • 동기 처리 과다
    • 불필요한 재시도
    • 낙관적 락 충돌 반복

실무에서는 먼저 DB → 트랜잭션 → 애플리케이션 순서로 진단하는 것이 일반적입니다.

 

락 대기로 인한 성능 병목 해결

트랜잭션 범위 최소화

가장 흔한 문제는 트랜잭션이 불필요하게 긴 경우입니다.

@Transactional
public void process(Order order) {
    callExternalApi();   // ❌ 트랜잭션 내부 외부 호출
    orderRepository.save(order);
}

위 구조는 외부 API 응답 지연 동안 DB 락을 점유하게 됩니다. 외부 호출은 반드시 트랜잭션 밖으로 분리해야 합니다.

 

public void process(Order order) {
    callExternalApi();
    saveOrder(order);
}

@Transactional
public void saveOrder(Order order) {
    orderRepository.save(order);
}

 

 

필요한 경우에만 명시적 락 사용

SELECT ... FOR UPDATE는 강력하지만, 남용하면 즉시 병목으로 이어집니다.

  • 단순 조회 → 일반 SELECT
  • 상태 변경이 필요한 경우에만 → FOR UPDATE

비관적 락은 “반드시 충돌이 발생하는 지점”에만 국한해야 합니다.

 

트랜잭션 격리 레벨로 인한 병목 완화

격리 레벨을 높이면 일관성은 강화되지만, 동시성은 감소합니다.

  • 조회 위주 시스템 READ COMMITTED
  • 정합성 중요, 충돌 빈번 REPEATABLE READ
  • SERIALIZABLE 최후의 수단

실무에서는 문제 발생 구간에만 격리 레벨 조정하는 것이 원칙입니다. 전체 시스템 격리 레벨 상향은 대부분 과도한 선택입니다.

 

 

낙관적 락 충돌로 인한 성능 저하 대응

낙관적 락은 데드락은 없지만, 충돌 시 재시도 비용이 발생합니다.

9.4 낙관적 락 충돌로 인한 성능 저하 대응

낙관적 락은 데드락은 없지만, 충돌 시 재시도 비용이 발생합니다.

 

 

충돌이 잦아질 경우:

  • 재시도 횟수 제한
  • 사용자에게 재시도 유도
  • 비관적 락으로 전략 전환 검토

낙관적 락은 “충돌이 드문 경우”에만 효과적입니다.

 

 

외부 연동으로 인한 병목 해결 전략

이벤트 기반 비동기 처리

외부 시스템 연동을 트랜잭션에서 분리하기 위해 Transactional Outbox 패턴을 적용할 수 있습니다.

  • DB 변경과 이벤트 기록을 하나의 트랜잭션으로 처리
  • 실제 외부 전송은 비동기 수행

이 방식은 트랜잭션 시간을 단축하고 락 점유를 최소화하며, 장애 전파를 차단합니다.

 

데이터 후보정 전략

외부 API와 완벽한 실시간 정합성이 필요 없는 경우 우선 커밋, 이후 비동기 보정하는 경우입니다.

예를 들어, 결제 승인 → 상태 임시 저장하는 경우 이후 정산·보정 작업 수행할 수 있습니다.

 

 

실무 성능 병목 해결 체크리스트

  • 트랜잭션에 외부 호출이 포함되어 있는가?
  • 락이 필요한 조회와 불필요한 조회가 구분되어 있는가?
  • 격리 레벨이 문제 구간에 맞게 설정되어 있는가?
  • 낙관적 락 충돌이 반복되고 있지는 않은가?
  • 동기 처리로 인해 불필요한 대기 시간이 발생하고 있지는 않은가?

성능 병목 문제는 단순히 DB 튜닝이나 락 추가로 해결되지 않습니다.

대부분의 병목은 트랜잭션 경계 설계와 동시성 전략 선택에서 발생합니다.

  • 트랜잭션은 짧게
  • 락은 꼭 필요한 곳에만
  • 외부 연동은 비동기로
  • 충돌 패턴에 맞는 락 전략 선택

이 원칙만 지켜도, 안정성과 성능을 동시에 만족하는 시스템을 설계할 수 있습니다.

 

 

맺음말 및 요약

트랜잭션과 락은 단독으로 존재하지 않습니다. 격리 레벨, 락 전략, 트랜잭션 범위는 반드시 함께 설계되어야 합니다. 공식 문서 기반 이해와 실행 계획, 락 상태 분석을 통해 안정적이고 효율적인 동시성 제어 시스템을 구축할 수 있습니다.