동시성 제어 테스트를 위해 테스트 코드작성을 하려고 계획을 했고 가장 처음 만난 문제는
동시성 제어는 단위테스트로 작성해야 하는가 통합테스트로 작성해야 하는가? 에 고민에 빠졌고
동시성 제어는 멀티스레드 환경에서 테스트를 진행해야하고 단위테스트는 각 메서드나
함수 단위로 테스트를 해야하기때문에 통합 테스트로 진행하기로 했다.
아래의 순서대로 로직을 작성했고 동시성제어는 성공적으로 작동했다.
하지만 문제점이 발생했다.
만난 문제점은 다음과 같다
Q. 예약, 입차 로직에서 동시성 제어를 테스트 해볼 테스트 코드를 작성했습니다.
테스트에서 동시성 제어는 문제없이 테스트가 잘 되었으나 테스트 후 실제 DB에
자료가 입력되었는데 이 방법이 맞는건지 아니면 테스트 방법이 잘못되었는지 궁금합니다.
A. 테스트 코드 실행 시 H2 DB를 사용하여 테스트를 진행하면 실제 DB에 입력이 되지않고
테스트는 가능하니 해당 방향으로 진행해보자
테스트 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:
H2 DB로 데이터를 호출하니 전에 몰랐던 문제점들이 더 많이 발생했다.
@beforeEach
어노테이션을 적용하고 객체를 따로 먼저 생성하고 테스트 메서드를 호출하게 적용해봤지만 실패.@Transectional
이 어떤이유에서든 롤백이 되어 저장이 되었어도 롤백이 되었을까 싶은 생각을 하였고 @Rollback
어노테이션을 적용해봤지만 실패@Commit
어노테이션은 바로 commit 적용된다고하여 적용해봤지만 실패.테스트 코드의 @Transactional
을 모두 삭제 후 테스트 코드가 동시성 제어까지 완벽하게
진행되었다..! 사실 테스트가 성공한 이유를 아직도 잘 모르겠고 몇가지 의문점이 있는데
조금 더 생각을 해봐야할것같다
@Transactional
을 테스트 코드에서 제외하니 동작을 하는가?@Transactional
을 제외했는데 더티체킹이 일어날 수 없는데 왜 객체의 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);
}
}
@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을 가져오는 방법을 찾아보게 되었다.
test {
systemProperty 'spring.profiles.active', 'test'
}
bootRun {
systemProperty "spring.profiles.active", "test"
}
@SpringBootTest(properties = "spring.profiles.active:test")
@AutoConfigureTestDatabase(connection = EmbeddedDatabaseConnection.H2)
위의 방법을 여러방면으로 시도해봐도 해결 될 기미가 보이지않아
서비스 로직에 로그를 찍기 시작했는데 이상한 부분이 보였다.
분명 H2 DB를 생성했고 User의 ID는 1번부터 시작해야되는데
무슨 문제인지 2번부터 시작한다 즉 ParkInfo가 2번이 생성되었다는 이야기가 되고
코드에서 2번 주차장을 찾게 변경을 했는데 성공적으로 빌드가 완료되었다
@Transectional
을 사용하지 않기때문에 Rollback이 일어나지 않고 다음 테스트에 영향을 미치게 된것이다.