티스토리 뷰

 회사에서 사용하는 기술스택과는 다른 기술스택을 사용하고자 개인프로젝트를 하고 있는데, 그중 조회수 관련하여 동시성 이슈를 만들어 해결한 경험이 있어서 정리한다.


문제

public DataResponse<BoardShow> findBoardById(Long boardId) {
    Board findBoard = boardRepository.findBoardById(boardId);
    findBoard.setViewCount(findBoard.getViewCount() + 1);
    return new DataResponse<>(findBoard != null ? findBoard.toBoardShow() : null);
}

이 부분은 게시물 아이디로 게시물을 조회한다. 이 부분에서 게시물의 조회수를 올려야 하므로 위와 같은 코드를 생각할 수 있다. 그런데 이 코드를 테스트하면 어떻게 될까?

@Test
@DisplayName("단시간내 한 게시글 여러번 조회시 조회수 정확히 증가_성공")
void findBoardById_viewCountTest() throws InterruptedException {
    CreateBoardRequest request = CreateBoardRequest.builder().title("테스트1").content("테스트입니다.").userId(USER_ID).build();
    DataResponse<BoardCreate> createBoardResult = boardService.saveBoard(request);

    int threadCount = 100;
    ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
    CountDownLatch latch = new CountDownLatch(threadCount);

    for (int i = 0; i < threadCount; i++) {
        executorService.submit(() -> {
            try {
                boardService.findBoardById(createBoardResult.getData().getId());
            } finally {
                latch.countDown();
            }
        });
    }

    latch.await();

    DataResponse<BoardShow> boardById = boardService.findBoardById(createBoardResult.getData().getId()); // 100 + 1을 하는 이유

    assertEquals(100 + 1, boardById.getData().getViewCount());

}

 위 코드가 테스트코드인데, 스레드 100개로 같은 게시물의 조회 요청을 한다. 이 경우 결과는 어떨까?

  100개의 조회 요청이 있었고, 마지막에 테스트 결과를 확인하기 위해 1개의 요청이 더 보내졌으므로 조회수는 101이어야 할 것 같지만 실제 조회수는 1이다. 이유는 먼저 조회한 스레드A가 조회수 1을 가져가서 2로 업데이트를 하기 전에 다른 스레드B가 조회수 1을 가져갔고, 스레드A가 조회수를 2로 업데이트하더라도 그직후에 스레드B가 조회수 2로 업데이트를 하기 때문이다.
 만약 트래픽이 대량으로 발생하지 않는 프로젝트라면 상관없겠지만 대량으로 트래픽이 발생할 경우, 100명이 조회를 했지만 정확한 조회수가 반영되지 않을 수 있다.


해결

 나는 이 문제를 Redis를 이용하여 해결했다. Redis는 싱글 스레드로 자원에 대한 race condition을 해결가능하기 때문이다. 특히나 조회수처럼 단순한 숫자같은 경우는 incr 명령어로 쉽게 다룰 수 있다고 알고 있어서 Redis를 사용했다.
 우선 설명하자면, 조회수를 올리고 내리는 부분을 Redis가 처리해주고, Redis에서 조회수를 받아서 DB를 업데이트한다.

public DataResponse<BoardShow> findBoardById(Long boardId) {
    Board findBoard = boardRepository.findBoardById(boardId);
    long incrementedViewCount = viewCountRedisRepository.increaseViewCount(String.valueOf(findBoard.getId()));
    findBoard.setViewCount(incrementedViewCount);
    return new DataResponse<>(findBoard != null ? findBoard.toBoardShow() : null);
}

 중간에 보면 incrementedViewCount가 있는데 viewCountRedisRepository.increaseViewCount의 반환값이다. 그리고 그 반환값을 게시물의 조회수로 수정한다.(즉, 조회수를 +1 한다.) viewCountRedisRepository가 뭐냐하면 조회수 관련해서 만든 Redis쪽 레포지토리이다.

@Repository
@RequiredArgsConstructor
public class ViewCountRedisRepository {

    private final RedisTemplate<String, String> redisTemplate;

