서버에서 사용하는 비동기 로직은 스레드가 블로킹되지 않게 하기 위해 사용된다는 것은 흔히들 알고 있는 사실이다. CPU를 사용하지 않는 I/O 작업 때문에 스레드를 묶어두는 것은 죄악이다!
그렇다면 만약 비동기로직을 SpringBoot기반 서버에 추가했다면, Tomcat의 스레드를 늘려야할까? 비동기로직을 처리하는 스레드가 추가된거니까 늘려야되는게 맞을까?
때는 한가로운 5월 1일, 근로자의 날... 운영 중이던 서비스에 장애가 발생했다.
동영상 파일을 Storage가 아닌 MongoDB의 GridFS 기능을 이용해 저장하고 있었는데, 디스크에 Write 작업이 몰리면서 쓰기 지연(write queue)이 발생했고, 결국 Timeout이 발생해 동영상이 정상적으로 저장되지 않는 문제가 나타났다.
(Write 작업이 몰린 근본적인 원인은 특정 단말모델의 잘못된 retry로직이긴 했다..)
GridFS는 하나의 파일을 여러 개의 chunk로 분할해 저장하고, 각 chunk는 별도의 메타데이터와 함께 저장된다. 특히 우리 시스템은 사용자 수가 급격히 증가하면서 총 10TB에 달하는 저장 용량으로 확장된 상태였고, 하나의 동영상이 여러 chunk로 나뉘고, 그만큼의 메타데이터가 따로 관리되는 구조였다.
이로 인한 대용량 환경에서의 저장/조회 성능 문제는 인지하고 있었고, 이를 S3 Storage로 이관할 계획이었다.
하지만 이관 전에, 장애가 발생해부렸다.
그러면서 급하게 MongoDB에서 S3 Storage로 저장하는 로직을 추가하기로 했고, 일단 임시방편으로 백그라운드 스레드에서 비동기로 I/O 작업을 처리하게끔 하자는 의견이 나왔다.
그러면서 그룹장님이 비동기로직을 추가했으니 스프링부트의 Max Tomcat 스레드 수를 늘려야하지 않겠냐고 말씀하셨다.
Tomcat 스레드랑 비동기 스레드는 다른 스레드입니다.. 그룹장님!!
민망하실까봐 위처럼 말하지 않았지만.. 둘이 별개의 스레드 풀에서 동작하고 있다는 것을 알고 있었기 때문에, 정중하게 의미가 없을 것이라고 말씀드렸고 동일한 tomcat 스레드 수 설정값으로 임시 배포가 될 수 있었다.
근데 여기서! 나도 이러한 사실을 '지식'으로만 알고 있었던 사실이었기에 이를 실제로 검증해보고자 글을 쓰게 되었다.
하나하나 알아보자!
독립변인으로 Tomcat의 Max 스레드 수를 바꿔가며 실험해볼 예정이다.
server:
tomcat:
threads:
max: 1
위와 같이 설정하자.
비동기 로직을 추가하는 방법이 보통 두가지가 있을 수 있다.
private final ExecutorService es = Executors.newFixedThreadPool(10);
Future<String> future = es.submit(() -> slowThread(idx));
ExecutorService를 활용해서 비동기 로직을 직접 구현하는 방식이다.여기에서는 좀 더 통상적으로 사용되는 @Async 어노테이션을 적용한 로직을 소개하려한다.
독립변인으로 최소 스레드 수를 바꿔가며 실험해볼 예정이다.
여기서 maxPoolSize는 큐가 다 차있을때만 늘어날 수 있는 최대 스레드 수의 개수이므로 혼동하지 말자. 선후 관계는 '큐가 찼을때' 이다!!
일단 최소 스레드를 1로 설정하자.
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1);
executor.setMaxPoolSize(100);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("MyAsyncTask-");
executor.initialize();
return executor;
}
}
@Async 어노테이션을 바탕으로 비동기 로직을 처리할 로직이다.
컨트롤러에서 직접 @Async를 달아도 되지 않을까 생각했는데, 생각해보니 Async는 AOP니까 반드시 다른 클래스에서 불려야되는 것을 잊어버렸었다. 그래서 AsyncService클래스를 따로 생성하고 컨트롤러에서 비동기 로직이 포함된 클래스의 메서드를 호출하게 했다.
그리고, 해당 스레드가 비동기스레드인지 확인하기 위해서 Thread의 이름을 남긴다.
I/O는 2초에 시간이 걸린다고 가정하자.
이 때 함수의 반환값은 CompletableFuture인 것을 알 수 있는데 이는 비동기로직을 억지로 동기로 만들어 검증결과를 확실하게 확인하기 위함이다.
@Service
@Slf4j
public class AsyncService {
@Async
public CompletableFuture<String> asyncLogic() {
log.info("[START] BACKGROUND THREAD : {}", Thread.currentThread().getName());
try {
// I/O 2초 걸린다고 가정
Thread.sleep(2000L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
log.info("[FINISH] BACKGROUND THREAD : {}", Thread.currentThread().getName());
return CompletableFuture.completedFuture("finish");
}
}
컨트롤러 클래스에서도 마찬가지로 Thread의 이름을 찍는다. 비동기 스레드랑 다른 스레드 풀을 사용하는지 확실히 구분하기 위해서이다.
그리고 비동기 로직을 호출하자.
하지만 비동기의 특성상 해당 응답을 기다리지 않고 바로 response를 해서 검증결과를 보기 쉽지 않은데, 이를 위해 응답을 기다리게 하기 위해 CompletebleFuture (Future)을 사용해서 타 스레드의 응답을 억지로 기다리게 하자.
@Slf4j
@RestController
@RequiredArgsConstructor
public class TomcatThreadAsyncThreadControllerWithAsyncAnnotation {
private final AsyncService asyncService;
@GetMapping("/async/block")
public String block(int idx) throws InterruptedException, ExecutionException {
log.info("TOMCAT REQUEST THREAD : {}", Thread.currentThread().getName());
CompletableFuture<String> response = asyncService.asyncLogic();
log.info("TOMCAT RESPONSE THREAD : {}", Thread.currentThread().getName());
response.get();
return "TOMCAT RESPONSE THREAD [ " + idx + " ]";
}
}
API 호출을 통한 검증을 위해 LoadTest 모듈을 만들어 진행했다.
아래처럼 작성하면 비동기로 i번의 API호출을 동시에 요청할 수 있으므로 웬만한 부하테스트 뺨친다!
첨엔 curl로 날리려고 했는데.. 다른 블로거분이 작성한 글을 통해 이를 차용해왔다! https://ooeunz.tistory.com/149
@Slf4j
public class LoadTest {
private static final String BASE_URL = "http://localhost:8080";
private static final RestTemplate restTemplate = new RestTemplate();
private static final ExecutorService es = Executors.newFixedThreadPool(100); // 여기 개수 변경하면서 실험해보면 어떻게 될까
public void fetchApi() {
for (int i = 1; i <= 100; i++) {
final int idx = i;
// 비동기처리
es.execute(() -> {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
String response = restTemplate.getForObject(BASE_URL + "/async/block?idx=" + idx, String.class);
stopWatch.stop();
log.info("response=" + response + ", stopWatch=" + stopWatch.getTotalTimeSeconds());
});
}
}
public static void main(String[] args) throws InterruptedException {
LoadTest loadTest = new LoadTest();
StopWatch stopWatch = new StopWatch();
stopWatch.start();
loadTest.fetchApi();
stopWatch.stop();
es.shutdown();
es.awaitTermination(1000, TimeUnit.SECONDS);
log.info("Total stop watch " + stopWatch.getTotalTimeSeconds());
}
}
Tomcat 스레드가 1개임을 확인하는 방법!
최대 스레드는 힘을 쓰지 못한다.
왜? 어차피 요청을 받는 스레드는 Tomcat 스레드인데, 1개로 제한되어 있으니까!
그러면 100번의 요청을 한번에 보내도 입구가 1개이므로 2초 * 100 = 200초가 걸릴 것으로 예상할 수 있다.
최소 4개의 스레드가 비동기로 작업할 수 있게된다.
하지만 Tomcat 스레드가 1개이므로 어차피 입구가 한개이므로 어차피 200초일 것이다.
이전 결과와 같다!
Tomcat 스레드를 2개로 늘렸어도, 비동기 스레드 풀에 있는 스레드가 1개만 가용되어, 똑같이 200초가 걸린다.
처음에 Tomcat 스레드 1,2 가 동시에 인입됐지만, AsyncTask-1만 가용되면서 다음과 같은 결과가 나왔다.
예상은? 2개의 스레드가 병렬로 동시에 비동기 스레드 풀에 넘기면서 2배 빨라질 것으로 예상할 수 있다. 즉 100초가 걸릴 것이다.
동시에 실행되는 모습을 볼 수 있다!!