그 외 공부/트러블 슈팅

왜 @TransactionalEventListener에서 커밋이 안될까?

SeongOnion 2024. 4. 24. 23:37
728x90

문제상황

@TransactionalEventListner를 사용하는 코드에서 문제가 발생했다.

 

문제를 유발한 코드는 아래와 유사했다.

@Service
@RequiredArgsConstructor
public class GroupService {
    private final GroupRepository groupRepository;
    private final ApplicationEventPublisher applicationEventPublisher;

    @Transactional
    public void deleteGroup(long groupId) {
        groupRepository.deleteById(groupId);
        applicationEventPublisher.publishEvent(new GroupDeletedEvent(groupId));
    }
}

먼저, @Transactional이 선언된 서비스 코드 메서드에서 특정 로직을 실행시킨 후, 애플리케이션 내부 이벤트를 발행한다.

@Component
@RequiredArgsConstructor
public class GroupDeletedEventListener {

    private final GroupMemberRepository groupMemberRepository;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, value = GroupDeletedEvent.class)
    public void handleGroupDeletedEvent(GroupDeletedEvent event) {
        groupMemberRepository.deleteAllByGroupId(event.getGroupId());
    }
}

이후, 핸들러 쪽에서 해당 이벤트를 받아 추가적인 로직을 수행한다.

 

서비스 메서드에서 작업한 변경사항이 모두 안정적으로 커밋된 후에만 핸들러 로직을 실행하고 싶으므로 @TransactionalEventListener를 선언해 phaseAFTER_COMMIT으로 잡아주었다.

@SpringBootTest
class GroupServiceTest {

    @Autowired
    private GroupRepository groupRepository;
    @Autowired
    private GroupMemberRepository groupMemberRepository;
    @Autowired
    private GroupService groupService;

    @Test
    void test() {
        // given
        Group group = groupRepository.save(new Group());
        GroupMember groupMember = groupMemberRepository.save(new GroupMember(group.getId()));

        // when
        groupService.deleteGroup(group.getId());

        // then
        Optional<GroupMember> byId = groupMemberRepository.findById(groupMember.getId());
        assertThat(byId).isEmpty();
    }
}

이후, 위와 같은 테스트 코드를 통해 이벤트 핸들러 쪽에서 처리한 작업이 정상적으로 수행되었는지 확인하였으나 테스트는 실패했다.

 

원인 분석

사실 원인은 @TransactionalEventListener의 독스에 이미 잘 명시되어있었다.

내용을 대략적으로 요약하자면 @TransactionalEventListenerphaseAFTER_COMMIT, AFTER_ROLLBACK, AFTER_COMPLETION 중 하나로 되어있을 경우, 해당 이벤트를 발행한 쪽(서비스 코드)의 트랜잭션이 커밋 혹은 롤백이 완료된 것은 맞으나, 트랜잭션 자체는 여전히 active한 상태로 남아있다는 것이다.

 

따라서 이벤트를 핸들링하는 쪽에서 추가적으로 트랜잭션을 시작하면 서비스 코드에서 열어놓은 트랜잭션에 합류하게 된다.

 

하지만 주의할 점은, 해당 트랜잭션에서 발생한 변경사항은 이미 서비스 코드에서 커밋 및 롤백이 되었다고 판단하기 때문에 이벤트 핸들러에서 발생된 변경사항은 커밋되지 않게된다.

 

이는 어쨌거나 이벤트를 발행하는 서비스 코드와 이벤트를 처리하는 핸들러에서 트랜잭션을 공유하는 상황에서 이벤트 핸들러에서 발생한 예외사항이 서비스 코드까지 전파되지 않게 하기 위함으로 이해할 수 있다.

 

그렇다면 이는 곧 이벤트 핸들러까지 커넥션을 쭉 유지한다는 의미일까?

 

메서드 실행 중에 사용 중인 커넥션 개수와 유휴 상태의 커넥션 개수를 직접 로깅해 확인해보자.

@Service
@RequiredArgsConstructor
public class GroupService {
    private final GroupRepository groupRepository;
    private final ApplicationEventPublisher applicationEventPublisher;
    private final HikariDataSource hikariDataSource;

    @Transactional
    public void deleteGroup(long groupId) {
        int activeConnections = hikariDataSource.getHikariPoolMXBean().getActiveConnections();
        int idleConnections = hikariDataSource.getHikariPoolMXBean().getIdleConnections();
        System.out.println("delete group...");
        System.out.println("activeConnections = " + activeConnections);
        System.out.println("idleConnections = " + idleConnections);
        System.out.println("delete group...");
        
        groupRepository.deleteById(groupId);
        applicationEventPublisher.publishEvent(new GroupDeletedEvent(groupId));
    }
}
@Component
@RequiredArgsConstructor
public class GroupDeletedEventListener {

