콘텐츠로 이동

운영체제 1편 - 자원 경쟁의 본질, 서버는 왜 느려지는가

Published: June 15, 2026

2025년, 한 프로덕션 서버가 분당 10,000 요청을 받기 시작하자 응답 시간이 200ms에서 40초 이상으로 폭증했다. 엔지니어는 커넥션 풀 크기를 50에서 100으로, 다시 150으로 늘렸다. 상황은 나아지지 않았다. CPU 사용률은 고작 20% 이었으나, 스레드 덤프를 떠보니 대다수의 스레드가 TIMED_WAITING 상태로 묶여 있었다.

같은 현상이 25년 전에도 기록된 적이 있다. 1999년, C10K Problem을 제기한 Dan Kegel 당시 웹 서버의 한계를 하나의 질문으로 정리했다. “서버 한 대가 동시접속 1만 건을 처리할 수 있는가?” 당시 Apache는 연결마다 프로세스를 할당하는 구조였고, 동시접속이 수천 건을 넘어서자 CPU는 요청 처리보다 프로세스 전환 관리에 더 많은 시간을 쓰기 시작했다.

Reference: Thread Pool Bug: 200ms to 40s Timeouts (2025)
Reference: Dan Kegel, “The C10K Problem” (1999)
Reference: Cloudflare: Why We Chose NGINX



두 사건 사이에는 긴 시간 차가 있지만, 부딪힌 벽은 같다. 일꾼을 늘리면 생산성이 올라갈 것이라는 직관이 현실에서는 오히려 시스템을 무너뜨린다는 사실이다.

데이터베이스 시리즈에서 우리는 디스크 I/O, 인덱스, 트랜잭션, 분산 시스템까지 “비용은 사라지지 않는다”는 현실 위에서 설계를 다뤘다. 운영체제는 그 비용을 숨기고, 미루고, 없는 척하는 기술이다. CPU 한 개가 수천 개의 작업을 동시에 처리하는 것처럼 보이는 현상. 그 이면에는 정교한 위장과, 위장을 유지하기 위한 비용이 존재한다.

이번 편은 감춰둔 조율 비용이 실제 일하는 연산 비용을 넘어서는 지점을 다룬다.


공장에 주문이 밀리면 일꾼을 늘리는 것이 상식이다. 하지만 앞서 본 두 사건에서 이 상식은 철저히 무너졌다. 일꾼을 늘릴수록 시스템은 오히려 더 빠르게 마비되었다.

문제는 그 순간에도 CPU 사용률이 고작 20%에 불과했다는 점이다. 일꾼의 수가 부족한 것도 아니고, 있던 일꾼들이 바쁘게 일하고 있던 것도 아니다. 그렇다면 서버는 대체 무엇 때문에 느려진 걸까?

계산이 병목인 작업과 대기가 병목인 작업

섹션 제목: “계산이 병목인 작업과 대기가 병목인 작업”

결국 컴퓨터가 시간을 쓰는 방법이 두 가지뿐이기 때문이다. 바로 계산하거나, 기다리거나. 문제는 CPU가 일을 안 해서가 아니라, 대부분의 시간을 ‘기다림’에 쓰고 있었다는 점이다.

만약 영상 인코딩이나 암호화 연산처럼 CPU가 쉴 틈 없이 연산하는 작업이라면 CPU 속도가 병목이 된다. 일꾼을 더 투입해도 물리적 한계에 부딪힌다. 그래서 등장한 개념이 바로 CPU Bound 작업이다.

반면 디스크에서 파일을 읽거나, 네트워크로 다른 API의 응답을 받는 작업은 상황이 완전히 다르다. 데이터를 받아오는 동안 CPU는 아무 일도 하지 않고 멈춰 선다. 그래서 이를 대기가 병목이 되는 I/O Bound 작업이라 부른다.

[ CPU Bound ]
CPU ████████████████████████████ (연산 100%)
I/O (대기 0%)
→ CPU가 병목. 더 빠른 CPU가 해답.
---
[ I/O Bound ]
CPU ██░░░░░░██░░░░░░██░░░░░░██ (연산 20%)
I/O ██████ ██████ ██████ (대기 80%)
→ 대기가 병목. CPU는 대부분 놀고 있다.

