sol 개발 블로그 로고
Published on

테스트 코드 작성하기 with Transaction (1)

Authors
  • avatar
    Name
    Chan Sol OH
    Twitter

목차

3-Tier-Architecture 테스트 코드 작성하기 1에서 JUnit의 생성주기, Controller, Service 코드를 테스트하는 방법을 다뤘다. Spring Transaction 실습에서는 여러 격리 수준에 따라 Service 코드의 결과가 달라지는 것을 확인했다.

이번 포스팅은 Service 코드의 테스트 실행 시간을 측정하는 Spring StopWatch 사용법과 멀티쓰레드로 데이터를 여러번 업데이트하고 그 결과를 테스트하는 방법을 소개한다.

Spring StopWatch

아래 test code는 단순히 StopWatch를 실행하고 비즈니스 코드의 실행 시간을 측정한다.

Test_StopWatch
@Test
@DisplayName("Stopwatch 실행시켜보는 코드")
public void testStopWatch (){
    StopWatch stopWatch = new StopWatch(); // 스톱워치 객체 생성
    stopWatch.start(); // 스톱워치 시작
    // 비즈니스 코드 시작
    // 1초 대기
    for(int i=0; i<100; i++){
        try {
            Thread.sleep(10); //0.01초 대기
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    // 비즈니스 코드 끝
    stopWatch.stop(); // 스톱워치 스탑
    System.out.println(stopWatch.prettyPrint());
}
output1
StopWatch '': running time = 1209436250 ns
---------------------------------------------
ns         %     Task name
---------------------------------------------
1209436250  100%

prettyPrint 메서드를 호출하면 나노초 단위로 나오는 것이 아쉽지만, 전체 시간과 각 테스크마다 걸린 시간을 측정해서 알려주기 때문에 좋다. 순서는 비즈니스 코드 앞에 스톱워치 객체를 생성하고 시작한다. 그리고 비즈니스 코드 뒤에 객체의 시간 측정을 중지시킨다.

하지만 여러 쓰레드에서 실행한다면 어떨까?

멀티스레드 테스트

이번에는 멀티스레드로 클래스의 static temp를 줄이는 실험을 할 것이다. 당연히 동시성 프로그래밍을 적용하지 않고 lock도 없기 때문에 실패할 것이다. 단지 이 실험을 하는 이유는 기존 Runnable 인터페이스를 구현하는 방법말고 Executors라는 클래스를 이용해서 멀티스레드를 실행시켜보고자 한다.

threadCount 만큼의 스레드로 requestCount 만큼의 요청을 처리한다.

multi_thread_test

private static int temp = 100;

@Test
    @DisplayName("메인 스레드가 멀티 스레드 작업이 다 끝날 때까지 기다리는지 테스트")
    public void multiThreadTest() throws InterruptedException {
        StopWatch stopWatch = new StopWatch(); // 스톱워치 객체 생성
        stopWatch.start(); // 스톱워치 시작
        int threadCount = 20; // 멀티스레드 개수
        int requestCount = 100; // 요청 개수
        ExecutorService executorService = Executors.newFixedThreadPool(threadCount); // 멀티스레드 생성
        // 스레드는 countDown을 호출해서 requestCount를 하나씩 감소시킴
        CountDownLatch countDownLatch = new CountDownLatch(requestCount); // 멀티스레드 카운트 생성

        for (int i = 0; i < requestCount; i++) {
            executorService.submit(() -> { // submit 안에 함수는 스레드가 실행시킴
                try {
                    temp -= 1; // static 변수 temp을 1씩 감소
                } finally {
                    countDownLatch.countDown(); // requestCount 하나 감소
                } });
        }

        // 메인 스레드는 requestCount가 0이 될때까지 blocked된다.
        countDownLatch.await();
        stopWatch.stop(); // 스톱워치 스탑
        System.out.println(stopWatch.prettyPrint());
        assertEquals(0, temp);
    }

위 코드를 실행시키면 아래와 같이 결과가 나온다. locking 전략을 적용하지 않았기 때문에 테스트는 실패했다. countDownLatch.await();를 주석처리해서 멀티스레드 작업을 기다리지 않고 그냥 끝내면 평균 1146166ns가 걸리지만, countDownLatch.await();를 적용시켜 기다리면 평균 1877750ns 정도 걸린다.

output2
StopWatch '': running time = 1715750 ns
---------------------------------------------
ns         %     Task name
---------------------------------------------
001715750  100%

필요:0
실제   :1

JUnit에서 Spring 컴포넌트 사용하기

위 두가지 상황은 JUnit 프레임워크에서 격리된 함수를 실행하는 테스트이다. 하지만 Spring 컴포넌트(Controller, Service, Repository)를 테스트하고 싶을 때도 있다. JUnit 프레임워크에서 Spring 컴포넌트를 사용하는 방법은 두가지가 있다.

  1. @SpringBootTest : 테스트를 실행할 때 모든 Spring 컴포넌트나 classes 배열 안에 선언된 컴포넌트를 불러온다.
  2. @DataJpaTest : 테스트를 실행할 때 @Repository가 붙은 모든 컴포넌트를 불러온다. Service나 Controller를 불러오지 않기 때문에 빠르게 테스트를 수행할 수 있다.
  3. @WebMvcTest : 테스트를 실행할 때 외부 네트워크와 연결되는 Controller의 동작을 테스트한다. 이때 보통 Controller는 mock 객체를 사용한다.

SpringBootTest 방법

위 어노테이션 중에서 선택적으로 컴포넌트를 가져올 수 있는 @SpringBootTest을 사용하는 방법은 아래와 같다.

SpringBootTest
@RunWith(SpringRunner.class)
@SpringBootTest(classes = {MapRepository.class, CarService.class}) // 원하는 컴포넌트만 선택
public class CarServiceWithRepoTest {

	@Autowired
	private CarService carService; // Service 객체 직접 사용

	@Test
	public void shouldReturnValidDateInTheFuture() {
    	Date date = carService.schedulePickup(new Date(), new Route());
    	assertTrue(date.getTime() > new Date().getTime());
	}
}

DataJpaTest 방법

Repository를 테스트하는 방법은 Service를 테스트하는 것보다 DB와 연결될 필요가 있기 때문에 더 복잡하다. 하지만 아래 순서를 따르면 손쉽게 할 수 있다.

  1. 테스트 클래스에 @DataJpaTest 붙이기
  2. Autowired로 RepositoryTestEntityManager 의존성 주입하기
  3. Service 컴포넌트처럼 Repository를 사용해서 코드를 작성하고 결과를 체크하기

Repository를 테스트를 할 때 그 결과가 실제 DB에 들어가면 안되고 격리된 복제본에 실제 동작을 테스트할 수 있어야한다. 그러기 위해 @DataJpaTest@Entity@Repository 컴포넌트만 스캔해서 data access test를 할 수 있게한다.

@DataJpaTest가 걸려있으면 기본적으로 test에 @Transactional이 걸려있다. 또한 테스트가 끝나면 transaction은 모든 작업을 롤백하기 때문에 초기 DB 상태에 영향을 주지 않는다. 테스트를 위한 EntityManager는 TestEntityManager로 따로 존재한다. 이는 @DataJpaTest에 다 포함되어 있기 때문에 그냥 사용하면 된다.

DataJpaTest를 이용한 테스크 코드

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class TestRepository {

    @Autowired
    TicketRepository ticketRepository;
    @Autowired
    TestEntityManager testEntityManager;


    @Test
    @DisplayName("티켓 수량 감소 후 수량 체크")
    public void givenNewTicket_whenUpdated_thenSuccess() throws InterruptedException {
        StopWatch stopWatch = new StopWatch(); // 스톱워치 객체 생성
        stopWatch.start(); // 스톱워치 시작

        Ticket newTiket = new Ticket("효랑이", 100L);
        testEntityManager.persist(newTiket);
        newTiket.subtractQuantity(); // 티켓 수량 감소
        ticketRepository.save(newTiket);
        Long result = testEntityManager.find(Ticket.class, newTiket.getTicketId()).getQuantity();

        assertEquals(99L, result);
        System.out.println("티켓 수량 : "+result);
        stopWatch.stop(); // 스톱워치 스탑
        System.out.println(stopWatch.prettyPrint());
    }
}

멀티스레드로 티켓 수량 감소시키기

멀티스레드 테스트와 비슷하지만, static 값이 아닌 DB에 위치한 티켓 데이터를 멀티스레드로 업데이트하는 테스트이다.

아래 테스트는 100개 수량을 가진 티켓을 20개의 멀티스레드100회 감소시켰을 때, 결과적으로 티켓 수량은 0이 나와야한다.

멀티스레드_엔티티_업데이트_테스트
@SpringBootTest
public class TestService {

    @Autowired
    TicketRepository ticketRepository;
    @Autowired
    TicketService ticketService;
    static int threadCount = 20; // 멀티 스레드 생성
    static int requestCount = 100;

    @BeforeEach
    public void before(){ // ProductServiceV2 객체 생성을 위한 생성자를 통한 DI
        ticketRepository.save(new Ticket("A좌석", (long) requestCount));
    }

    @AfterEach
    public void after() {
        ticketRepository.deleteAll();
    }
    @Test
    @DisplayName("멀티스레드로 transaction이 걸린 티켓에 수량 감소 후 체크")
    public void givenMultiThreadAndTransaction_whenUpdated_thenSuccess() throws InterruptedException {
        StopWatch stopWatch = new StopWatch(); // 스톱워치 객체 생성
        stopWatch.start(); // 스톱워치 시작

        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
        // 스레드는 countDown을 호출해서 requestCount를 하나씩 감소시킴
        CountDownLatch countDownLatch = new CountDownLatch(requestCount);
        Long result1 = ticketService.findByTicketName("A좌석").getQuantity();
        System.out.println("초기 티켓 수량 : "+result1);

        for (int i = 0; i < requestCount; i++) {
            executorService.submit(() -> { // submit 안에 함수는 스레드가 실행시킴
                try {
                    ticketService.subtract("A좌석"); // 티켓 수량 감소
                } catch (Exception e){
                    System.out.println(e.getMessage());
                }
                finally {
                    countDownLatch.countDown();
                } });
        }
        // 메인 스레드는 requestCount가 0이 될때까지 blocked된다.
        countDownLatch.await();

        Long result = ticketService.findByTicketName("A좌석").getQuantity();
        System.out.println("티켓 수량 : "+result);

        stopWatch.stop(); // 스톱워치 스탑
        System.out.println(stopWatch.prettyPrint());
        assertEquals(0L, result);
    }
}

위와 같은 코드에서 중요한 비즈니스 코드는 ticketService.subtract("A좌석"); 일 것이다. ticketService.subtract는 아래와 같다.

@Transactional // un-commit read 발생으로 update하지 못하는 상황 발생!
public Long subtract(String ticketName){
    try{
        Optional<Ticket> ticket = ticketRepository.findByTicketName(ticketName);
        if(ticket.isEmpty()){
            throw new RuntimeException("It's a non-existent ticket.");
        }
        ticket.get().subtractQuantity();
        return ticket.get().getQuantity();
    } catch (RuntimeException ex) {
        // Handle concurrent modification
        // For example, you can retry the operation or take other appropriate actions
        throw new RuntimeException("Concurrent modification detected "+ ex.getMessage());
    }
}

@Transactionsubtract에 붙였음에도 아무런 Exception이 발생하지 않고 100회의 감소가 발생하지 않고 12회만 감소했다. 이는 서로 다른 쓰레드의 transaction이 동시에 티켓 recode에 접근하여 수량 update를 시도한 것이다.

output3
티켓 수량 : 88
StopWatch '': 0.349443625 seconds
----------------------------------------
Seconds       %       Task name
----------------------------------------
0.349443625   100%

MySQL의 기본 격리 수준은 Repeatable Read이지만 왜 un-commit read 문제가 발생한 것일까?

다음 포스팅에서 위 문제를 해결하는 방법을 소개하겠다!!

Reference

  • Best Practices for How to Test Spring Boot Applications

https://tanzu.vmware.com/developer/guides/spring-boot-testing/

  • Testing Spring Data Repositories

https://courses.baeldung.com/courses/1295711/lectures/30127904

  • [Error] @DataJpaTest DataSource 설정 오류

https://charliezip.tistory.com/21