[인프런 김영한] 스프링 트랜잭션 AOP, 커밋/롤백, 전파

Jong Hwan
|2026. 2. 18. 22:23

트랜잭션 AOP 이해

트랜잭션 프록시 등록

 

  • @Transactional이 적용된 특정 메서드나 클래스가 하나라도 있다면, 트랜잭션 AOP는 프록시를 만들어서 스프링 컨테이너에 등록한다.
  • 그리고 프록시가 내부에서 실제 basicService를 참조하게 된다.

 

트랜잭션 프록시 동작 방식

 

  • 만약 호출되는 함수가 트랜잭션 적용 대상이라면, 트랜잭션을 먼저 시작하고 basicService.tx()를 호출한다.
  • 트랜잭션 적용 대상이 아니라면, 그냥 바로 basicSerivec.nonTx()를 호출한다.

 

 

트랜잭션 AOP 초기화 시점 주의사항

@SpringBootTest
public class InitTxTest {

    @Autowired
    Hello hello;

    @Test
    void go() {

    }

    @TestConfiguration
    static class InitTxTestConfig {
        @Bean
        Hello hello() {
            return new Hello();
        }
    }

    @Slf4j
    static class Hello {

        @PostConstruct
        @Transactional
        public void initV1() {
            boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("Hello init @PostConstruct tx active = {}", isActive);
        }

        @EventListener(ApplicationReadyEvent.class)
        @Transactional
        public void initV2() {
            boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("Hello init ApplicationReadyEvent tx active = {}", isActive);
        }
    }
}
  • 초기화 시점에는 트랜잭션 AOP가 적용이 되지 않을 수 있다. 예를 들어, @PostConstruct와 @Transactional을 함께 사용하면 트랜잭션이 적용되지 않는다.
  • 초기화 코드가 먼저 호출되고 그 다음에 트랜잭션 AOP가 적용되기 때문이다.
  • 따라서 ApplicationReadyEvent 이벤트를 통해, AOP를 포함한 스프링 컨테이너가 완전히 생성되고 난 다음에 트랜잭션 AOP가 적용될 수 있도록 한다.

 

 

 

스프링 트랜잭션 옵션

  • readOnly = true로 설정하면, 커밋 시점에 플러시를 호출하지 않으며 변경감지를 위한 스냅샷 또한 1차 캐시에 저장해두지 않는 식으로 최적화가 된다.
  • 또한, JDBC 드라이버와 DB에서도 읽기 전용인 경우, 내부에서 성능 최적화가 발생한다.

 

 

예외와 트랜잭션 커밋, 롤백

@SpringBootTest
public class RollbackTest {

    @Autowired
    RollbackService rollbackService;

    @Test
    void runtimeException() {
        Assertions.assertThatThrownBy(() -> rollbackService.runTimeException())
                        .isInstanceOf(RuntimeException.class);
    }

    @Test
    void checkedException() {
        Assertions.assertThatThrownBy(() -> rollbackService.checkedException())
                .isInstanceOf(MyException.class);
    }

    @Test
    void rollbackForCheckedException() {
        Assertions.assertThatThrownBy(() -> rollbackService.rollbackFor())
                .isInstanceOf(MyException.class);
    }

    @TestConfiguration
    static class RollbackTestConfig {

        @Bean
        RollbackService rollbackService() {
            return new RollbackService();
        }
    }

    @Slf4j
    static class RollbackService {

        // 런타임 예외: 롤백
        @Transactional
        public void runTimeException() {
            log.info("call runTimeException");
            throw new RuntimeException();
        }

        // 체크 예외: 커밋
        @Transactional
        public void checkedException() throws MyException {
            log.info("call checkedException");
            throw new MyException();
        }

        // 체크 예외 rollbackFor 지정: 롤백
        @Transactional(rollbackFor = MyException.class)
        public void rollbackFor() throws MyException {
            log.info("call rollbackFor checkedException");
            throw new MyException();
        }
    }

    static class MyException extends Exception {

    }
}
  • 스프링은 기본적으로 언체크 예외인 RuntimeException, Error와 그 하위 예외는 롤백하고, 체크 예외인 Exception과 그 하위 예외들은 커밋한다.
  • 스프링은 기본적으로 체크 예외는 비즈니스 의미가 있을 때 사용하고, 언체크(런타임) 예외는 복구 불가능한 예외로 가정한다.
  • 예를 들어, 고객의 잔고가 부족한 상황은 시스템은 정상 동작했지만 비즈니스 상황이 예외인 것으로, 이러한 비즈니스 예외는 매우 중요하고 반드시 처리해야 하는 경우가 많으므로 체크 예외를 고려할 수 있다.
  • 즉, 해당 비즈니스 예외가 발생하면 롤백하지 않고 커밋시키고(비즈니스 상황상, 변경사항이 db에 반영되어야 하므로) 별도 상태 컬럼을 변경하여 추후에 처리하는 식으로 활용 가능하다.
  • 따라서 이러한 이유로 인해, 스프링은 언체크 예외는 롤백하고 체크 예외는 커밋한다.
  • rollbackFor 옵션을 사용하면, 어떤 예외가 발생할 때 롤백을 할 지 지정할 수 있다
  • noRollbackFor은 rollBackFor과 반대로, 어떤 예외가 발생했을 때 롤백하면 안되는지 지정할 수 있다.

 

 

 

