프레임워크/스프링

@Transactional의 동작 원리: 트랜잭션은 어떻게 시작되고 롤백될까?

eodevelop 2025. 12. 26. 22:42
반응형

Spring을 사용하면서 @Transactional만큼 자주 쓰이면서도, 정확한 동작 원리는 모르고 쓰는 어노테이션도 드물 것 같습니다. "메소드에 붙이면 트랜잭션이 알아서 된다"는 건 알겠는데, 도대체 어떻게 알아서 되는 걸까요?

이번 글에서는 @Transactional이 AOP를 통해 어떻게 동작하는지, 트랜잭션이 언제 시작되고 언제 롤백되는지 초보자도 이해할 수 있도록 풀어보겠습니다.


트랜잭션이란?

본격적인 설명에 앞서, 트랜잭션이 뭔지 간단히 짚고 가겠습니다.

트랜잭션(Transaction)은 "더 이상 쪼갤 수 없는 작업의 단위"입니다. 은행 송금을 예로 들면:

1. A 계좌에서 10만원 출금
2. B 계좌에 10만원 입금

 

이 두 작업은 반드시 함께 성공하거나, 함께 실패해야 합니다. 1번만 성공하고 2번이 실패하면? A의 돈은 사라지고 B는 돈을 받지 못하는 끔찍한 상황이 발생합니다.

트랜잭션은 이런 상황을 방지합니다:

  • 모두 성공하면: 변경사항을 확정합니다 (커밋, Commit)
  • 하나라도 실패하면: 모든 변경을 취소합니다 (롤백, Rollback)

@Transactional 없이 트랜잭션 처리하기

Spring 없이 JDBC로 트랜잭션을 직접 처리하면 어떻게 될까요?

public void transfer(Long fromId, Long toId, int amount) {
    Connection conn = null;
    try {
        conn = dataSource.getConnection();
        conn.setAutoCommit(false);  // 트랜잭션 시작

        // 비즈니스 로직
        accountDao.withdraw(conn, fromId, amount);
        accountDao.deposit(conn, toId, amount);

        conn.commit();  // 성공 시 커밋
    } catch (Exception e) {
        if (conn != null) {
            conn.rollback();  // 실패 시 롤백
        }
        throw e;
    } finally {
        if (conn != null) {
            conn.setAutoCommit(true);
            conn.close();
        }
    }
}

 

보이시나요?

비즈니스 로직은 딱 2줄인데, 트랜잭션 관리 코드가 훨씬 많습니다. 이런 코드가 모든 서비스 메소드에 반복된다고 생각해보세요. 끔찍합니다.


@Transactional의 마법

이제 @Transactional을 사용하면 어떻게 되는지 보겠습니다.

@Service
public class AccountService {

    @Transactional
    public void transfer(Long fromId, Long toId, int amount) {
        accountRepository.withdraw(fromId, amount);
        accountRepository.deposit(toId, amount);
    }
}

 

어노테이션 하나로 위의 복잡한 트랜잭션 코드가 모두 사라졌습니다. 그런데 어떻게 이게 가능한 걸까요?


AOP: @Transactional의 비밀

정답은 AOP(Aspect-Oriented Programming, 관점 지향 프로그래밍)입니다.

AOP에 대해 간단히 설명하자면, "핵심 비즈니스 로직과 부가 기능을 분리하는 프로그래밍 기법"입니다. 트랜잭션 관리, 로깅, 보안 체크 같은 공통 관심사를 비즈니스 코드에서 분리해서 관리할 수 있게 해줍니다.

프록시 패턴

Spring의 @Transactional프록시 패턴을 사용합니다. 프록시가 뭘까요?

비유하자면, 프록시는 비서와 같습니다. 사장님(실제 객체)을 만나려면 비서(프록시)를 먼저 거쳐야 합니다. 비서는 미팅 전에 준비 작업을 하고, 미팅 후에 정리 작업을 합니다.

[클라이언트] → [프록시(비서)] → [실제 객체(사장님)]
                  ↑
         트랜잭션 시작/종료 처리

Spring이 프록시를 만드는 과정

Spring 컨테이너가 시작될 때 다음과 같은 일이 벌어집니다:

1. AccountService 빈을 생성한다
2. @Transactional이 붙은 메소드가 있는지 확인한다
3. 있다면, AccountService를 감싸는 프록시 객체를 생성한다
4. 다른 빈들에게는 실제 AccountService 대신 프록시를 주입한다

 

그림으로 보면 이렇습니다:

// 우리가 생각하는 구조
Controller → AccountService

// 실제 구조
Controller → AccountService$$Proxy → AccountService
                    ↑
              여기서 트랜잭션 처리

트랜잭션 처리 흐름 상세 분석

@Transactional이 붙은 메소드가 호출되면 실제로 어떤 일이 일어나는지 단계별로 살펴보겠습니다.

1단계: 프록시가 호출을 가로챈다

