스프링 개발자에게 @Transactional은 공기 같은 존재입니다. 하지만 이 공기가 가끔 멈출 때가 있습니다. 데이터가 수정되지 않거나, 에러가 났는데 롤백이 되지 않아 DB 데이터가 꼬이는 현상은 주니어부터 시니어까지 누구나 겪을 수 있는 공포입니다.
실제 개발할 때도 트랜잭션이 필요한 부분에서 적용되지 않거나, 전 과정이 하나의 트랜잭션으로 묶여 전부 진행될 때까지 테이블에 락이 걸리는 등의 문제가 발생하는 경우도 많은 것 같습니다. 트랜잭션의 개념을 정확하게 익히고, 때에 따라서 적용할 수 있으면 좋겠습니다.
오늘은 @Transactional이 작동하지 않는 모든 가능한 시나리오를 상세한 예시와 함께 정리해 보겠습니다.
1. 프록시의 한계: 내부 호출 (Self-Invocation)
가장 흔하면서도 고치기 까다로운 사례입니다.
- 증상: 한 클래스 내에서 트랜잭션이 없는 메서드가 트랜잭션이 있는 메서드를 호출할 때 발생합니다.
- 원인: 스프링 트랜잭션은 AOP(Proxy) 기반입니다. 외부에서 호출해야 프록시 객체가 트랜잭션을 시작하는데, 내부 호출은 this.method() 방식이라 프록시를 거치지 않습니다.
- 해결책:
- 메서드를 별도의 서비스 클래스로 분리합니다. (권장)
- 자기 자신을 주입받아(@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 등을 정확히 선택했는지 확인하세요.
💡 실무 트러블슈팅 체크리스트
만약 지금 트랜잭션이 안 된다면 다음 순서로 확인해 보세요.
- 호출 구조: 클래스 내부 호출인가? (Self-invocation)
- 접근자: public 메서드인가?
- 예외 처리: try-catch로 예외를 삼켰는가?
- 예외 종류: 체크 예외인데 rollbackFor를 안 썼는가?
- 빈 주입: 해당 클래스가 @Service 등으로 스프링 컨테이너에 등록된 빈이 맞는가?
🏁 마무리하며
@Transactional은 매우 편리하지만, 그 이면에는 Proxy, AOP, Exception Strategy라는 복잡한 메커니즘이 숨어 있습니다. 원리를 알면 "왜 안 되지?"라며 당황할 일이 줄어듭니다.
이 글이 여러분의 소중한 데이터를 보호하고, 디버깅 시간을 획기적으로 줄여주기를 바랍니다!
'TECH > 웹개발' 카테고리의 다른 글
| 웹 성능과 유지보수를 위한 선택: Page-centric design이 유리한 의외의 상황들 (0) | 2026.03.21 |
|---|---|
| [Spring Boot] 주기적인 작업 자동화, 스케줄러(Scheduler) 완벽 가이드 (0) | 2026.03.20 |
| [Security] API 보안의 핵심: Session vs JWT 완벽 비교 가이드 (1) | 2026.03.18 |
| [Java/Spring] 현대 인증의 표준, JWT(JSON Web Token) 완벽 가이드 (0) | 2026.03.17 |
| [Java/Spring] 로그인 구현의 클래식, 세션(Session) 인증 방식 A to Z (0) | 2026.03.16 |