Linux 네트워크 프로그래밍을 공부하면서 socket(), accept(), recv() 같은 API를 각각 외우는 것보다 더 중요했던 것은 왜 Blocking → Thread → Non-blocking → poll → epoll 순서로 발전하는지 이해하는 것이었다.
이번 실습에서는 가장 단순한 TCP 서버부터 시작해 하나의 스레드로 여러 connection을 처리하는 epoll 서버까지 직접 구현했다.
TCP 서버의 기본 흐름은 다음과 같다.
socket()
↓
bind()
↓
listen()
↓
accept()
↓
recv() / send()
socket()의 반환값도 일반 파일처럼 정수형 File Descriptor다.
fd 0 = stdin
fd 1 = stdout
fd 2 = stderr
fd 3 = server socket
즉 Linux에서는 파일뿐 아니라 socket도 FD라는 공통 인터페이스로 관리한다.
서버에서는 특히 두 종류의 socket을 구분해야 한다.
server_fd
= 새로운 connection을 받기 위한 listening socket
client_fd
= 특정 client와 실제 통신하기 위한 connected socket
예를 들면:
server_fd = 3
Listening Socket
│
┌─────────┼─────────┐
▼ ▼ ▼
fd=4 fd=5 fd=6
Client A Client B Client C
accept()가 호출될 때마다 새로운 client_fd가 만들어진다.
가장 단순한 서버는 다음과 같이 만들 수 있다.
while (1) {
int client_fd = accept(server_fd, NULL, NULL);
recv(client_fd, buffer, sizeof(buffer), 0);
send(client_fd, response, response_size, 0);
close(client_fd);
}
문제는 accept()와 recv()가 기본적으로 blocking이라는 것이다.
예를 들어 Client A가 연결만 하고 아무 데이터도 보내지 않는다면:
Main Thread
accept A
↓
recv(A)
↓
BLOCK
서버의 유일한 thread가 Client A를 기다리기 때문에 Client B, C를 애플리케이션 레벨에서 처리할 수 없다.
중요한 것은 blocking 자체가 나쁜 것이 아니라는 점이다.
문제는:
하나뿐인 실행 흐름이 특정 connection에서 blocking되면서 다른 connection까지 처리할 수 없어진다.
는 것이다.
가장 직관적인 해결 방법은 connection마다 thread를 만드는 것이다.
Main Thread
│
├── accept A → Worker A → recv(A)
│
├── accept B → Worker B → recv(B)
│
└── accept C → Worker C → recv(C)
이제 Client A가 recv()에서 기다려도 Worker A만 block된다.
Main thread는 계속 새로운 connection을 accept()할 수 있다.
하지만 새로운 문제가 생긴다.
100 connections
→ 약 100 worker threads
10,000 connections
→ 약 10,000 worker threads
각 thread에는 stack과 scheduling 상태가 필요하고, runnable thread가 많아지면 context switching 비용도 증가한다.
특히 많은 connection이 실제 작업을 하지 않고 recv()에서 기다리고만 있다면 connection마다 thread 하나를 유지하는 것이 비효율적일 수 있다.
다음 아이디어는 thread를 recv()에서 재우지 않는 것이다.
socket에 O_NONBLOCK을 설정한다.
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
이제 데이터가 없을 때:
recv(fd, buffer, size, 0);
가 기다리지 않는다.
대신:
return = -1
errno = EAGAIN 또는 EWOULDBLOCK
으로 즉시 돌아온다.
즉:
Blocking recv
데이터 없음
→ Thread sleep
에서
Non-blocking recv
데이터 없음
→ EAGAIN
→ 바로 다음 코드 실행
으로 바뀐다.
Listening Socket 역시 non-blocking으로 설정할 수 있다.
그 이유는 accept() 또한 blocking이기 때문이다.
server_fd non-blocking
→ accept() 때문에 event loop가 멈추지 않도록
client_fd non-blocking
→ recv() 때문에 event loop가 멈추지 않도록
처음에는 모든 client socket을 배열에 넣고 직접 순회했다.
while (1) {
accept(...);
for (모든 client_fd) {
recv(client_fd, ...);
}
}
그러면 대부분의 socket에서:
fd 4 → EAGAIN
fd 5 → EAGAIN
fd 6 → EAGAIN
fd 7 → EAGAIN
...
이 반복된다.
Thread는 block되지 않지만 CPU가 계속 모든 FD를 확인하게 된다.
즉 Busy Polling 문제가 발생한다.
Blocking 문제 해결
↓
Non-blocking
↓
새로운 문제
"어떤 FD에 실제 데이터가 있는지
어떻게 효율적으로 알 수 있을까?"
여기서 I/O Multiplexing이 등장한다.
poll()에서는 감시할 FD 목록을 kernel에 전달한다.
struct pollfd fds[MAX_CLIENTS];
fds[0].fd = server_fd;
fds[0].events = POLLIN;
poll(fds, count, -1);
poll()은 이벤트가 생길 때까지 thread를 sleep시킨다.
Application
│
│ poll()
▼
Kernel
"이 FD들 중
준비되는 것이 생기면 깨워줘."
이제 application이 계속 recv()를 호출해보면서 확인할 필요가 없다.
events와 revents도 구분할 필요가 있다.
events
= 내가 관심 있는 이벤트
revents
= 실제로 이번에 발생한 이벤트
예를 들어:
fds[i].events = POLLIN;
은 읽기 가능한 상태에 관심 있다는 뜻이고:
if (fds[i].revents & POLLIN)
으로 실제 발생 여부를 확인한다.
Listening Socket 역시 poll()에서 감시할 수 있다.
server_fd + POLLIN
→ accept() 가능한 connection 존재
client_fd + POLLIN
→ recv() 가능한 상태
이 방식으로 한 thread가 여러 socket을 동시에 기다릴 수 있다.
poll()은 Busy Waiting 문제를 해결한다.
하지만 수많은 FD를 관리할 때 여전히 비효율적인 부분이 있다.
예를 들어 10,000개의 FD 중 2개에만 이벤트가 발생했다고 하자.
poll() 이후 application은 여전히 pollfd[] 배열을 순회하며 어떤 FD에 이벤트가 발생했는지 찾아야 한다.
fds[0]
fds[1]
fds[2]
...
fds[9999]
그래서 Linux에서는 많은 connection을 효율적으로 다루기 위한 epoll을 제공한다.
epoll의 핵심 API는 세 개다.
epoll_create1()
epoll_ctl()
epoll_wait()
역할은 다음과 같다.
epoll_create1()
→ epoll 인스턴스 생성
epoll_ctl()
→ 감시할 FD를 등록 / 수정 / 제거
epoll_wait()
→ 이벤트가 발생한 FD들을 기다림
먼저 epoll instance를 만든다.
int epfd = epoll_create1(0);
Listening Socket을 등록한다.
struct epoll_event ev = {0};
ev.events = EPOLLIN;
ev.data.fd = server_fd;
epoll_ctl(
epfd,
EPOLL_CTL_ADD,
server_fd,
&ev
);
그 후:
struct epoll_event events[64];
int ready = epoll_wait(
epfd,
events,
64,
-1
);
을 호출한다.
전체 서버 구조는 다음처럼 된다.
Main Thread
│
▼
epoll_wait()
│
┌──────────────┴──────────────┐
│ │
▼ ▼
server_fd client_fd
EPOLLIN EPOLLIN
│ │
▼ ▼
accept() recv()
│
▼
client_fd
│
▼
epoll_ctl(ADD)
│
└───────────────→ epoll_wait()
별도의 "epoll 서버"나 epoll 전용 thread가 있는 것이 아니다.
현재 서버의 main thread가 epoll_wait()에서 여러 FD의 이벤트를 동시에 기다린다.
이전에는:
accept() 하나만 기다림
또는
recv(A) 하나만 기다림
이었다면 이제는:
server_fd
client A
client B
client C
...
중 무엇이든 준비되면 깨워줘.
가 된다.
이게 event loop의 핵심이다.
Listening Socket에 EPOLLIN 이벤트가 발생했다고 해서 새로운 connection이 정확히 하나만 있다는 뜻은 아니다.
Kernel의 accept queue에:
[A] [B] [C]
처럼 여러 connection이 들어와 있을 수도 있다.
따라서:
while (1) {
int client_fd = accept(server_fd, NULL, NULL);
if (client_fd == -1) {
if (errno == EAGAIN ||
errno == EWOULDBLOCK) {
break;
}
perror("accept");
break;
}
...
}
와 같이 처리한다.
accept A
accept B
accept C
accept
→ EAGAIN
→ 현재 받아들일 connection을 모두 처리함
Listening Socket을 non-blocking으로 설정했기 때문에 더 이상 connection이 없으면 accept()가 기다리지 않고 EAGAIN을 반환한다.
Client socket에서도 같은 패턴을 사용했다.
while (1) {
ssize_t n = recv(
active_fd,
buffer,
sizeof(buffer) - 1,
0
);
if (n > 0) {
...
}
else if (n == 0) {
// peer 정상 종료
...
break;
}
else {
if (errno == EAGAIN ||
errno == EWOULDBLOCK) {
break;
}
...
}
}
즉 현재 kernel receive buffer에서 읽을 수 있는 데이터는 계속 읽고:
recv()
recv()
recv()
...
EAGAIN
이 나오면 다시 epoll_wait()로 돌아간다.
현재 구현에서는:
client_ev.events = EPOLLIN;
만 사용했다.
따라서 기본 방식인 Level Triggered(LT)다.
예를 들어 socket buffer에:
HELLOWORLD
가 들어왔는데 일부만 읽어서:
WORLD
가 남아 있다면 다음 epoll_wait()에서도 읽을 데이터가 있다고 다시 알려준다.
즉:
읽을 데이터가 남아 있는 동안
→ 계속 readable 상태
다.
반면 Edge Triggered에서는 상태가 변한 순간을 중심으로 알려주기 때문에 데이터를 EAGAIN까지 비우는 패턴이 더욱 중요해진다.
이번 구현에서는 LT를 사용했지만 recv()를 EAGAIN까지 반복하는 구조를 사용했다.
개념적으로 비교하면:
poll
전체 FD 목록
↓
poll()
↓
이벤트 발생
↓
application이 전체 목록을 확인
반면 epoll은:
FD들을 미리 kernel에 등록
↓
epoll_wait()
↓
실제 준비된 이벤트 목록 반환
예를 들어:
등록된 socket = 10,000개
이벤트 발생:
fd 17
fd 921
이라면:
ready = 2;
events[0] → fd 17
events[1] → fd 921
처럼 준비된 이벤트 목록을 중심으로 처리할 수 있다.
그래서:
for (int i = 0; i < ready; i++)
만 순회하면 된다.
현재 구현한 서버의 핵심은 다음과 같다.
while (1) {
int ready =
epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < ready; i++) {
int active_fd = events[i].data.fd;
if (active_fd == server_fd) {
while (1) {
int client_fd =
accept(server_fd, NULL, NULL);
if (client_fd == -1) {
if (errno == EAGAIN ||
errno == EWOULDBLOCK) {
break;
}
break;
}
set_nonblocking(client_fd);
struct epoll_event client_ev = {0};
client_ev.events = EPOLLIN;
client_ev.data.fd = client_fd;
epoll_ctl(
epfd,
EPOLL_CTL_ADD,
client_fd,
&client_ev
);
}
}
else {
while (1) {
ssize_t n =
recv(active_fd, buffer, size, 0);
if (n > 0) {
send(active_fd, buffer, n, 0);
}
else if (n == 0) {
epoll_ctl(
epfd,
EPOLL_CTL_DEL,
active_fd,
NULL
);
close(active_fd);
break;
}
else {
if (errno == EAGAIN ||
errno == EWOULDBLOCK) {
break;
}
close(active_fd);
break;
}
}
}
}
}
이 서버는 thread 하나로 여러 TCP connection을 관리한다.
epoll 서버를 만든 뒤 TCP의 또 다른 중요한 특성을 확인했다.
Client에서:
send(fd, "HELLO", 5, 0);
send(fd, "WORLD", 5, 0);
send(fd, "ABCDE", 5, 0);
를 호출했다.
하지만 서버에서는:
HELLOWORLDABCDE
15바이트가 한 번에 들어오기도 했다.
이것은 정상이다.
TCP는:
send A
send B
send C
라는 호출 경계를 보존하지 않는다.
TCP가 제공하는 것은 ordered byte stream이다.
따라서 다음 결과가 모두 가능하다.
recv #1 → HELLOWORLDABCDE
또는:
recv #1 → HELLO
recv #2 → WORLD
recv #3 → ABCDE
또는:
recv #1 → HEL
recv #2 → LOWOR
recv #3 → LDABCDE
그래서 실제 application protocol은 메시지 경계를 직접 정의해야 한다.
대표적인 방법은:
Fixed Length
Delimiter
Length Prefix
등이다.
TCP가 byte stream이라는 사실 때문에 다음도 중요하다.
recv(fd, buffer, 1000, 0);
했다고 반드시 1000바이트가 반환되는 것이 아니다.
요청: 1000 bytes
실제 recv: 200 bytes
일 수 있다.
필요한 데이터가 모두 모일 때까지 connection별 buffer에 누적해야 한다.
반대로:
send(fd, buffer, 1000, 0);
했다고 1000바이트 전체가 처리된다는 보장도 없다.
예를 들어:
전송 요청 = 1000
send 반환 = 600
남은 데이터 = 400
이 가능하다.
특히 non-blocking socket에서는 송신 버퍼에 공간이 없으면:
send()
→ -1
→ errno = EAGAIN
도 발생한다.
실제 event-driven 서버라면 남은 데이터를 connection별 write buffer에 저장한 후 EPOLLOUT 이벤트를 이용해 다시 전송해야 한다.
현재 echo server는 이 부분까지는 구현하지 않았다.
실험 중 서버에 다음 로그가 발생했다.
[fd=5] recv: HELLOWORLDABCDE
recv error: Connection reset by peer
처음에는 데이터까지 정상적으로 받았는데 왜 바로 error가 나는지 이상했다.
Client는:
send
send
send
sleep
close
만 하고 서버가 돌려준 echo를 읽지 않았다.
서버에서는 받은 데이터를 다시 client에게 전송하고 있었다.
Client Server
HELLOWORLDABCDE ─────────────▶
recv()
↓
echo 전송
◀───────────────
Client application은
echo를 recv하지 않고 socket 종료
이런 상황에서는 connection이 정상적인 FIN 흐름 대신 reset되어 서버가 다음 recv()에서:
errno = ECONNRESET
을 관찰할 수 있다.
그래서:
Connection reset by peer
가 출력되었다.
TCP connection 종료는 단순히 "socket이 사라진다"가 아니다.
정상적인 종료에서는 peer가 FIN을 보내고:
Client Server
close()
│
└──── FIN ─────────────▶
recv()
↓
0
서버의 recv()가 0을 반환한다.
즉:
if (n == 0) {
// peer 정상 종료
}
로 확인할 수 있다.
반면 RST는:
"이 connection을 즉시 중단한다."
에 가깝다.
서버에서는:
recv()
↓
-1
↓
errno = ECONNRESET
로 관찰할 수 있다.
간단하게 기억하면:
FIN
= 정상적인 stream 종료
RST
= connection 강제 reset
이다.
로그를 보면 새로운 client가 계속:
fd=5
로 들어오는 경우가 있었다.
이것도 정상이다.
FD는 connection의 영구적인 ID가 아니다.
Process의 File Descriptor Table에서 사용할 수 있는 번호일 뿐이다.
Client A
→ fd 5
close(5)
fd 5 자리 비어 있음
Client B
→ fd 5 재사용
따라서 FD 번호만 보고 이전 connection과 동일한 connection이라고 판단해서는 안 된다.
이번 실습을 한 줄로 연결하면 다음과 같다.
Blocking Server
↓
한 client의 recv가 전체 server thread를 막음
Thread-per-Connection
↓
connection마다 thread 생성
↓
많은 connection에서 thread 비용 증가
Non-blocking
↓
recv/accept가 thread를 재우지 않음
↓
하지만 모든 FD를 직접 확인
↓
Busy Polling
poll()
↓
kernel이 여러 FD를 기다려줌
↓
하지만 큰 FD 배열 관리/순회
epoll
↓
FD를 kernel에 등록
↓
준비된 이벤트 중심으로 처리
Event Loop
최종적으로:
One Thread
│
▼
epoll_wait()
│
├── new connection
│ ↓
│ accept()
│
├── readable client
│ ↓
│ recv()
│
└── writable client
↓
send()
형태로 발전한다.
이 실습의 목적은 epoll API를 암기하는 것이 아니다.
Java 백엔드에서:
Linux epoll
↓
Java NIO Selector
↓
Netty EventLoop
로 연결되는 구조를 이해하는 것이 목적이다.
결국 고수준 프레임워크에서 보이는:
EventLoop
Channel
Selector
Worker
같은 개념들도 밑바닥에서는:
Socket
Non-blocking I/O
I/O Multiplexing
Event notification
문제를 해결하기 위한 추상화다.
이번 실습을 통해 epoll_wait()이 단순한 API 하나가 아니라 수많은 connection 중 준비된 I/O를 효율적으로 찾아 처리하기 위한 event-driven 구조의 핵심이라는 것을 이해할 수 있었다.
✅ TCP Socket
✅ Blocking Server
✅ Thread-per-Connection
✅ Non-blocking I/O
✅ EAGAIN / EWOULDBLOCK
✅ Busy Polling
✅ poll()
✅ epoll
✅ Event Loop
✅ TCP Byte Stream
✅ Partial Read 개념
✅ Partial Write 개념
✅ FIN / RST 기초
✅ File Descriptor 재사용
NEXT
→ TCP Connection 종료 과정
→ half-close
→ FIN
→ CLOSE_WAIT
→ TIME_WAIT
→ Socket Buffer
→ Backlog
→ Keepalive / Nagle
→ Java NIO Selector