[ParkNav] 코어 로직 동시성제어 테스트 코드 작성

Jae Hun Lee·2023년 4월 27일
0

parknav

목록 보기
1/8
post-thumbnail

테스트 방법 선택

동시성 제어 테스트를 위해 테스트 코드작성을 하려고 계획을 했고 가장 처음 만난 문제는

동시성 제어는 단위테스트로 작성해야 하는가 통합테스트로 작성해야 하는가? 에 고민에 빠졌고

동시성 제어는 멀티스레드 환경에서 테스트를 진행해야하고 단위테스트는 각 메서드나

함수 단위로 테스트를 해야하기때문에 통합 테스트로 진행하기로 했다.

테스트 흐름

아래의 순서대로 로직을 작성했고 동시성제어는 성공적으로 작동했다.

하지만 문제점이 발생했다.

  1. 객체를 생성하여 실제 DB에 저장을 한다
  2. 저장 된 객체를 모두 불러온다.
  3. 동시성 제어 테스트를 위해 동시에 서비스 로직을 호출한다
  4. 입차가 성공한 수와 입차가 실패한 수를 모두 저장한다
  5. 입차 성공수와 입차 실패수가 모두 맞는지 검증한다

1차 문제점

만난 문제점은 다음과 같다

  1. 테스트 코드에 Transectional을 적용하면 롤백이 된다고 알고있었지만 동시성 제어 테스트 후
    동시성 제어를 테스트한 모든 자료가 DB에 저장되어있었다.

문제점을 멘토님께 질의

Q. 예약, 입차 로직에서 동시성 제어를 테스트 해볼 테스트 코드를 작성했습니다.
테스트에서 동시성 제어는 문제없이 테스트가 잘 되었으나 테스트 후 실제 DB에
자료가 입력되었는데 이 방법이 맞는건지 아니면 테스트 방법이 잘못되었는지 궁금합니다.

A. 테스트 코드 실행 시 H2 DB를 사용하여 테스트를 진행하면 실제 DB에 입력이 되지않고

테스트는 가능하니 해당 방향으로 진행해보자

2차 테스트 작성

테스트 DB를 사용하기위해서 테스트 실행시마다 테스트 DB로 작동되는 방법을 찾아봤다.

@ActiveProfiles("test") 어노테이션을 사용하면 아주 간단하게 설정이 가능했다.

application.yml

spring:
  config:
    activate:
      on-profile: "test"

  h2:
    console:
      enabled: true

  jpa:
    show-sql: true
    database: H2
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true
        show_sql: true

  datasource:
    url: jdbc:h2:mem:db
    username: sa
    password:

2차 문제점

H2 DB로 데이터를 호출하니 전에 몰랐던 문제점들이 더 많이 발생했다.

  1. 객체를 생성하여 Save 하였는데 서비스 로직에서 findById를 했을때 결과가 없다.
    1차 흐름에서 내가 생성한 객체를 가져온줄 알았지만 사실 실제 DB의 데이터가 있어서
    호출이 되었던것이다

    ( 테스트로직에선 findById를 했을때 정상적으로 객체를 호출한다 )
  2. 다른 조에서는 테스트 코드 작성 시 롤백이 정상적으로 수행된다고 하였고
    Junit4를 사용했다고 하기에 동일하게 Junit4를 사용해봤지만 적용되지않았다.

해결 시도

  1. save 후 flush로 저장하여 즉시 데이터를 찾아올 수 있도록 시도해봤지만 실패.
  2. @beforeEach 어노테이션을 적용하고 객체를 따로 먼저 생성하고 테스트 메서드를 호출하게 적용해봤지만 실패.
  3. @Transectional이 어떤이유에서든 롤백이 되어 저장이 되었어도 롤백이 되었을까 싶은 생각을 하였고 @Rollback 어노테이션을 적용해봤지만 실패
  4. @Commit 어노테이션은 바로 commit 적용된다고하여 적용해봤지만 실패.

해결

테스트 코드의 @Transactional 을 모두 삭제 후 테스트 코드가 동시성 제어까지 완벽하게

진행되었다..! 사실 테스트가 성공한 이유를 아직도 잘 모르겠고 몇가지 의문점이 있는데

조금 더 생각을 해봐야할것같다

