안녕하세요. 매일 성장하는 개발자 stark입니다!
저는 실무에서 스프링 내부 이벤트를 발행할 때 @TransactionalEventListener(AFTER_COMMIT)를 정말 많이 사용합니다. 근데 after_commit 내부에서 트랜잭션을 사용해서 업데이트 처리를 해야 하는데 제대로 동작하지 않는 문제가 발생했습니다.
분명 트랜잭션도 제대로 걸어줬는데 왜 업데이트가 안되는지 이 상황이 도저히 이해가 되지 않았고 저는 이해되지 않은 채로 넘어가는 것이 싫어서 정말 오랜 시간 동안 그 이유를 찾아 헤매었습니다. 조금 오래 걸렸지만 결국은 답을 얻었고 그 과정을 정리해 봤습니다.
문제 상황 이해하기
게시글 저장 API를 호출하면 게시글을 저장하고, 그 과정에서 이벤트를 발행합니다. 이 이벤트는 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)로 설정된 리스너에 의해 처리됩니다. 이벤트 리스너에서는 게시글의 내용을 약간 수정해서 업데이트하는 메서드를 호출합니다. 메서드는 잘 동작하지만 실제 데이터베이스에는 변경 사항이 반영되지 않는 문제가 발생했습니다.
문제상황은 다음과 같습니다.
- 스프링 AFTER_COMMIT 이벤트 리스너 실행: ✅
- 리스너 내부에서 @Transactional이 적힌 서비스 메서드 호출: ✅
- 실제 DB 업데이트: ❌
문제가 발생하는 코드를 살펴봅시다.
코드는 mvc 패턴으로 작성해 두었습니다. 제가 로그를 너무 많이 적어놔서 코드가 복잡하게 생각되실 수 있습니다. 그래서 간단하게 설명드리겠습니다. 서비스 클래스에는 2개의 메서드가 있습니다. 먼저 게시글을 저장하고 spring event를 발행하는 메서드가 있습니다. 그리고 이벤트 리스너에서 호출하는 게시글 내용을 변경하고 업데이트하는 메서드가 있습니다.
만약 서비스 로직이 잘 실행되어서 게시글이 저장되면 이벤트가 정상적으로 발행될 것입니다. 그럼 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)로 설정된 이벤트 리스너가 동작하게 됩니다. 이 리스너는 phase가 after_commit으로 설정되어 있기 때문에 리스너 자체에는 트랜잭션이 없습니다. 그러나 리스너 내부에서 @Transactional을 가진 서비스 메서드를 호출하기에 서비스에서 트랜잭션이 새롭게 만들어지고 업데이트가 성공하는 것을 기대했습니다. 그러나 실제로는 db에 업데이트가 실패했습니다.
@RequiredArgsConstructor
@RestController
public class PostController {
private final PostService postService;
@PostMapping("/post")
public String createPost(
@RequestBody CreatePostDto createPostDto
) {
postService.savePost(createPostDto);
return "Post created";
}
}
// 엔티티 클래스
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor
@Getter
@Entity
public class PostEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "title")
private String title;
@Column(name = "content")
private String content;
// factory method
public static PostEntity of(Long id, String title, String content) {
return new PostEntity(id, title, content);
}
public void changeTitle(String updatedAfterCommit) {
this.title = updatedAfterCommit;
}
}
@Slf4j
@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
private final ApplicationEventPublisher eventPublisher;
/**
* 게시글 저장 (트랜잭션 내에서 실행)
*/
@Transactional
public void savePost(CreatePostDto createPostDto) {
System.out.println("===== SERVICE savePost START =====");
System.out.println("트랜잭션 활성화 여부: " + TransactionSynchronizationManager.isActualTransactionActive());
System.out.println("트랜잭션 이름: " + TransactionSynchronizationManager.getCurrentTransactionName());
// 게시글 엔티티 생성 및 저장
PostEntity post = PostEntity.of(null, createPostDto.title(), createPostDto.content());
postRepository.save(post);
// 게시글 저장 후 이벤트 발행
eventPublisher.publishEvent(new PostSavedEvent(post.getId()));
System.out.println("===== SERVICE savePost END =====\n");
}
/**
* 게시글 내용 업데이트 (트랜잭션 내에서 실행)
*/
@Transactional
public void updatePostContent(PostEntity post) {
log.info("===== SERVICE updatePostContent START =====");
log.info("트랜잭션 활성화 여부: {}", TransactionSynchronizationManager.isActualTransactionActive());
log.info("트랜잭션 동기화: {}", TransactionSynchronizationManager.isSynchronizationActive());
log.info("트랜잭션 이름: {}", TransactionSynchronizationManager.getCurrentTransactionName());
post.changeTitle("서비스에서 커밋 후 업데이트");
PostEntity savedPost = postRepository.save(post);
// 트랜잭션 리소스 상태 확인
log.info("트랜잭션 리소스: {}", TransactionSynchronizationManager.getResourceMap());
// 현재 트랜잭션에 등록된 동기화 객체들 확인
List<TransactionSynchronization> synchronizations = TransactionSynchronizationManager.getSynchronizations();
log.info("트랜잭션 동기화 객체 수: {}", synchronizations.size());
log.info("===== SERVICE updatePostContent END =====\n");
}
}
@RequiredArgsConstructor
@Slf4j
@Component
public class PostEventListenerDebug {
private final PostRepository postRepository;
private final PostService postService;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handlePostSaved(PostSavedEvent event) {
log.info("===== 이벤트 리스너 시작 =====");
log.info("현재 트랜잭션 활성화 상태: {}", TransactionSynchronizationManager.isActualTransactionActive());
log.info("현재 트랜잭션 동기화 상태: {}", TransactionSynchronizationManager.isSynchronizationActive());
// 게시글 조회
PostEntity post = postRepository.findById(event.postId())
.orElseThrow(() -> new RuntimeException("Post not found"));
log.info("게시글 조회 후 트랜잭션 상태: {}", TransactionSynchronizationManager.isActualTransactionActive());
// 서비스 메서드 호출 전
log.info("서비스 메서드 호출 전 트랜잭션 상태");
log.info("트랜잭션 활성화: {}", TransactionSynchronizationManager.isActualTransactionActive());
log.info("트랜잭션 동기화: {}", TransactionSynchronizationManager.isSynchronizationActive());
// @Transactional이 걸린 서비스 메서드 호출
postService.updatePostContent(post);
// 서비스 메서드 호출 후
log.info("서비스 메서드 호출 후 트랜잭션 상태");
log.info("트랜잭션 활성화: {}", TransactionSynchronizationManager.isActualTransactionActive());
log.info("트랜잭션 동기화: {}", TransactionSynchronizationManager.isSynchronizationActive());
// 서비스 호출 후 즉시 다시 조회
PostEntity updatedPost = postRepository.findById(event.postId()).orElseThrow();
log.info("업데이트 후 재조회한 제목: {}", updatedPost.getTitle());
log.info("===== 이벤트 리스너 종료 =====");
}
}
API 요청을 한 뒤 결과를 살펴봅시다.
### 게시글 저장
POST http://localhost:8100/post
Content-Type: application/json
{
"title": "test title",
"content": "test content"
}
DB를 확인해 보면 title이 '서비스에서 커밋 후 업데이트'로 변경되지 않고 저장된 것을 확인할 수 있습니다.
id | content | title |
96 | test content | test title |
분명 트랜잭션이 있는 서비스를 호출하는데 왜 업데이트가 안 되는 걸까요? 디버깅을 확인해 봤습니다.
- 디버깅 로그는 트랜잭션을 추적합니다.
===== SERVICE savePost START =====
트랜잭션 활성화 여부: true
트랜잭션 이름: com.example.blog.service.PostService.savePost
===== SERVICE savePost END =====
===== 이벤트 리스너 시작 =====
현재 트랜잭션 활성화 상태: true
현재 트랜잭션 동기화 상태: false
게시글 조회 후 트랜잭션 상태: false
서비스 메서드 호출 전 트랜잭션 상태
트랜잭션 활성화: false
트랜잭션 동기화: false
===== SERVICE updatePostContent START =====
트랜잭션 활성화 여부: true
트랜잭션 동기화: true
트랜잭션 이름: com.example.blog.service.PostService.updatePostContent
트랜잭션 리소스: {HikariDataSource (HikariPool-1)=org.springframework.jdbc.datasource.ConnectionHolder@7def61e5, org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean@e0d9e3f=org.springframework.orm.jpa.EntityManagerHolder@5051d5ca}
트랜잭션 동기화 객체 수: 0
===== SERVICE updatePostContent END =====
서비스 메서드 호출 후 트랜잭션 상태
트랜잭션 활성화: false
트랜잭션 동기화: false
업데이트 후 재조회한 제목: 서비스에서 커밋 후 업데이트
===== 이벤트 리스너 종료 =====
음.. 사실 저는 이 로그만 봐서는 문제가 없는 것 같습니다. 처음 savePost를 호출해서 잘 저장되었고 이벤트 리스너에서 조회까지도 문제가 없고 updatePostContet 메서드가 호출되었을 때 트랜잭션도 true로 잘 활성화되어 있습니다. 당연히 서비스 메서드 호출이 끝나면 트랜잭션 상태도 false로 종료됩니다.
문제상황을 상세하게 분석해 봅시다.
처음 이 문제를 직면했을 때 저는 도저히 이해가 되지 않았습니다. 왜냐하면 서비스 메서드에는 분명 트랜잭션이 걸려있고 readOnly도 아니었습니다. 이 상태에서 업데이트 메서드를 호출해서 jpa의 save()를 잘 호출했는데 실제 db의 값은 변경이 되지 않는다는 것이 매우 이상합니다. 재밌는 것은 디버깅을 통해 실제로 업데이트하고 리스너 내부로 반환된 post 엔티티 객체를 살펴보면 제가 원했던 대로 변경된 값을 가지고 있었습니다. 이것은 영속화된 객체의 값은 변경되었기 때문이 아닌가 생각됩니다. 그러나 오히려 이것 때문에 문제를 파악할 때 더 혼동이 왔습니다.
어떻게 해결할지 고민하던 중 가장 먼저 한 생각은 일반 @EventListener가 아니라 @TransactionalEventListener를 사용했으니 제가 모르는 특이한 동작 방식이 있지 않을까 하는 것이었습니다. 분명 트랜잭션 동작을 감지하여 phase로 원하는 시점에 동작시키는 이벤트 리스너이기 때문에 오히려 트랜잭션 관리가 편하고 잘 될 것이라 생각했는데 상상치도 못한 문제를 마주한 것입니다.
이 문제를 마주한 뒤 제가 MSA를 하며 엄청 자주 쓰는 기술인데 이것을 이해하지 못하고 사용해서는 안될 것 같다는 생각이 들었습니다. 그리고 도대체 왜 이런 현상이 발생하는 건지 너무 궁금해서 참을 수가 없었습니다. 그래서 일주일 내내 원인을 찾아보려고 퇴근하자마자 계속 알아보며 고군분투했습니다.
원인을 찾고자 저는 총 11개 정도의 상황을 가정해 보고 테스트를 해봤습니다.
1. @TransactionalEventListener의 phase가 after_commit 일 때만 업데이트가 안 되는 이유가 있는지 검색해 보기 (공식자료)
2. 컨트롤러에서 서비스 메서드 직접 호출하기 (서비스에는 트랜잭션 관련 로그를 남긴다.)
3. 이벤트 리스너에 @Transactional 적어보기 (리스너 자체에 트랜잭션을 걸어주면 잘 동작하나?)
4. 트랜잭션이 걸려있는 서비스 메서드에서 미리 데이터를 조회해서 영속화시킨 뒤 수정하기 (영속화의 문제인가?)
5. 서비스 메서드에서 save()를 사용하지 말고 jpa의 EntityManager를 주입받아서 merge() 호출해 보기 (save라서 안되나?)
6. 각 서비스 메서드에 트랜잭션 관련된 수많은 로그를 적어두고 호출해서 비교 분석하기 (트랜잭션 이름, 동기화, 활성화, 리소스 추적)'
7. 트랜잭션 인터셉터 코드 확인해 보기 (인터셉터가 동작중인지 로그 찍어보기)
8. @TransactionalEventListener의 phase를 before_commit으로 하면 잘 되는지 확인해 보기 (이것도 문제없이 잘된다)
9. 일반적인 @EventListener에서는 트랜잭션 업데이트가 잘 되는지 확인해 보기 (확인결과 잘 된다.)
10. @Transactional(propagation = Propagation.REQUIRES_NEW)를 사용해서 새로운 트랜잭션 만들기 (이건 되네?)
11. 트랜잭션 이벤트 리스너에 @Async를 추가해서 비동기로 호출하기 (트랜잭션을 종료한 스레드를 공유해서 발생하는 문제일까 생각이 되어서 이벤트 리스너에 비동기처리를 해봤는데 이것도 잘 된다)
이 많은 상황을 직접 다 찾아보면서 이 문제를 해결할 방법을 3가지 찾았습니다. 그러나 왜 업데이트가 안되는지에 대해서는 끝내 원인을 찾지 못했습니다. 제가 조금이라도 원인에 대한 실마리를 잡았다 생각돼서 알아보면 이것 때문이 아니라는 것을 알게 되었고 점점 미궁 속으로 빠져들어갔습니다. 이런 상황이 계속해서 반복되자 지치기도 하고 답답한 감정이 들었지만 제게 마음의 위안을 주었던 것은 이러한 상황을 하나씩 가정해 보며 해결방법을 확실하게 얻었다는 것입니다. (경험적인 측면에서 성장하는 기분이 들더군요.)
먼저 일반적인 이벤트 리스너(@EventListener)를 사용하는 것입니다. @TransactionalEventListener가 아닌 @EventListener를 사용할 때는 트랜잭션이 걸린 서비스 메서드에서 이벤트를 발행하면 서비스 코드를 동작시키던 스레드가 동기적으로 이어서 리스너 코드에 대해서 작업하기 때문에 리스너의 트랜잭션이 전혀 문제없이 잘 동작했습니다. 그러나 msa에서 이벤트 소싱 작업을 트랜잭션 커밋 후에 해야만 하는 상황도 있습니다. 예를 들면 Transactional Outbox Pattern 같은 경우가 있기 때문에 단순하게 @EventListener만으로 문제를 해결한다고 볼 수는 없습니다.
두 번째는 @TransactionalEventListener의 phase가 after_commit 상태일 때는 리스너에@Transactional(propagation = Propagation.REQUIRES_NEW)를 같이 적어주면 트랜잭션이 매번 새롭게 생성되면서 업데이트가 잘 동작합니다.
마지막은 @TransactionalEventListener의 phase가 after_commit 상태일 때 이 리스너 메서드에 @Async를 적용해서 비동기로 이벤트 리스너를 동작시키는 것입니다. 이 경우에는 기존 트랜잭션을 동작시키던 스레드와 다른 스레드가 이벤트 리스너를 동작시키므로 트랜잭션이 새롭게 만들어져서 전혀 문제없이 업데이트가 잘 되었습니다.
문제 분석을 통해 어떤 것을 알게 되었는가?
@Transactional(propagation = Propagation.REQUIRES_NEW) 이것만으로도 문제가 해결된다는 것을 보면서 트랜잭션을 완전히 새롭게 만들어주면 업데이트가 잘 된다는 것을 알게 되었습니다. 그렇다는 건 기존 트랜잭션을 종료한 뒤 호출되는 phase가 after_commit인 이벤트 리스너에서는 @Transactional이 걸려있는 서비스 메서드를 호출해도 새로운 트랜잭션을 만들지 못한다는 것입니다.
@TransactionalEventListener의 phase가 after_commit으로 설정되어 있다면 당연히 이 리스너 자체에는 트랜잭션이 걸려있지 않을 것입니다. 왜냐하면 트랜잭션이 commit 된 후에 이 리스너가 동작하는 것이기 때문입니다. 저는 트랜잭션을 커밋했다는 것은 트랜잭션이 종료되었다는 것과 동일하게 생각했습니다. 그래서 이 이벤트 리스너가 호출된 순간은 이미 트랜잭션이 종료된 후이기 때문에 이벤트 리스너 내부에는 당연히 트랜잭션이 없을 것이라고 생각했습니다.
자 그럼 지금 상황을 다시 정리해 봅시다. 트랜잭션이 없는 after_commit 이벤트 리스너에서는 @Transactional을 가진 서비스의 메서드를 호출합니다. 일반적인 상식으로는 이런 상황에는 서비스 메서드를 호출하는 순간 새로운 트랜잭션이 생성될 것입니다. 왜냐하면 트랜잭션의 기본 propagation 설정값은 REQUIRED이기 때문입니다. 이 설정은 기존 트랜잭션이 있으면 참석하고 없으면 새롭게 생성합니다. 그래서 우리가 api호출을 하면 컨트롤러 메서드에서 @Transcational이 걸린 서비스 메서드를 호출하면서 트랜잭션이 새롭게 만들어지고 잘 동작하는 것입니다.
근데 왜 after_commit 이벤트 리스너 내부의 서비스 메서드를 호출해도 트랜잭션이 생성되지 않고 있는 걸까요? 저는 이 문제를 트랜잭션 컨텍스트의 동기화 문제일 것 같다고 판단했습니다. 그러나 아무리 관련 로그를 찍어봐도 트랜잭션에는 문제가 없는 것 같아서 아리송했습니다. 이렇게 혼동스러워하던 중 혹시 이벤트 리스너는 조금 특수하게 동작해서 커밋한 후 jpa에서 flush가 안 되는 건가 싶어서 jpa의 save() 메서드를 saveAndFlush()로 변경해 보았습니다. 그리고 다시 api를 호출했는데 생전 처음 보는 에러가 발생했습니다. 에러가 이렇게 반가운 적은 처음이었습니다. 이번에는 진짜 실마리를 찾은 것만 같았습니다.
아래의 예외가 발생했습니다. no transaction is in progress
org.springframework.dao.InvalidDataAccessApiUsageException:
no transaction is in progress
...
Caused by:
jakarta.persistence.TransactionRequiredException:
no transaction is in progress
이 예외는 saveAndFlush 메서드를 호출할 때 활성화된 트랜잭션이 없기 때문에 발생하는 것이었습니다. saveAndFlush는 데이터베이스에 변경 사항을 즉시 반영하기 위해 트랜잭션이 반드시 필요합니다. 여기서 알게 된 것은 지금 트랜잭션이 없다는 것입니다.
이상합니다. 저는 분명 서비스 메서드에 @Transcational을 적어두었습니다.. 뭐가 문제일까요?
- 이벤트 리스너는 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)로 설정되어 있습니다.
- 이 말은 트랜잭션이 커밋된 후에 이벤트 리스너가 실행된다는 것입니다.
- 즉, 이미 트랜잭션이 끝난 상태에서 after_commit 이벤트 리스너가 동작합니다.
- 따라서 이벤트 리스너 메서드가 실행될 때는 트랜잭션이 없는 상태입니다.
- 이벤트 리스너 내부에서는 @Transactional이 적힌 서비스 메서드를 호출합니다.
- 서비는 메서드는 잘 호출되고 동작합니다. 그러나 실제로는 @Transactional이 동작하지 않았습니다. (업데이트 실패)
서비스의 @Transactional이 왜 동작하지 않을까요?
이렇게 추상적인 개념만으로 접근해서 문제가 발견되지 않는 경우에는 근본적인 구성을 살펴보면 항상 해결방법이 보였습니다. 그래서 저는 트랜잭션이라는 것 자체가 어떻게 구성되고 동작하고 있는지를 파악하기 시작했습니다. 그리고 간단하게 알게 된 코드들을 조금 정리해 봤습니다. 지금부터는 트랜잭션 인터셉터부터 실제 트랜잭션 커밋을 하는 processCommit() 메서드까지 알아보겠습니다.
먼저 트랜잭션 인터셉터입니다.
- 트랜잭션 인터셉터는 스프링 AOP를 활용하여 @Transactional이 붙은 메서드의 실행을 가로채서 트랜잭션 처리를 담당합니다. 여기서 invoke() 메서드는 실제로 트랜잭션 경계를 설정하고 관리하는 invokeWithinTransaction() 메서드를 호출합니다. 이 메서드는 트랜잭션의 시작부터 종료까지의 흐름을 관리하는 핵심 로직을 포함하고 있습니다.
// 1. @Transactional 메서드 호출시의 실제 동작 흐름
public class TransactionInterceptor extends TransactionAspectSupport implements MethodInterceptor, Serializable {
@Nullable
public Object invoke(MethodInvocation invocation) throws Throwable {
// AOP 프록시를 통해 들어온 메서드 호출을 처리
Class<?> targetClass = invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis()) : null;
Method var10001 = invocation.getMethod();
Objects.requireNonNull(invocation);
return this.invokeWithinTransaction(var10001, targetClass, invocation::proceed);
}
}
트랜잭션 인터셉터가 호출하는 TransactionAspectSupport의 invokeWithinTransaction 메서드를 살펴봅시다.
- 이 메서드는 트랜잭션의 경계를 정의하고, 대상 메서드의 실행 전후로 트랜잭션을 시작하고 종료하는 로직을 포함하고 있습니다. 비즈니스 로직이 성공적으로 실행된 후에는 commitTransactionAfterReturning(txInfo) 메서드를 호출하여 트랜잭션을 커밋합니다. 이 메서드가 현재 문제를 파악하는 핵심입니다.
// 2. 트랜잭션 처리의 핵심 로직
public abstract class TransactionAspectSupport {
protected Object invokeWithinTransaction(Method method, @Nullable Class<?> targetClass,
final InvocationCallback invocation) throws Throwable {
TransactionAttributeSource tas = getTransactionAttributeSource();
TransactionAttribute txAttr = (tas != null ? tas.getTransactionAttribute(method, targetClass) : null);
PlatformTransactionManager ptm = determineTransactionManager(txAttr);
// 트랜잭션 생성 및 실행
TransactionInfo txInfo = createTransactionIfNecessary(ptm, txAttr, joinpointIdentification);
Object retVal = null;
try {
retVal = invocation.proceedWithInvocation();
}
catch (Throwable ex) {
completeTransactionAfterThrowing(txInfo, ex);
throw ex;
}
finally {
cleanupTransactionInfo(txInfo);
}
// 여기서 commit() 메서드를 호출합니다 (중요!!)
commitTransactionAfterReturning(txInfo);
return retVal;
}
// 이 메서드에서 commit을 호출합니다.
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());
}
}
}
이 흐름은 중요하다고 생각되어 정리했습니다. 트랜잭션을 시작하고, 메서드를 실행한 후, 트랜잭션을 커밋 또는 롤백합니다.
- 트랜잭션 시작
- 필요한 경우 새로운 트랜잭션을 시작하고 TransactionInfo에 트랜잭션 상태를 저장합니다.
- 비즈니스 로직 실행
- retVal = invocation.proceedWithInvocation();을 통해 실제 비즈니스 메서드를 호출합니다.
- 예외 처리
- 비즈니스 로직 실행 중 예외가 발생하면 catch 블록에서 completeTransactionAfterThrowing(txInfo, ex);를 호출하여 트랜잭션을 롤백합니다.
- 트랜잭션 정보 정리
- finally 블록에서 cleanupTransactionInfo(txInfo);를 호출하여 트랜잭션 정보를 정리합니다.
- 트랜잭션 커밋
- 예외가 발생하지 않고 비즈니스 로직이 정상적으로 완료되면 commitTransactionAfterReturning(txInfo);를 호출하여 트랜잭션을 커밋합니다. (중요)
- 예외가 발생하지 않고 비즈니스 로직이 정상적으로 완료되면 commitTransactionAfterReturning(txInfo);를 호출하여 트랜잭션을 커밋합니다. (중요)
트랜잭션을 커밋할 때 AbstractPlatformTransactionManager 클래스의 commit() 메서드를 호출합니다.
- commit() 메서드는 내부적으로 processCommit()을 호출합니다.
- processCommit() 메서드에서는 실제로 트랜잭션 커밋 절차를 진행하며, 커밋 전후로 필요한 콜백 메서드들을 호출합니다.
public abstract class AbstractPlatformTransactionManager
implements PlatformTransactionManager, ConfigurableTransactionManager, Serializable
{
// 이 메서드가 commit을 진행합니다.
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);
}
}
}
// 실제 commit 프로세스는 여기서 진행됩니다. (핵심!)
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 var18) {
UnexpectedRollbackException ex = var18;
this.triggerAfterCompletion(status, 1);
this.transactionExecutionListeners.forEach((listener) -> {
listener.afterRollback(status, (Throwable)null);
});
throw ex;
} catch (TransactionException var19) {
TransactionException ex = var19;
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 var20) {
Throwable ex = var20;
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, (Throwable)null);
});
}
}
} finally {
this.cleanupAfterCompletion(status);
}
}
}
이제 TransactionalEventListener의 동작을 관리하는 트랜잭션 동기화 유틸 클래스를 살펴봅시다.
- TransactionalEventListener는 트랜잭션의 특정 단계에서 이벤트를 처리하기 위한 리스너입니다. 이 리스너의 동작은 TransactionSynchronizationUtils 클래스를 통해 관리됩니다.
- TransactionalEventListener는 내부적으로 TransactionSynchronization을 구현하고 있으며, 따라서 트랜잭션의 커밋이나 롤백 이후에 원하는 로직을 실행할 수 있습니다.
// 4. TransactionalEventListener가 사용하는 트랜잭션 동기화
public 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) {
Throwable ex = var5;
logger.error("TransactionSynchronization.afterCompletion threw exception", ex);
}
}
}
}
}
- triggerAfterCommit(): 트랜잭션이 커밋된 후에 호출되어, 등록된 모든 TransactionSynchronization의 afterCommit() 메서드를 실행합니다.
- triggerAfterCompletion(): 트랜잭션이 완료된 후에 호출되어, afterCompletion() 메서드를 실행합니다.
마지막으로 트랜잭션의 상태 관리를 담당하는 TransactionSynchronizationManager 클래스도 살펴봅시다.
- TransactionSynchronizationManager는 현재 스레드의 트랜잭션 상태와 관련된 정보를 저장하고 관리합니다. 트랜잭션이 종료된 후에는 이 클래스에서 트랜잭션과 관련된 모든 정보를 정리하게 됩니다.
// 3. 실제 트랜잭션 상태 관리
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();
}
}
- 트랜잭션 리소스 관리: 트랜잭션과 연관된 자원들을 관리합니다.
- 트랜잭션 동기화 관리: 트랜잭션 동기화 콜백들을 관리합니다.
- 트랜잭션 상태 정보 저장: 트랜잭션의 이름, 읽기 전용 여부, 격리 수준, 활성화 여부 등을 저장합니다.
- 트랜잭션 상태 초기화: 트랜잭션이 종료되면 clear() 메서드를 통해 트랜잭션 상태 정보를 초기화합니다.
전체적인 흐름을 다시 정리해 보겠습니다.
- 트랜잭션 인터셉터가 @Transactional 메서드를 가로채서 트랜잭션을 시작합니다.
- 비즈니스 로직이 실행되고, 정상적으로 완료되면 트랜잭션 매니저의 commit() 메서드를 호출합니다.
- commit() 메서드 내에서 processCommit() 메서드를 통해 트랜잭션 커밋 절차를 진행합니다.
- 트랜잭션이 커밋되면 TransactionSynchronizationUtils.triggerAfterCommit()이 호출되어, TransactionalEventListener의 afterCommit() 메서드가 실행됩니다.
- 트랜잭션이 완전히 종료되면 TransactionSynchronizationManager.clear() 메서드를 통해 트랜잭션 상태 정보가 초기화됩니다.
트랜잭션의 흐름과 지금 문제랑 무슨 연관이 있는 거야?
위의 수많은 코드 뭉치를 보다 보면 이런 생각이 들것입니다. "이걸 왜 보여준 거지? 난 이런 코드가 궁금한 게 아닌데?"
제가 이렇게 근본적인 코드를 남긴 이유는 3가지가 있습니다. 먼저 저희가 @Transactional이라는 하나의 애노테이션만으로 편리하게 적용시키고 있는 트랜잭션이 실은 이렇게 수많은 코드들이 유기적으로 어우러져서 완성되는 것이라는 것을 한 번쯤은 보면 좋을 것이라고 생각했습니다. 또한 트랜잭션의 시작부터 종료까지의 전체 흐름과 각 단계에서 어떤 클래스와 메서드가 관여하는지를 이해하면, 왜 특정 상황에서 @Transactional이 기대한 대로 동작하지 않는지 알 수 있습니다. 그리고 이를 통해 적절한 해결책을 적용할 수 있습니다.
그럼 이제부터는 트랜잭션이 어떻게 동작하는지 좀 더 세부적인 동작을 알아봅시다. 분명 이 동작을 함께 이해해나가다 보면 왜 after_commit를 사용한 이벤트 리스너가 내부에서 트랜잭션 메서드를 호출해도 제대로 업데이트 처리를 하지 못하는지 알게 될 것입니다.
트랜잭션의 흐름을 좀 더 확실하게 이해하기 위해 AbstractPlatformTransactionManager 클래스를 살펴봅시다.
- AbstractPlatformTransactionManager는 스프링 프레임워크에서 트랜잭션 관리를 위한 핵심적인 추상 클래스입니다. 이 클래스는 PlatformTransactionManager 인터페이스를 구현하며, 다양한 데이터 접근 기술(JDBC, JPA, Hibernate 등)에 대한 공통적인 트랜잭션 관리 기능을 제공합니다. 구체적인 트랜잭션 매니저들은 이 클래스를 상속받아 특정 기술에 맞는 트랜잭션 처리를 구현합니다.
package org.springframework.transaction.support;
public abstract class AbstractPlatformTransactionManager
implements PlatformTransactionManager, ConfigurableTransactionManager, Serializable {
// ... 메서드는 생략
}
AbstractPlatformTransactionManager 클래스의 주요 역할은 다음과 같습니다.
- 트랜잭션 수명 주기 관리
- 트랜잭션의 시작(doBegin), 커밋(doCommit), 롤백(doRollback) 등 기본적인 트랜잭션 흐름을 관리합니다. 템플릿 메서드 패턴을 사용하여 구체적인 트랜잭션 동작은 하위 클래스에서 구현하도록 합니다.
- 트랜잭션의 시작(doBegin), 커밋(doCommit), 롤백(doRollback) 등 기본적인 트랜잭션 흐름을 관리합니다. 템플릿 메서드 패턴을 사용하여 구체적인 트랜잭션 동작은 하위 클래스에서 구현하도록 합니다.
- 트랜잭션 전파 및 격리 수준 처리
- 트랜잭션의 전파 행동(Propagation)과 격리 수준(Isolation Level)을 처리하여 트랜잭션이 올바르게 전파되고 격리되도록 합니다. 이를 통해 다양한 트랜잭션 시나리오에서 일관된 동작을 보장합니다.
- 트랜잭션의 전파 행동(Propagation)과 격리 수준(Isolation Level)을 처리하여 트랜잭션이 올바르게 전파되고 격리되도록 합니다. 이를 통해 다양한 트랜잭션 시나리오에서 일관된 동작을 보장합니다.
- 트랜잭션 동기화 관리
- TransactionSynchronizationManager를 활용하여 트랜잭션 동기화를 관리하고, 트랜잭션의 시작과 종료 시점에 필요한 콜백을 처리합니다. 이를 통해 리소스가 적절하게 바인딩되고 해제되도록 합니다.
이제 AbstractPlatformTransactionManager에서 제일 중요한 processCommit() 메서드를 살펴봅시다.
- processCommit() 메서드를 통해 실제 트랜잭션 커밋이 이루어집니다.
- 어떤 호출이든 @Transactional을 사용 중이라면 processCommit() 메서드가 호출됩니다. (디버깅을 해보셔도 됩니다.)
// 실제 트랜잭션 커밋 처리 순서
private void processCommit(DefaultTransactionStatus status) throws TransactionException {
try {
boolean beforeCompletionInvoked = false;
boolean commitListenerInvoked = false;
try {
// 1. 커밋을 위한 준비
prepareForCommit(status);
// 2. 커밋 전 처리
triggerBeforeCommit(status); // TransactionSynchronizationUtils.triggerBeforeCommit
// -> 각 synchronization.beforeCommit(readOnly) 호출
// 3. 완료 전 처리
triggerBeforeCompletion(status); // TransactionSynchronizationUtils.triggerBeforeCompletion
// -> 각 synchronization.beforeCompletion() 호출
beforeCompletionInvoked = true;
// 4. 실제 커밋 수행
if (status.hasSavepoint()) {
// 세이브포인트 처리
status.releaseHeldSavepoint();
}
else if (status.isNewTransaction()) {
// 새 트랜잭션이면 실제 커밋
doCommit(status);
}
// 5. 커밋 후 처리
triggerAfterCommit(status); // TransactionSynchronizationUtils.triggerAfterCommit
// -> 각 synchronization.afterCommit() 호출
// -> @TransactionalEventListener(AFTER_COMMIT) 실행
// 6. 완료 처리
triggerAfterCompletion(status, TransactionSynchronization.STATUS_COMMITTED);
// TransactionSynchronizationUtils.invokeAfterCompletion
// -> 각 synchronization.afterCompletion(completionStatus) 호출
} catch (Exception ex) {
// 예외 처리...
}
} finally {
// 7. 정리 작업
cleanupAfterCompletion(status);
// -> TransactionSynchronizationManager.clear() 호출
// -> 모든 트랜잭션 리소스 정리
}
}
- @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)는 5번 단계에서 실행됩니다.
- 이 시점에서는 DB 커밋은 완료되었지만, 아직 트랜잭션 정리 작업은 남아있습니다.
- 7번의 cleanupAfterCompletion에서 최종적으로 모든 트랜잭션 리소스가 정리됩니다.
근데 DB 트랜잭션이 종료되었는지 어떻게 알 수 있을까요?
- doCommit 메서드를 살펴보면 그 답이 나옵니다. 총 4개의 트랜잭션 매니저가 이 코드를 구현하고 있습니다.
- JpaTransactionManager
- DataSourceTransactionManager
- HibernateTransactionManager
- JtaTransactionManager
저는 JPA를 사용 중이므로 JpaTransactionManager의 doCommit() 메서드를 살펴봤습니다.
public class JpaTransactionManager extends AbstractPlatformTransactionManager
implements ResourceTransactionManager, BeanFactoryAware, InitializingBean
{
protected void doCommit(DefaultTransactionStatus status) {
JpaTransactionObject txObject = (JpaTransactionObject)status.getTransaction();
if (status.isDebug()) {
this.logger.debug("Committing JPA transaction on EntityManager [" + txObject.getEntityManagerHolder().getEntityManager() + "]");
}
try {
EntityTransaction tx = txObject.getEntityManagerHolder().getEntityManager().getTransaction();
tx.commit();
} catch (RollbackException var6) {
RollbackException ex = var6;
Throwable var5 = ex.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", ex);
} catch (RuntimeException var7) {
RuntimeException ex = var7;
throw DataAccessUtils.translateIfNecessary(ex, this.getJpaDialect());
}
}
}
doCommit() 메서드 내부의 try문을 잘 살펴보시면 tx.commit()을 하는 것을 볼 수 있습니다. 이것은 EntityTransaction.commit()을 호출하여 실제 데이터베이스 트랜잭션을 커밋하는 것입니다. 이 시점에서 데이터베이스 레벨의 트랜잭션은 더 이상 존재하지 않게 됩니다.
따라서, 데이터베이스 트랜잭션은 doCommit(status) 호출 후 종료되며, after_commit 리스너가 실행되기 전에 이미 데이터베이스 트랜잭션이 종료된 것입니다. 만약 이 시점에서 새로운 데이터베이스 작업을 수행하려면, 별도의 트랜잭션을 명시적으로 시작해야 합니다.
이 코드의 흐름을 이해하기 쉽게 말로 풀어서 정리해 봤습니다.
// 트랜잭션 동기화의 주요 단계
1. 커밋 준비
prepareForCommit(status)
-> 커밋을 위한 준비 작업 수행
2. commit 전
triggerBeforeCommit(status)
-> 각 synchronization.beforeCommit(readOnly) 호출
3. 완료 전
triggerBeforeCompletion(status)
-> 각 synchronization.beforeCompletion() 호출
4. 실제 커밋
doCommit(status)
-> 실제 데이터베이스 트랜잭션 커밋
5. commit 후
triggerAfterCommit(status)
-> invokeAfterCommit(synchronizations)
-> 각 synchronization.afterCommit() 호출
-> @TransactionalEventListener(AFTER_COMMIT) 실행
6. 완전히 완료 후
triggerAfterCompletion(status, completionStatus)
-> invokeAfterCompletion(synchronizations, completionStatus)
-> 각 synchronization.afterCompletion(completionStatus) 호출
7. 최종 정리
cleanupAfterCompletion(status)
-> TransactionSynchronizationManager.clear() 호출
-> 모든 트랜잭션 리소스 정리
이것을 좀 더 간단하게 정리해 보면 다음과 같습니다.
- 트랜잭션 흐름을 제어하는 소스 코드를 보면 알 수 있듯이, AFTER_COMMIT 이벤트 리스너는 processCommit() 메서드가 실행되는 동안 그 내부에서 실행됩니다. 이것이 after_commit 이벤트 리스너에서 update를 제대로 하지 못하는 이유입니다.
1. 트랜잭션 시작 & 커밋
- DB 작업 수행
- 트랜잭션 커밋
2. TransactionSynchronizationUtils.triggerAfterCommit() 호출
- 등록된 모든 TransactionSynchronization에 대해
- afterCommit() 메서드 호출
3. TransactionSynchronizationUtils.triggerAfterCompletion() 호출
- 모든 동기화 객체의 afterCompletion() 호출
- 트랜잭션 관련 리소스 정리
흐름을 보면 트랜잭션이 시작되고 데이터베이스 작업이 수행됩니다. 트랜잭션이 커밋되면 TransactionSynchronizationUtils.triggerAfterCommit() 메서드가 호출됩니다. 여기서 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 애노테이션이 붙은 이벤트 리스너 메서드가 실행됩니다.
중요한 점은 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 메서드가 실행되는 시점은 doCommit() 메서드가 호출된 후라서 데이터베이스 트랜잭션이 이미 종료되었지만, 스프링 트랜잭션 컨텍스트는 아직 유지되고 있다는 것입니다. 따라서 이 시점에서는 새로운 데이터베이스 작업을 수행할 수 없으며, 필요하다면 명시적으로 새로운 스프링 트랜잭션 컨텍스트를 요청해야 합니다. (수행한다 해도 적용이 되지 않습니다.)
이벤트를 포함한 모든 트랜잭션 작업이 끝난 후에서야 최종적으로 cleanupAfterCompletion(status) 메서드가 호출됩니다. 이 메서드 내부에서 TransactionSynchronizationManager.clear()가 실행되며, 트랜잭션 관련 모든 리소스가 정리됩니다. 이 시점에서는 데이터베이스 트랜잭션뿐만 아니라 스프링 트랜잭션 컨텍스트도 완전히 종료되는 것입니다. 따라서 이 시점 이후에는 언제든지 새로운 데이터베이스 작업을 수행할 수 있습니다.
결국 after_commit 이벤트 리스너에서 업데이트가 제대로 되지 않았던 이유는 트랜잭션 커밋작업을 하는 processCommit() 메서드가 종료되지 않은 상태에서 계속해서 실행하다 보니 스프링 트랜잭션은 이어져도 DB트랜잭션은 종료되어 있었기 때문이었습니다.
@TransactionalEventListener의 트랜잭션 흐름을 정리해 봅시다.
이제 조금은 이해가 되지 않나요? 다시 한번 코드를 살펴봅시다.
/**
* AFTER_COMMIT 이벤트 리스너의 실행 컨텍스트
*
* 실행 시점:
* - DB 트랜잭션은 커밋 완료
* - 스프링의 트랜잭션 컨텍스트는 정리 진행 중
*
* 새로운 트랜잭션 시작이 필요한 경우:
* - REQUIRES_NEW를 사용하여 명시적으로 새 트랜잭션 컨텍스트 생성 필요
*/
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleEvent() {
// 이 시점에서는:
// 1. DB 트랜잭션이 커밋 완료됨 (데이터는 안전하게 저장됨)
// 2. 스프링 트랜잭션 컨텍스트는 정리 단계 진행 중
// 3. 새 트랜잭션을 시작하려면 REQUIRES_NEW로 명시적 선언 필요
}
핵심은 트랜잭션 커밋을 처리하는 processCommit()를 이해하는 것입니다.
// 실제 트랜잭션 커밋 처리 순서
private void processCommit(DefaultTransactionStatus status) throws TransactionException {
try {
boolean beforeCompletionInvoked = false;
boolean commitListenerInvoked = false;
try {
// 1. 커밋을 위한 준비
prepareForCommit(status);
// 2. 커밋 전 처리
triggerBeforeCommit(status); // TransactionSynchronizationUtils.triggerBeforeCommit
// -> 각 synchronization.beforeCommit(readOnly) 호출
// 3. 완료 전 처리
triggerBeforeCompletion(status); // TransactionSynchronizationUtils.triggerBeforeCompletion
// -> 각 synchronization.beforeCompletion() 호출
beforeCompletionInvoked = true;
// 4. 실제 커밋 수행
if (status.hasSavepoint()) {
// 세이브포인트 처리
status.releaseHeldSavepoint();
}
else if (status.isNewTransaction()) {
// 새 트랜잭션이면 실제 커밋
doCommit(status);
}
// 5. 커밋 후 처리
triggerAfterCommit(status); // TransactionSynchronizationUtils.triggerAfterCommit
// -> 각 synchronization.afterCommit() 호출
// -> @TransactionalEventListener(AFTER_COMMIT) 실행
// 6. 완료 처리
triggerAfterCompletion(status, TransactionSynchronization.STATUS_COMMITTED);
// TransactionSynchronizationUtils.invokeAfterCompletion
// -> 각 synchronization.afterCompletion(completionStatus) 호출
} catch (Exception ex) {
// 예외 처리...
}
} finally {
// 7. 정리 작업
cleanupAfterCompletion(status);
// -> TransactionSynchronizationManager.clear() 호출
// -> 모든 트랜잭션 리소스 정리
}
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)를 사용하면 이벤트 리스너 메서드는 5번 단계인 커밋 후 처리에서 실행됩니다. 이때 데이터베이스 트랜잭션은 이미 커밋되어 종료되었지만, 스프링의 트랜잭션 동기화 컨텍스트는 아직 활성화된 상태입니다.
이 상태에서 이벤트 리스너 내부에서 @Transactional이 붙은 메서드를 호출하면 어떻게 될까요? 기본 전파 속성인 Propagation.REQUIRED에 따라 새로운 트랜잭션이 생성되지 않고, 기존의 트랜잭션 컨텍스트에 참여하려고 시도합니다. 하지만 phase가 after_commit인 이벤트 리스너는 processCommit() 메서드 내부에서 호출되기 때문에 이미 DB 트랜잭션이 종료된 스프링 트랜잭션에 참여하게 됩니다. 이 상황은 실제로는 트랜잭션 없이 실행되는 것과 마찬가지입니다.
그 결과, 데이터베이스에 대한 Create, Update, Modify 작업이 반영되지 않는 문제가 발생합니다.
이를 해결하기 위해서는 @Transactional 애노테이션의 전파 속성을 Propagation.REQUIRES_NEW로 설정하고 이벤트 리스너에 추가해서 항상 새로운 트랜잭션을 시작하도록 명시해야 합니다. 이렇게 하면 기존의 트랜잭션 컨텍스트와 상관없이 독립적인 트랜잭션이 생성되어, 원하는 대로 데이터베이스 작업이 수행되고 커밋됩니다.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handlePostSaved(PostSavedEvent event) {
log.info("===== 이벤트 리스너 시작 =====");
// 게시글 조회
PostEntity post = postRepository.findById(event.postId())
.orElseThrow(() -> new RuntimeException("Post not found"));
// @Transactional이 걸린 서비스 메서드 호출
postService.updatePostContent(post);
// 서비스 호출 후 즉시 다시 조회
PostEntity afterUpdate = postRepository.findById(event.postId()).orElseThrow();
log.info("===== 이벤트 리스너 종료 =====");
}
그럼 phase가 BEFORE_COMMIT 단계일 때는 문제가 없을까요?
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)를 사용하면 어떻게 될까요? 이 경우 이벤트 리스너는 실제 데이터베이스 커밋 전에 실행됩니다. 따라서 DB 트랜잭션이 아직 활성화된 상태이며, 이벤트 리스너 내부에서 @Transactional 메서드를 호출해도 문제가 발생하지 않습니다. 기존의 트랜잭션에 참여하게 되고, 모든 작업이 한 트랜잭션 내에서 처리됩니다.
컨트롤러에서 트랜잭션 메서드를 호출하는 거랑 무슨 차이가 있을까요?
이 부분에 대해서 다시 한번 명확하게 정리해 보겠습니다.
컨트롤러 호출은 이벤트 리스너를 사용하지 않고 서비스 트랜잭션만으로 비즈니스를 처리한다고 가정해 봅시다.
@PostMapping("/post")
public void createPost() {
// 완전히 새로운 시작
// - 아무런 트랜잭션 컨텍스트가 없는 상태
// - @Transactional 메서드 호출시 새로운 트랜잭션 시작 가능
service.someMethod();
}
- 컨트롤러 메서드 createPost()는 트랜잭션 컨텍스트가 없는 상태에서 시작됩니다.
- service.someMethod()가 @Transactional로 선언되어 있다면, 스프링은 새로운 트랜잭션을 시작합니다.
- 이 새로운 트랜잭션은 데이터베이스와의 작업을 수행하고, 작업이 완료되면 커밋 또는 롤백됩니다.
- 이 과정에서 processCommit() 메서드가 호출되어 트랜잭션 커밋 프로세스를 관리합니다.
- 트랜잭션이 완료되면 TransactionSynchronizationManager는 트랜잭션 관련 정보를 정리합니다.
- 따라서, 컨트롤러에서 호출하는 경우에는 트랜잭션이 완전히 시작되고 종료되며, 트랜잭션 컨텍스트도 정리됩니다.
다음으로 AFTER_COMMIT 이벤트 리스너가 호출된 상황입니다.
private void processCommit(DefaultTransactionStatus status) {
try {
// ... 커밋 전 처리 ...
// 4. 실제 DB 커밋
doCommit(status);
// 5. AFTER_COMMIT 이벤트 리스너 실행
triggerAfterCommit(status);
// -> 트랜잭션 커밋 프로세스의 일부로 실행됨
// -> 데이터베이스 트랜잭션은 종료되었지만, 트랜잭션 컨텍스트는 아직 유지됨
// 6. 트랜잭션 완료 처리
triggerAfterCompletion(status, TransactionSynchronization.STATUS_COMMITTED);
} finally {
// 7. 트랜잭션 정리 작업
cleanupAfterCompletion(status);
// -> 여기서 트랜잭션 컨텍스트가 완전히 정리됩니다.
}
}
- doCommit(status)를 호출하면 데이터베이스 트랜잭션이 커밋되고 종료됩니다.
- 그러나 트랜잭션 커밋 프로세스는 아직 진행 중이며, TransactionSynchronizationManager에 트랜잭션 동기화 정보가 남아 있습니다.
- triggerAfterCommit(status)에서 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)로 등록된 메서드들이 실행됩니다.
- 이 시점에서는 데이터베이스 트랜잭션은 종료되었지만, 스프링의 트랜잭션 컨텍스트는 아직 유지되고 있습니다.
- 따라서, TransactionSynchronizationManager는 트랜잭션이 활성화된 것으로 인식합니다.
이것이 컨트롤러에서 호출하는 상황과 다른 핵심적인 차이입니다. 컨트롤러는 완전히 새로운 시작이지만, AFTER_COMMIT은 아직 진행 중인 트랜잭션 프로세스 내부에서 실행되는 것입니다. 이런 이유로 REQUIRES_NEW가 필요한 것입니다. 현재 진행 중인 트랜잭션 프로세스와 무관하게 완전히 새로운 트랜잭션을 시작하기 위해서입니다.
끝난 줄 알았나요? 아직 하나 남았습니다.
이대로 끝내면 굉장히 아쉬운 점이 하나 남아있습니다.
- 바로 데이터 조회 시 영속성 컨텍스트를 사용하기 때문에 지금처럼 실제 db에 반영되지 않는 경우 디버깅하기가 쉽지 않다는 것입니다. 지금부터 이벤트 리스너 코드를 살펴봅시다.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handlePostSaved(PostSavedEvent event) {
log.info("===== 이벤트 리스너 시작 =====");
// 게시글 조회
PostEntity post = postRepository.findById(event.postId())
.orElseThrow(() -> new RuntimeException("Post not found"));
// @Transactional이 걸린 서비스 메서드 호출
postService.updatePostContent(post);
// 서비스 호출 후 즉시 다시 조회
PostEntity updatedPost = postRepository.findById(event.postId()).orElseThrow();
log.info("===== 이벤트 리스너 종료 =====");
}
메서드의 흐름을 살펴보면 게시글을 조회한뒤 update를 진행합니다. 이후 다시 update 된 post를 확인하기 위해 findById로 조회하고 있습니다. 이렇게 되면 최초 조회한 post와 마지막에 조회한 afterUpdate가 가지는 PostEntity객체는 서로 다른 인스턴스(객체)라고 생각하실 수도 있습니다.
그러나 두 객체의 참조 주소값을 디버깅해 보면 다음과 같습니다. "post(PostEntity@14933), updatedPost(PostEntity@14933)" 이렇게 조회하는 post들이 같은 주소값을 참조하고 있다는 점이 중요합니다. 엔티티 값을 변경했을 때 실제로 db에는 업데이트가 안될 수도 있습니다. 그러나 이 상태에서 조회를 하면 db에 있는 값이 아니라 영속화된 객체의 값을 조회하기 때문에 값이 실제로 변경된 것처럼 보이는 것입니다.
단계를 나눠 좀 더 자세히 이해해 봅시다.
- @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 단계에서는
- DB 트랜잭션은 이미 종료되어 있습니다.
- 하지만 스프링 트랜잭션 컨텍스트는 아직 유지되고 있는 상태입니다.
- 이벤트 리스너 메서드 내에서 postRepository.findById(event.postId()) 를 호출하면
- 이때 엔티티 객체는 아직 영속성 컨텍스트에 관리되고 있는 상태입니다.
- 따라서 영속성 컨텍스트에 관리되고 있는 엔티티 객체를 그대로 반환받게 됩니다.
- 그 후에 postService.updatePostContent(post)를 호출하면
- 전달받은 post 엔티티 객체의 상태(예: 제목)가 변경됩니다.
- 이때 영속성 컨텍스트에 관리되고 있는 엔티티 객체의 상태가 변경되는 것이므로, 문제가 없습니다.
- 하지만 문제는 이 시점에서 DB 트랜잭션은 이미 종료되어 있기 때문에, 실제 DB에는 이 변경 사항이 반영되지 않습니다.
- 다시 postRepository.findById(event.postId()) 를 호출하면
- 영속성 컨텍스트에 이미 변경된 엔티티 객체가 존재하므로, 그 객체를 반환받게 됩니다.
- 따라서 post와 updatedPost가 동일한 객체를 가리키게 됩니다.
즉, 영속성 컨텍스트 내부에서는 엔티티 객체의 상태가 변경되지만, 데이터베이스 트랜잭션이 이미 커밋되어 종료된 상태이기 때문에 실제 데이터베이스에 반영되지 않습니다. 이로 인해 마치 변경이 성공한 것처럼 보이는 것입니다.
이는 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 단계에서 발생하는 문제로, 이 시점에서는 이미 데이터베이스 트랜잭션이 종료되어 있기 때문에 데이터베이스 작업을 수행할 수 없습니다.
따라서 이 상황에서는 실제 데이터베이스에 변경 사항이 반영되지 않지만, 영속성 컨텍스트 내에서는 엔티티 객체의 상태가 변경된 것으로 관리되고 있는 것입니다.
지금까지의 내용을 정리해 봅시다.
드디어 끝이 보입니다. 지금까지 너무 내용이 많고 길어서 읽기 힘드셨을 텐데 읽어주셔서 감사합니다.
1. 트랜잭션의 생명주기를 이해해야 합니다.
- "트랜잭션이 커밋되었다" ≠ "스프링 트랜잭션 컨텍스트가 종료되었다"
- DB 트랜잭션의 커밋과 스프링의 트랜잭션 컨텍스트는 별개의 생명주기를 가집니다.
- 스프링의 트랜잭션 컨텍스트는 processCommit() 메서드가 완전히 종료될 때 정리됩니다.
2. AFTER_COMMIT 시점에서는
- DB 트랜잭션은 이미 커밋되어 종료된 상태입니다.
- 스프링의 트랜잭션 컨텍스트는 아직 그대로 존재하는 상태입니다.
- 하지만 DB 트랜잭션이 종료되어 DB 작업은 불가능합니다.
3. 따라서 AFTER_COMMIT 시점에서 DB 작업이 필요하다면
- @Transactional(propagation = Propagation.REQUIRES_NEW)를 사용해야 합니다.
- 기존에 실행되던 트랜잭션(스프링 트랜잭션 컨텍스트)과 완전히 분리된 새로운 트랜잭션을 시작해야 하기 때문입니다.
- 이때 REQUIRES_NEW는
- 새로운 스프링 트랜잭션 컨텍스트 생성
- 새로운 DB 커넥션 획득
- 새로운 DB 트랜잭션 시작을 모두 수행합니다
결론적으로, 이 문제는 스프링 트랜잭션의 실행 구조에서 발생합니다.
AFTER_COMMIT 리스너가 호출되는 시점에서 DB 트랜잭션은 이미 커밋되어 종료되었지만, processCommit() 메서드는 아직 실행 중이고 이 안에서 스프링 트랜잭션 컨텍스트는 여전히 존재합니다. 이때 @Transactional(REQUIRED) 서비스 메서드(트랜잭션 기본 설정)를 호출하면, 스프링은 여전히 존재하는 트랜잭션 컨텍스트를 감지하고 새로운 트랜잭션을 시작하지 않습니다. 하지만 실제 DB 트랜잭션은 이미 종료되었기 때문에 DB 작업을 시도하면 "no transaction is in progress" 예외가 발생합니다.
따라서 이런 상황에서는 @Transactional(propagation = Propagation.REQUIRES_NEW)를 사용하여 완전히 새로운 트랜잭션(새로운 DB 트랜잭션과 새로운 스프링 트랜잭션 컨텍스트)을 시작해야 올바르게 DB 작업을 수행할 수 있습니다.
마무리하며
이번 포스팅을 작성하며 정말 많이 성장했다는 생각이 듭니다. 개인적으로 지금까지 발생한 문제 중에 원인을 찾는데 가장 오래 걸렸던 것 같습니다. 이벤트를 확인하는 테스트 코드 작성부터 시작해서 트랜잭션 매니저 코드 살펴보기, 모든 트랜잭션 포인트에 디버깅 찍어보기까지 정말 많은 작업을 해봤고 수많은 상황들을 가정해 봤습니다.
결론적으로 이 과정을 통해 저는 트랜잭션의 흐름을 더 잘 알게 되었고 문제점마저도 해결하게 되었습니다. 또한 지금까지 제가 작성했던 코드에는 이런 문제점이 없는지 한번 더 생각해 보게 되면서 많은 성장을 하게 된 것 같습니다.
성장할 수 있음에 감사했지만 문득 스프링 공식 문서에는 왜 이런 중요한 정보가 존재하지 않는 것인지에 대해서 의구심이 들었습니다. 분명 어딘가 적혀있겠지만 제 시야가 좁아서 찾지 못한 것이라고 생각합니다. 그러나 공식 문서라면 after_commit을 검색하기만 해도 이와 관련된 모든 정보가 바로 나올 만큼 잘 다뤄질 줄 알았습니다. 그러나 제가 다룬 내용에 대해서는 어떤 소개도 없다는 점에서 약간은 실망감이 들었습니다.
그래서 제가 적어둔 이 글이 다른 개발자들이 가진 트랜잭션에 대한 궁금증을 해결해 주고 개발에도 도움이 되었으면 좋겠습니다.
여기까지 읽어주신 모든 분들께 감사인사를 올리며 이만 물러가 보겠습니다.
감사합니다 :)
'Spring Data JPA' 카테고리의 다른 글
[Spring] JPA 엔티티에 왜 기본 생성자가 필수일까? (1) | 2024.10.21 |
---|---|
[Spring] Redis에서 RDB로 조회수 동기화하기 (25) | 2024.02.10 |
JPA N+1 문제가 발생하는 상황과 해결방법 (5) | 2023.12.28 |
[SpringBoot] 3.x.x 버전에서 P6Spy 적용하기 (3) | 2023.11.17 |
[Spring] Data JPA의 구조를 알아보자 (1) | 2023.10.24 |