스프링 @Transactional 이해하기

hyezuu·2025년 3월 24일

시작하며

개발을 하다 보면 @Transactional 어노테이션을 자주 사용하게 된다. 단순히 붙이기만 하면 트랜잭션이 적용된다는 건 알지만, 실제로 어떻게 동작하는지, 어떤 상황에서 주의해야 하는지 명확히 아는 개발자는 많지 않다. (나포함) 이 글에서는 Spring의 @Transactional이 어떻게 동작하는지, DB 커넥션과 어떤 관계가 있는지, 개발 시 주의해야 할 점들을 살펴보려 한다.

1. @Transactional의 기본 동작 원리

트랜잭션 시작과 커넥션 획득 시점

@Transactional이 붙은 메서드가 호출되면 다음과 같은 프로세스로 처리된다:

@Service
public class ProductService {

    @Transactional
    public void saveProduct(Product product) {
        // 메서드 로직
        productRepository.save(product);
    }
}
  1. 프록시 객체 생성: 스프링은 @Transactional이 붙은 클래스나 메서드에 대해 프록시 객체를 생성한다. 이 프록시는 AOP(Aspect-Oriented Programming)를 기반으로 동작한다.
  2. 트랜잭션 시작: saveProduct() 메서드가 호출되면, 프록시는 트랜잭션 매니저를 통해 트랜잭션을 시작한다. 이때 TransactionStatus 객체가 생성된다.
  3. 지연된 커넥션 획득: 중요한 점은 이 시점에서는 실제 DB 커넥션을 아직 획득하지 않는다는 것이다. Spring은 지연 연결(lazy connection acquisition) 방식을 사용한다.
  4. 첫 번째 DB 작업 시 커넥션 획득: 메서드 내에서 첫 번째 데이터베이스 작업(예: productRepository.save(product))이 실행될 때 비로소 커넥션 풀에서 커넥션을 획득한다.
  5. 트랜잭션 완료: 메서드가 정상적으로 종료되면 트랜잭션은 커밋되고, 예외가 발생하면 롤백된다. 이후 DB 커넥션은 풀로 반환된다.

이 프로세스의 핵심은 실제 DB 커넥션이 필요한 시점까지 획득을 지연한다는 것이다. 따라서 메서드의 앞부분에 DB와 관련 없는 작업(예: 검증 로직)이 있다면, 그 동안에는 DB 커넥션을 점유하지 않는다.

트랜잭션 전파(Transaction Propagation)

@Transactional 어노테이션에는 여러 속성이 있지만, 그중에서도 propagation 속성은 트랜잭션의 동작 방식을 크게 변화시킬 수 있다.

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveWithNewTransaction() {
    // 항상 새로운 트랜잭션을 시작
}

주요 전파 속성은 다음과 같다:

  • REQUIRED (기본값): 현재 트랜잭션이 있으면 참여하고, 없으면 새로 생성한다.
  • REQUIRES_NEW: 항상 새로운 트랜잭션을 생성한다. 기존 트랜잭션은 일시 중단된다.
  • SUPPORTS: 현재 트랜잭션이 있으면 참여하고, 없으면 트랜잭션 없이 실행한다.
  • NOT_SUPPORTED: 트랜잭션 없이 실행하며, 현재 트랜잭션이 있으면 일시 중단한다.
  • MANDATORY: 트랜잭션이 반드시 있어야 하며, 없으면 예외가 발생한다.
  • NEVER: 트랜잭션 없이 실행하며, 현재 트랜잭션이 있으면 예외가 발생한다.
  • NESTED: 현재 트랜잭션이 있으면 중첩 트랜잭션을 생성하고, 없으면 REQUIRED처럼 동작한다.

REQUIRES_NEW는 DB 커넥션 관리 측면에서 특히 중요하다. 이 속성을 사용하면 메서드마다 별도의 트랜잭션(따라서 별도의 DB 커넥션)을 사용하게 된다.

2. 트랜잭션과 DB 커넥션의 관계

커넥션 획득과 반환 시점

