[Spring] @Transactional 파헤치기

cup-wan·2025년 2월 9일
4

Transaction 신경 쓰시나요?

데이터베이스를 공부하면서 다들 트랜잭션에 대해 들어보셨을겁니다. 엄청 중요하대서 열심히 배웠지만 실제 개발에서는 Service 클래스에 @Transactional 로 트랜잭션을 “관리” 중이실거에요. 저도 마찬가지입니다.

그런데 개발을 하다 보면 문득 이런 의문이 들곤 합니다.
"트랜잭션이 자동으로 적용된다는 점은 편리하지만, 내부적으로 어떻게 동작하는지 정확히 알지 못한 채 사용해도 괜찮을까?"

만약 계좌 관리 서비스를 개발할 때 트랜잭션의 원리를 제대로 모르고 개발해 다음과 같은 상황이 발생한다면??? 아주 아찔합니다.

그래서 오늘은 Spring의 @Transactional 에 대해 자세히 알아보겠습니다.

Transaction

우선 Transaction에 대해 다시 한번 살펴봅시다.

Transaction은 DB의 논리적 작업 단위 입니다. DB의 여러 쿼리가 하나의 Transaction으로 묶여 실행되며 전체가 성공해야 DB에 반영됩니다. 하나라도 실패하면 전체 작업이 취소(롤백) 됩니다.

이런 Transaction은 ACID 원칙을 만족해야합니다.

  1. Atomicity (원자성)
  • 트랜잭션 내의 모든 연산이 완벽히 수행되거나 전혀 수행되지 않음
  • 중간에 오류가 발생하면 이전 작업도 모두 취소(rollback)
  1. Consistency (일관성)
  • 트랜잭션 실행 전후에 데이터의 무결성 유지
  • ex) 송금 트랜잭션이 완료된 후에도 총 잔액이 변하지 않음 (A가 100만원 송금, B가 100만원 입금 받으면 A+B는 변하지 않는다는 의미)
  1. Isolation (격리성)
  • 트랜잭션이 실행 중인 동안 다른 트랜잭션의 간섭을 받지 않음
  • 데이터의 일관성을 유지하기 위해 여러 수준의 격리 수준이 존재
  1. Durability (지속성)
  • 트랜잭션이 성공적으로 완료되면 그 결과는 영구적으로 저장
  • 시스템이 충돌하더라도 데이터가 유실되지 않음

Spring의 Transaction

Spring 이전의 Transaction

Spring 공식 docs에서는 Spring의 Transanction이 왜 좋은지 이전에 사용하던 방식을 통해 반증하고 있습니다.

  • 순수 JDBC로 트랜잭션 관리
Connection conn = dataSource.getConnection()
	try(connection) {
    	connection.setAutoCommit(false); // 오토커밋 X
        // 로직 수행
        connection.commit(); // 수동 커밋
    }catch(SQLException){
    	connection.rollback(); // 에러 발생 시 롤백
    }finally{
    	connection.close();
}

구와악 나의 Service가!!!!!!!!!!!!!!!! 라는 생각이 드는 코드입니다.
문제점은 다음과 같은데

  1. Connection 객체 생성 후DAO 호출할 때 마다 사용
// 이런 느낌?
orderRepository.createOrder(connection, order);
paymentRepository.chargePayment(connection, orderId);
  1. JDBC에 의존적 ➡️ MyBatis, JPA, Hibernate 등이 되면?

  2. 서비스 로직에 DB 접근 코드가 섞임

이런 문제를 Spring의 Transaction이 해결해줍니다.

Spring Transaction 전략

1️⃣ Connection 객체 생성 후DAO 호출할 때 마다 사용

트랜잭션 동기화로 해결

  1. DataSourceTransactionManager가 트랜잭션 시작하면서 Connection 생성
  2. 생성된 ConnectionTransactionSynchronizationManager에 등록 후 스레드별로 공유
  3. DAO에서 같은 Connection 사용 및 트랜잭션 완료 시 해당 리소스 정리

2️⃣ JDBC에 의존적 ➡️ MyBatis, JPA, Hibernate 등이 되면?

트랜잭션 추상화로 기술 종속성 해결


PlatformTransactionManager.java

public interface PlatformTransactionManager extends TransactionManager {

