[Spring] Spring 동시성 테스트

김민범·2024년 11월 7일

Spring

목록 보기
13/29


익명 게시판 api 를 구현하던 중 게시글 좋아요 누르기 요청을 처리하게 되었다.

PostService

@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 를 참조해 요청의 횟수만큼 좋아요가 증가하지 않을 수 있다.

이를 검증하기 위해 두 가지 방법으로 테스트 코드를 작성하였다.

Thread 생성

다수의 쓰레드를 만들어 동시에 addPostLike()를 호출한다.

PostServiceTest

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 를 사용하여 쓰레드를 생성하기 전 까지 락을 걸어 대기시키는 방법을 사용하기로 했다.

PostServiceTest

	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 사용

parallelStream 을 사용하면 내부적으로 다수의 쓰레드를 사용하여 병렬 처리를 수행한다. 이를 사용하여 addPostLike 메서드의 동시성 문제를 테스트 할 수 있다.

PostServiceTest

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가 나왔다.

동시성 문제가 발생함을 확인할 수 있다.

마무리

디비 요청 사이 대용량 트래픽이 발생할 경우 동시성 문제가 발생할 수 있고 테스트 코드를 통해 실제로 동시성 문제가 발생한다는 것을 확인했다.

다음은 이렇게 발생한 동시성 문제를 어떻게 해결할지 다뤄볼 예정이다.

0개의 댓글