스레드 풀을 관리해보자.

반응형

프로세스와 쓰레드라는 개념은 알고 있었지만, 돌이켜보면 실제로 이를 "직접" 다루며 개발한 경험은 거의 없었습니다. 테스트 코드에서는 몇 번 사용해본 적이 있지만, 실 서비스 코드에서는 의식적으로 쓰레드를 생성하거나 제어할 일은 드물었습니다.

처음에는 그 이유를 단순히 "자바나 스프링이 알아서 처리해주기 때문"이라고 생각했습니다. 하지만 이 생각은 절반만 맞는 이야기였습니다. 실제로는 제가 쓰레드를 사용하지 않았던 것이 아니라, 이미 쓰레드 위에서만 동작하는 환경에서 개발하고 있었음에도 그 사실을 인식하지 못하고 있었던 것에 가깝습니다.

프로세스와 스레드에 간략하게 알아봅시다.

* 이 글은 프로세스와 스레드를 상세하게 작성하는 글은 아닙니다.

프로세스: 운영체제가 하나의 프로그램을 실행하기 위해 할당하는 자원 컨테이너입니다. 프로세스는 독립적인 메모리 공간을 가지며, 다른 프로세스와 메모리를 직접 공유하지 않습니다. 이 때문에 프로세스 간 통신은 IPC 같은 별도의 메커니즘이 필요하고, 비용도 상대적으로 큽니다.
스레드:  프로세스 내부에서 실제 코드를 실행하는 실행 단위입니다. 하나의 프로세스는 하나 이상의 스레드를 가질 수 있으며, 같은 프로세스에 속한 스레드들은 메모리 공간(힙, 메서드 영역 등)을 공유합니다. 대신 스택과 레지스터 같은 실행 컨텍스트는 각 스레드가 개별적으로 가집니다.

그렇다면 스레드 풀은 무엇일까?

비유를 들어보면, 프로세스는 하나의 건물, 스레드는 그 안에서 일하는 직원들이라고 볼 수 있습니다. 직원이 많을수록 일을 동시에 많이 처리할 수는 있지만, 무작정 사람을 계속 늘리면 관리 비용이 커지고 오히려 효율이 떨어집니다.

서버도 마찬가지입니다. 요청이 올 때마다 새로운 스레드를 계속 만들어 쓰는 방식은 생성 비용, 컨텍스트 스위칭 비용, 자원 고갈 문제로 이어질 수 있습니다. 그렇다고 해서 프로세스를 무한정 늘릴 수도 없습니다.

그래서 등장한 것이 스레드를 미리 일정 개수만 만들어 두고, 필요할 때 재사용하는 구조, 즉 스레드 풀(Thread Pool) 입니다. 스레드 풀은 스레드의 개수를 제한하고, 재사용함으로써 시스템 자원을 안정적으로 통제하는 장치라고 볼 수 있습니다.


정리해보면, 스레드 풀(Thread Pool)이란 동시에 실행될 수 있는 스레드의 개수를 제한해 두고, 그 범위 안에서 작업을 처리하도록 제어하는 장치라고 볼 수 있습니다. 무한정 스레드를 생성하는 대신, 제한된 자원 안에서 시스템을 안정적으로 운영하기 위한 실행 제어 메커니즘인 셈입니다.

그렇다면, 이런 스레드 풀은 실제로 어떻게 생성되고, 우리는 어디서부터 사용하게 되는 걸까요?

가장 먼저 자바 부터 확인해봅시다.

자바는 Executor 프레임워크를 통해 스레드 풀을 구성합니다. 개발자가 Thread 를 직접 생성해서 실행하는 방식이 아니라, 실행기(Executor)에게 "작업"을 제출하고, 실행 방식은 런타임에게 위임하는 구조를 채택하고 있습니다.

스레드 풀(Thread Pool)은 대기열에 쌓인 작업(Task) 을, 미리 생성해 둔 제한된 개수의 스레드가 하나씩 가져가서 처리하도록 만드는 구조입니다. 이를 통해 동시에 실행되는 작업의 수를 통제하고, 무분별한 스레드 생성으로 인한 자원 고갈을 방지할 수 있습니다. 즉, 스레드 풀은 시스템 자원을 안정적으로 관리하기 위한 실행 제어 장치라고 볼 수 있습니다.

