← Posts

Thread Pool · Concurrency · Java

Thread Pool

스레드는 만드는 것도 무제한도 비싸다. 미리 만들어 재사용하고, 그 개수로 동시 실행을 제한하는 것 - Thread Pool.

목차
  1. 요청마다 스레드를 새로 만들면
  2. 스레드는 만드는 것도 무제한도 비싸다
  3. Thread Pool: 미리 만들어 재사용한다
  4. 재사용이 생성 비용을 없앤다
  5. 크기가 곧 상한이다
  6. 다 바쁘면 큐에서 기다린다
  7. 큐도 차면: 거부한다
  8. 풀 크기는 얼마로
  9. 실무: 어디에 있나
  10. 정리

요청마다 스레드를 새로 만들면

Concurrency에서 서버가 요청마다 스레드를 붙여 동시에 처리한다고 했다. 그러면 자연스러운 구현은 이렇다. 요청이 오면 스레드를 하나 만들고, 처리가 끝나면 버린다.

언뜻 문제없어 보인다. 요청 하나에 스레드 하나, 깔끔하다. 그런데 부하가 조금만 올라가면 두 가지가 무너진다.

스레드는 만드는 것도 무제한도 비싸다

첫째, 만드는 게 비싸다. 스레드 하나를 만드는 건 공짜가 아니다. OS에 등록하고, 스택 메모리(보통 수백 KB에서 1MB)를 잡는다. 요청마다 만들고 버리면, 매 요청에 이 비용을 처음부터 다시 낸다.

둘째, 무제한이면 자원이 고갈된다. 요청이 1만 개 몰리면 스레드가 1만 개 생긴다. 스택 메모리만 수 GB다. 게다가 CPU가 진짜로 동시에 돌리는 건 코어 수만큼인데, 스레드가 1만 개면 **누구를 돌릴지 갈아끼우는 일(컨텍스트 스위칭)**에 시간을 다 쓴다.

diagramdiagram

그래서 스레드는 늘릴수록 빨라지는 게 아니다. 어느 지점을 넘으면 오히려 느려지고 결국 죽는다. 두 문제 다 “요청마다 새로, 무제한으로”에서 온다.

Thread Pool: 미리 만들어 재사용한다

해법은 스레드를 미리 정해진 개수만큼 만들어두고 재사용하는 것이다. 이 스레드 묶음이 thread pool이다.

작업이 오면 노는 스레드에 맡긴다. 스레드는 작업을 끝내도 죽지 않고 풀로 돌아가 다음 작업을 기다린다.

diagramdiagram

재사용이 생성 비용을 없앤다

핵심은 **빌리고(borrow) 반납(return)**하는 구조다. 만드는 건 시작할 때 한 번뿐이고, 이후엔 같은 스레드가 작업을 계속 갈아가며 처리한다. 요청마다 내던 생성 비용이 사라진다.

Java에서는 ExecutorService가 이걸 해준다.

java
ExecutorService pool = Executors.newFixedThreadPool(10);  // 스레드 10개를 미리 만든다

pool.submit(() -> handle(request));   // 작업을 맡긴다 - 노는 스레드가 집어 처리

submit한 작업은 풀 안의 스레드가 집어 처리하고, 그 스레드는 끝나면 다음 작업으로 넘어간다. 우리는 스레드를 직접 만들지도 버리지도 않는다.

크기가 곧 상한이다

풀의 두 번째 값어치가 여기 있다. 풀 크기가 동시 실행 수의 상한이 된다.

스레드 10개짜리 풀이면, 요청이 아무리 몰려도 동시에 도는 건 10개까지다. 나머지는 기다린다. 이게 자원을 지킨다. 무제한이면 자원 고갈로 죽지만, 상한이 있으면 “여기까지만” 하고 버틴다.

즉 풀 크기는 재사용을 위한 숫자이면서 동시에 부하의 상한이다. 스레드를 늘리고 싶어도 이 숫자가 막아준다.

다 바쁘면 큐에서 기다린다

스레드 10개가 다 바쁠 때 11번째 작업이 오면 어떻게 되나. 버리지 않는다. 작업 큐에 넣어둔다. 스레드가 하나 비면 큐에서 다음 작업을 꺼내 처리한다.

이 큐가 순간적인 몰림(burst)을 흡수한다. 요청이 잠깐 튀어도 큐에 쌓였다가 차례로 처리된다. 처리 속도보다 빨리 들어오면 큐에 밀리는 것이라, 일종의 백프레셔로 볼 수 있다.

