AI 뉴스

Linux의 epoll과 io_uring 비교 — TinyGate가 다시 쓴 이유와 2026년 선택지

노동1호 2026. 6. 22. 20:03

2026년 6월 현재, 최신 Linux 서버에서 새 네트워크 프로젝트를 시작할 때 epoll 대신 io_uring을 첫 번째로 검토하는 흐름이 자리 잡고 있다. sibexico의 TinyGate는 학생용 워커 기반 리버스 프록시에서 출발해 epoll로 한 단계 도약하고, 다시 처음부터 io_uring으로 재작성하는 과정을 공개했다. 두 API의 설계 철학 차이는 단순한 성능 비교가 아니라 I/O를 바라보는 모델 자체가 다르다는 점을 보여준다. 이 글에서는 그 차이를 코드와 운영 비용 관점에서 정리한다.

TinyGate가 드러낸 epoll의 한계

TinyGate의 첫 버전은 단순한 워커 기반 구조였고, 교육용으로는 충분했지만 nginx나 haproxy 같은 도구와 비교하면 아키텍처 한계가 컸다. 두 번째 버전은 epoll 기반으로 바뀌며 첫 버전보다 성능이 크게 좋아졌지만 벤치마크에서는 여전히 nginx/haproxy를 넘지 못했다. 결국 저자는 epoll의 한계 때문에 io_uring으로 전환했고, 프로젝트를 처음부터 다시 작성했다. v0와 v1은 거의 처음부터 다시 쓴 완전히 다른 구현이고, 지금은 세 번째 구현을 작업 중이라고 한다.

핵심 교훈은 "성능을 끌어올리려면 추상화를 뜨거운 칼로 버터 자르듯 뚫고 지나가야 하지만, 그만큼 모든 것이 더 어려워진다"는 것이다. 소켓과 연결당 스레드 방식은 네트워크가 CPU에 비해 매우 느리던 시절에는 좋은 접근이었고, 오늘날에도 여전히 가장 단순한 접근이지만, 2026년의 동시성 요구에는 다른 답이 필요하다.

epoll: 준비 상태 통지와 반복 syscall

epoll은 Linux에서 오래 쓰인 비동기 I/O 관리 방식으로, 2002년에 Linux kernel에 들어갔다. 핵심은 "I/O가 가능한 시점"을 알려주는 준비 상태 통지다. epoll은 "읽거나 쓸 수 있음"만 알려주고, 실제 데이터 읽기와 쓰기는 이후 read() 또는 write() syscall로 애플리케이션이 수행한다. 일반적인 흐름에서는 이벤트마다 syscall 비용이 반복된다.

흐름을 분해하면 다음과 같다:

  • epoll_create1()로 epoll 인스턴스를 만든다
  • epoll_ctl()로 STDIN_FILENO 등 감시할 파일 디스크립터를 등록한다 (1회성 syscall)
  • epoll_wait()로 읽을 수 있을 때까지 블록한다
  • 이벤트가 오면 read() syscall로 데이터를 읽는다

이 흐름에서는 실제 I/O 이벤트마다 epoll_wait와 read가 필요하다. 결과적으로 이벤트 처리에 추가 syscall이 계속 붙는다. syscall은 사용자 모드와 커널 모드 사이 컨텍스트 전환을 만들며, 연결 수가 많아질수록 오버헤드가 커진다. HN 의견에서도 "프록시 맥락에서는 epoll_wait 바쁜 폴링도 언급해야 한다"는 지적이 나왔듯, 사용자 공간 바쁜 폴링조차 DPDK/VMA/io_uring 없이도 단순 소켓만으로 어느 정도 가능하다는 현실이 있다.

io_uring: 완료 모델과 공유 링 버퍼

io_uring은 epoll이 Linux kernel에 들어간 지 약 17년 뒤인 2019년에 등장했고, kernel v5.1+에서 지원된다. epoll과 달리 "I/O가 가능한가"가 아니라 "I/O가 완료됐는가"를 기준으로 동작한다. 애플리케이션과 커널은 공유 메모리의 링 버퍼를 함께 사용한다. 제출 큐(SQ)에는 애플리케이션이 커널에 요청할 작업을 넣고, 완료 큐(CQ)에는 커널이 완료 결과를 다시 올린다.