자바의 스레드 풀은 다음과 같은 구조를 가집니다.

[ Task Queue ] ---> [ ThreadPoolExecutor ] ---> [ Worker Threads ]
 
그리고 개념적으로 자바의 스레드 풀은 다음 세 요소의 조합으로 구성됩니다.
  • ThreadPoolExecutor: 전체 실행 흐름을 제어하는 컨트롤 타워
  • WorkQueue: 아직 실행되지 못한 작업(Task)이 대기하는 큐
  • Policy: 큐가 가득 찼을 때의 처리 전략(거절 정책 등)

즉, 흔히 말하는 "Thread Pool"은 단순히 스레드들의 묶음이 아니라, 작업을 큐잉하고, 제한된 스레드로 실행을 통제하는 실행 관리 시스템에 가깝습니다.

ThreadPoolExecutor는 내부적으로 corePoolSize, maximumPoolSize, workQueue, rejectPolicy를 조합하여 동시에 실행될 수 있는 작업(Task)의 개수와 시스템 부하를 통제합니다. 이 값들은 단순한 옵션이 아니라, "이 시스템이 어떤 방식으로 트래픽을 처리할 것인가"를 결정하는 실행 모델이라고 볼 수 있습니다.

  • corePoolSize: 기본 유지 스레드 수
  • maximumPoolSize: 최대 허용 스레드 수
  • workQueue: 작업 대기열
  • rejectPolicy: 큐가 꽉 찼을 때 정책

그렇다면 스프링 부트는 어떻게 하고 있을까?

Spring Boot는 Thread Pool을 직접 만들지 않습니다. 대신 WAS(Tomcat, Netty, Undertow)가 만들어 둔 Thread Pool 위에서 동작합니다. 여기서 말하는 WAS는 HTTP 요청을 받아 처리해주는 웹 서버 역할을 하는 실행 환경을 의미합니다.

그렇다면 WAS가 말하는 "스레드"는 무엇일까요? WAS의 스레드는 HTTP 요청을 처리하기 위해 할당되는 Worker Thread, 즉 실행 주체를 의미합니다.

HTTP 요청 한 개는 하나의 작업(Task) 이며, Tomcat은 이 요청(Task)을 처리하기 위해 Thread Pool에서 Worker Thread 하나를 할당합니다. 이 스레드는 요청 처리가 끝나면 다시 풀로 반환되어, 다음 요청을 처리하는 데 재사용됩니다.

그러면, 요청에 대한 제한도 할 수 있지 않을까?

앞에서 자바의 ThreadPoolExecutor를 살펴보면서, 스레드 풀은 여러 파라미터의  "조합" 으로 실행 모델이 결정된다는 것을 확인했습니다. 이제 그 개념을 스프링 부트 설정과 매핑해보면, 실제로 거의 1:1로 대응된다는 것을 알 수 있습니다.
스프링 부트의 비동기 Executor 설정은 내부적으로 ThreadPoolExecutor를 생성하며, 각 설정 값은 다음과 같이 매핑됩니다.

Spring 설정
ThreadPoolExecutor
core-size corePoolSize
max-size maximumPoolSize
queue-capacity workQueue capacity
keep-alive keepAliveTime

그렇다면 이들은 어떤것들을 제한을 하는 걸까요?

core-size

항상 유지되는 즉시 처리 가능한 동시 실행량을 제한합니다. 이 말은 최소한 이만큼은 대기 없이 바로 실행이 가능하다는 뜻입니다.
예를 들어,

core-size: 4

이 경우, 최소 4개의 작업(Task)은 대기 없이 즉시 실행됩니다. 이는 동시에 4개의 작업을 처리할 수 있는 실행 자원을 항상 확보해두겠다는 의미이며, 시스템의 기본 처리 용량(baseline throughput) 을 정의하는 값이라고 볼 수 있습니다.

max-size

