콘텐츠로 이동

운영체제 2편 — 대기 시간을 제거하는 설계, I/O 모델의 진화

Published: June 18, 2026

2024년, Discord 엔지니어링 팀은 자사 게이트웨이 서버의 WebSocket 트래픽을 분석했다. 수백만 개의 연결이 동시에 유지되고 있었지만, 대다수는 메시지를 보내지도, 받지도 않고 있었다. Discord는 이 연결들을 “passive session”이라 불렀다. 연결은 살아 있지만, 아무 일도 하지 않는 세션이었다. 이 passive session에 대한 불필요한 이벤트 전송을 제거하자 WebSocket 트래픽이 40% 감소했다.

같은 문제가 12년 전에도 기록된 적이 있다. 2012년, WhatsApp 엔지니어 Rick Reed는 Erlang Factory에서 서버 한 대에 동시접속 200만 건을 유지하는 과정을 발표했다. 연결 대부분은 메시지를 주고받지 않고 있었다. 사용자는 앱을 켜두기만 했을 뿐이다. 서버가 처리해야 할 메시지는 소수였지만, 서버가 관리해야 할 연결은 200만 개였다.

Reference: Discord: How Discord Reduced WebSocket Traffic by 40% (2024)
Reference: Rick Reed, “Scaling to Millions of Simultaneous Connections”, Erlang Factory SF (2012)
Reference: High Scalability: The WhatsApp Architecture (2014)



두 사건의 공통점은 단순하다. 서버가 느려진 이유는 처리할 메시지가 너무 많아서가 아니었다. 대부분의 연결이 아무 일도 하지 않는데, 서버는 그 모든 연결을 계속 감시하고 있어야 했기 때문이다.

1편에서 우리는 일꾼을 늘릴수록 조율 비용이 폭증한다는 사실을 확인했다. 이번 편은 일꾼을 늘리는 대신, 기다리는 방식 자체를 바꾸는 접근을 다룬다. 문제의 핵심이 “처리량”이 아니라 탐색 비용이라는 것을 인식한 순간, 운영체제의 I/O 모델은 근본적으로 다른 방향으로 진화한다.


10,000개의 연결 중 누가 준비됐는가

섹션 제목: “10,000개의 연결 중 누가 준비됐는가”

어느 대형 콜센터에 상담원 10,000명이 있다고 가정해보자.

문제는 고객이 10,000명이라는 사실이 아니다. 실제로 지금 전화를 걸고 있는 고객은 100명도 되지 않는다. 나머지 9,900명은 단지 번호를 유지한 채 기다리고 있을 뿐이다. 그런데 만약 관리자가 10,000명의 고객을 한 명씩 돌아다니며

“지금 상담할 건가요?” “지금은요?” “지금은요?”

라고 물어봐야 한다면 어떨까. 실제 상담보다 누구를 상담해야 하는지 찾는 일이 더 오래 걸릴 것이다.

서버도 같은 문제를 겪는다. 대규모 서비스의 서버는 수만, 수십만 개의 연결을 동시에 유지한다. 하지만 실제로 데이터를 보내고 있는 연결은 극소수다. 문제는 데이터를 처리하는 속도가 아니다. 수많은 연결 중에서 지금 당장 처리해야 할 연결을 찾아내는 비용이다.

1편에서 병목은 조율 비용이었다. 일꾼이 많아질수록 관리 비용이 증가했다.

이번 편에서 다룰 병목은 다르다. 일할 사람을 관리하는 문제가 아니라, 일할 사람을 찾아내는 문제다.

초기의 서버는 이 문제를 크게 고민할 필요가 없었다. 동시에 유지해야 하는 연결 수 자체가 많지 않았기 때문이다. 가장 단순한 해결책은 연결 하나마다 담당자를 붙이는 것이었다.

전화 한 통이 오면 상담원 한 명이 맡는다. 상담이 끝날 때까지 그 상담원이 계속 담당한다.

서버도 마찬가지였다. 연결 하나마다 스레드 하나를 붙였다.

Blocking I/O — 기다리는 동안 아무것도 못 하는 구조

섹션 제목: “Blocking I/O — 기다리는 동안 아무것도 못 하는 구조”

이 구조의 기반이 되는 것이 Blocking I/O다.

read()를 호출하면 데이터가 도착할 때까지 스레드는 반환되지 않는다. 쉽게 말해 상담원이 고객의 말을 기다리는 동안 다른 일을 할 수 없는 상태다. 고객이 10초 동안 아무 말도 하지 않으면 상담원도 10초 동안 아무것도 하지 못한다.

