1. 핵심 개념 이해
사용자 공간과 커널 공간
현대 운영체제는 가상 메모리를 사용합니다. 32비트 시스템 기준으로 4GB(2^32)의 가상 주소 공간이 존재합니다. 운영체제의 핵심인 커널은 보호된 메모리 영역에 접근 가능하며, 하드웨어 장치에 대한 모든 권한을 가집니다. 보안을 위해 사용자 프로세스가 커널에 직접 접근하지 못하도록 가상 공간을 두 부분으로 나눕니다. 리눅스에서는 상위 1GB(0xC0000000 ~ 0xFFFFFFFF)를 커널 공간으로, 하위 3GB(0x00000000 ~ 0xBFFFFFFF)를 사용자 공간으로 할당합니다.
프로세스 전환(Context Switching)
CPU에서 실행 중인 프로세스를 일시 중단하고 이전에 중단된 프로세스를 재개하는 기능을 프로세스 전환이라고 합니다. 이 과정은 커널의 지원을 통해 이루어집니다.
프로세스 전환 시 발생하는 단계:
- 프로그램 카운터와 레지스터 등 프로세서 컨텍스트 저장
- PCB(Process Control Block) 정보 갱신
- PCB를 준비, 블록 등 적절한 큐로 이동
- 다른 프로세스 선택 및 PCB 갱신
- 메모리 관리 데이터 구조 갱신
- 프로세서 컨텍스트 복원
프로세스 블로킹
실행 중인 프로세스가 자원 요청 실패, 특정 작업 완료 대기, 새 데이터 미도착 등의 이유로 기다려야 할 때, 시스템이 자동으로 블로킹 원시 코드(Block primitive)를 실행하여 프로세스를 실행 상태에서 블록 상태로 전환합니다. 이는 프로세스의 능동적인 행동이며, 오직 실행 상태(CPU 점유 중)인 프로세스만 블록 상태가 될 수 있습니다. 블록 상태의 프로세스는 CPU 자원을 소비하지 않습니다.
파일 디스크립터(File Descriptor, fd)
파일 디스크립터는 파일을 참조하는 추상화된 개념입니다. 형식적으로는 0 이상의 정수이며, 커널이 각 프로세스에 대해 유지 관리하는 열린 파일 레코드 테이블의 인덱스입니다. 프로그램이 파일을 열거나 생성하면 커널은 파일 디스크립터를 반환합니다. 이 개념은 주로 UNIX, Linux 계열 운영체제에서 사용됩니다.
버퍼 I/O (Buffered I/O)
대부분 파일 시스템의 기본 I/O 방식입니다. 데이터는 파일 시스템의 페이지 캐시(page cache)에 먼저 저장된 후, 커널 버퍼에서 애플리케이션 주소 공간으로 복사됩니다.
단점: 데이터 전송 시 애플리케이션과 커널 간 여러 번의 복사가 발생하여 CPU와 메모리 오버헤드가 큽니다.
2. I/O 모델 개요
네트워크 I/O의 본질은 소켓 읽기입니다. 리눅스에서 소켓은 스트림으로 추상화되며, I/O는 스트림에 대한 작업입니다. read 작업은 두 단계로 진행됩니다:
- 데이터 준비 대기: 네트워크 패킷이 도착하여 커널 버퍼에 복사될 때까지 대기
- 커널에서 프로세스로 데이터 복사: 커널 버퍼의 데이터를 애플리케이션 프로세스 버퍼로 전송
네트워크 애플리케이션이 처리하는 주요 문제는 네트워크 I/O와 데이터 계산입니다. 일반적으로 I/O 지연이 성능에 더 큰 병목을 초래합니다. 주요 I/O 모델은 다음과 같습니다:
- BIO (Blocking I/O) - 블로킹 모델
- NIO (Non-blocking I/O) - 논블로킹 모델
- I/O 멀티플렉싱 (Multiplexing I/O)
- AIO (Asynchronous I/O) - 비동기 모델
- SIO (Signal-driven I/O) - 시그널 기반 모델
참고: SIO(시그널 구동 I/O)는 실제 사용 빈도가 낮아 본 글에서는 다루지 않습니다.
3. I/O 모델 상세
3.1 BIO – 블로킹 I/O
동작 방식: 사용자 프로세스가 요청을 보낸 후 데이터를 받을 때까지 대기 상태로 유지되며, 데이터 복사는 사용자 프로세스가 수행합니다.
비유: 한 사람이 가게에 가서 칼을 사려고 합니다. 가게에 칼이 있으면 바로 받고, 없으면 주문 후 기다려야 합니다. 이 모든 과정 동안 구매자는 계속 기다려야 합니다.
코드 예제 (서버):
import socket
server = socket.socket()
server.bind(('127.0.0.1', 8080))
server.listen(5)
while True:
conn, addr = server.accept() # 블로킹
while True:
try:
data = conn.recv(1024) # 블로킹
if not data:
break
print(data)
conn.send(data.upper())
except ConnectionResetError:
break
conn.close()
문제점: 멀티스레드/프로세스 풀을 사용해도 I/O 대기 자체를 회피하지 못합니다. 여러 클라이언트가 각각 독립적으로 기다릴 뿐입니다.
3.2 NIO – 논블로킹 I/O
동작 방식: 사용자 프로세스가 요청 시 데이터가 준비되지 않았으면 즉시 "준비되지 않음"을 반환받습니다. 프로세스는 계속 요청을 보내거나 다른 작업을 하다가 나중에 다시 확인합니다. 데이터가 준비되면 프로세스가 직접 복사합니다.
비유: 칼을 사러 가게에 갔는데 재고가 없습니다. 주기적으로 "도착했나요?" 물어보며 기다리다가 도착하면 받아옵니다. 대기 중에는 다른 일을 할 수 있지만, 복사는 직접 해야 합니다.
코드 예제 (서버, 단일 스레드 논블로킹):
import socket
server = socket.socket()
server.bind(('127.0.0.1', 8081))
server.listen(5)
server.setblocking(False) # 논블로킹 모드
connections = []
to_remove = []
while True:
try:
conn, addr = server.accept()
connections.append(conn)
except BlockingIOError:
for conn in connections[:]: # 복사본 사용
try:
data = conn.recv(1024)
if not data:
conn.close()
connections.remove(conn)
continue
conn.send(data.upper())
except (BlockingIOError, ConnectionResetError):
pass
단점: 하나의 스레드가 여러 소켓을 처리하지만, 지속적인 폴링(polling)으로 CPU 낭비가 심합니다.
3.3 I/O 멀티플렉싱 (select/poll/epoll)
동작 방식: 블로킹 I/O와 유사하지만, 여러 파일 디스크립터를 한 번에 모니터링하는 대리자(selector)를 사용합니다. 데이터 복사는 여전히 사용자 프로세스가 수행합니다.
비유: 여러 사람이 한 가게에 칼을 주문합니다. 가게 주인(select/epoll)이 모든 주문을 기록하고, 공급이 완료되면 각 구매자에게 알립니다. 구매자는 통보를 받고 가게에 와서 칼을 직접 받습니다.
코드 예제 (select 기반 서버):
import socket
import select
server = socket.socket()
server.bind(('127.0.0.1', 8080))
server.listen(5)
server.setblocking(False)
read_list = [server]
while True:
# select를 통해 읽기 가능한 소켓 목록을 얻음 (블로킹)
readable, _, _ = select.select(read_list, [], [])
for sock in readable:
if sock is server: # 새로운 연결 요청
conn, addr = sock.accept()
read_list.append(conn)
else: # 기존 클라이언트 데이터
data = sock.recv(1024)
if not data:
sock.close()
read_list.remove(sock)
else:
print(data)
sock.send(b'response')
select, poll, epoll 비교
| 특징 | select | poll | epoll |
|---|---|---|---|
| 파일 디스크립터 수 제한 | 1024 (FD_SETSIZE) | 없음 (pollfd 배열) | 없음 (최대 열린 파일 수) |
| 데이터 구조 | 비트 배열 (fd_set) | pollfd 구조체 배열 | 이벤트 기반 (red-black tree + linked list) |
| 성능 | O(n) (모든 fd 스캔) | O(n) | O(1) (준비된 fd만 처리) |
| 호출 방식 | 매번 fd_set 초기화 및 복사 | 매번 pollfd 배열 복사 | epoll_ctl에서 한 번만 복사, epoll_wait은 준비 목록만 확인 |
| 트리거 모드 | 레벨 트리거 (Level Triggered) | 레벨 트리거 | 레벨 트리거 + 엣지 트리거 (Edge Triggered) |
select 동작 과정:
- 사용자 공간의 fd_set을 커널 공간으로 복사
- 모니터링할 모든 fd에 대해 poll 메서드 호출 (내부적으로 __pollwait 호출)
- 현재 프로세스(current)를 각 fd의 대기 큐에 등록
- 데이터가 준비되면 대기 큐의 프로세스 깨움
- 모든 fd를 순회하며 준비된 fd를 fd_set에 표시
- 결과를 사용자 공간으로 복사
epoll의 개선점:
- fd 등록 시 한 번만 커널에 복사 (epoll_ctl)
- 콜백 기반: 각 fd에 콜백 함수 등록, 이벤트 발생 시 준비된 fd를 리스트에 추가
- mmap을 활용해 사용자-커널 간 데이터 복사 최소화
- 준비된 fd만 반환하므로 O(1) 성능
3.4 AIO – 비동기 I/O
동작 방식: 요청을 보내면 즉시 응답을 받고, 데이터 복사는 커널이 완전히 처리한 후 결과를 알려줍니다. 프로세스는 전혀 기다리지 않습니다.
비유: 온라인으로 칼을 주문하면 가게에서 확인 메시지를 보내고, 가게가 직접 재고를 확보하여 배송까지 완료합니다. 구매자는 배송 알림만 받으면 됩니다.
참고:
- Windows에서는 IOCP, Linux에서는 epoll로 AIO를 부분적으로 구현합니다.
- 실제 고성능 프레임워크 대부분은 AIO보다 I/O 멀티플렉싱(epoll)을 선호합니다. 그 이유는 epoll의 성능이 AIO에 비해 크게 뒤지지 않고, AIO의 복잡성과 디버깅 어려움 때문입니다.