포스트

소켓과 커널 네트워크 스택 — accept 한 줄 뒤에서 벌어지는 일

BSD 소켓 API 흐름, 블로킹과 논블로킹, select·poll·epoll의 차이와 C10K 문제, 커널 수신 경로(NIC→sk_buff→소켓 버퍼), zero-copy와 SO_REUSEPORT까지 시스템 레벨을 다룹니다.

소켓과 커널 네트워크 스택 — accept 한 줄 뒤에서 벌어지는 일

“소켓을 연다”는 말의 진짜 의미

애플리케이션은 socket(), bind(), accept() 몇 줄로 네트워크를 다룹니다. 하지만 그 한 줄 뒤에서 커널은 파일 디스크립터를 발급하고, 수신 큐를 만들고, NIC가 올린 인터럽트를 처리하고, sk_buff를 소켓 버퍼에 줄 세웁니다. 이 글은 “소켓을 안다”를 시스템 콜 이름을 외운다에서 NIC 인터럽트부터 recv() 반환까지의 데이터 경로를 그릴 수 있다로 끌어올립니다.

여기를 모르면 두 순간에 반드시 막힙니다. (1) 커넥션 1만 개를 어떻게 한 스레드로 받지? (2) CPU는 노는데 처리량이 안 나오는 이유는? 둘 다 답은 소켓 API가 아니라 그 아래 커널 스택에 있습니다.

BSD 소켓 API — 서버와 클라이언트의 상태 흐름

소켓은 1983년 BSD가 정립한 API를 거의 그대로 씁니다. 서버와 클라이언트의 호출 순서가 다릅니다.

sequenceDiagram
    participant S as 서버
    participant K as 커널
    participant C as 클라이언트
    S->>K: socket() → fd
    S->>K: bind(fd, 주소:포트)
    S->>K: listen(fd, backlog)
    Note over K: SYN 큐 / accept 큐 생성
    C->>K: socket() → connect()
    Note over K,C: 3-way handshake
    K-->>S: accept() → 연결 fd 반환
    C->>S: send() / recv()
    S->>C: recv() / send()
    S->>K: close()

핵심은 listen()이 만드는 두 개의 큐입니다.

큐들어가는 시점가득 차면
SYN 큐(half-open)SYN 수신, SYN-ACK 회신 후SYN flood. tcp_syncookies로 방어
accept 큐(established)핸드셰이크 완료, accept() 대기새 연결 drop / 재전송 유발 — backlog·somaxconn 부족

현장 함정: listen(fd, 128)을 줬어도 커널은 min(backlog, net.core.somaxconn)로 자릅니다. 트래픽 폭주 시 accept 큐가 차면 커널이 ACK를 무시해 클라이언트가 재전송하고, 사용자에겐 “간헐적 1초 지연”으로 보입니다. ss -ltn의 Recv-Q(현재 대기)·Send-Q(큐 한계)로 확인합니다.

accept()는 TCP 3-way handshake가 이미 끝난 연결을 큐에서 꺼낼 뿐, 핸드셰이크를 직접 하지 않습니다. 이 분리가 고성능 서버 설계의 출발점입니다.

블로킹의 벽: 한 스레드 = 한 연결의 한계

accept()도 recv()도 기본은 블로킹입니다 — 데이터가 올 때까지 스레드가 잠듭니다. 연결마다 스레드를 붙이면(thread-per-connection) 1만 연결에 1만 스레드, 스택 메모리(스레드당 ~1MB)와 컨텍스트 스위칭만으로 무너집니다. 이게 C10K 문제입니다.

해법은 한 스레드가 수천 fd를 감시하는 I/O 멀티플렉싱입니다. 그 진화가 select → poll → epoll입니다.

 selectpollepoll
fd 표현비트마스크(FD_SETSIZE 1024)배열(무제한)커널 레드블랙트리
매 호출 복사전체 fd set 복사전체 배열 복사등록만 1회
ready 탐색O(n) 전체 스캔O(n) 전체 스캔O(1) ready만 반환
1만 fd에서매우 느림느림빠름

select/poll은 매 호출마다 전체 fd 목록을 커널에 복사하고 전부 스캔합니다. fd가 1만 개면 이벤트가 하나여도 1만 개를 훑습니다. epoll은 epoll_ctl로 관심 fd를 커널에 한 번 등록해두고, epoll_wait은 준비된 fd만 돌려줍니다.

epoll 이벤트 루프 — ready만 골라 처리

아래는 epoll의 핵심입니다. 수많은 fd 중 대부분은 조용하고(회색), 데이터가 도착한 일부만 ready가 되어 epoll_wait이 그것만 반환합니다. 한 스레드가 전체를 스캔하지 않고 신호 온 것만 처리합니다.

