@Transactional의 동작 원리와 주요 속성(propagation, isolation, rollbackFor)을 설명해주세요.
힌트
프록시 기반 AOP로 동작하며, 전파 레벨과 격리 수준을 설정할 수 있습니다.
정답 및 해설
@Transactional의 동작 원리와 주요 속성(propagation, isolation, rollbackFor)을 설명해주세요.
@Transactional은 Spring에서 선언적으로 트랜잭션을 관리하는 어노테이션입니다. 개발자가 트랜잭션 시작/커밋/롤백 코드를 직접 작성하지 않아도, Spring AOP 프록시가 이를 자동으로 처리합니다.
동작 원리: AOP 프록시
@Transactional은 Spring AOP를 기반으로 동작합니다. 빈을 주입받으면 실제 객체가 아닌 프록시 객체를 받게 되며, 이 프록시가 메서드 호출을 가로채 트랜잭션을 제어합니다.
호출자
│
▼
프록시 객체 (트랜잭션 시작)
│
▼
실제 서비스 메서드 (비즈니스 로직)
│
├─ 정상 종료 → 트랜잭션 커밋
└─ 예외 발생 → 트랜잭션 롤백
// 내부적으로 동작하는 방식 (의사 코드)
public Object invoke(MethodInvocation invocation) throws Throwable {
TransactionStatus status = transactionManager.getTransaction(transactionAttribute);
try {
Object result = invocation.proceed(); // 실제 메서드 실행
transactionManager.commit(status);
return result;
} catch (RuntimeException | Error ex) {
transactionManager.rollback(status);
throw ex;
}
}
propagation (전파 레벨)
트랜잭션이 이미 존재할 때 새 트랜잭션을 어떻게 처리할지 결정합니다.
주요 전파 레벨
| 옵션 | 기존 트랜잭션 있을 때 | 기존 트랜잭션 없을 때 |
|---|---|---|
REQUIRED (기본값) | 기존 트랜잭션에 참여 | 새 트랜잭션 생성 |
REQUIRES_NEW | 기존 트랜잭션 일시 중단 후 새 트랜잭션 생성 | 새 트랜잭션 생성 |
SUPPORTS | 기존 트랜잭션에 참여 | 트랜잭션 없이 실행 |
NOT_SUPPORTED | 기존 트랜잭션 일시 중단 후 비트랜잭션 실행 | 트랜잭션 없이 실행 |
MANDATORY | 기존 트랜잭션에 참여 | 예외 발생 |
NEVER | 예외 발생 | 트랜잭션 없이 실행 |
NESTED | 중첩 트랜잭션(Savepoint) 생성 | 새 트랜잭션 생성 |
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final AuditLogService auditLogService;
// REQUIRED: 기본값. 기존 트랜잭션이 있으면 참여
@Transactional
public void createOrder(OrderRequest request) {
Order order = orderRepository.save(new Order(request));
// REQUIRES_NEW: 항상 별도 트랜잭션으로 실행
// createOrder가 롤백되어도 audit 로그는 남음
auditLogService.log("ORDER_CREATED", order.getId());
}
}
@Service
public class AuditLogService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void log(String action, Long targetId) {
// 독립적인 트랜잭션으로 실행
auditLogRepository.save(new AuditLog(action, targetId));
}
}
NESTED vs REQUIRES_NEW
@Transactional
public void parentMethod() {
// REQUIRES_NEW: 완전히 독립된 트랜잭션
// 부모 롤백 → 자식은 영향 없음, 자식 롤백 → 부모도 영향 없음
// NESTED: Savepoint 기반 중첩 트랜잭션
// 부모 롤백 → 자식도 롤백, 자식 롤백 → 부모는 Savepoint로 복구 가능
childMethod();
}
isolation (격리 수준)
여러 트랜잭션이 동시에 실행될 때 서로 어느 정도 격리할지 결정합니다. 격리 수준이 높을수록 동시성은 낮아지고 데이터 정합성은 높아집니다.
격리 수준과 발생 가능한 문제
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read | 성능 |
|---|---|---|---|---|
READ_UNCOMMITTED | 발생 | 발생 | 발생 | 가장 높음 |
READ_COMMITTED | 방지 | 발생 | 발생 | 높음 |
REPEATABLE_READ | 방지 | 방지 | 발생 | 보통 |
SERIALIZABLE | 방지 | 방지 | 방지 | 가장 낮음 |
Dirty Read: 커밋되지 않은 데이터를 읽는 문제 Non-Repeatable Read: 같은 쿼리를 두 번 실행했을 때 결과가 다른 문제 (다른 트랜잭션의 UPDATE/DELETE) Phantom Read: 범위 쿼리를 두 번 실행했을 때 행 수가 달라지는 문제 (다른 트랜잭션의 INSERT)
@Transactional(isolation = Isolation.READ_COMMITTED)
public UserBalance getBalance(Long userId) {
// 커밋된 데이터만 읽음 (대부분의 경우 적합)
return balanceRepository.findById(userId).orElseThrow();
}
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
// 같은 트랜잭션 내에서 동일 행을 읽으면 항상 같은 값 보장
UserBalance from = balanceRepository.findById(fromId).orElseThrow();
UserBalance to = balanceRepository.findById(toId).orElseThrow();
from.withdraw(amount);
to.deposit(amount);
}
MySQL InnoDB의 기본 격리 수준은
REPEATABLE_READ이며 Phantom Read를 MVCC로 대부분 방지합니다. PostgreSQL, Oracle의 기본 격리 수준은READ_COMMITTED입니다.
rollbackFor
기본적으로 Spring은 **unchecked 예외(RuntimeException, Error)**만 롤백합니다. checked 예외는 롤백하지 않습니다.
// 기본 동작
@Transactional
public void defaultBehavior() throws Exception {
// RuntimeException → 롤백 O
// Exception(checked) → 롤백 X (커밋됨!)
}
// 모든 예외에서 롤백
@Transactional(rollbackFor = Exception.class)
public void rollbackOnAllExceptions() throws Exception {
// 모든 예외에서 롤백
}
// 특정 예외에서만 롤백
@Transactional(rollbackFor = {DataIntegrityException.class, IOException.class})
public void rollbackOnSpecificExceptions() throws IOException {
orderRepository.save(order);
fileService.writeReceipt(order); // IOException 발생 시 롤백
}
// 특정 예외는 롤백하지 않음
@Transactional(noRollbackFor = OptimisticLockException.class)
public void noRollbackForOptimisticLock() {
// OptimisticLockException 발생해도 롤백하지 않음
}
기타 주요 속성
@Transactional(
propagation = Propagation.REQUIRED,
isolation = Isolation.READ_COMMITTED,
rollbackFor = Exception.class,
timeout = 30, // 30초 초과 시 롤백
readOnly = true // 읽기 전용 최적화 (쓰기 불가, 성능 향상)
)
public List<Order> getOrders() {
return orderRepository.findAll();
}
readOnly = true를 사용하면:
- Dirty Checking(변경 감지) 비활성화로 성능 향상
- 영속성 컨텍스트의 스냅샷 저장 생략
- 일부 데이터베이스 드라이버에서 읽기 전용 최적화 적용
Self-Invocation 주의사항
같은 클래스 내에서 메서드를 호출하면 프록시를 거치지 않아 @Transactional이 적용되지 않습니다.
@Service
public class UserService {
@Transactional
public void createUser(UserRequest request) {
userRepository.save(new User(request));
// 내부 호출 — @Transactional 미적용!
this.sendWelcomeEmail(request.getEmail());
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendWelcomeEmail(String email) {
// 별도 트랜잭션으로 실행되지 않음
emailRepository.save(new EmailLog(email));
}
}
해결 방법:
@Service
@RequiredArgsConstructor
public class UserService {
private final EmailService emailService; // 별도 클래스로 분리
@Transactional
public void createUser(UserRequest request) {
userRepository.save(new User(request));
emailService.sendWelcomeEmail(request.getEmail()); // 외부 빈 호출
}
}
@Service
public class EmailService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendWelcomeEmail(String email) {
emailRepository.save(new EmailLog(email)); // 올바르게 별도 트랜잭션 실행
}
}
정리
| 속성 | 주요 옵션 | 기본값 |
|---|---|---|
propagation | REQUIRED, REQUIRES_NEW, NESTED | REQUIRED |
isolation | READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE | DEFAULT(DB 설정 따름) |
rollbackFor | Exception.class 등 | RuntimeException, Error |
readOnly | true / false | false |
timeout | 초 단위 정수 | -1(무제한) |