문제는 현실의 웹 서버 대부분이 후자에 속한다는 점이다. 요청이 오면 데이터베이스에 쿼리를 날리고 외부 API를 호출하느라 바쁘다. 이 과정에서 CPU가 실제로 연산하는 시간은 극히 일부이며, 나머지는 전부 대기다.

결국 당시 프로덕션 서버의 스레드들은 TIMED_WAITING 상태로 묶여 있었다. CPU는 놀고 싶어서 논 게 아니라, 할 일을 받지 못하고 있었다.

역설적으로 대부분의 서버가 이 상태라는 사실이 운영체제에게는 기회가 된다. 즉, CPU의 대기 시간을 다른 작업으로 채우는 속임수를 쓸 수 있다.

지금 CPU가 계산을 하고 있는가, 아니면 무언가를 기다리고 있는가. 이 구분이 이후의 모든 설계 결정을 가른다.


일꾼이 많아질수록 느려지는 역설

섹션 제목: “일꾼이 많아질수록 느려지는 역설”

서버가 느려질 때 스레드를 추가하는 것은 직관적인 해결책이다. 일꾼을 더 투입해 처리 속도를 높이기 위함이다.

하지만 투입하는 일꾼의 수가 늘어날수록 시스템 전체의 생산 효율은 오히려 하락한다. 즉, 생산을 위해 투입한 자원보다 조율을 위해 소모되는 자원이 더 커진다.

첫 번째 비용: 관리 대상의 증가

섹션 제목: “첫 번째 비용: 관리 대상의 증가”

물리적인 연산 장치(코어)의 수는 늘 고정되어 있다. 이 제한된 공간에 수천 개의 스레드를 투입하면, 한 사람이 작업대를 차지하고 일할 수 있는 시간은 극도로 짧아진다. 운영체제는 잘게 쪼갠 시간마다 일꾼을 교체해야 한다.

여기서 첫 번째 비용이 발생한다. 다음 사람에게 자리를 넘겨주기 위해 현재 어디까지 했는지 기록해야 하고, 새로 들어온 사람은 이전 작업 상태를 다시 불러와야 한다. 일꾼이 많아질수록 실제 일하는 시간보다 이 교대 준비에 쓰이는 행정 리소스 비중이 더 커진다. 정작 일은 안 하고 명부만 작성하는 상황이 오는 것이다.

운영체제는 이 교대 작업을 컨텍스트 스위칭(Context Switching)이라 부른다.

[ Context Switch ]
Thread A 실행 ──▶ [상태 저장] ──▶ [상태 복원] ──▶ Thread B 실행
│ │
└─── 이 구간은 ───┘
"아무 일도 안 하는 시간"

두 번째 비용: 조율 비용의 발생

섹션 제목: “두 번째 비용: 조율 비용의 발생”

하지만 문제는 단순한 교대 근무 비용에서 끝나지 않는다. 여러 일꾼이 같은 작업 공간을 공유한다는 점 때문에 두 번째 비용이 추가로 누적된다.

여러 스레드가 동일한 자원에 동시에 접근해 데이터를 수정하면 정합성이 파괴된다. 이를 막기 위해 특정 자원에 한 번에 한 스레드만 접근하도록 제한하는 락(Lock)이 강제된다.

[ Lock Contention ]
Thread 1 ──▶ [락 획득] ──▶ 작업 중 ...
Thread 2 ──▶ [대기 ─────────────────▶] ──▶ 락 획득
Thread 3 ──▶ [대기 ──────────────────────────────▶] ──▶ 락 획득
Thread 4 ──▶ [대기 ─────────────────────────────────────────▶]
스레드 수 ↑ → 대기 시간 ↑ → 동시성 이득 ↓

스레드 수가 늘어날수록 하나의 락을 얻기 위해 줄을 서야 하는 대기열이 길어진다. 동시성을 높이기 위해 투입한 일꾼들의 구조적 정체가 컨텍스트 스위칭 비용 위에 더해진다.

세 번째 비용: 하드웨어 비용으로의 전가

섹션 제목: “세 번째 비용: 하드웨어 비용으로의 전가”

앞선 두 비용은 모두 소프트웨어 내부에서 발생하는 조율 비용이었다. 하지만 문제는 여기서 끝나지 않는다. 일꾼이 지나치게 많아지면 그 영향은 하드웨어 계층까지 전파된다.

