개발에 AtoZ까지

[Spring] @Transactional을 붙였는데 롤백이 안 되는 네 가지 경우 — Spring 6 기준으로 다시 정리 본문

백엔드/Spring

[Spring] @Transactional을 붙였는데 롤백이 안 되는 네 가지 경우 — Spring 6 기준으로 다시 정리

AtoZ 개발자 2026. 8. 12. 19:30
반응형

@Transactional을 붙였으니 예외가 나면 당연히 되돌아갈 거라고 생각합니다. 그런데 로그에는 예외가 찍혔는데 DB에는 데이터가 그대로 남아 있는 일이 생깁니다.

더 헷갈리는 건 검색 결과입니다. "public 메서드에만 동작한다"는 설명이 많은데, 이건 지금 기준으로는 정확하지 않습니다. Spring 6.0에서 규칙이 바뀌었습니다.

이 글을 다 읽으면 세 가지가 정리됩니다. 첫째, 롤백이 안 되는 네 가지 경로를 구분할 수 있습니다. 둘째, Spring 6에서 무엇이 바뀌었는지 확인할 수 있습니다. 셋째, 내 코드에서 어디를 고쳐야 하는지 판단할 수 있습니다.

이 글은 Spring Framework 공식 문서를 기준으로 정리했습니다. 버전에 따라 동작이 다르니 본인 프로젝트 버전으로 확인해 주세요.

프록시를 거쳐 들어온 호출만 가로채집니다

경우 1 — 같은 클래스 안에서 호출했다

가장 자주 만나는 함정입니다. 공식 문서의 설명이 명확합니다.

프록시 모드(기본값)에서는 프록시를 통해 들어오는 외부 메서드 호출만 가로채집니다. 즉 self-invocation(대상 객체가 자기 자신의 다른 메서드를 호출하는 것)은 호출된 메서드에 @Transactional이 붙어 있어도 런타임에 실제 트랜잭션으로 이어지지 않습니다.

코드로 보면 이렇습니다.

@Service
public class OrderService {

    public void placeOrder(OrderRequest req) {
        validate(req);
        save(req);          // ← 같은 객체의 메서드 직접 호출 = 프록시를 안 거침
    }

    @Transactional
    public void save(OrderRequest req) {   // 트랜잭션이 시작되지 않는다
        orderRepository.save(req.toEntity());
        pointRepository.deduct(req.userId(), req.amount());
    }
}

placeOrder를 컨트롤러에서 호출하면 프록시를 거치지만, 그 안에서 save를 호출하는 것은 프록시가 아니라 실제 객체 자신입니다. 그래서 @Transactional이 무시됩니다.

고치는 방법은 세 가지입니다.

// 1) 트랜잭션 경계를 진입점으로 올린다 (가장 단순하고 권장)
@Transactional
public void placeOrder(OrderRequest req) { ... }

// 2) 다른 빈으로 분리해 프록시를 타게 한다
@Service
public class OrderService {
    private final OrderWriter writer;   // 별도 빈
    public void placeOrder(OrderRequest req) { writer.save(req); }
}

// 3) 자기 자신을 프록시로 주입받는다 (순환 참조라 @Lazy 필요)
@Service
public class OrderService {
    @Lazy private final OrderService self;
    public void placeOrder(OrderRequest req) { self.save(req); }
}

공식 문서는 AspectJ 모드(@EnableTransactionManagement(mode = AdviceMode.ASPECTJ))를 쓰면 바이트코드가 변경되어 모든 종류의 호출에 트랜잭션이 적용된다고도 안내합니다. 다만 빌드 설정이 늘어나므로 대부분의 프로젝트에서는 1번이 현실적입니다.

Spring Framework 공식 문서 — 프록시를 거쳐 들어온 외부 호출만 가로채지고, 6.0부터 protected·package 접근 메서드도 클래스 기반 프록시에서 동작합니다 (출처: docs.spring.io, 확인일 2026-08-08)

경우 2 — 메서드 접근 제어자 (여기가 Spring 6에서 바뀌었습니다)

오래된 글들은 "@Transactional은 public 메서드에만 동작한다"고 설명합니다. 지금은 절반만 맞습니다.

