@Transactionl - 롤백

ggwaang·2025년 4월 18일

  • 평소 @Transactional을 남발하던 나는 그렇게 남발하지 말라는 소리를 듣고 여러 글들을 찾아보던 중 https://techblog.woowahan.com/2606/
    라는 글을 읽게 되었고 흥미가 생겨 좀 더 찾아보게되었고, 아직 부족한 이해력과 실력이지만 글을 써보게 되었다.

@Transactional

Spring에서 JPA를 사용할 때 트랜잭션 관리를 위해 사용되며, 데이터베이스의 원자성, 일관성, 격리성, 영속성 즉, ACID 특성을 보장합니다.

  • 클래스에 어노테이션을 붙이면 클래스 내부의 모든 public 메서드가 전부 트랜잭션의 대상이 됩니다.
  • 메서드에만 트랜잭션을 적용할 수 있습니다.
  • Spring은 런타임 시 AOP 프록시를 생성해 트랜잭션 시작, 커밋, 롤백을 감싸서 처리합니다.
  • 자기 호출(self-invocation)
    • 같은 클래스 내부에서 한 메서드가 다른 메서드를 호출하면, 메서드 레벨의 트랜잭션이 적용되지 않는다. → 프록시가 가로채지 않기 때문에

프록시 패턴(Proxy Pattern)

  • 프록시 : 대리자, 대변인이라는 의미 → 대리자 역할을 하는 객체. 즉, 프록시에게 어떤 일을 대신 시키는 것
    • 어떤 객체를 사용하고자 할 때, 객체를 직접적으로 참조하는 것이 아닌 해당 객체를 대항하는 객체를 통해 대상 객체에 접근하는 방식으로 사용하면 해당 객체가 메모리에 존재하지 않아도 기본적인 정보를 참조하거나 설정할 수 있고, 실제 객체의 기능이 필요한 시점까지 객체의 생성을 미룰 수 있다.
  • 제어 흐름을 조정하기 위한 목적으로 중간에 계층을 도입하여 여러 기능을 추가하거나 제어하면서 실제 서비스를 대신 수행하는 패턴이다.

프록시에 대한 이해가 부족하지만 다음에 좀 더 알아보는 걸로 하고 넘어가도록 하자!

@Transactional의 동작방식 예시 코드로 살펴보기

  • Member

@Entity
public class Member {
    @Id 
    @GeneratedValue
    private Long id;
    
    private String name;
    
    public Member() {}
    
    public Member(String name) { this.name = name; }
}
  • MemberRepository
public interface MemberRepository extends JpaRepository<Member, Long> {}
  • MemberService
@Service
public class MemberService {
    private final MemberRepository repo;
    public MemberService(MemberRepository repo) { this.repo = repo; }

    @Transactional
    public void addAndFail(String name) {
        repo.save(new Member(name));
        // 강제 예외 발생 → 트랜잭션 롤백
        throw new RuntimeException("실패");
    }

    @Transactional
    public void addCommit(String name) {
        repo.save(new Member(name));
        // 예외 없이 정상 종료 → 커밋
    }
}
  • Self-Invocation 문제 이해를 위해 GPT에게 간단한 예시 코드를 부탁했다.
@Service
public class SelfInvocationService {
    private final MemberRepository repo;
    public SelfInvocationService(MemberRepository repo) { this.repo = repo; }

    public void outerMethod() {
        // 이 호출은 프록시를 거치지 않음 → addWithTx는 트랜잭션 없이 실행
        addWithTx("noTx");
    }

    @Transactional
    public void addWithTx(String name) {
        repo.save(new Member(name));
        throw new RuntimeException("실패 but no rollback because no Tx");
    }
}

콘솔 출력

=== 1. 정상 롤백 테스트 ===
Hibernate: 
    select
        next_val as id_val 
    from
        member_seq for update
Hibernate: 
    update
        member_seq 
    set
        next_val= ? 
    where
        next_val=?
Hibernate: 
    select
        count(*) 
    from
        member m1_0
DB count after rollback: 0

=== 2. 정상 커밋 테스트 ===
Hibernate: 
    select
        next_val as id_val 
    from
        member_seq for update
Hibernate: 
    update
        member_seq 
    set
        next_val= ? 
    where
        next_val=?
Hibernate: 
    insert 
    into
        member
        (name, id) 
    values
        (?, ?)
Hibernate: 
    select
        count(*) 
    from
        member m1_0
DB count after commit: 1

=== 3. SelfInvocation 문제 ===
Hibernate: 
    insert 
    into
        member
        (name, id) 
    values
        (?, ?)
Hibernate: 
    select
        count(*) 
    from
        member m1_0
DB count after self-invocation: 2

=== 4. 별도 빈 호출로 해결 ===
Hibernate: 
    select
        count(*) 
    from
        member m1_0
DB count after proxy call: 2

