스레드는 몇개면 충분할까
개요
블로그 프로젝트에서 조회수 기능을 구현하면서 조회 이벤트를 비동기(@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 |
|---|---|
| 1 | 14,012 |
| 2 | 15,998 |
| 3 | 11,711 |
| 4 | 14,147 |
| 5 | 10,301 |
16 Threads (평균 TPS: 16,285 ops/s)
| 실행 | TPS |
|---|---|
| 1 | 14,910 |
| 2 | 17,039 |
| 3 | 13,984 |
| 4 | 17,032 |
| 5 | 18,462 |
32 Threads (평균 TPS: 18,492 ops/s)
| 실행 | TPS |
|---|---|
| 1 | 17,681 |
| 2 | 19,189 |
| 3 | 18,476 |
| 4 | 19,131 |
| 5 | 17,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으로 쓰레드 풀을 설정했다.
큰 기능은 아니지만, 단순히 감으로 값을 정하는 대신 테스트를 통해 시스템 설정을 결정해본 경험이었다는 점에서 의미가 있었다.