큐도 차면: 거부한다

그럼 큐를 무한대로 두면 되나. 안 된다. 무한 큐는 무제한 스레드와 똑같은 함정이다. 요청이 처리 속도보다 계속 빨리 오면 큐가 끝없이 쌓여 메모리를 먹고, 큐에서 오래 기다린 요청은 어차피 타임아웃돼 쓸모없어진다.

diagramdiagram

그래서 큐에도 상한을 두고, 풀도 큐도 다 차면 새 작업을 거부한다. “지금은 더 못 받는다”를 빨리 알리는 게, 끝없이 받아 다 같이 죽는 것보다 낫다. 거부를 어떻게 할지(에러를 던지거나, 호출한 쪽이 대신 처리하게 하거나)가 거부 정책이다. 풀 크기 + 큐 크기 + 거부 정책이 한 세트다.

참고

함정 하나. Executors.newFixedThreadPool(...)은 사실 큐가 무한이다. 그래서 몰림이 오면 스레드가 아니라 큐가 끝없이 쌓인다. 이 때문에 실무에선 ThreadPoolExecutor를 직접 만들어 큐 크기와 거부 정책까지 정해주는 경우가 많다. 편해 보이는 기본값이 위 함정을 그대로 열어두는 셈이다.

풀 크기는 얼마로

그럼 몇 개가 적당한가. 정답은 작업의 성격에 달렸다.

  • CPU 바운드(계산 위주) - 스레드가 CPU를 계속 쓴다. 코어 수보다 많이 만들어봐야 컨텍스트 스위칭만 는다. 코어 수 근처가 적당하다.
  • IO 바운드(DB·외부 API 호출 위주) - 스레드가 대부분 응답을 기다린다. 기다리는 동안 다른 스레드가 CPU를 쓰면 되니, 코어 수보다 많이 둬도 이득이다. 얼마나 기다리느냐에 비례해 늘린다.

웹 서버의 요청 처리는 대개 DB 조회를 기다리는 IO 바운드라, 코어 수보다 넉넉히 잡는다. 다만 무작정 크게 잡으면 상한 보호가 약해진다. 그래서 뒤에 있는 자원(특히 DB)이 감당할 수 있는 선에서 정한다.

실무: 어디에 있나

직접 만들지 않아도 스레드 풀은 이미 곳곳에 있다.

  • 웹 서버 - Tomcat은 요청을 처리하는 스레드 풀을 갖고 있다. server.tomcat.threads.max 같은 설정이 그 크기다. Concurrency에서 본 “요청마다 붙는 스레드”가 사실 이 풀에서 나온다.
  • Java - ExecutorService·ThreadPoolExecutor가 표준이다.
  • Spring - 메서드에 @Async를 붙이면 별도 풀에서 실행된다(어느 풀을 쓸지 지정할 수 있다).

그리고 방금 “뒤에 있는 자원이 감당할 수 있는 선”이라고 했는데, 그 뒤에 있는 게 대개 DB다. 스레드 10개가 동시에 DB를 부르면 DB 연결도 그만큼 필요하다. 그 연결 역시 매번 만들면 비싸고 무제한이면 위험하다. 똑같은 풀링을 연결에 적용한 것이 Connection Pooling이고, 그게 이 이야기의 다음 편이다.

정리

왜 필요스레드는 만드는 것도 비싸고, 무제한이면 자원이 고갈돼 죽는다
Thread Pool미리 N개를 만들어두고 재사용한다 (빌리고 반납)
재사용생성 비용을 시작 때 한 번만 낸다
크기 = 상한풀 크기가 동시 실행 수의 상한. 부하로부터 자원을 지킨다
스레드가 다 바쁘면 작업은 큐에서 대기 (몰림 흡수)
거부큐도 차면 거부한다. 무한 큐는 무제한 스레드와 같은 함정
크기 정하기CPU 바운드 → 코어 수 근처 / IO 바운드 → 더 넉넉히
실무Tomcat 요청 풀 · ExecutorService · @Async

스레드를 아끼자는 게 요점이 아니다. 미리 만들어 재사용해 생성 비용을 없애고, 그 개수로 부하의 상한을 긋는 것 - 재사용과 제한, 이 둘이 풀이 하는 일이다.

관련 글