
이 글에서는 대량의 로그 트래픽을 받아 AWS ElastiCache에 저장하는 로그 수집 서버를 성능 개선하는 방법을 다루고 있습니다.
이 글은 다음 글들과 이어집니다.
- (1/4) 성능 테스트
- (2/4) Get started with k6
이전 정제 서버 성능 개선 글에서는 AWS Redshift로 대량의 쿼리를 Insert하는 로직을 개선하여 초당 2000건까지 처리할 수 있도록 하였습니다. 이에 맞춰 로그 수집 서버도 스케일업 없이 2000RPS를 소화하도록 개선해보겠습니다.

로그 수집 서버는 위와 같이 Game 서버에서 REST API 방식으로 로그 데이터를 전달받으며 이를 그대로 AWS ElastiCache Redis로 전달하는 단순한 로직을 가지고 있습니다.
테스트 환경
테스트 환경은 다음과 같습니다.
- Spring Boot 3.3.4
- Java 21
AWS EKS Pod spec
- request.cpu : 125m
- request.memory : 256Mi
- limits.cpu : 1000m
- limits.memory : 512Mi
stages: [
{ duration: '1m', target: 100 }, // 먼저 1분 동안 VUs 1에서 100까지 상승
{ duration: '2m',target: 100 }, // VUs 100에서 2분간 유지
{ duration: '30s', target: 125 },// 30초 동안 VUs 100에서 125까지 상승
{ duration: '2m',target: 125 }, // VUs 125에서 2분간 유지
{ duration: '30s', target: 0 }, // 30초 동안 VUs 0으로 하락
],
로그 수집 서버는 EKS 상에서 동작하고 있는데, Limit CPU를 1000m으로 지정했습니다. 쿠버네티스에서 1000m은 1CPU로, AWS에서는 1vCPU에 해당됩니다.
테스트는 k6로 진행했으며, VU가 100 ~125가 되도록 하였습니다.
성능 측정

