스레드를 사용한 테스트에서 @Transactional을 사용하면 테스트가 실패하는 이유

Glen·2023년 8월 9일
0

TroubleShooting

목록 보기
3/6

서론

지금 진행하고 있는 프로젝트인 페스타고는 공연의 티켓팅이 핵심인 프로젝트다.

티켓팅은 동시에 수많은 사용자가 몰리므로, 당연히 동시성 문제가 발생한다.

따라서 동시성으로 발생하는 문제를 해결해야 한다.

그리고 동시성으로 발생하는 문제를 해결하는 코드를 작성한다고 끝이 아니라, 검증을 하는 테스트 코드도 작성해야 한다.

현실을 가정한다면 동시에 여러 기기에서 요청을 보내 테스트 하는 것이 가장 정확할 것이다.

하지만 여러 기기에서 동시에 많은 요청을 보내는 것이 힘들기 때문에, 멀티스레드를 사용하여 서비스의 로직을 비동기적으로 거의 동시에 호출하는 방법으로 테스트 코드를 작성했다.

하지만 테스트를 실행하니, 티켓팅 자체가 되지 않아 테스트가 실패하는 문제가 발생했다.

왜 이런 문제가 발생했을까?

본론

우선 테스트 코드는 다음과 같다.

@SpringBootTest(webEnvironment = WebEnvironment.NONE)  
@Transactional
class TicketServiceIntegrationTest {
    @Test  
    @Sql("/ticketing-test-data.sql")
    void 예약() throws InterruptedException {  
        // given  
        Member member = memberRepository.findById(1L).get();  
        TicketingRequest request = new TicketingRequest(1L);  
      
        int threadCount = 1000;  
        ExecutorService executorService = Executors.newFixedThreadPool(32);  
        CountDownLatch latch = new CountDownLatch(threadCount);  
        for (int i = 0; i < threadCount; i++) {  
            executorService.submit(() -> {  
                try {  
                    ticketService.ticketing(member.getId(), request);  
                } finally {  
                    latch.countDown();  
                }  
            });  
        }  
        latch.await();  
      
        assertThat(memberTicketRepository.count()).isEqualTo(100);  
    }
}

단순하게 1000번 요청을 하여, 정확하게 100개의 티켓이 예매되었는지 확인하는 테스트 코드이다.

하지만 테스트를 실행하니 다음과 같은 결과와 함께 테스트가 실패했다.

expected: 100L
 but was: 0L

그리고 날아간 쿼리를 보니, INSERT 문이 단 하나도 없었다.

처음에는 테스트 코드니까 롤백이 되므로, INSERT 쿼리가 날아가지 않는건가? 생각했다.

하지만 롤백이 되든 말든, 우선 INSERT 쿼리는 날아가야한다.

따라서 작성한 로직에 문제가 있는건가 싶어 catch문으로 어떠한 예외가 발생하는 것인지 확인했다.

그랬더니 다음과 같은 예외가 잡혔다.

com.festago.exception.NotFoundException: 존재하지 않는 티켓입니다.

대체 무슨 상황인지 싶었다...

분명히 @Sql 어노테이션으로 직접 데이터를 INSERT 했기에 전혀 문제가 생길 수 없는 상황이었다.

insert into ticket (stage_id, ticket_type)  
values (1, 'VISITOR');

그래서 @Sql 어노테이션이 제대로 작동을 안했나 싶어 티켓을 조회하는 로직을 호출하여, 티켓을 조회했다.

void 예약() throws InterruptedException {  
    // given  
    Member member = memberRepository.findById(1L).get();  
    TicketingRequest request = new TicketingRequest(1L);  
  
    StageTicketsResponse stageTickets = ticketService.findStageTickets(1L);  
    System.out.println(stageTickets);  
  
    ...
}
StageTicketsResponse[tickets=[StageTicketResponse[id=1, ticketType=VISITOR, totalAmount=100, remainAmount=100]]]

하지만, 티켓은 정상적으로 조회가 되었다.

혹시 몰라 테스트에 있는 @Transactional을 제거하고 테스트를 실행하니 이번에는 테스트가 거짓말처럼 통과했다.

@SpringBootTest(webEnvironment = WebEnvironment.NONE)  
// @Transactional
class TicketServiceIntegrationTest {
    ...
}

대체 무엇과 연관이 있길래, @Transactional을 사용하면 테스트가 실패했던 것일까?

@Sql

해당 문제는 @Sql과 우선 연관이 되어 있다.

@Sql에서 사용할 수 있는 @SqlConfig 내의 JavaDocs에는 다음과 같이 설명되어 있다.

