왜 지금 io_uring 이야기가 다시 나오는가

고성능 서버를 만들다 보면 결국 한 군데로 수렴한다. "syscall을 어떻게 줄일까." 연결 1만 개, 10만 개를 받기 시작하면 read/write 하나하나의 컨텍스트 스위치 비용이 눈에 보이게 누적된다. 이게 epoll이 20년 넘게 사랑받았던 이유이자, 동시에 io_uring이 등장한 이유다.

최근 TinyGate라는 교육용 리버스 프록시 프로젝트 사례가 화제가 됐다. 워커 기반 → epoll 기반으로 바꾸며 성능을 크게 끌어올렸지만, 그래도 nginx/haproxy를 못 넘었고 결국 io_uring으로 처음부터 다시 작성했다는 이야기다. 단순한 "신기술 좋아요" 글이 아니라 "왜 epoll의 천장을 만났고, 무엇이 달랐는가"를 보여줘서 곱씹어 볼 만하다.

이 글에서는 둘의 동작 모델 차이를 실무자 시선으로 정리하고, 실제로 코드를 돌려보면서 어떤 함정을 만나는지까지 짚어본다. 참고로 io_uring은 kernel v5.1+에서 지원되고, epoll은 2002년부터 리눅스에 들어가 있던 오래된 친구다.

핵심: 준비 상태 모델 vs 완료 모델

둘의 차이는 딱 한 문장으로 요약된다.

  • epoll = 준비 상태(readiness) 모델 — "이제 읽을 수 있어"라고 알려준다. 실제 read는 네가 직접 호출해라.
  • io_uring = 완료(completion) 모델 — "읽기 작업 끝났고 데이터 여기 있어"라고 알려준다.

비유하자면 epoll은 택배 도착 알림 문자다. "물건이 경비실에 왔습니다" 알림을 받으면, 내가 직접 내려가서(=read syscall) 가져와야 한다. io_uring은 집 앞까지 배달해주는 방식이다. "현관에 놓고 갔습니다" 사진(=완료 큐 엔트리)이 오면 끝. 따로 내려갈 필요가 없다.

epoll의 흐름

전형적인 epoll 루프는 이렇게 돈다.

// 1회성: fd 등록
int epfd = epoll_create1(0);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = STDIN_FILENO };
epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &ev);

// 반복: 이벤트마다 epoll_wait + read 쌍이 돈다
struct epoll_event events[16];
int n = epoll_wait(epfd, events, 16, -1);   // syscall #1: "읽을 수 있음" 통지
for (int i = 0; i < n; i++) {
    char buf[1024];
    read(events[i].data.fd, buf, sizeof(buf)); // syscall #2: 실제 읽기
}

핵심은 epoll_ctl은 등록 시 1회만 호출하지만, 실제 I/O 이벤트마다 epoll_wait + read(또는 write)가 계속 붙는다는 점이다. 연결이 늘어날수록 이 syscall 쌍이 곱연산으로 늘어나고, 그때마다 유저 모드 ↔ 커널 모드 컨텍스트 전환 비용을 낸다.

io_uring의 흐름

io_uring은 커널과 유저 공간이 공유 메모리의 링 버퍼 두 개를 같이 쓴다. 제출 큐(SQ)에 "이 작업 해줘"를 넣고, 커널이 끝내면 완료 큐(CQ)에 결과를 올린다.

#include <liburing.h>

struct io_uring ring;
io_uring_queue_init(8, &ring, 0);

char buf[1024];
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);   // 완료 대기 — 별도 read() 없음!
if (cqe->res < 0) {
    fprintf(stderr, "read failed: %s\n", strerror(-cqe->res));
} else {
    // cqe->res 바이트만큼 buf에 이미 채워져 있다
}
io_uring_cqe_seen(&ring, cqe);

여기서 epoll과 결정적으로 다른 두 가지를 보자.

  1. 완료 시점에 별도 read를 안 한다. io_uring_wait_cqe가 돌아왔을 때 데이터는 이미 buf에 들어 있다.
  2. 여러 작업을 한 번에 제출/회수할 수 있다. SQE를 여러 개 채워 넣고 io_uring_submit 한 번으로 보내면 syscall 한 번에 N개 작업이 나간다. epoll의 "작업마다 syscall 쌍" 구조와 근본적으로 다르다.

