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

타임아웃을 설정하지 않은 블로킹 소켓은 상대가 응답하지 않을 때 끝없이 기다립니다.

// ❌ 문제: 서버가 멈추거나 응답이 없으면
int fd = socket(AF_INET, SOCK_STREAM, 0);
connect(fd, ...);
recv(fd, buffer, size, 0);  // 여기서 무기한 블로킹

스레드 풀에서 이런 호출이 몇 개만 걸려도 작업 스레드가 고갈되고, 서비스 전체가 멈춘 것처럼 보입니다. connect()도 마찬가지로, Linux 기본 설정(net.ipv4.tcp_syn_retries = 6)에서는 응답 없는 호스트에 대해 SYN 재전송을 반복하며 2분 남짓 블로킹한 뒤에야 ETIMEDOUT을 돌려줍니다.

반대로 실패를 다루는 쪽에서도 문제가 생깁니다. 재시작 중인 서비스에 실패 즉시 재시도하면 복구 직후 요청이 몰려 다시 쓰러뜨리고, 로드밸런서의 idle timeout으로 이미 끊긴 풀 연결에 send()하면 EPIPE나 ECONNRESET을 받으며, 시그널이 들어오면 EINTR로 블로킹 호출이 중단됩니다. 이 글은 이런 상황에서 errno를 어떻게 해석하고, 어디에 타임아웃을 걸고, 어떤 에러만 재시도하고, 계속 실패하는 상대를 서킷 브레이커로 어떻게 차단하는지를 POSIX 소켓(C++17) 기준으로 다룹니다. 일부 예제는 Boost.Asio를 사용합니다.


소켓 에러 코드와 errno 처리

errno 흐름도

flowchart TB
    Call[connect/send/recv 호출]
    Result{반환값}
    Success["성공 (recv의 0은 상대가 연결을 닫았다는 뜻)"]
    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에서 데이터 없음, 또는 SO_RCVTIMEO/SO_SNDTIMEO 만료poll/epoll로 대기 후 재시도, 타임아웃이면 상위로 보고
ECONNRESET상대가 RST로 연결을 끊음상대 프로세스 비정상 종료, 닫힌 소켓에 데이터 도착소켓을 닫고 새 연결
ETIMEDOUTTCP 수준 타임아웃connect의 SYN 재전송 한도 초과, 재전송·keepalive 한도 초과소켓을 닫고 백오프 후 재시도
ECONNREFUSED연결 거부해당 포트에 listen 중인 프로세스 없음(상대가 RST 응답)서버 상태 확인, 지수 백오프
EPIPEBroken pipe이미 닫힌 연결에 sendSIGPIPE 무시 또는 MSG_NOSIGNAL, 소켓 닫기
ENETUNREACH네트워크 도달 불가라우팅 경로 없음네트워크 설정 확인
EHOSTUNREACH호스트 도달 불가ICMP host unreachable 수신 등주소·네트워크 확인
ENOBUFS커널 버퍼 부족시스템 리소스 고갈대기 후 재시도
ENOMEM메모리 부족할당 실패리소스 해제, 재시도

SO_RCVTIMEO 만료가 ETIMEDOUT이 아니라 EAGAIN/EWOULDBLOCK으로 보고된다는 점을 자주 헷갈립니다. ETIMEDOUT은 커널의 TCP 재전송 로직이 포기했을 때 나오는 값입니다.

connect의 EINTR은 recv/send와 다르게 다뤄야 합니다. 블로킹 connect가 시그널로 중단되어도 연결 시도는 커널에서 비동기로 계속 진행되므로, 같은 소켓에 connect를 다시 호출하면 EALREADY(진행 중)나 EISCONN(이미 연결됨)을 받습니다. 올바른 방법은 poll로 쓰기 가능을 기다린 뒤 getsockopt(SO_ERROR)로 결과를 확인하는 것입니다.

errno 처리 코드

