@Transactional을 사용한 테스트에서 비동기 로직 테스트에서 발생한 문제

  • by

개요

사이드 프로젝트 서비스에 들어가는 게시 보고 기능을 개발하고 통합 테스트를 작성했습니다.

테스트할 로직은 ReportPostService를 통해 보고서 요청이 들어오면 보고서 도메인 개체인 ReportPost를 저장하는 기능입니다.

이때 신고 후 처리되는 로직이 있습니다만, 「슬랙으로 통지를 송신한다」라고 「신고가 20개 이상 적층된 경우, 해당의 투고를 블라인드 처리」하는 기능입니다.

이 부수적인 기능을 분리하고 싶었습니다.

이유는 다음과 같습니다.

  • 슬랙 알림을 보내기 위해 외부 종속성과 결합된 논리가 있습니다.

    여기에서 예외가 발생하면 기존 트랜잭션도 롤백되어 보고 처리가 더 이상 발생하지 않습니다.

    슬랙 알림을 보내지 못하면 보고서 트랜잭션이 성공해야 하므로 별도의 트랜잭션이나 트랜잭션 외부에서 작동하고 싶습니다.


  • 위의 문제는 Slack 알림을 예외 처리하는 방법으로도 해결할 수 있습니다.

    그러나 위의 로직 외에도 쿼리를 통해 20개 이상의 보고서를 쌓았는지 확인하고 쌓이면 게시 블라인드 처리 쿼리까지 실행합니다.

    보고 API의 응답 성능에 장애가 발생할 수 있으므로 보고 도메인 오브젝트를 저장하는 데만 성공하면 클라이언트에 성공 응답을 보내고 싶습니다.

    따라서 이 논리를 비동기적으로 실행하기로 결정했습니다.

    이 때 Aspect 처리하는 방법도 있지만, 그 기능을 여러 클래스에서 사용하지 않는 것 같고, 단지 서비스를 분리하고 @Async를 사용했습니다.

    @Async
    @Override
    public void notifyAndBlockIfRequired(Long postId, Long userId, String reason) {
        try {
            Long reportCount = reportPostRepository.countByPost_Id(postId);
            if (reportCount >= BLOCK_CRITERIA) {
                Post post = postRepository.findById(postId)
                        .orElseThrow(() -> new PhochakException(ResCode.NOT_FOUND_POST));
                post.blindPost();
            }

            String message = generateReportMessage(userId, postId, reason, reportCount);
            SlackMessageFormDto test = SlackMessageFormDto.builder()
                    .username(slackReportProperties.getBotNickname())
                    .text(message)
                    .build();
            slackPostReportFeignClient.call(test);
        } catch (Exception e) {
            log.error("(PostBlockServiceImpl|Fail) 게시글 블라인드 처리 시 예외 발생", e);
        }
    }

문제 상황

위의 notifyAndBlockIfRequired() 메서드 중 reportCount가 일정 수 이상 쌓이면 게시물을 블라인드하는 논리를 테스트하기 위해 다음과 같은 통합 테스트를 만들었습니다.

    @Transactional
    @Test
    @DisplayName("신고 카운트가 20개 이상 쌓이면 포스트가 노출되지 않는다")
    void processReport_overCriteria() throws InterruptedException {
        // given
        Long userId = user.getId();
        Long postId = post.getId();
        for (int i = 0; i < 19; i++) {
            User reporter = userRepository.save(User.builder()
                    .nickname("nickname" + i)
                    .providerId("providerId" + i)
                    .provider(OAuthProviderEnum.KAKAO)
                    .build());
            reportPostRepository.save(ReportPost.builder()
                    .post(post)
                    .reporter(reporter)
                    .reason("reason")
                    .build());
        }

        // when
        reportPostService.processReport(userId, postId, request);
        // 비동기 처리 위해 잠시 sleep
        Thread.sleep(200L);

        // then
        Optional<Post> post = postRepository.findById(postId);
        assertThat(post).isPresent();
        assertThat(post.get().isBlind()).isTrue();
    }

