Select 와 Poll 다시보기
I/O 멀티플렉싱을 구현하는 방식에는 여러 가지가 존재한다. 그중 가장 오래된 방식이 select와 poll인데, 이들은 내부적으로 모든 소켓을 순회하며 상태를 확인하는 구조를 가지고 있어 성능상의 한계가 뚜렷하다. 이러한 특성 때문에 현대의 고성능 서버 환경에서는 앞서 설명한 방식들과 함께 잘 사용되지 않는 편이다.

저번에 non-blocking에서 멀티플렉싱 i/o에 대해서 설명할 때 나타낸 그림이다위 그림에서 나타난 방식 역시 엄밀히 따지면 select와 poll이 사용하는 구조를 그대로 따른다. 두 방식의 차이는 본질적인 동작 방식의 차이라기보다는 API 인터페이스와 내부 자료구조의 차이에 가깝다. 즉, 멀티플렉싱이라는 관점에서 보면 select와 poll은 거의 동일한 방식으로 동작한다고 볼 수 있다.
| 구분 | select | poll |
| 개념 | Blocking I/O Multiplexing | Blocking I/O Multiplexing |
| FD 표현 | 비트마스크(fd_set) | 배열(pollfd) |
| FD 개수 제한 | 있음 | 없음 |
| 성능 특성 | O(n) | O(n) |
내부 방식 자세히 보기!

그림에서 볼 수 있듯이, select와 poll 방식은 여러 소켓을 동시에 감시하고, 그중 하나라도 read/write 가능한 상태가 될 때까지 스레드를 block시킨다. 이후 하나 이상의 소켓이 준비되면 스레드는 깨어나, “어떤 소켓이 지금 작업을 수행할 수 있는지”에 대한 정보를 전달받는다. 그리고 사용자 코드는 해당 정보에 따라 준비된 소켓들에 대해 read() 또는 write()를 순차적으로 수행한다.
바로 이 지점이 select와 poll 방식의 근본적인 한계다. 준비된 소켓의 개수와 관계없이 이벤트 처리는 단일 스레드에서 순차적으로 이루어지며, 또한 매번 모든 소켓의 상태를 검사해야 한다. 이로 인해 관리해야 할 소켓 수가 많아질수록 불필요한 검사 비용이 누적되고, 전체적인 처리 성능은 급격히 저하된다.
epoll은 무엇인가..?
앞서 살펴본 select와 poll은 여러 소켓을 동시에 감시할 수는 있지만, 매번 모든 소켓을 순회하며 상태를 검사하고, 이벤트 처리 역시 단일 스레드에서 순차적으로 수행된다는 구조적 한계를 가진다. 이러한 방식은 관리해야 할 소켓 수가 많아질수록 성능 저하로 직결된다.
이러한 문제를 해결하기 위해 Linux에서는 epoll 이라는 고성능 I/O 멀티플렉싱 메커니즘을 제공한다. epoll은 select나 poll과 달리, 이벤트가 발생한 소켓만을 커널이 직접 관리하고 통지하는 이벤트 기반 방식을 사용한다.
epoll의 동작 방식
epoll의 핵심은 단순하다.
“모든 소켓을 매번 검사하지 말고, 이벤트가 발생한 소켓만 알려주자.”
이를 위해 epoll은 커널 내부에 epoll 인스턴스라는 자료구조를 생성하고, 감시할 소켓들을 미리 등록해 둔다.

