해당 내용은 프로젝트 에서 사용자 경험을 위해 API 호출 시간을 줄이기 위해 구현하며 작성한 글입니다.
혹시, 잘못된 내용이나 다른 방법등이 있다면 댓글로 또는 joyson5582@gmail.com
로 남겨주세요!
이 내용은 # 외부(깃허브) 호출을 비동기로 30% 가량 빠르게(1) - 기본 로직 에서 이어집니다.
[2024-11-09 18:45:18:12776] [ForkJoinPool.commonPool-worker-7] INFO [corea.global.util.FutureUtil.lambda$supplyAsync$0:13] - Running in thread: ForkJoinPool.commonPool-worker-7
[2024-11-09 18:45:18:12776] [ForkJoinPool.commonPool-worker-1] INFO [corea.global.util.FutureUtil.lambda$supplyAsync$0:13] - Running in thread: ForkJoinPool.commonPool-worker-1
[2024-11-09 18:45:18:12776] [ForkJoinPool.commonPool-worker-2] INFO [corea.global.util.FutureUtil.lambda$supplyAsync$0:13] - Running in thread: ForkJoinPool.commonPool-worker-2
[2024-11-09 18:45:18:12776] [ForkJoinPool.commonPool-worker-3] INFO [corea.global.util.FutureUtil.lambda$supplyAsync$0:13] - Running in thread: ForkJoinPool.commonPool-worker-3
[2024-11-09 18:45:18:12777] [ForkJoinPool.commonPool-worker-6] INFO [corea.global.util.FutureUtil.lambda$supplyAsync$0:13] - Running in thread: ForkJoinPool.commonPool-worker-6
[2024-11-09 18:45:18:12776] [ForkJoinPool.commonPool-worker-4] INFO [corea.global.util.FutureUtil.lambda$supplyAsync$0:13] - Running in thread: ForkJoinPool.commonPool-worker-4
[2024-11-09 18:45:18:12777] [ForkJoinPool.commonPool-worker-5] INFO [corea.global.util.FutureUtil.lambda$supplyAsync$0:13] - Running in thread: ForkJoinPool.commonPool-worker-5
[2024-11-09 18:45:18:12778] [ForkJoinPool.commonPool-worker-7] INFO [corea.global.util.FutureUtil.lambda$supplyAsync$0:13] - Running in thread: ForkJoinPool.commonPool-worker-7
ForkJoinPool.commonPool
이라는 곳에서 제공되는 기본 스레드 7개를 통해서 비동기 작업이 수행된 걸 확인했습니다.
ForkJoinPool
에 대해서 먼저 알아보겠습니다.
자바 7 에서 도입된 프레임워크입니다.
모든 프로세서 코어를 최대한 활용해 병렬 처리를 도와줍니다.
모델명: MacBook Air
모델 식별자: Mac14,2
모델 번호: Z15Y0002AKH/A
칩: Apple M2
총 코어 개수: 8(4 성능 및 4 효율)
제가 사용하는 맥북의 코어는 총 8개이므로 7개의 스레드를 사용하는 거였네요!
왜 코어 -1 개의 스레드를 가지는가?
코어 1개를 남겨두면 시스템 작업과 애플리케이션 스레드 간의 경쟁을 줄이고, 병렬 처리 성능을 높일 수 있습니다.
코어 - 1로 제한하면 시스템이 더 안정적으로 작동하며, 작업 대기 시간이 줄어듭니다.
Runtime.getRuntime().availableProcessors();
ForkJoinPool.commonPool().getParallelism();
두개를 출력하면 8개, 7개가 나옵니다.
정말 개수가 더 많으면 성능이 떨어지는지 확인해보겠습니다.
추가로 더 다양한 테스트를 위해 댓글 수가 100~200개 사이의 각각 다른 목록들로 테스트 합니다.
private <T> void execute(String text, int count) {
long startTime = System.nanoTime();
List<CompletableFuture<T>> futures = ary.stream()
.map(integer -> (CompletableFuture<T>) FutureUtil.supplyAsync(() -> githubReviewProvider.provideReviewInfo("https://github.com/woowacourse-precourse/java-racingcar-6/pull/" + integer)))
.toList();
CompletableFuture<Void> allOf = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
allOf.join(); // 모든 요청이 완료될 때까지 대기
long endTime = System.nanoTime();
printElapsedTime(text, endTime - startTime);
}
ForkedJoinPool 개수 : 7
기존 코드 : Elapsed Time: 36 seconds, 614 milliseconds, 556 microseconds, 958 nanoseconds
첫 번째 문제만 해결 : Elapsed Time: 26 seconds, 993 milliseconds, 992 microseconds, 208 nanoseconds
둘다 해결 : Elapsed Time: 25 seconds, 805 milliseconds, 255 microseconds, 291 nanoseconds
ForkedJoinPool 개수 : 20
기존 코드 : Elapsed Time: 35 seconds, 821 milliseconds, 2 microseconds, 625 nanoseconds
첫 번째 문제만 해결 : Elapsed Time: 26 seconds, 53 milliseconds, 833 microseconds, 625 nanoseconds
둘다 해결 : Elapsed Time: 25 seconds, 250 milliseconds, 377 microseconds, 334 nanoseconds
( 시간 차이는 크게 없는걸로 보이네요. )
하지만, 위 실험은 크게 중요하지 않습니다.
ForkedJoinPool
의 핵심은 작업을 작은 단위로 나눠서(Fork
) 병렬 실행하고 결과를 합치는(Join
) 것입니다.
즉, 여러개의 네트워크 작업을 한번에 실행하기 위해서 사용되는게 아니라는 뜻입니다. - Avoid any blocking in ForkJoinTasks. in Baeldung
( parallelStream()
에서 사용되는게 이 ForkedJoinPool
입니다. )
그러면, 이런 작업을 할 때 효율적으로 비동기를 처리하려면 뭘 사용해야 할까요?
해당 클래스를 살펴보기 전 ThreadPool
부터 알아보겠습니다.
병렬 작업을 할 때마다 스레드를 생성&해제 하는것은 매우 비효율적입니다.
-> 기본적으로, 시스템 수준 리소스에 매핑되기 때문입니다.
( 물론, 자바 19부터는 Thread.ofVirtual()
와 같이 가상 스레드를 생성 가능은 합니다. )
스레드 풀을 통해 미리 만들어 놓은 스레드를 사용하고 삭제하지 않고 유지해서 작업이 가능합니다.
스레드 풀에 병렬 작업 형태
로 코드를 작성하고 제출하면 작업을 실행하고 관리해줍니다.
그냥 생성자를 통해 생성 가능합니다.
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, // corePoolSize
4, // maximumPoolSize
60, // keepAliveTime
TimeUnit.SECONDS, // keepAliveTime 단위
new ArrayBlockingQueue<>(10) // 작업 대기 큐,
new ThreadPoolExecutor.CallerRunsPolicy()); // 거절 실행 핸들러
);
해당 내용은 Tomcat 구현하기 미션 에서도 학습한 내용입니다.
위 내용들과 같이 나오는 ExecutorService
가 있습니다.
인터페이스 이며, ThreadPoolExecutor
, ForkJoinPool
같은 구현체들도 해당 인터페이스를 구현한 AbstractExecutorService
의 자식 클래스입니다.
Executors
라는 팩토리 메소드를 사용해 구현체를 생성합니다. ( 구체적인 설정을 할 필요 없이 간편하게 생성할때 사용 )
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
와 같이 편하게 생성 가능합니다.
저희는 좀 더 복잡한 튜닝을 위해 ThreadPoolExecutor
를 사용하겠습니다.
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(25);
executor.initialize();
return executor;
}
===
public TaskService(@Qualifier("customExecutor") Executor customExecutor) {
this.customExecutor = customExecutor;
}
@Async("customExecutor") // 특정 Executor 지정
public void executeAsyncTask(int i) {
...
}
와 같이 빈 등록 및 주입을 할 수 있습니다.
먼저 기본적인 Thread Pool 을 통해 다시 시간을 측정해보겠습니다.
@Bean(name = "apiExecutor")
public Executor apiExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(100);
executor.setQueueCapacity(25);
executor.initialize();
return executor;
}
@Bean(name = "clientExecutor")
public Executor clientExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(100);
executor.setQueueCapacity(25);
executor.initialize();
return executor;
}
public GithubReviewProvider(final GithubPullRequestReviewClient reviewClient,
final GithubPullRequestCommentClient commentClient,
final @Qualifier("apiExecutor") Executor executor) {
this.reviewClient = reviewClient;
this.commentClient = commentClient;
this.executor = executor;
}
public GithubPullRequestCommentClient(RestClient restClient,
GithubPullRequestUrlExchanger githubPullRequestUrlExchanger,
GithubPersonalAccessTokenProvider githubPersonalAccessTokenProvider,
@Qualifier("clientExecutor")Executor executor) {
super(restClient, githubPullRequestUrlExchanger, githubPersonalAccessTokenProvider,executor);
}
와 같이 Executor
를 설정 및 주입했습니다.
[2024-11-09 23:34:34:14131] [clientExecutor-10] INFO [corea.global.util.FutureUtil.lambda$supplyAsync$1:20] - Running in thread: clientExecutor-10
[2024-11-09 23:34:34:14131] [apiExecutor-14] INFO [corea.global.util.FutureUtil.lambda$supplyAsync$1:20] - Running in thread: apiExecutor-14
로그를 찍어 확인하면 성공적으로 스레드를 제공해줍니다.
테스트는 두가지로 진행했습니다.
startTime = System.nanoTime();
ary.forEach(integer -> githubReviewProvider.getGithubPullRequestReviewInfoSync("https://github.com/woowacourse-precourse/java-racingcar-6/pull/"+integer));
endTime = System.nanoTime();
printElapsedTime("기존 코드 : ", endTime - startTime);
private <T> void executeAsync(String text) {
long startTime = System.nanoTime();
List<CompletableFuture<T>> futures = ary.stream()
.map(integer -> (CompletableFuture<T>) FutureUtil.supplyAsync(() -> githubReviewProvider.getGithubPullRequestReviewInfoAsync("https://github.com/woowacourse-precourse/java-racingcar-6/pull/" + integer)))
.toList();
CompletableFuture<Void> allOf = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
allOf.join();
long endTime = System.nanoTime();
printElapsedTime(text, endTime - startTime);
}
기존 코드 : Elapsed Time: 37 seconds, 226 milliseconds, 116 microseconds, 417 nanoseconds
첫 번째 문제만 해결 : Elapsed Time: 29 seconds, 496 milliseconds, 377 microseconds, 584 nanoseconds
둘다 해결 : Elapsed Time: 27 seconds, 292 milliseconds, 174 microseconds, 42 nanoseconds
기존 코드 : Elapsed Time: 6 seconds, 385 milliseconds, 892 microseconds, 541 nanoseconds
첫 번째 문제만 해결 : Elapsed Time: 3 seconds, 164 milliseconds, 544 microseconds, 83 nanoseconds
둘다 해결 : Elapsed Time: 3 seconds, 568 milliseconds, 894 microseconds, 125 nanoseconds
기존 코드 : Elapsed Time: 40 seconds, 564 milliseconds, 653 microseconds, 83 nanoseconds
첫 번째 문제만 해결 : Elapsed Time: 27 seconds, 4 milliseconds, 581 microseconds, 709 nanoseconds
둘다 해결 : Elapsed Time: 28 seconds, 122 milliseconds, 420 microseconds, 667 nanoseconds
기존 코드 : Elapsed Time: 5 seconds, 969 milliseconds, 698 microseconds, 83 nanoseconds
첫 번째 문제만 해결 : Elapsed Time: 3 seconds, 35 milliseconds, 963 microseconds, 917 nanoseconds
둘다 해결 : Elapsed Time: 3 seconds, 319 milliseconds, 663 microseconds, 208 nanoseconds
이와 같은 결과가 나옵니다.
의 결과가 나왔습니다. ( 왜 첫 번째 문제만 해결한게 더 짧은지는 명확하게 모르겠네요.. 🥲 )
@Bean(name = "apiExecutor")
public Executor apiExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(20);
executor.setMaxPoolSize(50);
executor.setMaxPoolSize(100);
executor.setQueueCapacity(25);
executor.initialize();
return executor;
}
@Bean(name = "clientExecutor")
public Executor clientExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(30);
executor.setQueueCapacity(50);
executor.initialize();
return executor;
}
이와같이 변경을 하면?
기존 코드 : Elapsed Time: 39 seconds, 303 milliseconds, 886 microseconds, 125 nanoseconds
첫 번째 문제만 해결 : Elapsed Time: 29 seconds, 721 milliseconds, 726 microseconds, 334 nanoseconds
둘다 해결 : Elapsed Time: 28 seconds, 156 milliseconds, 877 microseconds, 208 nanoseconds
기존 코드 : Elapsed Time: 5 seconds, 757 milliseconds, 605 microseconds, 500 nanoseconds
첫 번째 문제만 해결 : Elapsed Time: 2 seconds, 885 milliseconds, 556 microseconds, 875 nanoseconds
둘다 해결 : Elapsed Time: 3 seconds, 560 milliseconds, 182 microseconds, 916 nanoseconds
큰 차이가 없는걸 볼 수 있습니다.
그라파나를 통해 도입 전 / 후 지표를 확인해보겠습니다.
응답시간은 비동기 도입 후 600ms -> 400ms 가량으로 33% 빨라졌습니다.
힙 메모리도 큰 피크는 튀지 않는걸 볼 수 있습니다.
( 물론, CPU 오버헤드 문제가 있을 수 있지만 이는 당장 확인 못했습니다. )
이렇게
등을
각자 팀이 확인을 해보고 상황에 맞게 도입을 해보면 좋을거 같습니다! 🙂
사용자 경험이 무조건 1순위라고 생각은 하나, 적절하게 타협은 해야할거 같습니다.
( 과도한 사용자 경험을 위해
Thread Pool 에 매우 많은 스레드나 무한 대기열 때문에 서버가 터진다면 더 최악일테니까요 )