기다리는 요청마다 일할 사람을 한 명씩 붙여 두면 어떻게 될까요? 손님마다 담당 직원을 붙여 주면 편하지만, 그 직원은 손님이 서류를 가지러 간 동안에도 자리를 지키고 앉아 있어야 합니다.
기초에서 먼저 보기창구가 200개인데 왜 10개만 일할까?웹 서버가 요청을 처리하는 가장 오래되고 직관적인 방법입니다. 요청이 도착하면 스레드 하나를 배정하고, 그 스레드가 2단원의 동기 코드를 위에서 아래로 그대로 실행합니다. 코드가 단순해서 읽기 쉽고 디버깅도 쉽습니다. 아래 애니메이션에서 요청이 하나씩 도착하며 스레드에 배정되는 과정을 보세요.
요청이 올 때마다 스레드를 새로 만들고 끝나면 버리면, 생성과 파괴에 드는 비용이 아깝습니다. 그래서 미리 정해진 개수만큼 스레드를 만들어 두고 요청이 오면 빌려주고 끝나면 돌려받습니다. 이 미리 만들어 둔 무리를 스레드풀이라고 합니다. 톰캣의 기본값 200개가 바로 이것입니다.
스레드 하나는 요청 하나를 붙잡고 있다가 디비 응답이 오면 마무리합니다. 그러니 스레드가 4개면 동시에 진행 중인 요청도 최대 4개입니다. 5번째 요청은 누군가 끝날 때까지 큐에서 기다립니다. 스레드 수를 1, 4, 16으로 바꿔 보면 완료 속도가 눈에 띄게 달라집니다.
재생하면서 오른쪽 상태 표시를 보세요. 어느 순간이든 대부분의 스레드가 "db 응답 대기"입니다. CPU가 실제로 일하는 시간은 요청 파싱과 응답 생성, 아주 짧은 순간뿐입니다. 서버가 바쁘다는 것은 대개 CPU가 바쁘다는 뜻이 아니라, 스레드가 전부 무언가를 기다리고 있다는 뜻입니다.
스레드마다 스택을 위한 메모리가 따로 잡힙니다(보통 수백 KB에서 1MB). 스레드가 1만 개면 스택만으로 수 기가바이트입니다. 게다가 스레드가 많아질수록 6단원에서 본 컨텍스트 스위치가 잦아져, 실제 일보다 갈아타기에 쓰는 시간이 늘어납니다. 화면 아래의 "스레드 메모리" 숫자와 "갈아타기" 숫자가 그 비용입니다.
흔한 오해가 많을수록 좋다는 것입니다. 앞 단원의 계산을 그대로 씁니다 — 요청 하나가 CPU를 10밀리초, 대기를 90밀리초 쓴다면 코어 하나당 열 개가 출발점입니다. 여기에 하류 자원의 한계를 곱해 최종 값을 정합니다. 커넥션 풀이 20개인데 스레드를 200개 두면, 늘어난 180개는 커넥션을 기다리며 서 있을 뿐입니다. 병목은 옮겨갈 뿐 사라지지 않습니다.
스레드풀에는 대기 큐가 붙습니다. 이 큐를 무제한으로 두면 과부하 때 메모리가 늘어나고, 이미 사용자가 포기한 요청을 뒤늦게 처리하게 됩니다. 그래서 큐 길이를 제한하고 넘치면 거절하는 편이 낫습니다. 빨리 거절하는 것이 느리게 실패하는 것보다 낫다 — 과부하 설계의 기본 원칙입니다.
1handle(req) {2 id = parse(req)① CPU 일함3 user = db.query(id)② 스레드가 여기서 잠듦4 return toJson(user)③ CPU 일함5}
시뮬레이터에서 해보기
이 단원의 용어
왜 이렇게 발전했나
이 모델의 진짜 장점은 성능이 아니라 단순함입니다. 코드가 위에서 아래로 읽히고, 스택 추적이 그대로 남고, 디버거로 한 줄씩 따라갈 수 있습니다. 그래서 오랫동안 서버의 기본값이었고, 지금도 대부분의 업무 서버가 이 방식입니다.
스레드풀 = 기다림을 "잠든 스레드"로 겹치기. 동시 처리량 = 스레드 수.
남은 문제
그런데 접속이 1만 개라면? 스레드 1만 개는 스택 메모리 1만 벌과 잦은 갈아타기를 뜻합니다. 1999년 이 고민에 이름이 붙었습니다 — C10K 문제. 기다림 하나에 스레드 하나씩 배정하는 게 아깝다면, 다른 방법이 있을까요? →