CPU는 현재 작업에 필요한 데이터를 가까운 곳에 보관해 두고 빠르게 처리한다. 하지만 교대가 너무 자주 일어나면, 방금 전까지 사용하던 데이터가 금세 쓸모없어진다.

새로운 스레드가 실행될 때마다 CPU는 필요한 데이터를 다시 가져와야 한다. 스레드 수가 많아질수록 이런 재준비 작업도 함께 증가한다. 결국 CPU는 연산보다 데이터를 다시 불러오는 데 더 많은 시간을 쓰게 된다. 캐시 미스(Cache Miss)는 이런 현상이 하드웨어 수준에서 나타난 결과다.

처음에는 스레드를 늘릴수록 처리량이 증가한다. 하지만 어느 순간부터는 새 스레드가 만들어내는 생산성보다, 관리하는 비용이 더 커진다.

바로 그 지점에서 처리량 역전이 발생한다.

처리량
│ ┌─── 정점
│ /│
│ / │
│ / │
│ / │ \
│ / │ \
│ / │ \ ← 조율 비용 > 실제 작업
│ / │ \
│ / │
└─────────────────────────▶ 스레드 수
최적점 이후
스레드 추가 = 성능 하락

결과적으로 스레드 수가 정점을 지나면 컨텍스트 스위칭, 락 경쟁, 캐시 오염이라는 삼중의 비용이 실제 작업 효율을 압도한다. 지표상으로는 CPU 사용률이 100%에 달해 서버가 매우 바쁘게 돌아가는 것처럼 보이지만, 정작 유저의 요청을 처리하는 유효 연산 비율은 바닥을 친다.

CPU가 바쁜 것과 CPU가 일을 하는 것은 같은 뜻이 아니다.


인력을 추가하면 일이 빨라지는가

섹션 제목: “인력을 추가하면 일이 빨라지는가”

1975년, IBM 시스템/360의 OS 개발을 이끌었던 프레더릭 브룩스는 소프트웨어 프로젝트의 가장 직관적인 해법이 왜 실패하는지를 분석했다. 일정이 지연될 때 관리자가 선택하는 가장 일반적인 대책은 인력 추가다. 그러나 인력이 추가될수록 프로젝트는 도리어 더 늦어진다.

브룩스가 지적한 원인은 단순하다. 사람이 늘어날 때, 실제 업무를 수행하는 시간보다 서로의 작업 상태를 맞추는 소통 비용이 더 가파르게 증가하기 때문이다. 팀원이 n명일 때 발생하는 1:1 커뮤니케이션 경로의 수는 n(n-1)/2의 곡선을 그리며 폭증한다.

Reference: Frederick Brooks, “The Mythical Man-Month” (1975)

[ 커뮤니케이션 비용의 폭증 ]
팀원 3명 → 경로 3개
A────B
╲ │
╲ │
╲ │
C
팀원 5명 → 경로 10개
A───B
│╲ ╱│
│ ╳ │
│╱ ╲│
C───D
╲ ╱
E
팀원 10명 → 경로 45개 ...

이 조직 관리의 법칙은 하드웨어와 운영체제 수준에서도 동일하게 작동한다. 브룩스가 발견한 문제는 사람이 많아질수록 소통 비용이 증가한다는 점이었다. 운영체제도 크게 다르지 않다. 스레드가 늘어날수록 실제 작업량보다 스레드 간 조율 비용이 더 빠르게 증가한다.

[ 맨먼스 미신의 OS 버전 ]
스레드 수 실제 작업 비율 조율 비용 비율
4 85% 15%
16 60% 40%
64 30% 70%
256 10% 90%

결과적으로 느려지는 서버에 임시방편으로 스레드만 추가하는 행위는 지연되는 프로젝트에 무작정 개발자를 투입하는 것과 같다. 최적점을 넘어서면 스레드 수와 처리량은 더 이상 비례하지 않으며, 서버는 오히려 더 느려진다.

데이터베이스 설계에서 각 트랜잭션이 동시에 실행될 때 시스템 전체가 마비되는 ‘합성의 오류’를 확인했듯, 스레드 풀 역시 동일한 제약을 공유한다.

동시성을 확보하기 위해 일꾼을 투입하는 모든 전략에는 일꾼 간의 조율 비용(Coordination Cost)이라는 근본적인 제약이 따라붙는다. 결국 비용은 사라지지 않는다. 실행 단위는 계속 가벼워졌지만, 조율 비용 자체가 없어진 적은 없다.


