C++ 네트워크 에러 처리: errno 해석, 타임아웃, 지수 백오프 재시도, 서킷 브레이커, EINTR

들어가며: “프로덕션에서 간헐적 연결 끊김이 발생해요”

실제 겪는 문제 시나리오

// ❌ 문제: 서버가 갑자기 멈추거나 응답 없음
int fd = socket(AF_INET, SOCK_STREAM, 0);
connect(fd, ...);
recv(fd, buffer, size, 0);  // 💥 여기서 영원히 블로킹!
// 클라이언트가 응답을 안 보내면 스레드가 죽은 것처럼 보임

실제 프로덕션에서 겪는 문제들:

  • 간헐적 연결 끊김: ECONNRESET이 갑자기 발생해 로그가 쌓임
  • 멈춘 연결: 상대가 응답을 안 보내면 recv가 영원히 블로킹
  • 스레드 고갈: 타임아웃 없이 recv 대기 → 스레드 풀 고갈
  • 재시도 폭주: 실패 시 즉시 재시도 → 서버 부하 악화

추가 문제 시나리오

마이크로서비스 간 통신

A 서비스가 B 서비스의 REST API를 호출합니다. B가 재시작 중이거나 네트워크 지연이 있으면 connect()가 ETIMEDOUT 또는 ECONNREFUSED를 반환합니다. 재시도 없이 바로 실패 처리하면 사용자에게 불필요한 에러를 노출하고, 즉시 재시도하면 B 서버가 복구되기 전에 요청이 폭주해 상황이 더 악화됩니다.

로드밸런서 뒤의 idle timeout

클라이언트가 연결 풀에서 연결을 꺼내 사용합니다. 로드밸런서가 60초 idle timeout을 적용하는데, 61초 동안 요청이 없다가 다음 요청에서 send()를 호출하면 EPIPE(Broken pipe) 또는 ECONNRESET이 발생합니다. 연결은 이미 끊겼는데 클라이언트는 그 사실을 모르는 상태입니다.

대량 동시 연결

채팅 서버에서 10,000개의 동시 연결을 처리합니다. 일부 클라이언트가 비정상 종료하면 recv()가 0을 반환하거나 ECONNRESET이 발생합니다. fd를 닫지 않고 재사용하면 “Bad file descriptor” 에러가 연쇄적으로 발생합니다.

시그널과 EINTR

recv() 대기 중에 SIGTERM(graceful shutdown)이 들어오면 EINTR이 반환됩니다. 이를 무시하고 -1을 에러로 처리하면 정상적인 종료 시퀀스가 깨집니다. 반대로 EINTR을 재시도하지 않으면 일시적 실패를 영구 실패로 오인하게 됩니다.

타임아웃 없는 connect

connect()는 기본적으로 시스템 기본 타임아웃(수십 초~수 분)을 사용합니다. DNS가 느리거나 상대 서버가 응답하지 않으면 스레드가 오래 블로킹되어 스레드 풀이 고갈됩니다.

해결책:

  1. errno 구분: EINTR/EAGAIN 재시도, ECONNRESET 복구
  2. 타임아웃: SO_RCVTIMEO, select, Asio deadline_timer
  3. 지수 백오프: 1초 → 2초 → 4초로 재시도 간격 증가
  4. 서킷브레이커: 연속 실패 시 요청 차단 목표:
  • connect/send/recv 실패와 errno 구분
  • SO_RCVTIMEO, select, Asio 타임아웃 구현
  • 지수 백오프 재시도, 서킷브레이커 패턴
  • ECONNRESET, ETIMEDOUT, EPIPE 해결법
  • 모니터링·로깅·프로덕션 디버깅 요구 환경: C++17 이상, POSIX 소켓 또는 Boost.Asio

소켓 에러 코드와 errno 처리

errno 흐름도

