포스트

스레드는 몇개면 충분할까

스레드는 몇개면 충분할까

개요

블로그 프로젝트에서 조회수 기능을 구현하면서 조회 이벤트를 비동기(@Async)로 처리하도록 만들었다. 조회 요청이 발생하면 바로 Redis에 조회수를 증가시키는 대신 이벤트를 발행하고, 이 이벤트를 비동기 쓰레드에서 처리하는 방식이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 이벤트 발행
public PostResponse findPost(Long postId, Long memberId, HttpServletRequest request) {
      ...
      publishViewEvent(postId,memberId,request);
      Long viewCount = viewCountReader.getViewCount(postId) + post.getViewCount();

      return PostResponse.from(post, viewCount, isLikedByMe);
}

@Async
@EventListener
public void handle(PostViewedEvent event){
    if(viewCountService.isDuplicated(event.postId(), event.identifier())){
        return;
    }

    viewCountService.increaseViewCount(event.postId());
}

이렇게 하면 게시글 조회 API는 Redis 처리와 관계없이 바로 응답할 수 있기 때문에 사용자 응답 속도에 영향을 주지 않는다. 하지만 비동기 처리를 도입하면서 한 가지 고민이 생겼다.

쓰레드 풀은 몇 개로 설정해야 할까?

너무 작게 설정하면 이벤트 처리가 밀리고, 너무 크게 설정하면 Redis나 애플리케이션이 과부하를 받을 수 있다. 그래서 쓰레드 수를 감으로 정하기보다는 실제 처리량을 측정해보고 결정하기로 했다.

비동기 처리량 계산 방법

비동기 이벤트 처리량은 아래와 같이 대략적으로 계산할 수 있다.

1
TPS = 동시 실행 쓰레드 수 / 이벤트 처리 시간

예를 들어 아래와 같이 가정 할 때

  • 쓰레드 수 : 8
  • 이벤트 처리 시간 : 10ms
1
TPS = 8 / 0.01 = 800 TPS

초당 약 800개의 이벤트를 처리할 수 있다는 의미이다.

내 조회 이벤트 하나는 얼마나 걸릴까?

내 조회 이벤트의 걸리는 시간을 측정하기 위해 테스트 코드를 작성했다.

시나리오 설계

실제 서비스에서는 같은 사용자가 여러번 새로고침 하는 경우가 많다.

그래서 다음과 같은 시나리오로 테스트를 진행했다.

  • 고유 사용자 수: 1000명
  • 사용자당 요청 수: 100
  • 총 요청 수: 100,000
  • 쓰레드 수: 16

하지만 중복 조회 정책에 의해 실제 조회 수 증가는 1000번이 이루어져야 한다.

TPS 측정

조회 이벤트 로직을 그래도 재현한 테스트 코드이다.

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@Test
void TPS_측정() throws Exception {
    Long postId = postRepository.findAll().getFirst().getId();

    int uniqueUsers = 1_000;    // 고유 사용자 수 = 유효 조회 수
    int requestsPerUser = 100;  // 사용자당 재요청 횟수 (중복 시뮬레이션)
    int total = uniqueUsers * requestsPerUser;
    int threads = 16;

    CountDownLatch done = new CountDownLatch(total);
    long startNs = System.nanoTime();

    try (ExecutorService executor = Executors.newFixedThreadPool(threads)) {
        for (int i = 0; i < uniqueUsers; i++) {
            String identifier = "user:" + i;
            for (int j = 0; j < requestsPerUser; j++) {
                executor.submit(() -> {
                    try {
                        // ViewCountEventHandler의 실제 처리 플로우 재현
                        if (!viewCountService.isDuplicated(postId, identifier)) {
                            viewCountService.increaseViewCount(postId);
                        }
                    } finally {
                        done.countDown();
                    }
                });
            }
        }

        boolean completed = done.await(60, TimeUnit.SECONDS);
        assertThat(completed).as("60초 내에 모든 요청이 완료되어야 한다").isTrue();
    }

    long elapsedNs = System.nanoTime() - startNs;
    double sec = elapsedNs / 1_000_000_000.0;
    double tps = total / sec;

    System.out.printf("[실험] 총 %,d건 (고유 사용자 %,d명 × %d회) / 총 걸린 시간: %.2fs → 초당 처리량: %.0f ops/s%n",
            total, uniqueUsers, requestsPerUser, sec, tps);

    // 중복 제거 후 고유 사용자 수만큼만 조회수가 증가해야 한다
    String key = ViewCountRedisKeys.getViewCountKey(postId);
    String rawCount = stringRedisTemplate.opsForValue().get(key);
    assertThat(rawCount).as("Redis 조회수 키가 존재해야 한다").isNotNull();

    Long actualCount = Long.parseLong(rawCount);
    System.out.printf("[결과] 기대 조회수: %,d / 실제 조회수: %,d%n", (long) uniqueUsers, actualCount);
    assertThat(actualCount).isEqualTo(uniqueUsers);
	}
}

