CareerBee 서비스에서 백엔드 개발을 맡고 있는 mumu입니다.
이번 글에서는 대회 참여 후 알림은 송신되지만 저장되지 않는 문제를 해결하며 배운 것들에 대해 다뤄보고자 합니다.
예상 독자
해당 글은 아래와 같은 독자들에게 도움이 될 것입니다.
- Spring의
@Transactional을사용했지만 내부 동작방식을 몰라요. @TransactionalEventListener를사용하며 DB CUD 연산이 적용되지 않는 문제를 겪고 있어요.
핵심 요약
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)에서DB CUD 작업이 적용되지 않는 문제 발생@Transactional내부 동작방식에 따라 AFTER_COMMIT 이후 DB 트랜잭션은 끊어져 DB에 CUD 작업이 되지않음@Trasactional(propagation= REQUIRED_NEW)를통해 새 트랜잭션을 생성해서 DB 트랜잭션 재생성해야 DB에 CUD작업이 정상 적용됨
문제 상황
CareerBee 서비스에선 대회에 참여한 후 포인트 획득에 대해 알림을 발송하는 기능이 있습니다.

하지만 어느 날부터 대회를 참여해도 알림은 발송되지만, DB에 저장되지 않는 문제가 발생하게 됐습니다.
문제 발생 시점
이 문제는 대회 참여 코드를 리팩토링 한 후부터 발생하고 있는데요.
리팩토링은 아래와 같이 이뤄졌습니다.
// Before
@RequiredArgsConstructor
@Transactional
@Service
public class CompetitionCommandServiceImpl implements CompetitionCommandService {
// 알림 전송 클래스 객체
private final NotificationEventPublisher eventPublisher;
...
@Override
public void submitCompetitionResult(
Long competitionId, CompetitionResultSubmitReq submitReq, Long accessMemberId
) {
// 기존 로직
...
// 포인트 획득 메시지 전송
eventPublisher.sendPointEarnedNotification(
new PointNotiInfo(validMember, 5, NotificationType.POINT, false)
);
}
}
// After
@RequiredArgsConstructor
@Slf4j
@Transactional
@Service
public class CompetitionCommandServiceImpl implements CompetitionCommandService {
// Spring의 EventPublisher 이용
private final ApplicationEventPublisher eventPublisher;
@Override
public CompetitionGradingResp submitCompetitionResult(
Long competitionId,
CompetitionResultSubmitReq req,
Long memberId
) {
SubmissionContext context = validateSubmission(competitionId, memberId);
GradingResult grading = gradeAnswers(competitionId, req);
persistAndNotify(context, grading, req.elapsedTime());
return new CompetitionGradingResp(grading.gradingInfos());
}
private void persistAndNotify(
SubmissionContext context, GradingResult grading, int elapsedTime
) {
context.member().plusPoint(PARTICIPATION_POINT);
competitionResultRepository.save(
CompetitionResult.of(
context.competition(),
context.member(),
grading.correctCount(),
elapsedTime
)
);
eventPublisher.publishEvent(
new PointEvent(context.member(), PARTICIPATION_POINT, NotificationType.POINT, false)
);
}
}
쉽게 말해 알림 발송 로직이 다음과 같이 변했습니다.
[Before] → 알림 발송 클래스의 메서드를 직접 호출하여 발송
[After] → Spring 내부에서 이벤트를 Publish 하는 객체인 ApplicationEventPublisher 활용
이렇게 변경한 이유는 반드시 대회 결과가 저장(commit)된 후에 알림 발송하는 로직이 실행되어야 했기 때문입니다.
이게 지켜지지 않는다면 이런 문제가 발생할 수 있기 때문입니다.
- DB에 알림 저장이 안 됨 But, SSE로 알림은 발송됨 → 알림 조회했을 때 데이터가 없음
아래 코드는 포인트 획득 이벤트에 대해, 알림을 저장하고 SSE로 알림을 발송하는 코드입니다.
@Component
@RequiredArgsConstructor
public class PointNotifier {
private final NotificationRepository notificationRepository;
private final SseService sseService;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(PointEvent pointEvent) {
// 알림을 DB에 저장
notificationRepository.save(
Notification.of(
pointEvent.member(),
String.valueOf(pointEvent.point()),
pointEvent.type(),
false
)
);
// SSE로 클라이언트에 알림 전송
sseService.sendTo(pointEvent.member().getId());
}
}
하지만, 이 코드로 변경한 후부터 다음과 같은 문제가 발생했습니다.
알림은 전송되지만, DB에는 저장이 안됨 → 따라서 조회하더라도 알림 데이터가 없음