flowchart TB
    Call[connect/send/recv 호출]
    Result{반환값}
    Success[성공]
    Err[errno 확인]
    EINTR[EINTR: 시그널]
    EAGAIN[EAGAIN: 논블로킹]
    Retry[재시도]
    Fatal[치명적: 연결 종료]
    
    Call --> Result
    Result -->|0 또는 >0| Success
    Result -->|-1| Err
    Err -->|EINTR| EINTR
    Err -->|EAGAIN/EWOULDBLOCK| EAGAIN
    Err -->|ECONNRESET, EPIPE 등| Fatal
    EINTR --> Retry
    EAGAIN --> Retry
    Retry --> Call
    
    style Success fill:#4caf50
    style Retry fill:#ffeb3b
    style Fatal fill:#f44336

자주 보는 errno 전체 목록

errno의미발생 시점대응
EINTR시그널로 중단recv/send/connect 중 시그널 수신같은 연산 재시도
EAGAIN/EWOULDBLOCK논블로킹에서 데이터 없음논블로킹 fd에서 즉시 반환 불가나중에 다시 시도 (poll/epoll)
ECONNRESET상대가 연결 끊음 (RST)상대가 RST 패킷 전송새 연결 생성, 재시도
ETIMEDOUT연결/송수신 타임아웃SO_RCVTIMEO/SNDTIMEO 초과타임아웃 설정 확인, 재시도
ECONNREFUSED연결 거부서버가 listen 안 함, 방화벽서버 상태 확인, 지수 백오프
EPIPEBroken pipe상대 연결 종료 후 sendSIGPIPE 무시 또는 MSG_NOSIGNAL
ENETUNREACH네트워크 도달 불가라우팅 실패네트워크 확인
EHOSTUNREACH호스트 도달 불가호스트 라우팅 실패DNS/네트워크 확인
ENOBUFS커널 버퍼 부족시스템 리소스 고갈대기 후 재시도
ENOMEM메모리 부족할당 실패리소스 해제, 재시도

완전한 errno 처리 코드

#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <errno.h>
#include <cstring>
#include <stdexcept>
#include <string>
enum class NetworkError {
    Success,
    ConnectionRefused,
    ConnectionReset,
    Timeout,
    BrokenPipe,
    NetworkUnreachable,
    HostUnreachable,
    Retryable,  // EINTR, EAGAIN
    Unknown
};
// errno를 NetworkError로 변환
NetworkError errno_to_network_error() {
    switch (errno) {
        case EINTR:
        case EAGAIN:
#ifdef EWOULDBLOCK
        case EWOULDBLOCK:
#endif
            return NetworkError::Retryable;
        case ECONNREFUSED:
            return NetworkError::ConnectionRefused;
        case ECONNRESET:
            return NetworkError::ConnectionReset;
        case ETIMEDOUT:
            return NetworkError::Timeout;
        case EPIPE:
            return NetworkError::BrokenPipe;
#ifdef ENETUNREACH
        case ENETUNREACH:
            return NetworkError::NetworkUnreachable;
#endif
#ifdef EHOSTUNREACH
        case EHOSTUNREACH:
            return NetworkError::HostUnreachable;
#endif
        default:
            return NetworkError::Unknown;
    }
}
// connect with retry (EINTR 처리)
int connect_with_retry(int fd, const char* host, uint16_t port) {
    struct sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_port = htons(port);
    inet_pton(AF_INET, host, &addr.sin_addr);
    for (;;) {
        int ret = connect(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr));
        if (ret == 0) return 0;
        auto err = errno_to_network_error();
        if (err == NetworkError::Retryable) {
            continue;  // EINTR → 재시도
        }
        if (err == NetworkError::ConnectionRefused ||
            err == NetworkError::Timeout) {
            return -1;  // 상위에서 재시도
        }
        throw std::runtime_error(
            std::string("connect failed: ") + strerror(errno));
    }
}
// recv with EINTR handling
ssize_t recv_safe(int fd, void* buf, size_t len, int flags) {
    for (;;) {
        ssize_t n = recv(fd, buf, len, flags);
        if (n >= 0) return n;
        if (errno != EINTR) return -1;
        // EINTR → 재시도
    }
}