문제는 이런 연결이 수만 개로 늘어났을 때다. 연결이 100개면 스레드 100개가 대기한다. 연결이 10,000개면 스레드 10,000개가 대기한다.

이처럼 연결 하나마다 스레드 하나를 붙이는 방식Thread-per-Request(또는 Thread-per-Connection) 모델이라고 부른다.

초기 Apache가 대표적인 예다. 겉보기에는 합리적이다. 각 연결을 담당하는 전담 인력이 있으니 관리도 쉽다. 하지만 규모가 커질수록 문제가 드러난다.

10,000개의 연결이 있다고 해서 10,000개의 연결이 동시에 일하는 것은 아니다. 실제로 일하고 있는 연결은 극소수다. 나머지는 단지 기다리고 있을 뿐이다. 그럼에도 서버는 그 모든 연결을 위해 스레드를 유지해야 한다. 1편에서 살펴본 컨텍스트 스위칭, 락 경쟁, 캐시 오염이 발생하는 이유도 여기에 있다.

하지만 더 근본적인 문제는 따로 있다.

서버는 대부분 아무 일도 하지 않는 연결들을 감시하기 위해 엄청난 비용을 쓰고 있다는 점이다. 다시 말해, 문제는 처리량이 아니라 탐색 비용이다.

그렇다면 질문은 자연스럽게 바뀐다.

10,000개의 연결을 전부 감시하는 대신, 준비된 연결만 알려주게 만들 수는 없을까?


이 질문에 대한 답은 사고방식의 전환에서 시작한다.

Thread-per-Request 모델에서 서버는 모든 연결을 능동적으로 감시한다. “네가 준비됐니?” “너는?” “너는?” 10,000개의 연결을 하나하나 돌아가며 확인하는 전수조사 방식이다. 대부분의 답은 “아직”이다.

반대로 생각해보면 어떨까. 서버가 연결을 확인하러 가는 대신, 연결이 준비되었을 때 서버에게 알려주는 구조로 뒤집는 것이다.

[ 기존 방식: 전수조사 ]
서버 ──▶ 연결 1: 준비됐나? → 아니오
서버 ──▶ 연결 2: 준비됐나? → 아니오
서버 ──▶ 연결 3: 준비됐나? → 아니오
...
서버 ──▶ 연결 9,999: 준비됐나? → 아니오
서버 ──▶ 연결 10,000: 준비됐나? → 예!
→ 9,999번의 헛수고 끝에 1건 발견
---
[ 전환된 방식: 이벤트 통지 ]
연결 10,000 ──▶ 서버: "나 준비됐어"
→ 준비된 연결만 알려준다. 나머지는 확인하지 않는다.

이 전환은 단순한 기술 최적화가 아니다. 관심의 방향이 역전된 것이다. 서버가 연결을 찾아가는 구조에서, 연결이 되면 서버에 알리는 구조로. 이 발상이 이후 등장할 모든 이벤트 기반 시스템의 출발점이 된다.


관심의 역전이라는 발상은 좋다. 준비된 연결이 서버에게 알려주면 된다. 그렇다면 질문이 하나 남는다.

도대체 누가 그 사실을 알고 서버에게 알려주는가?

결국 그 역할은 운영체제 커널의 몫이다. 리눅스가 select, poll, epoll을 거쳐 진화해온 역사는 수만 개의 연결 속에서 ‘준비된 연결’을 찾아내는 비용을 줄여온 과정이다.

1980년대 하드웨어 자원은 극도로 귀했다. 하나의 연결이 대기할 때마다 프로세스 전체가 멈추는 방식은 기회비용이 너무 컸다.

select가 등장하기 전에는 여러 연결을 동시에 효율적으로 감시할 방법이 없었다. 프로세스가 멈춰서 기다리거나, CPU를 낭비하며 계속 확인해야 했다. select는 하나의 실행 흐름으로 여러 연결을 감시할 수 있게 만들었다.Thread-per-Request보다 진보한 것이다.

하지만 문제는 매번 전체 목록을 넘기고, 매번 전체를 순회한다는 점이다. 연결이 10,000개면 호출할 때마다 10,000개를 전수조사한다. 준비된 연결이 1개뿐이어도 9,999개를 확인하는 비용을 치러야 한다.

poll — 제한은 풀렸지만 구조는 같다

섹션 제목: “poll — 제한은 풀렸지만 구조는 같다”

인터넷의 성장으로 동시 연결 수가 1,024를 가볍게 넘어서기 시작했다. pollselect의 FD 1,024개 제한을 제거했다.