	TransactionStatus getTransaction(TransactionDefinition definition) throws TransactionException;

	void commit(TransactionStatus status) throws TransactionException;

	void rollback(TransactionStatus status) throws TransactionException;
}

Spring은 Transaction을 다양한 방식으로 관리할 수 있도록 추상화를 통해 구현하였습니다. JPA를 자주 사용했기에 EntityManager를 알고 있었는데 JDBC, JTA 등 다른 트랜잭션 관리 전략을 사용 시 표와 같이 다양한 방법이 존재합니다.

전략설명예제
JDBC 트랜잭션 관리 (DataSourceTransactionManager)기본적인 JDBC 환경에서 트랜잭션을 관리Connection 객체를 활용한 수동 트랜잭션 처리
JTA (Java Transaction API) 트랜잭션 관리 (JtaTransactionManager)여러 데이터 소스를 사용하는 분산 트랜잭션 관리XA 트랜잭션 (2PC) 사용
Hibernate 트랜잭션 관리 (HibernateTransactionManager)Hibernate를 직접 사용할 때 트랜잭션 관리SessionFactory 활용
JPA 트랜잭션 관리 (JpaTransactionManager)Spring Data JPA와 함께 사용되는 트랜잭션 관리EntityManager 사용
선언적 트랜잭션 관리 (@Transactional)AOP 기반으로 선언적으로 트랜잭션을 관리@Transactional 적용

3️⃣ 서비스 로직에 DB 접근 코드가 섞임
이 자식을 해결하기 위해 AOP를 활용한 @Transactional이 나오게 됩니다.

선언적 Transaction vs. 프로그래밍 Transaction

선언적 Transaction

  • @Transactional 어노테이션을 사용하여 AOP 기반으로 트랜잭션을 자동 관리하는 방식
  • 트랜잭션이 자동으로 시작되고, 메서드 실행 후 커밋 또는 롤백 됨
  • 코드가 간결하고 유지보수가 쉬움
@Service
public class OrderService {

    @Transactional
    public void processOrder(Long orderId) {
        Order order = orderRepository.findById(orderId);
        order.updateStatus("SHIPPED");
        paymentService.processPayment(order.getPaymentId());
    }
}

흔히 사용하는 방식으로 @Transactional 어노테이션 선언만으로 트랜잭션 관리가 가능해집니다.


AOP를 활용한 @Transactional 동작 원리

  1. Caller (호출자)
  • @Transactional이 적용된 메서드를 호출하면, 해당 요청은 바로 비즈니스 로직으로 전달 ❌
  • 대신, AOP Proxy 가 먼저 요청을 가로챔
  1. AOP Proxy (트랜잭션 프록시)
  • Spring은 AOP를 활용한 동적 프록시(Dynamic Proxy 또는 CGLIB)를 생성하여 메서드 실행을 감싸버림
  • 이 프록시는 TransactionAdvisor 로 요청을 전달
  1. Transaction Advisor (트랜잭션 관리자 역할 수행)
  • TransactionInterceptor 가 동작하여 트랜잭션을 시작
  • 트랜잭션이 이미 존재하면 기존 트랜잭션을 재사용, 새로운 트랜잭션이 필요하면 트랜잭션을 생성
  1. Custom Advisor(s) (사용자 정의 인터셉터, 선택 사항)
  • @Transactional 이외에 사용자가 정의한 AOP 인터셉터(Custom Interceptor)가 있을 경우 추가적으로 실행 가능
  • 예를 들어, 로깅, 보안, 성능 모니터링 같은 기능을 수행
  1. Target Method (실제 비즈니스 로직 실행)
  • 트랜잭션이 활성화된 상태에서 비즈니스 로직 실행
  • 메서드 실행이 완료되면 트랜잭션을 종료, 예외가 발생하지 않으면 → 트랜잭션을 커밋 (Commit) / 예외가 발생하면 → 트랜잭션을 롤백 (Rollback)
  1. 컨트롤이 역방향으로 이동하여 호출자에게 반환
  • 실행 결과가 AOP Proxy → Caller 로 반환

프로그래밍 Transaction

  • TransactionTemplate 또는 PlatformTransactionManager를 직접 사용하여 트랜잭션을 수동 관리하는 방식
  • 특정한 트랜잭션 범위를 명확하게 지정할 때 유용