타임아웃 구현

방법 1: SO_RCVTIMEO / SO_SNDTIMEO

소켓 옵션으로 recv/send의 블로킹 시간을 제한합니다. 지정 시간 안에 데이터가 오가지 않으면 -1을 반환하고 errno는 EAGAIN(또는 플랫폼별 값)이 됩니다.

#include <sys/socket.h>
#include <sys/time.h>
void set_socket_timeout(int fd, int seconds) {
    struct timeval tv{seconds, 0};
    setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
    setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
}
// 사용 예
int fd = socket(AF_INET, SOCK_STREAM, 0);
set_socket_timeout(fd, 5);  // 5초 타임아웃
connect(fd, ...);
ssize_t n = recv(fd, buffer, size, 0);
if (n < 0 && errno == EAGAIN) {
    // 타임아웃 발생
}

주의: Linux에서 SO_RCVTIMEO/SO_SNDTIMEO는 connect()에는 적용되지 않습니다. connect 타임아웃은 별도로 처리해야 합니다.

방법 2: select로 타임아웃

SO_RCVTIMEO 없이 select로 “읽기 가능” 여부를 타임아웃과 함께 확인합니다.

#include <sys/select.h>
// select로 recv 타임아웃 (5초)
bool recv_with_select_timeout(int fd, void* buf, size_t len, int timeout_sec) {
    fd_set read_fds;
    FD_ZERO(&read_fds);
    FD_SET(fd, &read_fds);
    struct timeval tv{timeout_sec, 0};
    int ret = select(fd + 1, &read_fds, nullptr, nullptr, &tv);
    if (ret <= 0) {
        return false;  // 타임아웃 또는 에러
    }
    if (!FD_ISSET(fd, &read_fds)) {
        return false;
    }
    ssize_t n = recv(fd, buf, len, 0);
    return n > 0;
}

방법 3: connect 타임아웃 (논블로킹 + select)

connect에는 SO_RCVTIMEO가 적용되지 않으므로, 논블로킹 소켓 + select로 구현합니다.

#include <fcntl.h>
#include <sys/select.h>
int connect_with_timeout(int fd, const sockaddr* addr, socklen_t len,
                        int timeout_sec) {
    // 논블로킹으로 전환
    int flags = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
    int ret = connect(fd, addr, len);
    if (ret == 0) {
        fcntl(fd, F_SETFL, flags);  // 블로킹 복원
        return 0;
    }
    if (errno != EINPROGRESS) {
        fcntl(fd, F_SETFL, flags);
        return -1;
    }
    fd_set write_fds;
    FD_ZERO(&write_fds);
    FD_SET(fd, &write_fds);
    struct timeval tv{timeout_sec, 0};
    ret = select(fd + 1, nullptr, &write_fds, nullptr, &tv);
    fcntl(fd, F_SETFL, flags);
    if (ret <= 0) return -1;  // 타임아웃
    if (!FD_ISSET(fd, &write_fds)) return -1;
    int err = 0;
    socklen_t errlen = sizeof(err);
    if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &errlen) < 0) return -1;
    if (err != 0) {
        errno = err;
        return -1;
    }
    return 0;
}

방법 4: Boost.Asio deadline_timer

비동기 recv에 deadline_timer를 함께 사용합니다.