트랜잭션과 DB 커넥션의 생명주기는 밀접하게 연관되어 있다:

@Transactional
public void processData() {
    // 아직 DB 커넥션을 획득하지 않음
    doSomethingWithoutDB();  // DB 작업 없음

    // 이 시점에서 DB 커넥션 획득
    User user = userRepository.findById(1L);

    // 커넥션 유지 중
    doSomethingElse();

    // 같은 커넥션 사용
    userRepository.save(user);

}  // 메서드 종료 시 트랜잭션 커밋 및 커넥션 반환
  1. @Transactional 메서드 시작 시에는 실제 DB 커넥션을 획득하지 않는다.
  2. 첫 번째 DB 작업(userRepository.findById(1L))이 발생하면 커넥션을 획득한다.
  3. 이후 메서드 내에서 발생하는 모든 DB 작업은 동일한 커넥션을 사용한다.
  4. 메서드가 종료되면(정상 종료 또는 예외 발생) 트랜잭션이 커밋 또는 롤백되고 커넥션은 풀로 반환된다.

@Transactional 없이 저장하는 경우

@Transactional 없이 repository의 save 메서드를 호출하면 어떻게 될까?

public void saveWithoutTransaction(Product product, OtherEntity otherEntity) {
    // 트랜잭션 없이 저장
    productRepository.save(product);  // 자체 트랜잭션 생성, 실행 완료 시 즉시 DB에 커밋됨

    // 다른 저장 작업
    otherRepository.save(otherEntity);  // 별도의 트랜잭션으로 처리됨
    
    // 만약 이 시점에서 오류가 발생해도 위의 두 저장 작업은 롤백되지 않음
    // 각각 별도의 트랜잭션으로 이미 커밋되었기 때문
}
  1. @Transactional 없이 save() 메서드를 호출하면, Spring Data JPA는 내부적으로 각 메서드 호출마다 자체적인 트랜잭션을 생성한다.
  2. 각 작업이 완료되면 즉시 DB에 커밋되어 반영된다. 즉, 다른 트랜잭션/세션에서 조회하면 해당 데이터가 바로 보인다.
  3. 각 save/delete 호출마다 별도의 커넥션을 획득하고 반환하므로, 여러 작업 간의 원자성은 보장되지 않는다.
  4. 이로 인해 일부 작업만 성공하고 나머지는 실패하는 부분적 업데이트가 발생할 수 있다.

saveAndFlush() 메서드를 사용하는 경우에도 트랜잭션의 유무에 따라 다르게 동작한다:

  • 트랜잭션 내에서: 즉시 DB에 플러시하지만 커밋은 트랜잭션이 종료될 때까지 지연된다.
  • 트랜잭션 없이: 내부에서 짧은 트랜잭션을 생성하여 저장하고 즉시 커밋한 후 커넥션을 반환한다.

3. 실전에서의 주의사항

외부 API 호출과 트랜잭션

외부 API 호출이 포함된 트랜잭션 메서드는 성능 문제를 일으킬 수 있다:

@Transactional
public ProductDto createProduct(ProductRequest request) {
    // DB 작업 (커넥션 획득)
    Product product = productRepository.save(new Product(request.getName()));

    // 외부 API 호출 (DB 커넥션을 계속 점유)
    StockResponse stockResponse = stockClient.createStock(product.getId());

    return new ProductDto(product, stockResponse);
}

위 코드의 문제점:

  1. 제품을 DB에 저장한 후 외부 API를 호출하는 동안 DB 커넥션을 계속 점유한다.
  2. 외부 API가 느리거나 응답하지 않으면, DB 커넥션이 오랫동안 점유되어 다른 요청을 처리하지 못한다.
  3. 이로 인해 DB 커넥션 풀이 고갈될 수 있으며, 전체 시스템 성능이 저하될 수 있다.

개선된 접근법: @Transactional 어노테이션 제거