@Service
public class OrderService {

    private final PlatformTransactionManager transactionManager;

    public OrderService(PlatformTransactionManager transactionManager) {
        this.transactionManager = transactionManager;
    }

    public void processOrder(Long orderId) {
        TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
        try {
            Order order = orderRepository.findById(orderId);
            order.updateStatus("SHIPPED");
            paymentService.processPayment(order.getPaymentId());
            transactionManager.commit(status);  // 성공 시 commit
        } catch (Exception e) {
            transactionManager.rollback(status);  // 예외 발생 시 rollback
            throw e;
        }
    }
}

@Transactional

기존 코드에서 단 하나의 어노테이션으로 만들어지기까지의 과정을 살펴봤습니다. 얘기를 하다보니 스프링 어노테이션 개쩐다는 생각뿐인데 아무 생각 없이 사용해도 되는걸까요? 이제 @Transactional을 더 자세히 알아보겠습니다.

전파 propagation

Transaction Propagation
현재 실행 중인 트랜잭션이 있을 때, 새로운 트랜잭션을 생성할지, 기존 트랜잭션을 사용할지를 결정하는 정책

스프링의 @Transactional의 장점 중 하나는 트랜잭션의 경계를 설정할 수 있다는 것 입니다. 하지만 기존 진행 중인 트랜잭션이 있을 때 추가 트랜잭션 진행을 어떻게 할까요?

이런 이미지가 되겠죠? 이미 진행 중인 트랜잭션이 있는데 새로운 트랜잭션을 만나는 경우입니다. 이를 코드로 보면 아래와 같습니다.

@Service
public class OrderService {

    @Transactional
    public void placeOrder(Long orderId) {
        orderRepository.createOrder(orderId);
        paymentService.processPayment(orderId); // 내부 트랜잭션 호출
    }
}

@Service
public class PaymentService {

    @Transactional(propagation = Propagation.REQUIRED) 
    public void processPayment(Long orderId) {
        paymentRepository.charge(orderId);
    }
}

placeOrder()를 호출하면 트랜잭션이 하나 (외부) 실행되는 중에 paymentService()의 트랜잭션 (내부)을 하나 더 실행해야합니다.
이런 경우를 다루기 위해 스프링은 논리 트랜잭션이라는 개념을 도입합니다.

이러면 트랜잭션 범위가 2개라 개별 논리 트랜잭션이 있지만 실제로는 1개의 물리 트랜잭션(Connection 객체를 하나만 사용)을 사용하게 되는것이죠.
이 개념의 도입으로 내부 트랜잭션 로직에 대해 좀 더 단순하게 다가갈 수 있게 됩니다.

  • 모든 논리 트랜잭션이 커밋되어야 물리 트랜잭션 커밋
  • 하나의 논리 트랜잭션이라도 롤백된다면 물리 트랜잭션 롤백

이제 Spring이 제공하는 Propagation 속성 종류를 알아보겠습니다.

전파 레벨설명
REQUIRED (기본값)기존 트랜잭션이 있으면 참여, 없으면 새 트랜잭션 생성
REQUIRES_NEW항상 새로운 트랜잭션을 생성, 기존 트랜잭션은 보류
NESTED부모 트랜잭션 내에서 중첩된 트랜잭션 실행 (savepoint 사용)
SUPPORTS트랜잭션이 있으면 참여, 없으면 트랜잭션 없이 실행
NOT_SUPPORTED트랜잭션 없이 실행, 기존 트랜잭션이 있으면 일시 정지
MANDATORY기존 트랜잭션이 반드시 있어야 실행 가능, 없으면 예외 발생
NEVER트랜잭션이 있으면 예외 발생, 없으면 트랜잭션 없이 실행

7가지나..있다! 각 레벨을 예제를 통해 살펴봅시다.

1. REQUIRED : 기본값

  • 기존 트랜잭션 ⭕ : 참여
  • 기존 트랜잭션 ❌ : 새로 생성
@Service
public class OrderService {

    @Transactional(propagation = Propagation.REQUIRED) // 기본값
    public void placeOrder(Long orderId) {
        paymentService.processPayment(orderId);
    }
}

@Service
public class PaymentService {