#include <boost/asio.hpp>
#include <boost/asio/steady_timer.hpp>
using boost::asio::ip::tcp;
using boost::system::error_code;
void async_recv_with_timeout(
    tcp::socket& socket,
    boost::asio::mutable_buffer buffer,
    std::chrono::seconds timeout,
    std::function<void(error_code, size_t)> callback
) {
    auto timer = std::make_shared<boost::asio::steady_timer>(
        socket.get_executor(), timeout);
    timer->async_wait([&socket, callback](error_code ec) {
        if (!ec) {
            socket.cancel();  // 타임아웃 시 recv 취소
        }
    });
    boost::asio::async_read(
        socket, buffer,
        [timer, callback](error_code ec, size_t bytes) {
            timer->cancel();  // 완료 시 타이머 취소
            callback(ec, bytes);
        }
    );
}
// 사용 예
boost::asio::io_context io;
tcp::socket socket(io);
std::array<char, 4096> buf;
async_recv_with_timeout(
    socket, boost::asio::buffer(buf), std::chrono::seconds(5),
     {
        if (ec == boost::asio::error::operation_aborted) {
            // 타임아웃
        } else if (!ec) {
            // 수신 성공
        }
    }
);
io.run();

타임아웃 방법 비교

방법장점단점
SO_RCVTIMEO간단, 모든 recv에 적용블로킹만 가능, connect 미적용
select세밀한 제어, connect 타임아웃 가능코드 복잡, fd 수 제한(1024)
poll/epollfd 수 제한 없음select보다 코드 복잡
Asio timer비동기, 스레드 효율적Asio 의존

재시도 전략 (지수 백오프, 지터)

지수 백오프란?

실패 시 대기 시간을 1초 → 2초 → 4초 → 8초처럼 지수적으로 증가시켜 서버 부하를 줄이는 전략입니다.

flowchart LR
    R1[실패] --> W1[1초 대기]
    W1 --> R2[재시도]
    R2 --> W2[2초 대기]
    W2 --> R3[재시도]
    R3 --> W3[4초 대기]
    W3 --> R4[재시도]

지수 백오프 구현

#include <chrono>
#include <thread>
#include <cmath>
struct RetryConfig {
    int max_attempts = 5;
    std::chrono::milliseconds initial_delay{1000};
    double backoff_multiplier = 2.0;
    std::chrono::milliseconds max_delay{30000};  // 30초
};
template<typename Func>
auto retry_with_backoff(Func&& func, RetryConfig config = {}) {
    int attempt = 0;
    auto delay = config.initial_delay;
    while (true) {
        auto result = func();
        if (result.success) return result;
        if (++attempt >= config.max_attempts) {
            return result;  // 최종 실패
        }
        // 지수 백오프: delay *= multiplier
        std::this_thread::sleep_for(delay);
        delay = std::chrono::milliseconds(
            static_cast<long>(delay.count() * config.backoff_multiplier)
        );
        if (delay > config.max_delay) {
            delay = config.max_delay;
        }
    }
}
// 사용 예
struct ConnectResult {
    bool success;
    int fd;
};
auto result = retry_with_backoff( -> ConnectResult {
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (connect_with_retry(fd, "example.com", 80) == 0) {
        return {true, fd};
    }
    close(fd);
    return {false, -1};
});

지터(Jitter) 추가

여러 클라이언트가 동시에 재시도하면 “thundering herd” 문제가 발생합니다. 지터를 추가해 대기 시간에 랜덤 변동을 줍니다.

#include <random>
std::chrono::milliseconds add_jitter(
    std::chrono::milliseconds delay,
    double jitter_ratio = 0.2  // ±20%
) {
    static std::mt19937 rng(std::random_device{}());
    std::uniform_real_distribution<> dist(1.0 - jitter_ratio, 1.0 + jitter_ratio);
    auto jittered = static_cast<long>(delay.count() * dist(rng));
    return std::chrono::milliseconds(std::max(1L, jittered));
}
// 재시도 시 사용
std::this_thread::sleep_for(add_jitter(delay));

선형 vs 지수 백오프 비교

전략대기 시간적합한 상황
선형1초, 2초, 3초, 4초짧은 장애, 빠른 복구
지수1초, 2초, 4초, 8초장기 장애, 서버 보호
지수+지터0.81.2초, 1.62.4초…분산 시스템, thundering herd 방지

서킷브레이커 패턴

개요

연속 실패가 일정 횟수 이상이면 일정 시간 동안 요청을 차단하여, 실패한 서버로 요청이 폭주하는 것을 막습니다.

