스프링 백엔드 개발을 하다 보면 가장 많이 사용하는 어노테이션 중 하나가 바로 @Transactional입니다. 이를 선언적 트랜잭션 관리(Declarative Transaction Management)라고 부릅니다.
우리가 비즈니스 로직에만 집중할 수 있도록, 복잡한 트랜잭션 시작과 종료 로직을 마법처럼 처리해주는 이 기능은 내부적으로 어떻게 동작할까요? 그리고 실무에서 흔히 겪는 "어? 왜 롤백이 안 되지?" 하는 상황은 왜 발생하는 걸까요?
오늘은 스프링 트랜잭션의 동작 흐름과 핵심 주의사항을 정리해 보겠습니다.
@Transactional의 동작 과정을 이해하려면 다음 3가지 요소를 알아야 합니다.
클라이언트가 @Transactional이 붙은 서비스 메서드를 호출했을 때의 흐름은 다음과 같습니다.
DataSource를 통해 DB 커넥션을 생성(획득)하고, auto-commit을 false로 설정하여 트랜잭션을 시작합니다.ThreadLocal을 사용하여 멀티스레드 환경에서도 안전하게 보관됩니다.)PlatformTransactionManager): JDBC, JPA 등 기술마다 다른 트랜잭션 관리 코드를 추상화한 인터페이스입니다. 개발자는 기술이 바뀌어도 서비스 코드를 수정할 필요가 없습니다.Connection을 계속 넘겨주지 않아도 됩니다.이해를 돕기 위해 스프링이 생성하는 프록시 코드를 단순화해보면 아래와 같습니다.
1) 우리가 작성하는 서비스 코드
@Service
public class MemberService {
@Transactional
public void join(Member member) {
// 비즈니스 로직
memberRepository.save(member);
// 여기서는 별도의 커넥션 파라미터가 없어도
// 동기화 매니저에 있는 커넥션을 사용하여 저장함
}
}
2) 스프링이 만들어내는 프록시 코드 (핵심논리)
public class MemberServiceProxy { // 실제 서비스와 같은 인터페이스 구현 또는 상속
private final MemberService target; // 실제 비즈니스 로직 객체
private final PlatformTransactionManager transactionManager;
public void join(Member member) {
// 1. 트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
// 2. 실제 비즈니스 로직 호출
target.join(member);
// 3. 성공 시 커밋
transactionManager.commit(status);
} catch (Exception e) {
// 4. 예외 발생 시 롤백?! (주의: 예외 종류에 따라 다름)
transactionManager.rollback(status);
throw e;
}
}
}
요약
스프링 트랜잭션 AOP는 "프록시가 문지기 역할을 하여 트랜잭션을 여닫고, 트랜잭션 매니저가 기술을 추상화하며, 동기화 매니저가 커넥션을 배달해준다"라고 기억하면 됩니다.
앞서 살펴본 것처럼 스프링 트랜잭션은 프록시(Proxy)를 통해 동작합니다. 이 "프록시 방식"이기 때문에 발생하는 중요한 특징과 주의할 점들이 있습니다. 실무에서 가장 많이 마주치는 이슈인 트랜잭션 전파(Propagation)와 내부 호출(Self-Invocation) 문제를 알아보겠습니다.
서비스 로직이 복잡해지면 트랜잭션이 적용된 메서드가 또 다른 트랜잭션 메서드를 호출하는 경우가 생깁니다. 이때 트랜잭션은 어떻게 동작할까요? 합쳐질까요, 아니면 새로 생길까요? 이를 결정하는 것이 전파 속성(Propagation)입니다.
별다른 설정을 하지 않으면 디폴트는 REQUIRED입니다.
로그를 남기는 기능처럼, 본 로직이 실패해서 롤백되더라도 로그는 반드시 남겨야 하는 경우가 있습니다. 이때는 트랜잭션을 분리해야 합니다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveLog(Log log) {
// 외부 트랜잭션의 성공/실패와 무관하게 별도의 커넥션으로 동작
logRepository.save(log);
}
@Transactional을 썼는데 트랜잭션이 적용되지 않는 가장 흔한 실수가 바로 '같은 클래스 내의 메서드 호출'입니다.
(여기에 문제 상황 코드 예시를 넣어주세요)
위 코드에서 createOrder()를 호출하면, 내부에서 saveBill()을 호출할 때 트랜잭션이 적용되지 않습니다.
이유는 앞서 배운 프록시 동작 원리 때문입니다.
createOrder()를 호출합니다.createOrder()에는 @Transactional이 없으므로 프록시는 그냥 실제 객체(Target)의 createOrder()를 호출합니다.saveBill()을 호출할 때, 이것은 this.saveBill()입니다. 즉, 프록시를 거치지 않고 자기 자신의 메서드를 직접 호출하는 것입니다.가장 깔끔하고 권장되는 해결책은 별도의 클래스로 분리하는 것입니다.
@Service
public class OrderService {
private final BillService billService; // 별도 서비스 주입
public void createOrder() {
// ... 로직 ...
// 외부 객체의 메서드를 호출하므로 프록시를 거침 -> 트랜잭션 적용 OK ⭕️
billService.saveBill();
}
}
@Service
public class BillService {
@Transactional
public void saveBill() {
// ...
}
}
데이터를 조회만 하는 메서드나 서비스에는 가급적 읽기 전용 모드를 사용하는 것이 좋습니다.
@Service
@Transactional(readOnly = true) // 기본적으로 읽기 전용으로 설정
public class MemberService {
// 조회: 최적화 적용됨
public Member findOne(Long id) { ... }
// 변경: 쓰기 권한 필요하므로 오버라이딩
@Transactional
public void join(Member member) { ... }
}
* **JPA 사용 시:** 영속성 컨텍스트가 스냅샷을 만들지 않고, 변경 감지(Dirty Checking)를 수행하지 않아 메모리와 성능이 절약됩니다.
* **DB 부하 분산:** DB가 Master-Slave 구조일 때, Slave(읽기 전용) DB로 커넥션을 연결하도록 설정할 수도 있습니다.
스프링의 선언적 트랜잭션(@Transactional)은 매우 강력하지만, 그 기반 기술인 AOP와 프록시의 동작 원리를 이해하지 못하면 예상치 못한 버그를 만날 수 있습니다.