C++ 소켓 프로그래밍 완벽 가이드 | TCP/UDP·소켓 옵션·논블로킹·에러 처리 [#28-1]

C++ 소켓 프로그래밍 완벽 가이드 | TCP/UDP·소켓 옵션·논블로킹·에러 처리 [#28-1]

이 글의 핵심

네트워크 통신이 필요할 때 POSIX 소켓으로 TCP 클라이언트/서버 구현, SO_REUSEADDR·SO_KEEPALIVE, select/poll 논블로킹, EADDRINUSE·ECONNREFUSED 등 에러 처리, 연결 풀·타임아웃 프로덕션 패턴까지.

들어가며: “네트워크 통신이 필요해요”

실제 겪는 문제 시나리오

// ❌ 문제: 다른 서비스와 통신해야 하는데 어떻게 시작하지?
// - 게임 서버: 클라이언트가 접속하면 실시간으로 데이터 주고받기
// - 마이크로서비스: Order 서비스가 Payment 서비스에 HTTP 요청 보내기
// - IoT: 센서 데이터를 수집 서버로 전송
// - 채팅: 여러 클라이언트가 동시에 메시지 주고받기

// C++ 표준에는 네트워크 API가 없음 → OS가 제공하는 소켓 API 사용

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

  • 서버 재시작 시 “Address already in use”: 포트가 아직 TIME_WAIT 상태
  • 느린 상대 때문에 스레드 블로킹: recv에서 영원히 대기
  • 연결 끊김 감지 못 함: 상대가 죽었는데 모르고 send 시도 → EPIPE
  • 동시 다중 연결 처리: accept 하나로는 여러 클라이언트 동시 처리 불가

추가 문제 시나리오

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

시나리오 2: 게임 서버 동시 접속
100명이 동시에 접속하는 게임 서버에서 accept() 하나만 블로킹으로 사용하면, 한 클라이언트 처리 중에는 다른 99명이 대기합니다. 논블로킹 + select/poll/epoll로 멀티플렉싱하지 않으면 사용자 경험이 크게 저하됩니다.

시나리오 3: IoT 센서 데이터 수집
센서가 1분마다 데이터를 전송합니다. 서버가 recv()에서 블로킹하고 타임아웃이 없으면, 센서가 비정상 종료했을 때 수집 스레드가 영원히 대기합니다. SO_RCVTIMEO 또는 select 타임아웃으로 죽은 연결을 감지해야 합니다.

시나리오 4: 채팅 서버 연결 풀
클라이언트가 메시지 서버에 반복 연결합니다. 매 요청마다 socket()connect()send()close()를 하면 3-way handshake 비용이 누적됩니다. 연결 풀로 유휴 연결을 재사용하면 지연과 CPU 사용량을 줄일 수 있습니다.

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

해결책:

  1. 소켓 옵션: SO_REUSEADDR, SO_KEEPALIVE로 재시작·연결 상태 감지
  2. 논블로킹 + select/poll: 한 스레드에서 여러 소켓 동시 대기
  3. 에러 처리: EADDRINUSE, ECONNREFUSED, EINTR 구분
  4. 프로덕션 패턴: 연결 풀, 타임아웃, graceful shutdown

목표:

  • TCP 클라이언트/서버 완전 구현 (에러 처리 포함)
  • 소켓 옵션 (SO_REUSEADDR, SO_KEEPALIVE, SO_RCVTIMEO 등)
  • 논블로킹 소켓select/poll 멀티플렉싱
  • 자주 발생하는 에러와 해결법
  • 성능 팁프로덕션 패턴

요구 환경: POSIX 소켓 사용 예제는 Linux 또는 macOS에서 g++/Clang으로 빌드·실행. Windows에서는 WSL 사용 또는 Winsock으로 별도 구현 필요.

이 글을 읽으면:

  • socket, connect, bind, listen, accept, send, recv의 동작을 이해할 수 있습니다.
  • 실전에서 바로 쓸 수 있는 TCP 클라이언트/서버 코드를 작성할 수 있습니다.
  • Asio 같은 라이브러리를 쓰기 전 기초를 탄탄히 할 수 있습니다.

개념을 잡는 비유

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


목차

  1. TCP 클라이언트/서버 완전 구현
  2. 소켓 옵션 (SO_REUSEADDR, SO_KEEPALIVE 등)
  3. 논블로킹 소켓과 select/poll
  4. 자주 발생하는 에러와 해결법
  5. 성능 최적화 팁
  6. 베스트 프랙티스
  7. 프로덕션 패턴 (연결 풀, 타임아웃)
  8. UDP와 getaddrinfo

1. TCP 클라이언트/서버 완전 구현

TCP 연결 흐름

sequenceDiagram
  participant C as 클라이언트
  participant S as 서버
  S->>S: socket → setsockopt → bind → listen
  C->>S: connect (3-way handshake)
  S->>S: accept (새 clientFd 반환)
  Note over C,S: 연결 수립
  C->>S: send / recv
  S->>C: recv / send
  C->>C: close
  S->>S: close(clientFd)

1.1 TCP 서버 (에러 처리 포함)

서버는 listen 소켓클라이언트 소켓을 구분합니다. listen 소켓은 accept 전용, 각 클라이언트마다 accept가 반환하는 fd로 통신합니다.

// 복사해 붙여넣은 뒤: g++ -std=c++17 -o tcp_server tcp_server.cpp && ./tcp_server
// 터미널 1: ./tcp_server
// 터미널 2: echo "hello" | nc localhost 8080
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <iostream>
#include <string>

int main() {
    // 1. 소켓 생성
    int listenFd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenFd < 0) {
        perror("socket");
        return 1;
    }

    // 2. SO_REUSEADDR: 재시작 시 "Address already in use" 방지
    int opt = 1;
    if (setsockopt(listenFd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) {
        perror("setsockopt SO_REUSEADDR");
        close(listenFd);
        return 1;
    }

    // 3. 주소 바인딩
    sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_port = htons(8080);
    addr.sin_addr.s_addr = INADDR_ANY;  // 모든 인터페이스에서 수신

    if (bind(listenFd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0) {
        perror("bind");
        close(listenFd);
        return 1;
    }

    // 4. 대기 큐 설정 (backlog)
    if (listen(listenFd, 5) < 0) {
        perror("listen");
        close(listenFd);
        return 1;
    }

    std::cout << "Server listening on 0.0.0.0:8080\n";

    // 5. 클라이언트 연결 수락 (블로킹)
    sockaddr_in clientAddr{};
    socklen_t clientLen = sizeof(clientAddr);
    int clientFd = accept(listenFd, reinterpret_cast<sockaddr*>(&clientAddr), &clientLen);
    if (clientFd < 0) {
        perror("accept");
        close(listenFd);
        return 1;
    }

    // 클라이언트 IP 출력
    char clientIP[INET_ADDRSTRLEN];
    inet_ntop(AF_INET, &clientAddr.sin_addr, clientIP, sizeof(clientIP));
    std::cout << "Client connected: " << clientIP << ":" << ntohs(clientAddr.sin_port) << "\n";

    // 6. 에코: 받은 데이터 그대로 전송
    char buf[4096];
    ssize_t n;
    while ((n = recv(clientFd, buf, sizeof(buf), 0)) > 0) {
        ssize_t sent = 0;
        while (sent < n) {
            ssize_t w = send(clientFd, buf + sent, n - sent, 0);
            if (w < 0) {
                perror("send");
                break;
            }
            sent += w;
        }
    }
    if (n < 0) perror("recv");

    close(clientFd);
    close(listenFd);
    return 0;
}

핵심 포인트:

  • send 루프: send는 한 번에 전부 보내지 않을 수 있음. 남은 바이트만큼 반복 전송.
  • recv 반환값: 0이면 상대가 연결 종료(graceful shutdown), -1이면 errno 확인.
  • SO_REUSEADDR: 서버 재시작 시 이전 연결이 TIME_WAIT 상태여도 같은 포트 사용 가능.

1.2 TCP 클라이언트 (에러 처리 포함)

// 복사해 붙여넣은 뒤: g++ -std=c++17 -o tcp_client tcp_client.cpp && ./tcp_client
// 서버가 먼저 실행 중이어야 함: ./tcp_server
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <iostream>

int main() {
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd < 0) {
        perror("socket");
        return 1;
    }

    sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_port = htons(8080);
    if (inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) <= 0) {
        perror("inet_pton");
        close(fd);
        return 1;
    }

    if (connect(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0) {
        perror("connect");  // ECONNREFUSED: 서버 없음, ETIMEDOUT: 타임아웃
        close(fd);
        return 1;
    }

    const char* msg = "Hello, Server!\n";
    ssize_t total = strlen(msg);
    ssize_t sent = 0;
    while (sent < total) {
        ssize_t n = send(fd, msg + sent, total - sent, 0);
        if (n < 0) {
            perror("send");
            close(fd);
            return 1;
        }
        sent += n;
    }

    char buf[256];
    ssize_t n = recv(fd, buf, sizeof(buf) - 1, 0);
    if (n > 0) {
        buf[n] = '\0';
        std::cout << "Received: " << buf;
    } else if (n < 0) {
        perror("recv");
    }

    close(fd);
    return 0;
}

실행 순서:

  1. 터미널 1: ./tcp_server
  2. 터미널 2: ./tcp_client 또는 echo "hello" | nc localhost 8080

1.3 send/recv 완전 구현 (EINTR 재시도 포함)

실제 프로덕션에서는 send/recv가 EINTR로 중단될 수 있으므로, 재시도 가능한 래퍼를 사용하는 것이 좋습니다.

// sendAll: 전체 바이트 전송 보장, EINTR 재시도, EPIPE 처리
#include <sys/socket.h>
#include <cerrno>
#include <cstring>

ssize_t sendAll(int fd, const void* buf, size_t len, int flags = 0) {
    const char* p = static_cast<const char*>(buf);
    size_t sent = 0;
    while (sent < len) {
        ssize_t n = send(fd, p + sent, len - sent, flags | MSG_NOSIGNAL);
        if (n > 0) {
            sent += n;
        } else if (n < 0) {
            if (errno == EINTR) continue;  // 시그널로 중단 → 재시도
            if (errno == EAGAIN || errno == EWOULDBLOCK) return -1;  // 논블로킹
            return -1;  // EPIPE, ECONNRESET 등
        }
    }
    return static_cast<ssize_t>(sent);
}

// recvWithRetry: EINTR 재시도
ssize_t recvWithRetry(int fd, void* buf, size_t len, int flags = 0) {
    ssize_t n;
    do {
        n = recv(fd, buf, len, flags);
    } while (n < 0 && errno == EINTR);
    return n;
}

핵심 포인트:

  • MSG_NOSIGNAL: Linux에서 SIGPIPE 방지. macOS는 SO_NOSIGPIPE 사용.
  • recv 반환값: 0이면 상대가 graceful shutdown, -1이면 errno 확인.
  • sendAll: 부분 전송 시 남은 바이트만큼 반복. EINTR이면 재시도.

1.4 getaddrinfo 기반 TCP 클라이언트 (호스트명·포트 문자열)

// 호스트명과 포트로 연결, IPv4/IPv6 자동 처리
#include <sys/socket.h>
#include <netdb.h>
#include <unistd.h>
#include <cstring>
#include <iostream>

int connectToHost(const char* host, const char* port) {
    addrinfo hints{}, *result = nullptr;
    hints.ai_family = AF_INET;
    hints.ai_socktype = SOCK_STREAM;

    int ret = getaddrinfo(host, port, &hints, &result);
    if (ret != 0) {
        std::cerr << "getaddrinfo: " << gai_strerror(ret) << "\n";
        return -1;
    }

    int fd = -1;
    for (addrinfo* rp = result; rp != nullptr; rp = rp->ai_next) {
        fd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
        if (fd < 0) continue;
        if (connect(fd, rp->ai_addr, rp->ai_addrlen) == 0) break;
        close(fd);
        fd = -1;
    }
    freeaddrinfo(result);
    return fd;
}

int main() {
    int fd = connectToHost("localhost", "8080");
    if (fd < 0) return 1;
    const char* msg = "Hello, Server!\n";
    send(fd, msg, strlen(msg), MSG_NOSIGNAL);
    close(fd);
    return 0;
}

2. 소켓 옵션 (SO_REUSEADDR, SO_KEEPALIVE 등)

주요 소켓 옵션 요약

옵션레벨용도
SO_REUSEADDRSOL_SOCKET같은 포트 즉시 재바인딩 (서버 재시작)
SO_REUSEPORTSOL_SOCKET여러 프로세스가 같은 포트 listen (Linux 3.9+)
SO_KEEPALIVESOL_SOCKETTCP keepalive 프로브로 죽은 연결 감지
SO_RCVTIMEOSOL_SOCKETrecv 타임아웃 (초 단위)
SO_SNDTIMEOSOL_SOCKETsend 타임아웃
TCP_NODELAYIPPROTO_TCPNagle 비활성화 (지연 감소)

2.1 SO_REUSEADDR

서버를 종료하면 소켓이 TIME_WAIT 상태로 2MSL(보통 1~4분) 동안 남습니다. 이 동안 같은 포트로 bind하면 EADDRINUSE가 발생합니다.

// SO_REUSEADDR 설정
int opt = 1;
setsockopt(listenFd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 이제 TIME_WAIT 상태의 포트도 재사용 가능

주의: SO_REUSEADDR은 “이미 사용 중인 주소 재사용”을 허용합니다. 보안상 주의가 필요할 수 있으나, 로컬 개발·서버 재시작에는 거의 필수입니다.

2.2 SO_KEEPALIVE

연결된 상대가 갑자기 죽었을 때(전원 끊김, 네트워크 단절) 감지합니다. TCP keepalive 프로브를 주기적으로 보내고, 응답 없으면 연결 끊김으로 판단합니다.

// SO_KEEPALIVE 활성화
int opt = 1;
setsockopt(clientFd, SOL_SOCKET, SO_KEEPALIVE, &opt, sizeof(opt));

// Linux에서 keepalive 파라미터 조정 (선택)
// /proc/sys/net/ipv4/tcp_keepalive_* 또는 setsockopt TCP_KEEPIDLE, TCP_KEEPINTVL, TCP_KEEPCNT

기본 동작: 대부분 OS에서 기본값은 꽤 김(tcp_keepalive_time 7200초 등). 애플리케이션 레벨에서 주기적 ping/pong을 구현하는 경우도 많습니다.

2.3 SO_RCVTIMEO / SO_SNDTIMEO

recv/send 블로킹 시간을 제한합니다. struct timeval로 초·마이크로초 단위 지정.

#include <sys/time.h>

// 5초 타임아웃
struct timeval tv;
tv.tv_sec = 5;
tv.tv_usec = 0;
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));

// 타임아웃 시 recv/send가 -1 반환, errno == EAGAIN (또는 EWOULDBLOCK)

2.4 TCP_NODELAY

Nagle 알고리즘을 끕니다. 작은 패킷을 바로 보내고 싶을 때(게임, 실시간 채팅) 유용합니다.

int opt = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt));

