트랜잭션의 마지막 퍼즐
이전 글에서 우리는 트랜잭션이 시작되고(Proxy), 전파되고(Propagation), 실패하는(Rollback) 과정에 대해 상세히 알아보았습니다. 하지만 트랜잭션의 흐름을 이야기할 때 우리가 간단하게 넘겨짚었던 부분이 하나 있습니다.
바로 '커밋(Commit)'과 '롤백(Rollback)' 그 자체입니다.
우리는 흔히 PlatformTransactionManager가 커밋을 호출하면 "DB에 저장되고 끝!"이라고 생각합니다. 하지만 실무에서는 그 "끝나는 순간"에 해야 할 일들이 너무나 많습니다.
- "결제가 커밋된 직후에 알림을 보내고 싶어요."
- "롤백이 발생하면 캐시도 같이 지워야 해요."
단순히 commit()/rollback() 메서드 하나만으로는 이런 정교한 요구사항을 처리하기 어렵습니다. 그래서 스프링은 **TransactionSynchronizationManager**라는 보관소에 콜백(Callback)을 등록하여, 트랜잭션의 시작과 끝 사이에 개발자가 개입할 수 있는 틈을 만들어 두었습니다.
이번 글에서는 스프링이 숨겨놓은 이 '트랜잭션 훅(Hook)'을 이용해, 커밋과 롤백 사이의 빈틈을 우아하게 공략하는 방법을 알아보겠습니다.
commit() / rollback()의 작동 흐름
우선 commit()과 rollback()이 내부적으로 어떻게 동작하는지 알아야 합니다. AbstractPlatformTransactionManager의 코드를 뜯어보면, 커밋은 단순한 '순간'이 아니라 정교하게 짜인 '과정(Process)'임을 알 수 있습니다.
실제 스프링 코드를 흐름 위주로 요약하면 다음과 같습니다.
1) Commit 프로세스
커밋은 "준비 -> 알림 -> 실행 -> 완료"의 4단계를 거칩니다.
@Override
public final void commit(TransactionStatus status) throws TransactionException {
// ... 상태 분기 처리 생략
processCommit(defStatus);
}
private void processCommit(DefaultTransactionStatus status) throws TransactionException {
prepareForCommit(status);
triggerBeforeCommit(status); // (1) 커밋 직전
triggerBeforeCompletion(status); // (2) 완료(커밋/롤백) 직전
try {
doCommit(status); // (3) 실제 물리적 커밋
}
catch (...) {
// 예외 처리
}
triggerAfterCommit(status); // (4) 커밋 직후
triggerAfterCompletion(status, TransactionSynchronization.STATUS_COMMITTED); // (5) 완료 직후
}[커밋 경로]
beforeCommit→beforeCompletion→doCommit→afterCommit→afterCompletion
2) Rollback 프로세스
롤백 역시 단순히 취소하고 끝나는 것이 아닙니다. 리소스를 정리하고 상태를 알리는 과정이 포함됩니다.
@Override
public final void rollback(TransactionStatus status) throws TransactionException {
processRollback((DefaultTransactionStatus) status, false);
}
private void processRollback(DefaultTransactionStatus status, boolean unexpected) {
triggerBeforeCompletion(status); // (1) 완료(커밋/롤백) 직전
try {
doRollback(status); // (2) 실제 물리적 롤백 (DB 취소)
}
catch (...) { /* 예외 처리 */ }
// (3) 완료 직후 (상태값: STATUS_ROLLED_BACK)
triggerAfterCompletion(status, TransactionSynchronization.STATUS_ROLLED_BACK);
}[롤백 경로]
beforeCompletion→doRollback→afterCompletion
핵심은 `TransactionSynchronizationManager` (TSM)
위 코드에서 triggerXXX() 메서드가 바로 우리가 코드를 끼워 넣을 수 있는 훅(Hook) 지점입니다.
스프링은 이 지점마다 TransactionSynchronizationManager가 들고 있는 콜백 리스트를 확인합니다. 즉, 우리가 TSM 리스트에 작업을 등록해 두기만 하면, 커밋이나 롤백이 일어나는 정확한 타이밍에 나만의 커스텀 액션을 실행시킬 수 있습니다.
커밋과 롤백 흐름을 다시 간단히 요약하자면 다음과 같습니다.
AbstractPlatformTransactionManager→ “지금은 어떤 훅을 실행해야 하는지” 타이밍을 결정합니다.- TSM(
TransactionSynchronizationManager) → “그 타이밍에 어떤 콜백 객체들을 호출해야 하는지” 리스트를 가지고 있습니다. TransactionSynchronizationUtils→ 그 리스트를 순회하면서 각 콜백의 메서드를 실제로 호출합니다.
지금까지 전반적인 커밋/롤백의 작동 흐름을 살펴보았습니다. 그리고 TSM이 각 순서에 해야 할 일들을 저장해놓고 있다는 사실 또한 알게 되었습니다.
그렇다면 결론은 명확합니다. 사용자가 원하는 커스텀 액션을 TSM에 넣을 수만 있다면, AbstractPlatformTransactionManager가 정해진 순서에 따라 우리의 코드도 실행시켜 줄 것입니다.
Commit/Rollback에 커스텀 액션 등록하기
앞서 보았듯 TSM(TransactionSynchronizationManager)는 트랜잭션이 커밋되거나 롤백될 때 각 타이밍별로 어떤 콜백을 호출해야 하는지 리스트로 보관하고 있습니다.
즉, 개발자가 원하는 커스텀 액션을 이 리스트에 넣을 수만 있다면, 스프링의 거대한 커밋/롤백 파이프라인에 나의 로직도 자연스럽게 참여시킬 수 있다는 뜻입니다.
TSM은 이를 위해 registerSynchronization() 메서드를 제공합니다.
1) registerSynchronization()
private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations =
new NamedThreadLocal<>("Transaction synchronizations");
public static void registerSynchronization(TransactionSynchronization synchronization)
throws IllegalStateException {
Assert.notNull(synchronization, "TransactionSynchronization must not be null");
// (1) 현재 스레드의 동기화 작업 리스트를 가져온다.
Set<TransactionSynchronization> synchs = synchronizations.get();
// (2) 트랜잭션이 활성화되지 않았다면 예외 발생
if (synchs == null) {
throw new IllegalStateException("Transaction synchronization is not active");
}
// (3) 리스트에 내 작업 추가
synchs.add(synchronization);
}구조는 매우 직관적입니다. TransactionSynchronization 객체를 인자로 받아, Set 자료구조에 추가합니다.
여기서 가장 중요한 포인트는 synchronizations.get()이 가져오는 대상이 바로 ThreadLocal이라는 점입니다.
매번 강조하지만, 스프링 트랜잭션이 마법처럼 동작할 수 있는 이유는 모든 정보가 ThreadLocal에 묶여 있기 때문입니다. 커넥션(Connection), 트랜잭션 상태(Status), 그리고 지금 다루는 후처리 콜백 목록(Synchronizations)까지. 이 모든 것이 스레드 단위로 관리되기에 우리는 파라미터 지옥에서 벗어날 수 있습니다.
2) `TransactionSynchronization` 인터페이스
TSM에 등록할 수 있는 객체, TransactionSynchronization은 다음과 같은 계약을 가집니다.
public interface TransactionSynchronization extends Ordered, Flushable {
// 1. 커밋 직전 (아직 롤백 가능)
default void beforeCommit(boolean readOnly) {
}
// 2. 커밋/롤백 상관없이 종료 직전
default void beforeCompletion() {
}
// 3. 커밋 성공 직후 (롤백 불가능)
default void afterCommit() {
}
// 4. 커밋/롤백 상관없이 종료 직후 (리소스 정리용)
default void afterCompletion(int status) {
}
}보시다시피 afterRollback이라는 메서드는 따로 없습니다. 대신 afterCompletion(int status) 메서드에서 status 값을 확인하여 롤백일 때만 동작하도록 구현해야 합니다.
이 인터페이스는 “트랜잭션 생명주기 동안 언제 어떤 콜백을 실행할지”를 정의합니다. 사용자는 필요한 메서드만 오버라이드하여 구현하면 됩니다.
각 메서드의 핵심 역할은 다음과 같습니다.
beforeCommit(boolean readOnly)- 트랜잭션이 커밋될 가능성이 남아 있을 때 호출됩니다.
- 주로 JPA/Hibernate flush 등, “커밋이 일어날 수 있을 때만 의미 있는 작업”을 넣습니다.
- 중요: 여기서 예외를 던지면 커밋이 취소되고 롤백됩니다. (마지막 유효성 검증)
beforeCompletion()- 커밋이든 롤백이든 상관없이, 트랜잭션이 끝나기 직전에 항상 호출됩니다.
- 리소스 정리, ThreadLocal 클린업 같은 공통 작업에 사용합니다.
- 여기서 던진 예외는 로그만 남고 무시(Swallow)됩니다.
afterCommit()- 트랜잭션이 성공적으로 커밋된 이후에만 호출됩니다.
- "저장이 확실하다"는 전제하에 실행해야 하는 작업(알림 발송, 이벤트 발행)에 적합합니다.
afterCompletion(int status)- 트랜잭션이 완전히 끝난 뒤, 마지막에 한 번 호출됩니다.
status파라미터(STATUS_COMMITTED,STATUS_ROLLED_BACK)를 통해 결과에 따른 후처리가 가능합니다.
3) 실전 구현: 커스텀 빌더(builder) 만들기
TransactionSynchronization 인터페이스를 매번 익명 클래스로 구현하는 것은 코드를 지저분하게 만듭니다. 실무에서는 빌더 패턴을 활용해 유틸리티 클래스로 만들어두면 훨씬 직관적으로 사용할 수 있습니다.
제가 실제로 사용하는 코드를 공유합니다.
@Slf4j
@RequiredArgsConstructor
public class CustomTransactionSynchronization implements TransactionSynchronization {
@FunctionalInterface
public interface BeforeCommitCallback {
void run(boolean readOnly);
}
@FunctionalInterface
public interface AfterCompletionCallback {
void run(int status);
}
private final String name;
private final int order;
private final List<BeforeCommitCallback> beforeCommitCallbacks;
private final List<Runnable> beforeCompletionCallbacks;
private final List<Runnable> afterCommitCallbacks;
private final List<AfterCompletionCallback> afterCompletionCallbacks;
@Override
public int getOrder() {
return this.order;
}
@Override
public void beforeCommit(boolean readOnly) {
for (BeforeCommitCallback callback : beforeCommitCallbacks) {
try {
callback.run(readOnly);
} catch (RuntimeException ex) {
log.error("[{}] beforeCommit callback 실패", name, ex);
throw ex;
}
}
}
@Override
public void beforeCompletion() {
for (Runnable callback : beforeCompletionCallbacks) {
try {
callback.run();
} catch (RuntimeException ex) {
log.warn("[{}] beforeCompletion callback에서 예외 발생", name, ex);
}
}
}
@Override
public void afterCommit() {
for (Runnable callback : afterCommitCallbacks) {
try {
callback.run();
} catch (RuntimeException ex) {
log.error("[{}] afterCommit callback 실패", name, ex);
throw ex;
}
}
}
@Override
public void afterCompletion(int status) {
for (AfterCompletionCallback callback : afterCompletionCallbacks) {
try {
callback.run(status);
} catch (RuntimeException ex) {
log.warn("[{}] afterCompletion callback에서 예외 발생. status={}",
name, status, ex);
}
}
}
public static Builder builder(String name) {
return new Builder(name);
}
public static class Builder {
private final String name;
private int order = Ordered.LOWEST_PRECEDENCE;
private final List<BeforeCommitCallback> beforeCommitCallbacks = new ArrayList<>();
private final List<Runnable> beforeCompletionCallbacks = new ArrayList<>();
private final List<Runnable> afterCommitCallbacks = new ArrayList<>();
private final List<AfterCompletionCallback> afterCompletionCallbacks = new ArrayList<>();
private Builder(String name) {
this.name = name;
}
/** beforeCommit(boolean readOnly) 단계에 콜백 추가 */
public Builder beforeCommit(BeforeCommitCallback callback) {
this.beforeCommitCallbacks.add(Objects.requireNonNull(callback));
return this;
}
/** beforeCommit 편의 메서드 (readOnly 무시) */
public Builder beforeCommit(Runnable runnable) {
Objects.requireNonNull(runnable);
return beforeCommit(readOnly -> runnable.run());
}
/** beforeCompletion 단계에 콜백 추가 */
public Builder beforeCompletion(Runnable callback) {
this.beforeCompletionCallbacks.add(Objects.requireNonNull(callback));
return this;
}
/** afterCommit 단계에 콜백 추가 */
public Builder afterCommit(Runnable callback) {
this.afterCommitCallbacks.add(Objects.requireNonNull(callback));
return this;
}
/** afterCompletion 단계에 콜백 추가 */
public Builder afterCompletion(AfterCompletionCallback callback) {
this.afterCompletionCallbacks.add(Objects.requireNonNull(callback));
return this;
}
/** afterCompletion 편의 메서드 (IntConsumer 활용) */
public Builder afterCompletion(IntConsumer consumer) {
Objects.requireNonNull(consumer);
return afterCompletion((AfterCompletionCallback) consumer::accept);
}
/** 롤백일 때만 실행되는 afterCompletion 편의 메서드 */
public Builder afterRollback(Runnable callback) {
Objects.requireNonNull(callback);
return afterCompletion((AfterCompletionCallback) status -> {
if (status == STATUS_ROLLED_BACK) {
callback.run();
}
});
}
/** 결과와 상관없이 항상 실행되는 afterCompletion 편의 메서드 */
public Builder afterAlways(Runnable callback) {
Objects.requireNonNull(callback);
return afterCompletion((AfterCompletionCallback) status -> callback.run());
}
public Builder order(int order) {
this.order = order;
return this;
}
public CustomTransactionSynchronization build() {
return new CustomTransactionSynchronization(
this.name,
this.order,
new ArrayList<>(beforeCommitCallbacks),
new ArrayList<>(beforeCompletionCallbacks),
new ArrayList<>(afterCommitCallbacks),
new ArrayList<>(afterCompletionCallbacks)
);
}
}
}적용: 비즈니스 로직에 훅 끼워 넣기
이제 이 빌더를 사용하면 비즈니스 로직 안에서 아주 직관적으로 후처리 로직을 선언할 수 있습니다. registerSynchronization에 빌더로 만든 객체를 넘기기만 하면 됩니다.
@Transactional
public void createPost() {
log.info("1. 비즈니스 로직 실행 중...");
TransactionSynchronizationManager.registerSynchronization(
CustomTransactionSynchronization.builder("post-create")
// 1. 커밋 전 검증 (실패 시 롤백됨)
.beforeCommit(readOnly -> log.info("2. [검증] 커밋 직전 최종 확인 (readOnly={})", readOnly))
// 2. 커밋 성공 시 알림 발송
.afterCommit(() -> log.info("3. [알림] 게시글 생성 완료 알림 발송"))
// 3. 롤백 시 보정 작업
.afterRollback(() -> log.warn("4. [롤백] 게시글 생성 실패 보정 작업"))
// 4. 종료 로그
.afterAlways(() -> log.info("5. [종료] 트랜잭션 종료"))
.build()
);
log.info("비즈니스 로직 종료 (아직 커밋 안 됨)");
}[실행 결과]
1. 비즈니스 로직 실행 중...
비즈니스 로직 종료 (아직 커밋 안 됨)
2. [검증] 커밋 직전 최종 확인 (readOnly=false)
3. [알림] 게시글 생성 완료 알림 발송
5. [종료] 트랜잭션 종료4) 사용 시 유의점
이 기술은 강력하지만, 트랜잭션의 흐름에 직접 개입하는 만큼 주의해야 할 점이 많습니다.
① 반드시 트랜잭션 안에서 호출할 것
TransactionSynchronizationManager는 내부적으로 ThreadLocal에 의존합니다. 트랜잭션이 활성화되지 않은 상태에서 registerSynchronization()을 호출하면 예외가 발생합니다.
Check: 반드시
@Transactional경계 안, 그리고 트랜잭션이 시작된 스레드 안에서만 호출해야 합니다.
② 콜백 안에 '핵심 비즈니스 로직'은 피할 것
도메인의 상태를 바꾸거나 중요한 결정을 내리는 로직을 콜백에 숨겨두면 관리가 어려워집니다.
afterCommit은 이미 커밋이 끝난 뒤입니다. 여기서 예외가 나도 데이터를 되돌릴 수 없습니다.- 콜백 안에서 일어난 일은 별도의 영속 상태로 남지 않습니다.
- 성공/실패 추적이 중요한 로직이라면 콜백보다는 큐, 아웃박스 패턴 등을 사용하는 것이 안전합니다.
③ 훅(Hook) 별로 예외 처리 방식이 다르다
각 단계에서 던지는 예외가 트랜잭션에 미치는 영향이 다릅니다.
beforeCommit: 예외 발생 시 전파되어 롤백됩니다. (유효성 검증용)afterCommit: 예외 발생 시 전파는 되지만, 이미 커밋된 데이터는 롤백되지 않습니다. (후처리용)before/afterCompletion: 예외를 던져도 로그만 남고 무시(Swallow)됩니다.
④ 트랜잭션 전파(Propagation)를 고려하라
registerSynchronization은 "현재 스레드의 현재 트랜잭션"에 콜백을 붙입니다. 코드상으로는 지금 등록했지만, 실제 실행 시점은 해당 트랜잭션이 완전히 끝날 때입니다. 트랜잭션이 다른 곳으로 전파되거나 중첩되어 있다면, 콜백의 실행 시점도 그에 맞춰 달라질 수 있음을 인지해야 합니다.
마치며
지금까지 트랜잭션의 커밋과 롤백이 어떤 순서로 흘러가는지, 그리고 그 과정에 사용자 정의 콜백을 어떻게 끼워 넣을 수 있는지 정리해 보았습니다.
이전 글들까지 포함하여 트랜잭션 시리즈를 준비하면서, 저 역시 평소 익숙하게 사용하던 트랜잭션의 내부 동작을 다시 뜯어보고 몰랐던 부분을 하나씩 확인해가는 과정 자체가 꽤 의미 있었습니다.
트랜잭션은 기본 흐름을 방해하지 않는 것이 가장 좋습니다. 하지만 불가피하게 그 흐름에 개입해야 한다면, 언제 어디서 무엇이 실행되는지를 정확히 이해하고 들어가야 합니다.
스프링 트랜잭션을 사용하는 개발자는 많습니다. 그러나 트랜잭션을 제대로 이해하는 개발자는 많지 않습니다. 이 글이 그 차이를 만드는 데 도움이 되었기를 바랍니다.