#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <poll.h>
#include <errno.h>
#include <cstdint>
#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;
    }
}
// EINTR로 중단된 connect의 결과를 기다림 (connect를 다시 호출하지 않음)
static int wait_connect_result(int fd) {
    pollfd pfd{fd, POLLOUT, 0};
    int r;
    do { r = poll(&pfd, 1, -1); } while (r < 0 && errno == EINTR);
    if (r < 0) return -1;
    int err = 0;
    socklen_t len = sizeof(err);
    if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len) < 0) return -1;
    if (err != 0) { errno = err; return -1; }
    return 0;
}
// host는 IP 주소 문자열이어야 함 (도메인 이름은 getaddrinfo로 먼저 변환)
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);
    if (inet_pton(AF_INET, host, &addr.sin_addr) != 1) {
        throw std::invalid_argument(std::string("not an IPv4 address: ") + host);
    }
    if (connect(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) == 0) return 0;
    if (errno == EINTR && wait_connect_result(fd) == 0) return 0;
    auto err = errno_to_network_error();
    if (err == NetworkError::ConnectionRefused ||
        err == NetworkError::Timeout ||
        err == NetworkError::NetworkUnreachable ||
        err == NetworkError::HostUnreachable) {
        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 또는 EWOULDBLOCK이 됩니다(Linux에서는 두 값이 같음). 이미 일부를 받은 상태에서 만료되면 그때까지 받은 바이트 수를 반환합니다.

#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 || errno == EWOULDBLOCK)) {
    // 타임아웃 발생
}

connect 타임아웃은 플랫폼마다 다릅니다. Linux는 SO_SNDTIMEO를 블로킹 connect()에도 적용해 시간이 지나면 EINPROGRESS로 실패시키지만, 이는 이식성 있는 동작이 아니고 SO_RCVTIMEO는 connect와 무관합니다. 여러 OS에서 같은 동작이 필요하면 아래 방법 3처럼 논블로킹 connect와 select/poll을 씁니다.

방법 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 타임아웃은 논블로킹 소켓과 select로 구현합니다. 소켓이 쓰기 가능해졌다는 것은 연결 시도가 끝났다는 뜻일 뿐이므로, 성공 여부는 반드시 SO_ERROR로 확인해야 합니다.

#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 steady_timer

비동기 읽기와 타이머를 같이 걸고, 먼저 끝나는 쪽이 다른 쪽을 취소합니다. 타이머가 먼저 만료되면 socket.cancel()로 읽기를 취소하고, 읽기 핸들러는 operation_aborted를 받습니다. 아래 예제의 async_read는 버퍼가 가득 찰 때까지 읽으므로, recv처럼 도착한 만큼만 받으려면 socket.async_read_some을 씁니다.

#include <boost/asio.hpp>
#include <boost/asio/steady_timer.hpp>
#include <array>
#include <functional>
#include <memory>
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),
    [](error_code ec, size_t /*bytes*/) {
        if (ec == boost::asio::error::operation_aborted) {
            // 타임아웃
        } else if (!ec) {
            // 수신 성공
        }
    }
);
io.run();

타임아웃 방법 비교

방법장점단점
SO_RCVTIMEO/SO_SNDTIMEO간단, 해당 소켓의 모든 recv/send에 적용블로킹 소켓 전용, connect 적용 여부는 OS마다 다름
select어디서나 사용 가능, connect 타임아웃 구현 가능fd 번호가 FD_SETSIZE(보통 1024) 이상이면 쓸 수 없음
poll/epollfd 번호 제한 없음, 많은 연결에 적합(epoll)epoll은 Linux 전용
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 (fd < 0) return {false, -1};
    if (connect_with_retry(fd, "192.0.2.10", 80) == 0) {  // IP 주소 (예시용 TEST-NET)
        return {true, fd};
    }
    close(fd);
    return {false, -1};
});

지터(Jitter) 추가

같은 장애를 겪은 클라이언트들이 같은 간격으로 재시도하면, 서버가 복구되는 순간 요청이 한꺼번에 몰리는 thundering herd 문제가 생깁니다. 지터는 대기 시간에 무작위 변동을 섞어 재시도 시점을 흩어 놓습니다. 아래는 ±20% 범위로 흔드는 방식이고, 0부터 현재 지연값 사이에서 균등하게 고르는 “full jitter”도 많이 쓰입니다.

