영상 처리 요청이 동시에 여러 개가 들어오면 어떻게 될까?
이전에 사용자가 영상을 녹화한 후 대기시간을 줄이고자 영상 처리 작업을 비동기 처리한 바 있다.
이 비동기 작업은 크기가 4인, 즉 최대 4개의 작업을 동시에 병렬적으로 할 수 있는 별개의 스레드 풀에서 실행된다.
@Bean(name = "videoProcessingExecutor")
public Executor videoProcessingExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("VideoProcessing-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
CPU bound 작업의 경우에는 CPU 코어 개수와 동일하게 스레드 풀의 크기를 정하는 게 좋다.
IO Bound 작업의 경우에는 코어의 개수 뿐만 아니라 대기 시간을 고려해서 스레드 풀의 크기를 결정해야 한다.
그렇다면 내 프로젝트의 영상처리는 둘 중 어떤 작업에 해당하는가?
getVideoDurationFromS3, uploadThumbnailToS3, processShortVideoWithPresignedUrl, processLongVideoWithPresignedUrl)는 네트워크 I/O에 의존.videoRepository.findById, videoRepository.save)은 데이터베이스 I/O 작업.notificationService.send)은 외부 시스템과의 통신.processLongVideoWithPresignedUrl에서 병렬 처리로 CPU 사용량 증가.updateVideoThumbnailAndStatusvideoRepository.findById)와 저장(videoRepository.save)은 데이터베이스 I/O 작업.handleInvalidAnswervideoRepository.findById, videoRepository.save)은 I/O 작업.notificationService.send)은 외부 시스템과의 통신으로 네트워크 I/O.handleValidAnswervideoRepository.findById, videoRepository.save)은 I/O 작업.feedbackService.getFeedback, feedbackService.findFeedback)은 외부 시스템 또는 데이터베이스 I/O 포함 가능.notificationService.send)은 네트워크 I/O.handleErrorvideoRepository.findById, videoRepository.save)은 I/O 작업.notificationService.send)은 네트워크 I/O.isValidAnswergetVideoDurationFromS3headObject)는 네트워크 I/O 작업.getEstimatedBitrate)은 가벼운 CPU 연산.getEstimatedBitrateprocessShortVideoWithPresignedUrls3Client.putObject)는 네트워크 I/O.startTranscriptionJob, getTranscriptionResult)은 네트워크 I/O.ProcessBuilder로 FFmpeg 실행)은 비디오 디코딩 및 오디오 인코딩으로 CPU 집약적.processLongVideoWithPresignedUrlExecutorService)로 CPU 사용량 증가.uploadThumbnailToS3s3Client.putObject)는 네트워크 I/O.ProcessBuilder로 FFmpeg 실행)은 비디오 디코딩 및 이미지 변환으로 CPU 집약적.startTranscriptionJobtranscribeClient.startTranscriptionJob)은 네트워크 요청.getTranscriptionResulttranscribeClient.getTranscriptionJob)하며, 네트워크 요청과 응답 대기가 주요 시간.Thread.sleep)은 CPU가 아닌 스레드 대기 상태.| 메서드 | 주요 특성 | 이유 |
|---|---|---|
processVideoAsync | I/O 바운드 | S3, Transcribe, DB, 알림 I/O가 주도적, FFmpeg는 일부 CPU 바운드 |
updateVideoThumbnailAndStatus | I/O 바운드 | DB 조회/저장 I/O |
handleInvalidAnswer | I/O 바운드 | DB 조회/저장, 알림 전송 I/O |
handleValidAnswer | I/O 바운드 | DB 조회/저장, 피드백 서비스, 알림 전송 I/O |
handleError | I/O 바운드 | DB 조회/저장, 알림 전송 I/O |
isValidAnswer | CPU 바운드 | 문자열 처리(가벼운 CPU 연산) |
getVideoDurationFromS3 | I/O 바운드 | S3 메타데이터 조회 I/O, 가벼운 비트레이트 계산 |
getEstimatedBitrate | CPU 바운드 | 문자열 비교 및 조건문(가벼운 CPU 연산) |
processShortVideoWithPresignedUrl | I/O 바운드 | S3, Transcribe I/O가 주도적, FFmpeg는 일부 CPU 바운드 |
processLongVideoWithPresignedUrl | I/O 바운드 | S3, Transcribe I/O가 주도적, 병렬 FFmpeg로 CPU 바운드 비중 큼 |
uploadThumbnailToS3 | I/O 바운드 | S3 업로드 I/O가 주도적, FFmpeg는 일부 CPU 바운드 |
startTranscriptionJob | I/O 바운드 | AWS Transcribe API 호출 I/O |
getTranscriptionResult | I/O 바운드 | AWS Transcribe API 폴링, 네트워크 대기 I/O |
실제로는 HTTP 커넥션 풀 뿐만 아니라 JDBC 커넥션 풀, JMS로 부터의 요청 등 더 많은 요소들을 고려해야 한다.
따라서 여러 클래스에서 각자의 스레드 풀, 즉 여러 개의 스레드 풀이 존재한다면 각자의 워크로드에 따라 이 수치를 조정해야 한다. 이 경우 CPU 목표 사용률을 공식에 추가해 줄 수 있다.