stateDiagram-v2
    [*] --> Closed: 초기
    Closed --> Open: 실패 N회
    Open --> HalfOpen: timeout 후
    HalfOpen --> Closed: 성공
    HalfOpen --> Open: 실패

구현

#include <chrono>
#include <atomic>
#include <mutex>
enum class CircuitState { Closed, Open, HalfOpen };
class CircuitBreaker {
    std::atomic<CircuitState> state_{CircuitState::Closed};
    std::atomic<int> failure_count_{0};
    std::atomic<int> success_count_{0};
    std::chrono::steady_clock::time_point last_failure_time_;
    std::mutex mutex_;
    int failure_threshold_ = 5;
    std::chrono::seconds open_duration_{30};
    int success_threshold_ = 2;
public:
    bool allow_request() {
        auto s = state_.load();
        if (s == CircuitState::Closed) return true;
        if (s == CircuitState::HalfOpen) return true;
        // Open: 시간 경과 확인
        std::lock_guard<std::mutex> lock(mutex_);
        auto elapsed = std::chrono::steady_clock::now() - last_failure_time_;
        if (elapsed >= open_duration_) {
            state_ = CircuitState::HalfOpen;
            success_count_ = 0;
            return true;
        }
        return false;
    }
    void record_success() {
        if (state_ == CircuitState::HalfOpen) {
            if (++success_count_ >= success_threshold_) {
                state_ = CircuitState::Closed;
                failure_count_ = 0;
            }
        } else if (state_ == CircuitState::Closed) {
            failure_count_ = 0;
        }
    }
    void record_failure() {
        std::lock_guard<std::mutex> lock(mutex_);
        last_failure_time_ = std::chrono::steady_clock::now();
        if (state_ == CircuitState::HalfOpen) {
            state_ = CircuitState::Open;
        } else if (state_ == CircuitState::Closed) {
            if (++failure_count_ >= failure_threshold_) {
                state_ = CircuitState::Open;
            }
        }
    }
};
// 사용 예
CircuitBreaker breaker;
if (!breaker.allow_request()) {
    // 서킷 오픈: 요청 차단
    return;
}
int fd = connect_to_server();
if (fd >= 0) {
    breaker.record_success();
} else {
    breaker.record_failure();
}

ECONNRESET·ETIMEDOUT·EPIPE: 에러별 원인과 대응

ECONNRESET (Connection reset by peer)

원인: 상대가 RST 패킷으로 연결을 끊은 경우입니다(프로세스 종료, 타임아웃, 방화벽 등). 해결:

if (errno == ECONNRESET) {
    close(fd);
    fd = -1;
    // 새 연결 생성 후 재시도
    fd = socket(AF_INET, SOCK_STREAM, 0);
    connect_with_retry(fd, host, port);
}

추가 확인: 로드밸런서/방화벽의 idle timeout과 TCP keepalive 설정을 확인합니다.

ETIMEDOUT (Connection timed out)

원인: connect/recv/send가 지정 시간 안에 완료되지 않은 경우입니다. 해결:

// 1. SO_RCVTIMEO/SO_SNDTIMEO 설정
set_socket_timeout(fd, 10);
// 2. connect 타임아웃 (논블로킹 + select)
int fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr = { ... };
if (connect_with_timeout(fd, (sockaddr*)&addr, sizeof(addr), 5) < 0) {
    // 5초 내 연결 실패
}

EPIPE (Broken pipe)

원인: 상대가 연결을 끊은 뒤 send를 호출한 경우입니다. 해결:

// 1. SIGPIPE 무시 (권장)
signal(SIGPIPE, SIG_IGN);
// 2. MSG_NOSIGNAL 사용 (send 시)
send(fd, data, len, MSG_NOSIGNAL);
// 3. send 전 연결 상태 확인
// - keepalive 또는 주기적 ping

ECONNREFUSED (Connection refused)

