← 문제로

6. 스레드풀 설계: 적정 스레드 수, 작업 큐, I/O vs CPU 바운드

난이도 중
내 답안
모범답안

모범답안 — 스레드풀 설계: 적정 스레드 수, 작업 큐, I/O vs CPU 바운드

난이도: 중

핵심 답변

  • 스레드풀을 쓰는 이유: 스레드 생성/소멸은 비싸고(스택 할당, 커널 자료구조, 스케줄러 등록), 요청마다 만들면 부하가 몰릴 때 스레드가 수천 개로 폭발해 메모리 고갈·과도한 컨텍스트 스위칭으로 서버가 무너진다. 풀은 미리 만든 스레드를 재사용하고 큐로 동시 실행 수를 제한(throttle) 해 안정적인 처리량과 자원 한계를 보장한다.
  • 적정 스레드 수: 기준은 "스레드가 실제로 CPU를 쓰는 비율". CPU 바운드(거의 항상 계산)면 코어 수(±1) 정도가 최적 — 그 이상은 스위칭만 늘린다. I/O 바운드(대부분 대기)면 대기 시간만큼 더 많은 스레드가 동시에 진행할 수 있어 코어 수보다 훨씬 많이 둔다. 경험식: N = 코어수 × (1 + 대기시간/계산시간).
  • 큐 포화 대응: 무한 큐 금지. ① 유계 큐 + 거절(reject)(빠른 실패, 호출자에게 에러), ② 블로킹 제출(큐 차면 제출자가 대기 → 자연 백프레셔), ③ 호출자 실행(caller-runs), ④ 백프레셔를 상류로 전파(수신 throttle). 게임서버는 보통 유계 큐 + 거절/백프레셔를 쓴다.

깊이 있는 설명 (메커니즘, 왜)

thread-per-request가 무너지는 이유

스레드 하나는 기본 스택만 1MB(OS 기본)다. 동시 요청 1만 개면 스택만 10GB + 1만 개 스레드의 컨텍스트 스위칭으로 CPU가 스케줄링에 잠식된다. 부하 스파이크가 곧 서버 다운으로 직결된다. 풀은 동시 실행 수에 상한을 둬 이 폭발을 막는다.

시나리오: CPU 5%인데 느린 이유 = 잘못된 스레드 수

작업당 실제 CPU는 1ms 미만인데 I/O 대기는 20+100 = 120ms다. 스레드 16개는 거의 항상 I/O 응답을 기다리며 블로킹되어 있다. 한 스레드가 작업 하나를 처리하는 데 ~120ms가 걸리고 그동안 CPU를 안 쓰니, 16개 스레드의 최대 처리량은 대략:

TPS ≈ 스레드수 / 작업당 총시간 = 16 / 0.121s ≈ 132 TPS

CPU는 5%만 쓰는데 처리량이 막힌 건 동시 진행 작업 수가 16개로 묶여서다. 병목은 CPU가 아니라 "동시에 I/O를 띄울 수 있는 슬롯 수".

적정 스레드 수 계산

경험식 N = 코어 × (1 + W/C), W=대기시간(120ms), C=계산시간(1ms):

N ≈ 16 × (1 + 120/1) ≈ 16 × 121 ≈ 1936

즉 이 블로킹 모델을 고수한다면 수백~수천 스레드가 있어야 I/O 동시성을 채운다. 16은 압도적으로 부족했다. 하지만 수천 스레드는 스택/스위칭 비용이 크니, 이건 "스레드를 늘리자"가 정답이 아니라 블로킹 I/O 모델 자체가 부적합하다는 신호다(아래 비동기).

큐 포화 전략 상세

  • 유계 큐 + 거절: 큐 한계 도달 시 즉시 실패로 응답. 지연이 무한정 쌓이는 것보다 빠른 실패가 낫다(게임은 타임아웃·재시도로 대응).
  • 블로킹 제출: 제출자가 큐 자리가 날 때까지 대기 → 상류가 자연히 느려지는 백프레셔. 단, 제출자가 핫 패스(IO 스레드)면 그쪽이 막힐 수 있어 주의.
  • caller-runs: 풀이 포화면 제출한 스레드가 직접 실행 → 강제 감속.
  • 상류 throttle: 수신 윈도우/레이트 리밋으로 애초에 덜 받기. 공통 원칙: 무한 큐는 금지. 메모리만 먹다가 OOM + 지연 폭발로 끝난다.