조율 비용은 제거할 수 없다. 그렇다면 이 비용과 어떻게 싸워왔는가.

CPU가 무언가를 기다리며 놀고 있다면, 그 빈틈에 다른 일을 밀어 넣으면 된다. 이것이 동시성(Concurrency)의 출발점이다. 문제는 “어떻게 안전하고 효율적으로 다른 일을 밀어 넣을 것인가”다. 운영체제는 이 난제를 해결하기 위해 세 가지 수준의 실행 단위를 제안한다.

프로세스 — 벽으로 나뉜 독립 작업장

섹션 제목: “프로세스 — 벽으로 나뉜 독립 작업장”

동시에 여러 작업을 실행할 때 가장 먼저 해결해야 할 문제는 ‘안전성’이다. 한 작업이 에러를 내고 죽을 때, 다른 작업에 영향을 미쳐선 안되기 때문이다.

결국 작업 간 영향을 차단할 수 있는 실행 단위가 필요했다. 그래서 등장한 첫 번째 답이 바로 프로세스(Process)다. 메모리 공간과 프로세스 자원을 통째로 분리해 독립된 공간을 준다.

[ Process ]
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Process A │ │ Process B │ │ Process C │
│ ┌────────┐ │ │ ┌────────┐ │ │ ┌────────┐ │
│ │ Memory │ │ │ │ Memory │ │ │ │ Memory │ │
│ └────────┘ │ │ └────────┘ │ │ └────────┘ │
│ Code │ │ Code │ │ Code │
│ Stack │ │ Stack │ │ Stack │
│ File Table │ │ File Table │ │ File Table │
└──────────────┘ └──────────────┘ └──────────────┘
완전 격리 완전 격리 완전 격리

이 완벽한 격리 덕분에 Nginx 같은 서버는 하나의 워커가 죽어도 전체 서비스가 마비되지 않는 단단함을 얻었다.

동시 처리를 위해 이 거대한 작업장을 여러 개 유지하는 비용은 결코 가볍지 않다. 독립된 메모리 공간을 유지해야 하고, 프로세스 간 전환(Context Switch) 비용 역시 실행 단위 중 가장 비싸다. 안전을 확보한 대신, 자원을 가장 무겁게 소비하는 방식이다.

스레드 — 하나의 작업장을 나눠 쓰는 일꾼들

섹션 제목: “스레드 — 하나의 작업장을 나눠 쓰는 일꾼들”

매번 작업장을 새로 짓고 관리하는 비용이 너무 크다면, 기존 작업장 안에서 일꾼만 늘릴 수는 없을까? 이 질문에서 출발한 대안이 바로 스레드(Thread)다.

새로운 공간을 만드는 대신, 기존 프로세스의 집(Heap과 코드 영역)을 같이 쓰기로 타협한 것이다.

[ Thread ]
┌──────────────────────────────────────┐
│ Process A │
│ │
│ ┌──────────────────────────────┐ │
│ │ 공유 메모리 (Heap) │ │
│ └──────────────────────────────┘ │
│ │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │Thread 1│ │Thread 2│ │Thread 3│ │
│ │ Stack │ │ Stack │ │ Stack │ │
│ └────────┘ └────────┘ └────────┘ │
└──────────────────────────────────────┘
메모리 공유, 스택만 독립

공간을 복제하지 않고 메모리를 공유하기 때문에, 생성과 전환 속도가 프로세스와는 비교가 안 될 정도로 가볍다. Java의 스레드 풀이 거대한 트래픽을 받아내기 위해 선택한 표준이기도 하다.

그러나 공유는 필연적으로 갈등을 낳는다. 여러 일꾼이 같은 장부를 동시에 수정하려다 데이터가 깨지는 정합성 문제가 발생한다. 게다가 한 일꾼이 치명적인 사고를 치면 작업장 전체가 무너질 수 있다. 가벼움을 얻기 위해, 최소한의 안전장치였던 격리를 포기한 셈이다.

코루틴 — 스케줄링 비용을 줄인 경량 실행 단위

섹션 제목: “코루틴 — 스케줄링 비용을 줄인 경량 실행 단위”