// [실험] 총 100,000건 (고유 사용자 1,000명 × 100회) / 총 걸린 시간: 6.71s → 초당 처리량: 14910 ops/s
// [결과] 기대 조회수: 1,000 / 실제 조회수: 1,000

테스트 결과

테스트 결과는 다음과 같았다.

1
2
[실험] 총 100,000건 (고유 사용자 1,000명 × 100회) / 총 걸린 시간: 6.71s → 초당 처리량: 14910 ops/s
[결과] 기대 조회수: 1,000 / 실제 조회수: 1,000

즉, 조회 이벤트 처리 로직은 초당 1.8만 요청을 처리할 수 있다.

또한 동시 요청에도 조회수는 정확하게 증가한걸 확인할 수 있다.

쓰레드 수에 따른 처리량 비교

쓰레드 수를 8 / 16 / 32로 변경하며 각각 5회씩 실험을 진행했다.

8 Threads (평균 TPS: 13,234 ops/s)

실행TPS
114,012
215,998
311,711
414,147
510,301

16 Threads (평균 TPS: 16,285 ops/s)

실행TPS
114,910
217,039
313,984
417,032
518,462

32 Threads (평균 TPS: 18,492 ops/s)

실행TPS
117,681
219,189
318,476
419,131
517,986

결과 분석

8 → 16 Threads

  • 13,234 → 16,285 (약 23% 증가)

16 → 32 Threads

  • 16,285 → 18,492 (약 13% 증가)

쓰레드를 늘릴수록 처리량은 증가했지만 증가 폭은 점점 줄어드는 경향을 보였다.

이는 조회 이벤트 처리 로직이 CPU 연산이 아니라 Redis I/O 중심 작업이기 때문이다.

쓰레드를 늘리면 Redis 요청을 동시에 더 많이 보내게 되지만, Redis는 싱글 쓰레드이기 때문에 어느 순간부터는 Redis 또는 네트워크 I/O가 병목이 되면서 TPS 증가 폭이 줄어든다.

→ 참고로 Redis 6.0 부터는 부분적(네트워크 I/O 부분)으로 멀티 쓰레드를 사용한다.

쓰레드 풀 설정 결정

실험 결과를 기반으로 아래와 같이 쓰레드 풀을 설정했다.

1
2
3
4
5
6
7
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(5000);
executor.setThreadNamePrefix("view-async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();

corePoolSize = 8

일반적인 트래픽에서는 8개의 쓰레드만으로도 충분한 처리량이 나왔다.

1
평균 13,234 ops/s

maxPoolSize = 16

트래픽이 증가했을 때 쓰레드를 16개까지 확장하도록 설정했다.

1
평균 16,285 ops/s

32 쓰레드에서는 처리량이 조금 더 증가했지만 증가 폭이 크지 않았기 때문에 최대 쓰레드를 16으로 제한했다.

Queue와 RejecetedExecutionHandler

또한 순간적인 트래픽 폭주를 대비해 큐를 설정했다.

1
executor.setQueueCapacity(5000);

그리고 큐가 가득차면 CallerRunsPolicy 정책이 동작한다.

이 정책은 이벤트를 발행한 쓰레드가 직접 작업을 처리하도록 만든다.

정리

조회수 이벤트를 비동기로 처리하면서 쓰레드 풀 크기를 어떻게 정해야 할지 고민이 있었다.

처음에는 감으로 설정할 수도 있었지만, 실제로 어느 정도 처리량이 나오는지 확인해보고 싶었다.

그래서 간단한 부하 테스트를 통해 쓰레드 수에 따른 처리량을 측정해봤다.

1
2
3
8 threads  → ~13k ops/s
16 threads → ~16k ops/s
32 threads → ~18k ops/s

테스트 결과 쓰레드를 늘릴수록 처리량은 증가했지만, 증가 폭은 점점 줄어드는 것을 확인할 수 있었다. 이는 조회 이벤트 처리 로직이 CPU 연산보다는 Redis I/O 중심 작업이기 때문이다.

이 결과를 바탕으로 corePoolSize = 8, maxPoolSize = 16으로 쓰레드 풀을 설정했다.

큰 기능은 아니지만, 단순히 감으로 값을 정하는 대신 테스트를 통해 시스템 설정을 결정해본 경험이었다는 점에서 의미가 있었다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.