그냥 글을 써 봅니다/서버 개발 수기

EP.09 — 왜 io_uring인가: epoll 대신 이걸 고른 이유

Tiboong 2026. 5. 5. 09:21
728x90

이 글은 『서버 개발 수기』 시리즈의 아홉 번째 글이다.

Part 2 "해독"이 끝났다. 여기서부터 Part 3 "변환"이 시작된다.

다만 코드를 옮기기 전에, 먼저 길을 정해야 한다.

Linux에는 두 갈래 길이 있다. epoll과 io_uring.

※ 이 시리즈에서 다루는 서버 코드는 Project-AO(Ancient Origin)라는 가명으로 부른다.


두 갈래 길

2006년 코드는 IOCP 위에 서 있다. Windows 전용이다. 이걸 Linux로 옮기려면 둘 중 하나를 골라야 한다.

  • epoll — 2002년부터 있었다. Linux에서 고성능 네트워크 서버를 쓴다고 하면 거의 자동으로 이거였다. nginx, Redis, Node.js 다 epoll 위에 서 있다.
  • io_uring — 2019년에 나왔다. Jens Axboe가 만든 새 인터페이스. 5.1 커널부터 들어갔다.

이력만 보면 epoll이 안전한 선택이다. 검증됐고, 자료가 많고, 다들 쓰니까.

그런데 코드를 옮기는 입장에서 보면 이야기가 달라진다.


묻는 방식이 다르다

핵심은 "커널한테 어떻게 묻느냐"다.

 

epoll의 묻는 방식. "이 소켓 읽을 준비 됐어?" 커널이 답한다. "응, 읽을 수 있어." 그러면 내가 read()를 부른다. 데이터가 내 버퍼로 들어온다.

 

IOCP의 묻는 방식. "이 버퍼로 읽어줘." 커널이 알아서 한다. 끝나면 알려준다. "읽기 끝났어, 100바이트 들어갔어."

 

앞쪽은 준비 통보(readiness-based) 모델이다. 뒤쪽은 완료 통보(completion-based) 모델이다.

 

식당으로 비유하면 이런 차이다. 한 식당은 자리가 비면 진동벨을 울린다. 가서 주문하고, 음식 받고, 먹는 건 내가 한다. 다른 식당은 주문만 받아둔다. 음식이 다 되면 알려준다. 받아만 가면 된다.

 

Project-AO 코드는 완료 통보 모델 위에 설계되어 있다. OVERLAPPED 두 개로 read/write를 동시에 거는 것도, 커널이 "이 OVERLAPPED 끝났어" 하고 돌려주는 것도, 다 그 모델을 전제한 코드다.

 

이 코드를 epoll로 옮기면 모델 자체가 바뀐다. "끝났어"를 받던 코드가 "준비됐어"를 받게 된다. 받은 다음에 직접 read/write를 또 호출해야 한다. 코드 결이 통째로 어긋난다.

 

io_uring은 다르다. 같은 완료 통보 모델이다.

 

코드로 보면 차이가 한눈에 들어온다.

// epoll: 준비 통보
epoll_wait(epfd, events, ...);                 // "준비됐어"
read(fd, buffer, size);                        // 내가 직접 읽음

// io_uring: 완료 통보 (IOCP와 같은 모델)
io_uring_prep_read(sqe, fd, buffer, size, 0);  // "이 버퍼로 읽어줘"
io_uring_submit(ring);
// ... 다른 일 ...
io_uring_wait_cqe(ring, &cqe);                 // "다 됐어"

epoll은 두 단계다. 알림을 받고 → 직접 읽는다. io_uring은 한 단계다. 부탁하고 → 끝났다는 통보를 받는다. EP.08에서 본 OVERLAPPED 코드의 결이 io_uring 쪽에 그대로 있다.


io_uring의 모양

간단히만 정리하면.

 

io_uring은 두 개의 링 버퍼를 쓴다. SQ(Submission Queue)와 CQ(Completion Queue). 둘 다 사용자 공간과 커널이 같이 보는 공유 메모리다.

 

