"서버가 클라이언트 요청을 '기다리는' 방법이 하나가 아니라면, 어떤 방식이 가장 효율적일까?"

IO 모델을 공부하다 보니, 단순히 "비동기가 좋다"가 아니라 각 모델이 왜 등장했는지를 이해해야 선택 기준이 보이더라고요. Blocking IO에서 시작해서 io_uring까지, 서버가 요청을 처리하는 방식이 어떻게 진화해왔는지 정리해봤습니다.

1. Blocking IO — 가장 직관적인 모델

하나의 IO 요청이 완료될 때까지 스레드가 멈춰서 기다리는 방식입니다.

JAVA
// 전형적인 Blocking IO 서버
ServerSocket serverSocket = new ServerSocket(8080);
while (true) {
    Socket client = serverSocket.accept();  // 블로킹: 연결 올 때까지 대기
    new Thread(() -> {
        InputStream in = client.getInputStream();
        byte[] buf = new byte[1024];
        int n = in.read(buf);  // 블로킹: 데이터 올 때까지 대기
        // 처리...
    }).start();
}

장점과 한계

  • **장점 **: 코드가 직관적이고 디버깅이 쉬움
  • ** 한계 **: 연결 하나당 스레드 하나 → 동시 접속 1만 개면 스레드 1만 개
  • 스레드 수만 개는 컨텍스트 스위칭 비용만으로 CPU를 잡아먹음 → C10K 문제

Tomcat의 전통적인 모델이 이것입니다. maxThreads=200이 기본값인 이유가 여기 있습니다.

2. Non-blocking IO — 바쁜 대기(Busy Waiting)

소켓을 Non-blocking 모드로 설정하면 데이터가 없어도 즉시 반환됩니다.

C
// Non-blocking 소켓 설정
fcntl(fd, F_SETFL, O_NONBLOCK);

while (1) {
    int n = read(fd, buf, sizeof(buf));
    if (n == -1 && errno == EAGAIN) {
        // 데이터 없음 → 다른 작업 하거나 다시 시도
        continue;
    }
    // 데이터 도착 → 처리
}

문제점

  • 데이터가 올 때까지 반복적으로 read()를 호출 → CPU 낭비
  • 단독으로는 거의 사용하지 않고, IO Multiplexing과 조합해서 사용

3. IO Multiplexing — select, poll, epoll

하나의 스레드가 여러 소켓을 동시에 감시하는 방식입니다. "어떤 소켓에 이벤트가 발생했는지"를 커널에게 물어봅니다.

select (1983)

C
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(fd1, &readfds);
FD_SET(fd2, &readfds);

// 이벤트 발생할 때까지 대기
select(maxfd + 1, &readfds, NULL, NULL, NULL);

// 어떤 FD에 이벤트가 왔는지 전체 순회
for (int i = 0; i <= maxfd; i++) {
    if (FD_ISSET(i, &readfds)) {
        // 처리
    }
}
  • FD 상한 1024개 (FD_SETSIZE)
  • 매 호출마다 FD 집합을 커널로 복사
  • O(n) 순회 필요

poll (1986)

  • select의 FD 개수 제한 해결
  • 하지만 여전히 O(n) 순회

epoll (Linux 2.6, 2002)

C
int epfd = epoll_create1(0);

// FD 등록 (한 번만)
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = client_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);

// 이벤트 대기 — 발생한 것만 반환
struct epoll_event events[MAX_EVENTS];
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);

for (int i = 0; i < nfds; i++) {
    // events[i]에 이벤트가 발생한 FD만 들어있음
    handle(events[i].data.fd);
}

epoll이 게임 체인저인 이유:

  • FD를 ** 커널에 한 번 등록 **하고 계속 감시 (매번 복사 불필요)
  • 이벤트가 발생한 FD만 반환 → O(이벤트 수)
  • 수십만 개의 동시 연결도 효율적으로 처리
  • Nginx, Netty, Redis 모두 epoll 기반

epoll의 한계

  • 여전히 epoll_wait()로 이벤트를 받고, 별도의 read()/write() 시스템 콜이 필요
  • 이벤트 감지와 실제 IO가 분리되어 있어 시스템 콜 횟수가 많음
  • 네트워크 IO에는 강하지만 파일 IO에는 적합하지 않음

4. Signal-driven IO와 POSIX AIO

Signal-driven IO

커널이 IO 준비되면 시그널(SIGIO)로 알려주는 방식입니다.

  • 시그널 핸들러에서 IO 수행
  • 실전에서 거의 사용하지 않음 (시그널 관리가 복잡하고 확장성 부족)

POSIX AIO (aio_read/aio_write)

  • 진짜 비동기 IO를 목표로 설계
  • 하지만 Linux 구현이 내부적으로 스레드풀을 사용 → 진정한 비동기가 아님
  • API도 불편하고 성능도 기대 이하 → 실무에서 거의 안 씀

