TransactionalEventListener를 동기로 처리할 때의 발생가능한 문제

공병주(Chris)·2022년 11월 17일
0
post-thumbnail

해당 글에서 볼 수 있듯이, 속닥속닥의 알림 기능을 ApplicationEventPublisher와 TransactionalEventListener를 통해 개선하고 있었다. 그런데, Event 처리를 동기로 하는 경우에 이벤트를 발행하는 쪽의 트랜잭션이 Connection을 놓지 않는다는 이슈를 들어서 직접 테스트 해보았다.

테스트의 로직은 기존의 코드를 재사용한 것이기 때문에, 개연성은 중요하게 생각하지 않았다.

Hikari의 maximum-pool-size 설정

먼저, Hikari의 maximum-pool-size를 1로 설정해두었다. 왜 1로 설정한지는 아래에서 확인할 수 있을 것이다.

spring:
  datasource:
    # db 설정
    hikari:
      maximum-pool-size: 1

Member를 저장하고 TestEvent를 발행하는 TestMemberService라는 객체가 있다.

@Component
public class TestMemberService {

    private final MemberRepository memberRepository;
    private final ApplicationEventPublisher applicationEventPublisher;

    public TestMemberService(MemberRepository memberRepository,
                             ApplicationEventPublisher applicationEventPublisher) {
        this.memberRepository = memberRepository;
        this.applicationEventPublisher = applicationEventPublisher;
    }

    @Transactional
    public void saveNewMember(String username, String password, String nickname) {
        Member member = Member.builder()
                .username(Username.from(username))
                .password(Password.from(password))
                .nickname(Nickname.from(nickname))
                .build();
        memberRepository.save(member);
        applicationEventPublisher.publishEvent(new TestEvent(username));
    }
}

그리고 위에서 발행한 Event를 아래의 Listener가 처리를 한다.

@Component
public class TestListener {

    private final TicketRepository ticketRepository;

    public TestListener(TicketRepository ticketRepository) {
        this.ticketRepository = ticketRepository;
    }

    @TransactionalEventListener
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void handleTestEvent(TestEvent testEvent) {
        Ticket ticket = new Ticket(testEvent.getUsername());
        ticketRepository.save(ticket);
    }
}

TestListener에서 발행된 이벤트를 처리하는 방식은 따로 @Async를 걸어주지 않았기 때문에 동기 방식으로 처리된다. 동기 방식으로 처리하는 것이 핵심이다.

위의 설정에서 아래와 같은 테스트 코드를 짜보았다.

@SpringBootTest
class ATest {

    @Autowired
    private TestMemberService testMemberService;

    @Autowired
    private TicketRepository ticketRepository;

    @Test
    void myTest() throws InterruptedException {
        testMemberService.saveNewMember("chris", "Mypassword123!@", "공병주");

        List<Ticket> tickets = ticketRepository.findAll();
        assertThat(tickets.size()).isEqualTo(1);
    }
}

처음의 예상은 새로운 Member를 save하고 Event가 발행되어서 Ticket 객체를 save해서 테스트가 통과할지 알았다.

결과

결과를 먼저 이야기 하자면, 테스트는 실패한다. 조금 더 구체적인 결과는 Member는 save 쿼리가 나가지만, Ticket은 save가 나가지 않는다.

이전의 것을 다시 리마인드 하자면, 맨 위에서 Hikari의 maximum-pool-size는 1로 설정해두었다.

ApplicationEventListener로 발행한 event를 @TransactionEventListener로 동기 처리를 할 때, 이벤트를 발행하는 쪽에서 Transaction이 commit 되었음에도 Connection은 놓지 않고 있는다.

따라서, Event를 Listen하는 쪽에서 Ticket을 저장하려고 Connection을 얻으려고 해도 하나뿐인 connection은 Member를 save하는 곳에서 잡고 있기 때문에 사용할 수 있는 Connection이 존재하지 않는다.

해결방안

비동기로 처리

@Component
@Async("asyncExecutor") // 비동기로 처리!
public class TestListener {

    private final TicketRepository ticketRepository;

    public TestListener(TicketRepository ticketRepository) {
        this.ticketRepository = ticketRepository;
    }

    @TransactionalEventListener
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void handleTestEvent(TestEvent testEvent) {
        Ticket ticket = new Ticket(testEvent.getUsername());
        ticketRepository.save(ticket);
    }
}

이벤트 로직을 비동기로 처리하면 이벤트를 발행하는 쪽의 Transaction이 commit되고 Connection을 pool에 돌려놓는다. 따라서, 위 Listener에서 사용할 수 있는 Connection이 존재하고 Ticket을 save할 수 있다.

Connection 의 개수를 넉넉하게 두면 되지 않나? 라고 생각을 했는데, 만약 db와 Hikari pool의 connection 개수를 넉넉한 n 개로 잡아두어도 동시에 n번의 @TransactionalEventListener를 사용하는 api 호출이 발생한다면 문제가 발생할 것이다.

따라서, 지금 생각하기엔 비동기로 처리하는 것이 가장 최선의 방법이라고 생각한다.

profile
self-motivation

0개의 댓글