저번 주에 동시성 코드를 작성하려 했는데 마침 엘라스틱 서치를 하느라 놓치는 바람에 오늘 그걸 다시 하기로 했다. 동시성 코드는 참 알 수가 없다.
테스트 코드에서 일단 제일 어렵고 제일 중요한 부분부터 작성을 시작해보기로 했다. 주말에 조금 짬을 내서 해봤는데 계속 에러가 떠서 접어놨었다. 그래서 다시 오늘 고치고 또 시도해봤으나 여전히 에러가 뜬다.
일단 앞 부분 에러코드가 왜 발생했는지 알아봤다. 찾아보니 업데이트 되는 값을 트랜잭션으로 관리해야 하는데 해당 어노테이션이 누락되면 생기는 에러라고 했다.
근데 이상하다? 내가 쓰는 건 해당 어노테이션이 누락되지 않았는데..???
/**
* 채용공고 리다이렉트 동시성 제어를 위해 비관적 락이 적용된 조회수 카운팅 메서드 입니다.
* viewCount 정보를 관리하는 집계 테이블에서 조회수가 카운팅됩니다.
* viewcount 테이블에서 비관 락이 작동하여 count 값의 정합성을 유지합니다.
* @param id 채용공고 식별 id
*/
@Transactional
public void increaseViewCount(Long id) {
JobOpeningViewCount viewCount = jobOpeningViewCountRepository.findWithLockByJobOpeningId(id)
.orElseGet(() -> {
JobOpening jobOpening = jobOpeningFindByService.findById(id);
return jobOpeningViewCountRepository.save(JobOpeningViewCount.create(jobOpening));
});
viewCount.increaseViewCount();
}
알 수 없다 이거 뭐지..? 찾아보니 Transactional(propagation = Propagation.REQUIRES_NEW)
이걸 쓰랜다. 근데 얘를 검색을 해보니까 오히려 얘를 쓰면 강제적으로 트랜잭션을 새로 만드는 거라서 트랜잭션이 중첩되거나 데드락의 발생가능성이 올라간다는 얘길 들었다.
일단 튜터님께 쫄래쫄래 가본 결과, 내가 생각했던 부분에서 터진 게 아니었다. ㅋㅋㅋㅋㅋ 에러코드 잘 살펴볼걸.... 나는 테스트 코드가 돌아가는 부분에서 반복되다가 에러가 터진 줄 알았는데 알고보니 마지막 부분에서 카운팅 된 컬럼값을 단순 조회를 한 후에 결괏값을 비교해야 하는데, 이 부분에서 내가 무려 비관적 락이 걸린 조회 메서드를 사용해서 문제가 발생한 거였다.
메서드 분리를 하고 직관적인 이름으로 바꾼다고 이것저것 만지고 분리하다보니 놓친 부분이었다. 그래서 레포지토리 단에서 viewCount 관련 조회를 사용하기 위해 락 없는 메서드를 작성해주었다.
/**
* 특정 JobOpening ID에 해당하는 ViewCount 엔티티를 조회하는 메서드입니다.
* 비관적 락을 적용하지 않은 단순 조회 메서드
* @param id 조회를 위한 jobOpening의 ID
* @return 락 없이 일반적으로 조회된 JobOpeningViewCount 엔티티(Optional 형태)
*/
@Query("SELECT b FROM JobOpeningViewCount b WHERE b.jobOpening.id = :id")
Optional<JobOpeningViewCount> findByJobOpeningId(@Param("id") Long id);
이런 느낌으로다가 ㅋㅋㅋㅋ 위에 있는 비관락만 쏙 빼고, 이름도 Lock만 빼버렸다.
그리하여 최종 뻘짓을 해결하니 간단하게 테스트 코드가 통과되는 걸 알 수 있었다.
속도는 확실히 락 걸기 전이랑 별 차이가 없는 그런 속도라는 걸 알 수 있었다.
어, 근데 내가 실수로... 단위 테스트 설정이 아닌 통합테스트 설정을 해버린걸 이제야 눈치 채버렸다. (멍청이)
그리고 이 이유로 인해 테스트 코드를 다시 면밀히 공부하는 걸로 다시 포커스를 잡기로 했다. 이 부분은 일단 보류, 테스트 코드 관련 게시글 작성 후에 이어서 작업해봐야겠다.
뒷 게시글에 테스트 코드에 대한 정리를 끝내고, 이 게시물에 이어서 단위 테스트를 작성해보기로 했다.
현재 내 스케줄러 코드는 아래와 같다.
근데 이건 자동 동기화 코드인데, 도대체 뭐 어떻게 하란 말인가?
@Scheduled(fixedRate = 60000) //미완성이라 테스트용으로 짧게
@Transactional
public void syncViewCounts() {
// jobOpeningViewCount 테이블에서 viewCount값이 1이상인 jobOpening.id 목록 가져오기
List<Long> jobOpeningIds = jobOpeningViewCountRepository.findViewedJobOpeningIds();
// 각 jobOpening.id별로 viewCount 조회 및 업데이트
for (Long jobOpeningId : jobOpeningIds) {
Long viewCount = jobOpeningViewCountRepository.getViewCountByJobOpeningId(jobOpeningId);
if (viewCount != null && viewCount > 0) {
jobOpeningRepository.updateViewCount(jobOpeningId, viewCount); jobOpeningViewCountRepository.resetViewCountByJobOpeningId(jobOpeningId);
}
}
log.info("조회수 동기화 완료");
}
근데 뭐 이거 관련 글들을 여러 번 찾아봤는데 하나같이 통합테스트 얘길 한다. 하지만 나는 뭔가 이거 단위 테스트로도 분명 가능할 거 같다는 생각이 들어서 차근차근 하나씩 값을 넣고 있었다.
그리고 syncViewCounts()
를 실행시켜주면 이게 테스트가 되질 않을까? 하는 생각이 들어서 그렇게 작성해보았다.
@Test
void 집계테이블_조회수를_채용공고에_동기화 () throws InterruptedException {
//given
Long id = 1L;
JobOpening mockJobOpening = mock(JobOpening.class);
JobOpeningViewCount mockJobOpeningViewCount = mock(JobOpeningViewCount.class);
when(mockJobOpening.getId()).thenReturn(id);
when(mockJobOpeningViewCount.getJobOpening()).thenReturn(mockJobOpening);
when(mockJobOpeningViewCount.getViewCount()).thenReturn(10);
//when
jobOpeningViewCountScheduler.syncViewCounts();
//then
JobOpening jobOpening = jobOpeningRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("테스트에 사용할 채용공고 테이블이 존재하지 않습니다."));
assertThat(jobOpening).isEqualTo(10);
}
이러면 뭔가 될 거 같았는데 안된다.
테이블을 찾을 수 없다고 해서 이리저리 찾아본 결과 내가 Mock객체까지는 잘 썼는데, 조회할 때 findBy 어쩌구 하면서 실제 레포지토리를 조회하는 바람에 어긋난 것으로 보인다.
그리고 단순한 값 검증만 하는 assertThat
말고 메서드 호출(동작) 여부와 메서드로 전달된 viewCount 값까지 검증하는 verify()
를 썼다.
근데 또 이러는 거 봐서는 아무래도 처음에 given 부분에서 문제가 있었던걸까?
어디가 에러인 지 몰라서 찾다찾다가 보니 JobOpening mockJobOpening = mock(JobOpening.class);
이게 @Mock과 동일한 역할이라 이 부분을 위에 @Mock으로 다 빼버리고, 대신 when(jobOpeningViewCountRepository.findViewedJobOpeningIds()).thenReturn(List.of(id));
이런 식으로 실제 메서드를 호출해주었더니 문제가 해결되었다. 훨씬 깔끔, 간결하면서도 결과도 성공적이다.
when(jobOpeningViewCountRepository.findViewedJobOpeningIds()).thenReturn(List.of(id));
when(jobOpeningViewCountRepository.getViewCountByJobOpeningId(id)).thenReturn(viewCount);
이런 식으로 Mock 객체의 값을 할당해주고 스케줄러 메서드를 호출하는 방식으로 강제로 스케줄러를 돌린 뒤에, verify()
로 verify(mock객체).메서드(인자);
형식으로 검증을 해 주는 것이다. 나는 단순히 호출이 되었는지, 그리고 값이 업데이트 되었는지 보는 것이었기 때문에 times(1).updateViewCount(id, viewCount);
이런 식으로 사용했으나, times 뒤의 ()값이 바뀌면 또 다른 형식으로도 검증이 가능하다고 한다.
사실 이 코드를 짜면서 몇날 며칠을 짜면서 나는 정말 테스트 코드를 짜기 힘들다는 생각에 몰입되어 있었다.
그 탓에 유튜브 영상이라도 도움을 받아보고자 코딩에 관련된 영상을 찾아봤는데 막상 코딩하는 영상이 아닌 코딩이 지칠 때 이 영상을 보세요이라는 영상을 발견했다.
근데 웃긴 건 이 영상을 보고 나니까 뭔가 테스트 코드도 할 수 있을 거 같고, 해야할 거 같아서 스스로 못 한다, 어렵다라는 생각을 접어두고 차근차근 해보니까 생각보다 금방 해결되어버렸다. 어렵다고 계속 외치기보다는 하나씩 옛날처럼, 처음 코딩 배웠을 때처럼 돌아가보려고 노력해야겠다.
아까 낮에 테스트 코드에 대한 공부를 제대로 다시 하고 오니까 저번 주인가 만들었던 조회 테스트 코드가 손볼 곳이 많아 보였다.
@Test
void 채용공고_단건_조회성공() {
// given
Long jobId = 1L;
JobOpening mockJobOpening = mock(JobOpening.class);
when(mockJobOpening.getTitle()).thenReturn("S전자 신입공채 모집");
when(jobOpeningRepository.findById(jobId)).thenReturn(Optional.of(mockJobOpening));
// when
JobOpening result = jobOpeningFindByService.findById(jobId);
// then
assertNotNull(result);
assertEquals("S전자 신입공채 모집", result.getTitle());
verify(jobOpeningRepository, times(1)).findById(jobId);
}
아무리 봐도 @mock 어노테이션 안 쓰고 JobOpening을 mock으로 설정해둔 것과 assertThat을 안 쓴게 신경이 쓰인다.
@Test
void 채용공고_단건_조회성공() {
// given
Long jobId = 1L;
when(jobOpening.getTitle()).thenReturn("S전자 신입공채 모집");
when(jobOpeningRepository.findById(jobId)).thenReturn(Optional.of(jobOpening));
// when
JobOpening result = jobOpeningFindByService.findById(jobId);
// then
assertThat(result.getTitle()).isNotNull().isEqualTo("S전자 신입공채 모집");
verify(jobOpeningRepository, times(1)).findById(jobId);
}
그래서 이런 형태로 조금 더 깔끔하게 고쳐줬다. 따로 적진 않았으나 JobOpening은 어노테이션 형태로 변경해서 작성했다.
열심히 단위테스트를 졸아가며 만드는데 자꾸만 when에서 에러가 발생했다.
진짜 어이없지만, import 문제였다.
Mockito 관련 When을 import 해야하는데 엉뚱한 게 import 되어있었다.
그래서 겨우 빨간 줄을 없애고 실행을 시켜봤으나, 여전히 이상한 에러가 발생한다.
이래저래 해서 통합테스트를 참고해서 단위테스트 만들었고, 코드에 이것저것 추가하다보니 길어졌다.
@Test
@DisplayName("비관적 락 viewCount 동시성 제어 단위 테스트")
void 집계테이블_viewCount_동시성_제어_단위테스트() throws InterruptedException {
//given
Long id = 1L;
int totalRequests = 100;
int totalThreads = 10;
//비관적 락을 사용하여 조회하는 메서드가 호출될 때 Mock 객체 반환
when(jobOpeningViewCountRepository.findWithLockByJobOpeningId(id))
.thenReturn(Optional.of(jobOpeningViewCount));
//일반 조회 메서드 호출 시 동일한 Mock 객체 반환
when(jobOpeningViewCountRepository.findByJobOpeningId(id))
.thenReturn(Optional.of(jobOpeningViewCount));
/*
viewCount 값이 처음에는 0, 이후에는 100을 반환하도록 설정
처음은 0이고, 최종 ViewCount 엔티티 조회 때 100으로 조회되도록 설정해둔 것
*/
when(jobOpeningViewCount.getViewCount())
.thenReturn(0).thenReturn(100);
// 성능 측정을 위한 시작 시간 기록
long startTime = System.nanoTime();
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failureCount = new AtomicInteger(0);
CountDownLatch latch = new CountDownLatch(totalRequests);
ExecutorService executorService = Executors.newFixedThreadPool(totalThreads);
//when
for (int i = 0; i < totalRequests; i++) {
executorService.submit(() -> {
try {
jobOpeningService.increaseViewCount(id);
successCount.incrementAndGet();
} catch (Exception e) {
failureCount.incrementAndGet();
log.error("예외 발생: ", e);
} finally {
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
long duration = System.nanoTime() - startTime;
// Mock 객체를 사용하여 최종 ViewCount 엔티티 조회
JobOpeningViewCount finalViewCount = jobOpeningViewCountRepository.findByJobOpeningId(id)
.orElseThrow(() -> new IllegalArgumentException("테스트에 사용할 집계테이블이 존재하지 않습니다."));
log.info("결과 로그 출력 (생략)");
//then
// findWithLockByJobOpeningId 메서드가 정확히 totalRequests만큼 호출되었는지 검증
verify(jobOpeningViewCountRepository, times(totalRequests))
.findWithLockByJobOpeningId(id);
// viewCount 증가 메서드가 기대한 만큼 호출되었는지 검증
verify(jobOpeningViewCount, times(totalRequests)).increaseViewCount();
// 최종 viewCount 값이 기대 값과 일치하는지 검증
assertThat(finalViewCount.getViewCount()).isEqualTo(totalRequests);
}
동시성 제어도 단위테스트로 하니까 어마무시하게 빨라져서 당황스러웠다. 차이가 정말 극명하다는 생각이 들었다. 그리고 일단 오늘은 어찌어찌 성공했으니, 또 내일을 기약해보기로 했다.
<출처>
https://sunba30.tistory.com/21
https://woonys.tistory.com/238
https://xxeol.tistory.com/55
https://xpmxf4.tistory.com/85
https://mangkyu.tistory.com/
https://jojoldu.tistory.com/455
https://effortguy.tistory.com/144