트랜잭션은 마법이 아니다
우리는 종종 @Transactional을 '마법의 지팡이'처럼 사용합니다. 메서드 위에 붙이기만 하면 알아서 커밋해주고, 알아서 롤백해줄 것이라 믿죠.
하지만 현실은 냉혹합니다. "분명 롤백시켰는데 데이터가 남아있어요." "비동기 로직에서 트랜잭션이 사라졌어요." "REQUIRES_NEW를 썼는데도 같이 롤백돼요."
이런 문제가 발생하는 가장 큰 이유는 스프링이 트랜잭션을 "어디에, 어떻게 기억하고 이어 붙이는지" 모른 채 사용하기 때문입니다.
오늘은 이 마법의 껍데기를 벗겨보려 합니다. 스프링 트랜잭션의 내부 혈관인 3가지 핵심 매니저를 살펴보고, 우리가 현업에서 마주치는 대표적인 트랜잭션 실종 사건 2가지를 해결해 보겠습니다.
스프링 트랜잭션의 해부학 (Internal Flow)
스프링 트랜잭션은 거대한 AOP 체인으로 얽혀 있지만, 핵심만 추려보면 3명의 주인공이 등장합니다.

1) 선언 단계 - `@Transactional`과 `TransactionInterceptor`
이전에 알아본 @Transactional이 선언된 메서드나 클래스는 스프링이 *프록시(proxy)*를 생성한다. 이 프록시는 실제 객체를 감싸고 있다가 메서드가 호출되면 AOP 체인으로 진입시킨다.
그 체인에서 중요 역할을 하는 것이
이 클래스의 역할은 명확합니다. "이 메서드에 트랜잭션이 필요한가? 그렇다면 어떤 규칙(Option)으로 열어야 하는가?"
핵심 로직은 invoke() 메서드 안에 숨어 있습니다.
@Override
@Nullable
public Object invoke(MethodInvocation invocation) throws Throwable {
// 타겟 클래스(실제 비즈니스 로직이 있는 클래스)를 확인
Class<?> targetClass = (invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis()) : null);
// 트랜잭션 처리를 위한 핵심 메서드로 위임
return invokeWithinTransaction(invocation.getMethod(), targetClass, invocation::proceed);
}invokeWithinTransaction 메서드가 실행되면, 스프링은 다음과 같이 5단계의 정교한 과정을 거쳐 트랜잭션을 제어합니다.
(1) 속성 파싱: "어떤 규칙인가?" (`TransactionAttribute`)
가장 먼저 하는 일은 @Transactional에 적힌 옵션들을 읽어들이는 것입니다.
TransactionAttribute txAttr = (tas != null ? tas.getTransactionAttribute(method, targetClass) : null);readOnly=true인지?propagation은 무엇인지?rollbackFor에는 어떤 예외가 지정되었는지?
이 메타데이터들을 파싱 하여 TransactionAttribute라는 객체로 만들어둡니다. 이것이 트랜잭션의 설계도가 됩니다.
(2) 관리자 배정: "누가 담당할 것인가?" (`TransactionManager`)
설계도가 나왔으니, 이제 공사를 담당할 소장님(Manager)을 불러야 합니다.
TransactionManager tm = determineTransactionManager(txAttr, targetClass);스프링은 상황에 맞는 트랜잭션 매니저를 결정합니다.
@Transactional("name")에 특정 매니저 이름을 명시했는가?- 없다면, 설정(Config)에 등록된 기본
PlatformTransactionManager를 가져온다. - (일반적으로
DataSourceTransactionManager나JpaTransactionManager가 선택됩니다.)
(3) 트랜잭션 생성: "공사를 시작하자" (`createTransactionIfNecessary`)
이제 매니저에게 트랜잭션을 열라고 지시합니다. 이때가 가장 중요한 분기점입니다.
TransactionInfo txInfo = createTransactionIfNecessary(ptm, txAttr, joinpointIdentification);이 과정에서 3가지 핵심 작업이 일어납니다.
- 전파(Propagation) 판단: 이미 진행 중인 트랜잭션이 있는지 확인하고, 합류할지 새로 만들지 결정합니다.
- 상태(Status) 생성:
isNewTransaction,hasSavepoint등의 상태 정보를 담은 객체를 만듭니다. - 동기화(Synchronization): 생성된 트랜잭션 정보(Connection 등)를
TransactionSynchronizationManager(TSM)를 통해 ThreadLocal에 바인딩합니다. (이제부터 이 스레드는 트랜잭션 중임을 알게 됩니다.)
(4) 비즈니스 로직 실행: "실제 업무 수행"
트랜잭션 준비가 끝났으니, 드디어 실제 비즈니스 로직을 실행합니다.
try {
// 실제 서비스 메서드 호출 (target.method())
retVal = invocation.proceedWithInvocation();
} catch (Throwable ex) {
// 예외 발생 시 롤백 처리 로직으로 이동
completeTransactionAfterThrowing(txInfo, ex);
throw ex;
}이 과정에서 예외가 발생하면 catch 블록으로 이동하여 롤백 절차를 밟고, 성공하면 다음 단계인 커밋으로 이동합니다.
(5) 종료: "결과 확정 및 뒷정리"
로직 수행 결과에 따라 트랜잭션을 마무리합니다.
커밋
commitTransactionAfterReturning(txInfo);매니저에게 commit()을 명령합니다. DB에 변경 사항을 반영하고, 트랜잭션을 정상 종료합니다.
롤백
completeTransactionAfterThrowing(txInfo, ex);앞서 살펴본 롤백 정책(Checked vs Unchecked)을 여기서 판단합니다. 롤백 대상이라면 매니저에게 rollback()을 명령하여 태초의 상태로 되돌립니다.
클린업
cleanupTransactionInfo(txInfo);마지막으로 사용한 리소스를 반환하고, TSM의 ThreadLocal에 저장된 정보를 깨끗하게 비웁니다. 그래야 스레드 풀에 반환된 스레드가 다음 요청을 처리할 때 오동작하지 않기 때문입니다.
2) 실행 단계: 관리자 `PlatformTransactionManager`
문지기(TransactionInterceptor)가 "트랜잭션 처리가 필요하다"고 판단하면, 실질적인 업무는 PlatformTransactionManager(이하 PTM)에게 위임됩니다.
이 인터페이스는 스프링 트랜잭션의 심장(Engine)입니다. 트랜잭션을 시작하고, 상태를 관리하며, 최종적으로 커밋이나 롤백을 수행하는 총괄 책임자이기 때문입니다.
public interface PlatformTransactionManager extends TransactionManager {
// 1. 트랜잭션 시작 (또는 참여)
TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException;
// 2. 정상 종료 (커밋)
void commit(TransactionStatus status) throws TransactionException;
// 3. 예외 발생 시 (롤백)
void rollback(TransactionStatus status) throws TransactionException;
}PTM은 이 3가지 핵심 메서드를 통해 트랜잭션의 생명주기를 완벽하게 제어합니다.
(1) 트랜잭션의 정의와 상태 (`getTransaction`)
PTM의 첫 번째 임무는 "지금 트랜잭션을 어떻게 처리할 것인가?"를 판단하는 것입니다. 앞서 문지기가 넘겨준 TransactionDefinition (전파 속성, 격리 수준 등)을 보고 다음과 같은 의사결정을 내립니다.
- 현재 상태 확인: "이미 실행 중인 트랜잭션이 있는가?"
- 전파(Propagation) 판단:
REQUIRED: "있으면 합류하고, 없으면 새로 만들자."REQUIRES_NEW: "무조건 새로 만들자. 기존 건 잠시 미뤄둬."
- 상태 객체 생성: 판단 결과를
TransactionStatus객체에 담아 반환합니다. 이 객체는 나중에 커밋/롤백 시점에 "내가 방금 만든 트랜잭션인지, 아니면 기존에 참여만 한 것인지"를 증명하는 신분증이 됩니다.
(2) 리소스 동기화 (With TSM)
트랜잭션을 시작했다면, 이 사실을 모두에게 알려야 합니다. PTM은 트랜잭션을 시작한 직후, DB 커넥션이나 세션 같은 리소스를 TransactionSynchronizationManager에게 넘겨 저장하게 합니다. 그래야 이후에 실행되는 리포지토리(Repository)들이 매번 새로운 커넥션을 맺지 않고, 방금 만든 그 커넥션을 돌려쓸 수 있기 때문입니다.
3) 기억 단계: 공유 노트 `TransactionSynchronizationManager`
스프링 트랜잭션이 "마법"처럼 느껴지는 이유는 사실 이 TransactionSynchronizationManager(이하 TSM) 덕분입니다. TSM은 트랜잭션의 정보를 스레드(Thread) 단위로 기억하는 '공유 노트'입니다.
핵심은 `ThreadLocal`
TSM은 내부적으로 ThreadLocal을 사용합니다. 이게 왜 중요할까요? 개발자가 메서드마다 Connection 파라미터를 일일이 넘기고 다니지 않아도, "같은 스레드라면 어디서든 같은 트랜잭션 리소스에 접근할 수 있다"는 것을 보장해 주기 때문입니다.
public abstract class TransactionSynchronizationManager {
// 현재 스레드가 트랜잭션 중인지 확인
public static boolean isActualTransactionActive();
// 트랜잭션 리소스(Connection 등) 바인딩
public static void bindResource(Object key, Object value);
// 동기화 작업 등록 (커밋 전/후 할 일 등)
public static void registerSynchronization(TransactionSynchronization synchronization);
}isActualTransactionActive() 같은 메서드는 JPA나 로깅 라이브러리들이 "지금 트랜잭션 중인가?"를 확인할 때 사용하는 표준 척도가 됩니다.
동기화(Synchronization)란?
트랜잭션이 끝날 때(커밋/롤백), 단순히 DB 커밋만 하고 끝나는 게 아닙니다. 관련된 작업들이 타이밍에 맞춰 함께 움직여야 합니다.
- Hibernate/JPA:
flush()를 통해 영속성 컨텍스트의 변경 사항을 DB에 반영하고,clear()로 정리합니다. - 메시지 큐: 트랜잭션이 확실히 커밋된 후에 메시지를 발행합니다.
- 사용자 정의 훅: "트랜잭션이 끝나면 알림 보내기" 같은 커스텀 로직을 실행합니다.
TSM은 이런 작업들을 registerSynchronization으로 미리 등록해 두었다가, PTM이 commit()을 호출하는 시점에 순서대로 실행시킵니다.
💡 중간 정리
지금까지 스프링 트랜잭션이 흐르는 길을 따라가 보았습니다.
- TransactionInterceptor: 트랜잭션이 필요한지 검사하고 (선언)
- PlatformTransactionManager: 실제로 트랜잭션을 열고 닫으며 (실행)
- TransactionSynchronizationManager: 그 정보를 스레드에 저장해 공유합니다 (기억)
이 [프록시 → 매니저 → 스레드 로컬]로 이어지는 흐름을 머릿속에 넣고 있다면, 이제 실무에서 마주치는 "도대체 왜 트랜잭션이 안 먹히지?"라는 상황을 명쾌하게 분석할 수 있습니다.
이제 대표적인 2가지 문제 상황을 통해 이 이론을 실전 지식으로 바꿔보겠습니다.
트랜잭션이 정확히 동작하지 않는 예시 상황들
1) 내부 호출(Self-Invocation)의 함정: "왜 같이 롤백되죠?"
개발자라면 한 번쯤 이런 요구사항을 받아보셨을 겁니다.
요구사항: "파일을 여러 개 저장하는데, 중간에 하나가 실패해도 나머지는 성공해야 합니다. 각 저장은 서로 독립적인 트랜잭션으로 처리해 주세요."
개발자는 자신 있게 @Transactional(propagation = Propagation.REQUIRES_NEW)를 떠올리며 코드를 작성합니다.
@Slf4j
@Service
@RequiredArgsConstructor
public class FileService {
private final FileRepository fileRepository;
/**
* - 각 파일은 REQUIRES_NEW로 독립 트랜잭션에서 실행되길 기대한다.
* - 3번째에서 에러가 나도 나머지(1,2,4)는 커밋되길 바라는 상황.
*/
@Transactional
public void saveAll(List<String> fileUrls) {
log.info(">>> [saveAll] txActive={}, txName={}",
TransactionSynchronizationManager.isActualTransactionActive(),
TransactionSynchronizationManager.getCurrentTransactionName());
for (String fileUrl : fileUrls) {
log.info(">>> [saveAll] {} 저장 시도", fileUrl);
// (원래 정상적인 생각대로면 매 순간 try catch를 해서 saveAll()까지의 익셉션 전파를
// 막아야겠으나 우선 원인 도출을 위해 제거)
saveOneInNewTx(fileUrl);
}
log.info(">>> [saveAll] 종료");
}
/**
* 각 파일을 "새로운 트랜잭션"으로 처리하고 싶어서 REQUIRES_NEW를 건 상황.
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveOneInNewTx(String fileUrl) {
log.info(">>> [saveOneInNewTx] txActive={}, txName={}",
TransactionSynchronizationManager.isActualTransactionActive(),
TransactionSynchronizationManager.getCurrentTransactionName());
FileEntity entity = new FileEntity();
entity.setFileUrl(fileUrl);
fileRepository.save(entity);
if (fileUrl.equals("file3")) {
log.warn(">>> [saveOneInNewTx] file3 저장 직후 의도적으로 예외 발생");
throw new RuntimeException("3번째 파일 처리 중 예외");
}
log.info(">>> [saveOneInNewTx] 저장 완료 fileUrl={}", fileUrl);
}
}
개발자는 saveAll()이 전체 배치를 조율하는 상위 트랜잭션처럼 동작하고, 각 saveOneInNewTx() 호출이 REQUIRES_NEW로 서로 독립된 트랜잭션으로 처리되길 기대했을 것입니다.
그렇다면 file1 ~ file4까지 값을 전달하여 테스트해보면 결과를 알 수 있겠지요.
개발자의 기대
- file1: 커밋
- file2: 커밋
- file3: 자신의 트랜잭션에서 예외 발생 → 그 트랜잭션만 롤백
- file4: 커밋
따라서 DB에는 file1, file2, file4만 남아야 한다.
그러나 실제 결과는 어떤 파일도 저장되지 않았습니다. 3번째 파일에서 예외가 발생하면서, 앞에서 이미 저장된 file1, file2까지 모두 함께 롤백된 것입니다.
즉, 각 파일 저장 로직이 “개별 트랜잭션”으로 실행된 것이 아니라, 처음 saveAll()에서 열린 단일 트랜잭션 하나에 전부 묶여 있었다는 뜻이 됩니다.
왜 이런 일이 발생한 걸까?
결과가 의도와 다르게 나온 이유를 단계별로 뜯어보면 다음과 같습니다.
saveAll()은 외부에서 호출되었으므로 프록시를 거쳤습니다. 덕분에 하나의 트랜잭션이 정상적으로 시작되었습니다.- 문제는
saveAll()내부에서saveOneInNewTx()를 호출할 때 발생합니다. 이때 호출 주체는 프록시가 아니라 실제 객체 자기 자신(this)입니다. - 프록시를 거치지 않고 메서드를 직접 호출했기 때문에,
saveOneInNewTx()에 붙은REQUIRES_NEW설정은 아예 읽히지도 않습니다. - 결국
saveOneInNewTx()는 새로운 트랜잭션을 만들지 못하고, 이미 실행 중인saveAll()의 부모 트랜잭션에 포함되어(참여하여) 실행됩니다. file3처리 중 예외가 발생하자, 이 예외는saveAll()바깥까지 그대로 전파됩니다. 최종적으로saveAll()을 감싸고 있던TransactionInterceptor가 이 예외를 감지하고 전체 트랜잭션을 롤백시켜 버립니다.
그 결과, file1, file2를 포함한 모든 작업이 물거품이 된 것입니다.
트랜잭션 흐름을 다시 따라가 보자
말로만 설명하면 와닿지 않을 수 있으니, 실제 AOP 프록시가 움직이는 동선을 따라가 보겠습니다.
클라이언트 호출: 클라이언트(Controller/Test)가 주입받은
FileService는 실제 객체가 아니라 스프링이 만든 프록시입니다.Client → [Proxy] FileServicesaveAll()진입: 프록시를 통해 호출되므로 문지기(TransactionInterceptor)가 개입합니다.Client ↓ [Proxy] FileService.saveAll() ↓ TransactionInterceptor.invoke() ↓ invokeWithinTransaction(...) ↓ 트랜잭션 시작 (Transaction-A) ↓ [Real Object] FileService.saveAll()이 시점에서 트랜잭션 1개가 열린다.
saveOneInNewTx()내부 호출 (문제의 구간):saveAll()메서드 안에서 다음 메서드를 호출합니다.[Real Object] FileService.saveAll() ↓ this.saveOneInNewTx() <-- ⚠️ 프록시를 거치지 않음! ↓ [Real Object] FileService.saveOneInNewTx()여기서는 프록시가 없습니다. 문지기가 없으니
REQUIRES_NEW를 해석해 줄 사람도 없습니다. 결국 코드는 Transaction-A 상태 그대로 실행됩니다.예외 발생과 롤백: 세 번째 파일 처리 중 예외가 터집니다.
if (fileUrl.equals("file3")) { throw new RuntimeException("3번째 파일 처리 중 예외"); }이 예외(
RuntimeException)는 거칠 것 없이 상위 메서드로 전파됩니다.saveOneInNewTx(예외 발생) ↓saveAll(예외 전파) ↓TransactionInterceptor(예외 감지!)문지기는 "어? 예외가 터졌네?" 하고 판단하여 Transaction-A 전체를 롤백시킵니다. 이것이
REQUIRES_NEW를 썼음에도 전체가 롤백된 진짜 이유입니다.
해결방법
스프링의 특수성으로 인해 proxy를 감싼다는 특성을 잊어 발생하게 된 상황이므로 이 상황을 해결하기 위해서는 아예 클래스를 분리하여 self-invocation을 해결하는 방법입니다.
@Service
@RequiredArgsConstructor
public class FileWorkerService {
private final FileRepository fileRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveOneInNewTx(String fileUrl) {
FileEntity entity = new FileEntity();
entity.setFileUrl(fileUrl);
fileRepository.save(entity);
if (fileUrl.equals("file3")) {
throw new RuntimeException("3번째 파일 처리 중 예외");
}
}
}위와 같이 서비스를 분리해내고 FileService.saveAll()이 FileWorkerService.saveOneInNewTx()을 각각으로 호출하면 이 시점부터는 proxy로 감싸지게 되기 때문에 @Transactional이 인식되게 됩니다. 이렇게 되면 각 객체를 저장할 때마다 트랜잭션으로 관리하게되어지니 큰 트랜잭션 안에 작은 트랜잭션을 구현한다는 것이 가능해지는 거죠.
@Slf4j
@Service
@RequiredArgsConstructor
public class FileService {
private final FileWorkerService fileWorkerService;
@Transactional
public void saveAll(List<String> fileUrls) {
log.info(">>> [saveAll] txActive={}, txName={}",
TransactionSynchronizationManager.isActualTransactionActive(),
TransactionSynchronizationManager.getCurrentTransactionName());
FileEntity main = new FileEntity();
main.setFileUrl("mainFile");
fileRepository.save(main); // file3에서 넘겨 받은 예외로 인해 롤백되어짐
for (String fileUrl : fileUrls) {
log.info(">>> [saveAll] {} 저장 시도", fileUrl);
fileWorkerService.saveOneInNewTx(fileUrl); // file3에서 RuntimeException 넘겨 받음
}
log.info(">>> [saveAll] 종료");
}
}saveOneInNewTx() 을 사용할 때 try catch 없이 사용하게 되면 saveOneInNewTx()로 인해 넘겨받은 RuntimeException으로 인해 saveAll()의 트랜잭션도 롤백으로 돌아가게 됩니다. 그러면 mainFile또한 롤백되어지게 되는 것도 자연스러워지는 것이죠. 그리고 그로인해 모든 작업이 중단되어지고 file3 이후의 file4와 같은 추가 파일들은 저장 작업이 이루어지지 않게 될 것입니다.

file1, file2는 개별 트랜잭션이 성공적으로 이뤄짐에 따라 saveAll()의 실패에도 불구하고 저장이 된 것을 확인할 수 있게 됩니다.
하지만 mainFile과 file3는 RuntimeException의 영향으로 롤백처리되어 저장이 되지 못했고 file4는 저장 시도도 없이 무산되어집니다.
@Transactional
public void saveAll(List<String> fileUrls) {
log.info(">>> [saveAll] txActive={}, txName={}",
TransactionSynchronizationManager.isActualTransactionActive(),
TransactionSynchronizationManager.getCurrentTransactionName());
FileEntity main = new FileEntity();
main.setFileUrl("mainFile");
fileRepository.save(main);
for (String fileUrl : fileUrls) {
log.info(">>> [saveAll] {} 저장 시도", fileUrl);
try {
fileWorkerService.saveOneInNewTx(fileUrl);
} catch (Exception ignored) {}
}
log.info(">>> [saveAll] 종료");
}그러나 다음과 같이 try catch로 익셉션 전달을 차단하면 saveAll()의 트랜잭션은 안전해질 것이고 mainFile과 file3 이후의 저장도 지속적으로 진행될 것입니다.

이렇게 하면 원래 개발자가 의도한 로직대로 온전히 작동하게 됩니다.
2) `@Async`와 트랜잭션의 단절: "주문은 롤백됐는데, 알림은 왜 가죠?"
이번에는 비동기 처리(@Async)를 도입할 때 흔히 겪는 문제입니다. 요구사항은 다음과 같습니다.
요구사항:
- 주문을 저장한다.
- 주문이 성공하면 이메일, 앱 푸시, 슬랙 알림을 비동기로 발송한다.
- 만약 주문 저장에 실패하면, 알림 데이터도 남지 않아야 한다.
개발자는 성능을 고려해 알림 발송을 비동기(@Async)로 처리하고, 주문이 실패하면(Rollback) 알림도 같이 취소될 것이라 기대하며 코드를 작성했습니다.
주문 엔티티
@Entity
@Table(name = "orders")
@Data
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String userEmail;
private int amount;
@Enumerated(EnumType.STRING)
private OrderStatus status;
}알림 엔티티
@Entity
@Table(name = "notification")
@Data
public class Notification {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 물리적 FK는 피하되 논리적 참조 유지
private Long orderId;
@Enumerated(EnumType.STRING)
private NotificationType type;
private String message;
}
// 알림 타입
public enum NotificationType {
EMAIL,
PUSH,
ADMIN_SLACK
}알림 비동기 서비스
@Slf4j
@Service
@RequiredArgsConstructor
public class NotificationAsyncService {
private final NotificationRepository notificationRepository;
@Async
@Transactional
public void sendNotification(Long orderId, NotificationType type) {
log.info(">>> [sendNotification] type={}, thread={}, txActive={}, txName={}",
type,
Thread.currentThread().getName(),
TransactionSynchronizationManager.isActualTransactionActive(),
TransactionSynchronizationManager.getCurrentTransactionName());
Notification noti = new Notification();
noti.setOrderId(orderId);
noti.setType(type);
noti.setMessage("주문 알림: type=" + type + ", orderId=" + orderId);
notificationRepository.save(noti);
// 예시용: ADMIN_SLACK 에서는 일부러 에러를 던져본다.
if (type == NotificationType.ADMIN_SLACK) {
log.warn(">>> [sendNotification] ADMIN_SLACK 알림에서 예외 발생");
throw new RuntimeException("슬랙 알림 전송 실패");
}
log.info(">>> [sendNotification] 알림 저장 완료 type={}, orderId={}", type, orderId);
}
}주문 서비스
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final NotificationAsyncService notificationAsyncService;
/**
* - 주문 + 알림 전체가 하나의 "논리적 트랜잭션"처럼 동작하길 기대한 상황.
*/
@Transactional
public void placeOrder(String userEmail, int amount, boolean makeFailAfter) {
log.info(">>> [placeOrder] thread={}, txActive={}, txName={}",
Thread.currentThread().getName(),
TransactionSynchronizationManager.isActualTransactionActive(),
TransactionSynchronizationManager.getCurrentTransactionName());
Order order = new Order();
order.setUserEmail(userEmail);
order.setAmount(amount);
order.setStatus(OrderStatus.CREATED);
orderRepository.save(order);
// 주문이 생성되면 알림 3개 발행 (EMAIL, PUSH, ADMIN_SLACK)
notificationAsyncService.sendNotification(order.getId(), NotificationType.EMAIL);
notificationAsyncService.sendNotification(order.getId(), NotificationType.PUSH);
notificationAsyncService.sendNotification(order.getId(), NotificationType.ADMIN_SLACK);
// 주문 마지막에 실패 케이스 시뮬레이션
if (makeFailAfter) {
log.warn(">>> [placeOrder] 결제 게이트웨이 오류로 주문 전체 롤백");
throw new RuntimeException("결제 실패로 인한 주문 롤백");
}
log.info(">>> [placeOrder] 메서드 정상 종료");
}
}[개발자의 기대] 주문 로직 마지막에 예외가 터졌으니 주문은 롤백되고, 그 안에서 호출한 알림들도 당연히 같이 롤백되어 DB에는 아무것도 남지 않아야 합니다.
하지만 테스트를 해보면 그 결과 값이 완전히 다르게 됩니다.
@Slf4j
@SpringBootTest
class OrderServiceTest {
@Autowired
OrderService orderService;
@Autowired
OrderRepository orderRepository;
@Autowired
NotificationRepository notificationRepository;
@Test
void async_알림_다중발행_트랜잭션_문제() throws Exception {
// when
try {
orderService.placeOrder("user@example.com", 10000, true);
} catch (RuntimeException e) {
// 주문 실패 의도
}
// @Async 완료 기다리기
Thread.sleep(1000L);
long orderCount = orderRepository.count();
long notiCount = notificationRepository.count();
log.info(">>> orderCount={}, notiCount={}", orderCount, notiCount);
}
}>>> orderCount=0, notiCount=2주문은 사라졌는데(0), 알림은 2개(EMAIL, PUSH)가 버젓이 저장되어 있습니다. 트랜잭션이 따로 놀고 있습니다.
왜 이런 일이 발생한 걸까?
로그를 확인해보면 원인이 명확해집니다.
>>> [placeOrder] thread=Test worker, txActive=true, txName=...
>>> [sendNotification] type=PUSH, thread=task-2, txActive=true, txName=...
>>> [sendNotification] type=EMAIL, thread=task-1, txActive=true, txName=...
>>> [sendNotification] type=ADMIN_SLACK, thread=task-3, txActive=true, txName=...주문은 Test worker라는 스레드에서 실행되지만, 알림들은 task-1, task-2라는 전혀 다른 스레드에서 실행됩니다.
앞서 "TSM(기억 단계)은 트랜잭션 정보를 ThreadLocal에 저장한다"고 했습니다. @Async는 새로운 스레드를 생성하기 때문에, Test worker 스레드가 가진 트랜잭션 정보(주문 트랜잭션)를 task-N 스레드가 공유받을 수 없습니다.
결국 알림 로직은 주문의 성공/실패 여부와 관계없이 자기들만의 독자적인 트랜잭션(TxB, TxC...)을 열고 닫아버린 것입니다.
한가지 중요한 사실이 더 있습니다:
@Async는 다른 쓰레드를 생성하여 메서드를 실행한다.
@Async가 쓰레드를 새롭게 생성하니 notification이 ThreadLocal의 범위를 벗어나게 되어버리는 것입니다.
트랜잭션 흐름을 다시 따라가 보자
클라이언트가
OrderService프록시를 통해placeOrder()를 호출client ↓ (프록시) OrderService.placeOrder() ↓ TransactionInterceptor ↓ invokeWithinTransaction(...) ↓ 트랜잭션 시작 (Thread = Test worker, TxA) ↓ (실제 객체) OrderService.placeOrder()placeOrder()안에서orderAsyncService.saveOrderHistory(orderId)호출(실제 객체) OrderService.placeOrder() ↓ (프록시) NotificationAsyncService.sendNotification() ↓ @Async 인터셉터 (@AsyncAspectSupport) ↓ TaskExecutor.submit(Runnable) ↓ 현재 쓰레드(Test worker)는 즉시 반환비동기 작업이 쓰레드풀(task-1, task-2, task-3)에서 실행
(쓰레드: task-1) ↓ (프록시) NotificationAsyncService.sendNotification() ↓ TransactionInterceptor ↓ invokeWithinTransaction(...) ↓ 새로운 트랜잭션 시작 (Thread = task-1, TxB) ↓ (실제 객체) NotificationAsyncService.sendNotification() ↓ notificationRepository.save(...)동시에 다른 알림들도 각각 task-2, task-3에서 같은 구조로 동작
전체 흐름을 시각화하면 다음과 같습니다.
클라이언트
↓
OrderService.placeOrder() — TxA 시작 (Test worker)
↓
orderRepository.save() — TxA 내부
↓
notificationAsyncService.sendNotification() 3번 호출
↓
@Async → TaskExecutor로 비동기 작업 제출
↓
OrderService.placeOrder 마지막에서 예외 발생
↓
TxA 롤백 → 주문 롤백됨
------------------------------------------
이후 별도 쓰레드들에서 실행 시작:
task-1 → TxB 시작 → EMAIL 저장 → TxB 커밋
task-2 → TxC 시작 → PUSH 저장 → TxC 커밋
task-3 → TxD 시작 → ADMIN_SLACK 저장 → 예외 → TxD 롤백
------------------------------------------
최종 DB 상태:
orders = 0
notifications = 2해결방법
해결의 핵심은 @Async를 강하게 결합된 로직(주문의 성공 여부)에 직접 사용하지 않는 것입니다. @Async는 원래 결과에 상관없이 동작해도 되는 작업에 적합한 도구이기 때문입니다.
애초에 @Async의 특성상 이러한 방식으로 사용하는 것은 좋지 않은 방식입니다. @Async는 부모 트랜잭션/도메인 결과에 종속되면 안 되는 작업에 쓰는 도구인데, 위 예제에서는 order의 성공/실패에 강하게 종속되어야 하는 알림을 @Async로 빼버렸기 때문이죠.
각 알림 트랜잭션이 독립적으로 작동해도 된다면 지금과 같이 코드를 유지해도 되겠지만, 정확히 요구조건에 따라 비동기적으로 작동하되 order에 notification이 종속되어야 하는 상황이라면 설계 자체를 바꿀 필요성이 있습니다.
다음 두가지 방법을 생각해볼 수 있다:
- Outbox 패턴
TransactionSynchronizationUtils/@TransactionalEventListener활용하기
(1) Outbox 패턴
일단 같은 트랜잭션에 '보낼 예정'이라고 적어두자."
- 주문 트랜잭션 안에서
Order테이블과Outbox(알림 대기열) 테이블에 동시에 저장합니다. - 이 과정은 동기적이므로, 주문이 롤백되면
Outbox데이터도 같이 사라집니다. (안전함) - 별도의 비동기 워커(Worker)나 스케줄러가
Outbox테이블을 읽어 실제로 알림을 보냅니다.
이 방식은 구현 비용이 들지만, 데이터 정합성을 100% 보장해야 하는 대규모 시스템에서는 필수적인 패턴입니다.
Outbox 패턴 자체는 다룰 내용이 많아서 별도 글을 작성할 예정이므로 여기서는 개념적 요소만 가볍게 짚고 넘어가겠습니다.
(2) `TransactionSynchronizationManager` 활용: "커밋된 후에만 실행해 줘"
Outbox 패턴이 너무 무겁게 느껴진다면, 스프링이 제공하는 트랜잭션 훅(Hook) 기능을 직접 사용할 수 있습니다.
스프링 트랜잭션은 단순히 열고 닫는 것 외에도, 생명주기(Lifecycle)에 맞춰 특정 코드를 실행할 수 있는 콜백을 제공합니다. 우리는 TransactionSynchronizationManager를 통해 이 콜백을 직접 등록할 수 있습니다.
- beforeCommit: 커밋 직전
- afterCommit: 커밋 성공 직후
- afterCompletion: 커밋/롤백 상관없이 종료 후
해결책 적용 주문 서비스 코드에 다음과 같이 "동기화 작업"을 등록합니다.
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order); // (1) 주문 저장 (아직 커밋 안 됨)
// (2) 트랜잭션 동기화 작업 등록
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
// (3) 이 코드는 오직 '주문 트랜잭션이 성공(Commit)'한 뒤에만 실행됨
notificationAsyncService.sendNotification(order.getId());
}
});
// (4) 만약 여기서 예외가 터져 롤백되면? -> afterCommit은 실행되지 않음!
}이렇게 하면 주문 트랜잭션이 롤백될 경우 afterCommit 메서드는 호출되지 않습니다. 자연스럽게 알림 발송도 취소되는 것이죠. Outbox처럼 별도의 테이블이나 복잡한 폴링 구조 없이도 문제를 해결할 수 있는 가볍고 빠른 방법입니다.
⚠️ 치명적인 한계점: 하지만 이 방식에는 명확한 한계가 존재합니다. 바로 '후처리 실패에 대한 보장'이 없다는 점입니다.
- 주문 트랜잭션이 정상적으로 커밋되었습니다.
afterCommit이 실행되어 알림 비동기 메서드를 호출합니다.- 그런데 갑자기 네트워크 오류로 알림 서버가 죽어버렸다면?
이미 주문 트랜잭션은 끝났기 때문에 되돌릴(Rollback) 수 없습니다. 더 무서운 것은, 알림 발송이 실패했다는 사실이 DB 어디에도 남지 않는다는 점입니다.
결국 실패 시 재시도(Retry)를 하려 해도 근거 데이터가 없습니다. 따라서 이 방법은 "실패해도 치명적이지 않은(Best Effort)" 로직에만 사용해야 하며, 결제나 정산처럼 100% 정합성이 필요한 곳에서는 Outbox 패턴을 사용하는 것이 안전합니다.
마치며: 마법에서 공학으로
이번 글에서는 스프링 트랜잭션이 왜 우리 기대와 다르게 동작하는지, 그 내부 구조를 파헤쳐 보며 제가 실제 해본 '삽질' 사례들을 분석해 보았습니다.
우리는 종종 @Transactional을 마법의 주문처럼 사용하곤 합니다. 메서드 위에 붙이기만 하면 알아서 데이터를 지켜줄 것이라 믿죠. 하지만 그 편리함 뒤에는 AOP, 프록시(Proxy), ThreadLocal이라는 정교한 톱니바퀴들이 맞물려 돌아가고 있습니다.
이 톱니바퀴 사이에 모래알(Self-Invocation, Async)이 끼어들면 트랜잭션은 예고 없이 멈춰버립니다. 이것이 우리가 "자세히는 아니더라도, 어떻게 작동하는지"를 반드시 알아야 하는 이유입니다.
트랜잭션을 지배하기 위한 4가지 체크리스트
앞으로 트랜잭션 문제가 발생한다면, 막연해하지 말고 다음 4가지를 먼저 점검해 보세요.
- 경계(Boundary): 트랜잭션이 어디서 시작해서 어디서 끝나는지 명확한가?
- 연결(Connection): 모든 로직이 같은 ThreadLocal 위에서 놀고 있는가? (
@Async주의) - 진입(Entry): 프록시라는 문지기를 통과했는가? (Self-Invocation 주의)
- 예외(Exception): 스프링이 이 에러를 '실패'로 인식하는가? (Checked Exception 주의)
이 흐름을 인지하고 있다면, 더 이상 트랜잭션 오작동은 미스터리가 아닙니다. 원인을 분석하고 논리적으로 설명할 수 있는 현상이 될 것입니다.
트랜잭션이 안전하게 끝났다면(Commit), 그다음은 무엇을 해야 할까요? 알림을 보내거나, 로그를 남기거나, 캐시를 갱신해야 할 수도 있습니다. 하지만 방금 트랜잭션이 끝났는데, 만약 알림 전송이 실패한다면?
다음 글에서는 오늘 잠깐 스쳐 지나갔던 **TransactionSynchronizationManager.registerSynchronization()**을 본격적으로 다뤄보려 합니다. 트랜잭션의 생명주기에 정교하게 훅(Hook)을 걸어, 비즈니스 로직과 후처리 로직을 우아하게 분리하는 방법에 대해 이야기해 보겠습니다.
