본문으로 건너뛰기
yutils

네트워크 프로그래밍은 어떻게 동작할까?

socket · TCP vs UDP, C10K 문제와 epoll/kqueue/IOCP 가 만들어진 이유, blocking vs non-blocking vs async, file descriptor 추상화, Node.js 가 single-thread 인데 10,000 connection 처리 가능한 이유.

약 10분 읽기

모든 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

속성TCPUDP
연결3-way handshakeconnectionless (datagram)
순서보장X
전달retransmit, 보장best-effort, drop 가능
congestion controlOX (application 책임)
header overhead20 byte8 byte
use caseHTTP, DB, file transferDNS, 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 / 외부 서비스로 밀어야.

관련 도구

흔한 함정

  • 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.

가이드 목록으로