@Transactional
SpringBoot로 프로젝트를 해본 사람이라면 한 번쯤은 이 어노테이션을 써봤을 것이다. 이 기능은 메서드나 클래스에 적용하면 해당 메서드 혹은 클래스 내의 메서드들이 하나의 트랜잭션으로 묶인다.
즉, @Transactional은 스프링 프레임워크에서 데이터의 일관성을 보장하고 트랜잭션을 효과적으로 관리하기 위한 중요한 도구이다.
AOP(Aspect Oriented Programming)
AOP는 관점 지향 프로그래밍이라고도 하며, 애플리케이션의 핵심적인 기능에서 로깅, 트랜잭션, 보안 등 공통 관심사를 비즈니스 로직에서 분리하여 객체로 관리한다. 이를 통해 코드의 가독성, 유지보수성, 재사용성을 높인다.
- Aspect: 부가기능 모듈. 여기서 부가기능이란 로깅, 트랜잭션, 보안 등과 같이 핵심 로직을 수행하는 비즈니스 메서드에 공통으로 적용되는 기능
- Advice: 실제로 부가기능을 구현한 코드. 이 부가기능은 언제 적용될지를 결정하는데, 예를 들어 메서드 호출 전에 실행되거나, 메서드 호출 후에 실행되거나, 메서드가 예외를 던졌을 때 실행되는 등의 시점을 정할 수 있다.
- Pointcut: Advice를 적용할 대상(메서드)을 선정하는 역할. 즉, 어떤 클래스의 어떤 메서드에 Advice가 적용될 것인지를 결정
- Join Point: Advice가 적용될 수 있는 위치. 예를 들어 메서드 호출이나 예외 발생 등이 될 수 있다.
@Transactional과 AOP의 관계?
@Transactional를 사용하여 스프링이 AOP를 사용해서 트랜잭션을 편리하게 처리해 준다. @Transactional를 적용하면 Proxy객체가 요청을 먼저 받아서 실제 객체를 호출해준다. AOP를 적용하면 스프링은 대상 객체 대신에 프록시를 SpringBean으로 등록한다.
Proxy 객체?
실제 객체를 감싸서 그 객체에 대한 접근을 제어하거나 부가 기능을 제공하는 대리 객체이다. 스프링이 @Transactional이 붙은 클래스나 메서드를 발견하면, 해당 객체의 프록시를 생성한다.
Spring Bean?
스프링 IoC(Inversion of Control) 컨테이너에 의해 인스턴스화, 관리, 생성되는 객체를 말한다. 이 객체들은 스프링 컨테이너에 의해 생명 주기를 관리받으며, 스프링 빈으로 설정된 클래스는 필요에 따라 스프링 IoC 컨테이너에 의해 자동으로 주입(Dependency Injection)된다.
AOP 내부 동작 프로세스
- 스프링은 실제 객체를 생성한다. 스프링 컨테이너는 실제 객체를 생성하고 초기화한다.
- BeanPostProcessor 구현체 중에서 @Transactional를 처리하는 구현체가 @Transactional이 붙은 메서드를 가진 객체를 찾으면, 해당 객체를 프록시 객체로 생성한다.
- 프록시 객체가 생성되면, 스프링 컨테이너는 실제 객체 대신 프록시 객체가 빈으로 등록되어 관리한다. 이렇게 함으로써, 클라이언트는 트랜잭션 처리에 대해 알 필요 없이 원하는 메서드를 호출하면 된다.
- 클라이언트가 @Transactional이 붙은 메서드를 호출하면, 그 호출은 프록시 객체에게 전달된다. 프록시 객체는 트랜잭션 처리 과정을 거친 후 실제 객체의 메서드를 호출한다.
- 클라이언트의 메서드 호출이 프록시 객체에 의해 가로채지면, 프록시 객체는 트랜잭션을 시작한다. 그런 다음, 실제 객체의 메서드를 호출하고 메서드 실행이 완료되면 트랜잭션을 커밋한다. 만약 메서드 실행 중 예외가 발생하면 트랜잭션을 롤백한다.
- 마지막으로, 프록시 객체는 메서드의 실행 결과를 클라이언트에게 반환한다.
Proxy Bean 생성 시점
Spring Boot 애플리케이션 시작 순서:
1. 컴파일 → .class 파일 생성
2. JVM 시작
3. Spring Boot main() 메서드 실행
4. Spring 컨테이너 초기화 시작
├── 4-1. 컴포넌트 스캔
├── 4-2. Bean 정의 등록
├── 4-3. Bean 인스턴스 생성 ← 여기서 프록시 생성!
├── 4-4. 의존성 주입
└── 4-5. 초기화 콜백
5. 애플리케이션 준비 완료
Proxy가 실제 메서드를 감싸는 구조
// 원본 메서드
@Transactional
public void saveUser(User user) {
userRepository.save(user); // 실제 비즈니스 로직
}
// 프록시가 감싸는 형태
public void saveUser(User user) {
// === 프록시가 추가하는 부분 (Before) ===
트랜잭션 시작();
try {
// === 실제 메서드 호출 ===
실제객체.saveUser(user); // 원본 비즈니스 로직 실행
// === 프록시가 추가하는 부분 (After) ===
트랜잭션 커밋();
} catch (Exception e) {
// === 프록시가 추가하는 부분 (Exception) ===
트랜잭션 롤백();
throw e;
}
}
트랜잭션 사용 시 주의해야 할 점
트랜잭션을 적용하려면 항상 프록시를 통해서 대상 Target를 호출해야 한다. 프록시에서 트랜잭션을 적용하고 이후 Target 객체를 호출하게 되는 로직이기 때문이다.
프록시를 거치지 않고 Target을 호출하게 된다면 AOP가 적용되지 않고, 트랜잭션도 적용되지 않는다.
어떤 상황일 때 문제가 발생할까?
CallService라는 객체가 있다고 가정하고, VIP 멤버일 경우 특별 주문이 가능하다고 생각하자.
그럼 아래와 같이 코드를 생각할 수 있다.
@Service
@RequiredArgsConstructor
public class CallService {
private final UserRepositry userRepository;
...
public void checkVipMember(Long memberId) { // 외부 메서드
Rank userRank = UserRepository.findRankById(memberId);
if(userRank.equals(Rank.VIP) {
specialOrderByVipMember(userRank, memberId); // 내부 메서드
}
}
@Transactional // 트랜잭션 AOP 적용
public Order specialOrderByVipMember(Rank userRank, Long memberId) {
order(userRank, memberId); // 비즈니스 로직
System.out.println("VIP 회원의 특별주문이 완료되었습니다.");
}
}
CallService에는 외부 메서드(checkVipMember)와 내부 메서드(specialOrderByVipMember)가 있다.
이때 외부 메서드 호출을 거쳐 내부 메서드를 호출할 경우 @Transactional은 적용되지 않는다.
@Transactional을 붙였는데 왜 적용이 안되지?
- 클라이언트는 callService.external()를 호출한다. callService 객체는 스프링 컨테이너에서 관리하는 트랜잭션 프록시다.
- // 스프링이 CallService를 발견하고 검사
CallService 클래스에 @Transactional이 붙은 메서드가 있는가?
specialOrderByVipMember()에 @Transactional 발견!
따라서 CallService 전체를 프록시로 감싸야함
- // 스프링이 CallService를 발견하고 검사
- callService의 트랜잭션 프록시가 호출된다.
- @Transactional이 없으므로 external()는 실제 callService 객체 인스턴스의 external()를 호출한다.
- external()은 내부에서 internal() 메서드를 호출한다.
여기서 문제가 발생한다.
내부 메서드를 호출하게 되면 this.internal()를 호출하면서 this는 객체 target를 가리키고 이는 실제 객체를 호출하게 되는 것이다. 결국 내부 호출은 프록시를 거치지 않으므로 AOP를 거치지 않은 internal() 메서드는 트랜잭션을 적용할 수 없다.
해결: 내부 메서드를 별도의 클래스로 분리하자
해결방법은 단순하다. 외부메서드에서 호출되는 내부 메서드를 내부가 아닌, 별도의 클래스로 분리하면 된다.
@Service
@RequiredArgsConstructor
public class CallService {
private final UserRepositry userRepository;
private final InternalService internalService;
...
public void checkVipMember(Long memberId) { // 외부 메서드
Rank userRank = UserRepository.findRankById(memberId);
if(userRank.equals(Rank.VIP) {
internalService.specialOrderByVipMember(userRank, memberId); // 내부 메서드
}
}
}
@Service
public class InternalService {
@Transactional // 트랜잭션 AOP 적용
public Order specialOrderByVipMember(Rank userRank, Long memberId) {
order(userRank, memberId); // 비즈니스 로직
System.out.println("VIP 회원의 특별주문이 완료되었습니다.");
}
}
- 클라이언트는 callService.external()를 호출한다. callService 객체는 스프링 컨테이너에서 관리하는 트랜잭션 프록시가 아닌 실제 객체 인스턴스이다.
- callService는 주입받은 internalService.internal() 메서드를 호출한다.
- internalService 객체는 @Transactional이 붙어있는 메서드가 존재해 스프링 컨테이너에서 관리하는 트랜잭션 프록시이다.
- 트랜잭션 프록시에서 트랜잭션 적용 후 실제 internalService 객체에서 internal()를 호출한다.
외부 메서드에서 @Transactional이 걸린 내부 메서드를 호출할 때 적용이 되지 않는 이유는 이처럼 트랜잭션 프록시를 거치지 않은 실제 객체 메서드를 호출해서 그렇다. 원리를 잘 기억해야 이후에 이슈가 발생해도 능동적으로 대처할 수 있을 것이다.
그 외의 해결방법:
Self-Injection
@Service
@RequiredArgsConstructor
public class CallService {
@Autowired
private CallService self; // 자기 자신을 주입 (프록시 버전)
private final UserRepositry userRepository;
...
public void checkVipMember(Long memberId) { // 외부 메서드
Rank userRank = UserRepository.findRankById(memberId);
if(userRank.equals(Rank.VIP) {
// 이제 @Transactional이 동작함!
self.specialOrderByVipMember(userRank, memberId); // 내부 메서드
}
}
@Transactional // 트랜잭션 AOP 적용
public Order specialOrderByVipMember(Rank userRank, Long memberId) {
order(userRank, memberId); // 비즈니스 로직
System.out.println("VIP 회원의 특별주문이 완료되었습니다.");
}
}
장점:
- 구현 간단
- 기존 구조 유지
단점:
- 순환 참조 경고 발생 가능
- 코드 의도가 명확하지 않음
이외의 궁금증
@Transactional이 걸린 메서드만 프록시가 개별적으로 감싸는걸까?
A. 트랜잭션 프록시는 클래스를 감싼다.
- 클래스 전체가 프록시로 감싸짐
메서드만 개별적으로 감싸지지 않음
Spring이 생성하는 프록시 클래스
// Spring이 런타임에 생성하는 프록시 (CGLIB 방식)
public class UserService$$EnhancerBySpringCGLIB extends UserService {
private final UserService target; // 실제 UserService 객체
@Override
public void normalMethod() {
// @Transactional이 없으므로 바로 실제 메서드 호출
target.normalMethod();
}
@Override
public void saveUser(User user) {
// @Transactional이 있으므로 트랜잭션 처리
TransactionManager.begin();
try {
target.saveUser(user); // 실제 메서드 호출
TransactionManager.commit();
} catch (Exception e) {
TransactionManager.rollback();
throw e;
}
}
}
원본 클래스 객체도 빈으로 등록되고 프록시 객체도 빈으로 등록되나?
A. 오직 프록시 객체만 Bean으로 등록
- 프록시 객체: Bean으로 등록됨
- 원본 객체: Bean으로 등록되지 않음 (프록시 내부에 포함됨)
Component Scan과 Bean 등록 과정
- Component Scan: @Service 애노테이션이 붙은 UserService 클래스 발견
- Bean 정의 등록: UserService 클래스에 대한 Bean 정의 생성
- Bean 인스턴스 생성:
- 원본 UserService 객체 생성 (임시)
- @Transactional 검사 후 프록시 생성
- 프록시 객체만 Bean으로 등록
- 원본 객체는 프록시 내부로 이동
최종 Bean 등록 상태
- 등록됨: UserService$$CGLIB (프록시)
- 등록 안됨: UserService (원본)