Using the resolved transaction manager and data source, SQL scripts will be executed within an existing transaction if present; otherwise, scripts will be executed in a new transaction that will be immediately committed. An existing transaction will typically be managed by the TransactionalTestExecutionListener.

@Transaction 어노테이션이 붙은 @Sql으로 실행되는 쿼리는 트랜잭션의 범위를 따라간다.

즉, 테스트가 통과한 뒤 커밋이 된다.

@Transcational이 붙지 않을 경우 실행되는 쿼리는 즉시 DB에 커밋이 된다.

따라서 해당 문제는 DB에 즉시 커밋이 되지 않았기에 발생하는 문제였다.

그렇다면 대체 왜 멀티 스레드에서 이런 문제가 발생한 것 일까?

Transaction과 EntityManager

문제는 별도의 스레드에서 실행될 때 발생한다.

이것은 EntityManager의 1차 캐시와 연관이 있는데, EntityManager는 스레드마다, 트랜잭션이 시작되면 생성되고 트랜잭션이 끝나면 소멸한다.

테스트를 실행하는 스레드는 main 스레드이다.

[2023-08-09 02:57:26:2712] [main] INFO  [com.festago.application.integration.TicketServiceIntegrationTest.예약:85] - 테스트 실행

그리고 executorService에서 실행되는 코드는 ExecutorService가 생성한 스레드에서 실행이 된다.

[2023-08-09 02:57:26:2713] [pool-2-thread-1] INFO  [com.festago.application.integration.TicketServiceIntegrationTest.lambda$예약$1:91] - 티케팅

즉, main 스레드의 EntityManager와 executorService가 실행하는 스레드의 EntityManager는 서로 다른 1차 캐시를 가지고 있다.

따라서 executorService에서 실행하는 쿼리에서 티켓을 조회할 수 없었던 것이다.

main 스레드에서 INSERT 쿼리는 정상적으로 실행되어, 날아갔지만 실제 DB에 커밋이 되지 않았기에 다른 스레드에서 SELECT 쿼리를 날려도 조회되지 않는다.

해결법

문제의 원인을 파악했으니 해결법은 간단하다.

@Sql 어노테이션으로 실행된 쿼리를 main 스레드의 트랜잭션이 끝날 때 커밋 하는 것이 아니라 다른 스레드에서 트랜잭션이 시작되기 전에 커밋을 하면 된다.

@Sql 어노테이션에서 사용할 수 있는 @SqlConfig 어노테이션에는 다음과 같은 속성을 사용할 수 있다.

Can be set to SqlConfig.TransactionMode.ISOLATED to ensure that the SQL scripts are executed in a new, isolated transaction that will be immediately committed.

SqlConfig.TransactionMode.ISOLATED를 사용하면 즉시 커밋되는 새 트랜잭션에서 실행되는 쿼리를 날릴 수 있다.

따라서 다음과 같이 코드를 수정하면 된다.

@Test  
@Sql(scripts = "/ticketing-test-data.sql",  
    config = @SqlConfig(transactionMode = TransactionMode.ISOLATED))
void 예약() throws InterruptedException {  
    ...
}

이제 @Sql에서 날아간 쿼리는 별도의 트랜잭션에서 실행되고 바로 커밋되므로, 다른 스레드에서 다른 EntityManager로 실행되더라도 DB에 값이 존재하므로 문제 없이 테스트가 통과한다.

결론

멀티스레드 환경에서는 트랜잭션과 1차 캐시에 대해 데이터의 일관성이 깨지지 않도록 주의해야 한다.

스레드는 각자 서로 다른 EntityManager를 가지기 때문에, 다른 스레드의 트랜잭션에서 영속된 엔티티를 알 수 없다.

따라서 다른 스레드에서 영속된 엔티티를 사용하려면 해당 스레드의 트랜잭션이 정상적으로 완료되어야 사용할 수 있다.

쉽게 눈에 띄지 않던 문제였지만, 해결하면서 멀티스레드 환경에서의 EntityManager와 트랜잭션에 대해 인사이트를 얻을 수 있었다.

profile
꾸준히 성장하고 싶은 사람

3개의 댓글

comment-user-thumbnail
2023년 8월 9일

잘 읽었습니다. 좋은 정보 감사드립니다.

답글 달기
comment-user-thumbnail
2024년 6월 11일

저도 같은 문제를 겪고 논리적으로 이해가 되지 않았는데 이 글을 참고하여 이해할 수 있었습니다.
감사합니다.

1개의 답글