C++ 네트워크 에러 완벽 가이드 | errno·타임아웃·재시도·서킷브레이커 [#28-3]

C++ 네트워크 에러 완벽 가이드 | errno·타임아웃·재시도·서킷브레이커 [#28-3]

이 글의 핵심

프로덕션에서 간헐적 연결 끊김을 해결하는 방법. errno(EINTR, EAGAIN, ECONNRESET, ETIMEDOUT) 구분, SO_RCVTIMEO·select·Asio 타임아웃, 지수 백오프·서킷브레이커, 모니터링·디버깅까지 실전 코드로 구현합니다.

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

실제 겪는 문제 시나리오

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

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

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

추가 문제 시나리오

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

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

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

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

시나리오 5: 타임아웃 없는 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

개념을 잡는 비유

소켓과 비동기 I/O는 우편함 주소와 배달 경로로 이해하면 편합니다. 주소(IP·포트)만 맞으면 데이터가 들어오고, Asio는 한 우체국에서 여러 배달부(스레드·핸들러)가 일을 나누는 구조로 보시면 됩니다.


목차

  1. 소켓 에러 코드와 errno 처리
  2. 타임아웃 구현 (SO_RCVTIMEO, select, Asio)
  3. 재시도 전략 (지수 백오프, 지터)
  4. 서킷브레이커 패턴
  5. 자주 발생하는 네트워크 에러와 해결법
  6. EINTR과 부분 읽기/쓰기
  7. 베스트 프랙티스
  8. 프로덕션 패턴
  9. 모니터링과 로깅
  10. 프로덕션 디버깅 가이드

1. 소켓 에러 코드와 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 → 재시도
    }
}

2. 타임아웃 구현

방법 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_SNDTIMEOconnect()에는 적용되지 않습니다. 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 의존

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

지수 백오프란?

실패 시 대기 시간을 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 방지

4. 서킷브레이커 패턴

개요

연속 실패가 일정 횟수 이상이면 일정 시간 요청을 차단하여 실패한 서버에 대한 요청 폭주를 막음.

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

5. 자주 발생하는 네트워크 에러와 해결법

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
ETIMEDOUTWARN
ECONNREFUSEDWARN
EPIPEWARN
ENETUNREACHWARN

6. 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);
}

7. 베스트 프랙티스

1. 항상 errno 로깅

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

2. 실패 시 fd 반드시 닫기

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

3. SIGPIPE 무시 또는 MSG_NOSIGNAL

// 프로그램 시작 시
signal(SIGPIPE, SIG_IGN);

// 또는 send 시
send(fd, data, len, MSG_NOSIGNAL);

4. 타임아웃 항상 설정

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

5. 재시도 가능 errno만 재시도

ECONNREFUSED, ETIMEDOUT은 재시도. ECONNRESET은 새 연결 생성 후 재시도. EPIPE는 재시도하지 않음(연결 이미 끊김).

6. 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;
};

8. 프로덕션 패턴

패턴 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;
    // 처리...
}

9. 모니터링과 로깅

구조화된 로깅

#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";
    }
};

10. 프로덕션 디버깅 가이드

체크리스트

  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로 구현

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ HTTP 클라이언트·서버 완벽 가이드 | Beast·파싱·Keep-Alive·청크 인코딩
  • C++ Boost.Asio 입문 | io_context·async_read
  • C++ 비동기 I/O 이벤트 루프 완벽 가이드 | Asio run·post

이 글에서 다루는 키워드 (관련 검색어)

C++ 네트워크 에러, errno 처리, ECONNRESET, ETIMEDOUT, 지수 백오프, 서킷브레이커 등으로 검색하시면 이 글이 도움이 됩니다.


정리

항목내용
errnoEINTR/EAGAIN 재시도, ECONNRESET/ETIMEDOUT/EPIPE 구분
타임아웃SO_RCVTIMEO, select, connect_with_timeout, Asio deadline_timer
재시도지수 백오프, 지터, 최대 횟수·최대 대기 제한
서킷브레이커연속 실패 시 Open → timeout 후 HalfOpen
부분 I/Osend_all/recv_exact 루프로 전부 처리
프로덕션연결 풀 헬스 체크, TCP keepalive, 통합 에러 핸들러
모니터링구조화 로깅, 메트릭 수집

체크리스트

구현 체크리스트

  • errno별 분기 (EINTR, EAGAIN, ECONNRESET, ETIMEDOUT, EPIPE)
  • SO_RCVTIMEO/SO_SNDTIMEO 또는 select/Asio 타임아웃
  • connect 타임아웃 (논블로킹 + select)
  • 지수 백오프 재시도 (초기 1초, 최대 30초)
  • 지터 추가 (thundering herd 방지)
  • 서킷브레이커 (실패 5회 → 30초 차단)
  • send_all/recv_exact (부분 I/O 처리)
  • SIGPIPE 무시 또는 MSG_NOSIGNAL

프로덕션 체크리스트

  • 실패 시 errno, host:port 로깅
  • 메트릭 (connect_failed, timeout, retry)
  • TCP keepalive (idle timeout 대응)
  • 연결 풀 헬스 체크
  • tcpdump/strace 디버깅 방법 문서화

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. 소켓 에러 코드, connect/recv/send 실패 처리, 타임아웃 설정, 재시도 전략, 서킷브레이커, 모니터링·디버깅 패턴을 다룹니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

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와 비동기 연산


관련 글

  • C++ 소켓 프로그래밍 완벽 가이드 | TCP/UDP·소켓 옵션·논블로킹·에러 처리 [#28-1]
  • C++ HTTP 클라이언트·서버 완벽 가이드 | Beast·파싱·Keep-Alive·청크 인코딩
  • C++ Boost.Asio 입문 | io_context·async_read
  • C++ Boost 라이브러리 | Asio·Filesystem·Regex·설치부터 프로덕션까지 완벽 가이드
  • C++ JSON 처리 | nlohmann/json으로 파싱과 생성하기 [#27-2]