본문 바로가기
Research Notes (개인)

epoll / kqueue 완전 이해


한 줄 정의

"수천 개의 네트워크 연결을 단 하나의 스레드로 효율적으로 감시하는 OS 메커니즘"

epoll  → Linux 계열 (Ubuntu, CentOS 등)
kqueue → BSD 계열 (macOS, FreeBSD 등)
같은 역할, 다른 OS의 구현체

왜 필요한가? - 문제부터 이해

단순한 방법 (나쁜 방법)

Redis에 클라이언트 10,000개가 연결되어 있다면?

방법 1: 클라이언트마다 스레드 생성
  Thread 1  → Client 1 감시
  Thread 2  → Client 2 감시
  ...
  Thread 10000 → Client 10000 감시

문제:
  스레드 10,000개 생성 → 메모리 수십 GB
  컨텍스트 스위칭 비용 폭발
  → 실질적으로 불가능
방법 2: 루프로 하나씩 확인 (select/poll 방식)

while True:
    for client in all_10000_clients:
        if client.has_data():   ← 매번 10,000개 전체 순회
            process(client)

문제:
  10,000개를 매번 전부 확인 → O(N) 비용
  클라이언트가 많을수록 선형으로 느려짐
  대부분의 클라이언트는 데이터 없음 → 낭비

epoll의 해결책

핵심 아이디어:
  "내가 직접 확인하러 다니지 않는다"
  "이벤트가 생기면 OS가 나한테 알려준다"
[epoll 동작 원리]

① epoll_create()
   "나 감시 시작할게요" → OS가 감시 테이블 생성

② epoll_ctl(ADD, client1)
   epoll_ctl(ADD, client2)
   ...
   "이 연결들 감시해줘" → OS가 관심 목록에 등록

③ epoll_wait()  ← 여기서 대기 (블로킹)
   "이벤트 생길 때까지 자고 있을게요"

④ 클라이언트에서 데이터 도착!
   OS: "야, 깨어나! client3742에서 데이터 왔어"
   → 이벤트 발생한 것들만 반환

⑤ 반환된 것들만 처리
   process(client3742)  ← O(1), 전체 순회 없음!
select/poll vs epoll 성능 비교:

연결 수    select/poll    epoll
──────────────────────────────
100개      빠름           빠름
1,000개    느려짐         빠름
10,000개   매우 느림      빠름  ← O(1) 유지
100,000개  불가능         빠름

Redis에서의 역할

[Redis 이벤트 루프 전체 그림]

Client 1 ──┐  데이터 있음!
Client 2 ──┤
Client 3 ──┤  (조용함)         epoll_wait() 대기 중
Client 4 ──┤                        │
...        │                        │ "Client 1, Client 7 이벤트!"
Client 7 ──┘  데이터 있음!           │
                                     ▼
                           [Main Thread 깨어남]
                                     │
                           ┌─────────┴──────────┐
                           │ Client 1 명령 처리  │ ← O(μs)
                           │ Client 7 명령 처리  │ ← O(μs)
                           └────────────────────┘
                                     │
                           epoll_wait() 다시 대기
핵심 포인트:

1. 단일 스레드가 10,000개 연결을 감시 가능
   → epoll이 OS 레벨에서 대신 감시해주기 때문

2. 이벤트 없으면 CPU 사용 0%
   → epoll_wait()에서 자고 있음

3. 이벤트 있는 것만 처리
   → 전체 순회 없음 → O(1)

실생활 비유

[select/poll 방식 - 나쁜 편의점 직원]

직원이 매 1초마다 손님 10,000명한테 직접 가서 물어봄:
  "주문하실 건가요?"
  "주문하실 건가요?"
  "주문하실 건가요?"
  ...
→ 대부분 "아니요" → 엄청난 낭비


[epoll 방식 - 스마트한 편의점 직원]

직원은 카운터에서 대기
손님이 벨을 누르면 직원에게 알림
→ 직원은 벨 누른 손님만 처리
→ 나머지 시간은 대기 (CPU 낭비 없음)

Redis 6.0+ 에서의 변화

Redis 6.0 이전:
  epoll ─→ [Main Thread] ─→ 명령 실행 + 응답 전송
  (모든 걸 혼자 처리)

