[Spring] @Transactional이 동작하지 않는 이유 - 프록시 자기 호출

2021. 5. 11. 17:07·Web Backend/Spring
반응형

스프링과 JPA를 개념 순서대로 정리하는 시리즈입니다. 1부 스프링 컨테이너 (5/5)

@Transactional과 프록시

이번에는 @Transactional을 분명히 붙였는데도 트랜잭션이 걸리지 않는 상황에 대해 알아보겠습니다.

앞선 글에서 스프링 AOP가 프록시로 동작하며, 프록시를 거치지 않는 호출에는 적용되지 않는다는 점을 살펴봤습니다.

트랜잭션 역시 AOP로 동작하기 때문에 같은 문제가 그대로 나타납니다.

차이가 있다면 실행 시간 측정은 빠져도 큰 문제가 없지만, 트랜잭션이 빠지면 롤백되어야 할 데이터가 그대로 남는다는 점입니다.

이번에는 실제로 데이터가 남는 상황을 재현해 보겠습니다.

예제 코드는 Java 21, Spring Boot 3.5, Hibernate 6 환경에서 작성했습니다.


1. @Transactional은 어떻게 동작하는가

@Transactional이 붙은 빈은 프록시로 감싸집니다.

프록시는 메서드 호출을 가로채 다음과 같은 일을 합니다.

호출 → 프록시 → 트랜잭션 시작 → 원래 메서드 실행 → 커밋 또는 롤백

원래 메서드가 정상적으로 끝나면 커밋하고, 예외가 발생하면 롤백합니다.

즉 트랜잭션을 시작하고 끝내는 주체는 원래 객체가 아니라 프록시입니다.

이 문장이 이번 글의 전부라고 해도 됩니다.


2. 재현할 코드

세션을 저장한 뒤 예외를 던지는 메서드를 만들겠습니다.

트랜잭션이 걸려 있다면 저장은 롤백되어야 합니다.

@Service
public class SessionBatchService {

    private final WorkoutSessionRepository repository;

    /** 같은 객체 안에서 호출한다 */
    public void selfInvocation(Member member) {
        saveAndFail(member);
    }

    @Transactional
    public void saveAndFail(Member member) {
        System.out.println("    트랜잭션 활성 여부 : "
                + TransactionSynchronizationManager.isActualTransactionActive());
        repository.save(new WorkoutSession(member, LocalDate.now(), "롤백되어야 할 기록"));
        throw new IllegalStateException("의도적으로 발생시킨 예외");
    }
}

TransactionSynchronizationManager.isActualTransactionActive()는 지금 트랜잭션이 실제로 열려 있는지 알려줍니다.

이 값과 저장된 데이터 수를 함께 확인하겠습니다.

먼저 빈이 프록시인지 확인해 보겠습니다.

SessionBatchService 클래스 : com.example.workoutlog.service.SessionBatchService$$SpringCGLIB$$0

@Transactional을 붙였다는 이유만으로 프록시가 만들어졌습니다.


3. 외부에서 호출하면 정상 동작한다

먼저 saveAndFail()을 밖에서 직접 호출해 보겠습니다.

batch.saveAndFail(member);

결과는 다음과 같습니다.

  [1] 외부에서 @Transactional 메서드를 직접 호출
    트랜잭션 활성 여부 : true
    예외 발생 : 의도적으로 발생시킨 예외
    남은 세션 수 : 0

트랜잭션이 열렸고, 예외가 발생하자 저장이 롤백되어 데이터가 남지 않았습니다.

기대한 대로입니다.


4. 자기 호출하면 트랜잭션이 사라진다

이번에는 selfInvocation()을 호출해 보겠습니다.

이 메서드는 안에서 saveAndFail()을 부르기만 합니다.

batch.selfInvocation(member);

결과는 다음과 같습니다.

  [2] 같은 객체 안에서 호출 (자기 호출)
    트랜잭션 활성 여부 : false
    예외 발생 : 의도적으로 발생시킨 예외
    남은 세션 수 : 1

트랜잭션이 열리지 않았고, 데이터가 그대로 남았습니다.

같은 메서드이고 같은 예외인데 결과가 달라졌습니다.

차이는 어떻게 호출했는지 하나뿐입니다.

왜 이런 일이 생기는가