2.5 소켓 옵션 적용 예시

void configureServerSocket(int fd) {
    int opt = 1;
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct timeval tv;
    tv.tv_sec = 30;
    tv.tv_usec = 0;
    setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
    setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
}

void configureClientSocket(int fd) {
    int opt = 1;
    setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &opt, sizeof(opt));
    setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt));
}

3. 논블로킹 소켓과 select/poll

3.1 왜 논블로킹이 필요한가?

flowchart LR
  subgraph blocking["블로킹"]
    B1[accept] --> B2[대기...]
    B2 --> B3[1명만 처리]
  end
  subgraph nonblocking["논블로킹 + select"]
    N1[여러 fd] --> N2[select 대기]
    N2 --> N3[준비된 fd만 처리]
  end
  • 블로킹: accept/recv에서 한 연결만 처리하는 동안 다른 연결 대기 불가.
  • 논블로킹 + select/poll: 한 스레드에서 여러 소켓을 동시에 감시하고, “읽기/쓰기 가능”한 fd만 처리.

3.2 fcntl로 논블로킹 설정

#include <fcntl.h>

int setNonBlocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags < 0) return -1;
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

// 사용
setNonBlocking(listenFd);
setNonBlocking(clientFd);
// 이제 accept, recv, send가 블로킹하지 않고, 준비 안 됐으면 -1, errno == EAGAIN

