주문을 처리한다고 가정해보자. 재고를 줄이고, 결제를 기록하고, 주문 상태를 업데이트하는 세 가지 DB 작업이 필요하다. 재고는 줄었는데 결제 기록 중에 오류가 났다면 어떻게 해야 할까. 세 작업이 모두 성공하거나, 하나라도 실패하면 전부 취소되어야 한다. 이 "전부 아니면 전무" 보장이 트랜잭션이다.
직접 트랜잭션을 제어하려면 연결을 열고, commit과 rollback을 명시적으로 호출해야 한다. Spring은 @Transactional 하나로 이 반복 작업을 대신한다. 그런데 어노테이션 하나가 어떻게 트랜잭션을 처리하는 걸까.
@Transactional은 해당 메소드를 직접 수정하지 않는다. 대신 Spring이 애플리케이션 시작 시점에 @Transactional이 붙은 클래스를 감지하면, 실제 객체 대신 프록시 객체를 컨테이너에 등록한다.
이 작업을 담당하는 게 BeanPostProcessor다. 빈이 생성될 때마다 후처리로 끼어들어 @Transactional이 있으면 프록시로 교체한다.
// BeanPostProcessor가 하는 일 (개념적으로)
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (hasTransactionalAnnotation(bean)) {
return createProxy(bean); // 실제 빈 대신 프록시 등록
}
return bean;
}
이후 스프링 애플리케이션 컨텍스트에서 빈을 가져오면 실제 OrderServiceImpl이 아니라 프록시 객체가 들어온다. 메소드를 호출하면 프록시가 먼저 받아서 트랜잭션을 시작하고, 실제 메소드를 실행한 뒤 성공이면 commit, 예외면 rollback한다.
런타임마다 어노테이션을 체크하지 않고 시작 시점에 한 번만 프록시로 교체해두는 이유는 성능 때문이다. 매 호출마다 리플렉션으로 어노테이션을 읽는 비용을 없앤다.
Spring은 프록시를 만드는 방식으로 JDK Dynamic Proxy와 CGLIB 두 가지를 지원한다.
JDK Dynamic Proxy는 인터페이스를 구현한 프록시 객체를 런타임에 동적으로 생성한다. 인터페이스가 있을 때만 사용할 수 있다.
OrderService proxy = (OrderService) Proxy.newProxyInstance(
classLoader,
new Class[]{OrderService.class},
new TransactionInterceptor()
);
class OrderServiceProxy implements OrderService { ... }
(OrderService) orderServiceProxy; // 캐스팅 가능
(OrderServiceImpl) orderServiceProxy; // 캐스팅 불가 — ClassCastException
OrderServiceProxy는 위처럼 OrderService 인터페이스를 구현한 또 다른 구현체다. OrderServiceImpl 타입으로 캐스팅하려 하면 서로 다른 구현체 사이의 캐스팅이므로 ClassCastException이 발생한다.
이 문제 때문에 Spring Boot 2.x부터는 CGLIB를 기본값으로 사용한다. CGLIB는 대상 클래스를 직접 상속한 서브클래스를 런타임에 생성한다.
class OrderServiceImpl$$EnhancerByCGLIB extends OrderServiceImpl {
@Override
public void placeOrder() {
try {
super.placeOrder(); // 실제 메소드 실행
commit();
} catch (Exception e) {
rollback();
throw e;
}
}
}
OrderServiceImpl을 직접 상속하므로 구체 클래스 타입으로 받아도 문제없다. 단, 상속 방식이기 때문에 final에 대한 제약이 있다.
final 메소드는 클래스 자체는 프록시로 만들 수 있지만 해당 메소드를 오버라이드할 수 없다. 트랜잭션이 에러 없이 조용히 적용되지 않는다.
@SpringBootTest
class TransactionalTest {
interface OrderService {
@Transactional
void placeOrder();
}
static class OrderServiceImpl implements OrderService {
@Override
public final void placeOrder() { // final 메소드
boolean active = TransactionSynchronizationManager.isActualTransactionActive();
System.out.println("트랜잭션 활성화: " + active); // false — 조용히 무시
}
}
@TestConfiguration
static class Config {
@Bean
OrderService orderService() { return new OrderServiceImpl(); }
}
@Autowired OrderService orderService;
@Test
void checkFinalMethod() {
orderService.placeOrder(); // false
}
}
final 클래스는 CGLIB가 상속 자체를 할 수 없어서 애플리케이션 시작 시점에 IllegalStateException이 발생하며 뜨지 않는다.
@SpringBootTest
class TransactionalTest {
interface OrderService {
@Transactional
void placeOrder();
}
static final class OrderServiceImpl implements OrderService { // final 클래스
@Override
public void placeOrder() { ... }
}
@TestConfiguration
static class Config {
@Bean
OrderService orderService() { return new OrderServiceImpl(); }
}
@Test
void checkFinalClass() {
// → Failed to load ApplicationContext (시작 시점 에러)
}
}
final 클래스는 뜨자마자 에러가 터지니 바로 인지할 수 있는데, final 메소드는 서버가 정상적으로 올라가기 때문에 트랜잭션이 안 걸린다는 사실 자체를 모르고 지나치기 쉬우니, final 메소드를 사용할 때는 주의가 필요하다.
@Transactional을 어디에 달든 Spring이 찾아내는 건 AbstractFallbackTransactionAttributeSource가 아래 순서로 탐색하기 때문이다.
1. 구현 메소드 (OrderServiceImpl.placeOrder)
2. 구현 클래스 (OrderServiceImpl)
3. 원본 인터페이스 메소드 (OrderService.placeOrder)
4. 원본 인터페이스
더 구체적인 범위가 항상 우선이다. 메소드에 달면 클래스 레벨 설정을 오버라이드한다.
@Transactional(timeout = 30) // 클래스 레벨
public class OrderService {
public void placeOrder() { } // timeout=30 적용
@Transactional(readOnly = true)
public void getOrder() { } // readOnly=true 적용 (메소드 우선)
}
인터페이스에 @Transactional을 달아도 동작한다. 3번 탐색 단계에서 인터페이스 메소드까지 확인하기 때문이다. 아래 테스트로 확인할 수 있다.
@SpringBootTest
class TransactionalTest {
interface OrderService {
@Transactional
void placeOrder(); // 인터페이스에 @Transactional
}
interface OrderService2 {
void placeOrder(); // @Transactional 없음
}
static class OrderServiceImpl implements OrderService {
@Override
public void placeOrder() {
boolean active = TransactionSynchronizationManager.isActualTransactionActive();
System.out.println("트랜잭션 활성화: " + active); // true
}
}
static class OrderServiceImpl2 implements OrderService2 {
@Override
public void placeOrder() {
boolean active = TransactionSynchronizationManager.isActualTransactionActive();
System.out.println("트랜잭션 활성화: " + active); // false
}
}
@TestConfiguration
static class Config {
@Bean OrderService orderService() { return new OrderServiceImpl(); }
@Bean OrderService2 orderService2() { return new OrderServiceImpl2(); }
}
@Autowired OrderService orderService;
@Autowired OrderService2 orderService2;
@Test
void checkInterfaceTransactional() {
orderService.placeOrder(); // true
orderService2.placeOrder(); // false
}
}
다만 Spring 공식 문서는 클래스나 메소드에 직접 달 것을 권장한다. AspectJ weaving 모드에서는 인터페이스 어노테이션을 인식하지 못하는 경우가 있기 때문이다.
트랜잭션이 걸린 메소드에서 또 다른 트랜잭션 메소드를 호출하면 어떻게 될까. 이 동작을 결정하는 게 전파 규칙이다.
기존 트랜잭션이 있으면 합류하고, 없으면 새로 생성한다. outer와 inner가 같은 트랜잭션 안에 묶이므로 inner가 실패하면 outer까지 전체 rollback된다.
@Transactional // REQUIRED
public void outer() {
inner(); // 같은 트랜잭션에 합류
}
@Transactional // REQUIRED
public void inner() {
throw new RuntimeException(); // 전체 rollback
}
기존 트랜잭션을 중단하고 완전히 새 트랜잭션을 시작한다. 두 트랜잭션은 독립적으로 commit/rollback되므로 inner가 실패해도 outer는 영향 없다.
@Transactional
public void outer() {
try {
inner(); // 새 트랜잭션, 실패해도 outer는 무관
} catch (Exception e) {
// outer는 계속 진행 가능
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void inner() {
throw new RuntimeException(); // inner만 rollback
}
현재 트랜잭션 안에서 savepoint를 찍어 중첩 실행한다. inner가 실패하면 savepoint까지만 rollback되고 outer는 계속 진행할 수 있다. 단, outer가 실패하면 inner를 포함한 전체가 rollback된다.
outer 트랜잭션 시작
[savepoint]
inner 작업
inner 실패 → savepoint까지만 rollback
outer는 catch해서 계속 진행 → commit 가능
REQUIRES_NEW가 트랜잭션을 물리적으로 두 개 만드는 것과 달리, NESTED는 하나의 트랜잭션 안에서 savepoint로 구간을 나눈다.
checked exception은 컴파일러가 처리를 강제하는 예외다. 호출하는 쪽에서 반드시 try-catch나 throws로 대응해야 하며 IOException, SQLException 같은 게 여기 해당한다. Java 설계 철학상 "예측 가능하고 복구 가능한 예외"로 간주한다.
unchecked exception은 RuntimeException을 상속한 예외로, 컴파일러가 처리를 강제하지 않는다. NullPointerException, IllegalArgumentException 같은 게 여기 해당하며 "예측하지 못한 프로그래밍 오류"로 간주한다.
@Transactional은 이 철학을 그대로 따른다. unchecked exception은 기본 rollback 대상이고, checked exception은 기본적으로 rollback하지 않고 commit한다.
@Transactional
public void createOrder() throws IOException {
orderRepository.save(order);
throw new IOException("파일 오류"); // checked → rollback 안 됨, commit
}
@Transactional
public void createOrder() {
orderRepository.save(order);
throw new IllegalArgumentException(); // unchecked → rollback
}
checked exception의 기본 동작을 바꾸고 싶을 때 쓴다. 지정한 예외가 발생하면 rollback한다.
@Transactional(rollbackFor = IOException.class)
public void createOrder() throws IOException {
orderRepository.save(order);
throw new IOException(); // rollback
}
데이터 일관성을 보장할 수 있다는 장점이 있다. 다만 checked exception은 원래 복구 가능한 상황을 전제로 설계된 만큼, 무조건 rollback하는 게 의도에 맞지 않을 수 있어 신중하게 써야 한다.
unchecked exception의 기본 동작을 바꾸고 싶을 때 쓴다. 지정한 예외가 발생해도 rollback하지 않고 commit한다.
@Transactional(noRollbackFor = IllegalArgumentException.class)
public void createOrder() {
orderRepository.save(order);
throw new IllegalArgumentException(); // rollback 안 됨, commit
}
특정 비즈니스 예외에서 트랜잭션을 유지하고 싶을 때 유용하다. 단, 잘못 설정하면 롤백이 필요한 상황에서도 commit이 되어 데이터 불일치가 발생할 수 있어 주의가 필요하다.