
1. 개요
하나의 HTTP 요청이 여러 서비스 레이어를 거쳐 처리되는 과정에서, 각 서비스가 트랜잭션을 어떻게 공유하고 분리할지 결정하는 것은 시스템의 안정성에 적지 않은 영향을 미칩니다. 스프링 프레임워크가 제공하는 선언적 트랜잭션(@Transactional)은 복잡한 로직을 단순화해주지만, 트랜잭션 간의 상호작용인 전파 속성(Propagation)을 고려하지 않으면 의도치 않은 결과를 초래할 수 있습니다.
특히 실제 개발 환경에서는 기존 트랜잭션 흐름에 합류하거나, 혹은 별도의 실행 흐름을 만들어야 하는 경우가 빈번합니다. 본 글에서는 이러한 상황에서 주로 논의되는 REQUIRED와 REQUIRES_NEW 속성의 동작 원리와, 이를 실제 업무에 적용할 때 고려해야 할 기술적 특징들을 살펴보겠습니다.
2. 간단한 설명
트랜잭션 전파는 트랜잭션의 경계에서 새로운 트랜잭션 진입 시의 동작 방식을 정의합니다. 이를 명확히 이해하기 위해서는 스프링이 트랜잭션을 관리하는 두 가지 차원, 즉 논리 트랜잭션(Logical Transaction)과 물리 트랜잭션(Physical Transaction)의 개념을 구분할 필요가 있습니다.
- 물리 트랜잭션: 실제 데이터베이스 커넥션(Connection)을 통해 수행되는 트랜잭션으로, 실제
COMMIT이나ROLLBACK명령이 수행되는 단위입니다. - 논리 트랜잭션: 스프링이 코드 레벨에서 관리하는 트랜잭션 범위입니다.
💡 논리 트랜잭션은 어떻게 관리될까?
스프링은 TransactionSynchronizationManager를 통해 트랜잭션을 스레드 로컬(ThreadLocal)에 보관하고 관리합니다.
잠깐, 스레드 로컬(ThreadLocal)이란?
일반적인 변수는 여러 스레드가 공유할 수 있지만, 스레드 로컬은 오직 해당 스레드만 접근할 수 있는 전용 저장소입니다. 마치 '개인용 금고'처럼, A 스레드가 저장한 데이터는 B 스레드에서 절대 보이지 않습니다. 스프링은 이 기능을 이용해 "현재 스레드가 사용 중인 DB 커넥션"을 안전하게 보관하고, 파라미터로 넘기지 않아도 어디서든 꺼내 쓸 수 있게 합니다.
- 트랜잭션 동기화: 시작된 트랜잭션의 커넥션을 스레드 로컬에 저장하여, 동일한 스레드 내에서 리포지토리(Repository) 등이 같은 커넥션을 사용하도록 보장합니다.
- 논리적 참여:
REQUIRED전파 속성의 경우, 내부 메서드는 새로운 물리 트랜잭션을 만들지 않고 기존 트랜잭션 상태(TransactionStatus)에 참여합니다. 이때 스프링은 이를 '새로운 트랜잭션이 아님(newTransaction=false)'으로 인식합니다. - 예외 전파: 내부 논리 트랜잭션에서 예외가 발생하면
rollbackOnly플래그를true로 설정합니다. 물리 트랜잭션 커밋 시점에 이 플래그가 확인되면UnexpectedRollbackException과 함께 전체가 롤백됩니다.
2.1. REQUIRED (기본값)
Propagation.REQUIRED는 별도의 설정이 없을 때 적용되는 기본 속성입니다.
- 동작: 이미 진행 중인 트랜잭션이 있다면 해당 트랜잭션의 일부로 참여하고, 없다면 새로운 트랜잭션을 시작합니다.
- 특징: 여러 논리 트랜잭션이 하나의 물리 트랜잭션을 공유하는 형태입니다. 따라서 참여 중인 논리 트랜잭션 중 어느 한 곳에서만 예외가 발생해도, 전체 물리 트랜잭션이 롤백될 가능성이 높습니다. 이는 데이터의 일관성을 유지하는 데 유리한 구조입니다.
2.2. REQUIRES_NEW
Propagation.REQUIRES_NEW는 트랜잭션의 독립적인 실행이 필요할 때 사용되는 옵션입니다.
- 동작: 호출 시점의 트랜잭션 존재 여부와 상관없이 항상 새로운 물리 트랜잭션을 생성합니다. 만약 진행 중인 트랜잭션이 있다면, 그 트랜잭션을 잠시 보류(Suspend)하고 새로운 트랜잭션을 우선 처리합니다.
- 특징: 서로 다른 물리 트랜잭션을 사용하므로커밋과 롤백이 독립적으로 이루어집니다. 내부 트랜잭션에서 발생한 문제가 외부 트랜잭션에 전파되지 않도록 설계할 수 있습니다.
🤔 복수의 물리 트랜잭션, 우선순위는 어떻게 될까?
우선순위(Priority)보다는 스택(Stack) 구조의 실행 흐름으로 이해하는 것이 정확합니다.
- 일시 정지(Suspend): 기존 트랜잭션 A는 새로운 트랜잭션 B가 끝날 때까지 메모리 상에서 대기 상태가 됩니다.
- 순차 실행: 트랜잭션 B가 실행되고 종료(커밋/롤백)됩니다.
- 재개(Resume): B가 끝나면 A가 다시 활성화되어 남은 로직을 수행합니다.
- 주의: 물리적으로 커넥션이 분리되어 있으므로, 트랜잭션 B가 트랜잭션 A가 잡고 있는 DB 락(Lock)에 접근하려 하면 데드락(Deadlock)이 발생하여 영원히 기다릴 수 있습니다. 실행 순서는 B가 먼저지만, 자원은 A가 선점하고 있기 때문입니다.
3. 사용 사례 (예시 포함)
자주 접할 수 있는 '주문 처리와 이력 로그(Log) 저장' 시나리오를 통해 두 속성의 차이를 비교해 보겠습니다.
3.1. 상황 가정: 메인 로직과 서브 로직의 분리
OrderService를 통해 주문이 생성될 때, LogService를 호출하여 처리 이력을 남기려는 상황입니다.
이때 비즈니스 요건이 "로그 저장에 실패하더라도, 정상적으로 처리된 주문은 유지되어야 한다"라고 가정해 봅시다.
3.2. 단일 트랜잭션의 한계 (REQUIRED)
두 서비스가 모두 기본값인 REQUIRED로 설정되어 있다면, 로그 저장 실패가 전체 주문 취소로 이어지는 의도치 않은 결과가 발생할 수 있습니다.
@Service
@RequiredArgsConstructor
public class OrderService {
// ... dependencies
@Transactional // REQUIRED (물리 트랜잭션 시작)
public void processOrder(OrderDto dto) {
orderRepository.save(dto.toEntity()); // 주문 저장
try {
logService.saveLog(dto); // 로그 저장 시도 (트랜잭션 참여)
} catch (Exception e) {
// 예외를 catch하여 처리했으나, 이미 롤백 마크가 설정되었을 수 있음
log.warn("로그 저장 중 오류 발생");
}
}
}
@Service
public class LogService {
// ... dependencies
@Transactional // REQUIRED (기존 트랜잭션 참여)
public void saveLog(OrderDto dto) {
logRepository.save(new LogEntity(dto));
throw new RuntimeException("DB 오류"); // 예외 발생 -> 롤백 마크 설정
}
}
분석 - 왜 try-catch가 소용 없을까?:
흔히 "예외를 잡았으니(catch) 롤백이 안 되겠지?"라고 생각하기 쉽지만, 실제로는 그렇지 않습니다.
- 마킹(Marking):
LogService에서 예외가 터지는 순간, 해당 논리 트랜잭션은 '이미 망가진 트랜잭션'이라 판단하고 물리 트랜잭션에setRollbackOnly()마크를 남깁니다. - 전파(Propagation): 예외는
OrderService로 전달되고, 여기서catch를 통해 로그를 남기고 정상 흐름으로 복귀합니다. - 검증(Verification): 하지만
OrderService의 트랜잭션이 끝나는 시점에 스프링은 "이 물리 트랜잭션에 누군가 롤백 마크를 남겼는가?"를 확인합니다. - 폭발(Explosion): 롤백 마크가 발견되면, 스프링은 "정합성이 깨졌다"고 판단하여 커밋 대신
UnexpectedRollbackException을 던지고 모든 데이터를 롤백시킵니다. 단순히 예외를 잡는 것만으로는 이미 더럽혀진(Marked) 트랜잭션을 되살릴 수 없습니다.
3.3. 독립적인 트랜잭션 적용 (REQUIRES_NEW)
서브 로직의 실패가 메인 로직에 영향을 주지 않아야 한다면, REQUIRES_NEW를 통해 트랜잭션을 물리적으로 분리하는 방법을 고려할 수 있습니다.
@Service
public class LogService {
// ... dependencies
// 새로운 물리 트랜잭션을 생성하여 실행
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveLog(OrderDto dto) {
logRepository.save(new LogEntity(dto));
// 여기서 예외가 발생하더라도 별도의 커넥션에서 롤백되므로
// 호출한 쪽(OrderService) 트랜잭션은 안전할 수 있음
}
}
이 방식을 적용하면 LogService.saveLog() 호출 시점에 OrderService의 트랜잭션은 잠시 중단되고, 별도의 커넥션을 통해 로그 저장이 시도됩니다. 로그 저장이 실패하더라도 상위 OrderService에서 예외 처리만 적절히 해준다면 주문 정보는 정상적으로 커밋될 수 있습니다.
⚠️ 기술적 고려사항
독립적인 트랜잭션 사용은 유연성을 제공하지만, 다음과 같은 사이드 이펙트를 고려해야 합니다.
- 리소스 사용량: 하나의 요청 처리에 대해 두 개 이상의 DB 커넥션을 점유하게 되므로, 트래픽이 높은 구간에서는 커넥션 풀(Connection Pool) 고갈의 원인이 될 수도 있습니다.
- 잠금(Lock) 대기: 부모와 자식 트랜잭션이 동일한 데이터에 접근하려고 할 경우, 자식 트랜잭션은 처리를 시도하고 부모 트랜잭션은 락을 쥔 채 자식의 완료를 기다리는 교착 상태(Deadlock)가 발생할 가능성이 있습니다.
4. 맺음말
@Transactional은 생산성을 높여주는 강력한 도구이지만, 그 내부 동작 원리인 전파 속성을 이해하지 못하면 디버깅이 어려운 이슈를 만날 수 있습니다.
- REQUIRED: 하나의 비즈니스 단위로 완결성을 보장해야 하는 경우.
- REQUIRES_NEW: 메인 로직의 성공/실패 여부와 관계없이 독립적으로 수행되어야 하는 부가 기능인 경우.
각 속성의 특징과 리스크를 충분히 검토한 뒤 적용하는 것이, 보다 견고하고 예측 가능한 시스템을 만드는 방법이 될 것입니다.
'프로그래밍 PROGRAMMING > 자바 JAVA AND FRAMEWORKS' 카테고리의 다른 글
| [JAVA/Cache] 캐시 스탬피드(Cache Stampede): 만료된 순간 시스템이 멈추는 이유와 해결책 (2) | 2026.01.28 |
|---|---|
| [SPRING/JPA] JPA 2차 캐시(Second-Level Cache): 영속성 컨텍스트와 성능 최적화 (0) | 2026.01.27 |
| [Spring/JPA] Spring JPA N+1 문제 정리: 원인, 예시 코드, Fetch Join/EntityGraph 해결 전략 (0) | 2026.01.16 |
| [Spring] @Transactional 동작 원리 및 트랜잭션 미적용 원인 분석 (0) | 2026.01.15 |
| [Spring] Spring Bean 주입 방식 정리 (0) | 2026.01.14 |