목차
시스템 콜로 파일이나 네트워크를 부르면 답이 바로 안 온다. 그동안 그 일을 시킨 스레드는 무엇을 할까. 이 물음의 답이 스레드 풀·연결 풀 크기를 정하고, 비동기가 왜 나왔는지를 설명한다. 밑바닥을 다시 백엔드로 이으며 시리즈를 닫는다.
스레드가 응답을 기다리며 논다
read(socket)으로 DB에 쿼리를 보냈다고 하자. DB가 답하는 데 50밀리초가 걸린다면, 그 50밀리초 동안 이 스레드는 무엇을 하나. 답은 - 아무것도 안 한다. 응답이 올 때까지 그 자리에 멈춰 선다.
이렇게 결과가 올 때까지 스레드가 멈춰 서는 방식이 블로킹(blocking) I/O 다. 우리가 평소 쓰는 대부분의 파일·네트워크 호출이 이렇게 동작한다.
블로킹: 끝날 때까지 멈춰 선다
왜 멈추냐면, 시스템 콜이 결과를 손에 쥘 때까지 안 돌아오기 때문이다. read를 부르면 커널로 넘어가고, 커널은 데이터가 준비될 때까지 이 스레드를 재운다. 데이터가 오면 깨워서 결과를 돌려주고, 그제야 다음 줄로 넘어간다.
스레드: read(socket) 호출
│
│ ⏳ DB 응답 대기 (스레드 잠듦 - 아무 일 안 함)
│
▼
결과 받고 다음 줄로스레드 입장에선 코드가 한 줄에서 잠깐 멈췄다 이어지는 것처럼 보인다. 그 “잠깐”이 사실은 스레드가 통째로 잠들어 있던 시간이다.
기다리는 동안 CPU는 놀지 않는다
여기서 오해를 걷어내자. 스레드가 멈춘다고 CPU까지 노는 건 아니다. OS는 잠든 스레드를 CPU에서 내리고, 그 자리에 일할 준비가 된 다른 스레드를 올린다 - 바로 컨텍스트 스위치다.
그래서 스레드 하나가 DB를 기다리는 동안, 같은 CPU가 다른 요청을 처리한다. 블로킹은 그 스레드를 묶을 뿐, CPU를 놀리지는 않는다. 이 구분이 다음 이야기의 열쇠다.
그래서 I/O 위주면 스레드를 많이 둔다
context-switch 글에서 “I/O 위주면 스레드를 코어 수보다 많이 둬도 된다”고 한 게 이제 풀린다.
- 계산 위주 - 스레드가 CPU를 계속 쓴다. 코어 수만큼만 있으면 되고, 더 두면 교대 비용만 는다.
- I/O 위주 - 스레드가 대부분 시간을 기다리며 잠들어 있다. 코어 수만큼만 두면, 그 몇 개가 다 DB를 기다리는 순간 CPU가 논다. 그래서 스레드를 넉넉히 둬서, 몇이 기다리는 동안 다른 몇이 CPU를 쓰게 한다.
스레드 풀 크기를 일의 성격으로 정하라던 그 조언의 밑바닥이 블로킹이다. 스레드가 얼마나 자주 멈춰 서느냐가 적정 개수를 정한다.
하지만 스레드는 유한하다
넉넉히 두라지만 무한정은 아니다. 스레드마다 스택 메모리가 예약되고, 너무 많으면 교대 비용이 는다. 그런데 블로킹 방식에선 연결 하나 = 스레드 하나가 묶인다 - 동시 연결이 만 개면 스레드도 만 개가 필요하고, 그건 벽이다.
블로킹 모델: 요청 1 → 스레드 1 (DB 대기 중 묶임)
요청 2 → 스레드 2 (DB 대기 중 묶임)
...
요청 10000 → 스레드 10000 💥 스레드가 모자란다기다리기만 하는 스레드 수천 개가 스택 메모리와 관리 부담만 차지한다. “느려서”가 아니라 “기다리느라 묶여서” 스레드가 바닥나는 것이다.
다른 길: 안 기다리고 알림 받기
그래서 나온 발상이 논블로킹·비동기다. “결과가 올 때까지 멈춰 서지 말고, 일단 다른 일을 하다가 결과가 준비되면 알림을 받자.”
- 블로킹 - “답 줄 때까지 여기서 기다릴게.” 스레드가 묶인다.
- 논블로킹/비동기 - “준비되면 알려줘, 그동안 딴 일 할게.” 스레드 하나가 많은 연결을 오가며 돌본다.
이러면 적은 스레드로 수많은 연결을 감당한다 - 기다리는 동안 스레드가 안 묶이니까. 대신 코드가 “쭉 읽는” 흐름에서 “알림에 반응하는” 흐름으로 바뀌어 더 복잡해진다. 이 맞바꿈이 비동기 프로그래밍·이벤트 루프의 뿌리다. 여기선 “블로킹의 스레드 묶임을 풀려고 나왔다”까지만 쥐면 된다.
이 밑바닥이 백엔드 개념으로
시리즈를 여기서 닫는 이유가 있다. 우리가 처음에 “왜 그런지 모르고 외우던” 것들이 이제 밑바닥으로 설명된다.
- 스레드 풀 크기를 일의 성격으로 - 블로킹이 스레드를 얼마나 묶느냐가 정한다.
- 스레드와 연결은 함께 묶인다 - 블로킹 스레드가 연결(fd)을 붙든 채 기다리면, 스레드 하나와 연결 하나가 같이 묶여 함께 마른다.
- “스레드가 다 찼다” - 느린 DB를 기다리는 스레드가 풀을 다 묶어, 새 요청이 큐에서 기다리는 그 상황이다.
밑바닥을 알면 이 조언들이 규칙이 아니라 당연한 결과로 읽힌다.
실무에서: 묶인 스레드를 찾는다
- 스레드 덤프에서 대기를 본다 - 서버가 굳었을 때, 스레드들이 다 같은 DB·외부 API 응답에서 블로킹된 채 멈춰 있으면 그 바깥 의존성이 병목이다.
- 느린 하나가 전부를 묶는다 - 외부 API 하나가 느려지면, 그걸 부르는 스레드들이 다 묶여 풀이 마르고 무관한 요청까지 막힌다. 타임아웃을 둬서 무한정 안 묶이게 한다.
- 대기 많은 곳엔 비동기를 고려 - 외부 호출이 많아 스레드가 늘 기다린다면, 그 구간만 논블로킹으로 바꿔 적은 스레드로 버티는 걸 검토한다. 다만 복잡성이 느니 필요한 곳에만.
정리
- 파일·네트워크를 부르면 결과가 올 때까지 스레드가 멈춰 선다(블로킹) - 시스템 콜이 그때까지 안 돌아오기 때문이다.
- 멈춘 스레드는 CPU에서 내려가고 다른 스레드가 그 자리를 쓴다 - 그래서 I/O 위주면 스레드를 넉넉히 둔다.
- 하지만 블로킹은 연결 하나에 스레드 하나를 묶어, 동시 연결이 많으면 스레드가 바닥난다 - 그래서 비동기가 나왔다(적은 스레드로 많은 연결).
- 스레드 풀·연결 풀의 크기가 다 이 블로킹에서 나온다 - 밑바닥을 알면 외우던 조언이 당연해진다.
여기까지가 이 시리즈다. 프로세스에서 출발해 가상 메모리·스택과 힙·스레드·컨텍스트 스위치·시스템 콜·파일 디스크립터를 지나 블로킹 I/O까지 왔다. 밑바닥의 OS를 아는 값은, 매일 쓰는 스레드 풀·연결 풀·컨테이너가 왜 그렇게 생겼는지를 외우지 않고 이해하게 되는 것이다.