3.3 select로 멀티플렉싱

select는 여러 fd 중 “읽기/쓰기/예외” 가능한 것이 있는지 한 번에 확인합니다.

// 복사해 붙여넣은 뒤: g++ -std=c++17 -o select_server select_server.cpp && ./select_server
// 여러 클라이언트 동시 접속 가능 (nc localhost 8080 여러 터미널에서)
#include <sys/select.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <fcntl.h>
#include <cstring>
#include <iostream>
#include <vector>
#include <algorithm>

int setNonBlocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags < 0) return -1;
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main() {
    int listenFd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(listenFd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_port = htons(8080);
    addr.sin_addr.s_addr = INADDR_ANY;
    bind(listenFd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr));
    listen(listenFd, 5);

    setNonBlocking(listenFd);

    fd_set readFds;
    std::vector<int> clients;
    char buf[4096];

    std::cout << "Select server on 0.0.0.0:8080 (multiple clients)\n";

    while (true) {
        FD_ZERO(&readFds);
        FD_SET(listenFd, &readFds);
        int maxFd = listenFd;

        for (int c : clients) {
            FD_SET(c, &readFds);
            if (c > maxFd) maxFd = c;
        }

        struct timeval tv = {5, 0};  // 5초 타임아웃
        int nready = select(maxFd + 1, &readFds, nullptr, nullptr, &tv);
        if (nready < 0) {
            perror("select");
            break;
        }
        if (nready == 0) continue;  // 타임아웃

        // 새 연결
        if (FD_ISSET(listenFd, &readFds)) {
            sockaddr_in clientAddr{};
            socklen_t len = sizeof(clientAddr);
            int clientFd = accept(listenFd, reinterpret_cast<sockaddr*>(&clientAddr), &len);
            if (clientFd >= 0) {
                setNonBlocking(clientFd);
                clients.push_back(clientFd);
                std::cout << "Client " << clientFd << " connected\n";
            }
        }

        // 기존 클라이언트에서 데이터 수신
        for (auto it = clients.begin(); it != clients.end(); ) {
            int fd = *it;
            if (!FD_ISSET(fd, &readFds)) { ++it; continue; }

            ssize_t n = recv(fd, buf, sizeof(buf), 0);
            if (n > 0) {
                ssize_t sent = 0;
                while (sent < n) {
                    ssize_t w = send(fd, buf + sent, n - sent, 0);
                    if (w <= 0) break;
                    sent += w;
                }
            } else if (n == 0 || (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK)) {
                close(fd);
                it = clients.erase(it);
                continue;
            }
            ++it;
        }
    }

    for (int c : clients) close(c);
    close(listenFd);
    return 0;
}