스레드로 무게를 줄였음에도 동시 접속자가 수만 명으로 늘어나면 또다시 벽에 부딪힌다. 스레드 하나가 차지하는 기본 메모리는 수백 KB에 달하기 때문에 수만 개를 띄우기 버겁다. 무엇보다 이 스레드들을 교체하는 OS 스케줄러의 컨텍스트 스위칭 비용이 시스템의 한계를 만들기 때문이다.

OS 스케줄러의 부담을 줄이면서도 수만 개의 동시성을 확보할 수는 없을까?

그 결과 나온 것이 애플리케이션 런타임이 직접 실행 흐름을 제어하는 코루틴 계열의 기술이다.

[ Thread ]
Thread A ──▶ OS Scheduler ──▶ Thread B
OS가 필요하다고 판단하면 언제든 실행 대상을 변경
---
[ Coroutine ]
Coroutine A ──▶ suspend
Application Scheduler
Coroutine B
코루틴이 스스로 멈춘 시점에만 실행 대상 변경

Go의 Goroutine은 수 KB의 스택으로 시작하며, 수십만 개를 생성해도 수백 개 수준의 OS 스레드만으로 운용된다. Java 21의 Virtual Thread 역시 같은 방향을 추구한다.

이것이 운영체제 시리즈의 첫 번째 속임수다. 하나의 CPU 코어는 여전히 한 번에 하나의 작업만 실행한다. 하지만 작업이 대기(I/O)에 들어가는 순간 다른 작업에게 자리를 넘겨주면서, 마치 수십만 개의 작업이 동시에 실행되는 것처럼 보이게 만든다.

이를 위해 Go의 Goroutine이나 Java의 Virtual Thread는 OS 스케줄러 위에 자신들만의 경량 스케줄러를 둔다. 실제 커널 스레드는 소수만 유지한 채, 수많은 코루틴을 애플리케이션 레벨에서 전환한다.

다만 비용이 사라진 것은 아니다. OS 스케줄러가 하던 일부 조율 작업을 애플리케이션 런타임이 대신 수행하게 된 것에 가깝다.

실행 단위얻은 것잃은 것대표 사례
프로세스완전한 격리무거운 생성, 전환 비용Nginx 워커
스레드가벼운 전환, 메모리 공유격리 포기, 공유 자원 충돌Java 스레드 풀
코루틴극단적 경량화, 적은 전환 비용협력적 양보 의존, 블로킹 시 전체 정체Go Goroutine, Java Virtual Thread

세 실행 단위는 기술의 진보가 아니라, 격리와 효율이라는 두 가치 사이에서 서로 다른 타협점을 선택한 결과다. 비용은 사라지지 않는다. 프로세스에서 스레드로, 스레드에서 코루틴으로 넘어오며 비용의 형태만 바뀌었을 뿐이다.


서버가 느려지는 원인은 크게 두 가지다. CPU가 연산에 묶여 있는가(CPU Bound), 아니면 외부 응답을 기다리며 멈춰 있는가(I/O Bound). 대부분의 웹 서버는 후자의 문제를 해결하는 데 집중한다.

운영체제는 이 기다림의 공백을 메우기 위해 프로세스, 스레드, 코루틴 같은 실행 단위를 발전시켜 왔다. 덕분에 단일 CPU 코어 위에서도 수많은 작업을 동시에 처리할 수 있게 되었다. 하지만 이 동시성은 공짜가 아니다. 실행 단위가 늘어날수록 컨텍스트 스위칭, 락 경쟁, 캐시 오염 같은 조율 비용이 함께 증가한다.

운영체제가 해결하려는 문제는 CPU를 더 바쁘게 만드는 것이 아니다. 기다리는 시간을 다른 작업으로 채우면서도, 그 과정에서 발생하는 조율 비용을 통제하는 것이다.

CPU를 더 바쁘게 만드는 것은 어렵지 않다. 어려운 것은 CPU가 바쁜 만큼 실제 유효한 일을 하게 만드는 것이다.

다음 편: 그렇다면 일꾼을 늘리지 않고 대기 시간을 제거하는 방법은 없을까. 다음 편에서는 일꾼을 늘리는 대신, 기다리는 방식을 바꾸는 접근을 다룬다. Blocking에서 Non-Blocking으로, select에서 epoll로. 주제는 대기 시간을 제거하는 I/O 모델의 진화다.