초기 상태의 성능을 측정해보았습니다.
- http_req_duration : avg=138.31ms, p(95)=210.89ms
- http_reqs : 253373(reqs) 703.809544/s
평균 RPS가 703 수준이었으며, 요청 응답에 p(95) 기준으로 210.89ms 소요되었습니다. 처음과 마지막의 VU변동 부분을 고려하더라도 RPS는 800 내외로 목표인 2000에는 한참 못 미치는 수준입니다.
성능 저하의 원인
// Controller
@PostMapping
public Callable<ResponseObject> insertLogBuffer(@RequestBody String rawLog) {
return () -> collectLogService.insertLog(rawLog);
}
// Async Configuration
@Bean
public AsyncTaskExecutor taskExecutor() {
// 이해를 위해 불필요한 것은 삭제하였습니다.
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
@Override
public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
configurer.setTaskExecutor(taskExecutor());
}
현재는 위와 같이 Controller에서
Callable
객체를 반환하여 비동기 작업을 하도록 되어있습니다. 이렇게 하면 요청을 처리하는 서블릿 스레드가 즉시 해제되고, 별도의 스레드에서 Callable
이 실행됩니다. 그러면 병렬로 여러 개의 스레드가 요청을 처리할 것으로 기대되지만, 실제 로그를 확인해보면 스프링에서 하나의 스레드가 요청을 처리하고 있습니다.이는
ThreadPoolTaskExecutor
설정이 잘못되어있기 때문입니다.// class ThreadPoolTaskExecutor
private int corePoolSize = 1;
private int maxPoolSize = Integer.MAX_VALUE;
private int queueCapacity = Integer.MAX_VALUE;
ThreadPoolTaskExecutor
는 요청이 들어오면 코어 풀에서 스레드를 가져가서 처리하고, 코어 풀에 남은 스레드가 없으면 요청을 큐에 쌓아 놓습니다. 그리고 큐가 가득차면 그제서야 max 풀 사이즈까지 새로운 스레드를 생성합니다.위에서는 따로 큐 사이즈를 설정하지 않았기 때문에
Integer.MAX_VALUE
만큼 요청이 쌓일 때까지 스레드가 늘지 않았던 것입니다. Controller에서
Callable
객체를 반환 시 적합한 설정은 다음과 같습니다.@Bean
public AsyncTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(20);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(50);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
위에서
setRejectedExecutionHandler
는 남은 스레드가 없을 경우, 추가 요청이 오면 어떻게 처리할 것인지에 대한 설정입니다. CallerRunsPolicy
로 설정하면 ThreadPoolTaskExecutor
에 요청한 thread에서 직접 처리합니다. 누락없이 처리하려면
CallerRunsPolicy
로 설정하는 것이 좋습니다. 기본 설정은 AbortPolicy
로 요청 초과 시 RejectedExecutionException
를 발생시킵니다. Callable 최적화
최대 풀 사이즈를 지정하지 않고 테스트를 해보겠습니다. 테스트 시 메모리가 부족할 수 있어 한계 메모리를 1GB까지 늘렸습니다.
Thread 무제한
- http_req_duration : avg=78.68ms, p(95)=142.83ms
- http_reqs : 443484 1231.909566/s
스레드 생성에 한도가 없도록 하였을 때 메모리를 최대 858MiB까지 사용하였고, 평균 RPS는 1231 수준까지 올라왔습니다. 스레드가 너무 많으면 아무래도 성능에 좋지 않고, 메모리 또한 800MiB 이상 사용하는 것은 본래 취지에 맞지 않습니다. 따라서 최적의 스레드 개수를 찾아 봐야합니다.
Thread(20)
- http_req_duration : avg=72.44ms, p(95)=124.29ms
- http_reqs : 480480 1334.623717/s
여러 번의 테스트를 해보았을 때 큰 차이는 없지만 스레드 20개일 때가 가장 성능이 좋았습니다. 평균 RPS도 1334 정도로 기존에 비하면 약 89%의 성능 향상이 있었습니다. 설정만 제대로 해도 성능에서 큰 차이가 있다는 것을 알 수 있습니다.
@Async + CompletableFuture
Callable
과 마찬가지로 비동기 처리에 사용되는 방식입니다. 동작 과정을 따져보면 Callable
처럼 서블릿 스레드가 바로 해제되고 CompletableFuture
객체를 반환합니다. 그리고 ThreadPoolTaskExecutor
에서 가져온 별도의 스레드에서 동작하고 작업 완료 시 값을 반환하는 것도 비슷합니다. 그럼 지금 같은 간단한 동작에서 성능 상 차이가 있을지 확인해보겠습니다.// Controller
@PostMapping
public CompletableFuture<ResponseObject> insertLogBuffer(@RequestBody String rawLog) {
return collectLogService.insertLog(rawLog);
}
// Service
@Async
public CompletableFuture<ResponseObject> insertLog(String rawLog) {
/* Log Insert 동작 */
}
// Async Configuration
@Bean
public AsyncTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(50);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
설정은
Callable
때와 달리 taskExecutor
이름의 빈을 생성해주는 것으로 충분합니다. Thread(10)
- http_req_duration : , avg=68.31ms, p(95)=112.43ms
- http_reqs : 510415 1417.794445/s
마찬가지로 최적의 스레드 개수를 찾아봤을 때 큰 차이는 없지만 10개가 가장 적당했습니다. 평균 RPS는 1417로
Callable
때와는 6% 정도 성능 향상이 있었네요. 아무래도 동작 과정에서 별다른 차이가 없다보니 성능에서도 큰 차이는 보기 힘들었습니다. 다만, 작업 완료 후 Callable
객체와 CompletableFuture
객체의 결과 반환처리에서 약간의 차이로 인해 성능 차이가 있어보입니다.Callable
: 서블릿 스레드가 Callable
객체를 생성 후 반환하고 Callable
은 별도 스레드에서 처리CompletableFuture
: 컨트롤러 호출 시점에 비동기로 다른 스레드가 처리 (처리 시점이 더 빠름)VirtualThread + CompletableFuture
사실 이걸 써보고 싶어서 여태 테스트를 했습니다. Java 21부터 정식으로 등록된 VirtualThread는 얼마나 차이가 날까요? VirtualThread의 특징을 간단히 요약하면 다음과 같습니다.
- 가상 스레드는 OS 스레드에 직접 매핑되지 않아 매우 가벼움
- 여러 가상 스레드가 하나의 OS 스레드를 공유
- I/O 바운드 작업이 많고 높은 동시성이 요구되는 상황에서 특히 유용
지금과 같이 단순 I/O 작업만 있고, CPU 자원은 한정되어있는 상황에서 VirtualThread는 딱 적합한 방법인 것 같습니다. 또한 초기와 달리 Pinning 이슈도 많이 해결된 상황입니다.(MySQL Connector/J 8.x 버전까지 Pinning 이슈가 있었지만 이 역시 9버전 이후로 해결되었습니다.)
# application.properties
spring.threads.virtual.enabled: true
// Controller
private final ExecutorService taskExecutor = Executors.newVirtualThreadPerTaskExecutor();
@PostMapping
public CompletableFuture<ResponseObject> insertLogAsync(@RequestBody String rawLog) {
return CompletableFuture.supplyAsync(() -> collectLogService.insertLog(rawLog), taskExecutor);
}
설정 파일에서 Virtual thread를 사용 가능하도록 설정하고,
CompletableFuture
의 taskExecutor로 Virtual thread의 Executor를 넘겨줍니다. Virtual thread는 컨텍스트 스위칭 비용과 생성 비용이 적기 때문에 스레드를 재사용하지 않고, 그때 그때 새로운 스레드를 생성합니다. 따라서 풀 사이즈 설정 등이 필요하지 않습니다.VirtualThread

- CPU 880m, 메모리 305Mi
- http_req_duration : avg=53.78ms, p(95)=104.14ms
- http_reqs : 644003 1788.880797/s
평균 RPS가 1788로, 플랫폼 스레드 대비 약 26%의 성능 향상을 보였습니다. 또한, 리소스 사용 측면에서도 플랫폼 스레드가 CPU를 한계치인 1000m까지 모두 사용했던 것과 달리, 가상 스레드는 여유가 있었습니다. 메모리 역시 최대 305Mi까지만 사용하여, 제한된 리소스 환경에서 더욱 효율적인 방법임을 확인할 수 있었습니다.
정리
지금까지 Callable과 @Async + CompletableFuture 그리고 Virtual Thread를 활용하여 간단한 I/O 작업에 대한 성능을 확인하고 로그 수집 서버의 성능을 개선해보았습니다. 지금까지의 성능 측정 결과를 비교해보면 다음과 같습니다.
.png%3Ftable%3Dblock%26id%3D119930c7-4fda-80c1-aea6-dc7fcffbbcae%26cache%3Dv2&w=1920&q=75)
.png%3Ftable%3Dblock%26id%3D119930c7-4fda-802c-9b04-fe6953616a3b%26cache%3Dv2&w=1920&q=75)
적절한 설정과 경량 스레드의 사용을 통해 기존 대비 약 254%의 성능이 향상되었고, 목표했던 2000RP도 충분히 감당할 수 있게 되었습니다. 이런 결과를 보게되니 애초부터 경량 스레드를 가지고 만들어진 Go 같은 언어로 만든 서버는 어떨지 궁금해지네요. 기회가 된다면 다음에는 Webflux와 다른 언어의 경량 스레드로 테스트 해보도록 하겠습니다.
Share article