max-size는 아무리 많은 작업(Task)이 들어오더라도, 동시에 실행될 수 있는 작업(Task)의 절대적인 상한선을 의미합니다. 이 값을 넘어서는 작업은 어떤 경우에도 동시에 실행되지 않으며, 큐에 대기하거나(가능하다면), 그렇지 않으면 거절(reject)됩니다. 즉, max-size는 이 시스템이 감당하겠다고 허용한 최대 동시 실행량의 한계선입니다. 그러면 core-size와 max-size는 어떤 관계가 있을까요?

이 둘의 차이는, core-size는 시스템이 평소에 유지하는 기본 처리 용량이고, max-size는 트래픽이 폭주했을 때 일시적으로 확장할 수 있는 최대 처리 용량의 한계라는 점입니다.

queue-capacity

queue-capacity는 현재 즉시 실행할 수 없는 작업(Task) 들을 얼마나 대기시켜 둘 수 있는지를 결정하는 값입니다. 즉, 처리 가능한 스레드가 모두 사용 중일 때, 새로 들어온 작업들을 얼마나 버퍼링(완충)할 것인지를 정하는 설정입니다.
그렇다면 어떻게 잡는것이 좋을까요?

queue-capacity를 크게 잡는 경우

  • 순간적으로 몰리는 트래픽을 흡수하는 데에는 유리합니다.
  • 하지만 그만큼 많은 요청이 대기 상태로 쌓이게 되므로, 응답 지연(latency)이 증가하고, 타임아웃이 늘어나며, 메모리 사용량도 함께 증가하게 됩니다.
  • 결국 시스템은 바로 죽지는 않지만, 점점 느려지다가 무너지는 느리게 죽는(slowly dying) 구조가 되기 쉽습니다.

반대로, queue-capacity를 너무 작게 잡는 경우:

  • 처리하지 못하는 요청을 빠르게 실패시키기 때문에, 시스템 보호(Fail Fast) 측면에서는 유리합니다. 더 이상 요청을 쌓지 않기 때문에, 시스템 부하가 임계점을 넘어 계속 누적되는 상황을 막을 수 있습니다.
  • 하지만, 순간적인 트래픽 스파이크에도 민감하게 반응하여, 실제로는 처리할 수 있었던 요청들까지 불필요하게 실패시킬 가능성이 커집니다.

Keep-alive

keep-alive는 core-size를 초과하여 생성된 스레드(확장 스레드) 를 얼마나 오래 유지할지를 결정하는 값입니다.
즉, 트래픽 폭주로 인해 스레드 풀이 일시적으로 확장되었을 때, 부하가 다시 줄어들면 얼마나 기다렸다가 스레드 수를 다시 core-size 수준으로 줄일 것인지를 정하는 정책입니다.


요청에 대한 제한은 단순히 스레드 풀의 크기나 큐 길이 같은 숫자 설정값만으로 해결되는 문제가 아닙니다. 이런 설정들은 서버가 감당할 수 있는 물리적인 한계를 정해주는 장치일 뿐, 어떤 요청을 어떻게 처리할지에 대한 정책적인 판단까지 포함하지는 않습니다.

이런 "더 이상 처리할 수 없는 상황"에서, 들어온 요청(Task)을 어떻게 처리할 것인지를 결정하는 것이 바로Policy(RejectedExecutionHandler) 입니다.

자바의 ThreadPoolExecutor는 기본적으로 다음과 같은 4가지 정책을 제공합니다.

AbortPolicy(기본값)

AbortPolicy는 스레드 풀과 큐가 모두 포화 상태일 때, RejectedExecutionException 예외를 던지고 즉시 작업을 실패시키는 정책입니다.

이 정책의 특징은 Fail-Fast 성향이 매우 강하다는 점입니다. 더 이상 처리할 수 없는 상황이 되면, 문제를 숨기지 않고 즉시 예외로 드러내기 때문에 장애 상황을 빠르게 인지할 수 있습니다.

다만, 트래픽이 몰리는 상황에서는 대량의 요청이 한꺼번에 예외로 실패하면서, 예외 로그가 폭증하는 현상이 발생할 수 있습니다. 즉, 시스템이 버티지 못하는 상황을 명확하게 드러내는 대신, 사용자 입장에서는 실패가 급격하게 증가하는 형태로 보이게 됩니다.

executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());

DiscardPolicy

