C++ 네트워크 성능 최적화 | TCP 튜닝·제로카피·커널 바이패스 [#51-7]

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%를 넘어 추가 인코딩 지연이 발생합니다.

해결책:

  1. TCP 파라미터 튜닝: SO_RCVBUF, SO_SNDBUF, TCP_NODELAY
  2. 제로카피: sendfile, splice로 커널 내부 직접 전송
  3. 커널 바이패스: DPDK, io_uring으로 시스템 콜 최소화

목표:

  • TCP 버퍼·Nagle 알고리즘 이해 및 튜닝
  • sendfile/splice로 파일 전송 시 복사 제거
  • DPDK·io_uring 개요와 선택 가이드
  • 프로덕션 환경 설정·모니터링

요구 환경: C++17 이상, Linux (sendfile/splice), DPDK는 별도 설정


실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.

목차

  1. 문제 시나리오와 원인 분석
  2. 기본 개념: 데이터 경로와 병목
  3. TCP 파라미터 튜닝
  4. 제로카피: sendfile과 splice
  5. 고급 기법: 커널 바이패스 개요
  6. 완전한 예제: 파일 전송 서버
  7. 자주 발생하는 에러와 해결법
  8. 성능 벤치마크
  9. 프로덕션 패턴
  10. 구현 체크리스트

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 bufferCPU + 메모리 대역폭
senduser 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이유
실시간 게임, HFTON (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_KEEPIDLE7200초60초첫 프로브까지 대기
TCP_KEEPINTVL75초10초프로브 간격
TCP_KEEPCNT93재시도 횟수

용도별 권장:

  • 연결 풀/프록시: 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.285%12ms
read → send (256KB)2.172%8ms
sendfile6.825%2ms
splice6.528%2.5ms
DPDK (참고)9.895% (전용 코어)0.05ms

TCP 버퍼 크기별 영향

SO_SNDBUF처리량 (Gbps)비고
87KB (기본)1.5BDP 부족
512KB3.2개선
2MB5.1권장
4MB5.8수렴

TCP_NODELAY vs Nagle ON 비교 (작은 패킷)

설정64바이트 패킷 1000개 전송 지연 (P99)비고
Nagle ON (기본)180ms200ms 대기 영향
TCP_NODELAY2ms즉시 전송

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)에 맞춰 버퍼를 설정하세요.

RTT1Gbps 필요 버퍼10Gbps 필요 버퍼
1ms125KB1.25MB
10ms1.25MB12.5MB
100ms12.5MB125MB

9. 프로덕션 패턴

패턴 1: 점진적 최적화 적용

flowchart LR
    A[read/send] --> B[TCP 튜닝]
    B --> C[sendfile]
    C --> D[io_uring/DPDK]
  1. 먼저 SO_RCVBUF/SO_SNDBUF, TCP_NODELAY 적용
  2. 파일 전송 경로에 sendfile 도입
  3. 필요 시 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
연결당 지연 P9910ms 이하커스텀 로깅
에러율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은 극한 성능 시 검토
실무점진적 적용, 플랫폼 폴백, 모니터링 필수

핵심 원칙:

  1. 먼저 TCP 파라미터 튜닝
  2. 파일 전송은 sendfile/splice로 전환
  3. EAGAIN/EINVAL 등 에러 처리와 폴백 경로 확보
  4. 프로덕션에서는 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]
... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3