적정 스레드 개수를 구하는 공식은 대략적으로라도 알았다. 그렇다면 이러한 쓰레드의 개수가 지연시간이나
처리량(시스템이 처리 가능한 처리량)에 미치는 영향을 계산할 수 있는 방법은 없을까?
리틀의 법칙을 이용한다면 계산할 수 있다.
리틀의 법칙은 MIT 교수 리틀이 제시한 재고 산정 법칙으로 많은 분야에서 사용된다. IT 분야에선 이를 응용해 성능 평가에 많이 사용된다.
IT 분야에서 사용되는 리틀의 법칙을 설명하자면 다음과 같다.
L = λ * W
예를 들어 평균 응답시간이 55ms이고 스레드 풀의 크기가 22라고 하자. 리틀의 법칙을 적용해 시스템이 처리 가능한 평균 처리량을 구하면 다음과 같다.
22/0.055 = 400 -> 시스템이 1초당 처리할 수 있는 요청의 개수는 400개이다.
이처럼 공식을 이용하면 이상적인 스레드 풀의 적정 개수를 구할 수 있지만, 이것은 실제로 모든 프로젝트에 적용시키는 것에 대해서는 무리가 있다. 시스템에 요청이 일정하게 들어오면 좋겠지만, 트래픽이 폭증하는 등 평균 요청 개수는 들쑥날쑥할 수 있다. 따라서 위 공식은 참고용으로 사용하고 많은 테스트를 거쳐 적절한 스레드 풀의 크기를 정하는 것이 중요하다고 하겠다.
그러므로 일단은 스레드 풀의 크기를 4개로 설정한 후, 처리량 및 CPU 사용률을 측정해보자.
만약 10개의 요청이 동시에 들어온다면 어떻게 될까?
먼저 들어온 4개의 요청이 병렬적으로 처리되고 나머지 6개의 요청은 대기 큐에 들어갈 것이다.
앞서 스프링부트에서 동시요청을 어떻게 하는지 알아본 바 있다.
스프링부트가 아닌 내장 서블릿 컨테이너인 톰캣에서 다중 요청을 처리한다.
톰캣 설정 디폴트값:
public Tomcat() {
this.uriEncoding = StandardCharsets.UTF_8; // 클라이언트 요청 URI를 해석할 때 사용할 문자 인코딩 (기본: UTF-8)
this.maxConnections = 8192; // 동시에 수립 가능한 최대 커넥션 수
this.acceptCount = 100; // 요청 대기 큐의 최대 크기 (커넥션이 가득 찬 상태에서 추가 요청이 대기할 수 있는 개수)
this.processorCache = 200; // 요청을 처리할 프로세서 객체를 캐시할 수 있는 최대 개수
this.maxKeepAliveRequests = 100; // Keep-Alive 상태에서 하나의 커넥션이 처리할 수 있는 최대 요청 수
/.../
}
public static class Threads {
private int max = 200; // 최대 스레드 수 (이 수 이상은 생성되지 않음)
private int minSpare = 10; // 항상 유지할 여유 스레드 수 (즉시 처리 가능한 상태로 대기)
private int maxQueueCapacity = Integer.MAX_VALUE; // 요청 처리 대기 큐의 최대 용량 (Integer.MAX_VALUE는 사실상 무제한)
/.../
}

현재 설정해둔 값은 다음과 같다.

요청 처리 스레드의 크기가 10이므로 동시 요청 10개를 받을 수 있고 즉시 응답한다. 영상 처리 작업 스레드로 작업을 넘긴 후 완료되면 결과를 전달하고 요청 스레드를 스레드풀에 반환할 것이다.
이제 영상 처리 요청 10개를 동시에 생성하고
확인해보자.