public ProductDto createProduct(ProductRequest request) {
    // 1. 제품 저장 (별도 트랜잭션 - 이 시점에 실제 db에 즉시 반영됨)
    Product product = productRepository.save(new Product(request.getName()));

    // 2. 외부 API 호출 (DB 트랜잭션 외부에서)
    StockResponse stockResponse = stockClient.createStock(product.getId());

    return new ProductDto(product, stockResponse);
}

이 접근법의 장점:

  1. DB 작업과 외부 API 호출이 분리되어 DB 커넥션 점유 시간이 최소화된다.
  2. 외부 API 호출이 느려도 이미 DB 트랜잭션은 완료되었으므로 영향이 적다.

하지만 데이터 일관성 측면에서는 주의가 필요하다. 외부 API 호출이 실패하면 데이터 불일치가 발생할 수 있기 때문이다.

트랜잭션 롤백 처리

외부 API 호출과 DB 작업을 분리했을 때 발생할 수 있는 데이터 불일치 문제를 해결하기 위해 보상 트랜잭션을 사용할 수 있다:

public ProductDto createProduct(ProductRequest request) {
    // 1. 제품 저장 (별도 트랜잭션)
    Product product = saveProductInTransaction(request);

    try {
        // 2. 외부 API 호출
        StockResponse stockResponse = stockClient.createStock(product.getId());
        return new ProductDto(product, stockResponse);
    } catch (Exception e) {
        // 3. 외부 API 호출 실패 시 보상 트랜잭션으로 제품 삭제
        deleteProductInTransaction(product.getId());
        throw new ServiceException("재고 생성 실패로 제품 생성이 롤백되었습니다", e);
    }
}
// 별도의 클래스로 사용되어야함. 또는 자기참조
@Transactional
protected Product saveProductInTransaction(ProductRequest request) {
    return productRepository.save(new Product(request.getName()));
}

@Transactional
protected void deleteProductInTransaction(Long productId) {
    productRepository.deleteById(productId);
}

이 접근법은 외부 API 호출이 실패할 경우 이미 저장된 제품을 삭제하는 보상 트랜잭션을 실행하여 데이터 일관성을 유지한다. 하지만 보상 트랜잭션 자체가 실패할 가능성도 존재한다.

트랜잭션 범위와 성능 간의 균형

트랜잭션 범위를 결정할 때는 데이터 일관성과 성능 사이의 균형을 고려해야 한다:

// 방법 1: 모든 작업을 하나의 트랜잭션으로 (일관성 ↑, 성능 ↓)
@Transactional
public void processAllInOneTransaction() {
    // DB 작업 1, 2, 3, ...
    // 외부 API 호출
}

// 방법 2: 작업을 여러 트랜잭션으로 분리 (일관성 ↓, 성능 ↑)
public void processWithSeparateTransactions() {
    // 각 작업에 대해 별도의 @Transactional 메서드 호출
}

선택 기준:

  1. 데이터 일관성이 매우 중요한 경우(예: 금융 거래): 단일 트랜잭션 사용
  2. 성능과 확장성이 중요한 경우: 트랜잭션 분리 및 보상 메커니즘 구현
  3. 마이크로서비스 환경: 사가(Saga) 패턴 등의 분산 트랜잭션 패턴 고려

트랜잭션 확인 방법

개발 중에 트랜잭션이 제대로 적용되고 있는지 확인하려면 다음과 같은 방법을 사용할 수 있다:

import org.springframework.transaction.support.TransactionSynchronizationManager;

public void checkTransaction() {
    boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
    if (isActive) {
        System.out.println("현재 트랜잭션이 활성화되어 있습니다.");
        System.out.println("트랜잭션 이름: " + TransactionSynchronizationManager.getCurrentTransactionName());
    } else {
        System.out.println("현재 트랜잭션이 활성화되어 있지 않습니다.");
    }
}

더 상세한 트랜잭션 로그를 보려면 application.yml에 다음과 같이 설정한다:

logging:
  level:
    org:
      springframework:
        transaction:
          interceptor: TRACE
        jdbc:
          datasource:
            DataSourceTransactionManager: DEBUG
        orm:
          jpa:
            JpaTransactionManager: DEBUG