감시 중인 fd (epoll_ctl로 등록) epoll_wait() ready인 fd만 반환 이벤트 루프 1 스레드

여기에 트리거 모드가 추가됩니다. 레벨 트리거(LT, 기본) 는 버퍼에 데이터가 남아있는 한 계속 알리고, 엣지 트리거(ET) 는 상태가 바뀌는 순간만 한 번 알립니다. ET는 깨우는 횟수를 줄여 더 빠르지만, 알림 한 번에 EAGAIN이 날 때까지 전부 읽어야 합니다(안 그러면 남은 데이터를 영영 못 깨움). nginx·Redis가 ET를 쓰는 이유이자, 직접 짤 때 가장 흔한 버그 지점입니다.

1
2
3
4
5
6
7
8
int ep = epoll_create1(0);
struct epoll_event ev = { .events = EPOLLIN | EPOLLET, .data.fd = conn };
epoll_ctl(ep, EPOLL_CTL_ADD, conn, &ev);
for (;;) {
    int n = epoll_wait(ep, events, MAX, -1);   /* ready 개수만큼만 */
    for (int i = 0; i < n; i++)
        handle(events[i].data.fd);             /* ET면 EAGAIN까지 read */
}

NIC에서 recv()까지 — 패킷 한 개의 커널 여정

데이터가 도착하면, recv()가 반환되기까지 커널은 인터럽트·소프트IRQ·sk_buff를 거칩니다.

커널 공간 유저 공간 NIC DMA→링버퍼 softirq NAPI 폴링 sk_buff 프로토콜 처리 소켓 버퍼 recv 큐 recv() 앱 버퍼로 복사
  1. NIC가 패킷을 받아 DMA로 메모리(RX 링 버퍼)에 직접 씁니다.
  2. 인터럽트가 너무 잦으면 CPU가 마비되므로(인터럽트 스톰), 리눅스는 NAPI로 첫 인터럽트 후 폴링으로 전환해 한 번에 여러 패킷을 거둡니다.
  3. softirq(NET_RX_SOFTIRQ)가 패킷을 sk_buff 구조체로 감싸 IP·TCP 계층을 통과시키고, 목적지 소켓의 수신 버퍼에 넣습니다.
  4. recv()는 그 버퍼에서 유저 공간으로 복사해 옵니다. 여기서 커널↔유저 복사가 일어납니다.

왜 복사가 비싼가: 정적 파일 전송처럼 “디스크 → 소켓”만 하는데도 데이터가 커널→유저→커널로 두 번 복사되고 컨텍스트 스위칭이 발생합니다. sendfile()(zero-copy)은 이 왕복을 없애 커널 안에서 바로 보냅니다. nginx·Kafka가 처리량을 뽑는 핵심 기법입니다.

멀티코어로 더 — SO_REUSEPORT

한 리슨 소켓을 여러 스레드가 accept()하면 thundering herd(한 연결에 전부 깨어나 경쟁)와 락 경합이 생깁니다. SO_REUSEPORT 는 같은 포트에 여러 리슨 소켓을 열게 해주고, 커널이 들어오는 연결을 소켓들에 해시 분산합니다. 코어마다 독립 accept 큐 → 락 프리에 가까운 수평 확장. nginx reuseport, Envoy가 이걸로 멀티코어를 채웁니다.

flowchart TD
    P["들어오는 연결"] --> K["커널: 4-튜플 해시 분산"]
    K --> S1["리슨소켓 #1 (코어1)"]
    K --> S2["리슨소켓 #2 (코어2)"]
    K --> S3["리슨소켓 #3 (코어3)"]

버퍼 크기가 곧 처리량 — 그리고 디버깅

소켓 송수신 버퍼(SO_SNDBUF/SO_RCVBUF)가 작으면, 고지연 경로에서 파이프를 못 채워 처리량이 한계에 걸립니다. 이게 BDP와 윈도우 이야기와 직결됩니다.

증상원인진단해법
Too many open filesfd 고갈ulimit -n, ls /proc/PID/fd \| wc -lLimitNOFILE 상향, fd 누수 점검
간헐적 연결 실패accept 큐 오버플로ss -ltn(Recv-Q/Send-Q), nstat \| grep ListenOverflowsomaxconn·backlog 상향
CPU 노는데 처리량 ↓작은 소켓 버퍼/윈도우ss -i(cwnd·rtt), iperf3버퍼 자동튜닝, tcp_rmem 상향
시스템콜 폭주LT에서 과도한 wakestrace -c -p PIDET 전환·배치 처리
1
2
3
strace -p 31337 -e trace=network -tt   # 어떤 소켓 콜이 블로킹되는지
ss -tmi 'dst :443'                      # 소켓별 메모리(m)·내부 TCP 정보(i)
cat /proc/net/sockstat                  # 전체 소켓/메모리 사용 현황