Redis 6.0 이후:
  epoll ─→ [I/O Thread 1] ─→ 소켓 읽기/쓰기
           [I/O Thread 2] ─→ 소켓 읽기/쓰기   ─→ [Main Thread] 명령 실행
           [I/O Thread 3] ─→ 소켓 읽기/쓰기

  epoll은 여전히 사용,
  네트워크 I/O만 멀티스레드로 분산
  명령 실행은 여전히 단일 스레드

요약

┌─────────────────────────────────────────────────────┐
│  epoll/kqueue = OS가 제공하는 이벤트 알림 메커니즘   │
│                                                     │
│  핵심 가치:                                          │
│    단일 스레드로 수만 개 연결을 O(1)로 감시          │
│                                                     │
│  Redis에서의 역할:                                   │
│    단일 스레드 + epoll = 초고성능의 비결              │
│    "연결 수 많아도 느려지지 않는" 구조적 이유        │
│                                                     │
│  OS별 구현:                                          │
│    Linux  → epoll                                   │
│    macOS  → kqueue                                  │
│    Windows→ IOCP (같은 역할, 다른 이름)              │
└─────────────────────────────────────────────────────┘

epoll / kqueue 완전 정복


1. 등장 배경 - 왜 만들어졌나?

네트워크 서버의 근본 문제

서버가 클라이언트 10,000개를 동시에 처리해야 한다면?

문제:
  각 클라이언트 소켓에서 "데이터가 왔는지" 어떻게 알 수 있나?
  → 소켓은 기본적으로 "데이터 없으면 기다림 (블로킹)"

해결 시도의 역사

1세대: 멀티 프로세스 (Apache 초기 방식)
  클라이언트 1명 → 프로세스 1개 생성
  
  문제:
    프로세스 10,000개 → 메모리 수십 GB
    컨텍스트 스위칭 비용 폭발
    현실적으로 수백 개가 한계


2세대: 멀티 스레드
  클라이언트 1명 → 스레드 1개 생성
  
  문제:
    프로세스보다 가볍지만 여전히 한계
    스레드 10,000개 → 메모리 수 GB
    컨텍스트 스위칭 여전히 존재


3세대: select / poll (논블로킹 I/O)
  스레드 1개로 여러 소켓 감시
  
  문제:
    select: 최대 1024개 소켓 제한 (FD_SETSIZE)
    poll: 제한 없지만 매번 전체 순회 O(N)
    → 소켓 많을수록 선형으로 느려짐


4세대: epoll / kqueue (이벤트 기반)
  "이벤트 있는 것만 알려줘"
  → O(1) 이벤트 감지
  → 수십만 연결도 처리 가능

2. select/poll의 문제를 구체적으로

// select 방식 (문제 있는 방식)
while (1) {
    FD_ZERO(&readfds);
    FD_SET(sock1, &readfds);
    FD_SET(sock2, &readfds);
    // ... 10,000개 소켓 등록

    // 매번 10,000개 소켓 전체를 커널에 전달
    select(max_fd + 1, &readfds, NULL, NULL, NULL);

    // 어느 소켓에 이벤트 있는지 전체 순회
    for (int i = 0; i < max_fd; i++) {
        if (FD_ISSET(i, &readfds)) {  // ← 10,000번 체크
            handle(i);
        }
    }
}
select/poll의 두 가지 비효율:

① 매 호출마다 전체 소켓 목록을 유저 → 커널 복사
   10,000개 소켓 × 매 이벤트마다 = 엄청난 복사 비용

② 이벤트 발생 후 "어떤 소켓인지" 찾으려면 전체 순회
   10,000개 중 1개에 이벤트 → 나머지 9,999개 헛수고

3. epoll 동작 원리 (Linux)

핵심 아이디어

select: "이 소켓들 중에 이벤트 있는 거 있어?" (매번 전체 전달)
epoll:  "이 소켓들 관심 있어 (한 번만 등록)"
        "이벤트 생기면 너가 알려줘"
        → 이벤트 발생한 것만 받음

epoll 3개 시스템 콜

// ① epoll 인스턴스 생성 (감시 테이블 생성)
int epfd = epoll_create1(0);
// → 커널에 "관심 목록" 테이블 생성
// → epfd: 이 테이블의 파일 디스크립터


// ② 소켓 등록 (한 번만!)
struct epoll_event ev;
ev.events = EPOLLIN;     // 읽기 이벤트 관심
ev.data.fd = sock1;