4. 자주 발생하는 실수와 해결책

내부 메서드 호출과 트랜잭션

같은 클래스 내에서 @Transactional 메서드를 호출할 때 발생하는 문제:

@Service
public class ProductService {

    public void processProduct(Product product) {
        // 트랜잭션이 적용되지 않음
        saveProduct(product);  // 내부 호출은 프록시를 거치지 않음
    }

    @Transactional
    public void saveProduct(Product product) {
        // 트랜잭션 로직
    }
}

해결책:

  1. 서비스 클래스를 분리하여 외부 호출로 만들기 (가장 권장되는 방법)
  2. Self-Injection 사용하기 (Spring 공식적으로 권장하지는 않음)
  3. AopContext 사용하기
// 방법 1: 클래스 분리 (권장)
@Service
public class ProductFacadeService {
    
    private final ProductService productService;
    
    public ProductFacadeService(ProductService productService) {
        this.productService = productService;
    }
    
    public void processProduct(Product product) {
        // 트랜잭션이 제대로 적용됨
        productService.saveProduct(product);  // 외부 호출
    }
}

@Service
public class ProductService {
    
    @Transactional
    public void saveProduct(Product product) {
        // 트랜잭션 로직
    }
}

// 방법 2: Self-Injection (권장하지 않음)
@Service
public class ProductService {

    @Autowired
    private ProductService self;  // Self-Injection

    public void processProduct(Product product) {
        // 트랜잭션이 제대로 적용됨
        self.saveProduct(product);  // 프록시를 통한 호출
    }

    @Transactional
    public void saveProduct(Product product) {
        // 트랜잭션 로직
    }
}

예외 처리와 롤백

모든 예외가 롤백을 트리거하지는 않는다:

@Transactional  // 기본적으로 RuntimeException과 Error만 롤백
public void saveWithRollback() {
    try {
        // DB 작업
    } catch (Exception e) {
        throw new RuntimeException(e);  // 롤백됨
    }
}

@Transactional
public void saveWithoutRollback() throws Exception {
    // DB 작업
    throw new Exception("오류 발생");  // 롤백되지 않음 (Checked Exception)
}

해결책:

@Transactional(rollbackFor = Exception.class)  // 모든 예외에 대해 롤백
public void saveWithRollbackForAllExceptions() throws Exception {
    // DB 작업
}

5. 결론

@Transactional은 강력한 도구지만, 실제 동작 방식을 정확히 이해하고 사용해야 한다. 핵심 포인트를 정리하면:

  1. 트랜잭션이 시작되더라도 실제 DB 커넥션은 첫 번째 DB 작업 시점에 획득된다.
  2. 외부 API 호출이나 시간이 오래 걸리는 작업은 가능한 트랜잭션 외부에서 수행하거나, 작업을 분리하여 DB 커넥션 점유 시간을 최소화해야 한다.
  3. @Transactional이 없는 상태에서 repository 메서드(save, delete 등)를 호출하면 각 메서드마다 자체적인 트랜잭션이 생성되어 즉시 DB에 반영된다.
  4. 트랜잭션을 분리할 때는 데이터 일관성을 위한 추가 메커니즘(보상 트랜잭션 등)을 고려해야 한다.
  5. 내부 메서드 호출, 예외 처리, 마이크로서비스 통신과 같은 특수한 상황에서의 트랜잭션 동작에 주의해야 한다.

결국 트랜잭션 관리는 데이터 일관성과 성능 사이의 균형을 잡는 작업이다. 비즈니스 요구사항, 시스템 아키텍처, 그리고 성능 특성을 고려하여 최적의 접근법을 선택해야 한다. 무턱대고 높은 일관성을 기대하다간 병목이 발생할지도 모른다. 나도 여전히 어렵지만 ! 경험을 쌓아나가며 적절한 타협점을 찾아나가보자 (정답은 없으니까)

profile
기록

0개의 댓글