근본 해법: 비동기 I/O

대기 동안 스레드를 묶지 않는 게 핵심이다. async/await + 비동기 I/O(C#의 await SaveToDbAsync() / await CallPaymentApiAsync())를 쓰면, I/O 대기 중 스레드가 풀로 반환되어 다른 작업을 처리한다. 적은 스레드(코어 수 정도)로 수천 개의 I/O를 동시 진행할 수 있다. 운영체제 레벨로는 IOCP/epoll 같은 완료 통지 기반 비동기 I/O가 이를 받친다. 결과적으로 스레드 수천 개 없이도 같은 동시성을 얻는다.

응용/실무 연결 (게임서버에서)

  • 풀을 워크로드별로 분리: CPU 바운드(로직/물리/AI) 풀은 코어 수에 맞춰 작게, I/O·블로킹(DB·외부 API) 작업은 별도 풀 또는 비동기 I/O로 처리. 한 풀에 섞으면 느린 I/O가 CPU 작업 슬롯을 잡아먹어 전체가 막힌다(풀 고갈, pool starvation).
  • 비동기 우선: 현대 게임 백엔드는 DB/외부 호출을 async로 처리해 적은 스레드로 높은 동시성을 낸다. 블로킹 호출을 async 컨텍스트에서 그대로 부르면(.Result/.Wait()) 스레드풀 고갈 + 데드락 위험이 크다.
  • 유계 큐 + 백프레셔 + 모니터링: 큐 길이, 대기 시간, 거절률을 지표로 노출해 과부하를 조기에 감지하고 상류 throttle로 보호.

흔한 오답·함정

  • "스레드를 늘리면 빨라진다" → CPU 바운드면 코어 수 넘는 순간 스위칭으로 느려진다. I/O 바운드도 무작정 늘리면 스택/스위칭 비용이 폭증한다. 비동기가 근본 해법.
  • "큐를 무제한으로 두면 작업을 안 잃는다" → 메모리만 먹다 OOM, 지연이 무한 누적되어 사실상 전부 실패. 유계 + 백프레셔가 정석.
  • "I/O 바운드니 스레드 = 코어 수" → 반대다. I/O 바운드는 대기 비율만큼 더 많이 둬야 슬롯을 채운다(혹은 비동기). 코어 수는 CPU 바운드 기준.
  • "async/await면 스레드가 무한정 생긴다" → 아니다. 대기 중 스레드를 반납해 적은 스레드로 많은 동시성을 낸다. 핵심은 스레드를 늘리는 게 아니라 안 묶는 것.
  • "블로킹 호출을 async 메서드 안에서 .Result로 불러도 된다" → sync-over-async는 풀 고갈/데드락의 단골 원인.

꼬리질문 대비

  1. Q: work-stealing 큐는 무엇이고 왜 쓰나? A: 스레드마다 자기 작업 덱(deque)을 두고, 자기 일이 없으면 다른 스레드의 덱 꼬리에서 작업을 훔쳐온다. 단일 글로벌 큐의 경합(모든 스레드가 한 락/원자변수를 두드림)을 줄이고 부하를 자동 균형화한다. .NET ThreadPool, Java ForkJoinPool, Go 런타임이 사용.

  2. Q: 스레드풀 고갈(starvation)은 어떻게 생기나? A: 풀의 모든 스레드가 블로킹(예: 같은 풀의 다른 작업 완료를 기다리거나 sync-over-async)되면, 그 대기를 풀 새 작업이 진행시켜야 풀리는데 스레드가 없어 데드락처럼 멈춘다. I/O는 async로, 풀 안에서 같은 풀을 블로킹 대기하지 않는 게 원칙.

  3. Q: CPU 바운드인데 코어보다 1~2개 더 두는 이유도 있나? A: 페이지 폴트·간헐적 짧은 I/O로 한 스레드가 잠깐 멈출 때 코어를 놀리지 않으려는 여유분. 단 과하면 스위칭만 늘어 역효과라 보통 코어수 또는 코어수+1 수준.