원인: 서버가 listen하지 않거나, 방화벽이 차단했거나, 포트가 잘못된 경우입니다. 해결: 서버 상태를 확인하고, 지수 백오프로 재시도하며, 포트/호스트 설정을 검증합니다.

EINPROGRESS (논블로킹 connect)

원인: 논블로킹 소켓에서 connect가 즉시 완료되지 않은 경우입니다. 해결: select/poll로 쓰기 가능 상태를 기다린 뒤 getsockopt(SO_ERROR)로 결과를 확인합니다.

에러별 대응 요약

errno재시도연결 종료로깅
EINTR✅❌디버그
EAGAIN✅ (논블로킹)❌-
ECONNRESET새 연결로✅WARN
ETIMEDOUT✅✅WARN
ECONNREFUSED✅✅WARN
EPIPE❌✅WARN
ENETUNREACH✅✅WARN

EINTR과 부분 읽기/쓰기

EINTR

recv/send가 -1을 반환하고 errno==EINTR이면 한 번 더 시도합니다.

ssize_t recv_safe(int fd, void* buf, size_t len) {
    for (;;) {
        ssize_t n = recv(fd, buf, len, 0);
        if (n >= 0) return n;
        if (errno != EINTR) return -1;
    }
}

부분 전송 (send)

send는 요청한 것보다 적게 보낼 수 있습니다. 부족한 만큼 다시 send합니다.

ssize_t send_all(int fd, const void* data, size_t len) {
    const char* p = static_cast<const char*>(data);
    size_t sent = 0;
    while (sent < len) {
        ssize_t n = send(fd, p + sent, len - sent, MSG_NOSIGNAL);
        if (n < 0) {
            if (errno == EINTR) continue;
            return -1;
        }
        sent += n;
    }
    return static_cast<ssize_t>(sent);
}

부분 수신 (recv)

버퍼 크기만큼 채울 때까지(또는 프로토콜 경계까지) 반복합니다.

ssize_t recv_exact(int fd, void* buf, size_t len) {
    char* p = static_cast<char*>(buf);
    size_t received = 0;
    while (received < len) {
        ssize_t n = recv_safe(fd, p + received, len - received);
        if (n <= 0) return n;  // 0=연결종료, -1=에러
        received += n;
    }
    return static_cast<ssize_t>(received);
}

errno 로깅, SIGPIPE 처리, 재시도할 errno 고르기

항상 errno 로깅

if (connect(fd, ...) < 0) {
    // ✅ errno, strerror 포함
    log_error("connect failed: errno=%d (%s)", errno, strerror(errno));
    return -1;
}

실패 시 fd 반드시 닫기

int fd = socket(...);
if (connect(fd, ...) < 0) {
    close(fd);  // ✅ 누수 방지
    return -1;
}

SIGPIPE 무시 또는 MSG_NOSIGNAL

// 프로그램 시작 시
signal(SIGPIPE, SIG_IGN);
// 또는 send 시
send(fd, data, len, MSG_NOSIGNAL);

타임아웃 항상 설정

블로킹 소켓에는 SO_RCVTIMEO/SO_SNDTIMEO 또는 select/poll 타임아웃을 반드시 설정합니다.

재시도 가능 errno만 재시도

ECONNREFUSED, ETIMEDOUT은 재시도합니다. ECONNRESET은 새 연결을 만든 뒤 재시도합니다. EPIPE는 연결이 이미 끊겼으므로 재시도하지 않습니다.

RAII로 fd 관리

class SocketGuard {
    int fd_;
public:
    explicit SocketGuard(int fd) : fd_(fd) {}
    ~SocketGuard() { if (fd_ >= 0) close(fd_); }
    SocketGuard(const SocketGuard&) = delete;
    SocketGuard& operator=(const SocketGuard&) = delete;
};

연결 풀 헬스 체크, 통합 에러 핸들러, TCP Keepalive

패턴 1: 연결 풀 + 헬스 체크

