Transaction

·2025년 11월 13일

1. 트랜잭션(Transaction)이란?

트랜잭션은 하나의 작업 단위로 묶인 여러 데이터베이스 작업들을 말한다.

논리적인 이유로 여러 SQL이 단일 작업으로 묶여서 나눠질 수 없게 만든 것 !

하나의 작업 단위로 묶여있기 때문에
트랜잭션 내 SQL문들은 모두 성공하는 경우 commit되고,
하나라도 실패하는 경우에는 전체가 rollback된다.


ACID 속성

1. 원자성(Atomicity)

  • 트랜잭션은 더 이상 나눌 수 없는 하나의 작업 단위이다.
  • 트랜잭션 안의 모든 작업이 모두 성공하거나, 하나라도 실패하면 전체가 취소(rollback) 된다.
  • All or Nothing

내부적으로 어떻게 보장될까 ?

  • DB는 변경하기 전에 undo log 또는 redo log 에 원본 데이터를 기록하고,
    오류가 발생하면 로그를 바탕으로 롤백한다.
  • 커밋되기 전까지는 외부에서 결과가 보이지 않는다.

2. 일관성(Consistency)

  • DB에는 각종 제약조건(constraints)이 있는데
    • NOT NULL
    • UNIQUE
    • FOREIGN KEY
    • CHECK
    • 테이블 간 규칙
    • 비즈니스 규칙 (예 : 잔액은 음수가 될 수 없음)
  • 트랜잭션이 실행되기 전이나 후나, 이 규칙들이 항상 지켜져야 한다.
  • 일관성은 DB가 보장하는 것 + 애플리케이션이 보장하는 것 둘 다 필요하고, 트랜잭션만 있다고 지켜지는 건 아니다.

3. 격리성(Isolation)

  • 동시에 실행되는 여러 트랜잭션은 서로 간섭하면 안 된다.
  • 완벽하게 간섭이 없게 하면 성능이 크게 떨어지기 때문에, DB는 여러 격리 수준(Isolation Level)을 제공한다.
  • 격리성이 부족하면 생길 수 있는 문제
    • Dirty Read : 아직 커밋 안된 데이터를 읽음
    • Non-repeatable Read : 같은 데이터를 두 번 읽는데 값이 달라짐
    • Phantom Read : 같은 조건으로 조회했는데 새 행이 생기거나 사라짐
  • ANSI 표준 격리 수준 4단계
    1. READ UNCOMMITTED
    • 커밋되지 않은 데이터도 읽을 수 있음(= Dirty Read 허용)
    • 가장 빠르지만 거의 사용 안 함
    1. READ COMMITTED
    • 커밋된 데이터만 읽음
    • Dirty Read 방지
    1. REPEATABLE READ
    • 같은 트랜잭션 안에서는 같은 값을 보장
    • Non-repeatable Read 방지
    • MySQL InnoDB의 기본값 (MVCC 사용으로 '팬텀'도 일부 방지)
    1. SERIALIZABLE
    • 모든 트랜잭션을 순차적으로 실행한 것처럼 처리
    • 가장 안전하지만 성능이 제일 느림

4. 지속성(Durability)

  • 커밋된 데이터는 절대 사라지지 않는다.
  • 트랜잭션이 커밋되면 그 결과는 서버가 꺼져도, 장애가 나도, 디스크 문제가 나도
    최대한 보존되도록 보장된다.

내부적으로 어떻게 보장될까 ?

  • DB는 커밋 시 다음 작업을 한다.
    • redo log 에 반영 내용 기록 (Write Ahead Logging)
    • 디스크에 플러시
    • 실제 데이터를 테이블에 반영
      -> 즉, 로그를 먼저 남겨서 장애 시 복구 가능하게 만드는 방식 !

Spring에서 트랜잭션 관리 방식

트랜잭션 설정이 성능에 영향을 미치는 이유에는 잘못된 트랜잭션 범위불필요한 트랜잭션 기능이 있다.

우선 잘못된 트랜잭션 범위가 성능에 영향을 주는 이유는

