네트워크 학습 노트 목차

소켓 — 코드가 네트워크를 만지는 창구

지금까지 본 TCP·UDP·IP는 운영체제가 처리한다. 그럼 우리 프로그램은 그걸 어떻게 쓸까? 그 창구가 **소켓(socket)**이다. 소켓은 네트워크 연결의 끝점으로, 운영체제가 파일 디스크립터로 다룬다 — 그래서 네트워크도 읽고 쓰는 파일처럼 다뤄진다. 이 작은 API 하나를 어떻게 쓰느냐가 — 서버가 동시 접속을 몇 개나 감당하느냐를 가르는, 네트워크 프로그래밍의 핵심 갈림길이다.

연결을 맺는 절차 — bind·listen·accept

TCP 서버와 클라이언트가 소켓으로 연결을 맺는 순서는 정해져 있다.

다이어그램 로딩 중…

서버는 소켓을 만들어(socket) 포트에 묶고(bind) 연결을 기다리다(listen), 들어온 연결을 받아들인다(accept) — 이 accept가 반환하는 새 소켓으로 그 클라이언트와 read/write한다(앞의 TCP 3-way 핸드셰이크가 바로 이 사이에서 일어난다). 클라이언트는 소켓을 만들어(socket) 서버에 연결한다(connect). 단순해 보이는 이 절차에서 진짜 문제는 — read를 호출했는데 데이터가 아직 안 왔을 때 무엇을 하느냐다.

막힘이 문제다 — blocking과 C10K

기본 소켓blocking이다 — read를 부르면 데이터가 올 때까지 그 스레드가 멈춰 기다린다. 그래서 가장 단순한 서버는 연결마다 스레드 하나를 붙인다(thread-per-connection). 연결이 수십 개면 괜찮다. 그런데 *동시 접속이 1만 개(C10K)*가 되면? 스레드 1만 개는 스택 메모리(수 GB)와 컨텍스트 스위칭으로 무너진다 — 게다가 대부분은 데이터를 기다리며 놀고 있다. 연결 수에 비례해 스레드를 늘리는 모델은 여기서 한계에 부딪힌다.

한 스레드가 여럿을 — non-blocking과 I/O 멀티플렉싱

해법은 소켓을 non-blocking으로 바꾸고, 한 스레드가 수많은 소켓을 감시하는 것이다. 데이터가 준비된 소켓만 골라 처리하면, 스레드는 놀지 않고 일한다. 이 "여러 소켓을 한꺼번에 감시"가 I/O 멀티플렉싱인데, 그 도구가 진화해 왔다.

다이어그램 로딩 중…

select·poll매 호출마다 감시 대상 전체를 스캔해서 — 소켓이 많아질수록 그 자체가 느려졌다(O(n)). 리눅스의 epoll(BSD의 kqueue)은 이를 뒤집었다 — 커널이 "준비된 소켓 목록만" 돌려줘서, 감시 대상이 1만 개여도 실제 일이 생긴 것만 O(1)에 가깝게 처리한다. (출처: select vs poll vs epoll.) 이벤트 알림 방식엔 레벨 트리거(준비돼 있으면 계속 알림)와 엣지 트리거(상태가 바뀐 순간만 알림, non-blocking 필수)가 있는데, 엣지 트리거가 더 효율적이지만 읽을 수 있는 만큼 다 읽어야 한다.

왜 이게 핵심인가바로 이 epoll 위에 고성능 네트워크 서버들이 선다. *"한 스레드가 epoll로 수많은 연결을 논블로킹하게 다룬다"*는 이벤트 루프 모델이 — 수만 동시 연결소수의 스레드로 감당하는 비결이다. 핵심 한 문장은 "기다리는 동안 다른 일을 한다" — 한 연결의 응답을 기다리느라 스레드를 묶어 두지 않고, 그 시간에 다른 연결을 처리하는 것이다.

또 다른 길 — 경량 스레드

epoll·이벤트 루프는 강력하지만 코드가 콜백·비동기로 복잡해진다. 그래서 반대 길을 택하는 런타임도 있다 — blocking 코드를 그대로 쓰되, 스레드를 너무 싸게 만들어 수만 개를 띄우는 경량 스레드 방식이다. 겉보기엔 *연결당 스레드(읽기 쉬움)*인데, 내부적으로는 블로킹 지점에서 OS 스레드를 양보해 — 적은 OS 스레드로 많은 동시성을 낸다. 즉 *같은 목표(많은 동시 연결)*를 *이벤트 루프(논블로킹)*와 *경량 스레드(싼 블로킹)*가 다른 방식으로 푼다.

