C++ 네트워크 성능 최적화 | TCP 튜닝·제로카피·커널 바이패스 [#51-7]
들어가며: 네트워크 성능은 어디서 막히는가
정적 파일을 내보내는 서버가 NIC 대역폭을 다 쓰지 못하고 CPU만 높게 나오거나, 작은 메시지를 주고받는 서비스에서 이유 없이 수십 밀리초 지연이 끼는 경우가 있습니다. 원인은 대개 몇 가지로 좁혀집니다.
- 파일을
read()로 읽어send()로 보내는 루프는 같은 데이터를 커널과 유저 공간 사이에서 두 번 복사해, 대역폭이 높아질수록 CPU와 메모리 대역폭이 먼저 바닥납니다. - 소켓 버퍼가 대역폭×지연(BDP)보다 작으면, 먼 거리의 연결은 ACK를 기다리느라 회선을 다 채우지 못합니다.
- Nagle 알고리즘과 지연 ACK가 맞물리면 작은 쓰기가 수십~수백 밀리초 지연됩니다.
- 중간 장비가 조용히 끊은 유휴 연결을 애플리케이션이 모른 채 재사용합니다.
이 글은 Linux를 기준으로 TCP 소켓 옵션, sendfile·splice 제로카피, io_uring과 DPDK의 위치를 차례로 다룹니다. 어떤 기법이든 효과는 환경에 따라 크게 달라지므로, 마지막 절의 측정 방법으로 직접 확인하며 적용하는 것을 전제로 합니다.
read → send 루프의 비용
#include <sys/socket.h>
#include <unistd.h>
bool send_file_slow(int client_fd, int file_fd) {
char buffer[64 * 1024];
for (;;) {
ssize_t n = read(file_fd, buffer, sizeof(buffer)); // 페이지 캐시 → 유저 버퍼 (CPU 복사)
if (n < 0) return false;
if (n == 0) return true; // 파일 끝
ssize_t off = 0;
while (off < n) {
ssize_t sent = send(client_fd, buffer + off, n - off, 0); // 유저 버퍼 → 소켓 버퍼 (CPU 복사)
if (sent < 0) return false;
off += sent;
}
}
}
flowchart LR
subgraph slow["read + send 경로"]
D[디스크] -->|DMA| P[페이지 캐시]
P -->|CPU 복사| U[유저 버퍼]
U -->|CPU 복사| S[소켓 버퍼]
S -->|DMA| N[NIC]
end
디스크에서 페이지 캐시로, 소켓 버퍼에서 NIC로 가는 구간은 DMA라 CPU가 직접 복사하지 않습니다. CPU가 하는 복사는 read(페이지 캐시 → 유저 버퍼)와 send(유저 버퍼 → 소켓 버퍼) 두 번이고, 64KB마다 시스템 콜 두 번과 그에 따른 커널·유저 전환이 붙습니다. 처리량이 수 Gbps 수준으로 올라가면 이 복사가 메모리 대역폭과 CPU를 크게 차지합니다. send가 요청한 길이보다 적게 보낼 수 있으므로 남은 부분을 다시 보내는 루프도 필요합니다.
TCP 소켓 옵션
소켓 버퍼와 자동 튜닝
TCP는 상대가 ACK하지 않은 데이터를 송신 버퍼에 들고 있어야 하므로, 한 번에 “전송 중”일 수 있는 데이터 양은 버퍼 크기에 묶입니다. 회선을 꽉 채우려면 버퍼가 대역폭×왕복 지연(BDP) 이상이어야 합니다.
| RTT | 1Gbps BDP | 10Gbps BDP |
|---|---|---|
| 1ms | 125KB | 1.25MB |
| 10ms | 1.25MB | 12.5MB |
| 100ms | 12.5MB | 125MB |
여기서 놓치기 쉬운 점이 있습니다. Linux는 연결마다 송수신 버퍼 크기를 트래픽에 맞춰 자동으로 키웁니다(net.ipv4.tcp_rmem, tcp_wmem의 세 번째 값까지). 그런데 애플리케이션이 SO_RCVBUF나 SO_SNDBUF를 직접 설정하면 그 소켓은 자동 튜닝이 꺼지고 지정한 크기에 고정됩니다. 그래서 “버퍼를 4MB로 늘렸더니 오히려 느려졌다”는 일이 생깁니다. 자동 튜닝 상한이 그보다 컸다면 손해를 본 셈입니다.
대부분의 서버에는 소켓 옵션을 건드리지 않고 시스템 전체의 자동 튜닝 상한을 올리는 쪽이 낫습니다.
# /etc/sysctl.d/99-network-perf.conf
net.ipv4.tcp_rmem = 4096 131072 16777216 # 최소 기본 최대 (자동 튜닝 범위)
net.ipv4.tcp_wmem = 4096 16384 16777216
net.core.rmem_max = 16777216 # SO_RCVBUF로 요청할 수 있는 상한
net.core.wmem_max = 16777216
소켓 옵션을 직접 써야 하는 경우는 버퍼 크기를 의도적으로 제한하고 싶을 때(메모리 사용량 상한, 지연을 줄이려 큐를 짧게 유지)입니다. 이때는 두 가지 규칙을 지킵니다.
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <sys/socket.h>
#include <cstdio>
bool set_buffers(int fd, int bytes) {
// 연결 전에 설정해야 함: 윈도 스케일은 SYN 교환 때 한 번 정해짐
if (setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &bytes, sizeof(bytes)) < 0) {
perror("SO_RCVBUF");
return false;
}
if (setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &bytes, sizeof(bytes)) < 0) {
perror("SO_SNDBUF");
return false;
}
int actual = 0;
socklen_t len = sizeof(actual);
getsockopt(fd, SOL_SOCKET, SO_RCVBUF, &actual, &len);
std::printf("SO_RCVBUF requested %d, kernel reports %d\n", bytes, actual);
return true;
}
첫째, 수신 버퍼는 connect나 listen 전에 설정해야 합니다. TCP 윈도 스케일 옵션은 SYN을 주고받을 때 한 번 협상되므로, 연결된 뒤에 큰 버퍼를 설정해도 광고할 수 있는 윈도가 늘지 않을 수 있습니다. 서버에서는 리스닝 소켓에 설정하면 accept한 소켓이 이어받습니다. 둘째, 요청한 값은 net.core.rmem_max·wmem_max로 잘리고, 커널은 관리용 오버헤드를 고려해 요청값의 두 배를 저장하므로 getsockopt로 실제 값을 확인합니다.
Nagle 알고리즘과 TCP_NODELAY
Nagle 알고리즘은 아직 ACK되지 않은 데이터가 있으면, 새로 쓰는 작은 데이터를 바로 보내지 않고 ACK가 오거나 한 세그먼트(MSS)가 찰 때까지 모아 둡니다. 작은 패킷이 넘쳐 나는 것을 막는 장치인데, 수신 측의 지연 ACK와 만나면 문제가 생깁니다. 지연 ACK는 ACK를 바로 보내지 않고 짧게(Linux는 최소 40ms 안팎, 다른 OS는 최대 200ms) 기다렸다가 응답 데이터에 실어 보내려 합니다. 그 결과 “요청 헤더를 쓰고 이어서 본문을 쓰는” 식의 작은 쓰기 두 번에서, 두 번째 쓰기가 상대의 지연 ACK 타이머만큼 멈춰 있게 됩니다.
flowchart LR
subgraph nagle["Nagle 켜짐 (기본)"]
N1[작은 쓰기 1 전송] --> N2[작은 쓰기 2 보류]
N2 --> N3[상대 ACK 도착 또는 MSS 채워짐]
N3 --> N4[쓰기 2 전송]
end
subgraph no_nagle["TCP_NODELAY"]
M1[작은 쓰기] --> M2[즉시 전송]
end
int flag = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag));
요청·응답이 작고 지연이 중요한 프로토콜(RPC, 게임, 거래 시스템, HTTP 서버 대부분)은 TCP_NODELAY를 켭니다. 대신 애플리케이션이 작은 조각을 여러 번 send하면 그대로 작은 패킷 여러 개가 나가므로, 한 메시지는 버퍼에 모아 한 번에 쓰거나 writev로 여러 버퍼를 한 번의 호출로 보내는 것이 좋습니다. 반대로 “헤더 + 큰 파일”처럼 일부러 모아 보내고 싶을 때는 send에 MSG_MORE 플래그를 주거나 TCP_CORK를 잠시 켜 두면, TCP_NODELAY가 켜진 소켓에서도 헤더가 작은 패킷으로 따로 나가지 않습니다.
SO_KEEPALIVE와 죽은 연결 감지
연결 풀이나 장시간 유지되는 연결은 중간의 NAT·방화벽·로드밸런서가 유휴 연결을 조용히 정리해도 양 끝은 알지 못합니다. 그 연결로 요청을 보내면 그제야 ECONNRESET이나 EPIPE가 나거나, 응답 없이 재전송만 반복하다 오래 걸려 실패합니다. TCP Keep-Alive는 유휴 상태가 일정 시간 지나면 빈 프로브를 보내 상대가 살아 있는지 확인하고, 중간 장비에도 연결이 사용 중이라는 신호를 줍니다.
#include <netinet/tcp.h>
bool enable_keepalive(int fd, int idle_sec, int interval_sec, int count) {
int on = 1;
if (setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &on, sizeof(on)) < 0) return false;
#if defined(TCP_KEEPIDLE) && defined(TCP_KEEPINTVL) && defined(TCP_KEEPCNT)
setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &idle_sec, sizeof(idle_sec)); // 첫 프로브까지 유휴 시간
setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &interval_sec, sizeof(interval_sec)); // 프로브 간격
setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &count, sizeof(count)); // 실패 허용 횟수
#endif
return true;
}
// 예: 60초 유휴 후 10초 간격으로 3번 실패하면 끊음 → 최대 약 90초 안에 감지
// enable_keepalive(fd, 60, 10, 3);
| 옵션 | Linux 기본값 | 의미 |
|---|---|---|
TCP_KEEPIDLE | 7200초 | 마지막 데이터 이후 첫 프로브까지 |
TCP_KEEPINTVL | 75초 | 프로브 간격 |
TCP_KEEPCNT | 9 | 응답 없는 프로브 허용 횟수 |
기본값으로는 죽은 연결을 감지하는 데 2시간이 넘게 걸리므로, SO_KEEPALIVE만 켜고 타이밍을 바꾸지 않으면 사실상 효과가 없습니다. 유휴 시간은 경로상 가장 짧은 중간 장비의 유휴 타임아웃(클라우드 로드밸런서·NAT 게이트웨이는 수 분 단위인 경우가 많음)보다 짧게 잡아야 연결이 끊기는 것 자체를 막을 수 있습니다.
Keep-Alive는 연결이 유휴일 때만 동작합니다. 보낸 데이터가 ACK되지 않은 채 재전송 중이라면 프로브가 나가지 않고, 재전송 한도(tcp_retries2, 기본 설정에서 약 15분 이상)까지 기다립니다. 이 경우를 빨리 끊으려면 TCP_USER_TIMEOUT으로 ACK되지 않은 데이터를 기다리는 최대 시간을 정합니다. 애플리케이션 프로토콜에 자체 ping/pong이 있다면 그쪽이 더 정확하고 이식성도 좋습니다.
제로카피: sendfile과 splice
sendfile
#include <sys/sendfile.h>
#include <cerrno>
// 반환: 보낸 바이트 수, -1이면 에러. 논블로킹 소켓에서 EAGAIN이면 지금까지 보낸 양을 반환
ssize_t send_file_zero_copy(int sock_fd, int file_fd, off_t* offset, size_t count) {
ssize_t total = 0;
while (count > 0) {
ssize_t n = sendfile(sock_fd, file_fd, offset, count); // *offset은 커널이 갱신
if (n < 0) {
if (errno == EINTR) continue;
if (errno == EAGAIN || errno == EWOULDBLOCK) return total; // poll/epoll로 쓰기 가능 대기 후 재개
return -1;
}
if (n == 0) break; // 파일이 예상보다 짧음
total += n;
count -= static_cast<size_t>(n);
}
return total;
}
sendfile은 페이지 캐시의 데이터를 유저 공간을 거치지 않고 소켓으로 보냅니다. CPU 복사가 최대 한 번(페이지 캐시 → 소켓 버퍼)으로 줄고, NIC가 scatter-gather DMA와 체크섬 오프로드를 지원하면 커널은 페이지 위치만 넘겨 그 한 번마저 생략합니다. 시스템 콜도 큰 단위로 한 번씩만 부릅니다.
offset 포인터를 넘기면 커널이 보낸 만큼 *offset을 직접 갱신합니다. 루프 안에서 *offset += n을 또 하면 오프셋이 두 배로 증가해 파일 일부를 건너뛰는 버그가 됩니다. 논블로킹 소켓에서 EAGAIN이 나왔을 때 바로 다시 호출하면 CPU를 태우는 바쁜 대기가 되므로, 진행 상황을 저장해 두고 이벤트 루프에서 쓰기 가능 이벤트를 받은 뒤 이어서 보냅니다.
Linux의 sendfile은 입력 fd가 페이지 캐시를 거치는 일반 파일이어야 합니다(소켓이나 파이프는 입력이 될 수 없음). 출력 fd는 오래전에는 소켓만 가능했지만, 2.6.33부터는 일반 파일도 될 수 있습니다. TLS 연결이라면 데이터를 암호화해야 하므로 평문 sendfile을 그대로 쓸 수 없고, 커널 TLS(kTLS)를 설정한 소켓에서만 제로카피 경로를 살릴 수 있습니다.
헤더와 함께 보내기
HTTP 응답처럼 작은 헤더 뒤에 파일 본문을 보낼 때, TCP_NODELAY가 켜져 있으면 헤더가 작은 패킷으로 먼저 나갑니다. 헤더를 MSG_MORE로 보내면 커널이 뒤따를 데이터와 합쳐서 내보냅니다.
send(sock_fd, header.data(), header.size(), MSG_MORE); // 아직 보낼 데이터가 더 있음
off_t offset = 0;
send_file_zero_copy(sock_fd, file_fd, &offset, file_size);
splice
splice는 두 fd 사이에서 파이프를 중개로 데이터를 옮깁니다. 둘 중 하나는 반드시 파이프여야 하므로, 파일에서 소켓으로 보내려면 “파일 → 파이프 → 소켓” 두 단계를 거칩니다. sendfile이 지원하지 않는 소켓 → 소켓 중계(프록시) 같은 경우에 쓰입니다.
#include <fcntl.h>
#include <unistd.h>
#include <cerrno>
ssize_t splice_to_socket(int sock_fd, int in_fd, off_t* off_in, size_t len) {
int p[2];
if (pipe(p) < 0) return -1;
ssize_t total = 0;
bool ok = true;
while (len > 0 && ok) {
ssize_t in = splice(in_fd, off_in, p[1], nullptr, len, // *off_in은 커널이 갱신
SPLICE_F_MOVE | SPLICE_F_MORE);
if (in < 0 && errno == EINTR) continue;
if (in <= 0) { ok = (in == 0); break; }
// 파이프에 들어간 in 바이트를 모두 소켓으로 내보낼 때까지 반복
ssize_t left = in;
while (left > 0) {
ssize_t out = splice(p[0], nullptr, sock_fd, nullptr, left,
SPLICE_F_MOVE | SPLICE_F_MORE);
if (out < 0 && errno == EINTR) continue;
if (out <= 0) { ok = false; break; } // 블로킹 소켓 기준. 논블로킹이면 EAGAIN 처리 필요
left -= out;
total += out;
}
len -= static_cast<size_t>(in);
}
close(p[0]);
close(p[1]);
return ok ? total : -1;
}
파이프에 넣은 데이터를 소켓 쪽 splice가 일부만 가져갔다면 나머지는 파이프에 남아 있습니다. 그 상태로 루프를 빠져나오면 데이터가 사라지므로, 파이프를 비울 때까지 반복해야 합니다. sendfile과 마찬가지로 off_in을 넘기면 커널이 오프셋을 갱신하므로 직접 더하지 않습니다. 파이프나 소켓 쪽 오프셋 인자는 반드시 nullptr이어야 하며, 오프셋을 주면 ESPIPE(Illegal seek)가 납니다. 호출마다 파이프를 새로 만들면 시스템 콜이 늘어나므로, 실제 서버에서는 연결별로 파이프를 재사용합니다.
flowchart TB
subgraph sendfile_sg["sendfile"]
S1[디스크] -->|DMA| S2[페이지 캐시]
S2 -->|커널 내부, SG-DMA면 생략| S3[소켓 버퍼]
S3 -->|DMA| S4[NIC]
end
subgraph splice_sg["splice"]
P1[fd 입력] --> P2[파이프: 페이지 참조 이동]
P2 --> P3[fd 출력]
end
io_uring과 DPDK의 위치
flowchart TB
Start[병목 확인] --> Q1{"어디서 막히는가"}
Q1 -->|버퍼·Nagle·연결 관리| Tune[소켓 옵션·sysctl]
Q1 -->|파일 전송의 복사| Zero[sendfile·splice]
Q1 -->|시스템 콜 횟수·이벤트 처리| Uring[io_uring]
Q1 -->|커널 네트워크 스택 자체| Bypass[DPDK·AF_XDP 등 커널 바이패스]
io_uring은 커널과 공유하는 두 개의 링 버퍼(제출 큐, 완료 큐)로 I/O 요청을 일괄 제출하고 완료를 받는 Linux 비동기 인터페이스입니다. 네트워크 연산(accept, send, recv 등)은 5.x 초반부터 지원되었고, 송신 제로카피(IORING_OP_SEND_ZC)는 6.0에서 추가되었습니다. 요청을 여러 개 모아 한 번에 제출하고 폴링 모드를 쓰면 시스템 콜 횟수가 크게 줄어듭니다. 다만 io_uring은 커널 바이패스가 아닙니다. 패킷은 여전히 커널의 TCP/IP 스택을 지나가며, 줄어드는 것은 시스템 콜과 컨텍스트 전환 비용입니다. 기존 소켓 코드와 개념이 같아 점진적으로 도입하기 쉽고, 보안상의 이유로 일부 컨테이너 런타임이나 배포판이 기본으로 막아 두는 경우가 있다는 점은 확인해야 합니다.
DPDK는 NIC를 유저 공간 드라이버가 직접 제어해 커널 네트워크 스택을 완전히 우회합니다. 전용 CPU 코어가 NIC를 계속 폴링하며 패킷을 처리하므로 수 마이크로초 수준의 지연과 높은 패킷 처리율을 얻을 수 있지만, 대가가 큽니다. NIC를 커널에서 떼어 DPDK 드라이버에 바인딩해야 하고, 휴지 페이지(hugepage) 설정이 필요하며, 폴링 코어는 트래픽이 없어도 100% 사용률로 돕니다. TCP 같은 프로토콜 스택도 직접 쓰거나 별도 유저 공간 스택을 붙여야 합니다. 그래서 패킷 처리 장비, 고빈도 거래, 통신사 네트워크 기능처럼 커널 스택 자체가 병목인 특수한 경우에 쓰입니다. 그 중간으로, 커널 드라이버를 유지하면서 XDP로 패킷을 유저 공간 소켓에 직접 넘기는 AF_XDP도 있습니다.
sendfile 기반 파일 서버
// 컴파일: g++ -std=c++17 -O2 -o file_server file_server.cpp
#include <algorithm>
#include <arpa/inet.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <sys/sendfile.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
#include <filesystem>
#include <iostream>
#include <string>
namespace fs = std::filesystem;
static bool send_all(int fd, const char* data, size_t len, int flags = 0) {
while (len > 0) {
ssize_t n = send(fd, data, len, flags | MSG_NOSIGNAL);
if (n < 0) {
if (errno == EINTR) continue;
return false;
}
data += n;
len -= static_cast<size_t>(n);
}
return true;
}
static void reply_status(int fd, const char* status) {
std::string resp = std::string("HTTP/1.1 ") + status +
"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
send_all(fd, resp.data(), resp.size());
}
// 요청 경로를 문서 루트 안의 실제 파일로 바꿈. 루트 밖이면 빈 경로
static fs::path resolve(const fs::path& root, std::string path) {
if (path.empty() || path[0] != '/') return {};
if (path == "/") path = "/index.html";
std::error_code ec;
fs::path full = fs::weakly_canonical(root / path.substr(1), ec);
if (ec) return {};
// "../" 등으로 루트를 벗어나는 요청 차단
auto [r, f] = std::mismatch(root.begin(), root.end(), full.begin(), full.end());
if (r != root.end()) return {};
return full;
}
static void handle_client(int client_fd, const fs::path& root) {
char buf[4096];
ssize_t n = recv(client_fd, buf, sizeof(buf) - 1, 0);
if (n <= 0) { close(client_fd); return; }
buf[n] = '\0';
// 요청 줄만 단순 파싱 (예제용: 헤더 끝까지 읽기, URL 디코딩, 메서드 검사는 생략)
std::string req(buf);
size_t sp1 = req.find(' ');
size_t sp2 = (sp1 == std::string::npos) ? sp1 : req.find(' ', sp1 + 1);
if (sp2 == std::string::npos) { reply_status(client_fd, "400 Bad Request"); close(client_fd); return; }
fs::path full = resolve(root, req.substr(sp1 + 1, sp2 - sp1 - 1));
std::error_code ec;
if (full.empty() || !fs::is_regular_file(full, ec)) {
reply_status(client_fd, "404 Not Found");
close(client_fd);
return;
}
int file_fd = open(full.c_str(), O_RDONLY);
if (file_fd < 0) {
reply_status(client_fd, "500 Internal Server Error");
close(client_fd);
return;
}
off_t size = static_cast<off_t>(fs::file_size(full, ec));
std::string header = "HTTP/1.1 200 OK\r\nContent-Length: " + std::to_string(size) +
"\r\nConnection: close\r\n\r\n";
if (send_all(client_fd, header.data(), header.size(), MSG_MORE)) {
off_t offset = 0;
while (offset < size) {
ssize_t sent = sendfile(client_fd, file_fd, &offset, static_cast<size_t>(size - offset));
if (sent < 0 && errno == EINTR) continue;
if (sent <= 0) break; // 블로킹 소켓: 에러 또는 상대 종료
}
}
close(file_fd);
close(client_fd);
}
int main(int argc, char* argv[]) {
std::error_code ec;
fs::path root = fs::canonical(argc > 1 ? argv[1] : ".", ec);
if (ec) { std::cerr << "bad doc root\n"; return 1; }
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
int on = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
setsockopt(listen_fd, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on)); // accept한 소켓이 이어받음
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = INADDR_ANY;
if (bind(listen_fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0 ||
listen(listen_fd, SOMAXCONN) < 0) {
perror("bind/listen");
return 1;
}
std::cout << "File server on :8080, root=" << root << "\n";
for (;;) {
int client_fd = accept(listen_fd, nullptr, nullptr);
if (client_fd < 0) continue;
handle_client(client_fd, root); // 예제라 한 번에 한 연결만 처리
}
}
예제는 원리를 보이기 위해 연결을 하나씩 순서대로 처리합니다. 실제 서버라면 epoll 이벤트 루프와 논블로킹 소켓으로 여러 연결을 동시에 다루고, sendfile의 EAGAIN에서 멈춘 위치를 연결별로 저장해 두었다가 쓰기 가능 이벤트에서 이어 보냅니다.
요청 경로를 문서 루트에 그대로 이어 붙이면 GET /../../etc/passwd 같은 요청으로 루트 밖의 파일을 읽을 수 있습니다. 예제의 resolve처럼 경로를 정규화한 뒤 결과가 루트 안에 있는지 확인해야 합니다. 심볼릭 링크를 따라가는 정책, URL 인코딩된 %2e%2e 처리까지 고려하면 직접 구현보다는 검증된 웹 서버(Nginx 등)에 정적 파일 전송을 맡기는 편이 안전합니다. Nginx의 sendfile on;도 같은 시스템 콜을 씁니다.
자주 만나는 오류
sendfile이 EINVAL을 반환
입력 fd가 mmap 가능한 일반 파일이 아닌 경우(파이프, 소켓, 일부 특수 파일 시스템)이거나, O_APPEND로 연 출력 파일, 음수 오프셋 같은 잘못된 인자일 때 납니다. 파일 시스템이 지원하지 않아 실패하는 경우를 대비해, EINVAL이나 ENOSYS를 받으면 read/send 루프로 넘어가는 폴백 경로를 두는 것이 안전합니다.
SO_RCVBUF 설정이 적용되지 않음
앞에서 본 대로 요청값은 net.core.rmem_max로 잘리고, getsockopt는 두 배 값을 보여 줍니다. 연결 후에 설정했다면 윈도 스케일 때문에 효과가 제한될 수 있습니다. 그리고 수동 설정은 자동 튜닝을 끈다는 점을 다시 확인합니다. ss -tmi로 연결별 실제 버퍼(rb, tb)와 혼잡 윈도(cwnd), RTT를 볼 수 있어 버퍼가 병목인지 판단하는 데 유용합니다.
sysctl net.core.rmem_max net.core.wmem_max net.ipv4.tcp_rmem net.ipv4.tcp_wmem
ss -tmi state established '( dport = :443 )'
splice가 ESPIPE를 반환
파이프나 소켓 쪽 오프셋 인자에 포인터를 넘긴 경우입니다. 오프셋은 일반 파일 쪽에만 줄 수 있고, 파이프·소켓 쪽은 nullptr이어야 합니다.
논블로킹 소켓에서 EAGAIN
송신 버퍼가 가득 차 지금은 더 보낼 수 없다는 뜻입니다. 에러가 아니므로 연결을 끊지 말고, 보낸 위치를 저장한 뒤 epoll·poll에서 EPOLLOUT(쓰기 가능)을 받으면 이어서 보냅니다. 루프 안에서 바로 재시도하면 CPU를 100% 쓰는 바쁜 대기가 됩니다.
큰 버퍼로 인한 메모리 문제
read/send 방식에서 버퍼를 함수 지역 배열로 크게 잡으면(예: 100MB) 메모리 부족이 아니라 스택 오버플로로 바로 크래시합니다(Linux 기본 스택 8MB). 버퍼는 힙에 두고, 연결마다 큰 버퍼를 잡는 대신 고정 크기 버퍼를 재사용합니다. 동시 연결 수 × 연결당 버퍼(소켓 버퍼 포함)가 메모리 예산을 넘지 않도록 연결 수에 상한도 둡니다.
TCP_NODELAY를 켰더니 처리량이 떨어짐
애플리케이션이 작은 조각을 여러 번 send하고 있었다면, Nagle이 하던 합치기가 사라져 작은 패킷이 그대로 많이 나갑니다. 해결은 TCP_NODELAY를 끄는 것이 아니라 애플리케이션에서 한 메시지를 모아 한 번에 쓰는 것(버퍼링, writev, MSG_MORE)입니다.
측정과 적용 순서
최적화 전후를 비교하려면 처리량, CPU 사용률, 지연 분포를 함께 봐야 합니다. 처리량만 보면 CPU를 두 배 쓰면서 조금 빨라진 변화를 개선으로 착각하기 쉽습니다.
# 대용량 전송 처리량과 CPU
./file_server /var/www &
wrk -t4 -c100 -d30s http://localhost:8080/large.bin
mpstat -P ALL 1 # 코어별 CPU (sys, softirq 비중 확인)
# 순수 TCP 처리량 (애플리케이션 영향 제외)
iperf3 -s # 서버
iperf3 -c <server> -P 4 # 클라이언트, 병렬 스트림 4개
# 시스템 콜 횟수와 시간
strace -c -f -p <pid>
같은 머신 안에서 루프백으로 재면 실제 NIC, RTT, 패킷 손실이 빠져 결과가 실제 환경과 크게 다릅니다. 가능하면 실제 네트워크 경로에서, 대상 RTT를 함께 기록하며 측정합니다. 원격 환경을 흉내 내려면 tc qdisc add dev eth0 root netem delay 50ms로 지연을 넣어 볼 수 있습니다.
적용 순서는 비용이 낮은 것부터입니다. 먼저 ss로 버퍼와 혼잡 윈도가 병목인지 확인하고 자동 튜닝 상한을 조정합니다. 요청·응답 지연이 문제라면 TCP_NODELAY와 쓰기 묶기를 점검합니다. 파일 전송이 CPU를 많이 쓴다면 sendfile로 바꿉니다. 시스템 콜 비용이 프로파일에서 크게 보일 때 io_uring을, 커널 스택 자체가 병목이라는 측정 결과가 있을 때만 커널 바이패스를 검토합니다.
기타 sysctl
net.core.somaxconn = 4096 # listen 백로그 상한 (5.4 이후 기본 4096)
net.ipv4.tcp_tw_reuse = 1 # 나가는 연결에서 TIME_WAIT 소켓 재사용
tcp_tw_reuse는 클라이언트 쪽(나가는 연결)에서 TIME_WAIT 상태의 포트를 TCP 타임스탬프로 안전성을 확인한 뒤 재사용하게 하는 옵션입니다. NAT 환경에서 연결 실패를 일으켜 문제가 되었던 것은 tcp_tw_recycle이며, 이 옵션은 Linux 4.12에서 제거되었습니다. 인터넷에 남아 있는 오래된 튜닝 가이드를 그대로 적용하지 말고, 현재 커널의 기본값과 문서를 먼저 확인하는 것이 안전합니다.
플랫폼별 sendfile
#if defined(__linux__)
#include <sys/sendfile.h>
#elif defined(__APPLE__)
#include <sys/socket.h>
#include <sys/uio.h>
#endif
ssize_t send_file_portable(int sock_fd, int file_fd, off_t* offset, size_t count) {
#if defined(__linux__)
return sendfile(sock_fd, file_fd, offset, count); // offset은 커널이 갱신
#elif defined(__APPLE__)
off_t len = static_cast<off_t>(count);
// macOS: sendfile(파일, 소켓, 오프셋 값, 보낼/보낸 길이, 헤더·트레일러, 플래그)
int ret = sendfile(file_fd, sock_fd, *offset, &len, nullptr, 0);
if (ret < 0 && len == 0) return -1; // EAGAIN이어도 len에 보낸 양이 들어옴
*offset += len; // macOS는 직접 갱신해야 함
return len;
#else
return -1; // FreeBSD는 또 다른 시그니처, 그 밖의 플랫폼은 read/send 폴백
#endif
}
Linux와 macOS는 함수 이름만 같고 인자 순서와 의미가 다릅니다. macOS는 파일 fd가 먼저 오고, 오프셋을 값으로 받으며, 보낸 길이를 len으로 돌려주므로 오프셋 갱신을 호출자가 해야 합니다. FreeBSD의 sendfile은 다시 다른 시그니처를 가집니다. Windows에서는 TransmitFile이 같은 역할을 합니다.