#include <algorithm>
#include <random>
std::chrono::milliseconds add_jitter(
    std::chrono::milliseconds delay,
    double jitter_ratio = 0.2  // ±20%
) {
    thread_local 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 방지

서킷브레이커 패턴

개요

연속 실패가 일정 횟수 이상이면 일정 시간 동안 요청을 바로 실패시켜, 이미 문제가 있는 서버로 요청이 계속 쏟아지는 것을 막습니다. 차단 시간이 지나면 Half-Open 상태에서 시험 요청을 보내 보고, 성공하면 다시 정상(Closed)으로 돌아갑니다. 개요 다이어그램(주요 항목: 초기, 실패 N회, timeout 후, 성공, 실패, Closed, Open, HalfOpen)

구현

#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();
}

이 구현은 설명을 위해 단순화했습니다. Half-Open 상태에서 들어오는 요청을 모두 통과시키므로, 동시 요청이 많은 서비스라면 시험 요청 수를 하나(또는 소수)로 제한해야 복구 중인 서버에 다시 부하가 몰리지 않습니다. 또 상태 전이 일부가 뮤텍스 밖에서 일어나므로, 엄밀한 동작이 필요하면 모든 전이를 같은 잠금 아래에서 처리하는 편이 안전합니다.


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

loopback 소켓으로 재현한 ECONNREFUSED, SO_RCVTIMEO 만료의 EAGAIN, ECONNRESET, 상대 close 뒤 첫 send 성공과 두 번째 send의 EPIPE, 논블로킹 connect의 EINPROGRESS, SIGPIPE 종료 코드

그림: 실제 실행 결과(127.0.0.1) — 아무도 listen하지 않는 포트는 ECONNREFUSED, SO_RCVTIMEO 만료는 ETIMEDOUT이 아니라 EAGAIN, RST로 닫은 상대는 ECONNRESET이다. 상대가 close한 직후의 첫 send는 성공하고 두 번째 send에서 EPIPE가 나며, SIGPIPE를 무시하지 않으면 프로세스가 종료 코드 141로 죽는다. 논블로킹 connect는 EINPROGRESS다. errno 번호는 macOS 기준이고 Linux와 다르다(예: ECONNREFUSED 61 대 111). (환경: macOS 27.0.1 · Apple clang 21.0.0 · macOS(errno 번호는 Linux와 다름))

ECONNRESET (Connection reset by peer)

원인: 상대가 RST 패킷으로 연결을 끊은 경우입니다. 상대 프로세스가 읽지 않은 데이터를 남긴 채 소켓을 닫았거나, 이미 닫힌 연결에 데이터가 도착했거나, 중간 장비(방화벽, 로드밸런서)가 idle 연결을 정리한 경우가 흔합니다. 해결:

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

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

같은 환경에서 재현했습니다. 클라이언트가 보낸 데이터를 서버가 읽지 않은 채 연결 소켓을 close하자, 클라이언트의 다음 recv가 ECONNRESET을 반환했습니다. 읽지 않은 데이터가 남은 채 닫으면 정상 종료(FIN)가 아니라 RST로 끊긴다는 설명과 같습니다.

ETIMEDOUT (Connection timed out)

원인: 커널의 TCP 타이머가 포기한 경우입니다. connect에서는 SYN에 응답이 없어 재전송 한도를 넘겼을 때, 연결된 상태에서는 보낸 데이터의 재전송이나 keepalive 프로브가 한도를 넘겼을 때 나옵니다. SO_RCVTIMEO 만료는 ETIMEDOUT이 아니라 EAGAIN이라는 점에 주의합니다.

해결: 소켓을 닫고 백오프 후 다시 연결하되, 기다리는 시간을 애플리케이션이 정하도록 connect와 recv에 각각 짧은 타임아웃을 둡니다.

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

같은 환경에서 확인한 값입니다. 목적지 포트를 방화벽이 DROP하게 하고 TCP_USER_TIMEOUT을 3,000ms로 준 소켓은 connect가 3.00초 뒤 ETIMEDOUT으로 실패했습니다. 반면 SO_RCVTIMEO를 1초로 준 recv는 1.03초 뒤 ETIMEDOUT이 아니라 EAGAIN을 반환해 위의 주의가 맞았습니다. 타임아웃을 아예 주지 않으면 커널 기본값에 맡기게 되는데, 이 환경의 tcp_syn_retries는 6이라 SYN 재전송 간격(1, 2, 4, 8, 16, 32, 64초)을 더하면 약 127초 뒤에 포기합니다(이 계산값은 직접 기다려서 재 본 것이 아닙니다).

