Spring에서 @Transactional은 가장 많이 쓰이면서도 가장 자주 오용되는 기능 중 하나다.
겉으로는 단순해 보이지만, 내부 동작을 잘 모르고 쓰면 트랜잭션이 아예 시작되지 않거나, 롤백되지 않거나, 데이터 불일치 문제가 생길 수 있다.
이 글에서는 단순 개념이 아니라 실제로 개발하다가 부딪히는 문제들을 기준으로 @Transactional을 깊이 있게 정리해본다.
1. @Transactional이 작동하는 원리
핵심은 프록시와 AOP
Spring에서 @Transactional은 프록시 기반 AOP를 사용해 트랜잭션을 관리한다.
즉, 트랜잭션을 시작하거나 커밋/롤백하는 실제 코드는 프록시 객체에 의해 실행된다.
기본 흐름은 다음과 같다:
- Bean 등록 시 Spring이 트랜잭션 어노테이션이 붙은 클래스를 프록시로 감싼다.
- 프록시가 메서드 호출을 가로채 트랜잭션을 시작한다.
- 원래 메서드가 실행된다.
- 정상 종료되면 트랜잭션 커밋, 예외 발생 시 롤백.
Client → (Proxy)UserService → 실제 UserServiceImpl
주의: 이 구조 때문에 프록시를 거치지 않는 호출에서는 트랜잭션이 적용되지 않는다. 이게 가장 흔한 실수다.
2. 트랜잭션이 적용되지 않는 흔한 사례들
같은 클래스 내부에서 메서드 호출
@Service
public class OrderService {
public void createOrder() {
// 아래 메서드는 @Transactional이 있어도 프록시를 거치지 않음
saveOrder();
}
@Transactional
public void saveOrder() {
// 트랜잭션 미적용됨
}
}
설명: this.saveOrder()는 프록시를 거치지 않고 직접 호출되기 때문에 트랜잭션이 작동하지 않는다.
해결법:
- saveOrder()를 별도 클래스로 분리
- AopContext.currentProxy()를 활용해 프록시를 통해 자기 자신을 호출
3. 예외가 발생했는데도 롤백되지 않는 이유
Spring은 RuntimeException, Error에 대해서만 기본적으로 롤백한다.
@Transactional
public void doSomething() throws IOException {
throw new IOException("Checked Exception"); // rollback 안 됨
}
해결법:
@Transactional(rollbackFor = IOException.class)
public void doSomething() throws IOException {
throw new IOException("이제 롤백됨");
}
실무 팁: 선언부에서 rollbackFor를 명시하지 않으면, DB에는 반영된 것처럼 보이고 실제로는 반영되지 않은 데이터 불일치 상태가 발생할 수 있다.
4. 트랜잭션 전파(Propagation)의 진짜 의미
REQUIRED (기본값)
- 트랜잭션이 존재하면 그대로 참여
- 없으면 새로 시작
REQUIRES_NEW
- 무조건 새 트랜잭션 시작
- 기존 트랜잭션은 일시 중단
@Transactional
public void parent() {
child(); // REQUIRED이면 같은 트랜잭션, REQUIRES_NEW면 별도
}
실무에서 자주 부딪히는 문제
@Transactional
public void parent() {
try {
child(); // REQUIRES_NEW
} catch (Exception e) {
log.warn("자식 에러 무시");
}
// 여기서 에러 발생하면 child 트랜잭션은 이미 커밋된 상태
}
REQUIRES_NEW는 별도 커넥션을 쓰기 때문에, 부모에서 롤백돼도 자식은 커밋됨. 신중하게 써야 한다.
5. readOnly = true는 어떤 효과가 있을까?
@Transactional(readOnly = true)
public List<User> findAllUsers() {
return userRepository.findAll();
}
- JDBC에 따라 Connection.setReadOnly(true) 호출됨
- 쓰기 작업은 금지되지 않는다
- Hibernate는 flush() 생략 등 내부 최적화 수행
실무 팁: 쓰기 작업이 포함될 가능성이 있으면 readOnly는 사용하지 말 것.
무의식적으로 쓰기 작업이 들어가면 의도하지 않은 부작용이 생길 수 있다.
6. 트랜잭션이 시작되지 않는 시점
- @PostConstruct
- @Scheduled
- ApplicationListener
- Bean 초기화 도중
이 시점들에서는 프록시가 완전히 생성되기 전이기 때문에 트랜잭션이 시작되지 않는다.
@PostConstruct
@Transactional // 작동하지 않음
public void init() {
...
}
해결법:
- 별도 초기화 클래스를 만들고 @EventListener(ApplicationReadyEvent.class) 사용
7. 실무에서 많이 겪는 트랜잭션 오류들
| 증상 | 원인 | 해결책 |
| 트랜잭션 안 먹힘 | 내부 메서드 호출 | 클래스를 분리하거나 Proxy 호출 사용 |
| 롤백 안 됨 | Checked Exception | rollbackFor 사용 |
| DB는 업데이트 됐는데 에러 | readOnly 상태에서 쓰기 시도 | readOnly 사용 주의 |
| 트랜잭션 중첩으로 Deadlock | 전파 방식 오남용 | propagation 정책 설계 필요 |
@Transactional, 정확히 이해하고 써야 한다
트랜잭션은 “데이터를 안전하게 유지한다”는 단순한 목적을 갖지만, Spring에서 이를 구현하는 방식은 복잡하다.
프록시, 예외 처리, 전파 속성 등을 제대로 이해하지 못하면 예상치 못한 버그로 이어진다.
'Programming > Spring' 카테고리의 다른 글
| Spring Boot와 기존 Spring의 차이점 (0) | 2025.04.24 |
|---|---|
| Spring Bean 생명주기와 스코프 정리 (0) | 2025.04.24 |
| 스프링으로 웹페이지 만들기 - 6. 카카오오븐 사용하여 UI UX 정의하기 (0) | 2022.02.07 |
| 스프링으로 웹페이지 만들기 - 5. 부트스트랩 무료템플릿 사용하기 (0) | 2022.02.07 |
| 스프링으로 웹페이지 만들기 - 4. 데이터 주고 받기 (0) | 2022.01.25 |