먼저 사용자 공간에서는 epoll_create()를 호출하여 커널 공간에 epoll instance를 생성한다. 이 epoll instance는 감시할 소켓과 이벤트 정보를 관리하는 커널 객체로, 이후 모든 epoll 관련 동작의 중심이 된다. 그 다음 epoll_ctl()을 통해 감시할 소켓과 관심 이벤트(read, write 등)를 epoll instance에 등록한다. 이 등록 과정은 소켓이 추가되거나 제거될 때만 수행되며, 이 단계에서는 스레드가 block되지 않는다.
모든 등록이 끝난 후, 사용자 스레드는 epoll_wait()을 호출한다. 이 시점부터 스레드는 이벤트가 발생할 때까지 block 상태에 들어간다. 중요한 점은 이 block이 불필요한 CPU 낭비를 유발하지 않는다는 것이다. 커널은 epoll instance를 통해 내부적으로 이벤트를 관리하고 있으며, 네트워크 패킷 도착이나 버퍼 상태 변화 등으로 인해 특정 소켓이 read/write 가능한 상태가 되는 순간, 해당 소켓을 epoll instance의 이벤트 목록에 기록한다.
이벤트가 하나 이상 발생하면, epoll_wait()은 block 상태에서 깨어나 read/write 가능한 소켓 목록만을 사용자 공간으로 반환한다. 즉, epoll은 “어떤 소켓이 준비되었는지”에 대한 정보만 전달하며, 실제 I/O 작업은 수행하지 않는다. 이후 사용자 코드는 반환된 소켓들에 대해 read() 또는 write()를 직접 호출하여 데이터를 처리한다.
이러한 구조 덕분에 epoll은 select나 poll과 달리 매번 모든 소켓을 순회하지 않으며, 이벤트가 발생한 소켓만을 대상으로 처리할 수 있다. 그 결과, 관리해야 할 소켓 수가 많아지는 대규모 네트워크 환경에서도 높은 확장성과 성능을 유지할 수 있다.
Level Triggered (LT) vs Edge Triggered (ET)
epoll은 이벤트를 통지하는 방식에 따라 Level Triggered 와 Edge Triggered 두 가지 모드를 제공한다.
두 방식의 핵심적인 차이는 “언제, 그리고 얼마나 자주 이벤트를 알려주는가”에 있다.
Level Triggered (LT)
LT는 epoll의 기본 동작 방식으로, 개념적으로는 select나 poll과 유사하다.
소켓이 read/write 가능한 상태인 동안, epoll_wait()를 호출할 때마다 커널은 해당 이벤트를 반복해서 반환한다. 즉, 아직 처리되지 않은 데이터가 남아 있다면 커널은 계속해서 “아직 읽을 수 있다”는 신호를 보내준다.
이러한 특성 덕분에 LT 모드는 다음과 같은 장점을 가진다.
- 구현이 비교적 단순하다
- 일부 데이터만 읽고 남겨두어도, 다음 epoll_wait() 호출 시 다시 이벤트를 받을 수 있다
- 이벤트 처리 누락으로 인한 실수가 발생하기 어렵다
Edge Triggered (ET)
ET는 이름 그대로, 소켓의 상태가 변화하는 ‘순간’에만 이벤트를 통지하는 방식이다.
구체적으로는 소켓이 read 불가능 → read 가능 / write 불가능 → write 가능과 같이 상태가 변화하는 시점에만 이벤트를 단 한 번 전달한다. 한 번 이벤트를 전달한 이후에는, 애플리케이션이 데이터를 모두 처리하지 않더라도 커널은 동일한 이벤트를 다시 알려주지 않는다.
이러한 특성으로 인해 ET 모드는 다음과 같은 특징을 가진다.
- 불필요한 이벤트 통지가 줄어들어 성능이 뛰어나다
- 대규모 연결 환경에서 효율적이다
- 대신, 이벤트를 놓치지 않도록 구현 난이도가 높다
| 구분 | Level Triggered (LT) | Edge Triggered (ET) |
| 이벤트 통지 | 상태가 유지되는 동안 반복 | 상태 변화 시 1회 |
| 기본 모드 | O | X |
| 구현 난이도 | 낮음 | 높음 |
| 성능 | 보통 | 높음 |
| 실수 가능성 | 낮음 | 높음 |
왜 epoll은 non-blocking I/O와 함께 사용될까?
특히 ET 모드에서는 non-blocking I/O가 사실상 필수다.
저번에 정리한 blocking / non-blocking I/O의 차이를 다시 떠올려보면, read()를 호출했을 때 blocking 모드에서는 수신 버퍼(recv buffer)에 데이터가 없을 경우 데이터가 들어올 때까지 스레드가 계속 대기하게 된다. 반면 non-blocking 모드에서는 읽을 데이터가 없을 경우 즉시 반환하며, 기다리지 않는다.
ET 모드에서는 이벤트가 상태 변화 시점에 단 한 번만 전달되기 때문에, 애플리케이션은 정확히 얼마나 많은 데이터가 도착했는지를 알 수 없다. 따라서 이벤트를 받은 시점에 read()를 반복 호출하여, 더 이상 읽을 수 있는 데이터가 없을 때까지 모두 처리해야 한다.
그렇다면 모든 데이터를 다 읽어낸 이후에는 어떻게 될까?

