컴퓨터 구조 운영체제 네트워크 자료구조 알고리즘 데이터베이스 내 학습
os · 11

스레드 풀

동시성스레드풀스레드

서버가 요청을 받을 때마다 스레드를 새로 만들고 일이 끝나면 없애는 방식은 단순하지만 비용이 듭니다. 스레드를 만들려면 운영체제가 스택 메모리와 스레드 관리 구조를 준비해야 하고, 없앨 때도 정리가 필요합니다. 요청이 몰리면 스레드도 그만큼 동시에 늘어나 메모리와 CPU를 다 써 버릴 수 있습니다. 스레드 풀(thread pool)은 스레드를 일정 수까지 만들어 두고 재사용하는 방식입니다. 일감이 오면 쉬고 있는 스레드가 맡고, 모두 바쁘면 일감이 작업 큐(task queue)에서 기다립니다.

스레드 풀은 두 가지를 해결합니다. 스레드를 만들고 없애는 비용을 줄이고, 동시에 실행되는 스레드 수에 상한을 두어 과부하를 막습니다. 대신 풀 크기와 큐 길이를 잘 정해야 합니다. 너무 작으면 일감이 오래 기다리고, 너무 크면 컨텍스트 스위칭과 메모리 부담이 커집니다. 이 토픽에서는 요청마다 스레드를 만들 때와 풀을 쓸 때의 비용 차이, 작업 큐가 동작하는 방식, 작업 성격에 따라 풀 크기를 정하는 기준을 봅니다.

핵심 개념

스레드 재사용

풀의 스레드는 일을 마치면 사라지지 않고 큐에서 다음 일감을 꺼내 이어서 실행합니다. 스레드 생성과 소멸이 일감마다 일어나지 않고, 풀이 필요한 만큼만 스레드를 만들어 재사용합니다.

핵심 개념

작업 큐

모든 스레드가 일하는 동안 들어온 작업이 순서를 기다리는 곳입니다. 스레드가 비면 보통 먼저 들어온 작업부터 꺼내 실행하므로, 큐가 가득 차기 전까지는 요청이 몰려도 코어 수를 넘어 스레드가 늘지 않습니다.

핵심 개념

풀 크기

동시에 일하는 스레드의 상한입니다. 계산이 많은 작업은 코어 수 안팎이면 코어가 차고, 입출력을 기다리는 시간이 긴 작업은 대기 시간 동안 다른 스레드가 일하도록 더 크게 잡습니다.

핵심 개념

CPU 바운드와 I/O 바운드

작업 시간 대부분을 계산에 쓰면 CPU 바운드, 디스크나 네트워크를 기다리는 데 쓰면 I/O 바운드입니다. 두 유형은 알맞은 풀 크기가 다릅니다.

핵심 개념

거부 정책

큐가 가득 차고 스레드도 상한에 닿으면 새 작업을 받을 수 없습니다. 이때 예외를 던지거나, 호출한 스레드가 직접 실행하거나, 작업을 버리는 정책 가운데 하나를 고릅니다.

핵심 개념

큐의 길이

큐에 길이 제한이 없으면 일감이 끝없이 쌓여 메모리를 다 쓸 수 있습니다. 길이를 제한하면 과부하를 일찍 드러내고 거부 정책으로 이어집니다.

생성 비용을 한 번만 냅니다 — 요청마다 스레드를 만들면 요청 수만큼 생성과 소멸이 반복되지만, 풀은 미리 만든 스레드를 계속 씁니다.
스레드 수의 상한이 시스템을 지킵니다 — 요청이 몰려도 동시에 실행되는 스레드는 풀 크기를 넘지 않아 메모리와 전환 비용이 일정 선에서 멈춥니다.
크기는 작업 성격으로 정합니다 — 계산 중심이면 코어 수 안팎, 대기 중심이면 대기 비율만큼 더 크게 시작하고 측정으로 조정합니다.
풀 안에서 다른 작업을 기다리면 위험합니다 — 풀의 모든 스레드가 같은 풀의 다른 작업이 끝나기를 기다리면 아무도 진행하지 못하는 교착 상태가 될 수 있습니다.
이 시각화에서 확인할 것 — "생성 비용 비교" 탭에서 요청마다 스레드와 풀의 시간 합을, "작업 큐" 탭에서 스레드가 모두 바쁠 때 작업이 큐에서 기다리는 과정을, "풀 크기" 탭에서 작업 유형별 코어 사용률을 STEP으로 확인하세요.

스레드 풀의 배경

요청마다 스레드를 만들면 생성과 소멸 비용이 요청마다 들기 때문에, 스레드를 재사용해 이 비용을 여러 작업에 나누어 부담하는 풀 방식이 쓰입니다. 자바는 java.util.concurrent의 Executor 프레임워크로 스레드 풀을 표준 라이브러리에 포함하고, 파이썬은 concurrent.futures의 ThreadPoolExecutor를 제공합니다.

숫자로 확인하기

