지난 포스팅에 SNS, SQS를 활용한 비동기 썸네일추출 환경을 구성했다.
위 사진을 보면 worker-thumbnailator 라고 이름을 정의했는데, 처음에는 worker 서비스였다.
썸네일 추출 서버를 개발하는 도중에 큰 문제가 발생해 worker-thumbnailator 로 수정하고 별개의 서비스로 구성했다. 그 과정에 대해 기록하려고한다.
우선 썸네일 추출 이벤트가 어떻게 워커프로세스에 전달되는지부터 설명한다.
FE에서 이미지를 묶어서 api서버에 업로드 요청하면 api 서버에서 사내 minio 저장소에 이미지를 업로드하고, 이미지 업로드 이벤트를 발행한다. 해당 이벤트를 sqs에 적재해 worker 프로세스에서 사용하는 방식이다.
FE에서 이미지를 업로드할때 한번에 수백, 수천장의 이미지를 선택하고 업로드 할 수 있는데 하나의 요청에 담아 처리하면 비효율적이다. chunk로 나눠 병렬적으로 업로드를 하는데, 초기에는 chunk size를 20으로 다소 크게잡았다. 얘를들어 이미지를 1000장 업로드하면 20개로 뭉쳐진 50개의 묶음이 요청되는 방식이다.
즉, api -> sns -> sqs 로 전파되는 이벤트에도 20개의 이미지가 묶여서 처리된다는 의미다.
별 문제없어 보일 수 있지만 썸네일을 추출하는 작업은 생각보다 많이, 아주 많이 무거운 작업이었다.
worker 프로세스에서 이벤트가 처리되는 과정은 다음과같다.
1. 20개(chunk)의 imageId를 담고있는 이벤트를 listen한다.
2. minio에서 이미지를 다운로드한다.
3. 받은 이미지의 썸네일을 추출한다.
4. 추출한 썸네일 파일을 minio에 업로드하고, db에 반영한다.
5. 다운로드, 추출한 파일을 모두 디스크에서 삭제한다.
이미지 다운로드, 썸네일 추출 등의 작업은 코루틴을 사용해 병렬처리했다.
개발환경은 m1 macbook pro, memory 32GB
테스트에 사용한 이미지 크기는 1~2MB
이정도 사양인데 별 문제없겠거니 하는 마음으로 테스트를 진행했는데, 해당 워커프로세스의 메모리 사용량이 11GB 까지 치솟았고 처리속도 역시 느렸다.
뭔가 잘못됐다는걸 직감했다.
이미지 몇장 처리하는데 이러면 어떻게 서비스해야하지? 뭘 잘못한거지?
처음부터 되짚어보며 문제점을 찾기 시작했는데, sqs listener 사용하는 방식에서부터 문제가 있었다.
sqs listener는 이벤트를 하나씩 처리하지 않는다. 이벤트를 받아와 계속 처리하는데 이 서비스같이 처리에 오랜 시간이 걸리는경우 동시에 많은 이벤트를 처리하게된다. 이 문제를 해결하는 방법으로 ThreadPool을 사용하는 방법이 있었다.
@Configuration
class ThreadPoolConfig {
@Bean(THREAD_POOL_QUEUE_NAME)
fun threadPoolTaskExecutor(): ThreadPoolTaskExecutor {
val executor = ThreadPoolTaskExecutor()
executor.corePoolSize = POOL_SIZE
executor.maxPoolSize = POOL_SIZE
executor.setQueueCapacity(0)
executor.setRejectedExecutionHandler(BlockingTaskSubmissionPolicy(1000))
executor.initialize()
return executor
}
companion object {
const val THREAD_POOL_QUEUE_NAME = "threadPoolQueue"
private const val POOL_SIZE = 5
}
}
class BlockingTaskSubmissionPolicy(private val timeout: Long) : RejectedExecutionHandler {
override fun rejectedExecution(r: Runnable, executor: ThreadPoolExecutor) {
try {
val queue = executor.queue
if (!queue.offer(r, this.timeout, TimeUnit.MILLISECONDS)) {
throw RejectedExecutionException("The Thread Pool is full")
}
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
}
}
}
위와같이 쓰레드풀을 만들고, pool size를 넘어선 작업을 시도하려고 하면 예외처리하는것이다.
sqs listener에서 삭제정책을 SqsMessageDeletionPolicy.NEVER 로 사용하고있기 때문에 명시적으로 ack를 해주지않으면 해당 이벤트는 삭제되지 않는다.
만약 작업할 쓰레드가 없는경우 ack를 하지 못하고, 재시도하게된다.
class UploadImageEventConsumer(
@Qualifier(ThreadPoolConfig.THREAD_POOL_QUEUE_NAME) private val taskExecutor: ThreadPoolTaskExecutor,
...
) {
@SqsListener(
value = ["\${aws.sqs.upload-source-image}"], deletionPolicy = SqsMessageDeletionPolicy.NEVER,
)
@OptIn(ExperimentalTime::class)
fun consumeMessage(@Payload message: List<Long>, ack: Acknowledgment) {
taskExecutor.submit {
...
ack.acknowledge()
}
}
위와같이 개선했음에도 불구하고, 여전히 말도안되는 메모리 사용량과 느린 속도로 처리되고있었다.
운영서버에서 oom이 발생하지 않을 정도가지는 처리량을 내려야했다.
FE -> api 서버로 요청하는 chunk size를 20개에서 5개로 줄이고, worker 프로세스의 threadpool size도 5에서 2로 줄였다.
위와같이 처리량을 줄이니 메모리 사용량이 4GB 아래에서 유지되었다. 이 상태로 수평확장해 사용하기로했다.
썸네일은 당장 필요하지 않기떄문에 FE에서는 썸네일이 없으면 원본이미지를 사용한다.
낮은 처리량으로 점진적으로 추출해 갱신하기로 협의했다.
그래도 문제를 해결하고싶었기에 다른방법도 찾아봤다.
썸네일을 뽑는 라이브러리로 thumbnailator 를 사용하고있는데, issue에 메모리 관련 이슈들이 꽤 많았고 명확한 해결법은 나오지 않은 상태로 보인다.
그래서 다른 라이브러리를 사용해봤다.
Graphics2D (Java Platform SE 8) 자바 내장클래스를 사용해서 시도해봤는데, 품질이 너무 떨어지고 원본 비율을 유지하며 리사이징 하는 과정이 원만히 처리되지 않았다.
thumbnailator에 비해 속도나 메모리 측면에서는 다소 양호했으나 여전히 아쉬운부분들이 있어 사용하지 않았다.
이쯤되니 GC의 한계로 생각됐다. 이벤트를 처리해도 바로바로 메모리가 해제되지 않으니 누적되는 자원이 많았다. 라이브러리나 코드상으로 개선은 어렵다고 판단했다.
worker-thumbnailaor 서비스를 golang 등의 언어로 포팅하는게 더 효과적일 것 같다.