의문점

  1. @Transactional 을 테스트 코드에서 제외하니 동작을 하는가?
    1. 추측 : 서비스 로직의 Transaction과 테스트코드의 Transaction이 별개로 작동을 한다면.
      Transactional을 메서드 전체에 적용을 하면 save가 일어나도 transactional이 종료될때까지 캐시에 저장되어있어 서비스 로직을 호출해도 findById를 했을때 찾을수 없지않을까?
  2. @Transactional 을 제외했는데 더티체킹이 일어날 수 없는데 왜 객체의 update가
    작동이 되는가?
    1. 추측 : 아직 repository에 Save한 상태가 아니기때문에 캐시에 저장되어있어서 update하면 즉시 반영이 된다.

성공한 테스트 코드

@ActiveProfiles("test")
@Slf4j
@SpringBootTest
class BookingServiceTest {

    @Autowired
    private BookingService bookingService;
    @Autowired
    private ParkInfoRepository parkInfoRepository;
    @Autowired
    private ParkOperInfoRepository parkOperInfoRepository;
    @Autowired
    private UserRepository userRepository;
    @Autowired
    private CarRepository carRepository;
    @Autowired
    private MgtService mgtService;

    @Test
    void bookingPark() throws InterruptedException {
        //when
        ParkInfo parkInfo = parkInfoRepository.save(ParkInfo.of("테스트주차장", "테스트주소1", "테스트주소2", "33.2501489768202", "126.563230508718"));
        ParkOperInfo parkOperInfoTmp = ParkOperInfo.of(parkInfo, "민영");
        parkOperInfoTmp.update("00:00", "23:59","00:00", "23:59","00:00", "23:59",30, 1000, 30, 500, 20);
        parkOperInfoRepository.save(parkOperInfoTmp);
        int numOfUsers = 20;
        // 대기하는 스레드의 숫자를 지정
        CountDownLatch endLatch = new CountDownLatch(numOfUsers);
        ExecutorService executorService = Executors.newFixedThreadPool(numOfUsers);
        AtomicInteger successCount = new AtomicInteger();
        AtomicInteger failCount = new AtomicInteger();
        ParkOperInfo parkOperInfo = parkOperInfoRepository.findByParkInfoId(1L).get();
        createUser(numOfUsers);
        ParkSpaceInfo parkSpaceInfo = mgtService.getParkSpaceInfo(parkOperInfo);
        ParkSpaceInfo useSpaceInfo = mgtService.getUseSpaceInfo(parkInfo);
        BookingInfoRequestDto bookingInfoRequestDto = new BookingInfoRequestDto();
        bookingInfoRequestDto.setStartDate(LocalDateTime.now());
        bookingInfoRequestDto.setEndDate(LocalDateTime.now().plusHours(1));

        List<User> userList = new ArrayList<>();
        for (long i = 1; i <= numOfUsers; i++) {
            userList.add(userRepository.findById(i).get());
        }

        //given
        for (User user: userList){
            executorService.execute(() -> {
                try {
                    System.out.println("!--- 6번 ---!");
                    bookingService.bookingPark(parkInfo.getId(),bookingInfoRequestDto,user);
                    successCount.getAndIncrement();
                    log.info("예약 성공");
                    log.info("예약유저 : {}", user.getUserId());
                } catch (CustomException e) {
                    failCount.getAndIncrement();
                    log.info(e.getMessage());
                    log.info("예약 자리가 꽉찼습니다.");
                }
                //스레드가 끝나면 -1 을 해서 endLatch를 1 줄임.
                endLatch.countDown();
            });
        }

        // Then
        log.info("enter 동시성 테스트 결과 검증");
        // 모든 스레드들이 끝날때까지 대기. 모든 스레드들이 끝나면 다음 코드 실행, 즉 endLatch가 0이 되면 다음 코드 실행
        endLatch.await();
        log.info("예약 성공 개수: {}", successCount.get());
        Assertions.assertEquals(parkSpaceInfo.getBookingCarSpace()-useSpaceInfo.getBookingCarSpace(), successCount.get());
        log.info("예약 실패 개수: {}", failCount.get());
        Assertions.assertEquals(numOfUsers-parkSpaceInfo.getBookingCarSpace()-useSpaceInfo.getBookingCarSpace(), failCount.get());
    }

    private void createUser(int count){
        List<User> userList = new ArrayList<>();
        List<Car> carList = new ArrayList<>();
        for (int i = 0; i < count; i++) {
            User user = User.of("user"+i,"1234");
            userList.add(user);
            carList.add(Car.of("11가1"+String.format("%03d", i + 1),user,true ));
        }
        userRepository.saveAll(userList);
        carRepository.saveAll(carList);
    }

}