호출 경로를 그려 보면 명확해집니다.

[1] 외부 호출
    호출하는 쪽 → 프록시 → 트랜잭션 시작 → saveAndFail()

[2] 자기 호출
    호출하는 쪽 → 프록시 → 트랜잭션 시작 → selfInvocation()
                                              └→ this.saveAndFail()

selfInvocation()은 프록시를 거쳐 들어왔으므로 프록시가 트랜잭션을 열려고 합니다.

하지만 selfInvocation()에는 @Transactional이 없으므로 아무 일도 하지 않습니다.

그리고 그 안에서 부르는 saveAndFail()은 이미 원래 객체 안으로 들어온 상태입니다.

자바에서 같은 객체의 메서드를 부르면 this.saveAndFail()이 됩니다.

프록시를 거치지 않으므로 애노테이션은 읽히지도 않습니다.

그런데 데이터는 왜 남았을까

트랜잭션이 없었는데 저장은 어떻게 되었을까요.

JpaRepository의 save() 자체에 @Transactional이 붙어 있기 때문입니다.

외부 트랜잭션이 없으면 save()가 자기 트랜잭션을 열어 저장하고 곧바로 커밋합니다.

그 뒤에 예외가 발생해도 이미 커밋이 끝났으므로 되돌릴 것이 없습니다.

트랜잭션이 아예 없는 것보다 위험한 상황입니다.

저장은 성공했는데 그 뒤 작업이 실패해도 아무도 되돌려 주지 않기 때문입니다.


5. 해결 방법

다른 빈으로 분리한다

가장 권장되는 방법입니다.

트랜잭션이 필요한 메서드를 별도 빈으로 옮기면 호출이 프록시를 거치게 됩니다.

@Service
public class SessionWriter {

    @Transactional
    public void saveAndFail(Member member) { }
}

@Service
public class SessionBatchService {

    private final SessionWriter writer;

    public void viaAnotherBean(Member member) {
        writer.saveAndFail(member);   // 다른 빈이므로 프록시를 거친다
    }
}

확인해 보면 다음과 같습니다.

  [3] 다른 빈을 통해 호출
    트랜잭션 활성 여부 : true
    예외 발생 : 의도적으로 발생시킨 예외
    남은 세션 수 : 0

정상적으로 롤백되었습니다.

트랜잭션 경계를 위로 올린다

애초에 selfInvocation() 쪽에 @Transactional을 붙이는 방법도 있습니다.

바깥 메서드가 트랜잭션을 열면 안쪽 호출은 그 트랜잭션 안에서 실행됩니다.

대부분의 경우 이 편이 자연스럽습니다.

하나의 작업 단위가 어디까지인지 다시 생각해 보라는 신호이기 때문입니다.

권장되지 않는 방법

자기 자신을 주입받거나, AopContext.currentProxy()로 프록시를 직접 꺼내 호출하는 방법도 있습니다.

동작은 하지만 코드를 읽는 사람이 이유를 알기 어렵고, 프록시 구조에 의존하게 됩니다.

설계를 손보는 편이 낫습니다.


6. 또 하나의 함정 - 체크 예외

자기 호출과는 별개로, 예외의 종류 때문에 롤백되지 않는 경우도 있습니다.

다음 메서드는 외부에서 정상적으로 호출됩니다.

@Transactional
public void saveAndFailChecked(Member member) throws Exception {
    repository.save(new WorkoutSession(member, LocalDate.now(), "체크 예외 기록"));
    throw new Exception("체크 예외");
}

결과는 다음과 같습니다.

  [4] 체크 예외를 던지는 @Transactional 메서드
    예외 발생 : 체크 예외
    남은 세션 수 : 1

롤백되지 않았습니다.

스프링의 기본 롤백 규칙은 RuntimeException과 Error만 롤백하는 것입니다.

체크 예외는 롤백 대상이 아닙니다.

체크 예외에서도 롤백하려면 다음과 같이 지정해야 합니다.

@Transactional(rollbackFor = Exception.class)

7. private 메서드

@Transactional은 public 메서드에만 적용됩니다.

CGLIB 프록시는 대상 클래스를 상속해서 만들어지는데, private 메서드는 오버라이드할 수 없기 때문입니다.

문제는 애노테이션을 붙여도 아무런 경고 없이 무시된다는 점입니다.