    private final GroupMemberRepository groupMemberRepository;
    private final HikariDataSource hikariDataSource;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, value = GroupDeletedEvent.class)
    public void handleGroupDeletedEvent(GroupDeletedEvent event) {
        int activeConnections = hikariDataSource.getHikariPoolMXBean().getActiveConnections();
        int idleConnections = hikariDataSource.getHikariPoolMXBean().getIdleConnections();
        System.out.println("handleGroupDeletedEvent...");
        System.out.println("activeConnections = " + activeConnections);
        System.out.println("idleConnections = " + idleConnections);
        System.out.println("handleGroupDeletedEvent...");
        groupMemberRepository.deleteAllByGroupId(event.getGroupId());
    }
}

 

위처럼 서비스 코드와 이벤트 핸들러 코드의 메서드 실행 전의 Active 상태의 커넥션 개수와 Idle 상태의 커넥션 개수를 찍어보았다.

결과는 이벤트 핸들러에서도 쭉 커넥션을 유지하고 있었다..!

 

이는 비단 @TransactionalEventListener뿐 아니라, @EventListener에서도 동일한 방식으로 동작함을 확인했다.

 

여태껏 애플리케이션 내부 이벤트를 사용하는 것이 커넥션을 분리하는 효과가 있다고 생각했던 것은 대단히 잘못된 생각이었다.

해결

1. 트랜잭션 전파옵션을 REQUIRES_NEW로 설정하기

해당 문제를 가장 간단하게 해결할 수 있는 방법은 TransactionSynchronization.afterCommit()의 독스에 명시되어있다.

이벤트 핸들러 쪽에서 @Transactional의 전파 옵션(propagation)을 REQUIRES_NEW로 하여 이벤트 핸들러 작업이 별도 트랜잭션에서 진행되도록 강제해주는 것이다.

@Component
@RequiredArgsConstructor
public class GroupDeletedEventListener {

    private final GroupMemberRepository groupMemberRepository;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, value = GroupDeletedEvent.class)
    @Transactional(propagation = Propagation.REQUIRES_NEW) // 전파 옵션을 REQUIRES_NEW로 설정
    public void handleGroupDeletedEvent(GroupDeletedEvent event) {
        groupMemberRepository.deleteAllByGroupId(event.getGroupId());
    }
}

 

별도 트랜잭션에서 작업이 진행되므로 테스트에 성공하게 된다.

 

하지만 실제 프로젝트에서 REQUIRES_NEW 옵션은 하나의 요청에서 두 개 이상의 커넥션을 소유하는 상황을 유발해 성능 저하 및 데드락을 유발할 수 있으므로 그다지 선호되지 않는다.

 

만약 해당 이벤트를 처리하는 핸들러가 여러 개이고, 그들 모두에서 REQUIRES_NEW 옵션을 사용해 별도 커넥션을 맺게되면 서비스 메서드 호출 한 번에 수 십개의 커넥션이 점유되는 끔찍한 상황이 발생할 수도 있다.

 

2. 이벤트 핸들러를 비동기로 수행하기

꼭 동기로 수행되어야할 로직이 아니라면 이벤트 핸들러 메서드를 비동기로 수행하도록하여 문제를 해결할 수도 있다.

 

스프링에서는 멀티 스레드 환경에서 트랜잭션의 동기화를 보장하기 위해 ThreadLocal을 사용해 트랜잭션을 관리한다.

 

따라서 이벤트 핸들러가 비동기로 수행되어 서비스 코드의 스레드와 다른 별도의 스레드에서 트랜잭션 작업을 시작하게 된다면 자연스레 새로운 트랜잭션을 사용하게 된다.

 

서비스 코드를 실행시킨 스레드 또한, 이벤트 핸들러 메서드의 수행만을 요청하고 종료되기 때문에 해당 스레드가 점유하고 있던 커넥션도 자연스레 반납되게 될 것이다.

 

이벤트 핸들러를 비동기로 실행시키기 위한 예제 코드는 다음과 같다.

@Configuration
@EnableAsync
public class AsyncExecuteConfig {

    @Bean("asyncEventExecutor")
    public Executor asyncEventExecutor() {
        ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
        threadPoolTaskExecutor.setCorePoolSize(5);
        threadPoolTaskExecutor.setMaxPoolSize(5);
        threadPoolTaskExecutor.initialize();
        return threadPoolTaskExecutor;
    }
}

먼저 비동기 작업을 실행시켜줄 별도의 Executor를 빈으로 정의한다.

@Component
@RequiredArgsConstructor
public class GroupDeletedEventListener {

    private final GroupMemberRepository groupMemberRepository;
    
    @Async("asyncEventExecutor") // 비동기 선언
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, value = GroupDeletedEvent.class)
    public void handleGroupDeletedEvent(GroupDeletedEvent event) throws InterruptedException {
        groupMemberRepository.deleteAllByGroupId(event.getGroupId());
    }
}

이후, 비동기 수행을 원하는 곳에 @Async 어노테이션을 통해 비동기 작업을 선언해주면 끝이다.

 

해당 어노테이션은 메서드뿐 아니라 클래스 레벨에도 선언 가능하다.

역시나 테스트가 성공한 것까지 확인할 수 있다.