공식 문서의 현재 서술은 이렇습니다.

@Transactional 애노테이션은 보통 public 가시성 메서드에 사용됩니다. 6.0부터는 클래스 기반 프록시에서 protected 또는 패키지 접근 메서드도 기본적으로 트랜잭션 처리가 가능합니다. 인터페이스 기반 프록시의 트랜잭션 메서드는 항상 public이어야 하고 프록시 대상 인터페이스에 정의되어 있어야 합니다.

정리하면 이렇게 갈립니다.

프록시 방식 동작하는 가시성
클래스 기반(CGLIB, Spring Boot 기본) public, protected, package-private
인터페이스 기반(JDK 동적 프록시) 인터페이스에 선언된 public

private 메서드는 어느 쪽에서도 프록시가 가로챌 수 없어 여전히 동작하지 않습니다. final 메서드나 final 클래스도 CGLIB이 상속할 수 없어 프록시가 만들어지지 않습니다.

버전에 따라 동작이 갈리는 게 싫다면 공식 문서가 제시하는 방법으로 고정할 수 있습니다.

@Bean
TransactionAttributeSource transactionAttributeSource() {
    return new AnnotationTransactionAttributeSource(true);   // public 전용으로 통일
}

경우 3 — 예외 종류가 롤백 대상이 아니다

공식 문서가 밝힌 기본값입니다.

기본 @Transactional 설정은 다음과 같습니다. 모든 RuntimeException 또는 Error는 롤백을 트리거하고, checked Exception은 그렇지 않습니다.

IOException이나 직접 만든 checked 예외를 던지면 커밋됩니다. 예외가 밖으로 나갔는데 데이터는 저장되는 상황이 여기서 나옵니다.

// 롤백 안 됨 — checked 예외
@Transactional
public void export() throws IOException {
    repository.save(entity);
    throw new IOException("파일 쓰기 실패");   // 커밋된다
}

// 방법 1) 롤백 대상으로 지정
@Transactional(rollbackFor = IOException.class)
public void export() throws IOException { ... }

// 방법 2) 런타임 예외로 감싸 던진다
@Transactional
public void export() {
    try {
        write();
    } catch (IOException e) {
        throw new UncheckedIOException(e);
    }
}

Spring 6.2부터는 전역 기본값을 바꿀 수 있습니다.

@EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS)

한 가지 더. 예외를 try-catch로 잡아서 삼켜 버리면 당연히 롤백되지 않습니다. 트랜잭션 인터셉터는 메서드 밖으로 던져진 예외를 보고 판단하기 때문입니다. 로그만 찍고 넘어가는 catch 블록이 있으면 그 자리가 원인입니다.

예외 종류와 처리 방식에 따라 커밋·롤백이 갈리는 지점

경우 4 — 안쪽에서 롤백 표시가 찍혀 밖에서 터진다

앞의 셋과 반대로 "롤백이 안 되는" 게 아니라 "예상하지 못한 예외가 나는" 경우입니다. 로그에 이 문장이 보이면 여기에 해당합니다.

org.springframework.transaction.UnexpectedRollbackException:
    Transaction silently rolled back because it has been marked as rollback-only

기본 전파 방식인 REQUIRED는 이미 트랜잭션이 있으면 그 트랜잭션에 참여합니다. 안쪽 메서드에서 런타임 예외가 나면 트랜잭션에 rollback-only 표시가 찍히는데, 바깥 메서드가 그 예외를 잡아 정상 흐름으로 되돌리고 커밋을 시도하면 위 예외가 발생합니다.

@Transactional
public void process(List<Item> items) {
    for (Item item : items) {
        try {
            itemService.saveOne(item);   // @Transactional(REQUIRED) — 같은 트랜잭션
        } catch (Exception e) {
            log.warn("건너뜀: {}", item.id());   // 표시는 이미 찍혔다
        }
    }
}   // 커밋 시도 → UnexpectedRollbackException

"일부만 실패해도 나머지는 저장"이 목표라면 전파 방식을 나눠야 합니다.

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveOne(Item item) { ... }

Spring Framework 공식 문서의 트랜잭션 전파(Transaction Propagation) 섹션 (출처: docs.spring.io, 확인일 2026-08-08)