자기 호출과 마찬가지로 조용히 동작하지 않으므로 주의해야 합니다.


마무리

이번에는 @Transactional을 붙였는데도 트랜잭션이 걸리지 않는 상황에 대해 알아봤습니다.

같은 메서드에 같은 예외를 던졌는데도, 밖에서 호출하면 롤백되고 같은 객체 안에서 호출하면 데이터가 남는 것을 직접 확인했습니다.

트랜잭션을 시작하는 주체가 원래 객체가 아니라 프록시이기 때문입니다.

자바에서 같은 객체의 메서드를 부르면 프록시를 거치지 않으므로 애노테이션이 읽히지 않습니다.

해결 방법은 호출이 프록시를 지나가도록 만드는 것이며, 다른 빈으로 분리하거나 트랜잭션 경계를 위로 올리면 됩니다.

또한 기본 롤백 규칙이 RuntimeException과 Error만 대상으로 한다는 점과, private 메서드에는 적용되지 않는다는 점도 함께 살펴봤습니다.

이것으로 스프링 컨테이너를 다루는 1부를 마칩니다.

객체를 만드는 통제권이 컨테이너로 넘어가는 것에서 시작해, 빈이 등록되고 생명주기를 거치며, 필요할 때 프록시로 감싸져 트랜잭션과 AOP가 적용되는 과정까지 살펴봤습니다.

다음 글부터는 JPA로 넘어갑니다.

먼저 영속성 컨텍스트를 다루겠습니다.

1차 캐시, 변경 감지, 쓰기 지연이 실제로 어떤 SQL을 만들어 내는지 로그로 확인하면서 정리하겠습니다.

반응형
'Web Backend/Spring' 카테고리의 다른 글
  • [Spring] AOP - 관점 지향 프로그래밍과 프록시
  • [Spring] 의존성 주입 - 생성자 주입을 권장하는 이유
  • [Spring] 빈 등록과 생명주기 - @Component, @Bean, @Configuration
  • [Spring] IoC와 DI - 제어의 역전이란 무엇인가
seunghwaan
seunghwaan
공부한 내용을 정리하는 개발 기록 블로그
    반응형
  • seunghwaan
    SH's Devlog
    seunghwaan
  • 전체
    오늘
    어제
    • 분류 전체보기 (164)
      • Android (62)
        • Basic (17)
        • Kotlin(Java) (14)
        • UI & Animation (1)
        • Compose (2)
        • Coroutines (1)
        • Dependency Injection (6)
        • RxJava (8)
        • BLE (3)
        • TDD (2)
        • JetPack (1)
        • NextStep (4)
        • Error Log (3)
      • Flutter (14)
        • Basic (5)
        • Dart (1)
        • State Management (2)
        • Widgets (4)
        • Error and Tips (2)
      • iOS (8)
        • Basic (0)
        • Swift (8)
      • Web Frontend (16)
        • Basic (0)
        • JavaScript (5)
        • TypeScript (0)
        • React (11)
      • Web Backend (5)
        • Spring (5)
      • CS(Computer Science) (18)
        • Network (4)
        • Database (10)
        • Design Pattern (1)
        • Computer Architecture (3)
        • Operating System (0)
      • Cloud (6)
        • AWS (6)
      • DevOps (25)
        • GIT (4)
        • CI CD (8)
        • Linux (4)
        • Docker (9)
        • Error Log (0)
      • 코딩테스트 (10)
        • DB (6)
        • 알고리즘 (4)
      • Mac Tip (0)
      • Language (0)
        • English (0)
        • Japanese (0)
      • Temporary (0)
  • 블로그 메뉴

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

  • 공지사항

  • 인기 글

  • 태그

    Web Backend
    spring
    Algorithm
    Kotlin
    MySQL
    Jenkins
    AWS
    BLE
    Web Frontend
    Swift
    Linux
    RxJava
    IOS
    FLUTTER
    cs
    Network
    시작하세요! 도커
    Computer Science
    JavaScript
    error
    docker
    di
    Dagger
    React
    gradle
    CICD
    Android
    database
    Dependency Injection
    컴퓨터공학
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
seunghwaan
[Spring] @Transactional이 동작하지 않는 이유 - 프록시 자기 호출
상단으로

티스토리툴바