실무에서는파일 디스크립터 한계가 의외의 병목이다. 소켓도 fd라, 동시 연결이 많은 서버는 ulimit -n(프로세스당 fd 상한)을 충분히 올려야 "Too many open files"로 안 죽는다(TIME_WAIT 소켓 누적과 함께 챙길 자원 한계다, TCP 편). 그리고 대부분의 앱 개발자는 epoll을 직접 안 짜고프레임워크가 그 위에 올린 추상을 쓴다. 다만 왜 빠른지, 어디서 막히는지를 알려면 이 바닥(소켓·epoll)을 이해해야 한다.

실제로 눈으로 보기

서버 소켓 절차를 코드로

int fd = socket(AF_INET, SOCK_STREAM, 0);

int on = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof on);   // TIME_WAIT 때문에 필요

struct sockaddr_in addr = {
    .sin_family = AF_INET,
    .sin_port   = htons(8080),        // 네트워크 바이트 순서(빅엔디안)로
    .sin_addr   = { .s_addr = INADDR_ANY }
};
bind(fd, (struct sockaddr*)&addr, sizeof addr);
listen(fd, 128);                       // 128 = 완료 대기 큐 크기

for (;;) {
    int c = accept(fd, NULL, NULL);    // 연결 하나를 꺼낸다
    handle(c);
    close(c);
}

htons를 빠뜨리는 것이 고전적인 실수다. 네트워크는 빅엔디안으로 약속돼 있어 포트 번호도 그 순서로 넣어야 한다. 안 하면 8080이 엉뚱한 포트가 된다.

listen 큐가 넘치면

ss -ltn
# State   Recv-Q  Send-Q  Local Address:Port
# LISTEN  0       128         0.0.0.0:8080
#         │       └ 큐 최대치(backlog)
#         └ 현재 대기 중인 완료된 연결 수

Recv-QSend-Q에 붙어 있으면 accept가 따라가지 못하고 있다는 뜻이다. 큐가 가득 차면 새 SYN 이 버려지고, 클라이언트는 연결 시도가 조용히 지연되거나 실패한다.

netstat -s | grep -iE 'listen|overflow'
# 1284 times the listen queue of a socket overflowed

이 숫자가 오르고 있으면 backlog 를 키우거나 accept 이후 처리를 빠르게 해야 한다. backlog 만 키우면 대기열만 길어져 지연으로 나타난다.

지금 누가 무엇을 열고 있나

ss -tnp state established '( dport = :443 )'
# Recv-Q Send-Q Local Address:Port  Peer Address:Port  Process
# 0      0      10.0.2.15:52344     93.184.216.34:443  users:(("curl",pid=8123,fd=5))

ss -s
# Total: 1842
# TCP:   1203 (estab 812, closed 240, orphaned 0, timewait 238)

Send-Q가 큰 연결은 보냈는데 상대가 아직 확인 안 한 데이터가 쌓인 것이다 — 상대가 느리거나 경로가 막혔다. Recv-Q가 크면 도착했는데 애플리케이션이 안 읽어 간 것이다.

이 둘의 구분이 진단에서 중요하다. 전자는 네트워크·상대 문제, 후자는 우리 애플리케이션이 못 따라가고 있는 것이다.

blocking 하나가 전체를 막는다

// 이 서버는 한 번에 한 연결만 처리한다
int c = accept(fd, NULL, NULL);
read(c, buf, n);          // 클라이언트가 느리면 여기서 멈춘다
                          // 그동안 다른 연결은 큐에서 대기

연결 하나가 느리면 나머지 전부가 기다린다. 해법이 셋으로 갈렸다 — 연결마다 실행 흐름을 하나씩 주거나, 다중화로 한 흐름이 여럿을 보거나, 경량 실행 단위를 쓰거나.

ss -tn state established | wc -l    # 지금 몇 개를 동시에 물고 있나

이 숫자가 처리 흐름 수보다 훨씬 크면 다중화를 쓰고 있는 것이고, 비슷하면 연결당 하나씩 배정하는 구조다.

정리하면, 소켓은 프로그램이 TCP/UDP를 다루는 파일 디스크립터다 — 서버는 socket→bind→listen→accept, 클라이언트는 socket→connect로 연결을 맺는다. 기본 blocking연결당 스레드C10K에서 무너지고, *non-blocking + I/O 멀티플렉싱(select→poll→epoll)*은 한 스레드가 수많은 연결을 감시해 이를 푼다(이벤트 루프). 경량 스레드싼 블로킹이라는 다른 길로 같은 곳에 닿는다. 결국 *"기다리며 스레드를 놀리지 마라"*가 — 서버 동시성의 한 줄 원리다.

HTTPS·TLS — 대칭/비대칭·인증서·1.3·0-RTT로드밸런싱·프록시·CDN — 한 대로 안 될 때