■ 리뷰등록시 문제 가능성
너무나도 희망찬 이야기겠지만,
만약 우리 서비스의 인기가 폭발해서
동시에 많은 유저가 사진리뷰를 업로드한다면 어떨까?
동기적으로 처리할경우 대용량 사진 업로딩이 하나라도 있다면, 뒤를 이을 수많은 업로딩이 블로킹되며 작업이 주르륵 지연된다. 그래서 이때는 비동기 처리를 생각해야 한다.
이 상황을 대비하고자 하여..
문제의 코드를 수선해봤다.
■ 성능 측정 (TPS)
2초 안에 25건의 스레드(사용자)를 실행함 (= 약 0.08초마다 스레드를 순차 실행)
각 스레드(사용자)는 요청을 총 4개 보냄
(= 총 100건의 요청을 보내는 테스트)
□ 변경 이전
□ 변경 이후
전반적으로 TPS도 상승하고,
처리시간도 약 0.65배로 단축했다.
□ 비동기 + Multipart 관련 문제 해결
사진 업로드의 비동기 처리에서 가장 애먹은 것은 Multipart 이다.
처음에 Multipart에 대한 이해가 부족했던 것이 원인이다.
Multipart는 요청을 받고나서 임시 폴더에 이미지파일을 생성하고, 요청 종료시 자동으로 임시파일을 삭제한다.
- 따라서 요청을 받은 후 업로딩 작업을 비동기로 넘기고 요청을 종료해버리면, 나중에 처리하려고 할 때 임시파일은 이미 삭제되어 찾을 수 없다. 따라서 그 이미지의 업로드가 불가능해진다.
- 또는 요청이 끝나서 임시파일을 삭제하려고 하는 시점에, 어떤 비동기 작업이 임시파일을 사용하고 있으면 cannot delete 관련 IOExeption이 터진다.
아래 로그들을 만날 수 있을 것이다.
C:\Users\~~~\AppData\Local\Temp\tomcat.8080.7359979336386700710\work\Tomcat\localhost\ROOT\multipart-tmp-file\upload_07a8584c_4662_41e2_ab96_de55476b450f_00000059.tmp (지정된 파일을 찾을 수 없습니다)
java.io.UncheckedIOException: Cannot delete C:\Users\~~~~~\AppData\Local\Temp\tomcat.8080.7521401682000899591\work\Tomcat\localhost\ROOT\upload_23da0294_0bde_47a9_96be_3aaf2b961967_00000059.tmp
at org.apache.tomcat.util.http.fileupload.disk.DiskFileItem.delete(DiskFileItem.java:431)
at org.apache.catalina.core.ApplicationPart.delete(ApplicationPart.java:53)
at org.springframework.web.multipart.support.StandardServletMultipartResolver.cleanupMultipart(StandardServletMultipartResolver.java:134)
at org.springframework.web.servlet.DispatcherServlet.cleanupMultipart(DispatcherServlet.java:1251)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1108)
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:965)
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006)
at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:909)
비동기 작업을 하기 위한 설정은 간단히 이렇다.
1. 멀티스레드 작업 관리 도구 등록하기
2. 업로드 작업을 비동기 호출로 바꾸기
3. 비동기 + Multipart 문제 해결하기
- 1번을 위해서는 ThreadPoolTaskExecutor 빈을 등록해주면 된다. 이 친구는 스레드 풀을 사용하여 멀티 스레드 작업 관리를 해주는 도구다.
- 2번은 그 작업을 비동기로 호출해주면 된다.
- 3번을 Multipart 임시파일이 삭제되는것이 문제이므로, 비동기 작업이 이미지를 처리할 수 있도록 임시파일을 별도로 복사해서 쓰면 된다. 작업 끝나면 지우면 된다. 이 방법을 팀원에게 공유했더니 thread join을 사용해도 될 것 같다고 한다. 생각을 미처 못했다! 다음에 꼭 테스트 해보기로 함!!
그럼 가장 먼저 멀티스레드를 사용할 것으므로, ThreadPoolTaskExecutor먼저 설정해보자.
■ ThreadPoolTaskExecutor
스레드 풀을 사용하여 멀티 스레드 작업 관리를 해주는 도구이다.
이 친구의 개요는 이렇다.
1. 특정 스레드 개수 + 작업 큐 + 스레드 풀 개수를 만들어 둠
2. 스레드 큐가 작업 큐에서 작업을 꺼내서 처리함
3. 작업 처리 요청이 급증하더라도, 스레드 전체 개수가 고정되어있어 성능 저하 걱정 없음
- 설정 예시
아래와 같이 Bean을 설정한다.
threadPoolTaskExecutor의 경우 다음과 같은 설정들을 할 수 있다.
CORE_POOL_SIZE, MAX_POOL_SIZE는 cpu 스펙에 맞게 설정해주자.
지금 우리 서버는 쿼드코어라 저렇게 두었다.
히잉... 8개까지 사용할 일은 없겠지...
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@EnableAsync
@Configuration
public class AsyncConfig {
private static int CORE_POOL_SIZE= 4;
private static int MAX_POOL_SIZE = 8;
private static int QUEUE_CAPACITY = 100;
private static String THREAD_NAME_PREFIX = "async-task";
@Bean
public Executor asyncTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(CORE_POOL_SIZE);
executor.setMaxPoolSize(MAX_POOL_SIZE);
executor.setQueueCapacity(QUEUE_CAPACITY);
executor.setThreadNamePrefix(THREAD_NAME_PREFIX);
executor.setWaitForTasksToCompleteOnShutdown(true);
return executor;
}
}
기본적으로는 corePoolSize 개수의 스레드에서 task를 처리하다가, queue에 처리할 작업이 계속 쌓여 가득 찰 때 maxPoolSize만큼의 스레드를 더 생성해 처리하기 시작한다. 만약 maxPoolSize로 늘렸는데도 queue가 가득 찬다면 RejectedExecutionExceipton 예외가 발생한다.
예외를 처리하기 위한 정책이 여럿 존재한다.
예를 들면 다음과 같다.
taskExecutor.setRejectedExecutorHandler(new ThreadPoolExecutor.CallerRunsPolicy());
기본적인 예외 처리 정책들은 RejectedExecutionHandler 인터페이스를 구현하였다. 기본 설정인 AbortPolicy는 그저RejectedExecutionException을 발생시킨다. DiscardOldestPolicy는 오래된 작업을 버리는데, 모든 task가 꼭 처리될 필요는 없을 경우 사용한다. DiscardPolicy는 처리하려는 작업을 버린다. CallerRunsPolicy는 execute() 메서드를 호출한 스레드에서(예를들면 main 스레드) 직접 task를 처리한다. 가장 유실이 적지만, task 처리 딜레이가 존재하므로 무조건 좋은 것이 아니다.
만약 execute() 호출한 곳 (executor)을 종료한다면 아직 처리되지 못한 task들은 유실된다. 만약에 다 처리 후 종료 처리를 하고 싶으면, 그렇게 되도록 설정 가능하다. 또는 종료 대기시간을 설정할 수도 있다.
// 1. 모든 작업 처리 기다리기
taskExecutor.setWaitForTasksToCompleteOnShutDown(ture);
// 2. 종료 대기시간 걸기
taskExecutor.setAwaitTerminationSeconds(60);
참고로 설정사항들은 최종적으로 taskExecutor.initialize()를 통해서 초기화되는데, 명시적으로 적지 않더라도 Bean으로 등록될 시점에 initialize() 한다. 만약 spring Bean을 사용하지 않는 일반 자바 프로그램이라면, initialize()를 호출해야지만 executor를 사용할 수 있다. (초기화 안해줄 경우 java.lang.IllegalStateException: ThreadPoolTaskExecutor not initialized 예외 발생)
■ 작업을 비동기 호출하기
원하는 작업을 비동기 호출해주면 된다.
다음과 같이 Runnable을 만들거나, 비동기 메서드 어노테이션 @Async를 사용해도 좋다.
private void saveImage(File image, String imgName) {
Runnable runnable = () -> {
try {
Files.createDirectories(Paths.get(IMAGE_DIR));
Path targetPath = Paths.get(IMAGE_DIR, imgName);
BufferedImage resized = reviewService.resize(image);
ImageIO.write(resized, "jpg", new File(targetPath.toUri()));
image.delete();
} catch (Exception e) {
...
}
};
executor.execute(runnable);
}
이상!
폭증할 유저를 꿈꾸며..
안녕..
.
.
Ref.
https://jeonyoungho.github.io/posts/ThreadPoolTaskExecutor/
'JAVA > Application' 카테고리의 다른 글
[Spring AOP] Consumer onMessage() 비동기 수신 로깅 (0) | 2024.10.31 |
---|---|
[Redis] cannot deserialize from Object value (SerializationException) (0) | 2024.10.27 |
[Spring] Cache : 프로젝트에 간단히 적용해둠 (1) | 2024.03.28 |
[Java][Backend] null 반환 리팩터링 (0) | 2024.03.08 |
[Spring] AOP로 편리하게 jwt token 검증하기 (0) | 2024.03.02 |