Spring 전파 레벨, 잘 알고 써야 사고 안 납니다
서비스를 개발하다 보면 트랜잭션 관리는 정말 중요한 이슈입니다.
한 번의 실수로 데이터 불일치가 발생하면 큰 문제가 될 수 있거든요.
특히 Spring의 트랜잭션 전파 레벨(Propagation)을 제대로 이해하지 못하고 사용하면 예상치 못한 사고가 발생할 수 있습니다.
이번 글에서는 전파 레벨의 개념부터 실제 서비스에서 자주 사용하는 방식, 그리고 개발 시 주의할 점까지 Spring AOP 구조와 함께 정리해보려 합니다.
트랜잭션 전파 레벨이란?
트랜잭션 전파 레벨이란, 이미 트랜잭션이 진행 중인 상태에서 다른 메서드가 호출될 때, 해당 메서드가 기존 트랜잭션에 참여할지, 아니면 새로운 트랜잭션을 시작할지를 결정하는 설정값입니다.
Spring에서는 @Transactional 어노테이션의 propagation 속성을 통해 이를 제어합니다.
이 설정 하나가 전체 로직의 커밋/롤백 흐름에 직접적인 영향을 미치기 때문에, 실무에서 매우 중요합니다.
Spring AOP와 프록시 구조 이해하기
@Transactional은 Spring AOP 기반으로 동작합니다.
Spring은 트랜잭션을 관리하기 위해 해당 클래스를 프록시 객체로 감싸고, 메서드 호출 시 프록시가 트랜잭션 경계를 처리합니다.
AOP 적용 구조 예시
// AOP 미적용
Client → Target.method()
// AOP 적용
Client → Proxy.method()
↳ Advice 전처리 (트랜잭션 시작)
↳ Target.method() 실행
↳ Advice 후처리 (커밋/롤백)
이 과정을 위빙(Weaving)이라 부르며, 프록시는 핵심 로직 실행 전후에 공통 로직을 끼워 넣습니다.
❗ 내부 메서드 호출 시 트랜잭션이 무시되는 이유
문제는 같은 클래스 내에서 this.method() 방식으로 메서드를 호출하면 발생합니다.
@Service
class UserService {
@Transactional
fun createUser(user: User) {
userRepository.save(user)
this.sendNotification(user) // 프록시를 거치지 않음!
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun sendNotification(user: User) {
// 트랜잭션 적용되지 않음
}
}
- 이때 this는 프록시가 아니라 원본 객체 자기 자신을 의미합니다.
- 따라서 sendNotification()은 프록시를 거치지 않고 직접 호출되므로, @Transactional이 적용되지 않습니다.
해결 방법
@Service
class UserService(
private val notificationService: NotificationService
) {
@Transactional
fun createUser(user: User) {
userRepository.save(user)
notificationService.sendNotification(user) // 프록시를 거쳐 정상 동작
}
}
@Service
class NotificationService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun sendNotification(user: User) {
// AOP 프록시 적용됨
}
}
다른 클래스로 분리하면 Spring이 해당 클래스를 프록시로 감싸기 때문에, 트랜잭션 전파가 제대로 동작합니다.
실무에서 자주 사용하는 전파 레벨
1. REQUIRED (기본값)
- 기존 트랜잭션이 있으면 참여, 없으면 새로 생성
- 가장 일반적인 전파 방식
@Transactional // 기본값: REQUIRED
fun createUser() {
userRepository.save(...)
pointService.addWelcomePoint() // REQUIRED
}
@Transactional
fun addWelcomePoint() {
throw RuntimeException("포인트 추가 실패")
}
→ 내부 메서드에서 예외 발생 시 전체 트랜잭션이 롤백됩니다.
2. REQUIRES_NEW
- 기존 트랜잭션을 잠시 중단하고 새로운 트랜잭션을 시작
- 실패해도 다른 트랜잭션에 영향을 주지 않음
- 로그 저장, 로그인 이력 저장 등 독립적으로 커밋이 필요한 로직에 사용
@Transactional // 부모 트랜잭션
fun login() {
authService.verifyCredentials()
loginHistoryService.saveLoginHistory() // REQUIRES_NEW
throw RuntimeException("로그인 실패")
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun saveLoginHistory() {
loginHistoryRepository.save(...)
}
→ saveLoginHistory()는 부모 트랜잭션과 완전히 분리된 트랜잭션으로 커밋되며,
→ 이후 예외로 부모 트랜잭션이 롤백되어도 loginHistory는 롤백되지 않음.
💡 실제 서비스 활용 예시
로그인 로직에서는 히스토리를 반드시 기록해야 합니다.
로그인이 성공하든 실패하든 로그인 이력은 항상 남아야 하며, 이때 REQUIRES_NEW가 유용하게 사용됩니다.
@Transactional
fun login(command: LoginCommand, ...) {
try {
val user = getValidUser(command)
loginHistoryService.saveLoginHistory(user.email, siteType, true)
return LoginResponse.from(user)
} catch (e: Exception) {
loginHistoryService.saveLoginHistory(command.email, siteType, false)
throw e
}
}
- 성공 시 → 이력 저장
- 실패 시 → 이력 저장
- 이력 저장 중 실패 → 로그인 흐름엔 영향 없음
⚠️ 주의사항 요약
1. 같은 클래스 내부 호출은 트랜잭션 무시
→ 별도 클래스로 분리 필요
2. REQUIRES_NEW 남용 금지
→ 커넥션 풀 고갈, 데드락 위험 있음
3. 예외 발생 시 전파 방향 주의
→ REQUIRES_NEW는 자신만 롤백됨, 부모 트랜잭션에는 영향 없음
마무리
❓ 이 로직이 실패하면, 다른 로직도 함께 롤백되어야 하는가?
이 질문의 답이 전파 레벨 선택의 기준이 됩니다.
정확한 전파 레벨 설정은 단순히 옵션을 지정하는 것을 넘어, 데이터 정합성과 시스템 신뢰성을 높이는 중요한 도구입니다.
안전하고 견고한 서비스를 만들기 위해 트랜잭션 전파를 꼭 제대로 이해하고 활용하시길 바라겠습니다 :)