티스토리 뷰
✏️ 배경
현재 한국대학생IT경영학회(KUSITMS, 큐시즘)에서 교육기획팀장으로 활동하고 있다. 최근 운영진과 학회원 모집을 진행하면서 많은 지원자들의 서류를 읽게 되었는데, 생각보다 많은 분들이 프로젝트 경험에서 트랜잭션과 관련된 내용을 다루고 있었다. 글을 읽으면서 익숙하게 알고 있던 내용도 있었지만, 막상 "왜 이렇게 동작하지?"라고 생각해보니 명확하게 설명하기 어려운 부분도 있었고 새롭게 알게 된 내용도 있었다.
특히 @Transactional과 프록시의 관계, Self Invocation 문제, 외부 API 호출이 포함됐을 때의 트랜잭션 범위처럼 평소 자연스럽게 사용하면서도 제대로 짚고 넘어가지 않았던 부분들이 있었다. 그래서 이번 기회에 Spring의 @Transactional이 실제로 어떤 방식으로 동작하는지부터 하나씩 정리해보려고 한다.
✏️ 1. @Transactional은 어떻게 동작하는가
Spring의 @Transactional은 메서드 자체에 트랜잭션 로직을 삽입하는 방식이 아니다. Spring은 AOP Proxy를 이용해 실제 객체 앞에 대리 객체를 두고, 메서드 호출을 가로채 트랜잭션을 처리한다. (호출자 → Proxy → 실제 Service 객체)
예를 들어, 다음과 같은 서비스가 있다고 하자.
@Service
public class MemberService {
@Transactional
public void save() {
// DB 작업
}
public void find() {
// 조회
}
}
외부에서 save()를 호출하면 실제 흐름은 다음과 같다.
호출자
→ MemberService Proxy
→ @Transactional 적용 대상인지 확인
→ Transaction 시작
→ 실제 MemberService.save()
→ Commit / Rollback
반면 find()는 트랜잭션 적용 대상이 아니므로, Proxy를 거치더라도 별도의 트랜잭션 처리 없이 실제 객체에 호출을 그대로 위임한다. Proxy는 메서드를 호출할 때마다 새로 생성되는 게 아니다. Spring이 Bean을 구성하는 과정에서 Proxy가 필요한 Bean을 Proxy 형태로 등록해두고, 이후 모든 외부 호출은 이 Proxy를 거쳐서 이루어진다.
✏️ 2. Proxy와 @Transactional의 관계
save()만을 위한 Proxy와 find()만을 위한 Proxy가 각각 존재하는 게 아니다. Bean 단위로 Proxy가 하나 존재하고, 메서드가 호출될 때마다 그 호출이 트랜잭션 적용 대상인지를 판단한다. 여기서 조심해야 할 건 Spring의 모든 Bean에 프록시 객체가 생성되는 것은 아니다. Spring AOP의 적용 대상이 되는 Bean에 대해 프록시가 생성되며, @Transactional 역시 Spring AOP를 기반으로 동작한다. 트랜잭션 적용 대상 메서드를 가진 Bean은 프록시로 감싸지고, 외부에서는 해당 프록시를 통해 Bean의 메서드를 호출하게 된다.
@Service
public class MemberService {
@Transactional
public void save() {}
public void find() {}
}
MemberService Proxy
/ \
save() find()
↓ ↓
TX 처리 O TX 처리 X
↓ ↓
실제 객체 실제 객체
✏️ 3. Self Invocation 문제 - 같은 클래스 내부에서 호출하면?
다음 코드에서는 문제가 발생한다. 외부에서 memberService.process()를 호출하면, 처음 process()는 Proxy를 정상적으로 통과한다. 하지만 process() 내부에서 호출하는 save()는 사실상 this.save()와 같다. Proxy를 다시 거치지 않고 원본 객체끼리 바로 호출하는 셈이다.
@Service
public class MemberService {
public void process() {
save();
}
@Transactional
public void save() {
// DB 작업
}
}
호출자 → Proxy → 실제 MemberService.process()
호출자
→ Proxy
→ 실제 객체.process()
→ this.save()
→ 실제 객체.save() ← Proxy를 거치지 않음
save()에 @Transactional이 있어도 트랜잭션이 시작되지 않는다. 이를 Self Invocation(자기 호출) 문제라고 한다.
public void process() { // TX 없음
save();
}
@Transactional
public void save() { // TX 적용 안 됨
}
반대로, 외부 메서드에 @Transactional이 있다면?
이번에는 외부에서 process()를 호출하는 순간 Proxy가 트랜잭션을 시작한다(호출자 → Proxy → TX 시작 → process() → save() → Commit). save()에는 @Transactional이 없지만, 이미 시작된 트랜잭션 내부에서 실행되기 때문에 save()의 DB 작업도 동일한 트랜잭션에 포함된다.
@Transactional
public void process() {
save();
}
public void save() {
// DB 작업
}
핵심 정리
결국 중요한 건 두 가지다: @Transactional이 붙어 있는가, 그리고 해당 호출이 Proxy를 통과했는가.
| 호출 구조 | 결과 |
| 외부 → @Transactional A() | A에 TX 적용 |
| 외부 → A() → this.@Transactional B() | B의 @Transactional 적용 안 됨 |
| 외부 → @Transactional A() → B() | A와 B 모두 같은 TX에서 실행 |
✏️ 4. 외부 API를 하나의 DB 트랜잭션에 포함하면?
주문 처리 로직이 다음과 같다고 하자.
@Transactional
public void order() {
orderRepository.save(order);
paymentClient.pay(); // 외부 API
order.complete();
}
트랜잭션 흐름은 이렇다.
TX 시작 → DB INSERT → 외부 결제 API → DB UPDATE → Commit
문제는 외부 API 응답이 느릴 때다. 외부 API 호출을 기다리는 동안 불필요하게 긴 DB 트랜잭션이 유지되고, 그만큼 커넥션과 락도 오래 붙잡히게 된다.
TX 시작
│
├─ DB 작업
│
├── 외부 API 응답 대기 (5초) ──┐
│ ├─ DB Transaction 유지 │
│ ├─ Connection 점유 │
│ └─ Lock 유지 가능성 │
│ │
└──────────────────────────────┘
↓
Commit
✏️ 5. DB 트랜잭션을 짧게 가져가기
외부 API가 포함된 로직에서는 트랜잭션 경계를 나누는 방법을 생각할 수 있다. 즉 "DB 작업 → Commit → 외부 API → 새로운 DB 트랜잭션" 구조로 가져가는 것이다.
[TX 1] 주문 생성 → Commit
[TX 없음] 외부 결제 API
[TX 2] 주문 상태 변경 → Commit
같은 클래스에서 메서드만 나누면 안 되는 이유
다음처럼 작성하면 안 된다. 앞서 살펴본 Self Invocation 문제가 그대로 발생한다. createOrder(), completeOrder()의 @Transactional이 의도대로 적용되지 않는다.
@Service
public class OrderService {
public void order() {
createOrder();
paymentClient.pay();
completeOrder();
}
@Transactional
public void createOrder() {
// ...
}
@Transactional
public void completeOrder() {
// ...
}
}
외부 → Proxy → 실제 OrderService.order()
├─ this.createOrder() → Proxy 거치지 않음
├─ paymentClient.pay()
└─ this.completeOrder() → Proxy 거치지 않음
✏️ 6. 별도의 Bean으로 분리하기
이를 해결하는 방법은 오케스트레이션을 담당하는 Service와 트랜잭션을 담당하는 Service를 분리하는 것이다.
OrderService 는 전체 비즈니스 흐름을 조율하고 OrderTransactionService는 DB 트랜잭션이 필요한 작업만 담당한다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderTransactionService orderTransactionService;
private final PaymentClient paymentClient;
public void order() {
Long orderId = orderTransactionService.createOrder();
PaymentResult result = paymentClient.pay();
orderTransactionService.completeOrder(orderId, result);
}
}
@Service
@RequiredArgsConstructor
public class OrderTransactionService {
private final OrderRepository orderRepository;
@Transactional
public Long createOrder() {
Order order = new Order();
orderRepository.save(order);
return order.getId();
}
@Transactional
public void completeOrder(Long orderId, PaymentResult result) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.complete(result);
}
}
왜 별도 Bean으로 만들면 해결되는가
OrderTransactionService는 별개의 Spring Bean이다. OrderService가 이 Bean을 호출하면, 자기 자신이 아닌 다른 Bean의 Proxy를 거치게 되므로 트랜잭션이 정상 적용된다.
OrderService.order()
→ OrderTransactionService Proxy
→ @Transactional 확인 → TX 시작 → createOrder() → Commit
→ PaymentClient (외부 API 호출, DB TX 없음)
→ OrderTransactionService Proxy
→ @Transactional 확인 → TX 시작 → completeOrder() → Commit
[TX 1] createOrder() → Commit
↓
paymentClient.pay() (외부 API, Transaction 없음)
↓
[TX 2] completeOrder() → Commit
✏️ 7. 외부 API는 DB 트랜잭션으로 롤백할 수 없다
외부 API가 포함되면 더 중요한 문제가 남는다. DB는 롤백할 수 있지만, 결제 서버는 우리 DB 트랜잭션과 아무 관계가 없다. 그래서 다음과 같은 불일치가 생길 수 있다. 우리 DB는 Rollback이 되지만 외부 결제 서버에서는 결제가 이미 성공할 수 있다.
TX 시작 → 주문 DB 저장 → 결제 API 호출 → 결제 성공 ✅ → DB 처리 실패 ❌ → Rollback
즉 @Transactional은 외부 시스템까지 원자성을 보장해주는 기능이 아니다. 외부 시스템이 포함된 작업에서는 상태를 명시적으로 관리하거나 보상 작업을 고려해야 한다. 필요하다면 보상 트랜잭션(compensating transaction), Saga 패턴, Transactional Outbox 같은 방식으로 발전시킬 수 있다.
ORDER_CREATED → 외부 결제 요청 → 성공: PAID / 실패: PAYMENT_FAILED
✏️ 8. 최종 정리
@Transactional Spring은 @Transactional을 AOP Proxy로 구현한다.
외부 호출 → Proxy → Transaction 시작 → 실제 객체 → Commit / Rollback
Self Invocation 같은 객체 내부에서 자신의 메서드를 호출하면 Proxy를 거치지 않는다.
Proxy → 실제 객체 A() → this.B() → (Proxy 거치지 않음)
B에 @Transactional이 있어도 적용되지 않을 수 있다.
트랜잭션 분리 외부 API가 포함된 로직은 하나의 긴 트랜잭션으로 묶기보다, "DB 트랜잭션 → Commit → 외부 API 호출 → DB 트랜잭션 → Commit"처럼 필요한 DB 작업 단위로 트랜잭션 경계를 짧게 가져갈 수 있다.
이때 같은 클래스 내부의 메서드 호출은 Self Invocation 문제가 있으므로, 트랜잭션을 담당하는 별도의 Spring Bean으로 책임을 분리하는 방법을 쓸 수 있다. 결국 @Transactional을 제대로 이해하려면 "어디에 어노테이션을 붙였는가"보다 호출이 어떤 객체와 Proxy 경계를 통과하는가를 함께 봐야 한다.
'Back-End > 개인 공부' 카테고리의 다른 글
| [Spring Boot / 스프링 부트] - Spring Boot의 JDBC, JdbcTemplate, JPA (0) | 2026.08.13 |
|---|---|
| [Spring Boot / 스프링 부트] - 세모산 코드에 트랜잭션 적용해서 이해하기 (0) | 2026.08.12 |
| [K8s / 쿠버네티스] 쿠버네티스(Kubernetes) 수업 정리 (0) | 2026.06.02 |
| [K8s / 쿠버네티스] 쿠버네티스 MinIO 버킷 데이터 Mac으로 다운로드하기 (0) | 2026.05.31 |
| [Spring Boot / 스프링 부트] 축제 부스앱 결제 자동화 도전기 - Portone 반려부터 RT PAY까지 (2) | 2026.05.18 |
- Total
- Today
- Yesterday
- 자료구조
- 스택
- java
- 자바스크립트
- 투 포인터
- html
- 백준 풀이
- js
- Do it!
- 우선순위 큐
- 유클리드 호제법
- DFS
- c++ string
- BFS
- C++
- CSS
- 알고리즘 공부
- Spring Boot
- DP
- 스프링 부트 crud 게시판 구현
- 자바
- 백준
- 유니온 파인드
- 알고리즘
- 반복문
- 이분 매칭
- HTML5
- 카운팅 정렬
- 에라토스테네스의 체
- 세그먼트 트리
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | ||||||
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 | 29 |
| 30 | 31 |
