이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- Spring의
@Transactional은 DB 커넥션과 어떻게 연결되는가?- 프록시 기반 트랜잭션에서 self-invocation이 왜 문제가 되는가?
- JPA flush와 트랜잭션 커밋은 어떤 관계인가?
개요
Spring의 @Transactional은 DB 트랜잭션을 추상화한다. 개발자는 애너테이션을 붙이지만, 내부에서는 TransactionManager가 커넥션을 획득하고 트랜잭션을 시작하며, 성공 시 커밋하고 실패 시 롤백한다.
Spring Transaction은 AOP 프록시와 연결되므로 Spring AOP에서 배운 proxy 흐름이 그대로 중요하다.
원리
흐름은 다음과 같다.
Client
Transaction Proxy
TransactionManager begin
Connection 획득
Target method 실행
flush
commit 또는 rollback
Connection 반납@Transactional이 붙은 메서드를 외부에서 호출하면 Proxy가 트랜잭션을 열고 닫는다. 같은 클래스 내부에서 this.inner()로 호출하면 Proxy를 거치지 않아 트랜잭션 설정이 적용되지 않을 수 있다.
이 흐름은 AOP와 같다. public method라 해도 proxy를 거치지 않으면 transaction advice가 실행되지 않는다. 반대로 proxy를 통과했다면 메서드 안에서 첫 DB 접근 시점에 connection을 실제로 획득할 수 있으며, 트랜잭션이 끝날 때 commit/rollback과 함께 connection을 반환한다.
Self Invocation
다음 코드는 기대와 다르게 동작할 수 있다.
@Service
public class OrderService {
public void placeOrder(OrderCommand command) {
saveOrder(command);
}
@Transactional
public void saveOrder(OrderCommand command) {
orderRepository.save(command.toOrder());
}
}placeOrder 내부의 saveOrder 호출은 같은 객체 내부 호출이다. 프록시를 통과하지 않으면 @Transactional이 적용되지 않을 수 있다.
해결은 책임을 다른 Bean으로 나누는 것이다.
@Service
public class OrderFacade {
private final OrderWriteService orderWriteService;
public void placeOrder(OrderCommand command) {
orderWriteService.saveOrder(command);
}
}
@Service
public class OrderWriteService {
@Transactional
public void saveOrder(OrderCommand command) {
orderRepository.save(command.toOrder());
}
}테스트에서 self-invocation 문제를 잡으려면 “예외가 났는가”보다 “실제로 commit/rollback 되었는가”를 확인한다. 메모리 DB나 mock repository만 쓰면 proxy 적용 실패를 놓치기 쉽다.
Flush와 Commit
JPA에서 엔티티 변경은 즉시 SQL로 나가지 않을 수 있다. 영속성 컨텍스트가 변경을 추적하다가 flush 시점에 SQL을 보낸다.
@Transactional
public void changeName(Long memberId, String name) {
Member member = memberRepository.findById(memberId).orElseThrow();
member.changeName(name);
// 명시적 save가 없어도 flush 시 update 가능
}Flush는 보통 transaction commit 전, JPQL 실행 전 등에 발생할 수 있다. 중요한 점은 DB 제약 위반이나 lock 획득이 메서드 중간이 아니라 flush 시점에 드러날 수 있다는 것이다.
명시적 flush()는 오류를 빨리 드러내는 데 도움이 될 수 있지만, 남발하면 DB 왕복과 lock 시점을 앞당긴다.
@Transactional
public void issueCoupon(IssueCouponCommand command) {
couponIssueRepository.save(command.toIssue());
entityManager.flush(); // Unique 위반을 여기서 확인하고 싶을 때만 신중히 사용
}이런 코드는 “왜 중간 flush가 필요한가”를 주석이나 테스트로 설명할 수 있어야 한다.
readOnly
읽기 전용 트랜잭션은 의도를 드러낸다.
@Transactional(readOnly = true)
public MemberProfile getProfile(Long memberId) {
return memberRepository.findProfile(memberId);
}Hibernate에서는 readOnly가 flush mode 최적화와 연결될 수 있다. 그러나 readOnly가 DB 부하를 자동으로 없애지는 않는다. 느린 읽기 쿼리는 여전히 느리다.
또한 Spring의 readOnly=true는 주로 의도와 최적화 힌트다. DB에 강제 read-only transaction을 설정하는지, Hibernate dirty checking을 얼마나 줄이는지는 TransactionManager, JPA provider, DB driver에 따라 다를 수 있다. 따라서 readOnly 메서드에서 엔티티 변경을 금지하는 팀 규칙과 테스트가 필요하다.
실전 팁
@Transactional은 public service method 경계에 둔다.- self-invocation 문제를 만나면 구조를 나눈다.
- JPA 변경 감지는 flush 시 SQL로 변환된다는 점을 기억한다.
- readOnly는 읽기 의도와 최적화 힌트로 사용하지만 성능 보증으로 오해하지 않는다.
- transaction 로그와 SQL 로그를 함께 보면 커밋/롤백 시점을 이해하기 쉽다.
- propagation, isolation, timeout, readOnly 설정은 기본값에 기대지 말고 중요한 서비스 메서드에서 의도를 명시한다.
org.springframework.transaction로그와 Hibernate SQL 로그를 함께 보면 begin/commit/rollback, flush 시점을 학습하기 좋다.- 통합 테스트에서 checked exception, RuntimeException, self-invocation, readOnly 변경 시나리오를 확인한다.
위험 신호!
- private 메서드에
@Transactional을 붙인다. - 같은 클래스 내부 호출에 트랜잭션 전파를 기대한다.
@Transactional안에서 대량 엔티티를 로딩하고 오래 들고 있다.- flush 시점에 터지는 예외를 예상하지 못한다.
- readOnly인데 엔티티를 변경하는 코드가 있다.
- mock 기반 테스트만으로 transaction proxy 적용 여부를 검증했다고 생각한다.
- readOnly를 “DB가 절대 write를 막아준다”는 의미로 오해한다.
- 중간
flush()를 성능 최적화처럼 사용한다.
확인 질문
- Spring의
@Transactional이 self-invocation에서 적용되지 않을 수 있는 이유는 무엇인가?- 같은 객체 내부 호출은 Transaction Proxy를 통과하지 않기 때문이다.
- JPA flush를 이해해야 하는 이유는 무엇인가?
- SQL 실행, 제약 위반, lock 획득, update 발생 시점이 flush와 연결될 수 있기 때문이다.
- readOnly transaction을 성능 보증으로 오해하면 안 되는 이유는 무엇인가?
- 읽기 의도를 표현하고 일부 최적화에 도움을 줄 수 있지만, 나쁜 SQL과 인덱스 문제를 해결하지는 못하기 때문이다.
- Spring Transaction 문제를 통합 테스트로 확인해야 하는 이유는 무엇인가?
- proxy 적용, 예외별 rollback, flush 시점, 실제 DB 제약 위반은 mock만으로는 실제 commit/rollback 결과를 검증하기 어렵기 때문이다.