Hibernate:
select
cp1_0.id,
cp1_0.answer,
cp1_0.solution
from
competition_problem cp1_0
where
cp1_0.competition_id=?
Hibernate:
insert
into
competition_result
(competition_id, created_at, elapsed_time, member_id, modified_at, solved_count)
values
(?, ?, ?, ?, ?, ?)
Notification 테이블에 대한 Insert 쿼리 존재하지 않음
문제의 원인을 찾아보자
왜 이런 문제가 발생했을까요?
제가 첫 번째로 의심한 상황입니다.
해당 클래스에 @Transactional 이 존재하지 않아 적용될 트랜잭션이 없고, 따라서 DB에 커밋되지 않아서 저장되지 않음
이를 위해 알림 저장 로직을 외부 클래스로 뺐고, 트랜잭션을 적용하였습니다.
@Component
@RequiredArgsConstructor
public class PointNotifier {
private final SseService sseService;
private final NotiService notiService;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(PointEvent pointEvent) {
notiService.saveNoti(
Notification.of(
pointEvent.member(),
String.valueOf(pointEvent.point()),
pointEvent.type(),
false
));
sseService.sendTo(pointEvent.member().getId());
}
}
@RequiredArgsConstructor
@Transactional
@Service
public class NotificationCommandServiceImpl implements NotificationCommandService {
private final NotificationQueryService queryService;
private final NotificationRepository notificationRepository;
@Override
public void saveNoti(PointEvent pointEvent) {
notificationRepository.save(
Notification.of(
pointEvent.member(),
String.valueOf(pointEvent.point()),
pointEvent.type(),
false
)
);
}
}
과연 될까요? 쿼리 로그를 봐봅시다.
Hibernate:
select
cp1_0.id,
cp1_0.answer,
cp1_0.solution
from
competition_problem cp1_0
where
cp1_0.competition_id=?
Hibernate:
insert
into
competition_result
(competition_id, created_at, elapsed_time, member_id, modified_at, solved_count)
values
(?, ?, ?, ?, ?, ?)
이전과 똑같습니다. Notification 테이블에 대한 Insert 쿼리는 생성되지 않고 있습니다.
두 번째로 의심한 상황입니다.
트랜잭션이 제대로 동작하지 않고 있는가?
이를 테스트하기 위해 로그를 추가해 줬습니다.
@Component
@RequiredArgsConstructor
public class PointNotifier {
private final SseService sseService;
private final NotificationCommandServiceImpl commandService;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(PointEvent pointEvent) {
log.info("PointNotifier 이벤트 리스너 실행, 트랜잭션 활성 여부: {}", TransactionSynchronizationManager.isActualTransactionActive());
commandService.saveNoti(pointEvent);
sseService.sendTo(pointEvent.member().getId());
}
}
@RequiredArgsConstructor
@Transactional
@Service
public class NotificationCommandServiceImpl implements NotificationCommandService {
private final NotificationQueryService queryService;
private final NotificationRepository notificationRepository;
@Override
public void saveNoti(PointEvent pointEvent) {
log.info("NotiService 트랜잭션 활성 여부: {}", TransactionSynchronizationManager.isActualTransactionActive());
notificationRepository.save(
Notification.of(
pointEvent.member(),
String.valueOf(pointEvent.point()),
pointEvent.type(),
false
)
);
}
}
실행된 로그는 다음과 같습니다.
- PointNotifier 이벤트 리스너 실행, 트랜잭션 활성 여부: true
- NotiService 트랜잭션 활성 여부: true
로그를 보니 트랜잭션은 활성화된 상태입니다.
하지만 제 지식으로는 트랜잭션이 활성화된 상태에선 다음과 같은 순서가 동작해야 합니다.
- 트랜잭션이 활성화된 상태에서 JPA의
save(),delete()등을 호출 시 영속성 컨텍스트에 변경사항 저장 - 트랜잭션이 커밋되는 시점에 JPA 내부적으로
EntityManager.flush()가 호출되어 변경사항을 DB에 반영 - 트랜잭션 commit 수행
이를 통해 세 번째 의심을 하게 됩니다.
트랜잭션이 잘못됐나? 내가 아는 트랜잭션이 아닌가?
이를 좀 더 알아보기 위해 @Transactional의 동작방식에 대해서 공부해 봤습니다.
@Transactional의 동작방식
1. 트랜잭션 인터셉터 (TransactionInterceptor)
트랜잭션 인터셉터는 Spring AOP의 MethodInterceptor입니다.
@Transactional 이 붙은 메서드가 프록시를 통해 호출될 때, invoke()가 실행되어 트랜잭션 경계를 관리합니다.
public class TransactionInterceptor extends TransactionAspectSupport implements MethodInterceptor, Serializable {
@Nullable
public Object invoke(MethodInvocation invocation) throws Throwable {
Class<?> targetClass = invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis()) : null;
Method var10001 = invocation.getMethod();
Objects.requireNonNull(invocation);
return this.invokeWithinTransaction(var10001, targetClass, invocation::proceed);
}
}
위의 코드는 실제 invoke() 메서드인데요. 간단하게 살펴보면 다음과 같습니다.
Class<?> targetClass = invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis()) : null;
Method var10001 = invocation.getMethod();
Objects.requireNonNull(invocation);
// requireNonNull() 구현 코드
@ForceInline
public static <T> T requireNonNull(T obj) {
if (obj == null)
throw new NullPointerException();
return obj;
}
- 프록시가 감싸고 있는 실제 타깃 객체를 가져오며, 실제 구현 클래스를 알아내게 됩니다.
- 이때,
invocation인 AOP 호출 정보객체가 존재하지 않는다면 바로NPE를발생시켜 이후 트랜잭션이 잘못 실행되는 것을 방지합니다.
return this.invokeWithinTransaction(var10001, targetClass, invocation::proceed);
- 트랜잭션의 시작부터 종료까지의 흐름을 관리하는 메서드인
invokeWithinTransaction()을호출합니다.
2. invokeWithinTransaction()
트랜잭션의 전체 흐름을 관리하는 핵심 메서드입니다.
전체 로직 중에 핵심 내부 동작 흐름을 파악해 봅시다.
1️⃣ 트랜잭션 메타데이터 조회 및 트랜잭션 매니저 선택
TransactionAttributeSource tas = this.getTransactionAttributeSource();
TransactionAttribute txAttr = tas != null ? tas.getTransactionAttribute(method, targetClass) : null;
TransactionManager tm = this.determineTransactionManager(txAttr, targetClass);
- @Transactional()의 전파 / 격리 수준 / timeout / rollback 등의 규칙을 읽어옵니다.
- 여러 정보를 기반으로 트랜잭션 매니저를 결정짓게 됩니다.
2️⃣ 트랜잭션에 참여 또는 생성
TransactionInfo txInfo = this.createTransactionIfNecessary(ptm, txAttr, joinpointIdentification);
txAttr규칙에 따라 이미 진행 중인 트랜잭션에 참여하거나 새 트랜잭션을 시작합니다.
3️⃣ 실제 메서드 호출
Object retVal;
try {
retVal = invocation.proceedWithInvocation();
} catch (Throwable var23) {
this.completeTransactionAfterThrowing(txInfo, var23);
throw var23;
} finally {
this.cleanupTransactionInfo(txInfo);
}
proceedWithInvocation()을통해 타깃 메서드를 호출합니다.- 메서드 실행 중 예외가 터지면
rollbackOn규칙에 따라 롤백됩니다. - finally에서 컨텍스트를 정리합니다.
4️⃣ 정상 종료 시 커밋
- 만약 메서드가 정상 실행 됐다면 커밋됩니다.
this.commitTransactionAfterReturning(txInfo);
return retVal;
protected void commitTransactionAfterReturning(@Nullable TransactionInfo txInfo) {
if (txInfo != null && txInfo.getTransactionStatus() != null) {
if (this.logger.isTraceEnabled()) {
this.logger.trace("Completing transaction for [" + txInfo.getJoinpointIdentification() + "]");
}
txInfo.getTransactionManager().commit(txInfo.getTransactionStatus());
}
}
- 트랜잭션이 열려있다면
commit을호출합니다.
3. commit()
commit() 메서드에서는 커밋을 시도하되, Rollback 플래그가 있다면 커밋 대신 롤백을 하게 됩니다.
예를 들어 @Transactional(rollbackFor=..)를 설정해 둔 게 있다면, 해당 규칙에 따라 롤백이 될 수 있습니다.
그리고 롤백 없이 커밋을 수행하게 된다면 processCommit()을 호출하게 됩니다.
public final void commit(TransactionStatus status) throws TransactionException {
if (status.isCompleted()) {
// 이미 끝난 트랜잭션이라면 예외 발생
throw new IllegalTransactionStateException("Transaction is already completed - do not call commit or rollback more than once per transaction");
} else {
DefaultTransactionStatus defStatus = (DefaultTransactionStatus) status;
// 로컬 롤백 전용 플래그가 있다면 롤백
if (defStatus.isLocalRollbackOnly()) {
if (defStatus.isDebug()) {
this.logger.debug("Transactional code has requested rollback");
}
this.processRollback(defStatus, false);
// 글로벌 롤백 플래그가 있다면 롤백
} else if (!this.shouldCommitOnGlobalRollbackOnly() && defStatus.isGlobalRollbackOnly()) {
if (defStatus.isDebug()) {
this.logger.debug(
"Global transaction is marked as rollback-only but transactional code requested commit"
);
}
this.processRollback(defStatus, true);
// 롤백 플래그가 없다면 정상 커밋 수행
} else {
this.processCommit(defStatus);
}
}
}
4. processCommit()
해당 메서드에서는 실제 커밋을 수행하기 직전 또는 직후의 훅(hook)과 예외 케이스를 처리하게 됩니다.
private void processCommit(DefaultTransactionStatus status) throws TransactionException {
try {
boolean beforeCompletionInvoked = false;
boolean commitListenerInvoked = false;
try {
boolean unexpectedRollback = false;
this.prepareForCommit(status);
this.triggerBeforeCommit(status);
this.triggerBeforeCompletion(status);
beforeCompletionInvoked = true;
if (status.hasSavepoint()) {
if (status.isDebug()) {
this.logger.debug("Releasing transaction savepoint");
}
unexpectedRollback = status.isGlobalRollbackOnly();
this.transactionExecutionListeners.forEach(listener -> listener.beforeCommit(status));
commitListenerInvoked = true;
status.releaseHeldSavepoint();
} else if (status.isNewTransaction()) {
if (status.isDebug()) {
this.logger.debug("Initiating transaction commit");
}
unexpectedRollback = status.isGlobalRollbackOnly();
this.transactionExecutionListeners.forEach(listener -> listener.beforeCommit(status));
commitListenerInvoked = true;
this.doCommit(status);
} else if (this.isFailEarlyOnGlobalRollbackOnly()) {
unexpectedRollback = status.isGlobalRollbackOnly();
}
if (unexpectedRollback) {
throw new UnexpectedRollbackException(
"Transaction silently rolled back because it has been marked as rollback-only"
);
}
} catch (UnexpectedRollbackException ex) {
this.triggerAfterCompletion(status, 1);
this.transactionExecutionListeners.forEach(
listener -> listener.afterRollback(status, null)
);
throw ex;
} catch (TransactionException ex) {
if (this.isRollbackOnCommitFailure()) {
this.doRollbackOnCommitException(status, ex);
} else {
this.triggerAfterCompletion(status, 2);
if (commitListenerInvoked) {
this.transactionExecutionListeners.forEach(
listener -> listener.afterCommit(status, ex)
);
}
}
throw ex;
} catch (Error | RuntimeException ex) {
if (!beforeCompletionInvoked) {
this.triggerBeforeCompletion(status);
}
this.doRollbackOnCommitException(status, ex);
throw ex;
}
try {
this.triggerAfterCommit(status);
} finally {
this.triggerAfterCompletion(status, 0);
if (commitListenerInvoked) {
this.transactionExecutionListeners.forEach(
listener -> listener.afterCommit(status, null)
);
}
}
} finally {
this.cleanupAfterCompletion(status);
}
}
핵심 흐름은 아래와 같습니다.
1️⃣ 사전 단계
해당 단계에서는 커밋 전에 해야 할 작업들을 진행합니다.
this.prepareForCommit(status);
this.triggerBeforeCommit(status);
this.triggerBeforeCompletion(status);
beforeCompletionInvoked = true;
2️⃣ 커밋 분기
- 세이브 포인트가 있는 경우
- 신규 트랜잭션인 경우
- 기존 트랜잭션에 참여만 한 경우
위의 세 경우에 맞게 처리를 하게 되며, 예외처리 또한 진행됩니다.
if (status.hasSavepoint()) {
// 이미 존재하는 트랜잭션 내에서 세이브포인트가 설정된 경우
if (status.isDebug()) {
this.logger.debug("Releasing transaction savepoint");
}
unexpectedRollback = status.isGlobalRollbackOnly();
this.transactionExecutionListeners.forEach(
listener -> listener.beforeCommit(status)
);
commitListenerInvoked = true;
// 세이브포인트 해제 (해당 시점까지의 변경 사항을 커밋)
status.releaseHeldSavepoint();
} else if (status.isNewTransaction()) {
// 새로운 트랜잭션을 시작한 경우 (루트 트랜잭션)
if (status.isDebug()) {
this.logger.debug("Initiating transaction commit");
}
unexpectedRollback = status.isGlobalRollbackOnly();
this.transactionExecutionListeners.forEach(
listener -> listener.beforeCommit(status)
);
commitListenerInvoked = true;
// 실제 커밋 실행
this.doCommit(status);
} else if (this.isFailEarlyOnGlobalRollbackOnly()) {
// 기존 트랜잭션에 참여한 상태에서 "rollback-only" 플래그가 있는 경우
unexpectedRollback = status.isGlobalRollbackOnly();
}
// 롤백 전용으로 마킹된 경우 예외 발생
if (unexpectedRollback) {
throw new UnexpectedRollbackException(
"Transaction silently rolled back because it has been marked as rollback-only"
);
}
3️⃣ 예외 처리
커밋 분기 시에 발생한 예외에 대한 처리를 하게 됩니다.
4️⃣ 성공 처리
- 커밋이 정상적으로 끝나면
triggerAfterCommit()을 호출합니다. - 커밋 도중 예외가 발생하더라도 finally에서
triggerAfterCompletion()은 무조건 실행됩니다.- 이는 완료 콜백과 리스너 통지는 꼭 해야 하기 때문입니다.
- 마지막 finally에서
cleanupAfterCompletion()로 ThreadLocal/동기화 상태를 정리합니다.
{
try {
this.triggerAfterCommit(status);
} finally {
// 0은 COMMITTED상태 (성공적 커밋)
this.triggerAfterCompletion(status, 0);
if (commitListenerInvoked) {
this.transactionExecutionListeners.forEach((listener) -> {
listener.afterCommit(status, (Throwable)null);
});
}
}
} finally {
this.cleanupAfterCompletion(status);
}
5. 트랜잭션 커밋 또는 롤백 이후 로직 실행
TransactionalEventListener는 트랜잭션의 특정 단계에서 이벤트를 처리하기 위한 리스너이며,
동작은 TransactionSynchronizationUtils 클래스에서 관리됩니다.
TransactionalEventListener는 내부적으로 TransactionSynchronization을 구현하고 있어서 트랜잭션의 커밋이나 롤백 이후 원하는 로직을 실행할 수 있습니다.
public abstract class TransactionSynchronizationUtils {
public static void triggerAfterCommit() {
invokeAfterCommit(TransactionSynchronizationManager.getSynchronizations());
}
// 트랜잭션 커밋 후 동기화 처리
public static void invokeAfterCommit(@Nullable List<TransactionSynchronization> synchronizations) {
if (synchronizations != null) {
Iterator var1 = synchronizations.iterator();
while(var1.hasNext()) {
TransactionSynchronization synchronization = (TransactionSynchronization)var1.next();
synchronization.afterCommit();
}
}
}
public static void triggerAfterCompletion(int completionStatus) {
List<TransactionSynchronization> synchronizations = TransactionSynchronizationManager.getSynchronizations();
invokeAfterCompletion(synchronizations, completionStatus);
}
// 트랜잭션 완료 후 처리
public static void invokeAfterCompletion(@Nullable List<TransactionSynchronization> synchronizations, int completionStatus) {
if (synchronizations != null) {
Iterator var2 = synchronizations.iterator();
while(var2.hasNext()) {
TransactionSynchronization synchronization = (TransactionSynchronization)var2.next();
try {
synchronization.afterCompletion(completionStatus);
} catch (Throwable var5) {
logger.error("TransactionSynchronization.afterCompletion threw exception", var5);
}
}
}
}
}
triggerAfterCommit()- 트랜잭션이 커밋된 후에 호출되며, 등록된 모든 TransactionSynchronization의
afterCommit()메서드를 실행합니다.
- 트랜잭션이 커밋된 후에 호출되며, 등록된 모든 TransactionSynchronization의
triggerAfterCompletion()- 트랜잭션이 완료된 후에 호출되어,
afterCompletion()메서드를 실행합니다.
- 트랜잭션이 완료된 후에 호출되어,
6. 트랜잭션과 관련된 모든 정보 정리
트랜잭션의 상태와 정보는 TransactionSynchronizationManager에 존재하며 트랜잭션 마무리 후 clear() 메서드를 통해 모든 정보가 정리됩니다.
public abstract class TransactionSynchronizationManager {
private static final ThreadLocal<Map<Object, Object>> resources = new NamedThreadLocal("Transactional resources");
private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations = new NamedThreadLocal("Transaction synchronizations");
private static final ThreadLocal<String> currentTransactionName = new NamedThreadLocal("Current transaction name");
private static final ThreadLocal<Boolean> currentTransactionReadOnly = new NamedThreadLocal("Current transaction read-only status");
private static final ThreadLocal<Integer> currentTransactionIsolationLevel = new NamedThreadLocal("Current transaction isolation level");
private static final ThreadLocal<Boolean> actualTransactionActive = new NamedThreadLocal("Actual transaction active");
public static void clear() {
synchronizations.remove();
currentTransactionName.remove();
currentTransactionReadOnly.remove();
currentTransactionIsolationLevel.remove();
actualTransactionActive.remove();
}
}
드디어 @Transactional의 핵심 내부동작 방식을 마무리짓게 되었는데요.
전체 흐름을 정리하면 다음과 같습니다.
- 트랜잭션 인터셉터가
@Transactional이 붙은 메서드를 가로채서 트랜잭션을 시작합니다. - 타겟 메서드가 실행되고, 정상 완료된다면 트랜잭션 매니저의
commit()을호출합니다. - commit() 메서드 내에서
processCommit()메서드를 통해 트랜잭션 커밋 절차를 수행합니다. - 트랜잭션이 커밋되면 TransactionSynchronizationUtils.
triggerAfterCommit()이 호출되어, TransactionalEventListener의afterCommit()메서드가 실행됩니다. - 트랜잭션이 종료되면 TransactionSynchronizationManager.
clear()메서드를 통해 트랜잭션 상태 정보가 초기화됩니다.
문제 해결의 마무리
위의 정보들을 바탕으로 문제의 원인을 파악해 봅시다.
그리고 이를 위해 processCommit() 메서드의 핵심 로직을 다시 한번 살펴보도록 하겠습니다.
private void processCommit(DefaultTransactionStatus status) throws TransactionException {
try {
boolean beforeCompletionInvoked = false;
boolean commitListenerInvoked = false;
try {
// 1. 커밋 전 준비
this.prepareForCommit(status);
// 2. 커밋 전 처리
this.triggerBeforeCommit(status);
// -> 모든 synchronization 에 대해 beforeCommit 호출
// 3. 완료 전 처리
this.triggerBeforeCompletion(status);
// -> 모든 synchronization 에 대해 beforeCompletion 호출
beforeCompletionInvoked = true;
// 4. 실제 커밋 수행
if (status.hasSavepoint()) {
// 세이브 포인트 처리
status.releaseHeldSavepoint();
} else if (status.isNewTransaction()) {
// 새 트랜잭션이면 커밋 수행
this.doCommit(status);
}
} catch (UnexpectedRollbackException ex) {
// 예외 처리 ...
}
try {
// 5. 커밋 후 처리
this.triggerAfterCommit(status);
// -> 모든 synchronization 에 대해 afterCommit 호출
// -> @TransactionalEventListener(AFTER_COMMIT) 실행
} finally {
// 6. 완료 처리
this.triggerAfterCompletion(status, 0);
// -> 모든 synchronization 에 대해 afterCompletion 호출
if (commitListenerInvoked) {
this.transactionExecutionListeners.forEach(
listener -> listener.afterCommit(status, null)
);
}
}
} finally {
// 7. 트랜잭션 마무리 후 정리 작업
this.cleanupAfterCompletion(status);
// -> 모든 트랜잭션 리소스 정리
}
}
5번 로직에서@TransactionalEventListener(phase = TransactionPhase.*AFTER_COMMIT*)가 실행되는데요.
이 시점에 DB 트랜잭션은 종료됐지만, 모든 트랜잭션 리소스가 정리된 상태는 아닙니다.
DB 트랜잭션이 종료됐다는 건 JpaTransactionManager의 doCommit() 메서드를 통해 확인할 수 있습니다.
- 커리어비에서는 JPA를 사용하고 있기 때문에 TransactionManager는 자동으로 JpaTransactionManager로 설정됩니다.
protected void doCommit(DefaultTransactionStatus status) {
JpaTransactionObject txObject = (JpaTransactionObject)status.getTransaction();
if (status.isDebug()) {
this.logger.debug("Committing JPA transaction on EntityManager [" + String.valueOf(txObject.getEntityManagerHolder().getEntityManager()) + "]");
}
try {
EntityTransaction tx = txObject.getEntityManagerHolder().getEntityManager().getTransaction();
tx.commit();
} catch (RollbackException var6) {
Throwable var5 = var6.getCause();
if (var5 instanceof RuntimeException runtimeException) {
DataAccessException dae = this.getJpaDialect().translateExceptionIfPossible(runtimeException);
if (dae != null) {
throw dae;
}
}
throw new TransactionSystemException("Could not commit JPA transaction", var6);
} catch (RuntimeException var7) {
throw DataAccessUtils.translateIfNecessary(var7, this.getJpaDialect());
}
}
try 문 안에 tx.commit() 이 존재하는 것을 볼 수 있는데요, 이 메서드가 실행되면 EntityTransaction.commit()을 호출하여 실제 DB 트랜잭션을 커밋하게 됩니다.
그리고 이 이후 DB 트랜잭션은 더 이상 존재하지 않게 됩니다.
이 점이 현재 문제의 핵심 원인이었습니다.
- DB 트랜잭션이 존재하지 않음
- 따라서, CUD 작업이 수행되지 않음
- AFTER_COMMIT 시점에 트랜잭션 리소스 정리가 마무리되지 않음
- 따라서, 로그상으론 트랜잭션이 활성화 됐다고 나옴
이 문제는 새로운 DB 트랜잭션을 열어주면 해결할 수 있습니다.
따라서 saveNoti()에 REQUIRES_NEW 전파 속성을 부여해 줍시다.
@RequiredArgsConstructor
@Transactional
@Service
public class NotificationCommandServiceImpl implements NotificationCommandService {
private final NotificationQueryService queryService;
private final NotificationRepository notificationRepository;
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveNoti(PointEvent pointEvent) {
notificationRepository.save(
Notification.of(
pointEvent.member(),
String.valueOf(pointEvent.point()),
pointEvent.type(),
false
)
);
}
}
다시 실행해 보면 아래와 같이, 알림에 대한 insert 문이 잘 생성된 걸 볼 수 있습니다!!
Hibernate:
insert
into
notification
(content, created_at, is_read, member_id, modified_at, type)
values
(?, ?, ?, ?, ?, ?)
회고
우연찮게 @Transactional의 내부 동작방식까지 깊게 공부한 뜻깊은 트러블슈팅입니다.
특히, 필수적으로 사용되는 @Transactional의 내부동작을 깊게 배우다 보니, Spring AOP가 얼마나 개발자들에게 편리함을 가져다주고 있는지를 느낄 수 있었습니다.
기술적 성장 외에도 이런 개발자의 사소한 실수가 서비스 운영에 큰 손해를 끼칠 수도 있겠구나라고 생각이 들었습니다.
'스프링부트 > 트러블 슈팅' 카테고리의 다른 글
| MySQL의 공간인덱스를 적용해보자! (3) | 2025.08.14 |
|---|---|
| LIKE 쿼리에서 "_" 검색이 안되는 이유 (0) | 2025.05.26 |
| Serializing PageImpl instances as-is is not supported (0) | 2024.08.13 |
| Error creating bean with name 'securityConfig' defined in file (2) | 2024.03.18 |
| [Docker] Springboot 3.X 와 Redis 그리고 docker... (2) | 2024.02.18 |