본문 바로가기

Spring

@TransactionalEventListener 사용시 주의할 점(feat. Entity 수정 삭제)

모든 코드는 Github 에 있습니다.

0. 들어가기에 앞서

다음과 같은 요구사항이 있다.

1. 회원 탈퇴를 할 수 있다.
2. 탈퇴시 해당 회원의 구독 정보가 삭제된다.

이를 아래와 같이 이벤트 방식으로 구현했을 때 과연 아래 코드는 잘 동작할까?

서비스 코드

@Service
public class MemberService {
    private final MemberRepository memberRepository;
    private final ApplicationEventPublisher eventPublisher;

    public MemberService(MemberRepository memberRepository, ApplicationEventPublisher eventPublisher) {
        this.memberRepository = memberRepository;
        this.eventPublisher = eventPublisher;
    }

    @Transactional
    public void deleteMember(Long memberId) {
        System.out.println("start deleteMember");
        memberRepository.deleteById(memberId);
        eventPublisher.publishEvent(new MemberDeleteEvent(this, memberId));
        System.out.println("end deleteMember");
    }
}

이벤트 핸들러

@Component
public class MemberDeleteEventHandler {
    private final SubscribeRepository subscribeRepository;

    public MemberDeleteEventHandler(SubscribeRepository subscribeRepository) {
        this.subscribeRepository = subscribeRepository;
    }

    @Transactional
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
    public void handle(MemberDeleteEvent event) {
        System.out.println("start event handle");
        subscribeRepository.deleteAllByMemberId(event.getMemberId());
        System.out.println("end event handle");
    }
}

정답은 'No'이다.
왜 그럴까?

그 이유를 살펴보자.

 

1. 테스트로 동작 확인

먼저 위 로직의 테스트를 작성하여 확인해보자.

@SpringBootTest
class MemberServiceTest {
    @Autowired
    private MemberService memberService;

    @Autowired
    private MemberRepository memberRepository;

    @Autowired
    private SubscribeRepository subscribeRepository;

    @Test
    void name() {
        //given
        Member member = memberRepository.save(new Member("name"));
        subscribeRepository.save(new Subscribe(member.getId()));

        //when
        memberService.deleteMember(member.getId());

        //then
        Assertions.assertThat(subscribeRepository.findAll()).isEmpty();
    }
}

실행 결과는 다음과 같다.

회원 탈퇴가 이루어지면 이벤트 핸들러가 동작하면서 구독을 삭제해야 하는데 구독이 여전히 남아 있다.
디버깅을 해보면 이벤트 핸들러가 정상적으로 호출이 된다. 그럼에도 구독이 삭제가 안된다.

 

2. 구독이 삭제되지 않는 이유

구독이 삭제되지 않는 이유는 다음 글에서 찾을 수 있다.

https://dzone.com/articles/transaction-synchronization-and-spring-application

 

Transaction Synchronization and Spring Application Events: Understanding @TransactionalEventListener - DZone Java

Using an example that focuses on transaction synchronization issues, this tutorial shows how @TransactionalEventListener works, and how it differs from @EventListener.

dzone.com

위 글 중 이런 부분이 있다.

You need to keep in mind that with the synchronous call you are by default working within the same transaction as the event producer.

 

말하자면, 동기 호출을 사용하면 기본적으로 이벤트 생성자와 동일한 트랜잭션 내에서 작업한다는 것이다.

 

위 탈퇴 로직을 정리하면 다음과 같다.

1. 회원 탈퇴 로직 실행
2. 회원 탈퇴 로직이 커밋
3. '회원 탈퇴 로직 커밋'을 트리거로 하여 이벤트 핸들러 호출
4. 구독 삭제

하지만 안타깝게도 4. 구독 삭제 는 커밋되지 않는다.
이미 회원 탈퇴 로직에서 트랜잭션이 커밋되었기 때문이다.

트랜잭션 디버깅을 하면 눈으로 확인할 수 있다.

3. 해결 방안

1. TransactionPhase.BEFORE_COMMIT 사용

회원 탈퇴 로직이 커밋되기 전에 이벤트 핸들러를 실행시키고 커밋하는 방법이 있다

2. Propagation.REQUIRES_NEW 사용

이벤트 핸들러가 실행될 때 새로운 트랜잭션을 시작하는 방법이 있다.

3. 비동기 호출 사용

@Async 어노테이션을 활용하여 이벤트 핸들러를 비동기로 호출하는 방법이 있다.

비동기로 호출하면 새로운 트랜잭션이 시작된다.

threadPoolTaskExecutor

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Bean("threadPoolTaskExecutor")
    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(200);
        executor.setQueueCapacity(50);
        executor.setThreadNamePrefix("AsynchThread-");
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return new AsyncExceptionHandler();
    }
}

비동기를 사용했을 때 트랜잭션 디버깅 모습

4. 정리

- @TransactionalEventListener은 동기 호출을 사용하면 기본적으로 이벤트 생성자와 동일한 트랜잭션 내에서 작업한다.

- 따라서, 이벤트를 호출하기 전 트랜잭션을 커밋했다면 이후 이벤트 핸들러 안에서 어떠한 엔티티와 관련된 작업을 하더라도 반영되지 않는다.

- 해결 방안은 비동기를 사용하거나 새로운 트랜잭션을 사용하는 등의 방법이 있다.

 

 

**참고:
https://dzone.com/articles/transaction-synchronization-and-spring-application

https://kwonnam.pe.kr/wiki/springframework/transaction/transactional_event_listener

 

반응형