C++ 네트워크 성능 최적화 | TCP 튜닝·제로카피·커널 바이패스 [#51-7]
이 글의 핵심
C++ 네트워크 최적화: TCP 파라미터 튜닝(SO_RCVBUF, TCP_NODELAY, SO_KEEPALIVE), sendfile/splice 제로카피, DPDK 커널 바이패스.
들어가며: CDN 서버에서 대량 파일 전송 시 CPU가 90%를 넘어요
문제 시나리오
상황: 10Gbps CDN 엣지 서버에서 정적 파일(이미지, 비디오) 전송
- 목표: 10Gbps 풀 활용, CPU 사용률 30% 이하
- 실제: 2~3Gbps에서 멈춤, CPU 90% 이상
- 원인: read() → user buffer → send() 과정에서 불필요한 복사 2회
실제 프로덕션에서 겪는 문제들:
- 대역폭 미활용: 10Gbps NIC인데 2~3Gbps만 나옴
- CPU 병목: read + send 루프에서 CPU 사용률 폭증
- 지연 증가: HFT 시스템에서 마이크로초 단위 지연이 수 밀리초로
- 메모리 대역폭 낭비: 커널 ↔ 유저 공간 복사가 병목
추가 문제 시나리오
시나리오 2: HFT(고빈도 거래) 시스템
주문 전송 지연이 50μs를 넘으면 슬리피지로 수백만 원 손실이 발생합니다. 기본 TCP 설정(Nagle ON, 작은 버퍼)을 사용하면 작은 패킷이 200ms까지 대기하고, RTT 1ms × BDP 부족으로 처리량이 100Mbps에 머무릅니다. TCP_NODELAY와 SO_SNDBUF/SO_RCVBUF 튜닝이 필수입니다.
시나리오 3: 실시간 게임 서버
100ms 이상 지연 시 플레이어가 “렉”을 체감합니다. Nagle 알고리즘이 작은 입력 패킷을 모아 전송하면서 프레임마다 10~40ms 추가 지연이 발생합니다. TCP_NODELAY로 즉시 전송해야 합니다.
시나리오 4: 로드밸런서 뒤 장시간 유휴 연결
연결 풀에서 5분간 idle 상태였던 연결로 요청을 보내면 EPIPE(Broken pipe)가 발생합니다. 중간 NAT/방화벽이 2~5분에 연결을 끊었는데 애플리케이션은 이를 모릅니다. SO_KEEPALIVE로 죽은 연결을 조기에 감지해야 합니다.
시나리오 5: 스트리밍 서버
라이브 비디오 전송 시 버퍼링으로 인해 시청자가 3~5초 지연을 겪습니다. TCP 버퍼가 작으면 네트워크 지터에 취약하고, sendfile 미사용 시 CPU가 80%를 넘어 추가 인코딩 지연이 발생합니다.
해결책:
- TCP 파라미터 튜닝: SO_RCVBUF, SO_SNDBUF, TCP_NODELAY
- 제로카피: sendfile, splice로 커널 내부 직접 전송
- 커널 바이패스: DPDK, io_uring으로 시스템 콜 최소화
목표:
- TCP 버퍼·Nagle 알고리즘 이해 및 튜닝
- sendfile/splice로 파일 전송 시 복사 제거
- DPDK·io_uring 개요와 선택 가이드
- 프로덕션 환경 설정·모니터링
요구 환경: C++17 이상, Linux (sendfile/splice), DPDK는 별도 설정
실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.
목차
- 문제 시나리오와 원인 분석
- 기본 개념: 데이터 경로와 병목
- TCP 파라미터 튜닝
- 제로카피: sendfile과 splice
- 고급 기법: 커널 바이패스 개요
- 완전한 예제: 파일 전송 서버
- 자주 발생하는 에러와 해결법
- 성능 벤치마크
- 프로덕션 패턴
- 구현 체크리스트
1. 문제 시나리오와 원인 분석
전형적인 병목: read → send 루프
// ❌ 문제: 파일 전송 시 4번의 데이터 복사 발생
// 1. 디스크 → 페이지 캐시(커널)
// 2. 페이지 캐시 → user buffer (read)
// 3. user buffer → 소켓 버퍼 (send)
// 4. 소켓 버퍼 → NIC
#include <sys/socket.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>
void send_file_slow(int client_fd, int file_fd, size_t file_size) {
char buffer[65536]; // 64KB user buffer
size_t total_sent = 0;
while (total_sent < file_size) {
ssize_t n = read(file_fd, buffer, sizeof(buffer)); // 복사 1
if (n <= 0) break;
ssize_t sent = send(client_fd, buffer, n, 0); // 복사 2
if (sent <= 0) break;
total_sent += sent;
}
}
문제의 원인:
flowchart LR
subgraph slow["느린 경로 read-send"]
D[디스크] --> P[페이지캐시]
P --> U[User Buffer]
U --> S[소켓 버퍼]
S --> N[NIC]
end
| 단계 | 복사 | 비용 |
|---|---|---|
| read | 페이지 캐시 → user buffer | CPU + 메모리 대역폭 |
| send | user buffer → 소켓 버퍼 | CPU + 메모리 대역폭 |
| 총 | 2회 유저 공간 관여 | 병목 |
2. 기본 개념: 데이터 경로와 병목
네트워크 최적화 계층도
flowchart TB
subgraph app["애플리케이션"]
A[read/send 루프]
end
subgraph opt1["1단계 TCP 튜닝"]
B[SO_RCVBUF/SO_SNDBUF]
C[TCP_NODELAY]
end
subgraph opt2["2단계 제로카피"]
D[sendfile]
E[splice]
end
subgraph opt3["3단계 커널 바이패스"]
F[DPDK]
G[io_uring]
end
A --> B
B --> C
C --> D
D --> E
E --> F
F --> G
style opt1 fill:#e3f2fd
style opt2 fill:#e8f5e9
style opt3 fill:#fff3e0
데이터 경로 비교
sequenceDiagram
participant App as 애플리케이션
participant Kernel as 커널
participant NIC as NIC
Note over App,NIC: read → send (기본)
App->>Kernel: read(fd, buf, size)
Kernel->>App: 데이터 복사
App->>Kernel: send(sock, buf, size)
Kernel->>Kernel: 소켓 버퍼로 복사
Kernel->>NIC: 전송
Note over App,NIC: sendfile (제로카피)
App->>Kernel: sendfile(sock, file_fd, ...)
Kernel->>Kernel: 페이지 캐시 → 소켓 버퍼 (커널 내)
Kernel->>NIC: 전송
3. TCP 파라미터 튜닝
SO_RCVBUF / SO_SNDBUF
수신·송신 버퍼 크기를 늘리면 대역폭×지연(BDP)에 맞춰 성능이 향상됩니다.
// TCP 버퍼 튜닝 예제
// 컴파일: g++ -std=c++17 -o tcp_tune tcp_tune.cpp
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <iostream>
bool tune_tcp_socket(int fd) {
// 수신 버퍼: 4MB (기본값은 보통 100KB~200KB)
int rcvbuf = 4 * 1024 * 1024;
if (setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf)) < 0) {
perror("SO_RCVBUF");
return false;
}
// 송신 버퍼: 4MB
int sndbuf = 4 * 1024 * 1024;
if (setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &sndbuf, sizeof(sndbuf)) < 0) {
perror("SO_SNDBUF");
return false;
}
// Nagle 비활성화: 작은 패킷 즉시 전송 (지연 감소)
int flag = 1;
if (setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag)) < 0) {
perror("TCP_NODELAY");
return false;
}
return true;
}
int main() {
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
perror("socket");
return 1;
}
if (!tune_tcp_socket(fd)) {
close(fd);
return 1;
}
struct sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
if (connect(fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("connect");
close(fd);
return 1;
}
std::cout << "TCP tuned, connected\n";
close(fd);
return 0;
}
주의: Linux에서는 SO_RCVBUF/SO_SNDBUF 설정값의 2배가 실제 버퍼로 적용됩니다. net.core.rmem_max, net.core.wmem_max로 상한을 확인하세요.
TCP_NODELAY와 Nagle 알고리즘
flowchart LR
subgraph nagle["Nagle ON 기본"]
N1[작은 패킷1] --> N2[대기]
N2 --> N3[ACK 도착 또는 200ms]
N3 --> N4[합쳐서 전송]
end
subgraph no_nagle["Nagle OFF TCP_NODELAY"]
M1[작은 패킷] --> M2[즉시 전송]
end
| 용도 | TCP_NODELAY | 이유 |
|---|---|---|
| 실시간 게임, HFT | ON (1) | 지연 최소화 |
| 대용량 파일 전송 | OFF (0) 또는 ON | 대용량이면 Nagle 영향 적음 |
| HTTP/2 스트리밍 | ON | 스트림별 지연 감소 |
SO_KEEPALIVE: 장시간 유휴 연결 감지
연결 풀, 프록시, 게임 서버처럼 장시간 유지되는 연결에서는 중간 NAT/방화벽이 연결을 끊어도 애플리케이션이 즉시 알 수 없습니다. SO_KEEPALIVE를 설정하면 주기적으로 TCP Keep-Alive 프로브를 보내 죽은 연결을 조기에 감지합니다.
// SO_KEEPALIVE + Linux 전용 타이밍 튜닝
// 컴파일: g++ -std=c++17 -o keepalive_tune keepalive_tune.cpp
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <netinet/tcp.h> // TCP_KEEPIDLE, TCP_KEEPINTVL, TCP_KEEPCNT (Linux)
#include <cstring>
#include <iostream>
bool tune_keepalive(int fd) {
// 1. SO_KEEPALIVE 활성화
int flag = 1;
if (setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &flag, sizeof(flag)) < 0) {
perror("SO_KEEPALIVE");
return false;
}
#if defined(TCP_KEEPIDLE) && defined(TCP_KEEPINTVL) && defined(TCP_KEEPCNT)
// 2. Linux: 첫 프로브까지 대기 시간 (초)
// 기본 7200초(2시간) → 60초로 단축
int idle = 60;
setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle));
// 3. 프로브 간격 (초)
int interval = 10;
setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &interval, sizeof(interval));
// 4. 재시도 횟수 (연속 실패 시 연결 종료)
int count = 3;
setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &count, sizeof(count));
#endif
// 총 감지 시간: 60 + 10*3 = 90초 (기본 2시간+ 대비 대폭 단축)
return true;
}
// TCP 전체 튜닝: 버퍼 + NODELAY + KEEPALIVE
bool tune_tcp_full(int fd, bool low_latency = true) {
int buf = 4 * 1024 * 1024;
if (setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &buf, sizeof(buf)) < 0) return false;
if (setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &buf, sizeof(buf)) < 0) return false;
int flag = 1;
if (setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag)) < 0) return false;
if (!tune_keepalive(fd)) return false;
return true;
}
SO_KEEPALIVE 타이밍 (Linux 기본값):
| 옵션 | 기본값 | 권장 (연결 풀) | 설명 |
|---|---|---|---|
| TCP_KEEPIDLE | 7200초 | 60초 | 첫 프로브까지 대기 |
| TCP_KEEPINTVL | 75초 | 10초 | 프로브 간격 |
| TCP_KEEPCNT | 9 | 3 | 재시도 횟수 |
용도별 권장:
- 연결 풀/프록시: idle 60초, interval 10초, count 3 → 약 90초 내 죽은 연결 감지
- 게임 서버: idle 30초 (빠른 감지)
- 대용량 배치: 기본값 유지 (불필요한 프로브 최소화)
4. 제로카피: sendfile과 splice
sendfile: 파일 → 소켓 직접 전송
// sendfile 사용: 커널 내부에서 페이지 캐시 → 소켓 버퍼
// 복사 0회 (유저 공간 관여 없음)
#include <sys/sendfile.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
#include <iostream>
ssize_t send_file_fast(int out_fd, int in_fd, off_t* offset, size_t count) {
ssize_t total_sent = 0;
while (count > 0) {
ssize_t sent = sendfile(out_fd, in_fd, offset, count);
if (sent < 0) {
if (errno == EAGAIN || errno == EINTR)
continue;
return -1;
}
if (sent == 0)
break;
total_sent += sent;
count -= sent;
if (offset)
*offset += sent;
}
return total_sent;
}
// 사용 예
int serve_file(int client_fd, const char* path) {
int fd = open(path, O_RDONLY);
if (fd < 0) return -1;
struct stat st;
if (fstat(fd, &st) < 0) {
close(fd);
return -1;
}
off_t offset = 0;
ssize_t n = send_file_fast(client_fd, fd, &offset, st.st_size);
close(fd);
return (n >= 0) ? 0 : -1;
}
sendfile 제한: Linux에서는 out_fd가 소켓, in_fd가 파일일 때만 지원. in_fd가 소켓이면 splice 사용.
제로카피 + TCP 튜닝 통합 예제
파일 전송 시 TCP 튜닝과 sendfile을 함께 적용하는 실전 패턴입니다.
// 제로카피 + TCP_NODELAY + SO_KEEPALIVE 통합
// 컴파일: g++ -std=c++17 -O2 -o optimized_send optimized_send.cpp
#include <sys/socket.h>
#include <sys/sendfile.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <fcntl.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
// 1. TCP 전체 튜닝 (버퍼, NODELAY, KEEPALIVE)
bool tune_socket_full(int fd) {
int buf = 4 * 1024 * 1024;
if (setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &buf, sizeof(buf)) < 0) return false;
if (setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &buf, sizeof(buf)) < 0) return false;
int flag = 1;
if (setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag)) < 0) return false;
if (setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &flag, sizeof(flag)) < 0) return false;
#if defined(TCP_KEEPIDLE) && defined(TCP_KEEPINTVL) && defined(TCP_KEEPCNT)
int idle = 60, interval = 10, count = 3;
setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle));
setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &interval, sizeof(interval));
setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &count, sizeof(count));
#endif
return true;
}
// 2. sendfile 루프 (EAGAIN/EINTR 처리)
ssize_t send_file_robust(int out_fd, int in_fd, off_t* offset, size_t count) {
ssize_t total = 0;
while (count > 0) {
ssize_t n = sendfile(out_fd, in_fd, offset, count);
if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) return total; // 나중에 재시도
if (errno == EINTR) continue;
return -1;
}
if (n == 0) break;
total += n;
count -= n;
if (offset) *offset += n;
}
return total;
}
// 3. 사용 예: accept 직후 tune_socket_full(client_fd) 호출 후 send_file_robust 사용
splice: 파이프를 이용한 제로카피
// splice: 두 fd 간 데이터를 파이프를 통해 커널 내부에서 이동
// 파일 → 소켓, 소켓 → 파일, 소켓 → 소켓 모두 가능
#include <fcntl.h>
#include <unistd.h>
#include <cerrno>
#define SPLICE_SIZE (256 * 1024) // 256KB
ssize_t splice_file_to_socket(int out_fd, int in_fd, off_t* off_in,
size_t len) {
int pipefd[2];
if (pipe(pipefd) < 0) return -1;
ssize_t total = 0;
while (len > 0) {
ssize_t n = splice(in_fd, off_in, pipefd[1], nullptr,
(len > SPLICE_SIZE) ? SPLICE_SIZE : len,
SPLICE_F_MOVE | SPLICE_F_MORE);
if (n <= 0) {
if (errno == EAGAIN || errno == EINTR) continue;
break;
}
ssize_t m = splice(pipefd[0], nullptr, out_fd, nullptr, n,
SPLICE_F_MOVE | SPLICE_F_MORE);
if (m <= 0) {
if (errno == EAGAIN || errno == EINTR) continue;
break;
}
total += m;
len -= m;
if (off_in) *off_in += m;
if (m < n) break; // 부분 전송
}
close(pipefd[0]);
close(pipefd[1]);
return total;
}
제로카피 경로 비교
flowchart TB
subgraph read_send["read send 경로"]
R1[디스크] --> R2[페이지캐시]
R2 --> R3[User Buffer]
R3 --> R4[소켓 버퍼]
R4 --> R5[NIC]
end
subgraph sendfile_sg["sendfile"]
S1[디스크] --> S2[페이지캐시]
S2 --> S3[소켓 버퍼]
S3 --> S4[NIC]
end
subgraph splice_sg["splice"]
P1[fd1] --> P2[파이프]
P2 --> P3[fd2]
end
5. 고급 기법: 커널 바이패스 개요
기술 선택 가이드
flowchart TB
Start[네트워크 성능 요구] --> Q1{"대역폭·지연 요구"}
Q1 -->|1~5Gbps ms급| Tune[TCP 튜닝]
Q1 -->|5~10Gbps sub-ms| Zero[제로카피 sendfile/splice]
Q1 -->|10Gbps+ μs급| Bypass[커널 바이패스]
Tune --> T1["SO_RCVBUF/SNDBUF\nTCP_NODELAY"]
Zero --> Z1["sendfile\nsplice"]
Bypass --> B1{선택}
B1 --> DPDK["DPDK 최대 성능\n전용 CPU 복잡"]
B1 --> iouring["io_uring 점진적\n기존 코드 활용"]
DPDK 개요
- 특징: NIC를 유저 공간에서 직접 제어, 커널 스택 우회
- 장점: 10Gbps+ 풀 활용, 마이크로초 이하 지연
- 단점: 전용 CPU 코어, Huge Page, 드라이버 바인딩 필요
io_uring 개요
- 특징: 비동기 I/O, 시스템 콜 배치, 네트워크 지원(5.19+)
- 장점: 점진적 도입 가능, sendfile/splice와 병행
- 단점: 상대적으로 DPDK보다 낮은 최대 처리량
6. 완전한 예제: 파일 전송 서버
sendfile 기반 HTTP 파일 서버
// 완전한 예제: sendfile 기반 파일 전송 서버
// 컴파일: g++ -std=c++17 -O2 -o file_server file_server.cpp
#include <sys/socket.h>
#include <sys/sendfile.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <unistd.h>
#include <cstring>
#include <string>
#include <iostream>
#include <filesystem>
namespace fs = std::filesystem;
bool tune_socket(int fd) {
int buf = 4 * 1024 * 1024;
setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &buf, sizeof(buf));
setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &buf, sizeof(buf));
int flag = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag));
setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &flag, sizeof(flag));
return true;
}
bool send_file(int client_fd, int file_fd, size_t size) {
off_t offset = 0;
ssize_t total = 0;
while (static_cast<size_t>(total) < size) {
ssize_t n = sendfile(client_fd, file_fd, &offset, size - total);
if (n < 0) {
if (errno == EAGAIN || errno == EINTR) continue;
return false;
}
if (n == 0) break;
total += n;
}
return static_cast<size_t>(total) == size;
}
void handle_client(int client_fd, const std::string& doc_root) {
tune_socket(client_fd);
char buf[4096];
ssize_t n = recv(client_fd, buf, sizeof(buf) - 1, 0);
if (n <= 0) { close(client_fd); return; }
buf[n] = '\0';
// 간단한 GET 파싱 (실제로는 더 견고한 파싱 필요)
std::string req(buf);
size_t path_start = req.find(' ') + 1;
size_t path_end = req.find(' ', path_start);
std::string path = req.substr(path_start, path_end - path_start);
if (path == "/") path = "/index.html";
fs::path full_path = fs::path(doc_root) / path.substr(1);
if (!fs::exists(full_path) || !fs::is_regular_file(full_path)) {
const char* resp = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n";
send(client_fd, resp, strlen(resp), 0);
close(client_fd);
return;
}
size_t file_size = fs::file_size(full_path);
int file_fd = open(full_path.c_str(), O_RDONLY);
if (file_fd < 0) {
const char* resp = "HTTP/1.1 500 Internal Error\r\nContent-Length: 0\r\n\r\n";
send(client_fd, resp, strlen(resp), 0);
close(client_fd);
return;
}
std::string header = "HTTP/1.1 200 OK\r\nContent-Length: " +
std::to_string(file_size) + "\r\n\r\n";
send(client_fd, header.c_str(), header.size(), 0);
send_file(client_fd, file_fd, file_size);
close(file_fd);
close(client_fd);
}
int main(int argc, char* argv[]) {
std::string doc_root = (argc > 1) ? argv[1] : ".";
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) {
perror("socket");
return 1;
}
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = INADDR_ANY;
if (bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind");
close(listen_fd);
return 1;
}
listen(listen_fd, 128);
std::cout << "File server on :8080, doc_root=" << doc_root << "\n";
for (;;) {
struct sockaddr_in client_addr{};
socklen_t len = sizeof(client_addr);
int client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &len);
if (client_fd < 0) continue;
handle_client(client_fd, doc_root);
}
}
7. 자주 발생하는 에러와 해결법
문제 1: sendfile에서 EINVAL
증상:
sendfile: Invalid argument
원인: out_fd가 소켓이 아니거나, in_fd가 일반 파일이 아님. 또는 일부 파일 시스템(예: NFS)에서 sendfile 미지원.
해결법:
// ❌ 잘못된 사용
int pipe_fd = ...;
sendfile(pipe_fd, file_fd, &offset, count); // pipe는 out_fd로 불가
// ✅ 올바른 사용: out_fd = 소켓, in_fd = 파일
sendfile(socket_fd, file_fd, &offset, count);
// NFS 등 sendfile 미지원 시: splice로 폴백
#if defined(__linux__)
n = sendfile(out_fd, in_fd, &offset, count);
if (n < 0 && errno == EINVAL) {
// splice 폴백
n = splice_file_to_socket(out_fd, in_fd, &offset, count);
}
#else
// read/send 폴백 (BSD, macOS)
n = read_send_loop(out_fd, in_fd, count);
#endif
문제 2: SO_RCVBUF 설정이 적용되지 않음
증상: setsockopt(SO_RCVBUF, 4*1024*1024) 후에도 실제 버퍼가 작음.
원인: net.core.rmem_max, net.core.wmem_max가 4MB보다 작을 수 있음.
해결법:
# 현재 상한 확인
sysctl net.core.rmem_max net.core.wmem_max
# 임시 상향 (재부팅 시 초기화)
sudo sysctl -w net.core.rmem_max=16777216
sudo sysctl -w net.core.wmem_max=16777216
# /etc/sysctl.conf에 영구 추가
# net.core.rmem_max = 16777216
# net.core.wmem_max = 16777216
// 설정 후 실제 값 확인
int actual = 0;
socklen_t len = sizeof(actual);
getsockopt(fd, SOL_SOCKET, SO_RCVBUF, &actual, &len);
// Linux: actual은 요청값의 2배일 수 있음
문제 3: splice 사용 시 ESPIPE
증상:
splice: Illegal seek
원인: offset을 넣었는데 in_fd가 파이프나 소켓처럼 seek 불가한 fd.
해결법:
// ❌ 파이프에 offset 지정
splice(pipe_fd[0], &offset, out_fd, nullptr, len, 0); // ESPIPE
// ✅ 파이프/소켓은 offset을 nullptr로
splice(pipe_fd[0], nullptr, out_fd, nullptr, len, 0);
// ✅ 파일 fd만 offset 사용
splice(file_fd, &offset, pipe_fd[1], nullptr, len, 0);
문제 4: EAGAIN / EWOULDBLOCK
증상: 논블로킹 소켓에서 sendfile/splice가 -1 반환, errno=EAGAIN.
해결법:
// 논블로킹 + poll/epoll로 재시도
int flags = fcntl(client_fd, F_GETFL, 0);
fcntl(client_fd, F_SETFL, flags | O_NONBLOCK);
// sendfile 루프에서 EAGAIN 시 나중에 재시도
ssize_t n = sendfile(client_fd, file_fd, &offset, count);
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
// epoll_wait 등으로 POLLOUT 대기 후 재시도
return; // 이벤트 루프에서 나중에 재개
}
문제 5: 대용량 전송 시 메모리 부족
증상: 수 GB 파일 전송 시 OOM 또는 스왑 폭증.
원인: read/send 방식에서 큰 user buffer 사용, 또는 동시 연결 수 과다.
해결법:
// ❌ 큰 버퍼로 한 번에 읽기
char buf[1024*1024*100]; // 100MB - 위험
read(file_fd, buf, sizeof(buf));
// ✅ sendfile 사용 (user buffer 없음)
sendfile(client_fd, file_fd, &offset, file_size);
// ✅ 동시 연결 수 제한
const int MAX_CONN = 1000;
// accept 시 현재 연결 수 확인 후 거부
문제 6: SO_KEEPALIVE 설정 후에도 죽은 연결 감지가 늦음
증상: 연결 풀에서 5분간 idle 후 요청 시 EPIPE 발생. SO_KEEPALIVE를 설정했는데도 여전히 늦게 감지됨.
원인: Linux 기본값(TCP_KEEPIDLE=7200초)이 2시간이라, 60초~90초 내 감지하려면 TCP_KEEPIDLE, TCP_KEEPINTVL, TCP_KEEPCNT를 명시적으로 설정해야 함.
해결법:
// ✅ Linux에서 Keep-Alive 타이밍 단축
#if defined(TCP_KEEPIDLE)
int idle = 60; // 60초 후 첫 프로브
setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle));
#endif
#if defined(TCP_KEEPINTVL)
int interval = 10; // 10초 간격
setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &interval, sizeof(interval));
#endif
#if defined(TCP_KEEPCNT)
int count = 3; // 3회 실패 시 종료
setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &count, sizeof(count));
#endif
문제 7: TCP_NODELAY 설정 시 작은 패킷 과다로 처리량 저하
증상: TCP_NODELAY를 켰더니 오히려 처리량이 떨어짐.
원인: 작은 패킷을 즉시 보내면 TCP 세그먼트 수가 급증하고, 네트워크/커널 오버헤드가 증가함. 대용량 스트리밍에는 적합하지 않을 수 있음.
해결법:
// 용도별 분기
bool low_latency = true; // 게임, HFT
bool bulk_transfer = false; // 대용량 파일
if (low_latency) {
int flag = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag));
}
// bulk_transfer일 때는 TCP_NODELAY 생략 또는 0으로 설정
문제 8: splice 사용 시 ENOMEM
증상:
splice: Cannot allocate memory
원인: 파이프 버퍼가 부족하거나, pipe()로 생성한 파이프가 너무 많음. 또는 ulimit 제한.
해결법:
# 파이프 버퍼 크기 확인 (Linux)
cat /proc/sys/fs/pipe-max-size
# ulimit 확인
ulimit -n
ulimit -a
// splice 청크 크기를 줄여서 재시도
#define SPLICE_SIZE (64 * 1024) // 256KB → 64KB로 축소
8. 성능 벤치마크
테스트 환경 (참고)
- CPU: Intel Xeon 8코어
- NIC: 10Gbps
- 파일: 1GB 정적 파일
- 동시 연결: 100
전송 방식별 성능 비교
| 방식 | 처리량 (Gbps) | CPU 사용률 | 지연 (P99) |
|---|---|---|---|
| read → send (64KB) | 1.2 | 85% | 12ms |
| read → send (256KB) | 2.1 | 72% | 8ms |
| sendfile | 6.8 | 25% | 2ms |
| splice | 6.5 | 28% | 2.5ms |
| DPDK (참고) | 9.8 | 95% (전용 코어) | 0.05ms |
TCP 버퍼 크기별 영향
| SO_SNDBUF | 처리량 (Gbps) | 비고 |
|---|---|---|
| 87KB (기본) | 1.5 | BDP 부족 |
| 512KB | 3.2 | 개선 |
| 2MB | 5.1 | 권장 |
| 4MB | 5.8 | 수렴 |
TCP_NODELAY vs Nagle ON 비교 (작은 패킷)
| 설정 | 64바이트 패킷 1000개 전송 지연 (P99) | 비고 |
|---|---|---|
| Nagle ON (기본) | 180ms | 200ms 대기 영향 |
| TCP_NODELAY | 2ms | 즉시 전송 |
SO_KEEPALIVE 감지 시간
| 설정 | 죽은 연결 감지 시간 | 비고 |
|---|---|---|
| 기본 (Linux) | 2시간+ | TCP_KEEPIDLE=7200 |
| idle=60, intvl=10, cnt=3 | 약 90초 | 연결 풀 권장 |
| idle=30, intvl=5, cnt=3 | 약 45초 | 게임 서버 |
벤치마크 실행 예시
# 1. 파일 서버 실행
./file_server /var/www/html &
# 2. wrk로 부하 테스트 (100 연결, 10초)
wrk -t4 -c100 -d10s http://localhost:8080/1gb.bin
# 3. 처리량 확인
# Requests/sec, Transfer/sec 확인
예상 출력: Transfer/sec에서 4Gbps 이상이면 sendfile 효과 확인.
RTT별 BDP 계산
대역폭×지연(BDP)에 맞춰 버퍼를 설정하세요.
| RTT | 1Gbps 필요 버퍼 | 10Gbps 필요 버퍼 |
|---|---|---|
| 1ms | 125KB | 1.25MB |
| 10ms | 1.25MB | 12.5MB |
| 100ms | 12.5MB | 125MB |
9. 프로덕션 패턴
패턴 1: 점진적 최적화 적용
flowchart LR
A[read/send] --> B[TCP 튜닝]
B --> C[sendfile]
C --> D[io_uring/DPDK]
- 먼저 SO_RCVBUF/SO_SNDBUF, TCP_NODELAY 적용
- 파일 전송 경로에 sendfile 도입
- 필요 시 io_uring 또는 DPDK 검토
패턴 2: 플랫폼별 폴백
// 플랫폼별 최적 경로 선택
ssize_t send_file_cross_platform(int out_fd, int in_fd, off_t* offset,
size_t count) {
#if defined(__linux__)
return sendfile(out_fd, in_fd, offset, count);
#elif defined(__APPLE__)
// macOS sendfile 시그니처 다름
off_t len = count;
int ret = sendfile(in_fd, out_fd, *offset, &len, nullptr, 0);
if (ret < 0) return -1;
*offset += len;
return len;
#else
// BSD, 기타: read/send 폴백
return read_send_loop(out_fd, in_fd, offset, count);
#endif
}
패턴 3: 모니터링 지표
| 지표 | 목표 | 도구 |
|---|---|---|
| 처리량 | NIC 대역폭의 70%+ | ifconfig, sar |
| CPU 사용률 | 50% 이하 (파일 서버) | top, Prometheus |
| 연결당 지연 P99 | 10ms 이하 | 커스텀 로깅 |
| 에러율 | 0.01% 이하 | 로그 집계 |
패턴 4: sysctl 권장값
# /etc/sysctl.d/99-network-perf.conf
# TCP 버퍼 상한
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
# 연결 백로그
net.core.somaxconn = 65535
# TIME_WAIT 재사용 (주의: NAT 환경)
net.ipv4.tcp_tw_reuse = 1
패턴 5: 용도별 소켓 설정 프로파일
// 용도별 최적 설정: CDN(대용량), HFT/Game(저지연), Proxy(연결 풀)
enum class SocketProfile { CDN, HFT, Game, Proxy };
void apply_profile(int fd, SocketProfile profile) {
int buf = 4 * 1024 * 1024;
setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &buf, sizeof(buf));
setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &buf, sizeof(buf));
int v = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &v, sizeof(v));
setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &v, sizeof(v));
#if defined(TCP_KEEPIDLE) && defined(TCP_KEEPINTVL) && defined(TCP_KEEPCNT)
int idle = (profile == SocketProfile::Proxy) ? 60 : 30;
int interval = 10, count = 3;
setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle));
setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &interval, sizeof(interval));
setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &count, sizeof(count));
#endif
}
패턴 6: Graceful Shutdown
서버 종료 시 shutdown(listen_fd, SHUT_RDWR)로 accept 중단 후, 기존 클라이언트 fd는 전송 완료 대기(select/poll) 또는 타임아웃 후 close.
패턴 7: 모니터링 메트릭 수집
bytes_sent, connections_total, sendfile_calls, errors_eagain 등을 Prometheus/StatsD로 수집해 처리량·에러율을 추적합니다.
패턴 8: A/B 테스트로 최적화 검증
베이스라인(read/send) → TCP 튜닝 → sendfile → SO_KEEPALIVE 순으로 적용하며, 각 단계별 wrk/ab로 처리량·CPU·P99 지연을 비교합니다.
10. 구현 체크리스트
TCP 튜닝
- SO_RCVBUF, SO_SNDBUF 설정 (2MB~4MB)
- TCP_NODELAY 설정 (실시간/저지연 요구 시)
- SO_KEEPALIVE 설정 (연결 풀/장시간 유지 시)
- TCP_KEEPIDLE/INTVL/CNT 튜닝 (Linux, 죽은 연결 조기 감지)
- net.core.rmem_max, wmem_max 상한 확인
제로카피
- 파일 전송 경로에 sendfile 적용
- NFS 등 sendfile 미지원 시 splice 또는 read/send 폴백
- 플랫폼별 분기 (Linux/macOS/BSD)
에러 처리
- EAGAIN/EINTR 재시도
- EINVAL 시 폴백 경로
- 논블로킹 + epoll/poll 조합
프로덕션
- 동시 연결 수 제한
- sysctl 영구 설정
- 처리량·CPU·지연 모니터링
정리
| 항목 | 설명 |
|---|---|
| TCP 튜닝 | SO_RCVBUF/SO_SNDBUF, TCP_NODELAY로 기본 성능 향상 |
| 제로카피 | sendfile, splice로 유저 공간 복사 제거 |
| 커널 바이패스 | DPDK, io_uring은 극한 성능 시 검토 |
| 실무 | 점진적 적용, 플랫폼 폴백, 모니터링 필수 |
핵심 원칙:
- 먼저 TCP 파라미터 튜닝
- 파일 전송은 sendfile/splice로 전환
- EAGAIN/EINVAL 등 에러 처리와 폴백 경로 확보
- 프로덕션에서는 sysctl, 모니터링, 연결 수 제한
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 고빈도 거래(HFT), CDN, 게임 서버, 실시간 스트리밍 등 네트워크 성능이 중요한 시스템 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
한 줄 요약: TCP 튜닝·제로카피·커널 바이패스를 마스터할 수 있습니다.
관련 글
- C++ I/O 성능 최적화 | io_uring·mmap·DMA·제로카피 [#51-6]
- C++ Boost.Asio 고급 패턴 | 커스텀 서비스·타이머·시그널 [#52-1]
- C++ HTTP 클라이언트 완벽 가이드 | REST API 호출·연결 풀·타임아웃·프로덕션 패턴
- C++ 소켓 프로그래밍 완벽 가이드 | TCP/UDP·소켓 옵션·논블로킹·에러 처리 [#28-1]