트랜잭션을 단순히 데이터를 묶는 기능이 아니라
DB Connection을 오래 잡고 있는 상태라고 보면 되는데
범위를 넓게 잡을 경우 아래와 문제가 생긴다.

(1) DB 커넥션을 너무 오래 점유한다.

  • 트랜잭션이 시작되는 순간 DB 커넥션 get, Lock 발생 가능, 영속성 컨텍스트 flush 타이밍 관리를 하게 되는데
    이 상태는 비용이 매우 크다.
  • 범위를 넓게 잡아 DB를 쓰지 않는 코드까지 트랜잭션 안에서 포함시킬 경우 커넥션이 오래 잡혀있어 동시 처리량(throughput)이 떨어진다.

(2) 쓸데없이 영속성 컨텍스트가 길게 유지된다.

  • 트랜잭션이 유지되는 동안 영속성 컨텍스트도 메모리 안에 살아있게 되는데, 범위가 길면 엔티티 스냅샷 모두 소지, 변경 감지 모니터링 및 flush 타이밍 지연이 발생할 수 있다.
  • 메모리와 CPU를 더 소모하게 된다 !

(3) 트랜잭션 격리 수준이 영향을 더 크게 준다.

  • 격리 수준이 높을수록 (예: SERIALIZABLE) 비용이 크고 잠금이 길어진다.

두번째로는 불필요한 트랜잭션 기능이 성능에 영향을 주는 이유에 대해 살펴보겠다.

스프링 트랜잭션은 기능이 많다.
하지만 “기능이 많다 = 오버헤드가 발생한다”는 의미도 있다.

(1) readOnly=false 를 쓸 필요 없는 곳에서 사용

  • 기본값은 readOnly=false인데, 이 말은 “쓰기 작업이 발생할 수 있다”는 뜻을 의미한다.
  • JPA가 더 많은 더티체킹 수행하고, flush 자동 실행, 엔티티 스냅샷 저장 등의 작업을 전부 수행한다.
  • 위와 같은 이유로 조회 전용 메서드는 꼭 @Transactional(readOnly = true)을 붙여주는 것이 좋다. (더티체킹 비활성화, 엔티티 변경 감시 없음, flush 생략)
  • 조회 성능이 훨씬 좋아짐 (특히 대규모 조회)

(2) 불필요한 nested 트랜잭션 (REQUIRES_NEW 등) 사용

2. Spring @Transactional의 동작 원리

일반 @Transactional의 내부 동작

Spring의 @Transactional은 AOP(Aspect-Oriented Programming)를 기반으로 동작한다.
메서드 호출 시 프록시 객체가 중간에 개입해서 트랜잭션 처리를 담당하는 구조이다.

@Service
public class OrderService {
	@Transactional
    public void createOrder(OrderRequest request) {
    	// 비즈니스 로직
    }
}

위 코드에서 실제로 OrderService의 프록시 객체가 생성된다.
프록시는 다음과 같은 순서로 동작한다:

  1. 트랜잭션 시작 : EntityManager를 통해 DB 커넥션 획득 및 트랜잭션 시작
  2. 실제 메서드 실행 : 타겟 객체의 createOrder 메서드 호출
  3. 트랜잭션 커밋/롤백 : 정상 종료 시 커밋, 예외 발생 시 롤백
  4. 커넥션 반환 : DB 커넥션을 커넥션 풀에 반환

주의해야 할 프록시 제약사항

1. 같은 클래스 내부 호출 문제

@Service
public class UserService {
    public void outerMethod() {
        this.innerMethod(); // 프록시를 거치지 않음!
    }
    
    @Transactional
    public void innerMethod() {
        // 트랜잭션이 적용되지 않음
    }
}

this로 호출하면 프록시를 거치지 않아서 @Transactional이 무시된다.
이 경우 별도의 클래스로 분리하거나 self-injection을 사용해야 한다.


2. private 메서드 문제

@Transactional
private void saveData() {} // 작동하지 않음

프록시는 public 메서드만 가로챌 수 있다.
private 메서드에는 @Transactional을 붙여도 의미가 없다.


3. 트랜잭션 전파 옵션

@Transactional은 여러 옵션을 제공하는데, 가장 중요한 게 전파(Propagation) 설정이다.