select 한계:

  • fd 개수 제한 (FD_SETSIZE, 보통 1024)
  • fd_set이 매번 복사됨
  • O(n) 복잡도
  • poll 또는 epoll(Linux)이 더 확장성 좋음

3.4 poll로 멀티플렉싱

poll은 fd 배열을 넘기고, 각 fd의 이벤트를 struct pollfd로 관리합니다. select보다 사용이 직관적입니다.

#include <poll.h>

struct pollfd fds[64];
int nfds = 0;

// listen 소켓 등록
fds[0].fd = listenFd;
fds[0].events = POLLIN;
nfds = 1;

// poll 대기
int nready = poll(fds, nfds, 5000);  // 5초 타임아웃
if (nready < 0) {
    perror("poll");
    return -1;
}

for (int i = 0; i < nfds; ++i) {
    if (fds[i].revents & POLLIN) {
        if (fds[i].fd == listenFd) {
            // accept
        } else {
            // recv
        }
    }
    if (fds[i].revents & (POLLERR | POLLHUP)) {
        // 에러 또는 연결 끊김
    }
}

poll vs select:

  • poll은 fd 개수 제한이 없음 (배열 크기만큼)
  • revents로 “무슨 이벤트가 났는지” 명확히 구분
  • Linux에서는 epoll이 대량 연결에 더 적합

