스레드, 프로세스, 코어는 언제 많을수록 느려질까?
빠른 답
- 스레드 수를 늘리면 동시에 처리할 수 있는 작업은 늘 수 있지만, 컨텍스트 스위칭, 락 경합, 캐시 미스 비용도 함께 커질 수 있습니다.
- 프로세스는 메모리 격리와 장애 분리에 유리하지만, 스레드보다 생성 비용과 메모리 사용량, 프로세스 간 통신 비용이 큽니다.
- 코어가 많아도 프로그램이 일을 병렬로 나누지 못하거나 다른 자원에서 막히면 성능 향상은 제한됩니다.
- 적정 개수는 CPU 사용률, run queue, 메모리, I/O 대기, p95·p99 응답 시간을 함께 보며 측정해야 합니다.
목차
한눈에 비교
스레드, 프로세스, 코어는 모두 동시 처리와 관련 있지만 같은 층위의 개념은 아닙니다. 코어는 하드웨어 실행 자원이고, 프로세스는 운영체제가 격리해 관리하는 실행 단위이며, 스레드는 프로세스 안에서 작업을 나누는 실행 흐름입니다.
시간 흐름으로 이해하기
요청 하나만 보면 스레드를 늘리는 방식이 단순한 해결책처럼 보입니다. 하지만 요청이 연속으로 들어오고, 대기열이 길어지고, 실행 가능한 작업이 CPU 코어보다 많아지는 순간부터 성능은 다른 양상을 보입니다.
왜 많을수록 빨라 보일까
작업을 여러 개로 나누면 한 번에 더 많이 처리할 수 있다는 설명은 일부 상황에서 맞습니다. 서로 독립적인 계산 작업이 많고, 공유 자원 접근이 적고, 작업을 나누고 합치는 비용이 작다면 병렬화 효과가 큽니다. 이미지 변환, 대량 압축, 독립적인 배치 계산, 샤딩된 데이터 처리 같은 작업이 여기에 가깝습니다.
하지만 서버 요청 처리는 CPU만 쓰지 않습니다. 네트워크 응답을 기다리고, 데이터베이스 쿼리를 보내고, 로그를 쓰고, 큐에서 메시지를 가져오고, 같은 객체나 파일을 두고 경쟁하기도 합니다. 스레드 수를 늘리면 대기 중인 작업을 더 많이 품을 수 있지만, 동시에 더 많은 작업이 같은 자원을 향해 몰립니다.
동시성과 병렬성도 구분해서 봐야 합니다. 동시성은 여러 작업이 진행 중인 상태를 만드는 능력이고, 병렬성은 같은 시점에 여러 작업이 실제로 실행되는 능력입니다. 스레드가 100개 있어도 논리 CPU가 8개라면 CPU에서 동시에 실행되는 작업은 제한됩니다. 나머지는 실행 가능한 상태로 기다리거나 I/O, 락, 타이머 때문에 잠시 멈춰 있습니다.
스레드가 많을 때 생기는 비용
스레드는 같은 프로세스의 메모리를 공유하므로 데이터 전달이 빠르고 가볍게 보입니다. 웹 서버의 요청 처리 스레드, JVM의 스레드 풀, Node.js의 워커 스레드, Python의 워커 풀처럼 여러 런타임에서 흔히 쓰입니다. 다만 스레드는 공짜 자원이 아닙니다.
스레드가 많아지면 운영체제 스케줄러는 어떤 스레드를 실행할지 더 자주 결정해야 합니다. 실행 중이던 스레드의 레지스터와 상태를 저장하고 다른 스레드의 상태를 복원하는 컨텍스트 스위칭도 늘어납니다. 개별 전환 비용은 작아 보여도 초당 수천, 수만 번 반복되면 CPU 일부가 실제 업무가 아니라 전환 관리에 쓰입니다.
공유 메모리를 쓰는 구조에서는 락 경합도 중요합니다. 스레드가 같은 큐, 캐시, 맵, 파일, DB 커넥션 풀을 동시에 건드리면 어느 순간부터 병렬성이 아니라 대기 시간이 늘어납니다. 스레드를 늘렸는데 처리량은 조금 오르고 p95·p99 응답 시간이 크게 나빠진다면, CPU 부족보다 큐 대기나 락 경합을 먼저 확인해야 할 때가 많습니다.
프로세스가 많을 때 생기는 비용
프로세스는 주소 공간이 분리되어 있어 한 프로세스의 메모리 오류나 크래시가 다른 프로세스에 바로 번지지 않는다는 장점이 있습니다. 웹 서버의 워커 프로세스, 브라우저의 탭 분리, 배치 워커 분산처럼 안정성과 격리가 중요한 구조에서 프로세스 분리는 유용합니다.
대신 프로세스는 더 많은 메모리를 씁니다. 런타임, 라이브러리, 힙, 캐시, 파일 디스크립터 같은 자원이 프로세스별로 필요합니다. 같은 애플리케이션을 여러 프로세스로 띄우면 캐시가 중복되고, 프로세스마다 GC나 런타임 관리 비용도 발생할 수 있습니다.
프로세스 간 통신도 비용이 있습니다. 같은 프로세스의 스레드끼리는 공유 메모리를 통해 바로 데이터를 볼 수 있지만, 프로세스 사이에서는 파이프, 소켓, 메시지 큐, 공유 메모리 같은 IPC가 필요합니다. 안정성은 좋아질 수 있지만, 데이터를 직렬화하고 복사하고 동기화하는 비용을 함께 고려해야 합니다.
코어가 많아도 빨라지지 않는 경우
코어 수가 늘면 동시에 실행할 수 있는 작업의 상한이 올라갑니다. CPU 바운드 작업을 잘게 나눌 수 있다면 코어 증설이 처리량 개선으로 이어질 수 있습니다. 하지만 모든 프로그램이 코어를 자동으로 활용하지는 않습니다.
단일 스레드로 대부분의 일을 처리하는 프로그램은 코어가 많아도 한 코어만 바쁘게 쓸 수 있습니다. 전역 락 하나가 긴 시간 잡히는 구조도 비슷합니다. 여러 스레드가 있어도 중요한 구간이 하나씩만 실행된다면 추가 코어는 대기 시간이 줄어드는 데 크게 기여하지 못합니다.
하이퍼스레딩처럼 물리 코어 하나가 여러 논리 CPU로 보이는 경우도 해석에 주의가 필요합니다. 논리 CPU 수가 16개라고 해서 CPU 바운드 작업이 항상 물리 코어 16개처럼 선형으로 빨라지지는 않습니다. 논리 CPU는 실행 자원을 더 잘 활용하게 도와주지만, 같은 물리 코어의 실행 유닛과 캐시를 공유한다는 점은 남아 있습니다.
CPU 바운드와 I/O 바운드의 선택 기준
CPU 바운드 작업은 CPU가 계산하느라 바쁜 작업입니다. 암호화, 영상 인코딩, 대량 JSON 파싱, 이미지 처리, 복잡한 알고리즘 계산 등이 여기에 들어갑니다. 이런 작업에서는 실행 가능한 스레드나 프로세스가 코어 수보다 지나치게 많아지면 CPU를 나눠 쓰는 비용이 커집니다. 처음에는 논리 CPU 수 근처에서 시작해 처리량과 지연 시간을 보며 조정하는 방식이 이해하기 쉽습니다.
I/O 바운드 작업은 CPU보다 외부 응답을 기다리는 시간이 긴 작업입니다. HTTP API 호출, DB 쿼리, 파일 읽기, 메시지 큐 대기 같은 작업이 대표적입니다. 어떤 스레드가 I/O를 기다리는 동안 다른 스레드가 CPU를 사용할 수 있으므로 코어 수보다 많은 스레드가 도움이 될 수 있습니다. 다만 DB 커넥션 풀, 외부 API rate limit, 디스크 대역폭, 네트워크 지연 같은 다음 병목을 함께 봐야 합니다.
판단 흐름은 다음처럼 잡을 수 있습니다.
- CPU 사용률이 높고 run queue가 길다면 스레드나 프로세스를 더 늘려도 느려질 가능성이 큽니다.
- CPU 사용률은 낮은데 응답이 느리다면 I/O 대기, 락 대기, 커넥션 풀, 외부 서비스 지연을 확인해야 합니다.
- 메모리 사용량이 계속 늘어난다면 프로세스 수, 스레드 스택, 큐에 쌓인 작업, 캐시 중복을 같이 봐야 합니다.
- 평균 응답 시간은 괜찮아도 p95·p99가 나빠진다면 큐 대기와 경합이 이미 커졌을 수 있습니다.
워커 수와 스레드 풀 크기 잡기
처음부터 큰 값을 넣기보다 작은 기준값에서 부하를 걸어보고 병목이 어디로 이동하는지 보는 편이 안전합니다. 아래 설정은 웹 서버나 배치 워커에서 흔히 볼 수 있는 구조를 단순화한 예입니다. 숫자 자체보다 workers, threads.max, queueSize, connectionPool.maxSize가 서로 어떤 영향을 주는지 보는 것이 중요합니다.
server:
workers: 4
threads:
min: 8
max: 32
queueSize: 1000
database:
connectionPool:
maxSize: 20
connectionTimeoutMs: 3000
workers는 프로세스 또는 독립 워커 단위의 병렬 처리 개수를 뜻할 수 있습니다. threads.max는 한 워커 안에서 동시에 처리할 작업 수를 제한합니다. database.connectionPool.maxSize가 20인데 요청 처리 스레드를 200개로 늘리면 많은 스레드는 결국 DB 커넥션 앞에서 기다립니다. 겉으로는 스레드가 늘었지만 실제 병목은 DB 연결 풀에서 만들어지는 셈입니다.
CPU 바운드 작업이라면 워커 수를 논리 CPU 수 근처에서 시작하고, I/O 바운드 작업이라면 외부 자원의 제한을 넘지 않는 선에서 스레드 수를 더 높여볼 수 있습니다. 큐 크기는 장애를 오래 숨길 만큼 크게 잡기보다, 지연 시간이 커지는 시점을 관찰할 수 있게 잡는 편이 운영에 도움이 됩니다.
Node.js 워커 스레드로 비교하기
JavaScript 예시라고 해서 항상 브라우저 프런트엔드 주제는 아닙니다. 아래 코드는 Node.js 런타임에서 CPU 작업을 worker_threads로 분리해 실행하는 예입니다. 워커 수를 환경 변수로 바꿀 수 있게 두면 같은 코드로 여러 설정을 비교할 수 있습니다.
const { Worker } = require("node:worker_threads");
const os = require("node:os");
const workerCount = Number(process.env.WORKERS || os.availableParallelism());
for (let i = 0; i < workerCount; i += 1) {
const worker = new Worker("./cpu-worker.js", {
workerData: { workerId: i }
});
worker.on("message", (message) => {
console.log(`[worker:${i}] ${message}`);
});
worker.on("error", (error) => {
console.error(`[worker:${i}]`, error);
});
}
4코어 또는 8논리 CPU 서버에서 WORKERS=4, WORKERS=8, WORKERS=16처럼 값을 바꿔 실행해보면 처리량과 지연 시간이 어떻게 변하는지 볼 수 있습니다. CPU 작업이라면 어느 지점부터 워커를 늘려도 처리량이 거의 늘지 않거나 오히려 떨어질 수 있습니다.
$ WORKERS=4 node app.js
workers=4 throughput=820 req/s p95=42ms cpu=88%
$ WORKERS=8 node app.js
workers=8 throughput=910 req/s p95=65ms cpu=96%
$ WORKERS=16 node app.js
workers=16 throughput=870 req/s p95=140ms cpu=99%
이 출력에서는 워커 8개일 때 처리량이 가장 높고, 16개에서는 p95 지연 시간이 크게 나빠졌습니다. CPU가 이미 거의 가득 찬 상태에서 실행 가능한 워커가 더 늘어나 컨텍스트 스위칭과 대기 시간이 커졌다고 볼 수 있습니다. 평균 처리량만 보면 놓칠 수 있는 변화라서 지연 시간 지표를 함께 보는 것이 중요합니다.
운영 명령 출력으로 병목 확인하기
운영 중인 서버에서는 코드만 보고 스레드 수를 결정하기 어렵습니다. 서버의 논리 CPU 수, 실행 가능한 작업의 밀림, 특정 프로세스의 스레드 수, I/O 대기 비율을 같이 확인해야 합니다.
$ lscpu
CPU(s): 8
On-line CPU(s) list: 0-7
Thread(s) per core: 2
Core(s) per socket: 4
Socket(s): 1
Model name: Intel(R) Xeon(R) CPU
$ ps -o pid,ppid,nlwp,%cpu,%mem,cmd -p 12345
PID PPID NLWP %CPU %MEM CMD
12345 1 96 385 18.2 java -jar api-server.jar
$ pidstat -t -p 12345 1
12:00:01 UID TGID TID %usr %system %CPU CPU Command
12:00:02 1000 12345 - 380.0 18.0 398.0 3 java
12:00:02 1000 - 12351 72.0 3.0 75.0 1 |__java
12:00:02 1000 - 12352 68.0 4.0 72.0 2 |__java
12:00:02 1000 - 12353 69.0 3.0 72.0 5 |__java
lscpu 출력에서 논리 CPU는 8개이고, 물리 코어는 4개입니다. 이 서버에서 NLWP가 96이라고 해서 바로 문제라고 단정할 수는 없습니다. I/O 대기가 많은 서버라면 가능한 구성입니다. 다만 CPU 사용률이 높고 응답 시간이 나쁘다면 스레드가 CPU를 두고 과하게 경쟁하는지 확인할 필요가 있습니다.
pidstat에서 프로세스 전체 CPU가 398%라면 대략 CPU 4개 분량을 쓰고 있다고 해석할 수 있습니다. 이 값은 단독으로 결론을 내기보다 응답 시간, 큐 길이, GC 로그, DB 대기 시간과 함께 봐야 합니다. 스레드 몇 개가 CPU를 오래 점유하는지, 많은 스레드가 조금씩 CPU를 쓰며 흔들리는지도 중요한 단서가 됩니다.
top에서 load average와 CPU 상태를 함께 보면 현재 병목의 방향을 더 빨리 잡을 수 있습니다.
top - 12:10:15 up 10 days, 2:31, 1 user, load average: 18.42, 17.90, 16.75
Tasks: 245 total, 12 running, 233 sleeping, 0 stopped, 0 zombie
%Cpu(s): 92.1 us, 5.8 sy, 0.0 ni, 1.2 id, 0.4 wa, 0.0 hi, 0.5 si, 0.0 st
MiB Mem : 16000.0 total, 420.0 free, 14200.0 used, 1380.0 buff/cache
%Cpu(s): 12.0 us, 3.0 sy, 0.0 ni, 58.0 id, 26.5 wa, 0.0 hi, 0.5 si, 0.0 st
첫 번째 상태처럼 논리 CPU가 8개인 서버에서 load average가 18 근처로 계속 유지되고 CPU idle이 낮다면 실행 가능한 작업이 밀리고 있을 가능성이 큽니다. 이 상태에서 워커를 더 늘리면 처리량보다 지연 시간이 더 나빠질 수 있습니다.
두 번째 CPU 라인처럼 idle이 높고 I/O wait가 높다면 CPU 자체보다 디스크나 네트워크 I/O 대기가 병목일 수 있습니다. 스레드를 늘려 일부 처리량을 높일 수는 있지만, 느린 쿼리, 스토리지 지연, 외부 API 응답 지연이 원인이라면 대기 중인 요청 수만 커질 수 있습니다.
숫자를 조정할 때 남겨야 할 증거
스레드, 프로세스, 코어 수를 조정할 때는 변경 전후의 증거가 남아야 합니다. 단순히 “스레드를 늘렸더니 빨라졌다”보다 어떤 부하에서 처리량, 지연 시간, CPU, 메모리, I/O 대기가 어떻게 움직였는지를 같이 기록해야 다음 조정이 쉬워집니다.
기본적으로는 다음 항목을 같이 남기는 편이 좋습니다.
- 설정값: 워커 수, 스레드 풀 크기, 큐 크기, DB 커넥션 풀 크기
- 처리량: 초당 요청 수, 초당 작업 수, 배치 처리 시간
- 지연 시간: 평균보다 p95, p99, timeout 수
- CPU 상태: user, system, idle, iowait, load average, run queue
- 메모리 상태: RSS, 힙 사용량, 스왑 발생 여부, 캐시 중복
- 외부 자원: DB 대기 시간, 커넥션 풀 고갈, 외부 API 제한, 디스크 대기
스레드 풀을 키우기 전에는 DB 커넥션 풀과 외부 API 동시 호출 제한을 같이 확인해야 합니다. 프로세스 수를 늘릴 때는 프로세스당 RSS와 캐시 중복을 봐야 합니다. 코어 수를 늘린 뒤에도 단일 스레드 CPU 병목이나 전역 락이 남아 있으면 추가 코어는 충분히 쓰이지 못합니다.
결국 “많을수록 좋은가”라는 질문은 “현재 병목이 어디에 있는가”로 바꿔 보는 편이 더 정확합니다. CPU가 병목이면 실행 단위를 줄이거나 작업을 더 잘 나누는 방향이 필요하고, I/O가 병목이면 대기 원인을 줄이거나 외부 자원의 동시성 제한을 조정해야 합니다. 스레드, 프로세스, 코어는 성능을 높이는 수단이 될 수 있지만, 병목을 옮기거나 감추는 수단이 되기도 합니다.
원문 참고
https://www.maeil-mail.kr/question/84
댓글
댓글 쓰기