그리고 IORING_SETUP_SQPOLL을 켜면 커널 스레드가 제출 큐를 알아서 폴링한다. 정상 운용 상태에서는 io_uring_enter() 호출조차 거의 없앨 수 있다. 다만 공짜는 아니다 — 큐가 비어 있어도 커널 스레드가 돌기 때문에 CPU를 먹는다. sq_thread_idle 시간이 지나면 sleep으로 물러나지만 비용 자체가 사라지진 않는다.

실무 관점: 트레이드오프와 흔한 함정

오류 처리 모델이 완전히 다르다 (가장 흔한 함정)

epoll 시절 습관대로 짜면 반드시 한 번 걸린다. epoll에서는 read()가 실패하면 -1을 리턴하고 errno를 본다. io_uring은 동기 리턴값이 아니라 완료 큐 엔트리의 res 필드로 비동기적으로 오류가 돌아온다. 게다가 res는 음수의 errno 값이라 부호를 뒤집어야 한다.

// 잘못된 습관 — io_uring_submit 리턴값만 보고 안심
ret = io_uring_submit(&ring);
if (ret < 0) { /* ... */ }
// submit이 성공해도 정작 read는 실패했을 수 있다!

// 올바른 처리 — cqe->res를 확인
if (cqe->res < 0) {
    fprintf(stderr, "operation failed: %s\n", strerror(-cqe->res));
}

이걸 놓치면 "분명 submit은 성공했는데 데이터가 안 들어온다"는 식으로 디버깅이 산으로 간다.

SQE가 NULL을 리턴하는 경우

제출 큐가 가득 차면 io_uring_get_sqe()NULL을 반환한다. 예제 코드들이 단순화를 위해 흔히 빼먹는 부분인데, 실제 부하 상황에서 이걸 검사 안 하면 NULL 역참조로 죽는다.

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
if (!sqe) {
    // 큐가 꽉 찼다 — 먼저 submit해서 비우거나 백프레셔를 걸어야 한다
    io_uring_submit(&ring);
    sqe = io_uring_get_sqe(&ring);
}

빌드 환경에서 만나는 실제 에러

io_uring 코드를 처음 빌드하면 liburing 헤더가 없어서 십중팔구 이걸 본다.

$ gcc uring_example.c -o uring_example
uring_example.c:1:10: fatal error: liburing.h: No such file or directory
    1 | #include <liburing.h>
      |          ^~~~~~~~~~~~~
compilation terminated.

liburing을 설치하고 링크 플래그를 붙여야 한다.

# Ubuntu/Debian
$ sudo apt install liburing-dev

# 빌드 시 -luring 링크 필수
$ gcc uring_example.c -o uring_example -luring

커널이 io_uring을 막아둔 경우

이게 실무에서 진짜 발목을 잡는다. io_uring은 커널과 유저 공간이 메모리를 직접 공유하는 구조라 공격 표면이 넓고, 보안 이슈로 아예 비활성화해둔 환경이 많다. 먼저 현재 상태를 확인하자.

# 커널 파라미터 확인 (커널/배포판에 따라 존재 여부가 다름)
$ sysctl kernel.io_uring_disabled
kernel.io_uring_disabled = 2

# 값 의미 (참고): 0=전체 허용, 1=제한적, 2=완전 비활성

2로 막혀 있으면 io_uring_queue_init 단계에서 권한 오류가 난다.

$ ./uring_example
io_uring_queue_init: Operation not permitted

HN 토론에서도 이 점이 반복적으로 지적됐다. "더 빠르지만 대가는 잠재적 익스플로잇 가능성"이라는 말이 핵심이다. Go 같은 프로젝트도 io_uring을 합리적 기본값으로 깊게 넣지 않는 이유가 여기 있다. 다만 최근 흐름은 나아지고 있다 — 원문 토론에 따르면 RHEL 9/10은 이제 기본적으로 io_uring을 완전 지원하고, 최신 RC에 cBPF 기반으로 "전부 끄는 대신 실행 가능한 작업만 제한"하는 기능이 들어갔다고 한다. (배포판별 정확한 지원 범위는 각 벤더 문서 확인 필요.)