4. 자주 발생하는 에러와 해결법

4.1 EADDRINUSE (Address already in use)

원인: bind 시 해당 포트가 이미 사용 중. 서버 재시작 시 이전 소켓이 TIME_WAIT 상태.

해결:

// SO_REUSEADDR 설정 (bind 전에)
int opt = 1;
setsockopt(listenFd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
bind(listenFd, ...);

4.2 ECONNREFUSED (Connection refused)

원인: connect 시 대상 서버가 해당 포트에서 listen하지 않음.

❌ 잘못된 예:

// 에러 구분 없이 바로 실패 처리
if (connect(fd, ...) < 0) {
    perror("connect");
    return -1;  // ECONNREFUSED와 ETIMEDOUT 구분 못 함
}

✅ 올바른 예:

if (connect(fd, ...) < 0) {
    if (errno == ECONNREFUSED) {
        // 서버가 꺼져 있거나 해당 포트에서 listen 안 함
        // → 서버 상태 확인, 재시도 스케줄
    } else if (errno == ETIMEDOUT) {
        // 네트워크 경로 문제, 방화벽
    }
    return -1;
}

체크리스트: 서버 실행 여부, 방화벽/보안 그룹 포트 허용, 포트 번호 확인.

4.3 ETIMEDOUT (Connection timed out)

원인: connect 시 네트워크 경로 문제, 방화벽 차단, 상대 서버 부하.

해결:

  • 타임아웃 설정 후 재시도
  • 네트워크 경로 확인
// 논블로킹 + select로 connect 타임아웃 구현
setNonBlocking(fd);
connect(fd, ...);  // EINPROGRESS 반환 가능
// select로 fd가 쓰기 가능해질 때까지 대기, 일정 시간 초과 시 ETIMEDOUT 처리

4.4 EPIPE (Broken pipe)

원인: 상대가 연결을 끊었는데 send 시도. 기본 동작은 SIGPIPE로 프로세스 종료.

❌ 잘못된 예:

// 상대가 이미 close했는데 send → SIGPIPE로 프로세스 죽음
send(fd, data, len, 0);  // 💥 프로세스 종료 가능

✅ 올바른 예:

// Linux: MSG_NOSIGNAL로 SIGPIPE 방지
send(fd, data, len, MSG_NOSIGNAL);

// macOS/BSD: SO_NOSIGPIPE 소켓 옵션
#ifdef SO_NOSIGPIPE
int opt = 1;
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &opt, sizeof(opt));
#endif
// 이후 send() 사용

추가: recv가 0을 반환하면 상대가 graceful shutdown한 것 → send 중단.

4.5 EINTR (Interrupted system call)