    public long increaseViewCount(String key) {
        return redisTemplate.opsForValue().increment(key);
    }
    
}

 JdbcTemplate처럼 RedisTemplate이 있고, 이 RedisTemplate을 이용하여 Redis에 여러 요청을 보낼 수 있다. increaseViewCount는 key로 찾은 값을 1올리고 그 올려진 결과값을 반환하는데(increment) 이 명령어는 Redis에서 incr다. 이 incr 명령어는 성능도 좋다고 강의에서 들었었다.
 결론, 조회수를 올리고 내리는 것은 Redis에서 진행하는데, 싱글스레드여서 자원에 대한 race condition을 해결할 수 있다. DB에는 Redis에서 올려진 조회수를 전달받아(incr 명령어의 반환값은 증가된 값이므로) 그 조회수로 업데이트를 해준다.
 그렇다면 테스트코드는 아래와 같아진다.

@Test
@DisplayName("단시간내 한 게시글 여러번 조회시 조회수 정확히 증가_성공")
void findBoardById_viewCountTest() throws InterruptedException {
    CreateBoardRequest request = CreateBoardRequest.builder().title("테스트1").content("테스트입니다.").userId(USER_ID).build();
    DataResponse<BoardCreate> createBoardResult = boardService.saveBoard(request);
    viewCountRedisRepository.flush(String.valueOf(createBoardResult.getData().getId()));

    int threadCount = 100;
    ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
    CountDownLatch latch = new CountDownLatch(threadCount);

    for (int i = 0; i < threadCount; i++) {
        executorService.submit(() -> {
            try {
                viewCountRedisRepository.increaseViewCount(String.valueOf(createBoardResult.getData().getId()));
            } finally {
                latch.countDown();
            }
        });
    }

    latch.await();

    DataResponse<BoardShow> boardById = boardService.findBoardById(createBoardResult.getData().getId()); // 100 + 1을 하는 이유

    assertEquals(100 + 1, boardById.getData().getViewCount());
}

 테스트결과 성공한다.


의문과 검증

 그런데 난 여기에서 의문을 가졌다. 위 테스트코드에서 보면 100개의 스레드로 viewCountRedisRepository.increaseViewCount를 실행하고 있다. 그런데 왜 boardService.findBoardById로 서비스계층으로 접근하는 것으로 바꾸면 아래와 같이 테스트가 실패할까? 그럼 실제 요청도 잘못되어 보이는거 아닐까?

 그래서 대량 트래픽을 일으켜봐야겠다고 생각했고, 그래서 Jmeter를 사용해보았다.(윈도우에서 실행할 때 아래와 같이 뜨면서 실행이 안될 때도 있는데 그럴 땐 관리자 권한으로 실행해주면 된다.)
 Jmeter 사용방법은 https://man-tae.tistory.com/9 를 참고했다.

'findstr'은(는) 내부 또는 외부 명령, 실행할 수 있는 프로그램, 또는
배치 파일이 아닙니다.
Not able to find Java executable or version. Please check your Java installation.
errorlevel=2
계속하려면 아무 키나 누르십시오 . . .

 그리고 Jmeter가 실행되면 아래와 같이 Thread Group을 만들고 동시에 실행할 스레드 개수(100개)와 지정된 사용자가 모두 로딩될 시간(1초), 반복 횟수(5번)을 입력한다. 나의 경우와 같다면, 100개의 요청이 5번 반복되므로 마지막 요청의 게시물 조회수는 500이 되어야 한다.

 아래와 같이 요청을 보낼 서버의 정보를 입력하고 실행버튼을 클릭하면 실행이 된다.

 그리고 결과를 확인해보면 아래와 같이 제일 처음 보낸 요청은 1이지만 제일 마지막 요청은 조회수가 500임을 확인할 수 있다.

 위와 같이 Jmeter로 테스트해본 결과 정상적으로 조회수가 업데이트되어 조회수 동시성 이슈가 해결되었음을 확인할 수 있었다.

300x250
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/12   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31
글 보관함