EPIPE (Broken pipe)

원인: 상대가 연결을 끊은 뒤 send를 호출한 경우입니다. 상대가 close한 직후의 첫 번째 send는 커널 버퍼에 들어가 성공하고, 상대가 RST로 응답한 뒤의 두 번째 send에서 EPIPE가 납니다(아래 실행 화면 기준, loopback). 그래서 send가 한 번 성공했다고 상대가 살아 있다는 뜻은 아닙니다. 해결:

// 1. 프로세스 전체에서 SIGPIPE 무시
signal(SIGPIPE, SIG_IGN);
// 2. 호출 단위로 막기 (Linux: MSG_NOSIGNAL)
send(fd, data, len, MSG_NOSIGNAL);
// macOS/BSD에는 MSG_NOSIGNAL이 없거나 제한적이므로 소켓 옵션 사용
// int on = 1; setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on));

기본 동작에서는 끊긴 연결에 send하면 SIGPIPE가 프로세스를 종료시키므로, errno로 EPIPE를 받으려면 위 방법 중 하나가 필요합니다. EPIPE를 받은 소켓은 닫고 새로 연결합니다.

ECONNREFUSED (Connection refused)

원인: 서버가 listen하지 않거나, 방화벽이 차단했거나, 포트가 잘못된 경우입니다.

해결: 서버 상태를 확인하고, 지수 백오프로 재시도하며, 포트/호스트 설정을 검증합니다.

직접 재현해 보면 “방화벽이 차단했거나”는 방화벽이 어떻게 차단하느냐에 따라 달라집니다. Linux 컨테이너(Python 3, 루프백, NET_ADMIN 권한으로 iptables 사용)에서 같은 포트에 iptables 규칙만 바꿔 connect를 해 봤습니다.

상황connect 결과걸린 시간
listen하는 서버가 없음(닫힌 포트)ECONNREFUSED약 0.5ms
방화벽이 REJECT(기본, ICMP 응답)ECONNREFUSED즉시
방화벽이 REJECT --reject-with tcp-resetECONNREFUSED즉시
방화벽이 DROP응답 없음(타임아웃)TCP_USER_TIMEOUT 3초 설정 시 3.00초 뒤 ETIMEDOUT

ECONNREFUSED는 상대(또는 중간 장비)가 거부 응답을 돌려줬다는 뜻이라 빠르게 실패하고, 방화벽이 패킷을 조용히 버리는 DROP은 응답이 없어서 ECONNREFUSED가 아니라 시간 초과로 나타납니다. 그래서 연결이 즉시 거부되면 서버가 안 떠 있거나 REJECT 규칙이고, 한참 걸린 뒤 시간 초과가 나면 DROP이나 경로 문제를 먼저 의심합니다.

EINPROGRESS (논블로킹 connect)

원인: 논블로킹 소켓에서 connect가 즉시 완료되지 않은 경우입니다.

해결: select/poll로 쓰기 가능 상태를 기다린 뒤 getsockopt(SO_ERROR)로 결과를 확인합니다.

같은 환경에서 논블로킹 소켓으로 확인한 흐름입니다. connect는 즉시 EINPROGRESS를 돌려주고, 잠시 뒤 select는 쓰기 가능으로 알려 줬습니다. 이때 getsockopt(SO_ERROR)가 서버가 있는 포트에서는 0, 닫힌 포트에서는 ECONNREFUSED였습니다. 닫힌 포트도 select가 똑같이 쓰기 가능으로 깨웠으므로, 쓰기 가능이라는 사실만으로 연결이 성공했다고 판단하면 안 되고 SO_ERROR를 반드시 확인해야 합니다.

에러별 대응 요약

errno재시도연결 종료로깅
EINTROX디버그
EAGAINO (논블로킹)X-
ECONNRESET새 연결로OWARN
ETIMEDOUTOOWARN
ECONNREFUSEDOOWARN
EPIPEXOWARN
ENETUNREACHOOWARN

EINTR과 부분 읽기/쓰기

EINTR