    @Transactional(propagation = Propagation.REQUIRED) 
    public void processPayment(Long orderId) {
        paymentRepository.charge(orderId);
        throw new RuntimeException("결제 실패!"); // 예외 발생
    }
}

➡️ placeOrder() 실행 시 트랜잭션 시작
➡️ processPayment()도 같은 트랜잭션을 사용, 예외 발생 시 전체 롤백

2. REQUIRES_NEW

  • 기존 트랜잭션 ⭕ : 일시 정지
  • 기존 트랜잭션 ❌ : 항상 새로 생성
@Service
public class OrderService {

    @Transactional
    public void placeOrder(Long orderId) {
        orderRepository.createOrder(orderId);
        paymentService.processPayment(orderId); // 새로운 트랜잭션 실행
    }
}

@Service
public class PaymentService {

    @Transactional(propagation = Propagation.REQUIRES_NEW) 
    public void processPayment(Long orderId) {
        paymentRepository.charge(orderId);
        throw new RuntimeException("결제 실패!"); // 예외 발생
    }
}

➡️ placeOrder() 실행 시 트랜잭션 시작
➡️ processPayment() 새로 트랜잭션 시작
➡️ processPayment() 예외 발생 시 이 트랜잭션만 롤백

데이터 불일치가 발생할 수 있습니다(예외 발생하면 그 트랜잭션만 롤백되고 기존의 트랜잭션(placeOrder())은 커밋되기 때문). 그럼 왜 사용하느냐? 결제 시스템처럼 독립적인 트랜잭션이 필요하기 때문에 사용됩니다.

3. NESTED

  • 기존 트랜잭션 ⭕ : 새로 생성
  • 기존 트랜잭션 ❌ : 중첩 트랜잭션 생성
@Service
public class OrderService {

    @Transactional
    public void placeOrder(Long orderId) {
        orderRepository.createOrder(orderId);
        paymentService.processPayment(orderId);
    }
}

@Service
public class PaymentService {

    @Transactional(propagation = Propagation.NESTED)
    public void processPayment(Long orderId) {
        paymentRepository.charge(orderId);
        throw new RuntimeException("결제 실패!"); // 예외 발생
    }
}

➡️ placeOrder() 부모 트랜잭션 시작
➡️ processPayment() 부모 트랜잭션 내에서 중첩 트랜잭션 실행 : savepoint 생성
➡️ processPayment() 예외 발생 시 이 트랜잭션만 롤백

부모 트랜잭션이 롤백되지 않도록 특정 작업만 롤백하고 싶을 때 사용합니다.

4. SUPPORTS

  • 기존 트랜잭션 ⭕ : 참여
  • 기존 트랜잭션 ❌ : 없이 진행
@Service
public class OrderService {

    @Transactional
    public void placeOrder(Long orderId) {
        notificationService.sendEmail(orderId); // 트랜잭션 없이 실행
    }
}

@Service
public class NotificationService {

    @Transactional(propagation = Propagation.SUPPORTS)
    public void sendEmail(Long orderId) {
        emailSender.send(orderId);
    }
}

➡️ placeOrder() 실행 시 트랜잭션 시작
➡️ processPayment() 기존에 트랜잭션 있으므로 참여 (만약 placeOrder가 없었어도 (== 트랜잭션이 없이) 실행)

알림 시스템처럼 트랜잭션 여부와 상관없이 실행될 서비스에 사용합니다.

5. NOT_SUPPORTED

  • 기존 트랜잭션 ⭕ : 기존 트랜잭션 일시 정지시키고 트랜잭션 없이 진행
  • 기존 트랜잭션 ❌ : 없이 진행
@Service
public class OrderService {

    @Transactional
    public void placeOrder(Long orderId) {
        reportService.generateReport(orderId); // 트랜잭션 없이 실행
    }
}

@Service
public class ReportService {

    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    public void generateReport(Long orderId) {
        reportGenerator.generate(orderId);
    }
}

➡️ placeOrder() 실행 시 트랜잭션 시작
➡️ generateReport() 실행 시 트랜잭션 일시 정지 후 트랜잭션 없이 실행

트랜잭션이 필요 없는 배치 작업, 외부 API 호출 등에 사용

6. MANDATORY

  • 기존 트랜잭션 ⭕ : 참여
  • 기존 트랜잭션 ❌ : IllegalTransactionStateException 발생
@Service
public class OrderService {