기본 설정에서는 커널이 제출 큐를 확인하도록 io_uring_enter()를 호출해야 하지만, 한 번의 호출로 여러 작업을 제출하고 여러 완료를 회수할 수 있다. epoll과 read() 조합처럼 작업마다 syscall 쌍을 반복하는 구조가 아니다. IORING_SETUP_SQPOLL을 쓰면 커널 스레드가 제출 큐를 폴링하므로 정상 운용 상태에서는 syscall을 거의 없앨 수 있다. 다만 큐가 비어 있어도 커널 스레드가 돌기 때문에 CPU를 사용한다. sq_thread_idle 이후에는 sleep으로 물러나지만 비용이 사라지는 것은 아니다.

오류 처리도 설계 자체가 다르다. io_uring의 오류는 동기 syscall의 직접 반환값이 아니라 완료 큐 엔트리(cqe)의 res 필드로 비동기적으로 돌아온다. 즉, 동기식 return 코드에 익숙한 개발자는 cqe->res를 매번 확인하는 패턴으로 사고를 전환해야 한다.

코드 예제로 보는 차이

epoll — 준비 상태 통지 + 별도 read()


// epoll_create1 + epoll_ctl + epoll_wait + read
int epfd = epoll_create1(0);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = STDIN_FILENO;
epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &ev);

for (;;) {
    struct epoll_event events[1];
    int n = epoll_wait(epfd, events, 1, -1);
    if (n > 0 && events[0].data.fd == STDIN_FILENO) {
        char buf[4096];
        ssize_t r = read(STDIN_FILENO, buf, sizeof(buf));
        // buf 사용
    }
}

이 패턴에서 epoll_waitread는 항상 짝을 이뤄 호출된다. 이벤트 1건당 두 번의 모드 전환이 발생할 수 있다.

io_uring — 완료 모델 + 공유 링 버퍼


// liburing 기반: 큐 초기화 + read 준비 + 제출 + 완료 회수
struct io_uring ring;
io_uring_queue_init(1, &ring, 0);

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, STDIN_FILENO, buf, sizeof(buf), 0);
io_uring_submit(&ring);

struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
ssize_t r = cqe->res;          // 완료된 read() 결과
io_uring_cqe_seen(&ring, cqe);

io_uring 예제에는 별도 준비 상태 확인이 없고, 완료 시점에 따로 read()를 호출하지 않는다. 단순화를 위해 두 예제에는 중요한 예외 처리가 빠져 있다. stdin에 데이터가 없으면 영원히 블록될 수 있고, io_uring 예제는 제출 큐가 가득 찼을 때 io_uring_get_sqe()가 NULL을 반환하는 경우를 검사하지 않는다.

핵심 차이 비교

| 항목 | epoll | io_uring |

|------|-------|----------|

| 도입 시기 | 2002년 (Linux kernel) | 2019년 (kernel v5.1+) |

| 동작 모델 | 준비 상태 통지 (read 가능 신호) | 완료 통지 (read 끝났음 신호) |

| syscall 패턴 | epoll_wait + read/write 매번 호출 | io_uring_enter 1회로 여러 작업 일괄 처리 |

| I/O 호출 시점 | 사용자 코드에서 read()/write() 호출 | 커널이 완료 큐에 결과 기록 |

| 공유 자원 | 없음 (커널 내부 자료구조) | 사용자/커널 공유 링 버퍼 |

| zero-copy | 일반 syscall | io_uring_register_buffers + IORING_OP_SEND_ZC (kernel 6.0+) |

| syscall 비용 | 이벤트당 syscall 쌍 | SQPOLL 설정 시 거의 0 (CPU 비용으로 상쇄) |

| 오류 확인 | return 값 (동기) | cqe->res (비동기) |

io_uring을 쓸 때의 추가 조건

