TECH 으로 돌아가기
TECH HACKER NEWS 어제 9분 읽기 48 READS

리눅스 비동기 I/O의 두 강자, epoll과 io_uring 뭐가 다를까

수만 개의 연결을 동시에 처리한다는 것

웹 서버나 데이터베이스처럼 수많은 클라이언트를 한꺼번에 상대하는 프로그램을 만들다 보면, "이 많은 연결을 대체 어떻게 동시에 처리하지?"라는 고민에 꼭 부딪히게 되거든요. 연결 하나당 스레드 하나를 띄우는 방식은 직관적이긴 한데, 연결이 수만 개가 되는 순간 메모리도 CPU도 버티질 못해요. 스레드를 이리저리 바꿔가며 실행하는 비용(컨텍스트 스위칭)만으로도 서버가 헐떡이거든요.

그래서 나온 게 비동기 I/O(asynchronous I/O) 예요. 이게 뭐냐면, "데이터 준비되면 그때 나한테 알려줘, 그동안 난 다른 연결들 처리하고 있을게" 하는 방식이에요. 한 명이 식당에서 여러 테이블을 동시에 맡는 종업원을 떠올리면 쉬워요. 테이블마다 한 명씩 붙이는 대신, 한 명이 '주문 준비됐어요' 신호가 오는 테이블만 골라서 응대하는 거죠.

리눅스에서 오랫동안 이 역할을 해온 대표 선수가 epoll인데요. 최근 몇 년 사이 io_uring이라는 새 친구가 등장하면서 "이제 epoll 시대는 끝난 거 아니냐"는 이야기가 심심찮게 나와요. 오늘은 이 둘이 뭐가 다르고, 실제로 어떤 상황에서 뭘 골라야 하는지 풀어볼게요.

epoll은 어떻게 일하나

epoll의 기본 아이디어는 "내가 관심 있는 소켓 목록을 커널한테 미리 등록해두고, 그중에 이벤트가 발생한 애들만 한 번에 받아오자"예요. epoll_ctl로 감시할 소켓을 등록해두고, epoll_wait를 호출하면 "읽을 데이터가 준비된 소켓들" 목록을 돌려받는 식이죠. 예전에 쓰던 selectpoll은 매번 전체 소켓 목록을 커널에 통째로 넘겨야 해서 연결이 많아질수록 느려졌는데, epoll은 등록은 한 번만 하고 변화만 받아오니까 연결 수가 많아져도 성능이 잘 떨어지지 않아요.

다만 epoll에는 구조적인 한계가 하나 있어요. epoll은 "읽을 준비가 됐다"는 알림만 줄 뿐, 실제 데이터를 읽고 쓰는 건 여전히 read, write 같은 시스템 콜을 따로 호출해야 한다는 거예요. 시스템 콜이 뭐냐면, 우리 프로그램(유저 공간)이 커널한테 일을 시킬 때 넘어가는 '관문'인데, 이 관문을 넘나드는 데 매번 비용이 들어요. 연결 하나를 처리할 때마다 알림 받고(epoll_wait), 읽고(read), 쓰는(write) 식으로 관문을 여러 번 드나드는 셈이라, 초당 수십만 건을 처리하는 상황에선 이 작은 비용들이 쌓여서 무시 못 할 부담이 됩니다.

io_uring은 발상을 바꿨다

io_uring은 여기서 발상을 통째로 바꿨어요. 핵심은 두 개의 링 버퍼(ring buffer) 예요. 하나는 "이 작업들 좀 해줘"라고 요청을 쌓는 제출 큐(submission queue), 다른 하나는 "이건 다 끝났어"라고 결과가 돌아오는 완료 큐(completion queue)인데요. 이 두 큐를 유저 공간과 커널이 공유 메모리로 같이 본다는 게 마법이에요.

