Spring에서 JPA를 사용할 때 트랜잭션 관리를 위해 사용되며, 데이터베이스의 원자성, 일관성, 격리성, 영속성 즉, ACID 특성을 보장합니다.
프록시에 대한 이해가 부족하지만 다음에 좀 더 알아보는 걸로 하고 넘어가도록 하자!
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
private String name;
public Member() {}
public Member(String name) { this.name = name; }
}
public interface MemberRepository extends JpaRepository<Member, Long> {}
@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));
// 예외 없이 정상 종료 → 커밋
}
}
@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. Self‑Invocation 문제 ===
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
memberService.addAndFail("T1") 호출 시 런타임 예외가 발생하며 저장된 엔티티가 롤백되어 DB에 남지 않습니다.memberService.addCommit("T2") 호출 시 별다른 예외 없이 커밋되어 “T2” 엔티티가 영구 저장됩니다. → @Transactional이 붙은 메서드는 프록시를 거쳐 호출될 때, 예외 여부에 따라 자동으로 커밋 또는 롤백이 이루어짐을 보여줍니다.SelfInvocationService) 내부에서 this.addWithTx("noTx")로 트랜잭션 메서드를 호출하면, AOP 프록시가 개입하지 않아 트랜잭션이 전혀 생성되지 않습니다.addWithTx 안에서 예외가 터져도 롤백되지 않고, “noTx” 레코드가 DB에 남아 있게 됩니다. → “프록시 우회(self‑invocation)”가 발생하면 @Transactional이 무력화된다는 점을 알 수 있습니다.ProxyCorrectService)을 통해 selfInvocationService.addWithTx("withTx")를 호출하면, 프록시가 정상 작동하여 예외 시 롤백이 수행됩니다.문제상황
결과
→ 트랜잭션 경계를 명확히 분리하거나 Propagation.NESTED 등을 사용해 내부 오류가 외부까지 전이되지 않도록 설계해야 된다.
@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를 감지하고 예외를 던짐foo 레코드가 남지 않지만, outer 입장에선 예외를 잡았음에도 트랜잭션 전체가 롤백됨뭔가 많이 찾아보고 이해를 시도했지만 결국 “내부 실패 → 외부 예외 처리 → 커밋 시점 실패”라는 흐름이 고착화되어, 개발자는 의도와 달리 외부에서 예외를 잡아도 데이터가 영구 저장되지 못하고 전체가 롤백된다는 점을 공부했던 것이였다. 추후에 이 문제를 해결하는 방법들에 대해서도 찾아볼 필요가 있을 것 같다.