epoll_ctl(epfd, EPOLL_CTL_ADD, sock1, &ev);  // 등록
epoll_ctl(epfd, EPOLL_CTL_ADD, sock2, &ev);  // 등록
// ... (한 번 등록하면 계속 유지)


// ③ 이벤트 대기 (블로킹)
struct epoll_event events[64];
int n = epoll_wait(epfd, events, 64, -1);
// → 이벤트 발생할 때까지 잠들어 있음
// → 이벤트 발생 시 깨어남
// → n: 이벤트 발생한 소켓 수

// 이벤트 발생한 것만 처리
for (int i = 0; i < n; i++) {
    handle(events[i].data.fd);  // 딱 발생한 것만!
}

커널 내부 동작

[epoll 내부 구조]

epoll 인스턴스
  │
  ├── 관심 목록 (Red-Black Tree)
  │     ├── sock1 (EPOLLIN)
  │     ├── sock2 (EPOLLIN)
  │     └── sock3 (EPOLLIN | EPOLLOUT)
  │
  └── 준비 목록 (Linked List)  ← 이벤트 발생한 것만 여기 추가
        └── (비어있다가 이벤트 생기면 추가됨)


흐름:
  소켓에 데이터 도착
      │
      ▼
  커널 네트워크 스택이 감지
      │
      ▼
  해당 소켓이 epoll 관심 목록에 있는지 확인
      │
      ▼
  있으면 → 준비 목록에 추가
      │
      ▼
  epoll_wait() 깨어남
  → 준비 목록만 반환 (전체 순회 없음!)
성능 비교:

연결 수    select      poll        epoll
──────────────────────────────────────────
1,000      O(1000)     O(1000)     O(1)
10,000     O(10000)    O(10000)    O(1)
100,000    불가        O(100000)   O(1)

4. epoll 이벤트 종류

EPOLLIN      // 읽기 가능 (데이터 도착)
EPOLLOUT     // 쓰기 가능 (버퍼 여유)
EPOLLERR     // 에러 발생
EPOLLHUP     // 연결 끊김
EPOLLET      // Edge Trigger 모드 (중요!)
EPOLLONESHOT // 한 번만 이벤트 받음

Level Trigger vs Edge Trigger

Level Trigger (기본값, LT):
  "데이터가 있는 동안 계속 알려줌"

  데이터 100바이트 도착
  → epoll_wait 반환 (이벤트!)
  → 50바이트만 읽음
  → epoll_wait 또 반환 (아직 50바이트 남았으니까!)
  → 다 읽을 때까지 계속 알림

  특성: 안전하지만 반복 알림


Edge Trigger (EPOLLET):
  "상태가 변할 때 딱 한 번만 알려줌"

  데이터 100바이트 도착
  → epoll_wait 반환 (이벤트!)
  → 50바이트만 읽음
  → epoll_wait → 반환 안 함! (이미 알림 줬음)
  → 50바이트 영원히 못 읽을 수 있음

  특성: 고성능이지만 반드시 한 번에 다 읽어야 함
        Nginx, Redis 등 고성능 서버에서 사용


실무:
  LT: 구현 쉬움, 일반적 사용
  ET: 고성능 필요 시, 논블로킹 소켓 필수

5. kqueue 동작 원리 (macOS/BSD)

epoll과의 차이

epoll:  소켓(파일 디스크립터)만 감시
kqueue: 소켓 + 파일 + 프로세스 + 시그널 + 타이머 등 감시 가능
        → 더 범용적인 이벤트 시스템

kqueue 사용법

// ① kqueue 인스턴스 생성
int kq = kqueue();


// ② 이벤트 등록
struct kevent ev;

// 소켓 읽기 이벤트 등록
EV_SET(&ev,
    sock1,       // 감시할 fd
    EVFILT_READ, // 읽기 이벤트
    EV_ADD,      // 추가
    0, 0, NULL
);
kevent(kq, &ev, 1, NULL, 0, NULL);  // 등록


// ③ 이벤트 대기
struct kevent events[64];
int n = kevent(kq,
    NULL, 0,          // 추가 등록 없음
    events, 64,       // 이벤트 받을 버퍼
    NULL              // 무한 대기
);

for (int i = 0; i < n; i++) {
    handle(events[i].ident);  // 이벤트 발생한 것만 처리
}

kqueue가 감시할 수 있는 것들

