ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 멀티 쓰레드 환경에서 게시글 조회수 증가에 대한 검증
    프레임워크/JUnit 2024. 4. 15. 21:40

     

    조회수 증가와 같이 동시에 여러 쓰레드에서 접근하는 Race condition이 발생할 수 있는 상태가 적절하게 대응이 되어있는지 확인하기 위한 테스트를 작성해보겠습니다.

     

    모든 예제는 깃헙 소스코드의 concurrency 프로젝트에서 확인 가능합니다.

     


     

    이 글은 다음과 같은 구성으로 작성되었습니다.

    1. 프로젝트 세팅

    2. 테스트 코드 작성

    3. 멀티 쓰레드 환경의 테스트 코드는 어떻게 진행되는 것일가?

    4. 동시성 이슈로부터 안전한 코드 만들기

    5. 테스트 util 생성하기

     

     

     

    1. 프로젝트 세팅

    가장 기본적인 형태로 프로젝트가 구성되어 있습니다.

    서비스 레이어에는 생성과 조회를 담당하는 2가지 메소드가 있습니다.

    Repository에서는 JPA 를 사용하고 있고, 비관적락을 사용하는 메소드를 별도로 구현하였습니다.

     

     

    엔티티 코드 입니다.

     

    서비스 코드 입니다.

     

    Repository 코드입니다.

     

     

    마지막으로 프로젝트의 Service 코드의 경로와 동일하게 test 디렉토리 속에 ServiceTest 파일을 생성합니다.

     

     

    2.  테스트 코드 작성

    테스트를 통해서 검증해야하는 내용은 다음과 같습니다.

    1. 한 게시글을 1회 조회하면 viewCount가 1증가한다.

    2. 한 게시글을 10회 조회 할 경우 조회수가 10이 된다.

    3. 한 게시글을 동시에 10명의유저가 조회를 할 경우 조회수가 10이 된다.

     

    2-1. 한 게시글을 1회 조회하면 viewCount가 1증가한다. ( O )

    가장 기본적인 형태의 테스트를 작성해줍니다.

     

     

    2-2. 한 게시글을 10명의 유저가 조회를 할 경우 조회수가 10이 된다. ( O )

    위의 조건대로 잘 동작하는지 확인하는 것은 간단합니다. 게시글을 조회하는 로직을 10번 수행하고 viewCount를 확인하였을 때, 조회수가 우리가 예상했던 10이 맞는지를 테스트하면 됩니다. 

     

    2-3. 한 게시글을 동시에 10명의유저가 조회를 할 경우 조회수가 10이 된다. ( X )

    이 글을 쓰기 시작한 이유는 여기에 있습니다. 동시에 10명의 유저가 접근하는 상황을 만드려면 여러개의 쓰레드가 사용되어야합니다.

    코드 정리는 뒤에서 다시 살펴보고 동작을 확인해보겠습니다.

     

    concurrencyCount를 동시에 접속하는 유저의 숫자라고 생각해도 좋을 것 같습니다.

    10으로 설정하니 조회수가 10이 되지 않고 1이 되는 것을 볼 수 있습니다.

     

    숫자를 조금 더 늘려보겠습니다. 이번에는 1000 명이 조회를 한다고 생각해보겠습니다.

    이번에도 1000을 예상했지만 107이라는 엉뚱한 숫자가 나오네요.

     

    RaceCondition이 발생하는 것을 확인할 수 있습니다. 

    현재 Service 코드는 동시성에 대한 대응이 전혀 되어있지 않음을 테스트를 통해서 찾아낼 수 있었습니다.

     

     

    3. 멀티 쓰레드 환경의 테스트 코드는 어떻게 진행되는 것일까?

    위 코드는 어떻게 동작하기에 동시성 이슈를 만들어 낼까요?

     

    3-1.  멀티 쓰레드 환경에서 실행 

    우선 Executors라는 클래스를 사용해서 threadPool을 만들어주는 것을 볼 수 있습니다.

    개발자가 설정한 concurrencyCount 만큼의 쓰레드를 갖는 쓰레드 풀을 생성합니다.

     

    이렇게 생성된 pool은 submit이라는 메소드를 통해서 쓰레드를 실행시킵니다.

    submit은 람다를 인자로 받으며 해당 쓰레드에서 실행되어야할 동작을 주입받습니다.

    여기서는 쓰레드에서 getPost를 통해서 게시글을 조회하도록 코드를 작성하였습니다.

     

    위의 코드는 1번의 동작을 설명하였으니, 이제 우리가 원하는 만큼 실행을 시키려면 반복문을 사용하면 됩니다.

    getPost를 실행하는 쓰레드를 concurrencCount 만큼 submit 하는 코드입니다.

    하지만 아래의 코드는 메인 쓰레드가 getPost를 하고 있는 쓰레드를 기다리지 않고 바로 shutdown을 호출합니다.

    (shutdown이 호출되어도 개별 쓰레드에서 진행되던 작업이 바로 종료되는 것은 아닙니다)

     

    우리는 모든 쓰레드가 getPost를 하고 난 뒤의 조회수를 확인하고 싶기 때문에 의도대로 작동하는 코드가 아닙니다.

     

     

    3-2.  다른 쓰레드의 실행을 기다리기

    어떻게 하면 모든 쓰레드의 실행 종료를 보장할 수 있을까요?

    이 CountDownLatch라는 클래스를 사용해서 보장할 수 있습니다.

     

     

    참고로 latch는 걸쇄라는 뜻입니다.

     

     

    전체적인 코드는  1.latch를 생성한다 / 2. latch를 해결한다 / 3. 모든 latch가 해결될 때까지 기다린다 로 볼수 있습니다.

    코드가 시작 될 때 new CountDownLatch(concurrencyCount)에서 concurrencyCount 만큼 latch를 생성합니다.

    그리고 latch.await()을 통해서 모든 latch가 countDown이 될 때 까지 기다립니다.

    개별 쓰레드에서는 모든 동작이 끝날 때 finally 구문에서 latch를 해결합니다.

     

    따라서 executorService.shutdown이 실행되는 시점에는 모든 쓰레드에서 게시글 조회가 마무리 된 상태라는 것을 알 수 있습니다.

     

     

    4.  동시성 이슈로부터 안전한 코드 만들기

    게시글을 조회할 때, JPA 제공하던 findById 메소드 대신 비관적락을 사용하는 직접 구현한 메소드로 변경을 하였습니다.

    (이 글에서는 비관적 락에 대해서는 다루지 않겠습니다.)

     

     

    락은 사용한 조회 메소드를 사용하니 모든 테스트가 문제없이 통과하는 것을 확인할 수 있습니다.

     

     

    5.  테스트 util 생성하기

    지금은 게시글 조회에 관한 로직만 검증하지만, 프로젝트가 커진다면 여러 곳에서 멀티 쓰레드 환경에서 테스트해야하는 상황이 발생할 것입니다. 따라서 이번 테스트에서 사용한 멀티 쓰레드 관련 로직을 분리해보겠습니다.

     

    인스턴스를 만들어 사용하는 형태와 static 메소드를 사용하는 형태를 고민하던 중, 테스트 환경에서만 사용하게 될 것이고 인스턴스를 사용할 필요는 없다고 느껴서 아래와 같은 형태로 구성해보았습니다.

    public class ConcurrencyTestUtils {
    
      public static <T> void executeConcurrently(Executable<T> executor, int concurrencyCount) {
        ExecutorService executorService = Executors.newFixedThreadPool(concurrencyCount);
        CountDownLatch latch = new CountDownLatch(concurrencyCount);
    
        try {
          for (int i = 0; i < concurrencyCount; i++) {
            executorService.submit(() -> {
              try {
                executor.execute();
              } finally {
                latch.countDown();
              }
            });
          }
    
          latch.await();
        } catch (InterruptedException e) {
          Thread.currentThread().interrupt();
        } finally {
          executorService.shutdown();
        }
      }
    
    }

     

    @FunctionalInterface
    public interface Executable<T> {
      T execute();
    }

     

    분리된 코드를 적용한 모습입니다.

    쓰레드에서 실행될 로직과 원하는 쓰레드 숫자를 인자로 받아서 실행시키는 형태로 구현하였습니다.

     

     

    모든 테스트가 잘 실행되는 것을 확인할 수 있습니다.

     

     

     

     

    감사합니다.

     

     

     

Designed by Tistory.