[Spring] @Transactional의 8가지 함정

2026. 5. 24. 22:00·Framework/Spring

 

안녕하세요.

지난 글에서는 @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 프록시와 트랜잭션 매니저 위에서 동작하는 꽤 복잡한 기능입니다.

그리고 운영에서 발생하는 대부분의 문제는 결국 아래 두 가지를 제대로 이해하지 못해서 발생합니다.

  1. @Transactional은 프록시 기반으로 동작한다.
  2. 기본 롤백 정책은 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
'Framework/Spring' 카테고리의 다른 글
  • [Spring] Spring Security 인증 처리
  • [Spring] Spring Security
  • [Spring] @Transactional
으노로
으노로
  • 으노로
    study-library
    으노로
  • 전체
    오늘
    어제
    • 분류 전체보기 (42) N
      • Language (16)
        • JAVA (15)
        • JavaScript (1)
      • Framework (4) N
        • Spring (4) N
      • Web (4)
      • Infra (6)
      • Algorithm (10)
        • Programmers (10)
      • Database (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    spring
    분수의덧셈
    스프링부트
    코딩테스트
    문자열정렬하기(2)
    스프링
    트렌잭션
    자바
    @transactional
    알고리즘
    programmers
    inmemorydb
    transactional
    java
    OS
    문자열 정렬하기
    eclipse
    비동기 통신 방식
    spring boot
    프로그래머스
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
으노로
[Spring] @Transactional의 8가지 함정
상단으로

티스토리툴바