DiscardPolicy는 스레드 풀과 큐가 모두 포화 상태일 때, 아무 예외도 발생시키지 않고 해당 작업(Task)을 조용히 버리는 정책입니다.

이 정책은 겉보기에는 시스템이 멀쩡하게 동작하는 것처럼 보이지만, 실제로는 요청이 아무 흔적도 없이 유실되는 매우 위험한 상태를 만들 수 있습니다. 개발자 입장에서는 어디에서, 어떤 작업이, 왜 사라졌는지조차 추적하기 어려운 상황이 발생합니다.

결과적으로 DiscardPolicy는:

  • 데이터 유실 가능성
  • 장애 인지 불가
  • 원인 추적 불가능

이라는 치명적인 문제를 내포하고 있기 때문에, 대부분의 서버 사이드 애플리케이션에서는 사용하면 안 되는 정책에 가깝습니다.

executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());

DiscardOldestPolicy

DiscardOldestPolicy는 스레드 풀과 큐가 모두 포화 상태일 때, 큐에 대기 중인 작업(Task) 중 가장 오래된 하나를 버리고, 새로운 작업을 그 자리에 넣는 정책입니다. 즉, 항상 최신 요청을 우선시하는 전략이라고 볼 수 있습니다.

이 정책의 단점은 명확합니다.

  • 작업 처리 순서가 보장되지 않습니다.
  • 큐에 오래 대기하고 있던 중요한 작업이 예고 없이 유실될 수 있습니다.

그런 의미에서, DiscardOldestPolicy는 단순히 작업을 조용히 버리는 DiscardPolicy보다는 "조금 더 적극적인" 형태이긴 하지만, 여전히 데이터 유실과 정합성 붕괴라는 치명적인 위험을 안고 있는 정책이라고 볼 수 있습니다.

체감적으로는, DiscardPolicy의 "업그레이드된 변종" 에 가깝지만, 근본적인 문제(유실과 추적 불가)를 해결해주지는 못합니다.

executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardOldestPolicy());

callerRunsPolicy

CallerRunsPolicy는 스레드 풀과 큐가 모두 포화 상태일 때, 작업(Task)을 제출한 쪽(Caller)에서 해당 작업을 직접 실행하도록 만드는 정책입니다.

이는 원래 작업 처리가 분담되어 있던 구조에서, "지금 너무 바쁘니 네가 가져온 일은 네가 직접 처리하고 가라"라고 역할을 되돌려주는 방식에 가깝습니다.

이 정책이 적용되면, Task를 제출한 스레드가 직접 해당 작업을 수행하게 되며, 그 결과 요청을 많이 보내는 쪽일수록 더 오래 묶이게 되는 구조가 만들어집니다. 이를 통해 시스템 전체에 자연스러운 감속(Back-pressure) 효과가 발생합니다.

특징적으로, 이 방식은 예외 폭탄이나 조용한 데이터 유실 대신, 시스템 전체가 점진적으로 느려지는 형태로 부하를 흡수하게 만들며, 그 결과 쉽게 터지지 않는(Graceful Degradation) 특성을 가지게 됩니다.

executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());

결론

정리해보자면 스레드 풀은 스레드를 일정 수치만큼 제한해서 사용이 되어지는 것을 말합니다. 자바에서는 Executor 프레임워크를 이용하고 스프링 부트는 WAS에서 스레드풀에서 작업을 제한하고 있습니다. core-size, max-size, queue-capacity, keep-alive등을 이용해서 개발자가 직접 스레드 풀을 관리할 수 있습니다. 만약 더 이상 요청을 받을 수 없는 상황이라면, 정책들을 이용해서 처리를 진행해야 합니다. 자바에서는 4가지의 정책을 제공하고 있습니다.abort, discard, discardOldest, callerRuns이렇게 있습니다.

반응형

'개발' 카테고리의 다른 글

REST API란 무엇일까?  (0) 2026.01.14
filter vs interceptor vs AOP  (1) 2026.01.13
새로운 인증 전략 PASETO  (1) 2026.01.11
캐시를 이용하는 방법들  (0) 2026.01.09
Lint는 무엇인가?  (1) 2026.01.08

댓글

Designed by JB FACTORY