EVFILT_READ    → 소켓/파일 읽기 가능
EVFILT_WRITE   → 소켓/파일 쓰기 가능
EVFILT_PROC    → 프로세스 이벤트 (종료, fork 등)
EVFILT_SIGNAL  → 시그널 수신
EVFILT_TIMER   → 타이머 만료
EVFILT_VNODE   → 파일 변경 감지 (inotify 역할)

6. epoll vs kqueue vs select 최종 비교

┌──────────────┬────────────┬────────────┬────────────────┐
│              │  select    │   epoll    │    kqueue      │
├──────────────┼────────────┼────────────┼────────────────┤
│ OS           │ 모든 OS    │ Linux      │ macOS/BSD      │
│ 소켓 제한    │ 1024개     │ 무제한     │ 무제한         │
│ 감시 복잡도  │ O(N)       │ O(1)       │ O(1)           │
│ 등록 방식    │ 매번 전달  │ 한 번 등록 │ 한 번 등록     │
│ 감시 대상    │ 소켓만     │ 소켓만     │ 소켓+파일+프로세스 등 │
│ 사용처       │ 레거시     │ Linux 서버 │ macOS 앱/서버  │
└──────────────┴────────────┴────────────┴────────────────┘

7. Redis에서의 활용

// Redis ae.c (이벤트 루프) 내부
// OS에 따라 자동 선택

#ifdef HAVE_EVPORT    // Solaris
  #include "ae_evport.c"
#elif defined(HAVE_EPOLL)  // Linux
  #include "ae_epoll.c"    // ← epoll 사용
#elif defined(HAVE_KQUEUE) // macOS/BSD
  #include "ae_kqueue.c"   // ← kqueue 사용
#else
  #include "ae_select.c"   // fallback
#endif
Redis 이벤트 루프 동작:

① 시작 시 epoll/kqueue 인스턴스 생성

② 클라이언트 연결 시
   → 소켓을 epoll/kqueue에 등록 (EPOLLIN)

③ epoll_wait() / kevent() 호출
   → 이벤트 없으면 CPU 사용 0% 대기

④ 클라이언트가 명령 전송
   → OS가 Redis 깨움
   → 이벤트 발생한 소켓만 반환

⑤ 명령 읽기 → 실행 → 응답

⑥ 다시 epoll_wait() 대기

→ 단일 스레드로 수만 클라이언트 처리 가능한 이유!

8. Java NIO와의 연결 (실무 관점)

// Java NIO Selector = epoll/kqueue의 Java 추상화

Selector selector = Selector.open();
// → 내부적으로 Linux: epoll, macOS: kqueue 사용

ServerSocketChannel server = ServerSocketChannel.open();
server.configureBlocking(false);  // 논블로킹 필수!
server.register(selector, SelectionKey.OP_ACCEPT);

while (true) {
    selector.select();  // epoll_wait() / kevent() 호출

    Set<SelectionKey> keys = selector.selectedKeys();  // 이벤트 발생한 것만!
    for (SelectionKey key : keys) {
        if (key.isAcceptable()) { /* 연결 수락 */ }
        if (key.isReadable())   { /* 데이터 읽기 */ }
        if (key.isWritable())   { /* 데이터 쓰기 */ }
    }
}
Netty, Spring WebFlux, Vert.x 등
모든 고성능 Java 프레임워크가
내부적으로 epoll/kqueue 위에서 동작

최종 요약

┌─────────────────────────────────────────────────────────────┐
│                  epoll / kqueue 핵심 요약                    │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  탄생 이유:                                                  │
│    select/poll의 O(N) 한계 극복                             │
│    단일 스레드로 수만 연결 처리                               │
│                                                             │
│  핵심 원리:                                                  │
│    소켓을 한 번만 등록                                       │
│    이벤트 발생 시 OS가 알려줌                                │
│    이벤트 발생한 것만 O(1)로 반환                            │
│                                                             │
│  OS별 구현:                                                  │
│    Linux  → epoll                                           │
│    macOS  → kqueue (더 범용적)                              │
│                                                             │
│  Redis와의 관계:                                             │
│    Redis 단일 스레드 고성능의 핵심 기반                      │
│    "연결 수 많아도 느려지지 않는" 이유                       │
│                                                             │
│  Java와의 관계:                                             │
│    NIO Selector = epoll/kqueue 추상화                       │
│    Netty/WebFlux 등 모든 비동기 프레임워크의 기반            │
│                                                             │
└─────────────────────────────────────────────────────────────┘