생성·소멸 시간의 합(가정값) — 스레드 생성 3, 소멸 1, 작업 실행 2로 가정하고 요청 8개를 처리하면, 요청마다 스레드는 생성 24 + 소멸 8 = 32를 쓰고, 스레드 3개짜리 풀은 생성 9 + 소멸 3 = 12를 씁니다. 실행 시간 16은 같습니다.
작업 큐 — 스레드 3개에 작업 7개가 시간 차를 두고 들어오면 스레드가 모두 일하는 동안 일부 작업은 큐에서 기다렸다가 스레드가 비는 순서대로 실행됩니다.
풀 크기 공식 — 목표 CPU 사용률을 100%로 두고 코어 4개에서 계산 1, 대기 3인 작업은 코어 수 × (1 + 대기/계산) = 4 × (1 + 3/1) = 16개 정도의 스레드가 있어야 코어를 채울 수 있다고 어림합니다. 실제 값은 측정으로 정합니다.

자주 하는 오해

스레드가 많을수록 빠르다 — 계산 중심 작업은 코어 수 이상으로 스레드를 늘려도 동시에 실행되는 수는 코어 수에 묶이고 전환 비용만 늘어납니다.
풀을 쓰면 스레드가 필요 없는 일도 빨라진다 — 풀은 생성 비용과 동시 실행 수를 다루는 장치입니다. 작업 자체가 느리면 풀을 써도 그만큼 느립니다.
큐는 무한해도 상관없다 — 길이 제한이 없는 큐는 처리가 밀릴 때 일감이 계속 쌓여 메모리를 소진할 수 있습니다.
하나의 풀로 모든 작업을 처리하면 된다 — 오래 걸리는 작업이 풀의 스레드를 다 잡고 있으면 짧은 작업이 기다립니다. 성격이 다른 작업은 풀을 나누는 방법이 있습니다.

면접에서 이렇게 물어요

면접 질문

스레드 풀을 왜 쓰나요?

스레드를 요청마다 만들고 없애는 비용을 줄이고, 동시에 실행되는 스레드 수에 상한을 두어 요청이 몰려도 과부하가 되지 않게 하기 위해서입니다.

면접 질문

작업 큐는 무슨 역할을 하나요?

풀의 스레드가 모두 일하는 동안 들어온 작업을 순서대로 보관합니다. 스레드가 비면 보통 먼저 들어온 작업부터 꺼내 실행합니다.

면접 질문

풀 크기는 어떻게 정하나요?

CPU 바운드 작업은 코어 수 안팎으로, I/O 바운드 작업은 코어 수 × (1 + 대기 시간/계산 시간) 정도를 출발점으로 잡고 실제 부하를 측정해 조정합니다.

면접 질문

큐가 가득 차면 어떻게 되나요?

스레드가 상한에 닿고 큐도 가득 차면 거부 정책이 동작합니다. 예외를 던지거나, 호출한 스레드가 직접 실행하거나, 작업을 버리는 방식 가운데 선택합니다.

실제 시스템에서는

자바 Executor 프레임워크 — ThreadPoolExecutor는 코어 스레드 수, 최대 스레드 수, 작업 큐, 거부 정책을 설정합니다. 문서는 코어 스레드가 모두 일하면 먼저 큐에 넣고, 큐가 차면 최대 스레드 수까지 새 스레드를 만든다고 설명합니다.
웹 서버 — 톰캣 같은 서블릿 컨테이너는 요청 처리용 스레드 풀을 두고 최대 스레드 수를 설정으로 정합니다. 스프링 부트의 내장 톰캣은 server.tomcat.threads.max로 이 값을 바꿉니다.
자바 21의 가상 스레드 — JEP 444는 가상 스레드를 풀에 담지 않고 작업마다 새로 만들도록 권장합니다. 가상 스레드는 생성 비용이 매우 작기 때문입니다.
파이썬 — concurrent.futures.ThreadPoolExecutor는 max_workers를 지정하지 않으면 CPU 수에 맞춘 기본값을 쓰며, I/O 위주 작업에 알맞다고 문서가 안내합니다.

함께 보면 좋은 토픽

스레드 — 풀이 재사용하는 스레드가 무엇인지, 프로세스와 어떻게 다른지 봅니다.
CPU 스케줄링 — 풀의 스레드들이 코어를 나눠 쓰는 방식을 봅니다.
프로세스 동기화 — 풀의 스레드가 공유 자원을 안전하게 쓰는 방법을 봅니다.
교착 상태 — 풀 안의 작업이 서로를 기다릴 때 일어나는 문제와 연결해 봅니다.

참고 자료

파이썬 — concurrent.futures
교재 — Brian Goetz 외, "Java Concurrency in Practice" (스레드 풀 크기 산정과 Executor 프레임워크), Silberschatz 외 "Operating System Concepts"의 스레드 풀 절

Java — 고정 크기 스레드 풀에 작업 제출

ExecutorService pool = Executors.newFixedThreadPool(4);

