안녕하세요.
지난 글에서는 @Transactional의 개념과 동작 원리에 대해 정리했습니다.
@Transactional은 Spring AOP 기반의 프록시 방식으로 동작하며, 기본 롤백 정책이 RuntimeException과 Error에만 적용된다는 점을 살펴봤습니다.
이제 실제 운영 환경에서 트랜잭션이 어떻게 문제로 이어지는지 살펴보겠습니다.
대부분의 트랜잭션 이슈는 결국 두 가지 원인에서 시작됩니다.
- @Transactional은 프록시가 메서드 호출을 가로채는 방식으로 동작한다.
- 체크 예외(Exception 및 하위)는 기본적으로 롤백되지 않는다.
[Caller]
↓
[프록시]
↓ 트랜잭션 시작
[실제 Bean]
↓
[프록시]
↓ 커밋 / 롤백
이번 글에서는 운영에서 자주 마주치는 @Transactional의 대표적인 함정 8가지를 정리해보겠습니다.
함정 1. 같은 클래스 내부 호출은 트랜잭션이 동작하지 않는다.
가장 흔하게 마주치는 함정입니다.
같은 클래스 내부에서 메서드를 호출하면 프록시를 거치지 않기 때문에 @Transactional이 적용되지 않습니다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
public void placeOrder(OrderDto dto) {
saveOrder(dto); // ❌ self-invocation
}
@Transactional
public void saveOrder(OrderDto dto) {
orderRepository.save(dto.toEntity());
throw new IllegalStateException("롤백되어야 하지만 롤백 안 됨");
}
}
왜 이런 일이 생기는가?
스프링은 @Transactional이 붙은 빈을 프록시 객체로 감싸 등록합니다.
외부 호출은 프록시를 거치지만, 내부 호출은 실제 객체(this)가 직접 메서드를 호출하기 때문에 트랜잭션 로직이 개입하지 못합니다.
외부 호출 → 프록시 → 트랜잭션 시작 → 실제 객체
내부 호출(this.method())
→ 실제 객체 직접 호출
→ 프록시 미경유
→ 트랜잭션 미적용
해결책 - 트랜잭션 메서드를 별도 빈으로 분리
// Before
@Service
@RequiredArgsConstructor
public class OrderService {
public void placeOrder(OrderDto dto) {
saveOrder(dto);
}
@Transactional
public void saveOrder(OrderDto dto) { }
}
// After
@Service
@RequiredArgsConstructor
public class OrderFacade {
private final OrderTxService orderTxService;
public void placeOrder(OrderDto dto) {
orderTxService.saveOrder(dto); // ✅ 프록시 경유
}
}
@Service
@RequiredArgsConstructor
public class OrderTxService {
private final OrderRepository orderRepository;
@Transactional
public void saveOrder(OrderDto dto) { }
}
| 방법 | 추천도 |
| 별도 빈으로 분리 | ★★★★★ |
| Self-injection | ★★☆☆☆ |
| AopContext.currentProxy() | ★★☆☆☆ |
실무에서는 대부분 트랜잭션 책임 자체를 별도 클래스로 분리하는 방식이 가장 권장됩니다.
함정 2. Checked Exception은 기본적으로 롤백되지 않는다.
@Transactional
public void register(UserDto dto) throws IOException {
userRepository.save(dto.toEntity());
sendWelcomeMail(dto); // IOException 가능
}
IOException이 발생해도 트랜잭션은 롤백되지 않고 커밋됩니다.
즉, 사용자 데이터는 저장되지만 메일 발송은 실패하는 상황이 발생할 수 있습니다.
왜 이런 일이 생기는가?
Spring의 기본 롤백 정책은 아래와 같습니다.
| 예외 종류 | 롤백 여부 |
| RuntimeException | ✅ |
| Error | ✅ |
| 체크 예외 (Exception) | ❌ |
이는 과거 EJB CMT(Container Managed Transaction) 규약과의 호환성을 고려한 기본 정책입니다.
해결책
@Transactional(rollbackFor = Exception.class)
public void register(UserDto dto) throws IOException {
...
}
실무에서는 팀 규칙으로 rollbackFor = Exception.class를 기본 적용하는 경우도 많습니다.
함정 3. private / final / static 메서드는 무시된다.
@Service
public class ReportService {
@Transactional
private void saveReport() { } // ❌ 무시됨
@Transactional
public final void export() { } // ❌ 무시됨
}
왜 이런 일이 생기는가?
CGLIB 프록시는 오버라이드 가능한 메서드만 가로챌 수 있습니다.
하지만 아래 메서드들은 오버라이드 자체가 불가능합니다.
- private
- final
- static
엄밀히는 셋의 원인이 조금씩 다릅니다.
private은 인터셉트 대상에서 제외되고, static은 인스턴스 메서드가 아니라 프록시 대상이 아니며, final은 오버라이드가 불가능합니다.
결과적으로 모두 프록시가 트랜잭션 로직을 삽입할 수 없습니다.
해결책 - 규칙 설정
@Transactional은 항상 public 인스턴스 메서드에만 사용합니다.
함정 4. try-catch로 예외를 삼키면 rollback-only 상태가 남는다.
@Transactional
public void outer() {
saveA();
try {
inner();
} catch (Exception e) {
log.error("예외 무시", e);
}
saveB(); // ❌ UnexpectedRollbackException
}
왜 이런 일이 생기는가?
내부 메서드 inner()가 Propagation.REQUIRED(기본값)로 외부 트랜잭션에 참여하는 경우, 내부에서 예외가 발생하면 트랜잭션은 이미 rollback-only 상태로 마킹됩니다.
바깥에서 예외를 잡아도 마지막 커밋 시점에 아래 예외가 발생합니다.
UnexpectedRollbackException
해결책
부분 롤백이 필요하다면 내부 작업을 REQUIRES_NEW 트랜잭션으로 분리해야 합니다.
다만 다음 함정도 함께 봐야 합니다.
함정 5. Propagation.REQUIRES_NEW를 과신한다.
REQUIRES_NEW는 자주 오해되는 옵션입니다.
| 오해 | 실제 동작 |
| 내부 호출에서도 새 트랜잭션 생성 | ❌ self-invocation이면 무시 |
| 같은 커넥션 사용 | ❌ 새 커넥션 사용 |
| 바깥 롤백 시 같이 롤백 | ❌ 이미 커밋된 별개 트랜잭션 |
왜 이런 일이 생기는가?
REQUIRES_NEW는 현재 트랜잭션을 suspend 하고, 새로운 커넥션으로 새 트랜잭션을 생성합니다.
즉 완전히 독립적인 트랜잭션입니다.
주의사항
외부 트랜잭션 + 내부 REQUIRES_NEW 조합은 동시에 커넥션 2개를 점유할 수 있습니다.
잘못 사용하면 커넥션 풀 starvation으로 이어질 수 있습니다.
함정 6. readOnly = true는 "쓰기 금지"가 아니다.
@Transactional(readOnly = true)
public List<UserDto> findUsers() {
...
}
실제 효과
- Hibernate dirty checking 최적화
- 일부 드라이버의 읽기 전용 힌트
- 일부 flush 최적화
오해하면 안 되는 점
@Transactional(readOnly = true)
public void test() {
userRepository.save(...); // 환경에 따라 그냥 동작 가능
}
readOnly = true는 성능 힌트에 가깝지, 절대적인 쓰기 차단 장치가 아닙니다.
- JPA(Hibernate): flush 자체가 스킵되어 변경이 반영되지 않거나 일부 케이스에서 차단됨
- MyBatis / JDBC 직접 사용: 그냥 INSERT/UPDATE가 그대로 실행됨
실무 가이드
| 상황 | 권장 |
| 조회 전용 서비스 | readOnly = true 사용 |
| 실제 쓰기 차단 필요 | DB 권한 / DataSource 분리 |
함정 7. 트랜잭션 안에서 외부 API를 호출한다.
@Transactional
public void approve(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow();
order.approve();
paymentClient.charge(order); // ❌ 외부 HTTP 호출
}
왜 위험한가?
트랜잭션이 열려 있는 동안 DB 커넥션은 계속 점유됩니다.
외부 API가 느려지면 다음과 같은 상황이 발생할 수 있습니다.
- DB 커넥션 점유 시간 증가
- 커넥션 풀 고갈
- 전체 서비스 장애
또한 아래와 같은 상황도 발생할 수 있습니다.
외부 API 성공
→ DB 커밋 실패
→ 정합성 붕괴
해결책
| 방법 | 적용 상황 |
| 트랜잭션 범위 축소 | 단순 케이스 |
| @TransactionalEventListener(AFTER_COMMIT) | 커밋 이후 처리 |
| Outbox Pattern | 높은 정합성 필요 |
함정 8. 트랜잭션 범위가 너무 넓다.
@Transactional
public OrderResultDto placeOrder(OrderRequestDto req) {
validate(req); // CPU 작업
inventoryClient.check(req); // 외부 API
Order order = orderRepository.save(...);
return OrderResultDto.from(order);
}
왜 문제인가?
트랜잭션은 DB 커넥션을 잡는 순간부터 비용입니다.
검증·변환·외부 API까지 모두 트랜잭션 안에 넣으면 아래와 같은 문제로 이어집니다.
- 커넥션 점유 시간 증가
- 동시 처리량 감소
- lock 유지 시간 증가
실무 가이드
- 트랜잭션은 DB 상태 변경 최소 단위에만 건다.
- 외부 호출은 트랜잭션 밖으로 뺀다.
- 조회와 변경은 분리한다.
점검 체크리스트
| # | 점검 항목 |
| 1 | self-invocation 아닌가? |
| 2 | rollbackFor가 필요한가? |
| 3 | public 인스턴스 메서드인가? |
| 4 | try-catch로 트랜잭션 메서드 예외를 삼키고 있지 않은가? |
| 5 | REQUIRES_NEW 남용 아닌가? |
| 6 | 조회 메서드에 readOnly = true 적용했는가? |
| 7 | 트랜잭션 안에서 외부 API 호출하는가? |
| 8 | 트랜잭션 범위가 너무 넓지 않은가? |
마무리
@Transactional은 단순한 어노테이션처럼 보이지만 실제로는 Spring AOP 프록시와 트랜잭션 매니저 위에서 동작하는 꽤 복잡한 기능입니다.
그리고 운영에서 발생하는 대부분의 문제는 결국 아래 두 가지를 제대로 이해하지 못해서 발생합니다.
- @Transactional은 프록시 기반으로 동작한다.
- 기본 롤백 정책은 RuntimeException과 Error에만 적용된다.
이 두 가지만 정확히 이해해도 대부분의 트랜잭션 이슈를 훨씬 빠르게 진단할 수 있습니다.
읽어주셔서 감사합니다.
참조
- https://docs.spring.io/spring-framework/docs/5.3.x/reference/html/data-access.html#transaction-declarative
- https://docs.spring.io/spring-framework/docs/5.3.x/reference/html/core.html#aop-understanding-aop-proxies
- https://docs.spring.io/spring-framework/docs/5.3.x/reference/html/data-access.html#tx-propagation
'Framework > Spring' 카테고리의 다른 글
| [Spring] Spring Security 인증 처리 (0) | 2026.05.31 |
|---|---|
| [Spring] Spring Security (0) | 2026.05.30 |
| [Spring] @Transactional (1) | 2026.05.23 |