모든 HTTP 라이브러리·DB driver·gRPC client 의 바닥에는 socket 이 있다. 그 socket 이 어떻게 OS 와 대화하고, 왜 epoll / kqueue 같은 이름이 나오고, Node.js 가 single-thread 인데 10,000 connection 을 처리할 수 있는지 — 이 가이드는 그 메커니즘을 정리한다.
Socket — 네트워크의 file descriptor
// C (Linux)
int sock = socket(AF_INET, SOCK_STREAM, 0); // TCP socket 생성
connect(sock, &server_addr, sizeof(server_addr)); // 연결
write(sock, "GET / HTTP/1.1\r\n\r\n", 18); // 전송
read(sock, buf, 4096); // 수신
close(sock); // 닫기Unix 의 핵심 추상 — everything is a file. socket 도 file descriptor (정수 ID) 로 다룸. read / write / close 같은 동일 API 사용.
TCP vs UDP — 보장의 trade-off
| 속성 | TCP | UDP |
|---|---|---|
| 연결 | 3-way handshake | connectionless (datagram) |
| 순서 | 보장 | X |
| 전달 | retransmit, 보장 | best-effort, drop 가능 |
| congestion control | O | X (application 책임) |
| header overhead | 20 byte | 8 byte |
| use case | HTTP, DB, file transfer | DNS, VoIP, 게임, video stream |
HTTP/3 (QUIC) 가 UDP 위에 reliable + multiplexing 직접 구현 — TCP 의 head-of-line blocking 회피.
TCP 3-way handshake
Client Server
│ │
│──── SYN (seq=x) ──────────────→│
│ │
│←──── SYN-ACK (seq=y, ack=x+1)─│
│ │
│──── ACK (ack=y+1) ────────────→│
│ │
│── 데이터 송수신 가능 ─────────│RTT × 1.5 의 latency. 그래서 매 요청마다 새 connection 열면 비싸짐 → keep-alive / connection pool.
The C10K Problem — 1999 년의 벽
한 서버가 10,000 동시 connection 처리 가능한가? 1999 년 Dan Kegel 의 글이 화제. 당시 표준 모델로는 어려웠다.
모델 1 — Thread per connection (전통)
while (1) {
int client = accept(server_sock, ...);
pthread_create(&tid, NULL, handle, &client);
// 각 connection 마다 thread
}- thread 당 stack ~8 MB → 10,000 thread = 80 GB RAM 😱
- context switch overhead 큼
- scheduler 부담
모델 2 — select() / poll() (1990 년대)
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sock1, &readfds);
FD_SET(sock2, &readfds);
// ... 10,000 sockets
select(maxfd + 1, &readfds, NULL, NULL, &timeout);
// → 어떤 fd 가 read 가능한지 알려줌
for (int i = 0; i < maxfd; i++) {
if (FD_ISSET(i, &readfds)) { /* handle i */ }
}- thread 1 개로 N socket 처리 (event loop)
- 그러나 매 호출마다 fd 전체 list 를 kernel 에 복사 — O(N) overhead
- FD_SETSIZE 한도 (보통 1024)
epoll — Linux 의 답 (2002)
int epfd = epoll_create1(0);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &ev); // 한 번만 등록
// ... 다른 socket 들도 등록
while (1) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
// → ready 인 fd 만 반환 (O(ready), 전체 N 안 봄)
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
// handle fd
}
}- O(1) ready 알림 — kernel 이 fd state 변화를 내부에서 관리, 변경된 것만 반환
- FD 한도 거의 무한
- edge-triggered vs level-triggered 옵션
BSD/macOS 는 kqueue, Solaris 는 /dev/poll, Windows 는 IOCP — 모두 같은 아이디어의 변형.
Blocking vs Non-blocking — read 의 두 얼굴
// Blocking (default)
int n = read(sock, buf, 4096);
// 데이터 올 때까지 thread 정지
// Non-blocking
fcntl(sock, F_SETFL, O_NONBLOCK);
int n = read(sock, buf, 4096);
if (n < 0 && errno == EAGAIN) {
// 아직 데이터 없음 — 즉시 return, 다른 일 가능
}epoll + non-blocking = 한 thread 가 수천 connection 동시 처리. epoll_wait 가 알려준 ready fd 만 즉시 처리하고 다음 wait.
Async I/O — POSIX aio, io_uring (Linux 5.1+)
// epoll 은 "ready 알림" — read/write 는 application 이 직접
// io_uring 은 "작업 자체를 kernel 에 위탁"
struct io_uring ring;
io_uring_queue_init(32, &ring, 0);
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, 4096, 0);
io_uring_submit(&ring);
// kernel 이 비동기로 read 완료
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe); // 완료된 작업 결과
// cqe->res = read 한 byte 수epoll 보다 syscall 횟수 더 줄임. database / disk-heavy 워크로드에서 eye-popping 성능. 다만 application 코드 복잡도 ↑.
Node.js — single thread, 10K 처리
// Node.js 의 본질:
[V8 main thread] ──→ event loop
↑
│ (libuv)
│
[epoll / kqueue / IOCP]
application code 는 single-threaded.
파일·socket I/O 는 kernel 에 위탁, 완료되면 callback 실행.
cpu-bound 작업 (큰 JSON.parse, 암호화) 은 worker_threads 로 위임.Single thread = lock 불필요. 그러나 한 callback 이 오래 걸리면 event loop 전체 정지. CPU-heavy 는 worker_threads / 외부 서비스로 밀어야.
관련 도구
- cURL 빌더 — HTTP 요청 구성
- IP / CIDR 계산기 — IP / CIDR 계산
- HTTP 상태 코드 — HTTP status 코드 설명
흔한 함정
- TIME_WAIT 누적 — connection 종료 후 ~2 min TIME_WAIT state. short-lived connection 많으면 port 부족. keep-alive / pool 사용.
- SO_REUSEADDR 미설정 — server restart 시 "Address already in use" — listen socket 에 박아야.
- Nagle vs delayed ACK 의 deadlock 비슷한 latency — small write 가 200ms 대기.
TCP_NODELAY설정. - Connection pool size — DB · Redis client 의 pool 크기가 backend thread 수에 못 미치면 thread wait.
- SYN flood — handshake 의 SYN 만 보내고 ACK 안 하기. SYN cookies 로 방어.
마무리
Network programming 의 진화는 "한 thread 가 더 많은 connection" 방향이었다. thread-per-conn → select → epoll → io_uring. 같은 패턴: kernel 이 더 많은 작업을 위탁받고, application 은 callback / await / coroutine 으로 wait 시간을 다른 일에 쓴다.
async/await (JS/Rust/C#/Python) 의 본질도 동일 — coroutine 이 epoll/kqueue 위에서 await 시 자기 자신을 yield, kernel 이 ready 알리면 resume.