제로카피를 쓰려면 추가 준비가 필요하다

io_uring의 진짜 성능은 zero-copy에서 나오는데, 그냥 켜진다고 되는 게 아니다.

  • io_uring_register_buffers()로 버퍼를 미리 등록해야 커널이 작업마다 메모리를 다시 매핑하는 비용을 피한다.
  • 네트워크 전송 제로카피는 kernel 6.0+의 IORING_OP_SEND_ZC가 필요하다.

즉 "io_uring 썼으니 빨라지겠지"가 아니라, 버퍼 등록·SQPOLL·제로카피 옵션을 워크로드에 맞게 조율해야 비로소 epoll 대비 우위가 나온다.

TinyGate 마이그레이션이 주는 교훈

원문 사례를 정리하면 이렇다.

  1. v0: 단순 워커 기반 — 교육용으로는 동작하지만 nginx/haproxy 대비 아키텍처 한계가 컸다.
  2. v1: epoll 기반 — v0보다 성능이 크게 향상. 그래도 벤치마크에서 nginx/haproxy는 못 넘었다.
  3. v2: io_uring 기반 — epoll의 한계 때문에 처음부터 다시 작성. 아키텍처 선택 자체가 완전히 달라졌다.

여기서 주목할 점은 "라이브러리만 바꾸면 되는 일이 아니었다"는 것이다. 완료 모델은 준비 모델과 사고방식이 다르기 때문에 이벤트 루프 설계, 버퍼 생명주기, 오류 처리 경로를 통째로 다시 짜야 했다. HN 댓글에서도 어떤 사람은 Asio의 epoll 백엔드를 io_uring으로 바꿨더니 오히려 CPU 사용률이 확 올랐다고 했고, 또 어떤 사람은 RPS가 약 20% 빨라졌다고 했다. 즉 통합 방식에 따라 결과가 크게 갈린다. 무조건 빨라지는 마법이 아니다.

정리: 누가 언제 써야 하는가

한 줄 요약: epoll은 "읽을 수 있다"를 알려주고 read를 네가 하는 모델, io_uring은 "읽기 끝났다"를 알려주는 완료 모델이다. 후자가 syscall을 배치로 묶어 줄여서 고부하에서 유리하다.

  • io_uring을 선택할 때: kernel v5.1+ 환경에서 새 프로젝트를 처음부터 만들고, 커널에서 io_uring을 켤 수 있으며, 고연결·고처리량 워크로드(리버스 프록시, 고성능 파일 서버 등)를 다룰 때. 원문 결론도 "이런 환경에서는 epoll을 고를 이유가 많지 않다"고 본다.
  • epoll을 유지할 때: 오래된 커널 지원이 필요하거나, 보안 정책상 io_uring을 켤 수 없는 인프라이거나, 이미 잘 도는 안정적 시스템을 굳이 갈아엎을 이유가 없을 때. epoll은 20년간 검증됐고 어디서나 돈다.
  • 둘 다 과한 경우: 연결당 스레드 + 블로킹 소켓이 여전히 가장 단순하고, 트래픽이 많지 않다면 이게 정답일 때도 많다. 추상화를 뚫고 내려갈수록 모든 게 더 어려워진다.

실무 권장 순서는 이렇다. (1) 먼저 sysctl kernel.io_uring_disabled로 배포 환경이 io_uring을 허용하는지 확인하고, (2) PoC를 짜서 실제 워크로드 벤치마크로 epoll 대비 이득을 측정한 뒤, (3) 오류 처리 경로(cqe->res)와 SQE NULL 처리를 꼼꼼히 다지고 나서 도입하라. "더 빠른 모델"이라는 말만 믿고 갈아탔다가 CPU만 오르는 사례가 실제로 있으니, 측정 없는 마이그레이션은 금물이다.

참고 자료

728x90

+ Recent posts