원인: 블로킹 중 시그널(예: SIGCHLD, SIGTERM)에 의해 시스템 콜이 중단됨.

❌ 잘못된 예:

// EINTR을 에러로 처리 → graceful shutdown 시 문제
ssize_t n = recv(fd, buf, len, 0);
if (n < 0) {
    perror("recv");  // EINTR도 여기서 잘못된 에러 처리
    return -1;
}

✅ 올바른 예:

ssize_t recvWithRetry(int fd, void* buf, size_t len) {
    ssize_t n;
    do {
        n = recv(fd, buf, len, 0);
    } while (n < 0 && errno == EINTR);
    return n;
}

적용 대상: accept, connect, recv, send, read, write 등 모든 블로킹 I/O.

4.6 EAGAIN / EWOULDBLOCK

원인: 논블로킹 소켓에서 데이터가 아직 준비되지 않음 (recv) 또는 버퍼 가득 참 (send).

해결: 나중에 다시 시도. select/poll로 “준비됐을 때” 알림 받기.

if (recv(fd, buf, size, 0) < 0) {
    if (errno == EAGAIN || errno == EWOULDBLOCK) {
        // 나중에 다시 시도
        return 0;  // 또는 select/poll에 등록
    }
}

4.7 ECONNRESET (Connection reset by peer)

원인: 상대가 RST 패킷으로 연결을 강제 종료. 비정상 종료, 방화벽 중간 끊김 등.

ssize_t n = recv(fd, buf, size, 0);
if (n < 0 && errno == ECONNRESET) {
    // 상대가 비정상 종료 → fd 닫고 정리
    close(fd);
    return -1;
}

4.8 ENOTCONN (Transport endpoint is not connected)

원인: 연결되지 않은 소켓에 send/recv 시도.

해결: connect 성공 여부 확인, fd 상태 검증.

4.9 에러 처리 흐름도

flowchart TB
    Call[connect/send/recv]
    Check{반환값}
    Ok[성공]
    Fail[실패]
    Err[errno 확인]
    EINTR[EINTR: 재시도]
    EAGAIN[EAGAIN: 논블로킹, 나중에 재시도]
    EADDRINUSE[EADDRINUSE: SO_REUSEADDR]
    ECONNREFUSED[ECONNREFUSED: 서버 확인]
    EPIPE[EPIPE: 연결 끊김]
    Call --> Check
    Check -->|>=0| Ok
    Check -->|-1| Fail
    Fail --> Err
    Err --> EINTR
    Err --> EAGAIN
    Err --> EADDRINUSE
    Err --> ECONNREFUSED
    Err --> EPIPE

5. 성능 최적화 팁

5.1 버퍼 크기

  • 수신 버퍼: 너무 작으면 recv 호출 횟수 증가. 4KB~64KB 정도가 무난.
  • 송신 버퍼: 대량 전송 시 시스템 기본값이 작을 수 있음. SO_SNDBUF, SO_RCVBUF로 조정 가능.
int bufSize = 65536;  // 64KB
setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &bufSize, sizeof(bufSize));
setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &bufSize, sizeof(bufSize));

5.2 TCP_NODELAY

작은 메시지를 빠르게 보낼 때 Nagle이 지연을 유발. 실시간성이 중요하면 TCP_NODELAY 사용.

5.3 send/recv 루프

  • send: 반환값이 len보다 작을 수 있음. 남은 바이트만큼 반복 전송.
  • recv: 한 번에 전체가 오지 않을 수 있음. 프로토콜에 맞게 버퍼에 누적 후 파싱.

5.4 대량 연결 시

  • select: fd 수 적을 때 (수십 개)
  • poll: fd 수 중간 (수백 개)
  • epoll (Linux): 수천 개 이상
  • io_uring (Linux 5.1+): 최신 고성능 I/O

6. 베스트 프랙티스

6.1 소켓 생성 직후 설정 순서

// 권장 순서: socket → setsockopt → bind/connect
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) return -1;

// 1. 옵션 설정 (bind/connect 전에)
int opt = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

// 2. 서버: bind → listen
// 3. 클라이언트: connect

6.2 항상 반환값 검사

// ❌ 나쁜 예
send(fd, buf, len, 0);  // 부분 전송 무시

// ✅ 좋은 예
ssize_t sent = 0;
while (sent < len) {
    ssize_t n = send(fd, buf + sent, len - sent, MSG_NOSIGNAL);
    if (n < 0) { /* 에러 처리 */ break; }
    sent += n;
}

6.3 리소스 정리 (RAII 또는 명시적 close)

