
익명 게시판 api 를 구현하던 중 게시글 좋아요 누르기 요청을 처리하게 되었다.
@Transactional
@Override
public PostResDto addPostLike(Long id) {
PostResDto post = postRepository.findPostByIdOrElseThrow(id);
int updatedRow = postRepository.addPostLike(id, post.getLikes() + 1);
if (updatedRow > 0) {
return postRepository.findPostByIdOrElseThrow(id);
} else {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "something went wrong");
}
}
post DB 에서 해당 id 레코드를 가져와 기존 likes + 1 로 update 를 진행하는 방법으로 구현을 했다.
여기서 다음과 같은 문제가 발생한다
postRepository.findPostMyIdOrElseThrow(id) 와 postRepository.addPostLike(id, post.getLikes()) 사이에 여러 요청이 동시에 발생한다면 조회된 likes + 1 을 업데이트 처리가 끝나기 전 들어온 요청에 대해 같은 likes 를 참조해 요청의 횟수만큼 좋아요가 증가하지 않을 수 있다.
이를 검증하기 위해 두 가지 방법으로 테스트 코드를 작성하였다.
다수의 쓰레드를 만들어 동시에 addPostLike()를 호출한다.
public void 게시글좋아요_동시성테스트_Thread() throws InterruptedException {
// 새로운 게시글 생성
CreatePostReqDto newPost = new CreatePostReqDto("0000", "test", "testContents");
PostResDto post = postService.createPost(newPost);
int threadCount = 100;
List<Thread> threads = new ArrayList<>();
// 100개의 쓰레드를 생성하고 각 쓰레드는 동시에 addPostLike()를 호출
for (int i = 0; i < threadCount; i++) {
Thread thread = new Thread(() -> postService.addPostLike(post.getId()));
threads.add(thread);
thread.start();
}
// 모든 쓰레드가 완료될 때까지 대기
for (Thread thread : threads) {
thread.join();
}
// 기대하는 값은 좋아요가 100 증가한 값
PostResDto updatedPost = postService.findPostById(post.getId());
assertThat(updatedPost.getLikes()).isEqualTo(100);
}
이렇게 테스트를 진행했지만 좋아요 수의 문제가 아닌 404 NOT_FOUND 에러가 발생하였다.
이에 다음과 같은 가설을 세웠다.
createPost 를 호출하여 post 를 생성할 때 Thread 를 생성하여 addPostLike 요청을 수행하기 전 까지 post 가 생성되지 않아 NOT_FOUND 에러가 발생하였다.
createPost 가 완료될 때 까지 addPostLike 요청이 시작되지 않도록 CountDownLatch 를 사용하여 쓰레드를 생성하기 전 까지 락을 걸어 대기시키는 방법을 사용하기로 했다.
public void 게시글좋아요_동시성테스트_Thread() throws InterruptedException {
CreatePostReqDto newPost = new CreatePostReqDto("0000", "test", "testContents");
PostResDto post = postService.createPost(newPost);
int threadCount = 100;
List<Thread> threads = new ArrayList<>();
CountDownLatch latch = new CountDownLatch(1); // 모든 쓰레드를 대기시키는 락
// 각 쓰레드가 addPostLike 메서드를 호출할 준비를 하고 대기 상태로 설정
for (int i = 0; i < threadCount; i++) {
Thread thread = new Thread(() -> {
try {
latch.await(); // latch가 0이 될 때까지 대기
postService.addPostLike(post.getId()); // addPostLike 호출
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
threads.add(thread);
thread.start();
}
// 모든 쓰레드가 준비될 때까지 대기한 후, createPost가 완료된 시점에 latch 카운트를 0으로 설정
latch.countDown();
// 모든 쓰레드가 실행을 완료할 때까지 대기
for (Thread thread : threads) {
thread.join();
}
PostResDto updatedPost = postService.findPostById(post.getId());
assertThat(updatedPost.getLikes()).isEqualTo(100);
}
위 방법을 사용하여 테스트를 진행했다.
그러나 그대로 404 에러가 발생하였다. 이유를 찾아보니 다음과 같은 원인을 알아냈다.
@Transactional이 적용된 경우, 트랜잭션이 커밋되기 전에 쓰레드가 비동기로 addPostLike 를 호출하려 시도하면 다른 쓰레드가 createPost 의 트랜잭션에 접근하지 못해 404 에러가 발생할 수 있다.
@Transactional 을 제거하고 테스트를 돌려보았다.

likes 값으로 100을 예상했지만 11이 나왔다.
동시성 문제가 발생함을 확인할 수 있다.
parallelStream 을 사용하면 내부적으로 다수의 쓰레드를 사용하여 병렬 처리를 수행한다. 이를 사용하여 addPostLike 메서드의 동시성 문제를 테스트 할 수 있다.
public void 게시글좋아요_동시성테스트_parallelStream() {
CreatePostReqDto newPost = new CreatePostReqDto("0000", "test", "testContents");
PostResDto post = postService.createPost(newPost);
// parallelStream을 사용해 addPostLike()를 병렬로 호출
IntStream.range(0, 100).parallel().forEach(i -> postService.addPostLike(post.getId()));
// 기대하는 값은 좋아요가 100 증가한 값
PostResDto updatedPost = postService.findPostById(post.getId());
assertThat(updatedPost.getLikes()).isEqualTo(100);
}

likes 값으로 100을 예상했지만 14가 나왔다.
동시성 문제가 발생함을 확인할 수 있다.
디비 요청 사이 대용량 트래픽이 발생할 경우 동시성 문제가 발생할 수 있고 테스트 코드를 통해 실제로 동시성 문제가 발생한다는 것을 확인했다.
다음은 이렇게 발생한 동시성 문제를 어떻게 해결할지 다뤄볼 예정이다.