이 한계를 근본적으로 해결하려고 나온 것이 io_uring입니다.

5. io_uring — 커널 5.1의 혁신 (2019)

커널과 유저 공간이 ** 공유 링 버퍼 **를 통해 IO 요청과 완료를 주고받는 방식입니다.

동작 원리

PLAINTEXT
유저 공간                          커널 공간
┌──────────────┐              ┌──────────────┐
│  SQ (제출 큐) │  ──요청──▶  │   IO 처리     │
│              │              │              │
│  CQ (완료 큐) │  ◀──결과──  │              │
└──────────────┘              └──────────────┘
     ↑ mmap으로 공유 메모리
  1. 유저 공간에서 SQ(Submission Queue)에 IO 요청을 채움
  2. io_uring_enter() 한 번으로 모든 요청을 커널에 제출
  3. 커널이 처리 완료하면 CQ(Completion Queue)에 결과를 넣음
  4. 유저 공간에서 CQ를 폴링하여 결과 수확

io_uring은 항상 빠를까? — 아닙니다

공부하면서 가장 놀랐던 부분인데, io_uring이 epoll보다 항상 빠른 것은 아닙니다.

단일 작업 제출 시:

PLAINTEXT
epoll: epoll_wait() → read()       = 시스템 콜 2회
io_uring: SQ에 넣기 → io_uring_enter() → CQ에서 꺼내기
         = 시스템 콜 1회 + 링 버퍼 관리 오버헤드

한 번에 하나씩 제출하면 링 버퍼 관리 비용이 epoll의 시스템 콜 비용과 비슷하거나 오히려 느릴 수 있습니다.

배치 처리에서 진가가 드러난다

PLAINTEXT
epoll + 1000개 요청:
  epoll_wait() 1회 + read() 1000회 = 시스템 콜 1001회

io_uring + 1000개 요청:
  SQ에 1000개 채우기 + io_uring_enter() 1회 = 시스템 콜 1회

io_uring의 핵심 장점은 배치 처리(batching) 입니다. 여러 IO 작업을 한 번의 io_uring_enter() 시스템 콜로 제출하여 syscall 비용을 분산시킵니다.

실제 벤치마크 수치:

  • **처리량 **: 배치 처리 시 epoll 대비 약 25% 향상
  • ** 지연 시간 **: p99 기준 약 1ms 개선
  • 하지만 한 팀은 io_uring 적용을 위해 18만 줄의 코드를 재작성 했는데, 수확 체감(diminishing returns)이 컸다는 보고도 있음

서버 프레임워크별 IO 모델

프레임워크IO 모델설명
Tomcat (BIO)Blocking IO + 스레드풀연결당 스레드, maxThreads=200
Tomcat (NIO)epoll 기반 Multiplexing8.5+부터 기본값
Nettyepoll (Linux) / kqueue (macOS)이벤트 루프 패턴, 소수 스레드로 수만 연결
Nginxepoll + 이벤트 드리븐워커 프로세스 × epoll
Redisepoll 싱글 스레드단일 스레드 이벤트 루프

모델별 비교 요약

PLAINTEXT
Blocking IO
  ├─ 장점: 직관적
  └─ 한계: 연결당 스레드 → C10K 불가

Non-blocking IO
  ├─ 장점: 안 기다림
  └─ 한계: busy waiting → CPU 낭비

IO Multiplexing (select/poll)
  ├─ 장점: 하나의 스레드로 다수 FD 감시
  └─ 한계: O(n) 순회, FD 복사 오버헤드

IO Multiplexing (epoll)
  ├─ 장점: O(이벤트 수), 등록 한 번
  └─ 한계: 이벤트 감지와 IO가 분리, 파일 IO에 부적합

io_uring
  ├─ 장점: 배치 처리로 syscall 최소화, 파일+네트워크 IO 통합
  └─ 한계: 복잡한 API, 단일 작업에선 이점 적음

정리

  • Blocking IO 는 직관적이지만 동시 접속에 한계가 있고, epoll 이 이 문제를 해결했습니다
  • io_uring 은 epoll의 다음 단계지만 항상 빠른 것은 아닙니다 — 배치 처리에서 진가가 나옵니다
  • 대부분의 서버 프레임워크(Netty, Nginx, Redis)는 epoll 기반이고, io_uring 전환은 아직 점진적입니다
  • 실무에서 IO 모델 선택은 "가장 새로운 것"이 아니라 ** 워크로드 특성에 맞는 것 **이 기준입니다

공부하다 보니 "비동기가 무조건 좋다"는 말이 얼마나 단순한지 느꼈습니다. 각 모델의 트레이드오프를 이해하는 것이 핵심이라고 생각합니다.

댓글 로딩 중...