// fd를 스코프 끝에서 자동 close
class ScopedFd {
    int fd_;
public:
    explicit ScopedFd(int fd) : fd_(fd) {}
    ~ScopedFd() { if (fd_ >= 0) close(fd_); }
    int get() const { return fd_; }
};

6.4 프로토콜 설계

  • 고정 길이 헤더: 예) 4바이트 길이 + 페이로드. 파싱이 단순함.
  • 구분자 기반: 예) \r\n으로 메시지 경계. 텍스트 프로토콜에 적합.
  • 재전송/순서: TCP는 순서 보장하지만, 애플리케이션 레벨에서 시퀀스 번호를 두면 디버깅에 유리함.

6.5 로깅과 모니터링

// 연결/종료 시 로깅
std::cout << "Client " << clientFd << " from " << clientIP << " connected\n";
// 에러 시 errno와 함께 로깅
perror("recv");  // 또는 spdlog::error("recv: {}", strerror(errno));

7. 프로덕션 패턴 (연결 풀, 타임아웃)

7.1 연결 풀 (Connection Pool)

동일 서버에 반복 연결할 때, 연결을 재사용하면 3-way handshake 비용을 줄일 수 있습니다.

// 개념: 풀에 유휴 연결 보관, 요청 시 풀에서 꺼내 사용, 완료 후 반환
class ConnectionPool {
    std::vector<int> idle_;
    std::string host_;
    uint16_t port_;
    std::mutex mtx_;

public:
    int acquire() {
        std::lock_guard<std::mutex> lock(mtx_);
        if (!idle_.empty()) {
            int fd = idle_.back();
            idle_.pop_back();
            return fd;
        }
        return connectNew();  // 새 연결 생성
    }

    void release(int fd) {
        std::lock_guard<std::mutex> lock(mtx_);
        if (idle_.size() < maxPoolSize)
            idle_.push_back(fd);
        else
            close(fd);
    }
};

7.2 타임아웃

SO_RCVTIMEO/SO_SNDTIMEO로 블로킹 타임아웃:

struct timeval tv;
tv.tv_sec = 10;
tv.tv_usec = 0;
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));

connect 타임아웃: 논블로킹 + select로 구현. 네트워크 에러 글에서 상세 다룸.

7.3 connect 타임아웃 (논블로킹 + select)

// connect에 타임아웃 적용: 논블로킹 소켓 + select
#include <fcntl.h>
#include <sys/select.h>

int connectWithTimeout(int fd, const sockaddr* addr, socklen_t len, int timeout_sec) {
    setNonBlocking(fd);
    int ret = connect(fd, addr, len);
    if (ret == 0) return 0;  // 즉시 연결됨
    if (ret < 0 && errno != EINPROGRESS) return -1;

    fd_set wfds;
    FD_ZERO(&wfds);
    FD_SET(fd, &wfds);
    struct timeval tv = {timeout_sec, 0};
    ret = select(fd + 1, nullptr, &wfds, nullptr, &tv);
    if (ret <= 0) return -1;  // 타임아웃 또는 에러

    int err = 0;
    socklen_t errlen = sizeof(err);
    if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &errlen) < 0 || err != 0)
        return -1;
    return 0;
}

7.4 Graceful Shutdown

// 1. 더 이상 받지 않음
shutdown(fd, SHUT_RD);

// 2. 남은 데이터 전송
send(fd, ...);

// 3. 전송 완료 후 종료
shutdown(fd, SHUT_WR);

// 4. 상대의 FIN 수신 대기
while (recv(fd, buf, size, 0) > 0) {}

// 5. close
close(fd);

7.5 Heartbeat (연결 유지)

// 주기적 ping/pong으로 죽은 연결 감지 (SO_KEEPALIVE 대안)
// 클라이언트: 30초마다 PING 전송
// 서버: PING 수신 시 PONG 응답, 90초 응답 없으면 연결 종료
void sendHeartbeat(int fd) {
    const char* ping = "PING\n";
    send(fd, ping, 5, MSG_NOSIGNAL);
}

7.6 구현 체크리스트

  • SO_REUSEADDR (서버 리스닝 소켓)
  • SO_KEEPALIVE 또는 애플리케이션 레벨 heartbeat
  • SO_RCVTIMEO/SO_SNDTIMEO 또는 select/poll 타임아웃
  • send/recv 반환값 처리 (부분 전송·수신)
  • EINTR 재시도
  • EPIPE/SIGPIPE 처리
  • 연결 풀 (동일 서버 반복 연결 시)

8. UDP와 getaddrinfo

8.1 UDP 기본

SOCK_DGRAM은 UDP 소켓입니다. connect 없이 sendto에 목적지 주소를 넘깁니다.