recv/send가 -1을 반환하고 errno가 EINTR이면 같은 호출을 다시 하면 됩니다. 앞에서 만든 recv_safe가 이 처리를 합니다. sigaction에 SA_RESTART를 주면 커널이 recv/send를 자동으로 재시작하지만, SO_RCVTIMEO가 걸린 소켓 호출 등 재시작되지 않는 경우가 있으므로 EINTR 처리는 남겨 두는 편이 안전합니다.

부분 전송 (send)

send는 요청한 것보다 적게 보낼 수 있습니다(블로킹 소켓에서도 시그널이나 SO_SNDTIMEO로 중간에 끊기면 일부만 보낸 바이트 수를 반환합니다). 반환값만큼 포인터를 옮겨 나머지를 다시 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)

TCP는 메시지 경계를 보존하지 않으므로, 상대가 한 번에 보낸 100바이트가 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, 0);
        if (n <= 0) return n;  // 0=연결종료, -1=에러
        received += n;
    }
    return static_cast<ssize_t>(received);
}

errno 로깅, 재시도할 errno 고르기, fd 관리

errno는 바로 저장해서 로깅

errno는 다음 시스템 콜이나 라이브러리 호출이 덮어쓸 수 있습니다. 실패 직후 지역 변수에 저장한 뒤 close()나 로깅을 해야 엉뚱한 값이 기록되지 않습니다.

if (connect(fd, ...) < 0) {
    int saved = errno;          // close()가 errno를 바꿀 수 있음
    close(fd);                  // 실패한 fd도 반드시 닫아 누수 방지
    log_error("connect failed: errno=%d (%s)", saved, strerror(saved));
    return -1;
}

재시도할 errno 고르기

ECONNREFUSED와 ETIMEDOUT은 백오프 후 재시도할 만합니다. ECONNRESET과 EPIPE는 그 소켓으로는 더 할 수 있는 일이 없으므로 닫고 새 연결을 만들어야 하며, 이때 이미 보낸 요청이 상대에게 처리되었는지 알 수 없다는 점이 중요합니다. 결제처럼 멱등하지 않은 요청은 요청 ID로 중복을 막는 장치가 없다면 자동 재시도하지 않는 편이 안전합니다.

RAII로 fd 관리

여러 실패 경로마다 close()를 빠뜨리지 않으려면 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 처리, 재시도, 서킷 브레이커를 한곳에서 다룹니다. 서킷 브레이커는 호출마다 새로 만들면 실패 횟수가 누적되지 않으므로, 대상 서버별로 하나를 두고 공유해야 합니다. 앞의 retry_with_backoff가 result.success를 검사하므로 결과 구조체의 필드 이름도 맞춥니다.

struct NetworkResult {
    bool success;
    int err;
    std::string message;
};
NetworkResult do_request(CircuitBreaker& breaker,   // 서버별로 공유
                         const char* host, uint16_t 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)};
    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"};
        SocketGuard guard(fd);                 // 모든 경로에서 close
        set_socket_timeout(fd, 10);
        if (connect_with_retry(fd, host, port) < 0) {
            int e = errno;
            breaker.record_failure();
            return NetworkResult{false, e, "connect failed"};
        }
        if (send_all(fd, req, req_len) < 0) {
            int e = errno;
            breaker.record_failure();
            return NetworkResult{false, e, "send failed"};
        }
        ssize_t n = recv_safe(fd, resp, resp_len, 0);
        if (n <= 0) {
            int e = (n == 0) ? 0 : errno;      // 0은 상대가 연결을 닫음
            breaker.record_failure();
            return NetworkResult{false, e, "recv failed"};
        }
        breaker.record_success();
        return NetworkResult{true, 0, ""};
    }, retry_config);
}

이 래퍼는 같은 요청을 최대 5번 보낼 수 있으므로, 앞에서 말한 것처럼 멱등한 요청에만 써야 합니다.

패턴 3: TCP Keepalive

연결이 오래 idle 상태로 있으면 NAT나 로드밸런서가 연결 정보를 지워 버릴 수 있습니다. keepalive 프로브를 그보다 짧은 간격으로 보내면 연결을 유지할 수 있고, 상대가 사라진 연결은 프로브 실패로 감지해 ETIMEDOUT으로 정리됩니다. Linux 기본값은 2시간 뒤에야 첫 프로브를 보내므로 소켓별로 값을 줄여 줍니다.

