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 등 모든 비동기 프레임워크의 기반 │
│ │
└─────────────────────────────────────────────────────────────┘