면접/리뷰 단골 질문

  • Q. select/poll 대신 epoll을 쓰는 이유? → 매 호출 fd 전체 복사·O(n) 스캔이 없다. 커널에 한 번 등록하고 ready만 O(1)로 받는다. C10K 이상에서 결정적.
  • Q. 레벨 트리거와 엣지 트리거 차이와 함정? → LT는 데이터 남으면 계속 알림(안전·다소 비효율), ET는 상태 변화 시 1회. ET는 EAGAIN까지 다 읽지 않으면 기아 발생.
  • Q. accept()는 핸드셰이크를 하나? → 아니다. 커널이 끝낸 established 연결을 accept 큐에서 꺼낼 뿐. SYN 큐·accept 큐는 별개.
  • Q. zero-copy가 왜 빠른가? → 커널↔유저 복사와 컨텍스트 스위칭을 제거. sendfile/splice로 커널 내에서 디스크→소켓 직결.
  • Q. SO_REUSEPORT가 푸는 문제? → 단일 accept 큐 락 경합과 thundering herd. 코어별 큐로 수평 확장.

정리

  • 소켓 API의 listen은 SYN 큐 + accept 큐 두 개를 만들고, accept는 완료된 연결을 꺼낼 뿐이다.
  • 블로킹 thread-per-connection은 C10K에서 무너진다 → epoll이 등록 1회·ready만 O(1) 반환으로 한 스레드에 수만 연결을 태운다.
  • 패킷 수신은 NIC DMA → NAPI/softirq → sk_buff → 소켓 버퍼 → recv 복사 경로를 탄다. 복사 제거가 sendfile(zero-copy).
  • 멀티코어는 SO_REUSEPORT로, 처리량 한계는 버퍼/윈도우로 — 둘 다 ss -i로 들여다본다.

이 위에서 도는 게 TCP 상태 머신과 혼잡 제어이며, 버퍼가 처리량을 막는 원리는 네트워크 성능·BDP에서, 이 소켓들을 투명하게 가로채는 사이드카는 현대 네트워킹에서 이어집니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

시리즈 · 네트워크 A-Z (20편)

  1. 네트워크란 무엇인가 — 회선 교환 vs 패킷 교환, 왜 인터넷은 패킷을 선택했나
  2. OSI 7계층과 TCP/IP — 왜 네트워크를 굳이 층으로 쪼갰나
  3. 이더넷·MAC·스위치 — 스위치는 어떻게 "어느 포트로 보낼지" 스스로 배우나
  4. IP 주소와 서브넷팅 — /24를 /26으로 쪼갠다는 게 비트 단위로 무슨 뜻인가
  5. 라우팅은 어떻게 길을 찾나 — 라우팅 테이블, Longest Prefix Match, 그리고 BGP
  6. ARP와 L2/L3 경계 — IP는 아는데 왜 MAC을 또 묻나
  7. TCP는 어떻게 신뢰를 만드나 — 3-way handshake, 상태 머신, 그리고 TIME_WAIT
  8. TCP는 얼마나 빨리 보낼까 — 흐름 제어 vs 혼잡 제어, 그리고 톱니파의 비밀
  9. UDP와 QUIC — TCP를 버려야 더 빨라지는 순간
  10. DNS는 어떻게 이름을 주소로 바꾸나 — 루트부터 권한 서버까지, 그리고 캐시의 마법
  11. HTTP의 진화 — 1.1의 줄 서기에서 HTTP/2 멀티플렉싱, HTTP/3까지
  12. TLS/HTTPS는 어떻게 도청 불가능한 채널을 만드나 — 핸드셰이크부터 인증서 체인까지
  13. NAT와 사설망 — 주소 하나로 수백 대가 인터넷을 쓰는 법, 그리고 P2P가 막히는 이유
  14. 로드 밸런싱 — L4와 L7은 무엇이 다르며, 왜 헬스체크가 서비스를 살리고 죽이나
  15. 프록시·리버스 프록시·CDN — 콘텐츠를 사용자 코앞으로 옮기는 기술
  16. 방화벽·보안그룹·네트워크 ACL — stateful과 stateless가 가르는 모든 것
  17. VPC & 클라우드 네트워킹 — 라우팅 테이블 한 줄이 퍼블릭과 프라이빗을 가른다
  18. 소켓과 커널 네트워크 스택 — accept 한 줄 뒤에서 벌어지는 일 지금 보는 글
  19. 네트워크 성능 — 대역폭은 왜 빠름이 아닌가 (RTT·BDP·처리량의 진실)
  20. 현대 네트워킹 — 사이드카, eBPF, 그리고 경계가 사라진 신뢰