#include <netinet/tcp.h>   // TCP_KEEPIDLE 등
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: 그레이스풀 셧다운

SIGTERM을 받으면 블로킹 recv가 EINTR로 깨어나게 하고, 그 시점에 종료 플래그를 확인합니다. 여기서는 EINTR을 무조건 재시도하는 recv_safe를 쓰면 안 됩니다. 재시도하는 순간 다시 블로킹되어 종료 요청을 놓치기 때문입니다. 핸들러는 SA_RESTART 없이 등록해야 recv가 자동 재시작되지 않고 EINTR을 돌려줍니다.

#include <csignal>
volatile sig_atomic_t g_shutdown = 0;
void sigterm_handler(int) { g_shutdown = 1; }

void install_handler() {
    struct sigaction sa{};
    sa.sa_handler = sigterm_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;                 // SA_RESTART 없음 → recv가 EINTR 반환
    sigaction(SIGTERM, &sa, nullptr);
}

// recv 루프
while (!g_shutdown) {
    ssize_t n = recv(fd, buf, len, 0);
    if (n < 0 && errno == EINTR) continue;   // 루프 조건에서 g_shutdown 확인
    if (n <= 0) break;
    // 처리...
}

시그널이 recv 호출 직전에 도착하면 이 방식도 놓칠 수 있으므로, 확실한 종료가 필요하면 recv에 타임아웃을 함께 두거나 self-pipe, signalfd, eventfd를 poll에 같이 넣는 방식을 씁니다.


모니터링과 로깅

구조화된 로깅

#include <cstring>
#include <iostream>
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_total;
        ++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로 네트워크 장애 추적하기

먼저 확인할 로그

간헐적 장애는 재현이 어려우므로 로그에 판단 근거가 남아 있어야 합니다. 실패 시점의 errno와 strerror 문자열, 원격 host:port와 로컬 포트, 몇 번째 재시도였고 얼마나 기다렸는지, 서킷 브레이커 상태 전이를 함께 남겨 두면 “특정 서버에서만 RST가 온다”거나 “매번 정확히 60초 idle 뒤에 끊긴다” 같은 패턴을 찾을 수 있습니다. 패턴이 보이면 아래 도구로 패킷과 시스템 콜 수준에서 확인합니다.

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'

아무도 listen하지 않는 포트(9번)에 connect한 뒤 send하는 최소 클라이언트에 이 명령을 쓰면 아래처럼 errno 이름이 그대로 보입니다(Ubuntu 24.04, strace 기준). connect가 실패한 소켓에 그대로 send를 하면 ECONNREFUSED가 아니라 EPIPE가 나오므로, 로그에는 처음 실패한 connect의 errno를 남겨야 합니다.

connect(3, {sa_family=AF_INET, sin_port=htons(9), sin_addr=inet_addr("127.0.0.1")}, 16) = -1 ECONNREFUSED (Connection refused)
sendto(3, "x", 1, 0, NULL, 0)           = -1 EPIPE (Broken pipe)

자주 묻는 질문 (FAQ)

ECONNRESET이 자주 나면?

상대 서버, 방화벽, 로드밸런서의 idle timeout이 클라이언트 연결 풀의 유지 시간보다 짧은지 먼저 확인하세요. 풀의 idle 연결 최대 유지 시간을 그보다 짧게 잡거나, TCP keepalive 간격을 줄여 연결을 유지할 수 있습니다.

Asio와 raw socket 중 무엇을 써야 하나요?

Asio는 비동기 모델과 타이머, error_code 기반 에러 처리를 제공해 많은 연결과 타임아웃을 다루기 편합니다. raw socket은 의존성이 없고 동작을 세밀하게 제어할 수 있지만, 이 글에서 다룬 EINTR, 부분 전송, 타임아웃을 모두 직접 처리해야 합니다. 동시 연결이 많은 서버라면 Asio 같은 이벤트 루프 라이브러리가, 의존성을 줄여야 하는 작은 클라이언트라면 raw socket이 맞는 경우가 많습니다.

다음 글: [C++ 실전 가이드 #29-1] Asio 라이브러리 입문: io_context와 비동기 연산


같이 보면 좋은 글