    @Transactional
    public void placeOrder(Long orderId) {
        loggingService.logOrder(orderId); // 트랜잭션을 유지해야 실행 가능
    }
}

@Service
public class LoggingService {

    @Transactional(propagation = Propagation.MANDATORY)
    public void logOrder(Long orderId) {
        logRepository.save(orderId);
    }
}

➡️ placeOrder() 실행 시 트랜잭션 시작
➡️ logOrder() 실행 시 기존 트랜잭션 있으면 정상 실행, 트랜잭션 없이 실행되면 예외 발생

반드시 트랜잭션 내에서 실행해야 하는 로직에 적합

7. NEVER

  • 기존 트랜잭션 ⭕ : IllegalTransactionStateException 발생
  • 기존 트랜잭션 ❌ : 트랜잭션 없이 진행
@Service
public class ExternalService {

    @Transactional(propagation = Propagation.NEVER)
    public void callExternalApi() {
        apiClient.call();
    }
}

➡️ callExternalApi() 실행 시 트랜잭션 없어야 실행 가능
➡️ callExternalApi() 실행 시 트랜잭션 있으면 예외 발생

외부 API 호출처럼 트랜잭션이 있으면 안될때 사용

격리 수준 isolation

Transaction Isolation
트랜잭션이 동시 실행될 때 데이터 정합성을 보장하기 위해 특정 수준에서 고립 시키는 방법

DB를 공부하면서 한번쯤 마주쳤을 그 "격리 수준" 맞습니다.
트랜잭션이 동시에 실행될 때 어떤 데이터를 읽고 쓸 수 있는지를 제어하는 것을 격리 수준을 설정해준다 말합니다.
격리 수준과 데이터 정합성은 비례하고, 성능과는 반비례합니다.
이 내용은 기회가 된다면 다른 글에서 다루겠습니다

Spring에서 제공하는 @Transactional의 격리 수준은 다음과 같습니다.

격리 수준설명문제 해결
DEFAULT데이터베이스 기본 격리 수준 사용DB 설정에 따름
READ_UNCOMMITTED커밋되지 않은 데이터 읽기 허용Dirty Read 방지 X
READ_COMMITTED커밋된 데이터만 읽기 허용Dirty Read 방지 O
REPEATABLE_READ트랜잭션 내 동일한 조회 결과 보장Non-repeatable Read 방지 O
SERIALIZABLE완전한 직렬화 (동시 실행 차단)모든 문제 방지 O

1. READ_UNCOMMITTED

  • 트랜잭션이 커밋되지 않은 데이터도 읽기 가능
  • Dirty Read, Non-repeatable Read, Phantom Read 발생 가능
  • 성능 ⬆️, 데이터 정합성 ⬇️
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
public void readUncommittedExample() {
    List<Order> orders = orderRepository.findAll();
}

💣시나리오
1. A 트랜잭션이 특정 행 업데이트했지만 커밋 X
2. B 트랜잭션이 해당 행 조회 시 A가 커밋하기 전 데이터 읽음
3. A 트랜잭션 롤백 시 B가 읽은 데이터는 잘못된 값 (Dirty Read)

데이터 정합성 중요한 서비스에서 사용 절대 금지

2. READ_COMMITTED

  • 커밋된 데이터만 읽기 가능
  • Dirty Read 방지
  • Non-repeatable Read, Phantom Read 발생 가능
@Transactional(isolation = Isolation.READ_COMMITTED)
public void readCommittedExample() {
    List<Order> orders = orderRepository.findAll();
}

💣시나리오
1. A 트랜잭션이 특정 행 업데이트, 커밋 전까지는 다른 트랜잭션에서 조회 불가능
2. B 트랜잭션이 해당 데이터 조회 시 A 트랜잭션이 커밋한 데이터만 읽기 가능
3. A 트랜잭션이 커밋 후 다시 조회하면 값이 변경될 수 있음 (Non-repeatable Read)

대부분 RDBMS의 기본값 (Oracle, PostgreSQL, SQL Server...)

3. REPEATABLE_READ

  • 트랜잭션 내에서 동일한 조회 결과 보장
  • Phantom Read 발생 가능
  • MySQL의 기본값
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void repeatableReadExample() {
    List<Order> orders = orderRepository.findAll();
}