// Controller에서 호출
accountService.transfer(1L, 2L, 10000);

 

이 호출은 실제 AccountService가 아닌 프록시 객체로 전달됩니다.

2단계: 트랜잭션 시작

프록시는 실제 메소드를 호출하기 전에 트랜잭션을 시작합니다.

// 프록시 내부 (의사 코드)
public void transfer(Long fromId, Long toId, int amount) {
    // 1. 트랜잭션 시작
    TransactionStatus status = transactionManager.getTransaction(definition);

    try {
        // 2. 실제 메소드 호출
        target.transfer(fromId, toId, amount);

        // 3. 성공 시 커밋
        transactionManager.commit(status);
    } catch (RuntimeException e) {
        // 4. 예외 발생 시 롤백
        transactionManager.rollback(status);
        throw e;
    }
}

3단계: 실제 비즈니스 로직 실행

프록시가 실제 AccountServicetransfer 메소드를 호출합니다.

// 실제 AccountService
public void transfer(Long fromId, Long toId, int amount) {
    accountRepository.withdraw(fromId, amount);
    accountRepository.deposit(toId, amount);
}

4단계: 커밋 또는 롤백

  • 정상 완료: 프록시가 commit()을 호출하여 변경사항을 확정합니다
  • 예외 발생: 프록시가 rollback()을 호출하여 모든 변경을 취소합니다

롤백은 언제 일어날까?

여기서 중요한 포인트가 있습니다. @Transactional모든 예외에 대해 롤백하지 않습니다.

기본 롤백 규칙

@Transactional
public void doSomething() {
    // RuntimeException 또는 Error → 롤백 O
    // Checked Exception → 롤백 X (커밋됨!)
}

 

기본적으로:

  • RuntimeException(Unchecked Exception): 롤백됨
  • Checked Exception: 롤백되지 않음 (커밋됨)

왜 이렇게 설계했을까요? Checked Exception은 "예상 가능한 예외"로, 비즈니스 로직에서 정상적으로 처리할 수 있다고 간주하기 때문입니다.

롤백 규칙 변경하기

모든 예외에 대해 롤백하고 싶다면:

@Transactional(rollbackFor = Exception.class)
public void doSomething() throws IOException {
    // 이제 IOException이 발생해도 롤백됨
}

 

특정 예외는 롤백하지 않으려면:

@Transactional(noRollbackFor = CustomBusinessException.class)
public void doSomething() {
    // CustomBusinessException이 발생해도 커밋됨
}

실제 프록시 코드 확인하기

정말로 프록시가 생성되는지 직접 확인해보겠습니다.

@Service
public class AccountService {

    @Transactional
    public void transfer(Long fromId, Long toId, int amount) {
        System.out.println("실제 메소드 실행");
    }
}

@RestController
@RequiredArgsConstructor
public class TestController {

    private final AccountService accountService;

    @GetMapping("/test")
    public String test() {
        System.out.println("주입된 객체: " + accountService.getClass().getName());
        return "OK";
    }
}

 

결과:

주입된 객체: com.example.AccountService$$SpringCGLIB$$0

 

$$SpringCGLIB$$라는 접미사가 보이시나요? 이게 바로 Spring이 CGLIB 라이브러리를 사용해서 생성한 프록시 객체입니다.


주의할 점: Self-Invocation 문제

@Transactional을 사용할 때 가장 많이 겪는 함정이 있습니다.

@Service
public class OrderService {

    public void createOrder(OrderRequest request) {
        // 주문 생성
        saveOrder(request);

        // 결제 처리 (트랜잭션 적용 안 됨!)
        processPayment(request);
    }

    @Transactional
    public void processPayment(OrderRequest request) {
        // 결제 로직...
    }
}

 

createOrder에서 processPayment를 호출했는데, @Transactional동작하지 않습니다!

왜 동작하지 않을까?

외부 호출: Controller → Proxy → OrderService.createOrder()
내부 호출: OrderService.createOrder() → OrderService.processPayment()
                                          ↑ 프록시를 거치지 않음!

 

같은 클래스 내부에서 메소드를 호출하면 this.processPayment()로 호출됩니다. 이건 프록시가 아닌 실제 객체의 메소드를 직접 호출하는 것입니다. 프록시를 거치지 않으니 트랜잭션 처리가 되지 않습니다.

해결 방법

방법 1: 별도의 클래스로 분리

@Service
@RequiredArgsConstructor
public class OrderService {

    private final PaymentService paymentService;

    public void createOrder(OrderRequest request) {
        saveOrder(request);
        paymentService.processPayment(request);  // 외부 빈 호출 → 프록시 동작
    }
}

@Service
public class PaymentService {

    @Transactional
    public void processPayment(OrderRequest request) {
        // 결제 로직...
    }
}

 

방법 2: 자기 자신을 주입받기

@Service
public class OrderService {

    @Autowired
    @Lazy  // 순환 참조 방지를 위해 필수
    private OrderService self;  // 프록시가 주입됨