for (int i = 1; i <= 10; i++) {
    int id = i;
    pool.submit(() -> {
        System.out.println("작업 " + id + " - " + Thread.currentThread().getName());
    });
}

pool.shutdown();                       // 새 작업은 받지 않고, 제출된 작업은 끝까지 실행
pool.awaitTermination(10, TimeUnit.SECONDS);

스레드 4개짜리 풀에 작업 10개를 제출하면 출력의 스레드 이름이 pool-1-thread-1부터 4까지 반복됩니다. 스레드가 작업마다 새로 생기는 것이 아니라 4개를 돌려 쓴다는 뜻입니다. 스레드가 모두 일하는 동안 들어온 작업은 풀 안의 작업 큐에서 기다립니다. newFixedThreadPool은 길이 제한이 없는 큐를 쓰기 때문에, 작업이 처리 속도보다 빨리 들어오면 큐가 계속 길어질 수 있습니다. 풀을 다 쓰면 shutdown으로 닫아야 하며, 닫지 않으면 풀의 스레드가 남아 프로그램이 끝나지 않을 수 있습니다.

Java — 큐 길이와 거부 정책을 직접 설정

ThreadPoolExecutor pool = new ThreadPoolExecutor(
    4,                                   // corePoolSize: 기본 스레드 수
    8,                                   // maximumPoolSize: 최대 스레드 수
    30, TimeUnit.SECONDS,                // 코어 수를 넘은 스레드가 쉬어도 되는 시간
    new ArrayBlockingQueue<>(100),      // 길이를 제한한 작업 큐
    new ThreadPoolExecutor.CallerRunsPolicy() // 가득 차면 호출한 스레드가 직접 실행
);

ThreadPoolExecutor는 코어 스레드가 모두 일하면 먼저 작업을 큐에 넣고, 큐까지 가득 차야 최대 스레드 수까지 새 스레드를 만듭니다. 그래서 큐를 크게 잡으면 최대 스레드 수에 닿기 전에 작업이 큐에 오래 머뭅니다. 큐 길이를 100으로 제한하면 과부하가 일찍 드러나고, 스레드도 최대에 닿았을 때 거부 정책이 동작합니다. 기본은 예외를 던지는 AbortPolicy이고, CallerRunsPolicy는 작업을 낸 스레드가 직접 실행해 제출 속도를 늦춥니다. 코어 수를 넘어 만들어진 스레드는 지정한 시간 동안 일감이 없으면 정리되어 풀이 다시 줄어듭니다.

Python · Spring Boot — 풀 크기 지정

# Python: I/O 위주 작업에 스레드 풀
from concurrent.futures import ThreadPoolExecutor
import urllib.request

def fetch(url):
    with urllib.request.urlopen(url) as r:
        return len(r.read())

with ThreadPoolExecutor(max_workers=16) as ex:
    sizes = list(ex.map(fetch, urls))

# Spring Boot application.properties: 내장 톰캣의 요청 처리 스레드 상한
server.tomcat.threads.max=200

파이썬의 ThreadPoolExecutor는 with 블록을 벗어나면 풀을 닫고 작업이 끝나기를 기다립니다. 네트워크 요청처럼 대기 시간이 긴 I/O 위주 작업이라 코어 수보다 큰 16개를 썼습니다. 계산 중심 작업이라면 기본 CPython 빌드에서는 전역 인터프리터 락(GIL) 때문에 스레드를 늘려도 계산이 병렬로 빨라지지 않을 수 있어, 프로세스 풀을 검토합니다. 스프링 부트의 server.tomcat.threads.max는 내장 톰캣이 요청을 처리하는 스레드의 상한이고, 풀 크기는 설정만 보지 말고 실제 부하에서 측정해 조정합니다.

Q1

스레드 풀(Thread Pool)을 사용하는 주된 이유는?

스레드를 만들고 없애는 일도 공짜가 아닙니다.
스레드 풀은 미리 만들어 둔 스레드를 재사용해 요청마다 스레드를 생성·소멸하는 비용을 줄이고, 스레드 수의 상한을 두어 요청이 몰려도 시스템이 과부하되지 않게 합니다.

Q2

스레드 풀의 작업 큐(Task Queue)의 역할은?

바쁜 스레드가 하나 빌 때까지 새 요청은 어디서 기다릴까요?
풀의 스레드가 모두 작업 중일 때 들어온 작업은 작업 큐에 쌓여 대기하다가, 스레드가 비면 순서대로 꺼내 실행됩니다.

Q3

CPU 연산이 대부분인(CPU 바운드) 작업의 스레드 풀 크기를 정하는 일반적인 기준은?

코어보다 스레드가 훨씬 많아도 동시에 실행되는 건 코어 수만큼입니다.
CPU 바운드 작업은 코어 수보다 스레드를 많이 만들어도 실제 병렬 실행은 코어 수만큼만 되고 컨텍스트 스위칭 비용만 늘어납니다. 반면 I/O 대기가 많은 작업은 대기 시간이 길어 더 큰 풀이 도움이 됩니다.