💣시나리오
1. A 트랜잭션이 특정 데이터 조회
2. B 트랜잭션이 해당 데이터 변경 후 커밋
3. A 트랜잭션이 다시 조회 시 변경된 데이터 반영 X (Non-Repeatable Read 방지)
4. 하지만, B 트랜잭션이 새로운 데이터를 INSERT 하면 A 트랜잭션에서 조회 시 반영 가능 (Phantom Read 발생)

같은 데이터를 여러 번 조회해도 일관된 결과 보장

4. SERIALIZABLE

  • 모든 데이터 정합성 문제 해결
  • 트랜잭션 순차적으로 실행 (동시 실행 XXXXXXX)
  • 성능 레전드 구려짐, DB에 락을 걸기에 동시 처리 힘들어짐
@Transactional(isolation = Isolation.SERIALIZABLE)
public void serializableExample() {
    List<Order> orders = orderRepository.findAll();
}

💣시나리오
1. A 트랜잭션 데이터 조회할 때 B 트랜잭션이 동일 데이터 NSERT/UPDATE/DELETE 불가능
2. A 트랜잭션 완료 후 B 트랜잭션 실행 가능

강력한 격리 수준, 미쳐버린 성능 저하
➡️ 실무에서 사용 X

한계점

여기까지 달려오신 모든 분들 고생하셨습니다. 이제 진짜 찐막 @Transactional의 한계에 대해 알아봅시다!

같은 클래스 내부의 @Transactional

@Service
public class OrderService {

    @Transactional
    public void placeOrder(Long orderId) {
        orderRepository.createOrder(orderId);
        processPayment(orderId); // 내부 호출
    }

    @Transactional
    public void processPayment(Long orderId) {
        paymentRepository.charge(orderId);
    }
}

바로 코드부터 봅시다.
1. placeOrder() 호출 : 트랜잭션 시작
2. processPayment() 내부에서 직접 호출
3. processPayment()는 기존 트랜잭션을 사용 ➡️ WHY????
이 코드에서 왜 내부 호출된 processPayment@Transactional이 무시될까요?
➡️AOP 기반의 어노테이션
@Transactional은 AOP 기반으로 동작하기 때문에 프록시 객체를 통해 관리됩니다. 하지만 같은 클래스 내부에서 메서드를 직접 호출 시(self-invocation) 프록시를 거치지 않기 때문에 @Transactional이 적용되지 않습니다. (내부에서 this.processPayment로 불러진다 생각하면 이해하기 편합니다!)
이 문제를 어떻게 해결할 수 있을까요?

  1. Spring 컨테이너에 셀프 주입받아 사용하기
@Service
public class OrderService {

    private final OrderService self;

    // 생성자 주입
    public OrderService(OrderService self) {
        this.self = self;
    }

    @Transactional
    public void placeOrder(Long orderId) {
        orderRepository.createOrder(orderId);
        self.processPayment(orderId); // 자기 자신을 통해 호출
    }

    @Transactional
    public void processPayment(Long orderId) {
        paymentRepository.charge(orderId);
    }
}

이제 processPayment()self 객체를 통해 호출되고, 그 과정에서 프록시를 거치기 때문에 @Transactional이 정상 적용됩니다.
하지만 순환 참조 문제가 발생할 가능성이 있고, 코드 가독성을 매우....엄청....매우엄청많이....떨어뜨립니다.

  1. 메서드 분리
// 기존 서비스
@Service
public class OrderService {

    private final PaymentService paymentService;

    public OrderService(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    @Transactional
    public void placeOrder(Long orderId) {
        orderRepository.createOrder(orderId);
        paymentService.processPayment(orderId); // 다른 클래스의 메서드를 호출
    }
}

// 분리한 서비스
@Service
public class PaymentService {

    @Transactional
    public void processPayment(Long orderId) {
        paymentRepository.charge(orderId);
    }
}

이제 processPayment()는 인스턴스를 통해 호출되기 때문에 프록시를 거쳐 @Transactional이 정상 적용됩니다.