연결 풀에서 꺼낸 연결이 idle timeout으로 끊겼을 수 있으므로, 사용 전에 간단한 헬스 체크(또는 첫 요청 실패 시 재연결)를 수행합니다.

bool is_connection_alive(int fd) {
    char buf;
    ssize_t n = recv(fd, &buf, 1, MSG_PEEK | MSG_DONTWAIT);
    if (n > 0) return true;
    if (n == 0) return false;  // 연결 종료
    return errno == EAGAIN || errno == EWOULDBLOCK;  // 데이터 대기 중
}

패턴 2: 통합 에러 핸들러

connect, send, recv를 하나의 래퍼로 묶어 errno 처리, 재시도, 로깅을 일원화합니다.

struct NetworkResult {
    bool ok;
    int err;
    std::string message;
};
NetworkResult do_request(const char* host, int port,
                         const void* req, size_t req_len,
                         void* resp, size_t resp_len) {
    RetryConfig retry_config{5, std::chrono::seconds(1), 2.0, std::chrono::seconds(30)};
    CircuitBreaker breaker;
    return retry_with_backoff([&]() {
        if (!breaker.allow_request()) {
            return NetworkResult{false, 0, "circuit open"};
        }
        int fd = socket(AF_INET, SOCK_STREAM, 0);
        if (fd < 0) return NetworkResult{false, errno, "socket failed"};
        set_socket_timeout(fd, 10);
        if (connect_with_retry(fd, host, port) < 0) {
            close(fd);
            breaker.record_failure();
            return NetworkResult{false, errno, "connect failed"};
        }
        if (send_all(fd, req, req_len) < 0) {
            close(fd);
            breaker.record_failure();
            return NetworkResult{false, errno, "send failed"};
        }
        ssize_t n = recv_safe(fd, resp, resp_len);
        close(fd);
        if (n <= 0) {
            breaker.record_failure();
            return NetworkResult{false, errno, "recv failed"};
        }
        breaker.record_success();
        return NetworkResult{true, 0, ""};
    }, retry_config);
}

패턴 3: TCP Keepalive

idle 연결이 중간 장비에 의해 끊기는 것을 방지합니다.

void enable_keepalive(int fd) {
    int on = 1;
    setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &on, sizeof(on));
#ifdef TCP_KEEPIDLE
    int idle = 60;   // 60초 후 첫 프로브
    setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle));
#endif
#ifdef TCP_KEEPINTVL
    int interval = 10;  // 10초 간격
    setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &interval, sizeof(interval));
#endif
#ifdef TCP_KEEPCNT
    int count = 3;  // 3회 실패 시 종료
    setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &count, sizeof(count));
#endif
}

패턴 4: 그레이스풀 셧다운

EINTR을 활용해 SIGTERM 수신 시 정상 종료합니다.

volatile sig_atomic_t g_shutdown = 0;
void sigterm_handler(int) { g_shutdown = 1; }
// recv 루프에서
while (!g_shutdown) {
    ssize_t n = recv_safe(fd, buf, len, 0);
    if (n <= 0) break;
    // 처리...
}

모니터링과 로깅

구조화된 로깅

#include <iostream>
#include <chrono>
#include <sstream>
struct NetworkLog {
    static void connection_failed(const char* host, int port, int err) {
        std::cerr << "[NETWORK] connection_failed host=" << host
                  << " port=" << port << " errno=" << err
                  << " errstr=" << strerror(err) << "\n";
    }
    static void timeout(const char* op, int fd) {
        std::cerr << "[NETWORK] timeout op=" << op << " fd=" << fd << "\n";
    }
    static void connection_reset(int fd) {
        std::cerr << "[NETWORK] connection_reset fd=" << fd << "\n";
    }
    static void retry(int attempt, int max, const char* reason) {
        std::cerr << "[NETWORK] retry attempt=" << attempt << "/" << max
                  << " reason=" << reason << "\n";
    }
};
// 사용
if (connect(fd, ...) < 0) {
    NetworkLog::connection_failed("example.com", 80, errno);
}