    public void createOrder(OrderRequest request) {
        saveOrder(request);
        self.processPayment(request);  // 프록시를 통해 호출
    }

    @Transactional
    public void processPayment(OrderRequest request) {
        // 결제 로직...
    }
}

 

주의할 점이 있습니다. Spring Boot 2.6 이상에서는 순환 참조가 기본적으로 금지되어 있어서, @Lazy 없이 자기 자신을 주입하면 애플리케이션 실행 시 오류가 발생합니다. 반드시 @Lazy를 함께 사용해야 합니다.

개인적으로는 방법 1(클래스 분리)을 추천합니다. 더 깔끔하고, 역할 분리도 명확해지기 때문입니다.

private 메소드에는 @Transactional이 동작하지 않는다

Self-Invocation과 함께 자주 하는 실수가 하나 더 있습니다.

@Service
public class OrderService {

    @Transactional  // 동작하지 않음!
    private void saveOrder(OrderRequest request) {
        // ...
    }
}

 

private 메소드에 @Transactional을 붙여도 동작하지 않습니다. 프록시는 외부에서 접근 가능한 메소드만 가로챌 수 있기 때문입니다. @Transactional을 적용하려면 반드시 public 메소드여야 합니다.


트랜잭션 전파(Propagation)

하나의 트랜잭션 안에서 다른 @Transactional 메소드를 호출하면 어떻게 될까요?

@Service
public class OrderService {

    @Autowired
    private InventoryService inventoryService;

    @Transactional
    public void createOrder(OrderRequest request) {
        saveOrder(request);
        inventoryService.decreaseStock(request.getProductId());
    }
}

@Service
public class InventoryService {

    @Transactional
    public void decreaseStock(Long productId) {
        // 재고 감소
    }
}

 

기본 설정(Propagation.REQUIRED)에서는:

  • 이미 트랜잭션이 있으면 → 기존 트랜잭션에 참여
  • 트랜잭션이 없으면 → 새 트랜잭션 생성

여기서 "참여"라는 표현이 조금 헷갈릴 수 있는데, 새로운 트랜잭션을 만드는 것이 아니라 이미 시작된 트랜잭션(Connection)을 그대로 이어받아 사용한다는 뜻입니다. 즉, 물리적으로는 하나의 트랜잭션입니다.

createOrder() ─────── 트랜잭션 시작 ───────────────────── 커밋
    │                     │                               │
    └─ decreaseStock() ───┘ (같은 트랜잭션에 참여) ────────┘

 

즉, decreaseStock에서 예외가 발생하면 createOrder의 변경사항도 함께 롤백됩니다.

다른 전파 옵션들도 있습니다:

// 항상 새로운 트랜잭션 생성 (기존 트랜잭션과 독립)
@Transactional(propagation = Propagation.REQUIRES_NEW)

// 트랜잭션 없이 실행 (기존 트랜잭션 일시 중단)
@Transactional(propagation = Propagation.NOT_SUPPORTED)

// 트랜잭션이 있으면 참여, 없으면 트랜잭션 없이 실행
@Transactional(propagation = Propagation.SUPPORTS)

정리

@Transactional의 동작 원리를 정리하면 다음과 같습니다:

  1. 프록시 생성: Spring은 @Transactional이 붙은 클래스에 대해 프록시 객체를 생성합니다
  2. AOP를 통한 트랜잭션 처리: 프록시가 메소드 호출을 가로채서 트랜잭션 시작/커밋/롤백을 처리합니다
  3. 기본 롤백 규칙: RuntimeException과 Error는 롤백, Checked Exception은 커밋됩니다
  4. Self-Invocation 주의: 같은 클래스 내 메소드 호출은 프록시를 거치지 않아 트랜잭션이 적용되지 않습니다
  5. 트랜잭션 전파: 기본적으로 이미 진행 중인 트랜잭션이 있으면 참여합니다

@Transactional은 정말 편리한 기능이지만, 내부 동작 원리를 모르면 예상치 못한 버그를 만나기 쉽습니다. 특히 Self-Invocation 문제는 실무에서 정말 자주 발생하니 꼭 기억해두시길 바랍니다.


추가로 알아두면 좋은 것들

읽기 전용 트랜잭션

조회만 하는 메소드라면 readOnly 옵션을 사용하면 좋습니다:

@Transactional(readOnly = true)
public List<Order> getOrders() {
    return orderRepository.findAll();
}
  • 데이터베이스에 따라 성능 최적화가 적용될 수 있습니다
  • 실수로 데이터를 변경하는 것을 방지합니다
  • JPA 사용 시 더티 체킹을 스킵하여 성능이 향상됩니다

타임아웃 설정

@Transactional(timeout = 10)  // 10초
public void longRunningProcess() {
    // 10초 이상 걸리면 예외 발생
}

 

긴 작업이 데이터베이스 커넥션을 오래 점유하는 것을 방지할 수 있습니다.

반응형