내가 "이 버퍼로 읽어줘" 하고 부탁하고 싶으면 SQE(Submission Queue Entry) 하나를 SQ에 넣는다. 한 번에 여러 개 넣어도 된다. 다 넣고 나서 io_uring_enter()를 한 번 호출하면 커널이 그걸 다 가져간다. 끝난 것들은 CQE(Completion Queue Event)로 CQ에 들어온다.

 

이 구조에서 두 가지가 따라온다.

 

첫째, 시스템콜이 줄어든다. 요청을 한 줄씩 보내는 게 아니라 묶어서 보낸다. epoll이 "준비됐어" 알림을 받고 그때마다 read/write를 따로 부르는 것과 비교하면 호출 횟수 자체가 다르다.

 

둘째, 모델이 IOCP와 같다. SQE 넣고 CQE 받는 흐름이, OVERLAPPED 걸고 GetQueuedCompletionStatus 받는 흐름과 결이 같다. 발상 자체가 한 줄에 놓인다.


여섯 기법은 어떻게 되나

Part 2에서 들여다본 여섯 기법이 새 환경에서 어떻게 되는지 짚어보자. 이게 결국 "옮길 만한가"의 답이다.

OVERLAPPED 분리 → SQE 두 장

EP.08에서 봤다. 한 소켓에 OVERLAPPED를 두 개 둬서 read와 write를 동시에 굴린다.

io_uring에서는 더 단순해진다. read용 SQE 하나, write용 SQE 하나, 같은 fd에 동시에 제출한다. 끝. 커널이 둘을 독립적으로 처리한다. man page에도 "두 방향(read/write)은 독립적"이라고 명시되어 있다.

구조체 주소로 식별하던 건 io_uring에서는 user_data 필드로 바뀐다. SQE에 넣어둔 64비트 값이 CQE로 그대로 돌아온다. "이게 어느 작업이었는지"를 표시하는 태그다. 이름만 다르지 발상이 같다.

버퍼 풀 → registered buffers

EP.04에서 봤다. 16슬롯짜리 버퍼 풀을 만들어서 매번 할당/해제 안 하고 돌려쓴다.

io_uring에서는 이걸 OS 차원에서 받아준다. IORING_REGISTER_BUFFERS라는 기능이 있다. 버퍼들을 미리 커널에 등록해두면 매 I/O마다 페이지 매핑하는 비용이 사라진다. 등록된 버퍼는 인덱스로 참조한다. 우리가 슬롯 번호로 관리했던 것과 닮았다.

2006년에 사용자 공간에서 직접 만들었던 발상이, 2019년 이후 OS 차원의 정식 기능이 되어 있다.

Write 병합 → batching

EP.06에서 봤다. send를 매번 부르지 않고 16개 모아서 한 번에 보낸다.

io_uring은 batching이 디자인의 핵심이다. SQ에 SQE를 여러 개 쌓아두고 io_uring_enter() 한 번으로 제출한다. 우리가 사용자 공간에서 직접 했던 일이, 인터페이스 자체에 박혀 있다.

물론 모양은 다르다. 우리는 "바이트를 모아서 한 번에 send"였고, io_uring은 "send 요청을 모아서 한 번에 제출"이다. 그런데 목적은 같다. 시스템콜 횟수를 줄인다.

참조 카운팅, 락-프리 포인터, 스핀락 → 그대로

EP.05, EP.07, EP.03에서 본 세 기법은 OS 무관이다. 사용자 공간에서 도는 동기화 메커니즘이니까. io_uring으로 옮긴다고 해서 바꿀 이유가 없다.

 

오히려 io_uring을 멀티스레드에서 쓰려면 SQ/CQ 링의 head/tail을 사용자 공간에서 직접 동기화해야 한다고 한다. 락-프리 패턴이 거기서도 필요하다. 우리 코드에 있는 동기화 자산이 그대로 쓰임새가 있다.

 


여기서 잠깐

지금까지 흐름만 보면 io_uring이 만능 같다. 그건 사실이 아니다.

 