테스트 환경에서는 H2 Database에서 테스트별로 트랜잭션을 롤백하고 다시 실행하는 방법을 사용합니다.

@Transactional을 사용했습니다.

기존의 1 개의 포스트에 19 개의 보고를 작성해 두고, processReport() 를 호출해 1 개의 보고를 한층 더 추가한 후, 그 post 가 blind 되어 있는지를 검증하는 테스트입니다.

그러나 테스트를 실행할 때 Async 측 메서드는 보고서 수를 쿼리할 때 0개가 나와 테스트가 실패했습니다.


해결 과정

Test의 트랜잭션 경계

그 문제의 원인을 나는 트랜잭션 경계 설정이 잘못되었다고 추측했다.

Test 메소드의 given 절, 심지어 @BeforeEach로 테스트를위한 게시물을 저장 한 결과조차도 비동기 메소드에서는 보이지 않았기 때문입니다.

아시다시피 Spring이 제공하는 @Transactional은 선언적 트랜잭션입니다.

이 주석 첨부 메소드가 실행되면(자), 트랜잭션(transaction)가 개시되어 예외가 발생했을 경우, 또는 메소드 호출이 정상적으로 종료하면(자), 커밋 또는 롤백이 행해집니다.

현재, 상기의 구조에서는 @Transactional 를 테스트로부터 붙여 넣고 있으므로, 테스트 메소드의 실행으로부터 테스트 메소드의 끝까지를 트랜잭션(transaction) 경계로 실행됩니다.

따라서 비동기 메서드로 결과를 확인하는 시점에서는 트랜잭션 결과가 아직 커밋되지 않았기 때문에 보고서 수가 0으로 쿼리되었습니다.

또, 이 구조에서는, 테스트 메소드가 종료할 때까지, 비동기 메소드로 그 트랜잭션의 결과를 표시하는 방법이 없기 때문에, 테스트 방법을 변경할 필요가 있었습니다.

또 다른 질문

원인을 조사하는 동안 추가로 신경이 쓰일 수 있습니다.

Spring이 제공하는 @Transactional은 프록시 방식으로 작동합니다.

일반적으로 Proxy가 작동하려면 해당 클래스 외부에서 호출해야 한다는 것을 알고 있습니다.

Java 환경에서는 일반적으로 JUnit을 통해 테스트를 실행하지만, Junit Framework가 테스트를 실행할 때 이 클래스를 외부에서 호출라고 추측하게 되었습니다.

게다가, 각 테스트 메소드가 실행되기 전의 @BeforeEach 메소드가 존재합니다만, 만약 @Transactional 를 테스트 클래스가 아니고 각 테스트 메소드에 붙이는 경우, 이 @BeforeEach 는 트랜잭션(transaction)외인가 어떤가 궁금했습니다.

위의 테스트 예에서 확인해 보았습니다.

현재, 상기 테스트의 @BeforeEach 에서는 다음과 같은 동작을 합니다.

@BeforeEach
void setUp() {
    request = new ReportPostRequestDto("test");
    user = new User(1234L, OAuthProviderEnum.KAKAO, "testId", "report", "testImage");
    Shorts shorts = new Shorts(1L, "upload key", "shorts", "thumbnail");
    post = new Post(user, shorts, PostCategoryEnum.CAFE);

    userRepository.save(user);
    postRepository.save(post);
}

게시물 보고서를 테스트하기 위해 보고할 게시물과 게시물을 만든 사용자 개체를 생성하는 코드입니다.

이 코드가 트랜잭션 외부이면 save하자마자 DB에 커밋됩니다.

그러니까 @Async로 작동 notifyAndBlockIfRequired에서 커밋되지 않았으므로 누락된 보고서 수와 달리 게시물을 찾을 수 있습니다.

그래서 나는 테스트 메소드에만 @Transactional을 걸고 @Async 내에서 게시물을 찾았습니다.