  1. AspectJ의 Weaving 사용하기
    바이트 코드를 조작하는 방법으로 해결이 가능합니다.
    기존 코드를 수정하지 않아도 된다는 장점이 있지만....!
    Lombok 같이 바이트 코드를 조작하는 다른 라이브러리와 충돌할 가능성이 매우 높아 권장하지 않습니다.

Checked Exception 자동 롤백 불가능


자바에서는 예외 처리를 크게 Checked Exception과 Unchecked Exception으로 나누는데 @Transactional은 Unckecked Exception만 자동으로 롤백 합니다.

@Service
public class PaymentService {

    @Transactional
    public void processPayment(Long orderId) throws Exception { // Checked Exception
        paymentRepository.charge(orderId);
        throw new Exception("결제 실패!"); // ❌ 롤백되지 않음
    }
}

이 코드에서 Exception은 Checked Exception이라 트랜잭션 중 예외가 발생해도 롤백되지 않습니다!!!! why? 과거의 스프링 트랜잭션 관리 정책이었던 EJB의 정책을 그대로 답습했기 때문....
하지만 물론 해결책은 있습니다! 속성 중 rollbackFor = 예외.class를 작성해주면 해결할 수 있습니다.

@Service
public class PaymentService {

    @Transactional(rollbackFor = Exception.class)
    public void processPayment(Long orderId) throws Exception {
        paymentRepository.charge(orderId);
        throw new Exception("결제 실패!"); // ✅ 롤백됨
    }
}

private,protected 사용 불가능

같은 클래스 안에서 내부 메서드 호출 시 @Transactional이 적용 안되는 것과 똑같은 예제입니다.
CGLIB은 상속 기반이기 때문에 private, protected 메서드는 AOP에 적용되지 않습니다 ➡️ @Transactional 적용이 안됨

트랜잭션 도중 커밋해야 하는 경우

@Service
public class OrderService {

    @Transactional
    public void placeOrder(Long orderId) {
        orderRepository.createOrder(orderId);
        // 여기서 DB에 즉시 반영하고 싶은 경우 불가능
        paymentService.processPayment(orderId);
    }
}

트랜잭션은 시작 후 메서드 종료될 때 트랜잭션이 커밋 or 롤백 되기 때문에 중간에 커밋되지 않습니다. 이런 경우 JPA에서는 flush를 사용합니다. ➡️ 관련 내용은 영속성 컨텍스트를 참고해주세요!

@Transactional
public void placeOrder(Long orderId) {
    orderRepository.createOrder(orderId);
    entityManager.flush(); // 즉시 반영
    paymentService.processPayment(orderId);
}

readOnly=true 의 함정

@Transactional(readOnly = true)
public void updateOrder(Long orderId) {
    Order order = orderRepository.findById(orderId);
    order.setStatus("SHIPPED"); // 변경됨
}

@Transactional의 속성 중 readOnly 속성을 사용하면 트랜잭션을 읽기 전용으로 설정할 수 있지만 JPA에서는 일부 상황에서 데이터 변경이 가능합니다.
그 이유는 영속성 컨텍스트가 변경을 감지하기 때문입니다.

테스트 코드에서 @Transactional 사용

엄청..엄청난 분들이 논의를 하신 내용입니다. 토비님과 재민님의 토론 내용인데 매우 재밌어요. 궁금하신 분들은 한번씩 찾아보세요!
후에 이 글을 다시 읽을 때 제 의견을 살짝 첨언해보겠습니다.
제미니의 개발실무 - 테스트에서 @Transactional 을 사용해야 할까?
토비님 Facebook 게시글
테스트 데이터 초기화에 대한 다른 분 - 향로님 의 생각

마무리

뭔가 엄청 긴 글을 작성하게 되었는데요? @Transactional에 대해 많은 지식을 얻어가시길 바랍니다. 저도 글 쓰면서 엄청 많이 배웠어요. AOP 관련 공부를 최근에 해서 이해하는데 많은 도움이 됐습니다.
스프링 진짜 잘만들었네요. 선배 개발자님들은 왤케 멋질까.
그럼 긴 글 읽어주셔서 감사합니다.

출처

Spring Docs
Propagation
Isolation

profile
아무것도 안해서 유죄 판결 받음

1개의 댓글

comment-user-thumbnail
2025년 2월 15일

글 잘읽었습니다. Transactional 알고 쓰는 것과 모르고 쓰는 건 정말 차이가 많이 나는군요.
이제 의미 없이 Transactional 남발 보다는 생각하고 써보겠습니다.

답글 달기