Spring을 사용하면서 @Transactional만큼 자주 쓰이면서도, 정확한 동작 원리는 모르고 쓰는 어노테이션도 드물 것 같습니다. "메소드에 붙이면 트랜잭션이 알아서 된다"는 건 알겠는데, 도대체 어떻게 알아서 되는 걸까요?
이번 글에서는 @Transactional이 AOP를 통해 어떻게 동작하는지, 트랜잭션이 언제 시작되고 언제 롤백되는지 초보자도 이해할 수 있도록 풀어보겠습니다.
트랜잭션이란?
본격적인 설명에 앞서, 트랜잭션이 뭔지 간단히 짚고 가겠습니다.
트랜잭션(Transaction)은 "더 이상 쪼갤 수 없는 작업의 단위"입니다. 은행 송금을 예로 들면:
1. A 계좌에서 10만원 출금
2. B 계좌에 10만원 입금
이 두 작업은 반드시 함께 성공하거나, 함께 실패해야 합니다. 1번만 성공하고 2번이 실패하면? A의 돈은 사라지고 B는 돈을 받지 못하는 끔찍한 상황이 발생합니다.
트랜잭션은 이런 상황을 방지합니다:
- 모두 성공하면: 변경사항을 확정합니다 (커밋, Commit)
- 하나라도 실패하면: 모든 변경을 취소합니다 (롤백, Rollback)
@Transactional 없이 트랜잭션 처리하기
Spring 없이 JDBC로 트랜잭션을 직접 처리하면 어떻게 될까요?
public void transfer(Long fromId, Long toId, int amount) {
Connection conn = null;
try {
conn = dataSource.getConnection();
conn.setAutoCommit(false); // 트랜잭션 시작
// 비즈니스 로직
accountDao.withdraw(conn, fromId, amount);
accountDao.deposit(conn, toId, amount);
conn.commit(); // 성공 시 커밋
} catch (Exception e) {
if (conn != null) {
conn.rollback(); // 실패 시 롤백
}
throw e;
} finally {
if (conn != null) {
conn.setAutoCommit(true);
conn.close();
}
}
}
보이시나요?
비즈니스 로직은 딱 2줄인데, 트랜잭션 관리 코드가 훨씬 많습니다. 이런 코드가 모든 서비스 메소드에 반복된다고 생각해보세요. 끔찍합니다.
@Transactional의 마법
이제 @Transactional을 사용하면 어떻게 되는지 보겠습니다.
@Service
public class AccountService {
@Transactional
public void transfer(Long fromId, Long toId, int amount) {
accountRepository.withdraw(fromId, amount);
accountRepository.deposit(toId, amount);
}
}
어노테이션 하나로 위의 복잡한 트랜잭션 코드가 모두 사라졌습니다. 그런데 어떻게 이게 가능한 걸까요?
AOP: @Transactional의 비밀
정답은 AOP(Aspect-Oriented Programming, 관점 지향 프로그래밍)입니다.
AOP에 대해 간단히 설명하자면, "핵심 비즈니스 로직과 부가 기능을 분리하는 프로그래밍 기법"입니다. 트랜잭션 관리, 로깅, 보안 체크 같은 공통 관심사를 비즈니스 코드에서 분리해서 관리할 수 있게 해줍니다.
프록시 패턴
Spring의 @Transactional은 프록시 패턴을 사용합니다. 프록시가 뭘까요?
비유하자면, 프록시는 비서와 같습니다. 사장님(실제 객체)을 만나려면 비서(프록시)를 먼저 거쳐야 합니다. 비서는 미팅 전에 준비 작업을 하고, 미팅 후에 정리 작업을 합니다.
[클라이언트] → [프록시(비서)] → [실제 객체(사장님)]
↑
트랜잭션 시작/종료 처리
Spring이 프록시를 만드는 과정
Spring 컨테이너가 시작될 때 다음과 같은 일이 벌어집니다:
1. AccountService 빈을 생성한다
2. @Transactional이 붙은 메소드가 있는지 확인한다
3. 있다면, AccountService를 감싸는 프록시 객체를 생성한다
4. 다른 빈들에게는 실제 AccountService 대신 프록시를 주입한다
그림으로 보면 이렇습니다:
// 우리가 생각하는 구조
Controller → AccountService
// 실제 구조
Controller → AccountService$$Proxy → AccountService
↑
여기서 트랜잭션 처리
트랜잭션 처리 흐름 상세 분석
@Transactional이 붙은 메소드가 호출되면 실제로 어떤 일이 일어나는지 단계별로 살펴보겠습니다.
1단계: 프록시가 호출을 가로챈다
// Controller에서 호출
accountService.transfer(1L, 2L, 10000);
이 호출은 실제 AccountService가 아닌 프록시 객체로 전달됩니다.
2단계: 트랜잭션 시작
프록시는 실제 메소드를 호출하기 전에 트랜잭션을 시작합니다.
// 프록시 내부 (의사 코드)
public void transfer(Long fromId, Long toId, int amount) {
// 1. 트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(definition);
try {
// 2. 실제 메소드 호출
target.transfer(fromId, toId, amount);
// 3. 성공 시 커밋
transactionManager.commit(status);
} catch (RuntimeException e) {
// 4. 예외 발생 시 롤백
transactionManager.rollback(status);
throw e;
}
}
3단계: 실제 비즈니스 로직 실행
프록시가 실제 AccountService의 transfer 메소드를 호출합니다.
// 실제 AccountService
public void transfer(Long fromId, Long toId, int amount) {
accountRepository.withdraw(fromId, amount);
accountRepository.deposit(toId, amount);
}
4단계: 커밋 또는 롤백
- 정상 완료: 프록시가
commit()을 호출하여 변경사항을 확정합니다 - 예외 발생: 프록시가
rollback()을 호출하여 모든 변경을 취소합니다
롤백은 언제 일어날까?
여기서 중요한 포인트가 있습니다. @Transactional은 모든 예외에 대해 롤백하지 않습니다.
기본 롤백 규칙
@Transactional
public void doSomething() {
// RuntimeException 또는 Error → 롤백 O
// Checked Exception → 롤백 X (커밋됨!)
}
기본적으로:
- RuntimeException(Unchecked Exception): 롤백됨
- Checked Exception: 롤백되지 않음 (커밋됨)
왜 이렇게 설계했을까요? Checked Exception은 "예상 가능한 예외"로, 비즈니스 로직에서 정상적으로 처리할 수 있다고 간주하기 때문입니다.
롤백 규칙 변경하기
모든 예외에 대해 롤백하고 싶다면:
@Transactional(rollbackFor = Exception.class)
public void doSomething() throws IOException {
// 이제 IOException이 발생해도 롤백됨
}
특정 예외는 롤백하지 않으려면:
@Transactional(noRollbackFor = CustomBusinessException.class)
public void doSomething() {
// CustomBusinessException이 발생해도 커밋됨
}
실제 프록시 코드 확인하기
정말로 프록시가 생성되는지 직접 확인해보겠습니다.
@Service
public class AccountService {
@Transactional
public void transfer(Long fromId, Long toId, int amount) {
System.out.println("실제 메소드 실행");
}
}
@RestController
@RequiredArgsConstructor
public class TestController {
private final AccountService accountService;
@GetMapping("/test")
public String test() {
System.out.println("주입된 객체: " + accountService.getClass().getName());
return "OK";
}
}
결과:
주입된 객체: com.example.AccountService$$SpringCGLIB$$0
$$SpringCGLIB$$라는 접미사가 보이시나요? 이게 바로 Spring이 CGLIB 라이브러리를 사용해서 생성한 프록시 객체입니다.
주의할 점: Self-Invocation 문제
@Transactional을 사용할 때 가장 많이 겪는 함정이 있습니다.
@Service
public class OrderService {
public void createOrder(OrderRequest request) {
// 주문 생성
saveOrder(request);
// 결제 처리 (트랜잭션 적용 안 됨!)
processPayment(request);
}
@Transactional
public void processPayment(OrderRequest request) {
// 결제 로직...
}
}
createOrder에서 processPayment를 호출했는데, @Transactional이 동작하지 않습니다!
왜 동작하지 않을까?
외부 호출: Controller → Proxy → OrderService.createOrder()
내부 호출: OrderService.createOrder() → OrderService.processPayment()
↑ 프록시를 거치지 않음!
같은 클래스 내부에서 메소드를 호출하면 this.processPayment()로 호출됩니다. 이건 프록시가 아닌 실제 객체의 메소드를 직접 호출하는 것입니다. 프록시를 거치지 않으니 트랜잭션 처리가 되지 않습니다.
해결 방법
방법 1: 별도의 클래스로 분리
@Service
@RequiredArgsConstructor
public class OrderService {
private final PaymentService paymentService;
public void createOrder(OrderRequest request) {
saveOrder(request);
paymentService.processPayment(request); // 외부 빈 호출 → 프록시 동작
}
}
@Service
public class PaymentService {
@Transactional
public void processPayment(OrderRequest request) {
// 결제 로직...
}
}
방법 2: 자기 자신을 주입받기
@Service
public class OrderService {
@Autowired
@Lazy // 순환 참조 방지를 위해 필수
private OrderService self; // 프록시가 주입됨
public void createOrder(OrderRequest request) {
saveOrder(request);
self.processPayment(request); // 프록시를 통해 호출
}
@Transactional
public void processPayment(OrderRequest request) {
// 결제 로직...
}
}
주의할 점이 있습니다. Spring Boot 2.6 이상에서는 순환 참조가 기본적으로 금지되어 있어서, @Lazy 없이 자기 자신을 주입하면 애플리케이션 실행 시 오류가 발생합니다. 반드시 @Lazy를 함께 사용해야 합니다.
개인적으로는 방법 1(클래스 분리)을 추천합니다. 더 깔끔하고, 역할 분리도 명확해지기 때문입니다.
private 메소드에는 @Transactional이 동작하지 않는다
Self-Invocation과 함께 자주 하는 실수가 하나 더 있습니다.
@Service
public class OrderService {
@Transactional // 동작하지 않음!
private void saveOrder(OrderRequest request) {
// ...
}
}
private 메소드에 @Transactional을 붙여도 동작하지 않습니다. 프록시는 외부에서 접근 가능한 메소드만 가로챌 수 있기 때문입니다. @Transactional을 적용하려면 반드시 public 메소드여야 합니다.
트랜잭션 전파(Propagation)
하나의 트랜잭션 안에서 다른 @Transactional 메소드를 호출하면 어떻게 될까요?
@Service
public class OrderService {
@Autowired
private InventoryService inventoryService;
@Transactional
public void createOrder(OrderRequest request) {
saveOrder(request);
inventoryService.decreaseStock(request.getProductId());
}
}
@Service
public class InventoryService {
@Transactional
public void decreaseStock(Long productId) {
// 재고 감소
}
}
기본 설정(Propagation.REQUIRED)에서는:
- 이미 트랜잭션이 있으면 → 기존 트랜잭션에 참여
- 트랜잭션이 없으면 → 새 트랜잭션 생성
여기서 "참여"라는 표현이 조금 헷갈릴 수 있는데, 새로운 트랜잭션을 만드는 것이 아니라 이미 시작된 트랜잭션(Connection)을 그대로 이어받아 사용한다는 뜻입니다. 즉, 물리적으로는 하나의 트랜잭션입니다.
createOrder() ─────── 트랜잭션 시작 ───────────────────── 커밋
│ │ │
└─ decreaseStock() ───┘ (같은 트랜잭션에 참여) ────────┘
즉, decreaseStock에서 예외가 발생하면 createOrder의 변경사항도 함께 롤백됩니다.
다른 전파 옵션들도 있습니다:
// 항상 새로운 트랜잭션 생성 (기존 트랜잭션과 독립)
@Transactional(propagation = Propagation.REQUIRES_NEW)
// 트랜잭션 없이 실행 (기존 트랜잭션 일시 중단)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
// 트랜잭션이 있으면 참여, 없으면 트랜잭션 없이 실행
@Transactional(propagation = Propagation.SUPPORTS)
정리
@Transactional의 동작 원리를 정리하면 다음과 같습니다:
- 프록시 생성: Spring은
@Transactional이 붙은 클래스에 대해 프록시 객체를 생성합니다 - AOP를 통한 트랜잭션 처리: 프록시가 메소드 호출을 가로채서 트랜잭션 시작/커밋/롤백을 처리합니다
- 기본 롤백 규칙: RuntimeException과 Error는 롤백, Checked Exception은 커밋됩니다
- Self-Invocation 주의: 같은 클래스 내 메소드 호출은 프록시를 거치지 않아 트랜잭션이 적용되지 않습니다
- 트랜잭션 전파: 기본적으로 이미 진행 중인 트랜잭션이 있으면 참여합니다
@Transactional은 정말 편리한 기능이지만, 내부 동작 원리를 모르면 예상치 못한 버그를 만나기 쉽습니다. 특히 Self-Invocation 문제는 실무에서 정말 자주 발생하니 꼭 기억해두시길 바랍니다.
추가로 알아두면 좋은 것들
읽기 전용 트랜잭션
조회만 하는 메소드라면 readOnly 옵션을 사용하면 좋습니다:
@Transactional(readOnly = true)
public List<Order> getOrders() {
return orderRepository.findAll();
}
- 데이터베이스에 따라 성능 최적화가 적용될 수 있습니다
- 실수로 데이터를 변경하는 것을 방지합니다
- JPA 사용 시 더티 체킹을 스킵하여 성능이 향상됩니다
타임아웃 설정
@Transactional(timeout = 10) // 10초
public void longRunningProcess() {
// 10초 이상 걸리면 예외 발생
}
긴 작업이 데이터베이스 커넥션을 오래 점유하는 것을 방지할 수 있습니다.
'프레임워크 > 스프링' 카테고리의 다른 글
| JPA N+1 문제: 개발자를 가장 괴롭히는 성능 이슈와 해결책 (0) | 2025.12.28 |
|---|---|
| JPA 영속성 컨텍스트(Persistence Context): 1차 캐시와 쓰기 지연이 주는 이점 (0) | 2025.12.28 |
| AOP(관점 지향 프로그래밍): 어떻게 메소드 실행 전후에 로그를 남길까? (0) | 2025.12.24 |
| 스프링의 Scope(스코프): 싱글톤(Singleton)과 프로토타입(Prototype)의 결정적 차이 (0) | 2025.12.23 |
| Filter vs Interceptor: 둘 다 거름망인데 도대체 뭐가 다를까? (0) | 2025.12.22 |