dev notes

Spring ThreadPoolTaskExecutor의 작업 수용 순서와 LLM 사용량 저장 설계

2026-05-218 min read
공유

maxPoolSize=5가 의미하는 것#

이전 글에서 LLM 사용량 저장을 별도 Executor로 분리하면서 core 2, max 5, queue 100을 설정했습니다. 숫자만 보면 요청이 몰릴 때 worker가 다섯 개까지 바로 늘어날 것 같지만, queueCapacity가 있는 ThreadPoolTaskExecutor는 그렇게 동작하지 않습니다.

두 개의 core worker가 실행 중이면 이후 작업은 먼저 queue로 들어갑니다. queue 100개가 모두 찬 뒤에야 세 번째 worker가 만들어지므로, 작업이 끝나지 않는 상황에서는 102번째 작업까지도 pool size가 2로 유지됩니다. maxPoolSize=5는 평상시 처리량이 아니라 queue 포화 이후에 사용할 수 있는 추가 처리량에 가깝습니다.

Core 이후 Queue를 먼저 채우는 순서#

이 동작은 Spring이 별도로 정한 규칙이 아니라 내부에서 사용하는 ThreadPoolExecutor의 작업 수용 순서에서 나옵니다. Java 17 문서는 worker가 core보다 적을 때 새 worker를 만들고, core에 도달하면 작업을 queue에 넣으며, queue가 더 이상 작업을 받지 못할 때만 max 범위에서 worker를 추가한다고 설명합니다.

ThreadPoolTaskExecutor도 같은 순서를 따릅니다. Spring Framework 5.3.39 문서를 보면 양수인 queueCapacityLinkedBlockingQueue의 크기로 사용됩니다. 현재 설정에 숫자를 대입하면 경계는 다음과 같습니다.

text
1~2번째 작업     core worker에서 실행
3~102번째 작업   queue에서 대기
103~105번째 작업 추가 worker에서 실행
106번째 작업     rejection handler로 전달

물론 이 경계는 앞선 작업이 하나도 끝나지 않았을 때의 값입니다. 작업이 처리되면서 queue에 자리가 생기면 새 작업은 다시 queue에 들어가고, max worker까지 늘어나지 않을 수도 있습니다.

설정값 경계 재현#

실행 중인 작업이 중간에 끝나지 않도록 CountDownLatch로 대기시킨 뒤, 2개와 102개, 105개, 106개를 차례로 제출했습니다. 운영 코드의 rejection handler는 Micrometer counter를 올리지만, ThreadPoolQueueOrder.java에서는 같은 흐름을 LongAdder로 단순화했습니다.

text
2 tasks: pool=2 active=2 queue=0 dropped=0
102 tasks: pool=2 active=2 queue=100 dropped=0
105 tasks: pool=5 active=5 queue=100 dropped=0
106 tasks: pool=5 active=5 queue=100 dropped=1

102개를 테스트에 넣었을 때 실제로 뜬 worker는 두 개였고, queue가 찬 이후에 pool이 다섯 개까지 늘었습니다. 그 상태에서 한 건을 더 넣었을때 worker나 queue에 자리가 없었기 때문에 rejection handler가 호출됐습니다.

사용량 저장에서 Queue를 유지한 이유#

사용량 저장은 핵심 비지니스로직과는 관련이 없고 DB에 관련 데이터를 저장만 하면 되었기에 저장을 몇 ms 더 빨리 끝내는 것보다 사용량 수집이 DB connection을 과도하게 점유하지 않도록 제한하는 쪽이 중요하다고 판단되었습니다. 따러서 평소에는 core worker 두 개로 처리하고, 이후 작업은 queue에 보관하는 전략을 활용했습니다.

max 5는 queue가 포화된 뒤 DB에 허용하는 동시 처리 값으로, 이 값을 키우면 Executor 안의 대기는 줄일 수 있지만, 동시에 실행되는 DB insert만 늘어나게 됩니다. 만약에 queue를 줄이게 된다면 추가 worker를 더 일찍 사용하는 대신 rejection도 빨라집니다. 따라서 세 값을 사용량 저장에 허용할 DB 동시성과 backlog를 함께 고려하는것이 좋습니다.

현재 queue 100으로 두었는데 이는 무제한 적재를 막으려는 목적이기도 합니다. queue와 max가 모두 찬 경우에는 고객 요청 스레드에서 저장을 대신 실행하지 않고 새 이벤트를 drop하여 llm.usage.async.dropped로 그 횟수를 남깁니다. 일부 누락을 허용할 수 있는 운영 데이터라서 이러한 선택을 하였고, 만약 과금체계같은 데이터라면 이 구조 대신 영속 큐나 outbox를 활용하는 것이 좋습니다.

운영에서 보는 기준#

설정 조정은 여러 메트릭을 확인하면서 조정이 필요한데 queue가 잠깐 늘었다가 다시 비워진다면 현재 완충 구간 안에서 처리되고 있는 것이고, 유입이 줄어든 뒤에도 queue가 남아 있다면 core worker의 처리 속도가 평상시 유입량을 따라가지 못하는 상태입니다. drop counter가 증가했을 때는 repository 저장 시간과 오류율, DB connection 사용량을 함께 확인합니다. DB가 이미 병목이라면 pool이나 queue를 늘려도 대기하는 위치만 달라질 수 있기 때문입니다.

따라서 core 2, max 5, queue 100을 범용적인 권장값으로 보지는 않습니다. 현재 사용량 저장의 처리 시간과 허용 가능한 유실 범위, DB에 배정할 수 있는 동시 요청 수를 기준으로 둔 운영 경계이며, 이 조건에 따라서 적절히 수정해서 사용해야합니다.

마무리하며#

다음 글에서는 Executor의 수용량을 벗어나 Redis와 @Async로 만든 Job의 실행 상태를 다룹니다. 같은 대상에 대한 시작 요청이 겹쳤을 때 중복 Job이 생성되고, 늦게 끝난 작업이 이미 확정된 상태를 덮어쓰는 문제는 Redis 기반 비동기 Job의 상태 전이에서 이어집니다.

Connected Notes