스프링 트랜잭션 전파

외부 (물리), 내부 (논리) 트랜잭션 기본 동작

 

  • 외부 트랜잭션에서 또 다른 내부 트랜잭션이 시작되면, 스프링은 둘을 묶어서 하나의 트랜잭션으로 만들어준다.
  • 내부 트랜잭션이 외부 트랜잭션에 참여하는 것이다. 이것이 기본 동작이다. 
  • 여기서, 하나의 트랜잭션으로 묶은 트랜잭션을 물리 트랜잭션이라고 하고, 물리 트랜잭션에 묶인 각각의 트랜잭션을 논리 트랜잭션이라고 한다.

물리 트랜잭션과 논리 트랜잭션의 롤백, 커밋

 

  • 모든 논리 트랜잭션이 커밋되어야 물리 트랜잭션이 커밋될 수 있다. 

 

 

트랜잭션 전파 요청 흐름

 

트랜잭션 전파 응답 흐름

 

  • 한 트랜잭션 내에서는 커밋 혹은 롤백은 한 번씩만 호출될 수 있다.
  • 따라서, 내부 트랜잭션은 별도 커넥션을 얻어 트랜잭션을 시작하지 않고 외부 트랜잭션이 시작한 트랜잭션에 참여하기 위해 아무것도 하지 않는다. 또한, 커밋해도 DB 커넥션에 실제 커밋을 하지 않는다. 
  • 외부 트랜잭션만 물리 트랜잭션을 시작하고 DB 커넥션에 커밋한다.
  • 즉, 트랜잭션 매니저에 커밋을 호출한다고 해서 항상 실제 커넥션에 물리 커밋이 발생하지는 않는다는 것이다.
  • 신규 트랜잭션인 경우에만 실제 커넥션을 사용해서 물리 커밋과 롤백을 수행하는 것이다.

 

 

 

스프링 트랜잭션 전파 - 롤백

외부 트랜잭션 롤백 동작

 

  • 내부 트랜잭션은 커밋되고 외부 트랜잭션은 롤백된다면, 전체 물리 트랜잭션이 롤백된다.

 

내부 트랜잭션 롤백 흐름

 

  • 내부 트랜잭션은 롤백되면 실제 물리 트랜잭션을 롤백할 수 없다. 대신 기존 트랜잭션을 롤백 전용으로 표시한다. (트랜잭션 동기화 매니저에 rollbackOnly=true로)
  • 때문에 외부 트랜잭션 커밋 시점에, 전체 트랜잭션이 롤백 전용으로 표시되어 있기 때문에 전체 트랜잭션을 롤백한다.
  • 그리고 UnexpectedRollbackException 예외를 던진다. 외부 트랜잭션은 커밋을 호출했는데, 실제로는 커밋이 되지 않고 롤백되었기 때문에 해당 사실을 명확하게 알려주기 위함이다.
  •  

 

 

 

REQUIRES_NEW

REQUIRES_NEW 동작 흐름

 

  • 외부 트랜잭션과 내부 트랜잭션을 완전히 분리해서 각각 별도의 물리 트랜잭션을 사용하는 방법이다.
  • 따라서 내부 트랜잭션은 외부 트랜잭션에 영향을 주지 못한다.
  • 외부 트랜잭션이 시작되어 con1을 사용하다가 내부 트랜잭션이 시작되면 con1 사용을 보류하고 con2를 사용한다. 내부 트랜잭션이 끝나서 con2를 다 사용하면 con1을 다시 사용한다.

 

 

 

스프링 트랜잭션 전파 - 복구

REQUIRED 논리 트랜잭션 롤백 복구 흐름

 

  • 예외가 터졌을 때 내부에서 예외를 잡으면 해당 물리 트랜잭션은 롤백되지 않는다.
  • 하지만, 내부에서 터진 예외를 외부에서 잡으면 rollbackOnly=true로 표시되어서 위처럼 물리 트랜잭션이 롤백된다.
  • 즉, 예외가 내부 트랜잭션 프록시를 통과해서 밖으로 나오는 순간 이미 롤백은 확정된다.

 

REQUIRES_NEW 논리 트랜잭션 롤백 복구 흐름

 

  • 하지만, REQUIRES_NEW를 사용하여 트랜잭션을 분리한다면 로그 저장이 실패하더라도 회원가입은 정상적으로 진행되는 비즈니스 요구사항을 만족할 수 있다.
  • 즉, 논리 트랜잭션이 하나라도 롤백되면 관련 물리 트랜잭션 또한 롤백되므로, 트랜잭션을 분리해서 해결하는 것이다.
  • 하지만, DB 커넥션을 더 많이 사용하기 때문에 성능이 중요한 곳에서는 이런 부분을 주의해서 사용해야 한다.
  • 따라서 REQUIRES_NEW를 사용하지 않고 문제를 해결할 수 (구조 변경 등) 있다면 해당 방법을 사용하는 것이 더 좋다.

 

구조 변경으로 문제 해결

 

  • 위처럼 구조를 바꾸면 DB 커넥션을 순차적으로 사용하기 때문에 커넥션을 하나만 사용하면서, REQUIRES_NEW를 사용하지 않고도 문제를 해결할 수 있다.
  • 하지만 구조상 REQUIRES_NEW를 사용하는 것이 더 깔끔한 경우도 있으므로, 트레이드오프를 이해해서 적절히 사용하면 된다.