io_uring이 만능은 아니다. 다음 조건을 미리 인지하고 설계에 반영해야 한다.

  • zero-copy I/O를 쓰려면 io_uring_register_buffers()로 버퍼를 미리 등록해야 한다. 작업마다 커널이 메모리를 다시 매핑하는 일을 피할 수 있다
  • 네트워크 전송에서는 kernel 6.0+의 IORING_OP_SEND_ZC가 버퍼를 커널로 복사하지 않는 전송을 제공한다. sendfile 만큼 쓰기 편하진 않지만, splice(2)와 결합하면 거의 비슷한 효과를 얻을 수 있다
  • IORING_SETUP_SQPOLL은 syscall을 줄일 수 있지만 CPU 사용량이 비용이다. 큐가 비어 있어도 커널 스레드가 계속 폴링한다. idle timeout 이후 sleep으로 전환될 수 있지만 비용이 없어지는 것은 아니다
  • io_uring의 오류는 동기 syscall의 직접 반환값이 아니라 완료 큐 엔트리의 res 필드로 비동기적으로 돌아온다. 오류 처리는 cqe->res를 통해 해야 한다

HN 의견에서 DPDK로 만들면 훨씬 복잡해지겠지만 성능에서 nginx를 압도할 기회가 생기고, FPGA에서 돌리게 만들면 더 복잡해진다는 코멘트가 있었다. NAPI 컨텍스트별로만 동작하고 NAPI ID를 쉽게 제어할 수는 없지만, 머신 전체를 프록시 전용으로 쓴다면 NAPI ID별로 소켓을 전용 폴러에 배정하는 간단한 꼼수가 가능하다. 다만 일반 애플리케이션에는 과한 최적화다.

최신 Linux 서버에서의 선택

epoll은 I/O 가능 시점 통지와 별도 syscall 호출에 기반한 오래된 Linux 비동기 I/O 방식이다. io_uring은 최신 Linux에서 완료 기반 모델과 배치 제출·완료 처리를 제공한다. 현대 Linux 서버에서 처음부터 새 프로젝트를 만든다면 io_uring을 선택하는 쪽이 자연스럽다. kernel v5.1+ 환경에서는 epoll을 고를 이유가 줄어들고 있다.

다만 옛날 시스템과의 호환성이 중요하다면 epoll이 여전히 답이다. 그리고 io_uring을 도입하더라도 HTTP 처리 같은 상위 계층은 Boost.Asio, libuv 같은 잘 정비된 라이브러리에 맡기는 편이 안전하다. 2026년 기준으로 io_uring을 직접 다루는 코드는 시스템콜 회피 같은 성능 극대화가 필요한 영역(리버스 프록시, 고성능 파일 서버, 커스텀 데이터베이스)에 집중하는 패턴이 보인다.

io_uring 기반 웹 서버에서 아직 공유 버퍼는 테스트하지 않았다는 HN 의견도 있었다. 파일에서 읽어서 쓰는 대신 mmap된 영역에서 직접 보내는 구현이 가능하다는 것이다. Rust와 kTLS 같은 조합도 검토되고 있고, 슬리퍼(Slumber) 같은 터미널 기반 HTTP 클라이언트, 무복사 할당자(ck, mimalloc), 그리고 eBPF/XDP 같은 도구도 함께 보면서 비교하는 것이 현실적인 학습 경로다.

요약

  • epoll은 "준비 상태"를 알려주고 read()/write()를 별도로 호출하는 모델이라 이벤트마다 syscall이 반복된다
  • io_uring은 "완료"를 통지하고 공유 링 버퍼로 작업을 일괄 처리하므로 syscall과 컨텍스트 전환 비용이 크게 줄어든다
  • IORING_SETUP_SQPOLL로 syscall을 거의 0에 가깝게 만들 수 있지만 CPU 비용을 대신 지불한다
  • zero-copy는 io_uring_register_buffers + IORING_OP_SEND_ZC로 가능하며 kernel 6.0+가 필요하다
  • 2026년 현재 kernel v5.1+ 최신 Linux 서버에서는 새 프로젝트의 기본 선택지로 io_uring을 먼저 검토하는 흐름이 자리 잡고 있다
  • 단, epoll이 사라지는 것은 아니며, 호환성과 단순성이 중요한 영역에서는 여전히 합리적인 선택이다