@Transactional(propagation = Propagation.REQUIRED) // 기본값
public void method1() {
    method2(); // 같은 트랜잭션 사용
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void method2() {
    // 새로운 트랜잭션 생성
}
  • REQUIRED(기본값): 기존 트랜잭션이 있으면 참여, 없으면 새로 생성
  • REQUIRES_NEW: 항상 새 트랜잭션 생성, 기존 트랜잭션은 일시 중단
  • NESTED: 중첩 트랜잭션 생성(savepoint 활용)

트랜잭션 범위가 중요한 이유

1. 데이터 정합성 보장

트랜잭션 범위가 제대로 설정되지 않으면 데이터 불일치가 발생한다.

// 나쁜 예: 트랜잭션 범위가 너무 좁음
public void processOrder(Long orderId) {
    updateOrderStatus(orderId);      // 트랜잭션 1
    decreaseStock(orderId);          // 트랜잭션 2
    createPayment(orderId);          // 트랜잭션 3
}

각 메서드가 독립적인 트랜잭션이면, 재고 감소는 성공했는데 결제 생성이 실패하는 경우 데이터가 꼬인다.

// 좋은 예: 하나의 트랜잭션으로 묶음
@Transactional
public void processOrder(Long orderId) {
    updateOrderStatus(orderId);
    decreaseStock(orderId);
    createPayment(orderId);
    // 모두 성공하거나 모두 실패
}

2. 영속성 컨텍스트 범위와 일치

JPA의 영속성 컨텍스트는 트랜잭션과 생명주기를 같이한다.

@Transactional
public void updateUser(Long userId, String name) {
    User user = userRepository.findById(userId).get();
    user.changeName(name); // dirty checking으로 자동 업데이트
    // 트랜잭션 종료 시 변경사항 자동 반영
}

트랜잭션 범위 내에서 조회한 엔티티는 변경 감지(dirty checking)가 가능하다. 트랜잭션이 없으면 LazyInitializationException이 발생할 수 있다.


3. 성능 최적화 포인트

트랜잭션이 너무 길면 DB 커넥션을 오래 점유해서 성능이 떨어진다.

// 안 좋은 예: 외부 API 호출까지 트랜잭션에 포함
@Transactional
public void processPayment(PaymentRequest request) {
    Payment payment = createPayment(request);
    paymentRepository.save(payment);
    
    // 외부 API 호출 - 3초 소요
    externalPaymentGateway.process(payment);
    
    payment.complete();
} // 3초 이상 DB 커넥션 점유

// 개선: 트랜잭션 범위 최소화
public void processPayment(PaymentRequest request) {
    Payment payment = createAndSavePayment(request);
    
    // 트랜잭션 밖에서 외부 API 호출
    externalPaymentGateway.process(payment);
    
    completePayment(payment.getId());
}

@Transactional
public Payment createAndSavePayment(PaymentRequest request) {
    Payment payment = createPayment(request);
    return paymentRepository.save(payment);
}

@Transactional
public void completePayment(Long paymentId) {
    Payment payment = paymentRepository.findById(paymentId).get();
    payment.complete();
}

4. 격리 수준에 따른 동시성 제어
트랜잭션 격리 수준(Isolation Level)은 동시성과 정합성의 트레이드오프다.

@Transactional(isolation = Isolation.READ_COMMITTED) // 기본값
public void method1() { }

@Transactional(isolation = Isolation.SERIALIZABLE)
public void criticalOperation() {
    // 가장 엄격한 격리, 성능은 낮지만 정합성 보장
}

실무에서는 대부분 READ_COMMITTED를 사용하고, 필요한 경우 비관적/낙관적 락으로 추가 제어한다.


결국 @Transactional의 핵심은 '어디서부터 어디까지를 하나의 작업 단위로 볼 것인가'를 명확히 정의하는 것이다.
이게 제대로 설정되지 않으면 데이터 정합성 문제부터 성능 저하까지 다양한 이슈가 발생한다.
프록시 기반이라는 동작 원리를 이해하면 왜 특정 상황에서 트랜잭션이 작동하지 않는지도 파악할 수 있다.

0개의 댓글