@Transactional 전파 레벨을 정확히 이해하지 못하면, 분명히 @Transactional을 붙였는데 롤백이 안 되거나, 반대로 롤백되면 안 되는 로그 저장까지 같이 롤백되는 장애를 겪게 됩니다. “그냥 서비스 메서드에 @Transactional 달면 되는 거 아닌가요?”라고 생각하는 순간, 자기 호출(Self-Invocation) 함정에 빠지거나, REQUIRES_NEW를 잘못 써서 교착 상태(Deadlock)를 만들거나, readOnly = true를 붙였는데 오히려 성능이 나빠지는 상황이 생깁니다. 이 글에서는 Spring AOP 프록시 기반 트랜잭션 동작 원리부터 전파 레벨 7가지, 격리 수준 4단계, 롤백 조건, 실무에서 반드시 알아야 할 함정과 해결법까지 실무 예제 코드와 함께 완벽하게 분석합니다.
목차
- @Transactional의 동작 원리 – AOP 프록시와 트랜잭션 관리자
- 트랜잭션 전파 레벨 7가지 완전 정복
- 격리 수준 4단계 – Dirty Read부터 Phantom Read까지
- 롤백 전략 – 언제 롤백되고 언제 안 되는가
- 실무에서 마주치는 @Transactional 함정 8가지
- 전문가 관점 – readOnly 최적화·테스트 전략·설계 원칙
1. @Transactional의 동작 원리 – AOP 프록시와 트랜잭션 관리자
트랜잭션이란? ACID 복습
트랜잭션은 데이터베이스의 작업 묶음 단위입니다. 은행 계좌 이체를 예로 들면 “A 계좌 출금 + B 계좌 입금”이 하나의 트랜잭션으로 묶여, 둘 다 성공하거나 둘 다 실패해야 합니다.
[ACID 속성 – 트랜잭션이 보장해야 할 4가지]
A – Atomicity (원자성): 전부 성공 또는 전부 실패, 중간 상태 없음
C – Consistency(일관성): 트랜잭션 전후 DB 무결성 제약 항상 만족
I – Isolation (격리성): 동시 트랜잭션 간 서로 간섭하지 않음
D – Durability (지속성): 커밋된 결과는 장애 후에도 영구 보존
Spring이 @Transactional을 처리하는 방법 – AOP 프록시
@Transactional은 마법이 아닙니다. Spring이 AOP(관점 지향 프로그래밍) 프록시를 통해 트랜잭션 처리 코드를 메서드 앞뒤에 자동으로 삽입합니다.
[@Transactional 적용 전후 코드 변환]
개발자가 작성한 코드:
┌─────────────────────────────────────┐
│ @Transactional │
│ public void placeOrder(Order order) │
│ { │
│ // 비즈니스 로직 │
│ } │
└─────────────────────────────────────┘
Spring이 실제로 실행하는 코드 (프록시 래핑):
┌─────────────────────────────────────────────────────┐
│ public void placeOrder(Order order) { │
│ │
│ TransactionStatus tx = txManager.getTransaction │
│ (new DefaultTransactionDefinition()); │
│ // ↑ 트랜잭션 시작 (자동 삽입) │
│ │
│ try { │
│ // 원본 비즈니스 로직 실행 │
│ target.placeOrder(order); │
│ │
│ txManager.commit(tx); │
│ // ↑ 정상 완료 시 커밋 (자동 삽입) │
│ } catch (RuntimeException e) { │
│ txManager.rollback(tx); │
│ // ↑ 예외 발생 시 롤백 (자동 삽입) │
│ throw e; │
│ } │
│ } │
└─────────────────────────────────────────────────────┘
프록시 생성 방식 – JDK 동적 프록시 vs CGLIB
Spring은 상황에 따라 두 가지 방식으로 프록시를 생성합니다.
java
// JDK 동적 프록시: 인터페이스 기반 (인터페이스가 있을 때)
public interface OrderService {
void placeOrder(Order order);
}
@Service
public class OrderServiceImpl implements OrderService {
@Transactional
public void placeOrder(Order order) { ... }
}
// → Spring이 OrderService 인터페이스의 프록시 생성
// → 실제 타입: com.sun.proxy.$Proxy123
// CGLIB 프록시: 클래스 기반 (인터페이스 없을 때, Spring Boot 기본값)
@Service
public class OrderService { // 인터페이스 없음
@Transactional
public void placeOrder(Order order) { ... }
}
// → Spring이 OrderService를 상속한 서브클래스 프록시 생성
// → 실제 타입: OrderService$$EnhancerBySpringCGLIB$$abc123
yaml
# Spring Boot 기본값: CGLIB 프록시 사용
spring:
aop:
proxy-target-class: true # true=CGLIB(기본), false=JDK 동적 프록시
CGLIB 프록시의 제약:
java
// ❌ final 클래스 또는 final 메서드에는 @Transactional 불가
// (CGLIB는 상속으로 프록시 생성 → final 상속 불가)
@Service
public final class UserService { // ❌ final 클래스
@Transactional
public final void save(User user) { } // ❌ final 메서드
}
트랜잭션 동기화 – ThreadLocal 기반 컨텍스트
Spring은 트랜잭션 상태를 ThreadLocal에 저장합니다. 같은 스레드 내에서 여러 메서드가 호출되면 동일한 트랜잭션 컨텍스트를 공유합니다.
[ThreadLocal 트랜잭션 동기화 구조]
Thread-A:
→ OrderService.placeOrder() 트랜잭션 시작
→ ThreadLocal에 Connection 저장
→ InventoryService.decrease() 호출
→ ThreadLocal에서 같은 Connection 꺼내 사용
→ PaymentService.charge() 호출
→ ThreadLocal에서 같은 Connection 꺼내 사용
→ 트랜잭션 커밋 (모두 같은 Connection → 같은 트랜잭션)
Thread-B:
→ 별도 ThreadLocal → 별도 Connection → 별도 트랜잭션
2. 트랜잭션 전파 레벨 7가지 완전 정복
**@Transactional 전파 레벨(Propagation)**은 “이미 트랜잭션이 진행 중일 때 새 메서드를 호출하면 어떻게 처리하는가”를 정의합니다. 이 개념이 실무 트랜잭션 설계의 핵심입니다.
java
// 전파 레벨 지정 방법
@Transactional(propagation = Propagation.REQUIRED)
public void someMethod() { ... }
전파 레벨 1 – REQUIRED (기본값, 가장 많이 사용)
기존 트랜잭션이 있으면 참여, 없으면 새로 생성. 가장 일반적인 설정이며 @Transactional의 기본값입니다.
[REQUIRED 동작]
상황 1: 외부 트랜잭션 없음
외부 메서드 호출 (트랜잭션 없음)
→ 내부 @Transactional(REQUIRED) 메서드
→ 새 트랜잭션 생성 후 실행
상황 2: 외부 트랜잭션 있음
외부 @Transactional(REQUIRED) 메서드 [TX-1 시작]
→ 내부 @Transactional(REQUIRED) 메서드
→ 기존 TX-1에 참여 (새 트랜잭션 생성 안 함)
→ 내부 메서드 정상 완료
외부 메서드 [TX-1 커밋]
java
@Service
@RequiredArgsConstructor
public class OrderService {
private final PaymentService paymentService;
private final InventoryService inventoryService;
@Transactional // REQUIRED (기본값)
public void placeOrder(OrderRequest request) {
// 모두 같은 TX-1 트랜잭션 안에서 실행
inventoryService.decrease(request.getProductId(), request.getQty());
paymentService.charge(request.getUserId(), request.getAmount());
// → 어느 하나라도 실패하면 모두 롤백
}
}
@Service
public class PaymentService {
@Transactional // REQUIRED → 외부 OrderService의 트랜잭션에 참여
public void charge(Long userId, BigDecimal amount) {
// TX-1 안에서 실행
}
}
REQUIRED의 핵심 특성 – 내부 롤백이 외부 트랜잭션에 미치는 영향:
java
@Transactional // 외부: REQUIRED → TX-1 시작
public void outer() {
try {
inner(); // 내부에서 예외 발생 → TX-1에 rollback-only 마킹
} catch (Exception e) {
// 예외를 잡았어도...
}
// TX-1은 rollback-only 상태 → 커밋 시도 시
// UnexpectedRollbackException 발생!
}
@Transactional // 내부: REQUIRED → TX-1에 참여
public void inner() {
throw new RuntimeException("내부 오류");
// → TX-1 전체에 rollback-only 마킹
}
이 동작이 많은 개발자를 혼란에 빠뜨립니다. try-catch로 예외를 잡았는데 UnexpectedRollbackException이 발생하는 이유입니다.
전파 레벨 2 – REQUIRES_NEW (완전히 독립된 새 트랜잭션)
항상 새 트랜잭션 생성. 기존 트랜잭션은 일시 중단.
[REQUIRES_NEW 동작]
외부 @Transactional(REQUIRED) [TX-1 시작]
→ 내부 @Transactional(REQUIRES_NEW) [TX-1 일시 중단]
→ [TX-2 새로 시작]
→ 내부 메서드 실행
→ [TX-2 커밋 또는 롤백 (TX-1과 독립)]
→ [TX-1 재개]
→ 외부 메서드 커밋 또는 롤백
java
@Service
@RequiredArgsConstructor
@Slf4j
public class OrderService {
private final AuditLogService auditLogService;
private final OrderRepository orderRepository;
@Transactional
public void placeOrder(OrderRequest request) {
try {
Order order = Order.create(request);
orderRepository.save(order);
// 주문 처리 중 예외 발생
if (request.getAmount().compareTo(MAX_AMOUNT) > 0) {
throw new OrderLimitExceededException("한도 초과");
}
} finally {
// ✅ REQUIRES_NEW: 주문 트랜잭션 롤백되어도 감사 로그는 독립적으로 저장
auditLogService.log(request.getUserId(), "ORDER_ATTEMPT", request);
}
}
}
@Service
public class AuditLogService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void log(Long userId, String action, Object payload) {
// TX-1(주문)과 완전히 독립된 TX-2에서 실행
// → 주문 롤백 여부와 무관하게 감사 로그 저장 성공
auditLogRepository.save(AuditLog.of(userId, action, payload));
}
}
REQUIRES_NEW 실무 사용 사례:
✅ REQUIRES_NEW가 적합한 경우:
- 감사 로그 (주요 트랜잭션 성공 여부와 무관하게 저장)
- 알림·이메일 발송 기록 (발송 시도 자체를 독립적으로 기록)
- 실패 이력 저장 (실패했을 때 실패 원인을 별도 저장)
⚠️ REQUIRES_NEW 주의사항:
- 외부 트랜잭션 Connection이 점유된 상태에서 새 Connection 획득
- Connection Pool 고갈 위험 (Pool 크기 = N이면 최대 N/2개만 REQUIRES_NEW 가능)
- 외부·내부 트랜잭션 간 데이터 가시성 주의 (서로 커밋 전 데이터 못 봄)
전파 레벨 3 – NESTED (중첩 트랜잭션 – 세이브포인트)
기존 트랜잭션 안에 중첩 트랜잭션 생성. 내부 롤백 시 세이브포인트까지만 롤백.
[NESTED 동작]
외부 @Transactional(REQUIRED) [TX-1 시작]
→ SAVEPOINT SP-1 생성
→ 내부 @Transactional(NESTED) 실행
→ 내부 예외 발생 시 SP-1까지만 롤백 (외부 TX-1 유지)
→ 외부는 계속 진행 가능
→ 외부 커밋 시 SP-1 이후 작업 제외하고 커밋
java
@Service
@RequiredArgsConstructor
public class DataMigrationService {
private final LegacyDataService legacyDataService;
private final NewDataService newDataService;
@Transactional
public MigrationResult migrate(List<LegacyRecord> records) {
int successCount = 0;
int failCount = 0;
for (LegacyRecord record : records) {
try {
// NESTED: 한 건 실패해도 전체 롤백 없이 해당 건만 롤백
newDataService.save(record);
successCount++;
} catch (Exception e) {
log.warn("마이그레이션 실패: recordId={}", record.getId(), e);
failCount++;
// → 이 건만 세이브포인트까지 롤백, 나머지 계속 진행
}
}
return MigrationResult.of(successCount, failCount);
// → 외부 트랜잭션: 성공한 건들만 커밋
}
}
@Service
public class NewDataService {
@Transactional(propagation = Propagation.NESTED)
public void save(LegacyRecord record) {
// 실패 시 이 메서드 시작 전 세이브포인트까지만 롤백
newRepository.save(NewRecord.from(record));
validate(record); // 검증 실패 시 이 메서드 이전 상태로만 롤백
}
}
REQUIRES_NEW vs NESTED 핵심 차이:
| 구분 | REQUIRES_NEW | NESTED |
|---|---|---|
| 트랜잭션 수 | 2개 독립 트랜잭션 | 1개 트랜잭션 (세이브포인트) |
| 내부 롤백 영향 | 외부 트랜잭션에 영향 없음 | 세이브포인트 이후만 롤백 |
| 외부 롤백 영향 | 내부 트랜잭션에 영향 없음 | 내부도 함께 롤백 |
| Connection 사용 | 별도 Connection 필요 | 동일 Connection 사용 |
| DB 지원 | 모든 DB | SAVEPOINT 지원 DB만 |
전파 레벨 4 – SUPPORTS (트랜잭션 있으면 참여, 없으면 없이 실행)
java
@Transactional(propagation = Propagation.SUPPORTS)
public ProductDto getProduct(Long productId) {
// 트랜잭션 안에서 호출되면 → 트랜잭션 참여
// 트랜잭션 없이 호출되면 → 트랜잭션 없이 실행
// 조회 전용 메서드에서 트랜잭션 강제가 불필요할 때 사용
return productRepository.findById(productId).map(ProductDto::from).orElse(null);
}
전파 레벨 5 – NOT_SUPPORTED (항상 트랜잭션 없이 실행)
java
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void sendExternalNotification(NotificationRequest request) {
// 트랜잭션이 있으면 일시 중단 후 실행
// 외부 API 호출 등 트랜잭션 커넥션을 점유하면 안 되는 작업에 사용
// → DB Connection을 불필요하게 점유하지 않음
externalApiClient.send(request);
}
전파 레벨 6 – MANDATORY (반드시 기존 트랜잭션 필요)
java
@Transactional(propagation = Propagation.MANDATORY)
public void criticalDatabaseOperation(Long id) {
// 트랜잭션 없이 호출되면 IllegalTransactionStateException 발생
// 반드시 트랜잭션 안에서 호출되어야 하는 내부 메서드에 사용
// → 실수로 트랜잭션 없이 호출 시 즉시 예외 → 버그 조기 발견
}
전파 레벨 7 – NEVER (트랜잭션이 있으면 예외)
java
@Transactional(propagation = Propagation.NEVER)
public void nonTransactionalOperation() {
// 트랜잭션 안에서 호출되면 IllegalTransactionStateException 발생
// 트랜잭션 없이 실행해야만 하는 작업에 사용
// (예: 외부 시스템 호출, 트랜잭션 범위 밖에서만 실행되어야 하는 검증)
}
전파 레벨 7가지 한눈에 보기
[전파 레벨 비교 요약]
propagation 기존 TX 있음 기존 TX 없음
─────────────────────────────────────────────────────────
REQUIRED 기존 TX 참여 새 TX 생성 ← 기본값
REQUIRES_NEW 기존 TX 중단 + 새 TX 새 TX 생성
NESTED 세이브포인트 + 중첩 TX 새 TX 생성
SUPPORTS 기존 TX 참여 TX 없이 실행
NOT_SUPPORTED 기존 TX 중단 + TX 없이 TX 없이 실행
MANDATORY 기존 TX 참여 예외 발생
NEVER 예외 발생 TX 없이 실행
3. 격리 수준 4단계 – Dirty Read부터 Phantom Read까지
격리 수준(Isolation Level)은 동시에 실행되는 트랜잭션들이 서로를 얼마나 차단하는가를 정의합니다. 격리 수준이 높을수록 데이터 정확성은 높아지지만 동시성은 낮아집니다.
동시성 문제의 3가지 유형
격리 수준을 이해하려면 먼저 동시성 문제의 유형을 알아야 합니다.
[동시성 문제 3가지 – 실제 시나리오]
① Dirty Read (더티 읽기)
TX-A: 재고를 100 → 50으로 수정 (아직 커밋 안 함)
TX-B: 재고 조회 → 50 읽음 (TX-A의 미확정 데이터 읽기)
TX-A: 롤백 → 재고 다시 100
TX-B: 50을 기준으로 주문 처리 → 데이터 불일치!
② Non-Repeatable Read (반복 불가능 읽기)
TX-A: 상품 가격 조회 → 10,000원
TX-B: 상품 가격 10,000 → 15,000으로 수정 + 커밋
TX-A: 같은 상품 가격 다시 조회 → 15,000원
→ 같은 TX 안에서 같은 쿼리 결과가 달라짐!
③ Phantom Read (팬텀 읽기)
TX-A: 가격 10,000~20,000 상품 조회 → 5건
TX-B: 가격 15,000짜리 상품 INSERT + 커밋
TX-A: 같은 조건으로 다시 조회 → 6건
→ 범위 조회 결과에 없던 행이 생김!
격리 수준 1 – READ_UNCOMMITTED (가장 낮은 격리)
미커밋 데이터까지 읽을 수 있습니다. Dirty Read가 발생하므로 실무에서는 거의 사용하지 않습니다.
java
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
public List<Product> getProductsForAnalysis() {
// 다른 트랜잭션의 미커밋 변경 사항도 읽음
// → 실시간성보다 정확도가 덜 중요한 분석 쿼리에서 극히 드물게 사용
return productRepository.findAll();
}
격리 수준 2 – READ_COMMITTED (커밋된 데이터만 읽기)
대부분의 RDBMS 기본값입니다(Oracle, PostgreSQL, SQL Server). 커밋된 데이터만 읽으므로 Dirty Read는 방지하지만, Non-Repeatable Read는 발생합니다.
java
@Transactional(isolation = Isolation.READ_COMMITTED)
public OrderResult processOrder(OrderRequest request) {
// TX-1: 재고 조회 → 100개
int stock = inventoryService.getStock(request.getProductId());
// 이 순간 TX-2가 재고 50개 차감 후 커밋
// TX-1: 재고 다시 조회 → 50개 (같은 TX 안에서 결과 달라짐!)
if (stock >= request.getQty()) {
// Non-Repeatable Read 위험: 첫 조회 때는 100개였지만
// 실제 차감 시점에는 재고가 부족할 수 있음
inventoryService.decrease(request.getProductId(), request.getQty());
}
return OrderResult.success();
}
격리 수준 3 – REPEATABLE_READ (반복 읽기 보장)
MySQL InnoDB 기본값입니다. 같은 TX 안에서 같은 쿼리는 항상 같은 결과를 반환합니다. Non-Repeatable Read는 방지하지만 Phantom Read는 발생할 수 있습니다.
java
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void calculateMonthlyRevenue(YearMonth month) {
// TX 시작 시점의 스냅샷으로 읽기 (MVCC)
BigDecimal revenue1 = orderRepository.sumRevenue(month); // 1,000,000원
// 이 사이에 다른 TX가 주문 데이터 수정해도...
BigDecimal revenue2 = orderRepository.sumRevenue(month); // 동일 1,000,000원 보장
// → 같은 TX 내 반복 조회 결과 일관성 보장
}
격리 수준 4 – SERIALIZABLE (가장 높은 격리)
모든 동시성 문제를 방지하지만, 성능이 가장 낮습니다. 트랜잭션을 순차적으로 처리하는 것과 같은 효과입니다.
java
@Transactional(isolation = Isolation.SERIALIZABLE)
public void transferFunds(Long fromId, Long toId, BigDecimal amount) {
// 완전한 격리: Dirty Read, Non-Repeatable Read, Phantom Read 모두 방지
// 단, 다른 트랜잭션과 동시 실행 시 대기 또는 교착 상태 가능
// → 금융 거래처럼 절대적 정확성이 필요한 경우에만 사용
Account from = accountRepository.findById(fromId).orElseThrow();
Account to = accountRepository.findById(toId).orElseThrow();
from.withdraw(amount);
to.deposit(amount);
}
격리 수준별 동시성 문제 방지 현황
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read | 성능 |
|---|---|---|---|---|
| READ_UNCOMMITTED | ❌ 발생 | ❌ 발생 | ❌ 발생 | 최고 |
| READ_COMMITTED | ✅ 방지 | ❌ 발생 | ❌ 발생 | 높음 |
| REPEATABLE_READ | ✅ 방지 | ✅ 방지 | ❌ 발생 | 보통 |
| SERIALIZABLE | ✅ 방지 | ✅ 방지 | ✅ 방지 | 최저 |
java
// 실무 격리 수준 선택 가이드
@Transactional(isolation = Isolation.READ_COMMITTED) // 대부분의 경우 (기본)
@Transactional(isolation = Isolation.REPEATABLE_READ) // 같은 TX 내 반복 조회 필요
@Transactional(isolation = Isolation.SERIALIZABLE) // 금융, 재고 등 정확성 최우선
// DEFAULT: DB 기본 격리 수준 따름 (권장)
@Transactional(isolation = Isolation.DEFAULT)
4. 롤백 전략 – 언제 롤백되고 언제 안 되는가
Spring의 기본 롤백 규칙
[Spring @Transactional 롤백 기본 규칙]
✅ 자동 롤백:
- RuntimeException (및 하위 클래스)
- Error (및 하위 클래스)
예: NullPointerException, IllegalArgumentException, OutOfMemoryError
❌ 롤백 안 됨 (커밋):
- Checked Exception (Exception의 하위, RuntimeException 제외)
예: IOException, SQLException, ParseException
java
@Service
public class PaymentService {
@Transactional
public void processPayment(PaymentRequest request) throws IOException {
paymentRepository.save(Payment.create(request));
// ✅ RuntimeException → 자동 롤백
if (request.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("결제 금액은 0보다 커야 합니다");
}
// ❌ Checked Exception → 롤백 안 됨! (주의)
if (!externalApiAvailable()) {
throw new IOException("외부 PG사 연결 실패");
// → IOException은 Checked Exception → 트랜잭션 커밋됨!
// → 결제 데이터는 저장됐지만 외부 연동 실패 → 데이터 불일치!
}
}
}
롤백 대상 예외 명시적 지정
java
@Service
public class OrderService {
// Checked Exception도 롤백 대상으로 지정
@Transactional(rollbackFor = Exception.class)
public void placeOrder(OrderRequest request) throws Exception {
orderRepository.save(Order.create(request));
// IOException 발생해도 롤백됨
externalService.notify(request);
}
// 특정 예외는 롤백하지 않음
@Transactional(noRollbackFor = {
OptimisticLockingFailureException.class,
StaleObjectStateException.class
})
public void updateWithRetry(Long id, UpdateRequest request) {
// 낙관적 락 충돌은 롤백 없이 재시도 로직으로 처리
entity.update(request);
entityRepository.save(entity);
}
// 여러 예외 조합
@Transactional(
rollbackFor = { IOException.class, ExternalApiException.class },
noRollbackFor = { ValidationWarningException.class }
)
public void complexOperation() throws IOException { ... }
}
수동 롤백 – TransactionAspectSupport
java
@Transactional
public BatchResult processBatch(List<Item> items) {
int successCount = 0;
int failCount = 0;
for (Item item : items) {
try {
process(item);
successCount++;
} catch (RecoverableException e) {
log.warn("처리 실패 (복구 가능): itemId={}", item.getId());
failCount++;
}
}
// 실패율이 20% 초과 시 전체 롤백
if ((double) failCount / items.size() > 0.2) {
log.error("실패율 {}% 초과 → 전체 롤백", failCount * 100 / items.size());
// 예외 없이 수동으로 롤백 표시
TransactionAspectSupport.currentTransactionStatus()
.setRollbackOnly();
return BatchResult.failed(failCount);
}
return BatchResult.partial(successCount, failCount);
}
5. 실무에서 마주치는 @Transactional 함정 8가지
함정 ① 자기 호출(Self-Invocation) – 가장 흔한 함정
같은 클래스 내부에서 @Transactional 메서드를 직접 호출하면 프록시를 거치지 않아 트랜잭션이 동작하지 않습니다.
java
@Service
public class UserService {
// ❌ 자기 호출 – @Transactional 무시됨
public void registerAndSendEmail(UserDto dto) {
this.register(dto); // this = 실제 객체 (프록시 아님!)
this.sendWelcomeEmail(dto); // 트랜잭션 적용 안 됨
}
@Transactional
public void register(UserDto dto) {
userRepository.save(User.create(dto));
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendWelcomeEmail(UserDto dto) {
emailLogRepository.save(EmailLog.of(dto.getEmail()));
}
}
해결책 3가지:
java
// 해결책 1: 별도 Bean으로 분리 (권장)
@Service
@RequiredArgsConstructor
public class UserFacadeService {
private final UserService userService;
private final EmailService emailService;
public void registerAndSendEmail(UserDto dto) {
userService.register(dto); // 프록시 경유 ✅
emailService.sendWelcomeEmail(dto); // 프록시 경유 ✅
}
}
// 해결책 2: ApplicationContext에서 자기 자신 Bean 가져오기 (비권장, 코드 복잡)
@Service
public class UserService {
@Autowired
private ApplicationContext applicationContext;
public void registerAndSendEmail(UserDto dto) {
UserService self = applicationContext.getBean(UserService.class);
self.register(dto); // 프록시 경유 ✅
self.sendWelcomeEmail(dto); // 프록시 경유 ✅
}
}
// 해결책 3: AspectJ 모드 (컴파일 타임 위빙 – 복잡한 환경에서만)
@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)
함정 ② private 메서드 – 프록시 접근 불가
java
@Service
public class OrderService {
// ❌ private 메서드 → @Transactional 완전 무시
@Transactional
private void saveOrder(Order order) {
orderRepository.save(order);
// 트랜잭션 절대 적용 안 됨!
}
// ✅ public 또는 protected로 변경
@Transactional
public void saveOrder(Order order) {
orderRepository.save(order);
}
}
함정 ③ 멀티스레드 – 트랜잭션은 스레드에 종속
java
@Service
public class BulkProcessService {
@Transactional // ❌ 메인 스레드의 트랜잭션
public void processBulk(List<Item> items) {
items.parallelStream().forEach(item -> {
// 병렬 스트림은 별도 스레드 → 메인 트랜잭션 컨텍스트 없음!
itemService.process(item); // 트랜잭션 없이 실행
});
}
// ✅ 해결: 각 스레드에서 독립 트랜잭션
public void processBulkCorrect(List<Item> items) {
items.parallelStream().forEach(item -> {
itemService.processWithTransaction(item);
// itemService.processWithTransaction에 @Transactional → 각 스레드 독립 TX
});
}
}
함정 ④ REQUIRES_NEW와 Connection Pool 고갈
java
@Service
public class RiskyService {
@Transactional // TX-1: Connection-A 사용
public void outerMethod() {
for (int i = 0; i < 100; i++) {
innerMethod(); // ← REQUIRES_NEW로 Connection-B 추가 획득 시도
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void innerMethod() {
// TX-1(Connection-A)을 점유한 채
// 새 Connection-B 획득 시도
// Connection Pool이 1개뿐이라면? → 교착 상태(Deadlock)!
// → Connection-A가 Connection-B를 기다리고 (무한 대기)
}
}
// ✅ 해결: Connection Pool 크기를 REQUIRES_NEW 중첩 깊이의 2배 이상으로 설정
// application.yml
// spring.datasource.hikari.maximum-pool-size: 20 (최소 필요 수의 2배)
함정 ⑤ JPA Lazy Loading과 트랜잭션 범위 불일치
java
// ❌ 트랜잭션 범위 밖에서 Lazy Loading 시도
@RestController
public class OrderController {
@GetMapping("/orders/{id}")
public OrderDetailDto getOrder(@PathVariable Long id) {
Order order = orderService.findById(id); // 트랜잭션 종료
// 이 시점에 트랜잭션 없음 → Lazy Loading 불가
String userName = order.getUser().getName(); // LazyInitializationException!
return OrderDetailDto.of(order, userName);
}
}
// ✅ 해결책 1: 트랜잭션 범위 안에서 모두 조회 (DTO 변환 포함)
@Service
@Transactional(readOnly = true)
public class OrderService {
public OrderDetailDto findOrderDetail(Long id) {
Order order = orderRepository.findById(id).orElseThrow();
// 트랜잭션 안에서 Lazy Loading 실행
String userName = order.getUser().getName(); // OK
return OrderDetailDto.of(order, userName);
}
}
// ✅ 해결책 2: JPQL FETCH JOIN으로 즉시 로딩
@Query("""
SELECT o FROM Order o
JOIN FETCH o.user
WHERE o.id = :id
""")
Optional<Order> findByIdWithUser(@Param("id") Long id);
함정 ⑥ 예외 삼키기 – 롤백이 되어야 하는데 안 됨
java
@Service
public class UserService {
@Transactional
public void createUser(UserDto dto) {
try {
userRepository.save(User.create(dto));
externalService.register(dto); // 예외 발생
} catch (Exception e) {
// ❌ 예외를 삼키고 로깅만 함 → 트랜잭션은 커밋됨!
log.error("사용자 생성 실패", e);
// 외부 서비스 등록 실패했지만 DB는 저장 완료 → 불일치!
}
}
// ✅ 해결: 예외를 다시 던지거나 수동 롤백 지정
@Transactional
public void createUserSafe(UserDto dto) {
try {
userRepository.save(User.create(dto));
externalService.register(dto);
} catch (Exception e) {
log.error("사용자 생성 실패", e);
throw e; // ✅ 반드시 재던지기 → 롤백 보장
// 또는:
// TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
}
}
함정 ⑦ @Transactional과 @Async 조합
java
@Service
public class AsyncTransactionService {
// ❌ 잘못된 조합 – 비동기 메서드의 트랜잭션이 호출자와 분리됨
@Transactional
@Async
public void asyncMethod() {
// 새 스레드에서 실행 → 새 트랜잭션 컨텍스트
// 호출자의 트랜잭션과 무관하게 동작
// @Transactional 효과 없음 + @Async도 같은 빈에서 호출 시 무시
}
// ✅ 올바른 패턴: Async는 별도 Bean에서 처리
@Async
public CompletableFuture<Void> asyncWrapper(Long userId) {
// 비동기 메서드가 내부에서 트랜잭션 메서드 호출
transactionalService.processUser(userId); // 프록시 경유 ✅
return CompletableFuture.completedFuture(null);
}
}
함정 ⑧ 테스트에서의 @Transactional 오해
java
// ❌ 테스트 @Transactional이 운영 동작과 다름
@SpringBootTest
@Transactional // 테스트 후 자동 롤백
class UserServiceTest {
@Test
void 사용자_저장_테스트() {
userService.createUser(dto); // DB 저장
// → 테스트 종료 후 자동 롤백 (DB에 실제 저장 안 됨)
// → 운영에서 @Transactional 없이 호출되는 메서드 테스트 시
// 테스트에서는 트랜잭션 있음 → 다른 동작 발생
}
// ✅ 실제 운영과 동일한 트랜잭션 동작 테스트
@Test
@Commit // 테스트 후 롤백 대신 커밋 (DB 상태 직접 확인)
void 사용자_저장_커밋_테스트() { ... }
@Test
void 트랜잭션_없이_호출_테스트() {
// @Transactional 없이 직접 호출 → 실제 운영과 동일
// @SpringBootTest에서 Bean 직접 주입
assertThatThrownBy(() -> userService.callWithoutTransaction())
.isInstanceOf(LazyInitializationException.class);
}
}
6. 전문가 관점 – readOnly 최적화·테스트 전략·설계 원칙
readOnly = true – 성능 최적화의 핵심
java
// readOnly = true 동작 원리
@Transactional(readOnly = true)
public List<ProductDto> getProductList(ProductSearchRequest request) {
/*
readOnly = true 효과:
1. JPA Dirty Checking 비활성화 → 스냅샷 저장 안 함 → 메모리 절약
2. 영속성 컨텍스트 플러시 모드 → MANUAL로 변경 → 불필요한 UPDATE 방지
3. DB 레벨: 읽기 전용 힌트 전달 (일부 DB에서 최적화)
4. Spring Data JPA: 읽기 전용 레플리카 DB 라우팅 (AbstractRoutingDataSource)
*/
return productRepository.findByCondition(request).stream()
.map(ProductDto::from)
.collect(Collectors.toList());
}
// 읽기·쓰기 DB 분리 라우팅 설정
@Configuration
public class RoutingDataSourceConfig {
@Bean
public DataSource routingDataSource(
@Qualifier("writeDataSource") DataSource writeDs,
@Qualifier("readDataSource") DataSource readDs) {
ReplicationRoutingDataSource routing = new ReplicationRoutingDataSource();
routing.setDefaultTargetDataSource(writeDs);
routing.setTargetDataSources(Map.of(
"write", writeDs,
"read", readDs
));
return routing;
}
}
public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
// readOnly = true이면 읽기 DB, 아니면 쓰기 DB
return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
? "read" : "write";
}
}
트랜잭션 설계 원칙 – 서비스 레이어 패턴
java
// ✅ 트랜잭션 설계 권장 패턴
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true) // 클래스 레벨: 기본 읽기 전용
public class ProductService {
private final ProductRepository productRepository;
// 조회 메서드: 읽기 전용 트랜잭션 상속
public ProductDto getProduct(Long id) { ... }
public Page<ProductDto> searchProducts(ProductSearchRequest req) { ... }
// 쓰기 메서드: 명시적으로 readOnly = false 오버라이드
@Transactional // readOnly 기본값 false → 쓰기 가능
public ProductDto createProduct(ProductCreateRequest request) { ... }
@Transactional
public ProductDto updateProduct(Long id, ProductUpdateRequest request) { ... }
@Transactional
public void deleteProduct(Long id) { ... }
}
트랜잭션 범위 설계 체크리스트
[@Transactional 설계 최종 체크리스트]
트랜잭션 범위:
□ 비즈니스 로직 단위로 트랜잭션 설정 (너무 작거나 크지 않게)?
□ 네트워크 호출(외부 API)이 트랜잭션 안에 포함되어 있지 않은가?
□ 파일 I/O, 메일 발송 등이 트랜잭션을 불필요하게 점유하지 않는가?
전파 레벨:
□ 기본값(REQUIRED)이 적절한지 검토 완료?
□ 감사 로그·알림은 REQUIRES_NEW로 독립 처리?
□ REQUIRES_NEW 사용 시 Connection Pool 크기 충분한가?
격리 수준:
□ DB 기본 격리 수준(DEFAULT)으로 충분한가?
□ 반복 읽기가 필요한 경우 REPEATABLE_READ 적용?
롤백 전략:
□ Checked Exception도 롤백 필요 시 rollbackFor 지정?
□ 예외를 삼키는 코드(catch 후 로깅만) 없는가?
성능:
□ 조회 메서드에 readOnly = true 적용?
□ 클래스 레벨 readOnly = true + 쓰기만 @Transactional 오버라이드?
함정 방지:
□ 자기 호출(Self-Invocation) 코드 없는가?
□ private 메서드에 @Transactional 없는가?
□ 멀티스레드 환경에서 트랜잭션 공유 시도 없는가?
□ Lazy Loading이 트랜잭션 범위 안에서 실행되는가?
□ @Async와 @Transactional 잘못된 조합 없는가?
결론
@Transactional 전파 레벨은 단순히 REQUIRED 하나로 끝나지 않습니다. 감사 로그와 주요 비즈니스 로직을 분리하는 REQUIRES_NEW, 부분 롤백이 필요한 NESTED, 트랜잭션 강제를 검증하는 MANDATORY까지 전파 레벨 7가지는 각각 명확한 사용 목적이 있습니다. 격리 수준은 대부분 DB 기본값(READ_COMMITTED 또는 REPEATABLE_READ)으로 충분하지만, 비즈니스 요구사항에 따라 의식적으로 선택해야 합니다. 자기 호출·private 메서드·멀티스레드·예외 삼키기 같은 8가지 함정을 미리 알고 설계해야 비로소 트랜잭션이 안전하게 동작합니다.
지금 바로 프로젝트의 @Transactional 어노테이션을 본문 체크리스트로 점검하고, 자기 호출과 Checked Exception 롤백 누락 여부부터 확인해 보세요.
답글 남기기
댓글을 달기 위해서는 로그인해야합니다.