무슨 뜻이냐면, 우리 프로그램이 "이 소켓 읽어줘, 저 파일도 써줘" 같은 요청을 공유 메모리에 그냥 차곡차곡 적어두기만 하면 되거든요. 그러면 한 번의 시스템 콜로 여러 작업을 몰아서 커널에 넘길 수 있고, 심지어 커널이 알아서 폴링하게 설정하면 시스템 콜 없이도 작업이 흘러가요. 게다가 io_uring은 단순히 '알림'이 아니라 읽기·쓰기 작업 자체를 대신 수행해서 결과를 완료 큐에 넣어줘요. epoll처럼 알림 따로, read 따로 할 필요가 없는 거죠. 관문을 드나드는 횟수가 확 줄어드니, 이론상 훨씬 효율적입니다. 게다가 네트워크 소켓뿐 아니라 디스크 파일 I/O까지 같은 인터페이스로 비동기 처리할 수 있다는 것도 큰 장점이에요.

그래서 io_uring이 항상 이기느냐, 그건 아니에요

여기서 많은 분들이 "그럼 무조건 io_uring 써야겠네"라고 생각하는데, 실제 벤치마크를 들여다보면 그렇게 단순하지 않아요. 워크로드에 따라 epoll이 여전히 비등하거나 더 나은 경우도 적지 않거든요. 예를 들어 연결마다 주고받는 데이터가 작고 단순한 에코 서버 같은 경우엔, io_uring의 링 관리·배칭 오버헤드가 오히려 이득을 깎아먹기도 해요. io_uring의 진짜 강점은 여러 작업을 한 번에 묶어서(배칭) 제출할 때, 그리고 버퍼를 미리 등록(registered buffer)해두고 재사용할 때 비로소 드러납니다. 즉, 단순히 epoll을 io_uring으로 갈아끼운다고 공짜 성능이 나오는 게 아니라, io_uring에 맞게 코드 구조 자체를 다시 짜야 효과를 보는 거예요.

현실적인 제약도 있어요. io_uring은 비교적 최신 기능이라 오래된 커널에선 못 쓰고요. 강력한 만큼 보안 취약점도 여러 차례 보고돼서, 일부 클라우드 환경이나 보안에 민감한 곳에서는 io_uring을 아예 막아두기도 해요. epoll은 그에 비하면 오래되고 검증된, 어디서나 돌아가는 안정적인 선택지죠.

업계는 지금 어디쯤 와 있나

인프라 쪽에선 이미 io_uring을 적극적으로 흡수하는 중이에요. 러스트의 비동기 런타임 tokio는 io_uring 기반 런타임을 실험·도입하고 있고, 데이터베이스나 스토리지 엔진처럼 디스크 I/O가 성능을 좌우하는 곳들이 특히 io_uring에 진심이에요. 반면 nginx 같은 검증된 네트워크 서버들은 여전히 epoll을 기본으로 단단하게 굴리고 있고요. 결국 업계 흐름은 "epoll이 사라진다"기보다는, 디스크와 네트워크를 한 번에 다뤄야 하는 고성능 영역부터 io_uring으로 옮겨가고, 일반적인 영역은 당분간 epoll과 공존하는 모양새예요.

한국 개발자에게 주는 시사점

실무에서는 대부분 이 둘을 직접 만질 일은 없어요. Node.js의 libuv, Go의 런타임, 자바의 네티(Netty) 같은 라이브러리가 안에서 알아서 epoll을 깔고 있으니까요. 하지만 "내가 쓰는 프레임워크가 안에서 어떻게 돌아가는지"를 아는 것과 모르는 것은 성능 튜닝이나 장애 분석 때 하늘과 땅 차이예요. 특히 직접 고성능 서버나 프록시, DB 엔진을 만지는 분이라면 io_uring을 한번 깊게 파볼 가치가 충분합니다. 다만 "새 거니까 빠르겠지"라는 막연한 기대보다는, 내 워크로드에서 실제로 측정해보고 배칭과 버퍼 등록까지 제대로 활용할 때만 io_uring의 진가가 나온다는 걸 꼭 기억하세요.

핵심 한 줄로 정리하면 이래요. epoll은 검증된 안정적 일꾼, io_uring은 제대로 길들였을 때 폭발하는 신예. 여러분 프로젝트에서 I/O 병목을 느껴본 적 있나요? 그때 원인이 정말 커널 인터페이스였는지, 아니면 그 위 애플리케이션 로직이었는지 한번 같이 이야기해봐요.


🔗 출처: Hacker News

SOURCE · HACKER NEWS
원문 전체 보기 → https://sibexi.co/posts/epoll-vs-io_uring/
SHARE
처리 중...