이때 소켓이 blocking 모드라면, 모든 데이터를 처리한 뒤 추가로 read()를 호출하는 순간 읽을 데이터가 없는 상태에 놓이게 되고, 해당 호출에서 스레드는 즉시 block 상태에 진입한다. 이 경우 이벤트 루프 스레드가 멈추게 되며, 그 결과 다른 소켓에서 발생한 이벤트들까지 처리하지 못하는 심각한 문제가 발생할 수 있다.

반면 소켓이 non-blocking 모드라면, 더 이상 읽을 데이터가 없는 경우 read()는 block되지 않고 즉시 EAGAIN 또는 EWOULDBLOCK을 반환한다. 이를 통해 애플리케이션은 “이번 이벤트에서 처리할 데이터는 모두 소진되었다”는 사실을 인지하고, 다시 epoll_wait()로 돌아가 다음 이벤트를 안전하게 처리할 수 있다. 이러한 이유로 epoll 기반 서버는 non-blocking I/O를 전제로 설계된다.
epoll 기반 서버의 전형적인 구조
이러한 이유로, 일반적인 epoll 기반 서버는 다음과 같은 구조를 따른다.
epoll + non-blocking socket + 이벤트 루프

- 이벤트 루프가 특정 I/O 작업에서 멈추지 않도록, 모든 소켓을 non-blocking 모드로 설정한다.
- epoll_create()를 통해 epoll instance를 생성한다.
- 감시할 소켓과 관심 이벤트(read/write)를 epoll_ctl()을 통해 epoll instance에 등록한다.
- epoll_wait()을 호출하여 이벤트 발생을 대기한다.
- 이벤트가 발생하면, 전달받은 소켓들에 대해 가능한 만큼 read() / write()를 반복 수행한다.
- 더 이상 처리할 수 없을 경우(EAGAIN) 해당 이벤트 처리를 종료한다.
- 이후 다시 epoll_wait()로 돌아가 다음 이벤트를 기다린다.
이 구조는 Reactor 패턴이라고 부르며, 다음과 같은 역할 분리를 가진다.
- Reactor (epoll) : 어떤 소켓에서 이벤트가 발생했는지를 감지하고 통지한다.
- Handler (사용자 코드) : 통지된 이벤트에 대해 실제 I/O 처리를 수행한다.
즉, 이벤트를 감지하는 역할과 이벤트를 처리하는 로직이 명확히 분리된 구조이며, 이러한 이벤트 중심 처리 모델을 Reactor 패턴이라고 부른다.
여기까지 select와 poll, 그리고 epoll에 대해 간단히 살펴보았다. 처음에는 개념이 단순해 보이지만, 실제 동작 방식을 따라가다 보면 생각보다 구조가 복잡하다는 것을 느끼게 된다. 특히 현대 서버 환경에서는 리눅스를 사용하는 경우가 많기 때문에, 실무 관점에서는 epoll에 대해 더 깊이 이해할 필요성도 자연스럽게 생긴다.
다만 이 시리즈의 목적은 특정 API를 깊게 파는 것이 아니라, I/O 멀티플렉싱이라는 개념 자체를 이해하는 것에 있다. 따라서 다음 글에서는 리눅스의 epoll을 넘어, 이벤트 처리 모델의 관점에서 Reactor 패턴과 Proactor 패턴(IOCP) 을 비교하며 이 시리즈를 마무리하려 한다.
'CS > OS' 카테고리의 다른 글
| 비동기/ 동기가 뭐길래.. (2) | 2026.01.21 |
|---|---|
| I/O Multiplexing: Reactor 와 Proactor (IOCP) (0) | 2026.01.21 |
| Block I/O vs Non-Block I/O 무슨 차이지..? (0) | 2026.01.19 |