// UDP 송신
int fd = socket(AF_INET, SOCK_DGRAM, 0);
sockaddr_in to{};
to.sin_family = AF_INET;
to.sin_port = htons(8080);
inet_pton(AF_INET, "127.0.0.1", &to.sin_addr);
sendto(fd, data, len, 0, reinterpret_cast<sockaddr*>(&to), sizeof(to));

// UDP 수신
sockaddr_in from{};
socklen_t fromLen = sizeof(from);
ssize_t n = recvfrom(fd, buf, sizeof(buf), 0,
    reinterpret_cast<sockaddr*>(&from), &fromLen);

8.2 getaddrinfo

호스트명·포트 문자열을 sockaddr 형태로 변환합니다. IPv4/IPv6를 일관되게 다룰 수 있습니다.

#include <netdb.h>

addrinfo hints{}, *result = nullptr;
hints.ai_family = AF_INET;       // IPv4
hints.ai_socktype = SOCK_STREAM; // TCP
hints.ai_flags = AI_PASSIVE;      // 서버용 (bind)

int ret = getaddrinfo(nullptr, "8080", &hints, &result);
if (ret != 0) {
    fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(ret));
    return -1;
}

for (addrinfo* rp = result; rp != nullptr; rp = rp->ai_next) {
    int fd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
    if (fd < 0) continue;
    if (bind(fd, rp->ai_addr, rp->ai_addrlen) == 0) {
        // 성공
        freeaddrinfo(result);
        return fd;
    }
    close(fd);
}
freeaddrinfo(result);
return -1;

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

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

  • C++ HTTP 클라이언트·서버 완벽 가이드 | Beast·파싱·Keep-Alive·청크 인코딩
  • C++ 네트워크 에러 완벽 가이드 | errno·타임아웃·재시도·서킷브레이커 [#28-3]
  • C++ Boost.Asio 입문 | io_context·async_read

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

C++ 소켓, socket, TCP 서버, select poll, SO_REUSEADDR, 논블로킹 소켓, 네트워크 프로그래밍 등으로 검색하시면 이 글이 도움이 됩니다.

정리

항목내용
TCP 서버socket → setsockopt → bind → listen → accept
TCP 클라이언트socket → connect → send/recv (getaddrinfo로 호스트명 지원)
send/recvsendAll(부분 전송·EINTR 재시도), recvWithRetry(EINTR)
소켓 옵션SO_REUSEADDR, SO_KEEPALIVE, SO_RCVTIMEO, TCP_NODELAY
논블로킹fcntl O_NONBLOCK + select/poll
자주 나는 에러EADDRINUSE, ECONNREFUSED, EPIPE, EINTR, EAGAIN, ECONNRESET
베스트 프랙티스반환값 검사, RAII, 프로토콜 설계, 로깅
프로덕션연결 풀, 타임아웃, connect 타임아웃, graceful shutdown, heartbeat

자주 묻는 질문 (FAQ)

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

A. 마이크로서비스 통신, 채팅 서버, 게임 서버, IoT 디바이스 연동 등 네트워크 통신이 필요한 모든 C++ 프로젝트에서 사용합니다. Asio를 쓰더라도 소켓 기초 이해가 디버깅에 필수입니다.

Q. select vs poll vs epoll?

A. select는 fd 수 제한이 있고 레거시. poll은 select보다 나음. Linux에서 대량 연결이면 epoll, 최신 고성능이면 io_uring을 고려하세요.

Q. Boost.Asio를 써야 하나요?

A. 크로스 플랫폼, 비동기, 타이머, SSL 등이 필요하면 Asio가 편합니다. 소켓 동작 원리를 이해하려면 한 번은 POSIX 소켓으로 직접 구현해 보는 것이 좋습니다.

한 줄 요약: socket·connect·send/recv·소켓 옵션·select/poll로 TCP 기초를 익히면 Asio 이해에 도움이 됩니다. 다음으로 HTTP 클라이언트/서버(#28-2)를 읽어보면 좋습니다.

다음 글: [C++ 실전 가이드 #28-2] HTTP 클라이언트/서버 구현: 요청 파싱과 응답 생성

이전 글: [C++ 실전 가이드 #27-3] 로깅 라이브러리 (spdlog): 빠른 로깅과 다중 싱크


관련 글

  • C++ HTTP 클라이언트·서버 완벽 가이드 | Beast·파싱·Keep-Alive·청크 인코딩
  • C++ 네트워크 에러 완벽 가이드 | errno·타임아웃·재시도·서킷브레이커 [#28-3]
  • C++ Boost.Asio 입문 | io_context·async_read
  • C++ 네트워크 |
  • C++ Boost 라이브러리 | Asio·Filesystem·Regex·설치부터 프로덕션까지 완벽 가이드