TECH/웹개발

[Spring] "분명 붙였는데 왜?" — @Transactional이 미작동하는 7가지 모든 사례

런코리치 2026. 3. 19. 08:00

스프링 개발자에게 @Transactional은 공기 같은 존재입니다. 하지만 이 공기가 가끔 멈출 때가 있습니다. 데이터가 수정되지 않거나, 에러가 났는데 롤백이 되지 않아 DB 데이터가 꼬이는 현상은 주니어부터 시니어까지 누구나 겪을 수 있는 공포입니다.

실제 개발할 때도 트랜잭션이 필요한 부분에서 적용되지 않거나, 전 과정이 하나의 트랜잭션으로 묶여 전부 진행될 때까지 테이블에 락이 걸리는 등의 문제가 발생하는 경우도 많은 것 같습니다. 트랜잭션의 개념을 정확하게 익히고, 때에 따라서 적용할 수 있으면 좋겠습니다.

오늘은 @Transactional이 작동하지 않는 모든 가능한 시나리오를 상세한 예시와 함께 정리해 보겠습니다.


1. 프록시의 한계: 내부 호출 (Self-Invocation)

가장 흔하면서도 고치기 까다로운 사례입니다.

  • 증상: 한 클래스 내에서 트랜잭션이 없는 메서드가 트랜잭션이 있는 메서드를 호출할 때 발생합니다.
  • 원인: 스프링 트랜잭션은 AOP(Proxy) 기반입니다. 외부에서 호출해야 프록시 객체가 트랜잭션을 시작하는데, 내부 호출은 this.method() 방식이라 프록시를 거치지 않습니다.
  • 해결책:
    1. 메서드를 별도의 서비스 클래스로 분리합니다. (권장)
    2. 자기 자신을 주입받아(@Autowired) 호출합니다. (차선책)

2. 접근 제어자의 제약: Public이 아닌 메서드

  • 증상: private, protected, default 접근 제어자가 붙은 메서드에는 트랜잭션이 적용되지 않습니다.
  • 원인: 프록시 방식(특히 CGLIB)은 타겟 클래스를 상속받아 구현하는데, 부모의 private 메서드는 자식(프록시)이 가로챌 수 없기 때문입니다.
  • 해결책: 트랜잭션이 필요한 메서드는 반드시 public으로 선언하세요.

3. 예외의 종류: Checked Exception 발생

  • 증상: IOException, SQLException 같은 체크 예외가 발생하면 로직이 중단되어도 DB는 커밋되어 버립니다.
  • 원인: 스프링의 기본 전략은 Unchecked Exception(RuntimeException)과 Error만 롤백 대상으로 삼습니다.
  • 해결책: @Transactional(rollbackFor = Exception.class) 처럼 명시적으로 모든 예외에 대해 롤백을 선언해야 합니다.

4. 잘못된 예외 처리: try-catch로 예외를 먹어버린 경우

  • 증상: 분명 에러가 났는데 롤백이 안 됩니다.
  • 원인: 개발자가 try-catch 블록으로 예외를 잡아서 로그만 찍고 다시 던지지(throw) 않으면, 트랜잭션 프록시는 예외가 발생했는지 모른 채 '정상 종료'로 판단하고 커밋을 수행합니다.
Java
@Transactional
public void saveUser() {
    try {
        userRepository.save(user);
        throw new RuntimeException(); // 에러 발생!
    } catch (Exception e) {
        log.error("에러 발생했지만 무시함"); // 여기서 예외를 다시 던지지 않음
    }
}
  • 해결책: 예외를 처리하더라도 마지막에 다시 throw 하거나, 예외를 삼키지 마세요.

5. 우선순위 혼동: readOnly 설정 오류

  • 증상: 데이터를 수정하려고 하면 ReadOnlyException이 발생하거나 수정이 안 됩니다.
  • 원인: 클래스 상단에 @Transactional(readOnly = true)가 붙어 있고, 메서드에 별도의 설정이 없으면 해당 메서드도 '읽기 전용'이 됩니다.
  • 해결책: 쓰기 작업이 필요한 메서드에는 별도로 @Transactional을 붙여 기본값인 readOnly = false가 적용되게 하세요.

6. 초기화 시점의 문제: @PostConstruct와의 충돌

  • 증상: @PostConstruct가 붙은 초기화 메서드 내에서 DB 작업을 할 때 트랜잭션이 적용되지 않습니다.
  • 원인: @PostConstruct는 의존성 주입이 완료된 직후에 실행되는데, 이때는 아직 프록시 객체가 완전히 생성되기 전일 수 있습니다.
  • 해결책: ApplicationReadyEvent 리스너를 사용하거나 별도의 서비스 호출 방식을 사용하세요.

7. 트랜잭션 전파 설정: Propagation.NOT_SUPPORTED

  • 증상: 트랜잭션 설정이 분명히 있는데 무시됩니다.
  • 원인: 전파 옵션이 NOT_SUPPORTED(트랜잭션 없이 실행)나 NEVER(트랜잭션이 있으면 예외)로 설정된 경우입니다.
  • 해결책: 비즈니스 요구사항에 맞춰 REQUIRED(기본값)나 REQUIRES_NEW 등을 정확히 선택했는지 확인하세요.

💡 실무 트러블슈팅 체크리스트

만약 지금 트랜잭션이 안 된다면 다음 순서로 확인해 보세요.

  1. 호출 구조: 클래스 내부 호출인가? (Self-invocation)
  2. 접근자: public 메서드인가?
  3. 예외 처리: try-catch로 예외를 삼켰는가?
  4. 예외 종류: 체크 예외인데 rollbackFor를 안 썼는가?
  5. 빈 주입: 해당 클래스가 @Service 등으로 스프링 컨테이너에 등록된 빈이 맞는가?

🏁 마무리하며

@Transactional은 매우 편리하지만, 그 이면에는 Proxy, AOP, Exception Strategy라는 복잡한 메커니즘이 숨어 있습니다. 원리를 알면 "왜 안 되지?"라며 당황할 일이 줄어듭니다.

이 글이 여러분의 소중한 데이터를 보호하고, 디버깅 시간을 획기적으로 줄여주기를 바랍니다!