io_uring 커뮤니티에 잘 알려진 비교가 있다. 단순한 ping-pong 워크로드에서는 io_uring이 epoll을 이긴다. 그런데 streaming 워크로드에서는 epoll이 더 빠른 경우도 있다. 단순 echo 서버 벤치마크에서 epoll이 io_uring보다 빠르다는 보고도 GitHub에 올라와 있다.

io_uring의 진가는 batching을 적극적으로 활용할 때 나온다. 그냥 epoll 코드를 io_uring API로 1:1 치환하면 별 차이가 없거나 오히려 느릴 수 있다는 게 여러 벤치마크의 결론이다. 한 DBMS 연구에 따르면, libaio를 io_uring으로 바로 바꿨을 때 1.06배 향상이지만, 시스템을 io_uring 특성(batching, registered buffers)에 맞춰 재설계하면 2배까지 간다고 한다.

 

그리고 io_uring은 여전히 활발히 개발 중인 신기능이다. 5.1에서 처음 들어왔지만, 멀티샷 receive(6.0)나 zero-copy 송수신 같은 기능이 계속 추가되고 있다. 과거에는 보안 이슈도 몇 번 있었다.

 

그러니까 "무조건 io_uring"은 아니다. 워크로드를 보고 골라야 한다.

 

그런데 이 시리즈의 코드는 MMORPG 서버다. 한 소켓에 양방향으로 끊임없이 데이터가 흐르고, 한 번의 게임 틱마다 수백 개의 패킷이 동시에 나가고 들어오는 구조. 이건 io_uring의 batching이 정확히 빛을 보는 워크로드에 가깝다. 그리고 무엇보다, 모델이 같다.


그래서 io_uring이다

정리하면 이렇다.

  • 모델이 같다. 완료 통보. 코드 결이 어긋나지 않는다.
  • 여섯 기법 중 셋이 OS 차원에서 받아준다. 직접 안 만들어도 된다.
  • 나머지 셋은 OS 무관이라 그대로 가져갈 수 있다.
  • 워크로드도 io_uring 쪽이 더 어울린다.

epoll로 가면 코드를 바닥부터 다시 짜야 한다. "준비 통보" 모델로 머리부터 발끝까지 갈아엎어야 한다. io_uring으로 가면 발상은 그대로 두고 인터페이스만 바꾸면 된다.

그래서 io_uring이다.


그런데 여기서 한 가지 의문

2006년에 짠 코드가 2019년에 나온 인터페이스와 모델이 같다는 게, 사실 좀 이상한 일이다.

 

2006년의 IOCP는 Windows 전용이었다. Linux 진영에서는 "epoll이면 충분하다"는 분위기가 오래 갔다. io_uring이 나온 건 13년 뒤인 2019년이다. "epoll로는 부족하다"는 이야기가 그 무렵 슬슬 나오기 시작했다.

 

그러니까 2006년에 누군가가 IOCP 위에 짠 이 코드는, 그 시점에는 Windows 전용 기법으로 보였지만, 13년 뒤에는 모델 자체가 옳았다는 게 증명된 셈이다. Linux도 결국 같은 곳으로 왔다.

 

이게 포팅의 자신감이 되어준다. 인터페이스만 갈아끼우면 된다는, 모델은 안 바꿔도 된다는 자신감.

 

그리고 한 가지 더, 마음에 걸리는 게 있다.

io_uring의 SQ/CQ 링 버퍼 구조. 사용자 공간과 커널이 공유 메모리로 통신하면서, 요청을 모아서 묶음으로 처리하는 구조. 이게 io_uring만의 발상이 아니다. 어디선가 비슷한 그림을 본 적이 있다.

 

어디였더라.


다음 회에서는

이 시리즈의 마지막 글이다. Part 3 "변환"의 시작이자, 1막의 에필로그.

2006년 MMORPG 서버 코드에서 발견한 발상들이, 어떻게 2025년의 LLM 인프라에서 다시 나타나는지. 버퍼 풀과 KV cache, Write 병합과 continuous batching, 그리고 비동기 I/O와 LLM serving 아키텍처.

20년 전 코드가 본 미래는 io_uring 하나가 아니었다.

→ EP.10: 에필로그 — 2006년 MMORPG 서버와 2025년 LLM 인프라

728x90