해결중인 의문점

  1. @Transactional 을 테스트 코드에서 제외하니 동작을 하는가?

코드를 실행하면서 진행되는 로그를 확인하기위해 사이사이 로그를 찍어놓고 확인하던 중

유의미한 차이를 발견했다.

 List<User> userList = new ArrayList<>();
        for (long i = 1; i <= numOfUsers; i++) {
            userList.add(userRepository.findById(i).get());
        }
        System.out.println("!--- 5번 ---!");
        //given
        for (User user: userList){
            executorService.execute(() -> {
                try {
                    bookingService.bookingPark(parkInfo.getId(),bookingInfoRequestDto,user);
                    successCount.getAndIncrement();
                    log.info("예약 성공");
                    log.info("예약유저 : {}", user.getUserId());
                } catch (CustomException e) {
                    failCount.getAndIncrement();
                    log.info(e.getMessage());
                    log.info("예약 자리가 꽉찼습니다.");
                }
                //스레드가 끝나면 -1 을 해서 endLatch를 1 줄임.
                endLatch.countDown();
            });
        }

위의 코드의 로그찍은 위치에 나온 내용이다

  • 트랜잭션을 걸지 않았을때

  • 트랜젝션을 걸었을때

위의 로그에서 확인가능하듯 트랜잭션을 걸지않으면 findById를 했을때 직접 쿼리를 날리게 된다.

하지만 아래의 로그에서는 영속성컨텍스트에서 바로 가져오게 된다.

@Transactional 을 안걸었을때는 로그찍은 위치에서는

이미 객체가 Commit이 되었다는 의미로 생각이 된다.

@Transactional 을 걸었을때는 save로직이 실행 될때까지도 영속성 컨텍스트 안에있는 상태이고

Commit이 진행되지 않았다는것을 의미하는 것으로 생각이 된다

그렇기 때문에 @Transactional 어노테이션을 달았을경우

@Transactional 안에서 생성한 객체는 바로 서비스 로직에 사용할수없다. 라는 결론을 혼자내려봤지만 조금 더 많은 정보를 찾아봐야 할것같다.

빌드 시 에러발생

빌드 시 에러가 발생하여 에러를 확인해본 결과

테스트 코드를 실행하면서 관리자가 아니라는 Exception을 발생시켰다

즉 실제 DB를 조회하여 데이터를 가져온다는것으로 보인다

그렇다면 빌드를 진행할때 dev profile을 읽어온다는 것인데

build를 진행할때도 Test코드에 대해서는 test profile을 가져오는 방법을 찾아보게 되었다.

시도

  1. 빌드 할때도 테스트 profile을 참조하도록 build.gradle에 추가해봤지만 실패.
test {
    systemProperty 'spring.profiles.active', 'test'
}
bootRun {
    systemProperty "spring.profiles.active", "test"
}
  1. @SpringBootTest(properties = "spring.profiles.active:test")
    SpringBootTest 어노테이션을 불러올때 적용시켜봤지만 실패.
  2. @AutoConfigureTestDatabase(connection = EmbeddedDatabaseConnection.H2)
    어노테이션을 적용하고 테스트 해봤지만 실패.

실마리 발견

위의 방법을 여러방면으로 시도해봐도 해결 될 기미가 보이지않아

서비스 로직에 로그를 찍기 시작했는데 이상한 부분이 보였다.

분명 H2 DB를 생성했고 User의 ID는 1번부터 시작해야되는데

무슨 문제인지 2번부터 시작한다 즉 ParkInfo가 2번이 생성되었다는 이야기가 되고

코드에서 2번 주차장을 찾게 변경을 했는데 성공적으로 빌드가 완료되었다

해결

  • 빌드를 진행하면서 MgtServiceTest보다 먼저 진행이되는 BookingServiceTest에서 주차장 정보를 생성한다 하지만 코드에서 @Transectional을 사용하지 않기때문에 Rollback이 일어나지 않고 다음 테스트에 영향을 미치게 된것이다.
  • 기존에 1L로 하드코딩 되어있던 ParkInfoId를 변수에 담아 ParkInfoId가 다른 숫자가 되어도 테스트가 가능하게 변경한 뒤로 정상적으로 빌드가 되었다!
  • 테스트가 서로에게 영향을 미칠수 있다는점과 하드코딩에서 나올수 있는 문제를 알게되었다.
profile
기록을 남깁니다

0개의 댓글