@Transactional
@Test
@DisplayName("신고 카운트가 20개 이상 쌓이면 포스트가 노출되지 않는다")
void processReport_overCriteria() throws InterruptedException {
    // 이하 중략
@Async
@Override
public void notifyAndBlockIfRequired(Long postId, Long userId, String reason) {
    try {
        postRepository.findById(postId).orElseThrow(() -> new PhochakException(ResCode.NOT_FOUND_POST));
        // 중략

결과는 다음과 같이 게시물을 찾을 수 없다는 예외가 발생했습니다.


이 실험의 결과 @Transactional을 사용했던 (아마 JUnit이) @BeforeEach 메소드도 트랜잭션 경계에 넣고 메소드를 실행한다는 것을 알 수 있습니다.

따라서 내가 작성한 테스트에서는 다음 작업이 하나의 트랜잭션에서 작동합니다.

  • (@BeforeEach에서 만든) user 저장, post 저장
  • (테스트 메소드의 given절로 작성한) user 19명, ReportPost 19개 저장
  • (테스트 메서드의 when 절에서 호출됨) processReport 메서드

그리고 위의 세 가지 행동의 결과는 테스트 메서드 호출이 끝날 때 커밋이 발생합니다.

그 전에 @Async가 다른 스레드(트랜잭션)에서 실행하는 notifyAndBlockIfRequired 메소드는 이 트랜잭션의 결과를 볼 수 없습니다.

마지막 질문으로, procesReport 메서드의 전파 속성을 REQUIRES_NEW로 설정하면 마찬가지로 테스트 메서드의 트랜잭션 결과를 볼 수 없습니까? 라는 생각이 들었습니다.

@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processReport(Long userId, Long postId, ReportPostRequestDto reportPostRequestDto) {
    Post post = postRepository.findById(postId).orElseThrow(() -> new PhochakException(ResCode.NOT_FOUND_POST));
    // 생략

그리고 테스트를 실행하면 다음 예외가 발생합니다.


예상대로 서로 다른 트랜잭션 간에 분리되어 커밋 전에 다른 트랜잭션의 결과를 볼 수 없습니다.

임시 솔루션

그래서 저는 그 기능을 테스트하는 두 가지 방법을 생각했습니다.

  • 비동기 메서드에서 DB를 쿼리하고 유효성 검사가 필요한 경우 테스트 메서드에서 @Transcatioanl을 사용하지 마십시오. 그러나 테스트 결과 롤백을 위해 수동 롤백 프로세스를 거칩니다.

    (데이터를 직접 삭제)
  • 혹은, 현재도 호출원(processReport)으로 커밋이 행해지기 전에 비동기 메소드(notifyAndBlockIfRequired)로 조회하면 타이밍에 의해 무결성 문제가 발생할 가능성이 있기 (위해)때문에, 필요한 데이터를 조회해 비동기 메소드에 건네주도록(듯이) 로직을 수정합니다.

    (이 기사의 예에서와 같이 게시 보고서의 수를 미리 쿼리하고 Async에 전달합니다.

    ) -> 그 후 비동기 메서드 (notifyAndBlockIfRequired)가 20 개 이상의 게시물 일 때 Post를 blind 처리하는 트랜잭션이 커밋 때까지 테스트 방법을 Sleep 등에서 지연하는지 확인!

결론

개인 프로젝트를 하면서 여러가지 시도한 것을 시도해 보니 새로운 문제를 만났습니다.

해결하는 과정에서 근본적인 @Transactional의 행동을 더 파헤칠 기회가 되었습니다.

아무것도 생각하지 않고 사용한 (실제로는 Test Rollback 목적으로만 사용한) @Test에서 사용되는 @Transaction도 일반적으로 사용하는 것과 같은 방식으로 작동했다는 것을 알았습니다.

그래서 실제 비즈니스 로직을 설계할 때도 선언적 트랜잭션 경계가 어디에서 어디에 있는지 항상 생각하고 코드를 짜야 한다고 생각했습니다.

우연히 실무를 하면서도, AOP와 관련된 트랜잭션 경계에 대해 혼란스러워하는 점이 있었습니다만, 향후의 실험을 해, 해당의 토픽에서도 문장을 작성해 봅시다.