그러나 진짜 문제는 일하는 방식에 있었다. poll은 매 호출마다 전체 목록을 새로 전달받고, 처음부터 끝까지 순회하는 구조를 그대로 유지했다. select가 **“전체를 뒤져서 찾는다”**였다면, poll은 **“더 많이 뒤질 수 있다”**에 불과했다. 탐색 범위의 제한은 풀렸지만, 탐색 비용이 연결 수에 비례하는 구조 자체는 그대로였다.

epoll — 준비된 것만 알려주는 구조

섹션 제목: “epoll — 준비된 것만 알려주는 구조”

2002년, 리눅스 커널 2.5.44에 도입된 epoll은 접근 자체를 뒤집었다. select와 poll이 “뒤져서 찾는” 구조였다면, epoll은 “찾지 않는다. 알려달라고 등록한다.”

조직으로 치면, 관리자가 직원의 자리를 하나하나 돌아다니며 “끝났어?”라고 묻는 대신, **“업무가 끝난 사람만 공유 결재함에 서류를 넣어라”**라고 한 번 지시하는 것이다. 이후 관리자는 결재함만 확인하면 된다.

[ select / poll ]
매 호출마다:
애플리케이션 ──▶ 커널: "이 10,000개 확인해줘"
커널: 1번부터 10,000번까지 순회
커널 ──▶ 애플리케이션: "3번, 7,042번 준비됐어"
→ 연결 수에 비례하는 비용. 매번 전수조사.
---
[ epoll ]
최초 1회:
애플리케이션 ──▶ 커널: "이 10,000개 감시해줘" (epoll_ctl)
이후 매 호출:
애플리케이션 ──▶ 커널: "준비된 거 있어?" (epoll_wait)
커널 ──▶ 애플리케이션: "3번, 7,042번" (준비된 것만 반환)
→ 비용은 준비된 연결 수에 비례. 전체 연결 수와 무관.

selectpoll에서 탐색 비용은 전체 연결 수에 비례했다. 연결이 10만 개면 매번 10만 개를 확인해야 했다. epoll에서 탐색 비용은 준비된 연결 수에 비례한다. 10만 개의 연결 중 100개만 준비됐다면, 서버는 100개만 확인하면 된다.

1편에서 다뤘던 조율 비용의 맥락에서 보면, epoll은 조율 비용 자체를 제거한 것이 아니다. 조율 비용이 발생하는 기준을 바꾼 것이다. 전체 연결이 아니라, 실제로 일할 준비가 된 연결에 대해서만 비용을 치르도록 만들었다.


Event Loop — 준비된 것만 처리하는 실행 모델

섹션 제목: “Event Loop — 준비된 것만 처리하는 실행 모델”

epoll이 “누가 준비됐는지”를 효율적으로 알려주는 메커니즘이라면, Event Loop는 그 위에서 “준비된 작업만 순서대로 처리”하는 실행 모델이다.

왜 이 둘이 반드시 결합해야 할까? 아무리 유능한 경영자라도 수만 개의 부서를 일일이 감시해야 한다면, 의사결정을 시작도 하기 전에 업무가 마비된다. Event Loop가 단 하나의 스레드로 가동될 수 있는 이유는, epoll이 소음을 걷어내고 진짜 일거리만 올려주는 필터링을 밑바탕에 깔아주었기 때문이다.

구조는 단순하다. 하나의 스레드가 무한 루프를 돌면서 epoll에게 “준비된 거 있어?”라고 묻는다. 준비된 연결이 있으면 처리하고, 없으면 대기한다. 수만 개를 일일이 들춰보는 전수조사가 아니라, epoll이 이미 채워둔 결재함만 수확하는 형태다.

[ Event Loop ]
loop {
준비된 이벤트 = epoll_wait()
for 이벤트 in 준비된 이벤트 {
처리(이벤트)
}
}
→ 스레드 1개로 수만 개의 연결을 관리
→ 준비되지 않은 연결은 확인하지 않음

Node.js, Netty, Nginx는 모두 이 모델을 기반으로 작동한다. 구체적인 실행 구조는 조금씩 다르다. Node.js는 싱글 스레드, Nginx는 멀티 프로세스 기반의 이벤트 루프를 쓴다. 그러나 핵심 원리는 같다. 준비된 연결만 찾아 처리한다는 점이다.

그렇다면 실제 서비스에서는 이 차이가 얼마나 큰 결과를 만들었을까?

이벤트 기반 모델의 대표적인 성공 사례가 Nginx다.

Apache와 Nginx의 차이는 단순한 구현 방식의 차이가 아니다. 둘은 시스템이 비용을 지불하는 기준 자체가 달랐다.