메트릭 수집 (Prometheus 스타일)

#include <atomic>
struct NetworkMetrics {
    std::atomic<uint64_t> connect_total{0};
    std::atomic<uint64_t> connect_failed{0};
    std::atomic<uint64_t> connect_timeout{0};
    std::atomic<uint64_t> recv_timeout{0};
    std::atomic<uint64_t> retry_total{0};
    void record_connect_success() { ++connect_total; }
    void record_connect_failure(int err) {
        ++connect_failed;
        if (err == ETIMEDOUT) ++connect_timeout;
    }
    void record_recv_timeout() { ++recv_timeout; }
    void record_retry() { ++retry_total; }
    void print_stats() {
        std::cout << "connect_total=" << connect_total.load()
                  << " connect_failed=" << connect_failed.load()
                  << " connect_timeout=" << connect_timeout.load()
                  << " recv_timeout=" << recv_timeout.load()
                  << " retry_total=" << retry_total.load() << "\n";
    }
};

tcpdump·strace로 네트워크 장애 추적하기

먼저 확인할 로그

  1. errno 로깅: 실패 시 errno, strerror(errno) 로그
  2. 주소 로깅: host:port, 로컬/원격 주소
  3. 타임아웃 확인: SO_RCVTIMEO/SNDTIMEO 설정 여부
  4. 재시도 로그: attempt, max_attempts, 대기 시간
  5. 서킷 상태: Open/Closed/HalfOpen 로그

tcpdump로 패킷 확인

# RST 패킷 확인
tcpdump -i any 'tcp[tcpflags] & tcp-rst != 0'
# 특정 포트 트래픽
tcpdump -i any port 8080 -A

strace로 시스템 콜 확인

# connect/recv/send 에러 확인
strace -e trace=network -f ./my_client 2>&1 | grep -E 'connect|recv|send'

자주 하는 실수

실수해결
EINTR 무시recv/send 루프에서 EINTR 시 재시도
타임아웃 없음SO_RCVTIMEO 또는 select/Asio timer 설정
즉시 재시도지수 백오프 적용
EPIPE로 크래시SIGPIPE 무시 또는 MSG_NOSIGNAL
연결 안 닫기실패 시 close(fd) 호출
connect 타임아웃 없음논블로킹 + select로 구현

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 지수 백오프 재시도에 지터를 섞는 이유는 무엇인가요?

A. 서버 장애 뒤 많은 클라이언트가 같은 간격으로 재시도하면 서버가 복구되는 순간 요청이 한꺼번에 몰려 다시 장애가 날 수 있습니다. 대기 시간에 무작위 값을 섞으면 재시도 시점이 흩어져 부하가 분산됩니다. 여기에 최대 대기 시간과 최대 재시도 횟수 상한을 함께 두고, 멱등하지 않은 요청은 재시도해도 안전한지 먼저 확인해야 합니다.

Q. ECONNRESET이 자주 나면?

A. 상대 서버/방화벽/로드밸런서의 idle timeout, keepalive 미설정 등을 확인하세요. 클라이언트에서 TCP keepalive를 켜거나 주기적으로 데이터를 보내 연결을 유지할 수 있습니다.

Q. Asio vs raw socket, 어떤 걸 써야 하나요?

A. Asio는 비동기이고 타임아웃·에러 처리가 편합니다. raw socket은 의존성이 없고 세밀하게 제어할 수 있습니다. 고성능 서버는 Asio, 임베디드/간단한 클라이언트는 raw socket을 많이 씁니다. errno·타임아웃·지수 백오프·서킷브레이커로 연결 실패를 다루면 안정적인 네트워크 코드를 짤 수 있습니다. 이전 글: C++ 실전 가이드 #28-2: HTTP 클라이언트/서버 다음 글: [C++ 실전 가이드 #29-1] Asio 라이브러리 입문: io_context와 비동기 연산