REQUIRES_NEW는 별도 트랜잭션으로 분리되므로 안쪽 실패가 바깥을 오염시키지 않습니다. 대신 커넥션을 하나 더 쓰고, 바깥 트랜잭션이 롤백돼도 안쪽 커밋은 유지된다는 점을 감안해야 합니다.

어디를 볼지 한 표로

증상 원인 후보 확인할 것
예외가 났는데 데이터가 남아 있다 checked 예외 / catch 삼킴 예외 타입, catch 블록
로그에 트랜잭션 시작 자체가 없다 self-invocation 호출 경로가 프록시를 거치는지
특정 메서드만 동작하지 않는다 private·final 접근 제어자, final 여부
커밋 직전에 예외가 난다 rollback-only 표시 전파 방식, 내부 예외 처리
인터페이스에 붙였는데 무시된다 애노테이션 위치 구현 클래스로 이동

마지막 항목은 공식 문서가 직접 권고하는 부분입니다. 인터페이스의 애노테이션에 의존하지 말고 구체 클래스의 메서드에 붙이라고 되어 있습니다. 자바 애노테이션은 인터페이스에서 상속되지 않고, AspectJ 모드에서는 인식되지 않아 트랜잭션이 조용히 무시될 수 있기 때문입니다.

동작을 눈으로 확인하는 설정

추측하지 말고 로그로 보시는 게 빠릅니다.

# 트랜잭션 시작·커밋·롤백을 찍는다
logging.level.org.springframework.transaction.interceptor=TRACE
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG

# 실제 SQL과 바인딩 파라미터
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.orm.jdbc.bind=trace

TRACE로 켜면 Getting transaction for [...]Completing transaction for [...] 같은 줄이 나옵니다. 이 줄이 아예 없으면 트랜잭션이 시작되지 않은 것이고, 그럼 경우 1이나 2입니다.

테스트로 못 박아 두는 것도 방법입니다.

@SpringBootTest
class OrderServiceTest {

    @Autowired OrderService orderService;
    @Autowired OrderRepository orderRepository;

    @Test
    void 예외가_나면_저장되지_않는다() {
        assertThatThrownBy(() -> orderService.placeOrder(invalidRequest()))
                .isInstanceOf(IllegalStateException.class);

        assertThat(orderRepository.count()).isZero();   // 롤백 확인
    }
}

확인 순서

  • [ ] 문제 메서드가 프록시를 거쳐 호출되는지 경로를 확인한다
  • [ ] 접근 제어자가 private이거나 메서드·클래스가 final인지 확인한다
  • [ ] 던지는 예외가 RuntimeException 계열인지 checked인지 확인한다
  • [ ] 중간에 예외를 삼키는 catch 블록이 있는지 확인한다
  • [ ] UnexpectedRollbackException이면 내부 트랜잭션의 전파 방식을 확인한다
  • [ ] 애노테이션이 인터페이스가 아니라 구현 클래스에 있는지 확인한다
  • [ ] 트랜잭션 인터셉터 로그를 TRACE로 켜서 시작 여부를 확인한다

다음에 볼 글

트랜잭션 경계가 정리되면 그다음에 만나는 것이 지연 로딩 문제입니다. LazyInitializationException과 그 해결 과정에서 나오는 MultipleBagFetchException을 이어서 보시면 조회 흐름이 한 번에 잡힙니다. 예외를 응답으로 내보내는 형식은 ProblemDetail 글에 정리해 두었습니다.

잘못된 내용이나 보충이 필요한 부분이 있으면 댓글로 알려 주시면 반영하겠습니다.

참고한 자료

  • Spring Framework Reference — Using @Transactional: 프록시 모드와 self-invocation, 메서드 가시성(6.0 변경), 인터페이스 대신 클래스 권고 (확인일 2026-08-08)
  • Spring Framework Reference — 기본 롤백 규칙(RuntimeException·Error 롤백, checked Exception 비롤백), rollbackFor·noRollbackFor, 6.2의 rollbackOn (확인일 2026-08-08)
  • Spring Framework Reference — Transaction Propagation: REQUIRED, REQUIRES_NEW (확인일 2026-08-08)
반응형
Comments