Apache는 연결 수가 늘어날수록 비용이 함께 증가하는 구조였다. 실제 요청이 오지 않아도 연결마다 스레드와 메모리를 유지해야 했고, 규모가 커질수록 관리 비용 역시 비례해서 커졌다.

반면 Nginx는 연결 수보다 활성 이벤트 수에 맞춰 비용을 지불하는 구조를 선택했다. 수만 개의 연결이 존재하더라도 실제로 움직이는 연결이 적다면 추가 비용은 거의 발생하지 않는다.

Nginx가 Apache를 넘어선 이유는 동시성을 높인 것이 아니라, 낭비를 제거했기 때문이다.


전수조사가 만드는 정보 처리 비용

섹션 제목: “전수조사가 만드는 정보 처리 비용”

2편에서 반복해서 등장한 문제는 단순하다.

실제로 움직이는 연결은 극소수인데, 준비된 연결을 찾기 위해 전체를 확인해야 한다는 점이다.

연결이 수십 개 수준일 때는 문제가 아니다. 하지만 수만 개를 넘어가는 순간 비용 구조가 바뀐다. 데이터를 처리하는 비용보다, 처리할 대상을 찾는 비용이 더 커지기 시작한다.

이것이 대규모 시스템에서 나타나는 탐색 비용(Search Cost)의 본질이다. 정보를 처리하는 비용보다, 처리할 대상을 찾는 비용이 더 커질 때 시스템은 정체된다.

허버트 사이먼의 제한된 합리성

섹션 제목: “허버트 사이먼의 제한된 합리성”

흥미로운 점은 이 문제가 컴퓨터만의 문제가 아니라는 것이다.

조직 역시 규모가 커질수록 비슷한 한계에 부딪힌다. 직원 10명이 보고하는 조직에서는 대표가 모든 보고서를 읽을 수 있다. 하지만 직원이 1만 명이 되면 상황이 달라진다.

중요한 의사결정 자체보다, 어떤 보고가 중요한지 찾아내는 일이 더 어려워진다.

노벨 경제학상 수상자 허버트 사이먼은 “정보의 풍요는 주의(Attention)의 빈곤을 낳는다”고 말했다. 처리해야 할 정보의 총량이 관리자의 인지 능력을 넘어서면 과부하가 걸린다는 의미다. 이를 제한된 합리성(Bounded Rationality)이라 부른다.

결국 조직이든 시스템이든 규모가 커질수록 부족해지는 것은 처리 능력이 아니라 주의력(attention)이다.

그래서 거대 조직은 전수조사를 포기한다. 모든 보고를 위로 올리는 대신, 정상 업무는 현장에서 처리하고 대응이 필요한 예외 상황만 상위 조직에 전달한다. 이를 예외 중심 관리(Management by Exception)라고 한다.

운영체제가 epoll을 통해 선택한 방식도 본질적으로 같다.

  • 연결이 너무 많아짐 (정보 과잉)
  • 전부 스캔할 수 없음 (제한된 합리성)
  • 필터링 체계 필요 (예외 중심 관리)
  • 준비된 연결만 통지 (epoll)

즉, 전체를 감시하는 구조에서 예외만 보고받는 구조로 바뀐 것이다.

비용이 사라진 것은 아니다. 다만 비용이 발생하는 기준이 달라졌다. 전수조사에 쓰이던 상시 탐색 비용을, 실제 사건이 발생했을 때만 지불하는 통지 비용으로 전환한 것이다.


서버가 느려지는 원인이 처리량이 아니라 누가 준비됐는지 탐색하는 비용일 수 있다는 것이 이번 편의 출발점이었다.

이 문제를 해결한 핵심은 더 많은 자원을 투입하는 것이 아니라, 비용이 발생하는 기준을 바꾸는 것이었다. 전체 연결을 감시하는 대신, 준비된 연결에 대해서만 비용을 치르도록 구조를 뒤집은 것이다.

비용은 사라지지 않는다. 하지만 비용이 발생하는 기준을 바꿀 수는 있다. 이것이 select에서 epoll로, Blocking에서 Non-Blocking으로 이어지는 I/O 모델 진화의 본질이다.

다음 편: 일꾼의 수도 줄이고 탐색 비용도 낮췄지만, 또 다른 물리적 제약이 남아 있다. 빠르지만 비싼 메모리는 늘 부족하고, 느리지만 거대한 디스크는 연산 장치의 속도를 따라오지 못한다. 다음 주제는 운영체제가 이 하드웨어적 한계를 숨기고 없는 메모리를 있는 척하는 기술, 가상 메모리와 메모리 관리 전략이다.