저번 주에 낙관적 락에 대한 테스트 코드 작성을 겨우 끝냈는데, 이제는 비관적 락이다.
하... 낙관적 락으로 끝내도 될 거 같지만, 자꾸만 이유를 가져다 대라는 팀장님의 말,말,말... 나는 왜 이걸 지금 이렇게 하고 있는가... 필요 없지 않는가...?
결국 돌골돌아서 무엇이 맞는가 여러 사람들에게 의견을 묻고 다니다가, 결론을 냈다.
결론은 이거였다. 내가 안 써보고 어떻게 말을 해?????
아무리 실무 경험자들(튜터님들)이 있다고 하고 그 분들이 비관적 락이 조회수 동시성 제어에 적용될 때 비효율적이라 말한다 한들 그것이 내 경험이 될 수는 없는 것이었다. 그로 인해서 나는 비관적 락과 락이 없는 부분까지 테스트를 써보기로 결심했다.
저번에 코드 작성할 때 나는 정말 큰 실수를 했다. 바로, 진짜 문제가 발생하는 지 확인도 안 하고 문제가 발생할거야~ 라는 추론을 한 것이다.
그래서 오늘은 진짜 문제가 발생하는지 여부를 체크해보기로 했다. 저번에 했던 낙관적 락 테스트를 조금 고치고 버전 필드를 주석처리해 준 뒤에, 마지막에 assertThat을 사용하여 초기요청값과 예상 결괏값이 같으면 테스트가 성공하도록 생성해봤다.
뭐 예상대로 테스트는 실패했다. 이거는 뭐, 에러가 나면 성공하도록 바꿔야 할 수준이긴 했다.
근데 이거 보니 낙관적 락도 이정도였던 거 같은데... 여기저기 들어보니 낙관적 락이 거의 락이라고 보기 힘들다고 한 이유를 알 거 같다. 낙관적 락은 걸었을 때나 안 걸었을 때나 정합성이 정말 떨어진다는 걸 실감할 수 있었다.
비관적 락을 설정하려면 먼저 Entity에 원래 설정해놨던 낙관적 락을 위한 version을 주석하거나 지워주고, 대신 Repository 단에서 쓰던 findById 대신에 update라는 메서드를 JPQL 형태로 생성해줘야 한다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select b from JobOpening b where b.id = :id")
Optional<JobOpening> findByForUpdateViewCount(Long id);
이러한 형태로 작성해주면 된다.
왜냐하면 락을 건다는 것은 단순히 조회해서 count 값을 올리는 것으로 끝나는 것이 아니고, count 값을 증가시키는 도중에 다른 이가 접근하는 걸 막는 기능을 추가하는 것이기 때문에 값을 증가시키는 메서드를 따로 지정해줘서 거기에 락을 걸어야 하기 때문이다.
기본으로 되어있는 findById에 락을 걸어버리면 Count증가 시키는 것 하나 때문에 너도나도 멈춰버리기 때문에 큰일이 날 수 있다.
어라..? 근데 이 부분도 추가해주고 서비스 단에서 수정을 마치고 Postman으로 확인을 해보니 500 에러가 발생했다.
이게 무슨 일이야..?? 당황스러워서 콘솔 창을 봤더니 또 대환장 상태였다.
음..? 이게 뭐지...채용공고 서비스에 대해 30번째 줄이면 내가 딱 고친 메서드의 이름이었다. 뭔지 이유를 몰라서 찾아보니 아, 추측되는 문제가 @Transactional을 추가하는 문제였다. 그동안에는 데이터를 조회해서 업데이트 하는 것임에도 불구하고 따로 트랜잭션을 쓰질 않았는데, 이번에는 락을 걸어야 하기 때문에 이걸 써야하는 모양이다.
트랜잭션은 DB의 상태를 변환시키는 작업이 있을 때 한꺼번에 모두 수행되어야 할 일련의 연산과정들의 집합이다. 근데 이게 왜 저번에는 없어도 동작이 되었던걸까?
알고보니 위에서 사용한 @Lock(LockModeType.PESSIMISTIC_WRITE) 이게 트랜잭션이 동작하는 범위 내에서 발생하는 어노테이션이라고 한다.
<비관적 락 동작 방식>
그러니 애초에 비관적 락을 사용하려면 트랜잭션 어노테이션도 사용해 줘야하는 것이다.
@Transactional을 추가해주면 더미데이터가 없는 페이지라서 404가 뜨긴 하는데, postman의 json 출력 내용과 인텔리제이 콘솔 쪽을 보면 그래도 호출이 되고 최종 리다이렉트가 제대로 된 걸 확인할 수 있었다.
그리고 찾다보니 어떤 사람이 curl을 사용해서 동시성 제어를 체크해보는 게 있길래 나도 그걸 한 번 적용해보려다가... 대차게 망했다.
뭔가 잘못한 거 같은데, 일단 curl을 사용하는 건 나중으로 미뤄두고, 이런 방법도 있구나~ 하고 넘어가기로 했다. 솔직히 정신건강을 위해서 curl이 아니라 테스트 코드라던가 테스트 코드라던가 테스트 코드를 해야할 거 같다.
일단 부팀장님이 저번에 주신 csv 파일을 적용해서 더미데이터를 추가하려고 했는데 이 파일, Localdatetime을 쓰고 있는 파일이다...
분명히 ZonedDateTime인가 그걸로 바꾼 거 같은데.. 어떡하지!?
일단 에러가 나므로 너무 아쉽지만 해당 더미는 다시 삭제하고 저번에 쓰던 테스트 코드 더미로 다시 해보기로 했다.
솔직히 비관락도 다른 락 검증이랑 로직이 별로 다를 게 없어서 그냥 아까 만든 assertThat 추가된 테스트 코드로 검증해봤다.
확실히 비관적 락이 정합성은 최고라는 생각이 들었다. 근데 아무래도 이것만으로는 단순 비교는 될 수 없기 때문에 비교를 위한 테스트 코드를 만들기로 했다. 일단 낙관락이랑 비관락을 동시에 걸어두고 비교가 가능한건지 여부부터 확인해봐야지...
테스트 코드를 작성하기 전, 일단 성능을 비교하는 것에는 무엇이 필요한가를 생각해봤다.
1. 처리 속도
2. 처리 정확성(정합성)
3. 데드락 가능성
이정도가 큰 이슈가 될 거 같다.
그리하여 락 없는 테스트 코드를 아래와 같이 수정했다.
길이가 길어져서 앞부분 정의하는 것들은 주석으로 적어놨다.
@Test
@DisplayName("채용공고를 동시에 클릭했을 때")
void 채용공고_동시성_문제_테스트 () throws InterruptedException {
//given
/**
* 채용공고 id 가져와서 Long으로 저장, 시간 비교를 위해 정확할 수 있게 nanoTime으로 시간정의
* 원자성을 보장하는 AtomicInteger로 시작 조회수를 0으로 지정
* 총 요청과 총 스레드 지정
**/
CountDownLatch latch = new CountDownLatch(totalRequests);
ExecutorService executorService = Executors.newFixedThreadPool(totalThreads);
//when
for(int i=0;i<totalRequests;i++) {
executorService.submit(() -> {
try {
jobOpeningService.getJobOpeningUrlAndIncreaseViewCount(jobOpeningId);
successCount.incrementAndGet();
} catch (Exception e) {
log.info("예외발생", e);
} finally {
//작업 종료 시 기다리는 스레드를 줄어들게 함.
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
long duration = System.nanoTime() - startTime;
//then
JobOpening updateViewCount = jobOpeningRepository.findById(jobOpeningId)
.orElseThrow(() -> new RuntimeException("채용공고 데이터가 없습니다."));
//소요시간, 요청 수, 성공 수 출력 로그
}
이렇게 수정된 테스트 코드로 아무것도 쓰지 않은 락을 돌려보면 소요시간은 600~1000으로 다 다르게 나오는데, 완료 요청 수는 9~11 사이로 정말 처참한 정합성을 보이는 걸 알 수 있었다.
이어서 낙관적 락 테스트를 하려고 버전 필드를 작성하고, 그랬는데 왜지..
왜 이런 새빨간 저세상 에러가 발생했을까..?
알쏭달쏭하다. 아무래도 충돌 발생시 낙관적락에 관련된 exception이 발생하도록 처리해서 그런 듯 하다. 이쯤 되면 저번 주에 작성한 코드가 너무 느긋한 코드였던 게 아닌 가 하는 생각도 든다.
정말 바보 같은 실수인데 버전 필드를 확인해보니 전부 null이 되어있었다..ㅋㅋㅋㅋㅋ
버전필드 만들고 -> 더미데이터 재실행을 해야하는데 안 했던 게 문제였다.
그래서 다시 했는데 이번에는 낙관적 락 충돌이 0이라는 결과가 나왔다.
여러 번 해도 계속 충돌은 0이고, ~~내 머리는 어디에 충돌이 난 거 같은 ~~ 멘붕상태가 되었다.
지금 코드가 실행 > 낙관락 예외 > 그냥 예외 이렇게 돌아가는데, 이게 왜 이러지???
찾아보니 낙관적 락은 발생하고 있으나, 그냥 예외로 들어가는 모양이다. 이걸 어떻게 찾았냐하면, 되게 무식하고 없어보이는 방법이지만 papago를 사용했다.
인간적으로 영어공부를 할 필요성이 시급하다. 영어공부해라 휴먼
아하, 그렇군요. 결국 제대로 된 낙관적 락이 발생한다는 소리인데...... failedCount는 왜 안 늘어나는가? 바로, 낙관적 락을 재시도 하는 로직이 없어서 그렇다고 한다. 그리고 lock을 사용할 때 JPQL을 사용하여 특정 컬럼만 체크하는 방법이 있다는 걸 알았다. 낙관적 락은 기본적으로 Version 필드만 있으면 동작한다지만 매번 모든 필드를 가지고 하면 효율성이 떨어질 것 같아서 이번에는 JPQL로 따로 낙관적 락을 설정해주기로 했다.
@Modifying
@Query("UPDATE JobOpening b SET b.viewCount = b.viewCount + 1, b.version = b.version + 1 WHERE b.id = :id AND b.version = :version")
int updateViewCountWithOptimisticLock(@Param("id") Long id, @Param("version") Long version);
이런 식으로 update set을 사용하여 직접 db에 viewCount와 version 값을 올려주고, 이 때 version필드가 변했다면 예외처리가 될 수 있도록 조건절에 version이 동일한지 체크를 해 준다.
어...? 그런데, 이상하다.... 이렇게 처리했더니... 낙관적 락 재시도까지 3회 추가해서 처리했는데 테스트 코드가 너무 정합성이 높아져버렸다.
설마 그동안에는 낙관적 락을 전체에 걸어서 이렇게 정합성이 낮았던 거였나???????
일단 혹시 몰라서 요청이랑 스레드랑 엄청 늘려봤다. 근데도 충돌이 안난다..
도대체 이거 충돌 나는지 확인을 해야지 락이 걸린건지 아닌지 알 거 아니냐고... 그래서 이것저것 추가해봤다. 중간에 버전이 늘어나는 것도 확인해보고 log도 이것저것 추가해 본 결과, 여전히 충돌이 안 난다고 나온다.
아마 이거는 최종 실패 수만 카운팅하는 거라서 이런 게 아닐까? 하는 추측이 든다.
그리하여 결국 튜터님에게 자문을 구해보았더니, 어머나.... 재시도 예외처리 로직에 문제가 있었다.ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ
원인은 재시도 해놓고 예외 발생하면 throws 하는게 아니라 그냥 log만 나오게 하고 있었던 거였다. 바보인가
그리고 로직 중에 요청 실패 로직도 문제가 있다는 걸 발견했다. 이거는 내가 처음에 Version만 사용하려고 했다가 나중에 JPQL을 추가하는 바람에 생긴 문제인 걸로 판명되었다. OptimisticLockException은 @Version을 사용하면 자동작동하지만, JPQL을 사용하면 작동을 안 한다고 한다.
드디어 만족스런(?) 충돌이 발생했다. 후후
평균값을 비교하기 위해 100개로 내려서 다시 테스트 해 보았다.
소요시간이 1500~800사이로 락 없을 때보다는 조금 걸리는 편이긴 했다.
비관적 락 테스트는 아까 해봤기 때문에 정말 얼마 안 걸려서 끝났다.
비관적 락은 100이면 100 다 성공했고, 시간은 2100~1500대 사이로 확실히 오래 걸리는 편이었다. 2100이면 락이 없을 때보다 2배 넘게 속도가 걸린다는 건데...
실제로 사용하는 유저 입장에서 본다면 너무 느리다는 생각이 들 거 같다.
일단 정합률은 비관적 락의 압승이다.
그리고 속도는... 비관적 락이 완패인 것 같다.
이러한 중간이 없는 비관적 락을 쓰는 게 나은 것인가, 아니면 낙관적인 마인드로 낙관적 락을 쓰는 것이 맞는가?
일단 다 해보고 낸 결론은 낙관적 락을 쓰자! 이다. 왜냐하면 조회 수에서 만약 비관적 락을 걸게 된다면 조회수 하나를 업데이트 하기 위해 다른 일들이 멈출 수 있기 때문이다. 일단 우리 프로젝트인 채용공고 리다이렉트만 예를 들어봐도 다음과 같은 추론이 가능하다.
만일 내가 리다이렉팅 하는 채용공고에 대한 DB가 viewCount 하나를 조정하기 위해 비관적 락에 걸렸다고 치자, 누군가 조회를 눌렀을 때는 락 때문에 변경이 불가한 상태인 것이다.
근데 채용정보가 중요하게 바뀔 일이 생길 수도 있는데 인기순위를 매기는 조회수 하나를 정확하게 집계하기 위하여 채용공고에서 변경되어야 하는 내요을 이용자가 제 때 못 보게 된다면 그건 로직의 우선순위가 잘못된 것이라고 생각한다.
물론 이 부분도 조회를 하는 동시에가 아니라 조회를 하고 나중에 update를 처리해주면 완화될 수 있는 부분일 수 있다. 몇 번을 말하지만 락에 정답은 없기 때문에 사용처에 적절하게 써주면 되는 것이다.
나중에 Redis를 추가하게 된다면 또 다른 공부와 또 다른 결론이 나오겠지만 현재의 단계에서는 낙관적 락만 적용하는 것이 맞는 것 같다는 생각이 든다.
<출처>
https://k3068.tistory.com/92
https://reiphiel.tistory.com/entry/understanding-jpa-lock
https://velog.io/@jkijki12/동시성-이슈-동시성-이슈-해결해보기
https://inpa.tistory.com/entry/LINUX-📚-CURL-명령어-사용법-다양한-예제로-정리
https://fireheal.tistory.com/80