실행 결과를 통해 알 수 있는 점

  1. 정상적인 트랜잭션 커밋·롤백 동작 확인
    • memberService.addAndFail("T1") 호출 시 런타임 예외가 발생하며 저장된 엔티티가 롤백되어 DB에 남지 않습니다.
    • memberService.addCommit("T2") 호출 시 별다른 예외 없이 커밋되어 “T2” 엔티티가 영구 저장됩니다. → @Transactional이 붙은 메서드는 프록시를 거쳐 호출될 때, 예외 여부에 따라 자동으로 커밋 또는 롤백이 이루어짐을 보여줍니다.
  2. Self‑Invocation 문제의 실체
    • 같은 빈(SelfInvocationService) 내부에서 this.addWithTx("noTx")로 트랜잭션 메서드를 호출하면, AOP 프록시가 개입하지 않아 트랜잭션이 전혀 생성되지 않습니다.
    • 따라서 addWithTx 안에서 예외가 터져도 롤백되지 않고, “noTx” 레코드가 DB에 남아 있게 됩니다. → “프록시 우회(self‑invocation)”가 발생하면 @Transactional이 무력화된다는 점을 알 수 있습니다.
  3. 프록시를 경유한 호출로 문제 해결
    • 별도의 빈(ProxyCorrectService)을 통해 selfInvocationService.addWithTx("withTx")를 호출하면, 프록시가 정상 작동하여 예외 시 롤백이 수행됩니다.
    • 결과적으로 “withTx” 레코드는 저장되지 않고, DB 카운트가 변하지 않습니다. → 트랜잭션이 적용된 메서드는 “반드시” 스프링 컨테이너가 관리하는 프록시 빈을 통해 호출해야 한다는 교훈을 확인할 수 있습니다.

전역 rollback-only 마킹

  1. REQUIRED 공유 트랜잭션
    • @Transactional의 기본 전파 방식은 REQUIRED로, 내부 트랜잭션이 호출자의 트랜잭션 컨텍스트를 그대로 사용합니다.
      • 내부에서 예외 발생 시 같은 트랜잭션 전체가 영향을 받는다.
  2. 예외 발생 시 rollback-only 표시
    • Spring은 unchecked 예외(RuntimeException)가 트랜잭션 경계 안에서 발생하면 즉시 rollback-only 상태로 마킹한다.
      • rollback-only 상태로 표시된 트랜잭션은 더 이상 커밋할 수 없다.
  3. 외부에서 예외를 잡더라도 해제가 되지 않음
    • 외부 메서드가 try-catch로 내부 예외를 처리해도, 이미 설정된 rollback-only 플래그는 해제되지 않는다.
      • 커밋 대신 강제로 롤백하고, UnexpectedRollbackException을 던진다.

문제상황

  • 내부 예외가 트랜잭션 전체를 rollback-only로 표시 → 외부에서 잡더라도 커밋 불가

결과

  • UnexpectedRollbackException이 발생하며 전체 롤백

→ 트랜잭션 경계를 명확히 분리하거나 Propagation.NESTED 등을 사용해 내부 오류가 외부까지 전이되지 않도록 설계해야 된다.

예시 코드

  • Inner throws, Outer catches
@Service
public class InnerService {
    private final MemberRepository repo;
    public InnerService(MemberRepository repo) { this.repo = repo; }

    @Transactional
    public void innerSaveAndFail(String name) {
        repo.save(new Member(name));
        // 여긴 rollback‑only 마킹: RuntimeException 던짐
        throw new RuntimeException("inner failure");
    }
}
@Service
public class OuterService {
    private final InnerService inner;
    public OuterService(InnerService inner) { this.inner = inner; }

    @Transactional
    public void outerHandlesInner() {
        try {
            inner.innerSaveAndFail("foo");
        } catch (RuntimeException ex) {
            // 문제: 예외를 잡아도 이미 rollback‑only 상태라 commit 시 예외 발생
            System.out.println("Caught in outer: " + ex.getMessage());
        }
        // 여기서 반환되면, 트랜잭션 인터셉터가 commit 시도 → UnexpectedRollbackException
    }
}

결과

Hibernate: 
    select
        count(*) 
    from
        member m1_0
Before count: 0
Hibernate: 
    select
        next_val as id_val 
    from
        member_seq for update
Hibernate: 
    update
        member_seq 
    set
        next_val= ? 
    where
        next_val=?
Caught in outer: inner failure
Hibernate: 
    select
        count(*) 
    from
        member m1_0
After count: 0
>>> Runner caught: UnexpectedRollbackException
  • Caught in outer: outer가 inner의 RuntimeException을 정상적으로 잡음
  • UnexpectedRollbackException: 트랜잭션 인터셉터가 commit 시점에 rollback‑only를 감지하고 예외를 던짐
  • DB 상태foo 레코드가 남지 않지만, outer 입장에선 예외를 잡았음에도 트랜잭션 전체가 롤백됨

트랜잭션 경계 분리

  • 트랜잭션 경계 분리는 하나의 서비스 빈 내에서 여러 트랜잭션을 관리하는 대신, 각각의 트랜잭션 경계를 별도 빈으로 분리하는 패턴이다.
  • inner 로직의 실패가 outer 로직의 트랜잭션에 전파되지 않으므로, 독립적인 커밋, 롤백을 보장할 수 있다.

뭔가 많이 찾아보고 이해를 시도했지만 결국 “내부 실패 → 외부 예외 처리 → 커밋 시점 실패”라는 흐름이 고착화되어, 개발자는 의도와 달리 외부에서 예외를 잡아도 데이터가 영구 저장되지 못하고 전체가 롤백된다는 점을 공부했던 것이였다. 추후에 이 문제를 해결하는 방법들에 대해서도 찾아볼 필요가 있을 것 같다.

0개의 댓글