C++ 소켓 프로그래밍: TCP/UDP 구현, SO_REUSEADDR 같은 소켓 옵션, select·poll·epoll 비교
들어가며: 표준에 없는 네트워크 API
C++ 표준 라이브러리에는 네트워크 API가 없어서, TCP/UDP 통신은 OS가 제공하는 소켓 API(POSIX에서는 BSD 소켓)를 직접 쓰거나 이를 감싼 라이브러리를 씁니다. 소켓 API는 몇 개의 함수만 알면 동작하는 예제를 금방 만들 수 있지만, 실제 서버에서는 예제에서 보이지 않던 문제가 드러납니다. 서버를 재시작하면 “Address already in use”로 bind가 실패하고, 응답하지 않는 상대 때문에 recv가 영원히 블로킹되며, 상대가 이미 끊은 연결에 send하면 SIGPIPE로 프로세스가 죽습니다. 블로킹 accept 하나로는 여러 클라이언트를 동시에 처리할 수도 없습니다.
socket·bind·listen·accept·connect·send·recv로 에러 처리까지 갖춘 TCP 클라이언트/서버를 직접 작성하고, SO_REUSEADDR·SO_KEEPALIVE·SO_RCVTIMEO 같은 소켓 옵션과 논블로킹 소켓 + select/poll 멀티플렉싱을 다룹니다. Asio 같은 라이브러리를 쓰기 전에 알아 두면 좋은 기초입니다.
요구 환경: POSIX 소켓 사용 예제는 Linux 또는 macOS에서 g++/Clang으로 빌드·실행. Windows에서는 WSL 사용 또는 Winsock으로 별도 구현 필요.
TCP 클라이언트와 서버 구현
그림: 서버는 socket, setsockopt, bind, listen으로 준비한 뒤 accept에서 기다리고, 클라이언트의 connect가 3-way handshake로 연결을 맺는다. 이후 통신은 accept가 돌려준 새 fd로 한다.
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)
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는 요청한 바이트를 한 번에 다 보낸다는 보장이 없으므로, 반환값만큼 오프셋을 옮기며 남은 바이트를 반복해서 보냅니다. recv가 0을 반환하면 상대가 송신 방향을 정상 종료(FIN)했다는 뜻이고, -1이면 errno로 원인을 확인합니다. 이 서버는 한 클라이언트만 처리하고 끝나는 최소 예제이며, 여러 클라이언트를 다루는 방법은 뒤의 select 절에서 봅니다.
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:
./tcp_server - 터미널 2:
./tcp_client또는echo "hello" | nc localhost 8080
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은 끊긴 연결에 send했을 때 SIGPIPE 대신 EPIPE를 돌려받게 하는 Linux 플래그입니다. 예전 macOS에는 이 상수가 없어 컴파일되지 않으므로, macOS에서는 소켓에 SO_NOSIGPIPE 옵션을 켜거나 프로세스 전체에서 signal(SIGPIPE, SIG_IGN)으로 무시합니다. 위 sendAll은 단순화를 위해 실패하면 -1만 돌려주는데, 논블로킹 소켓에서 EAGAIN이 나면 그때까지 보낸 바이트 수를 잃게 되므로 실제로는 보낸 양을 함께 반환하도록 만드는 편이 좋습니다.
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_UNSPEC; // IPv4/IPv6 모두 허용
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;
}
소켓 옵션 (SO_REUSEADDR, SO_KEEPALIVE 등)
주요 소켓 옵션 요약
| 옵션 | 레벨 | 용도 |
|---|---|---|
| SO_REUSEADDR | SOL_SOCKET | 같은 포트 즉시 재바인딩 (서버 재시작) |
| SO_REUSEPORT | SOL_SOCKET | 여러 프로세스가 같은 포트 listen (Linux 3.9+) |
| SO_KEEPALIVE | SOL_SOCKET | TCP keepalive 프로브로 죽은 연결 감지 |
| SO_RCVTIMEO | SOL_SOCKET | recv 타임아웃 (struct timeval) |
| SO_SNDTIMEO | SOL_SOCKET | send 타임아웃 |
| TCP_NODELAY | IPPROTO_TCP | Nagle 비활성화 (지연 감소) |
SO_REUSEADDR
TCP 연결을 먼저 닫은 쪽은 TIME_WAIT 상태로 2MSL 동안 남습니다. 표준은 MSL을 2분으로 잡지만 구현마다 다르며, Linux는 TIME_WAIT를 60초로 고정해 둡니다. 서버를 재시작했을 때 이전 프로세스가 맺었던 연결이 TIME_WAIT로 남아 있으면, 같은 포트로 bind하는 순간 EADDRINUSE가 발생합니다.
// SO_REUSEADDR 설정
int opt = 1;
setsockopt(listenFd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 이제 TIME_WAIT 상태의 포트도 재사용 가능
Linux와 BSD에서 SO_REUSEADDR는 TIME_WAIT 연결이 남은 포트에 다시 bind하는 것을 허용할 뿐, 다른 프로세스가 이미 listen 중인 포트를 가로채게 해 주지는 않습니다. 그 경우에는 여전히 EADDRINUSE가 나므로, 이 에러가 계속된다면 ss -ltnp로 해당 포트를 쓰는 프로세스를 확인합니다. 반면 Windows의 SO_REUSEADDR는 의미가 달라서 다른 프로세스가 같은 포트에 bind하는 것까지 허용하므로, Windows 서버에서는 SO_EXCLUSIVEADDRUSE를 쓰는 것이 권장됩니다.
직접 확인했습니다(Ubuntu 24.04 컨테이너, g++ 13.3). 위 서버와 클라이언트를 그대로 컴파일해 실행하면 클라이언트가 Received: Hello, Server!를 받았고, 서버가 없을 때 클라이언트는 connect: Connection refused를 출력했습니다. 재시작 실험은 nc로 연결을 열어 둔 채 서버를 kill하고 곧바로 다시 실행하는 방식이었습니다. 이때 ss -tan에는 8080 쪽에 FIN-WAIT-2, 상대 쪽에 CLOSE-WAIT가 남았습니다. SO_REUSEADDR 호출을 SO_KEEPALIVE로 바꾼 서버는 bind: Address already in use로 실패했고, 원래 서버는 LISTEN 0.0.0.0:8080 상태로 다시 떴습니다. 즉 포트를 붙잡고 bind를 막는 것은 TIME_WAIT만이 아니라, 서버 쪽이 먼저 닫아 연결이 정리되지 않은 상태 전반입니다. 반대로 위 예제처럼 클라이언트가 먼저 닫고 서버가 따라 닫는 정상 흐름에서는 TIME_WAIT가 클라이언트 쪽 임시 포트에 남으므로, 이 서버는 바로 재시작해도 bind에 실패하지 않습니다.
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
Linux 기본값은 유휴 7200초(2시간) 뒤부터 75초 간격으로 9번 프로브를 보내는 것이라, 기본 설정만으로는 죽은 연결을 2시간 넘게 알아채지 못합니다. 그래서 TCP_KEEPIDLE 등으로 소켓별로 줄이거나, 애플리케이션 레벨에서 주기적 ping/pong을 구현하는 경우가 많습니다.
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)
TCP_NODELAY
Nagle 알고리즘을 끕니다. Nagle은 확인 응답(ACK)을 받지 못한 데이터가 있으면 작은 세그먼트를 모아 두었다가 보내므로, 상대의 지연 ACK와 겹치면 작은 요청 하나가 수십 밀리초씩 늦어질 수 있습니다. 작은 메시지를 바로 보내야 하는 게임이나 실시간 통신에서 켭니다.
int opt = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt));
소켓 옵션 적용 예시
void configureServerSocket(int fd) {
int opt = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 리스닝 소켓에 SO_RCVTIMEO를 걸면 Linux에서는 accept()의 대기 시간이 제한됨
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));
}
논블로킹 소켓과 select/poll
왜 논블로킹이 필요한가?
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만 처리.
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
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 <cerrno>
#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 >= FD_SETSIZE) {
close(clientFd); // fd_set에 넣을 수 없는 번호 → 거절
} else 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, MSG_NOSIGNAL);
if (w <= 0) break; // 단순화: EAGAIN이면 나머지를 버림 (실제로는 송신 버퍼에 보관)
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 번호입니다. fd_set은 고정 크기 비트맵이라 FD_SETSIZE(보통 1024) 이상의 번호를 FD_SET하면 메모리를 덮어쓰므로, 위 코드처럼 그런 연결은 거절해야 합니다. 또 select가 fd_set을 결과로 덮어쓰기 때문에 매번 집합을 다시 만들어야 하고, 커널과 애플리케이션 모두 감시하는 fd 전체를 훑어야 해서 비용이 fd 수에 비례합니다. 연결이 많아지면 poll이나 Linux의 epoll이 낫습니다.
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은 fd 번호 제한이 없고, 입력(events)과 결과(revents)가 분리되어 있어 매번 집합을 다시 만들 필요가 없습니다. revents로 어떤 이벤트가 났는지도 명확히 구분됩니다. 다만 호출마다 배열 전체를 커널에 넘기고 훑는 구조는 select와 같으므로, 수천 개 이상의 연결에서는 Linux의 epoll이 더 적합합니다.
EADDRINUSE부터 ENOTCONN까지: errno별 원인과 대응
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, ...);
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;
}
체크리스트: 서버 실행 여부, 방화벽/보안 그룹 포트 허용, 포트 번호 확인.
ETIMEDOUT (Connection timed out)
원인: connect 시 네트워크 경로 문제, 방화벽 차단, 상대 서버 부하. 해결:
- 타임아웃 설정 후 재시도
- 네트워크 경로 확인
// 논블로킹 + select로 connect 타임아웃 구현
setNonBlocking(fd);
connect(fd, ...); // EINPROGRESS 반환 가능
// select로 fd가 쓰기 가능해질 때까지 대기, 일정 시간 초과 시 ETIMEDOUT 처리
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을 반환했다는 것은 상대가 더 이상 보내지 않겠다고 FIN을 보냈다는 뜻입니다. 상대가 shutdown(SHUT_WR)으로 절반만 닫았다면 아직 받을 수는 있지만, 대부분의 프로토콜에서는 이 시점에 남은 응답만 보내고 연결을 정리합니다.
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, recv, send, read, write 같은 블로킹 호출은 이렇게 다시 호출하면 됩니다. 예외는 connect입니다. EINTR로 중단되어도 연결 시도는 커널에서 계속 진행되므로, 같은 connect를 다시 부르면 EALREADY나 EISCONN이 납니다. 이때는 poll로 쓰기 가능을 기다린 뒤 SO_ERROR로 결과를 확인합니다. 시그널 핸들러를 SA_RESTART 플래그로 등록하면 상당수 호출이 커널에서 자동 재시작되지만, SO_RCVTIMEO가 걸린 소켓 호출처럼 재시작되지 않는 경우도 있어 재시도 코드는 여전히 필요합니다.
EAGAIN / EWOULDBLOCK
원인: 논블로킹 소켓에서 데이터가 아직 준비되지 않음 (recv) 또는 버퍼 가득 참 (send). 해결: 나중에 다시 시도. select/poll로 “준비됐을 때” 알림 받기.
ssize_t n = recv(fd, buf, size, 0);
if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 아직 데이터 없음: 에러가 아님. select/poll로 읽기 가능을 기다린 뒤 다시 시도
return WouldBlock; // 0을 돌려주면 연결 종료(recv == 0)와 구분할 수 없으므로 별도 값 사용
}
}
ECONNRESET (Connection reset by peer)
원인: 상대가 RST 패킷으로 연결을 강제 종료. 비정상 종료, 방화벽 중간 끊김 등.
ssize_t n = recv(fd, buf, size, 0);
if (n < 0 && errno == ECONNRESET) {
// 상대가 비정상 종료 → fd 닫고 정리
close(fd);
return -1;
}
ENOTCONN (Transport endpoint is not connected)
원인: 연결되지 않은 소켓에 send/recv 시도. 해결: connect 성공 여부 확인, fd 상태 검증.
에러 처리 흐름도
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
버퍼 크기, TCP_NODELAY, send 루프: 처리량 튜닝
버퍼 크기
애플리케이션의 recv 버퍼는 너무 작으면 시스템 콜 횟수가 늘어나므로 수 KB에서 수십 KB 정도로 잡는 경우가 많습니다. 커널의 소켓 버퍼는 SO_RCVBUF, SO_SNDBUF로 조정할 수 있지만, Linux는 기본적으로 수신 버퍼를 자동 조정하므로 직접 고정하면 오히려 처리량이 줄 수도 있습니다. 직접 설정한다면 Linux가 지정한 값의 두 배를 잡는다는 점(관리용 오버헤드 포함), 그리고 TCP 윈도 스케일이 연결 수립 때 정해지므로 listen/connect 전에 설정해야 한다는 점을 알아 두어야 합니다.
int bufSize = 65536; // 64KB (Linux에서 getsockopt로 읽으면 두 배 값이 나옴)
setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &bufSize, sizeof(bufSize));
setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &bufSize, sizeof(bufSize));
send/recv 루프와 메시지 경계
TCP는 바이트 스트림이라 송신 측의 send 호출 단위가 수신 측 recv 단위와 일치하지 않습니다. 메시지 하나가 여러 번의 recv로 나뉘어 오거나 두 메시지가 한 번에 붙어 올 수 있으므로, 받은 데이터를 버퍼에 누적한 뒤 프로토콜 규칙(길이 헤더나 구분자)에 따라 잘라 파싱해야 합니다.
연결 수에 따른 선택
수십 개 정도의 연결이라면 select나 poll로 충분합니다. 수천 개 이상의 동시 연결을 Linux에서 다룬다면 epoll(BSD/macOS에서는 kqueue)이 맞고, 시스템 콜 횟수 자체를 줄이고 싶다면 Linux 5.1부터 제공되는 io_uring도 선택지입니다.
소켓 옵션 설정 순서, 반환값 검사, 메시지 경계 설계
소켓 생성 직후 설정 순서
// 권장 순서: 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
항상 반환값 검사
// ❌ 나쁜 예
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;
}
리소스 정리 (RAII 또는 명시적 close)
// fd를 스코프 끝에서 자동 close
class ScopedFd {
int fd_;
public:
explicit ScopedFd(int fd) : fd_(fd) {}
~ScopedFd() { if (fd_ >= 0) close(fd_); }
ScopedFd(const ScopedFd&) = delete; // 복사하면 같은 fd를 두 번 close
ScopedFd& operator=(const ScopedFd&) = delete;
ScopedFd(ScopedFd&& o) noexcept : fd_(o.fd_) { o.fd_ = -1; }
int get() const { return fd_; }
};
복사 생성을 막지 않으면 복사본과 원본이 각각 소멸하면서 같은 fd를 두 번 닫습니다. 두 번째 close는 그사이 다른 스레드가 같은 번호로 연 전혀 다른 파일이나 소켓을 닫을 수 있어, 재현하기 어려운 버그가 됩니다.
프로토콜 설계
메시지 경계를 정하는 가장 단순한 방법은 4바이트 길이 헤더 뒤에 페이로드를 붙이는 것입니다. 수신 측은 헤더를 먼저 읽어 몇 바이트를 더 기다려야 하는지 알 수 있지만, 길이 값은 신뢰할 수 없는 입력이므로 상한을 검사해야 합니다. 텍스트 프로토콜이라면 HTTP나 Redis처럼 \r\n 같은 구분자로 경계를 나누는 방식이 디버깅하기 편합니다. TCP가 순서를 보장하더라도 요청 ID나 시퀀스 번호를 넣어 두면 요청과 응답을 짝지어 로그를 추적하기 쉬워집니다.
로깅과 모니터링
// 연결/종료 시 로깅
std::cout << "Client " << clientFd << " from " << clientIP << " connected\n";
// 에러 시 errno와 함께 로깅
perror("recv"); // 또는 spdlog::error("recv: {}", strerror(errno));
연결 풀, connect 타임아웃, graceful shutdown, heartbeat
연결 풀 (Connection Pool)
동일 서버에 반복 연결할 때, 연결을 재사용하면 3-way handshake 비용을 줄일 수 있습니다. 아래는 구조만 보여 주는 스케치로, connectNew()와 maxPoolSize는 생략했습니다. 풀에 보관하는 동안 서버가 유휴 연결을 닫을 수 있으므로, 실제로는 꺼낸 연결로 첫 요청이 실패하면 새 연결로 한 번 재시도하는 처리가 필요합니다.
// 개념: 풀에 유휴 연결 보관, 요청 시 풀에서 꺼내 사용, 완료 후 반환
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);
}
};
타임아웃
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로 구현. 네트워크 에러 글에서 상세 다룸.
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;
}
Graceful Shutdown
// 1. 남은 데이터를 모두 전송
sendAll(fd, data, len);
// 2. 송신 방향만 닫음 → 상대에게 FIN 전송 (수신은 계속 가능)
shutdown(fd, SHUT_WR);
// 3. 상대가 남은 데이터를 보내고 FIN을 보낼 때까지 읽기 (recv가 0을 반환)
// 실제로는 SO_RCVTIMEO나 poll로 대기 시간에 상한을 둔다
while (recv(fd, buf, size, 0) > 0) {}
// 4. close
close(fd);
먼저 shutdown(SHUT_RD)를 호출하면 안 됩니다. Linux에서 SHUT_RD 이후의 recv는 즉시 0을 반환하므로 상대의 FIN을 기다리는 단계가 의미가 없어지고, 상대가 보낸 데이터가 수신 버퍼에 남은 채 close하면 커널이 FIN 대신 RST를 보내 상대 쪽에서 아직 읽지 않은 데이터가 버려질 수 있습니다.
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);
}
UDP와 getaddrinfo
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);
UDP에서 가장 흔한 실수는 응답을 영원히 기다리는 것입니다. 서버가 꺼져 있거나 패킷이 도중에 사라지면 recvfrom은 반환하지 않으므로, SO_RCVTIMEO로 수신 타임아웃을 걸고 시간 안에 응답이 없으면 재전송하거나 실패로 처리해야 합니다. 또 데이터그램은 한 번의 recvfrom에 통째로 들어오며, 버퍼보다 큰 데이터그램은 남는 부분이 TCP처럼 다음 호출로 이어지지 않고 잘려서 버려집니다(Linux에서는 MSG_TRUNC 플래그로 원래 크기를 알 수 있습니다). 경로 MTU를 넘는 데이터그램은 IP 단편화를 거치는데 조각 하나만 잃어도 전체가 버려지므로, 실무에서는 페이로드를 1200바이트 안팎으로 유지하는 경우가 많습니다.
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;
hints.ai_family = AF_INET으로 고정하면 IPv6만 있는 환경에서 동작하지 않으므로, 클라이언트라면 AF_UNSPEC으로 두고 돌려받은 주소 목록을 차례로 connect해 보는 것이 일반적입니다. 이때 connect는 상대가 응답하지 않으면 OS 기본 타임아웃(Linux 기본 tcp_syn_retries=6이면 약 2분)까지 블로킹됩니다. 연결 거부(ECONNREFUSED)는 즉시 실패하지만 방화벽이 패킷을 조용히 버리면 이 긴 대기가 그대로 드러나므로, 사용자 앞에서 동작하는 클라이언트는 논블로킹 connect와 poll로 직접 타임아웃을 거는 편이 좋습니다.
listen backlog와 연결마다 스레드 하나
listen(fd, 5)의 두 번째 인자는 “동시에 접속할 수 있는 클라이언트 수”가 아니라, 3-way 핸드셰이크를 마쳤지만 애플리케이션이 아직 accept하지 않은 연결을 쌓아 둘 큐의 크기입니다. Linux에서는 net.core.somaxconn으로 상한이 잘리고, 큐가 가득 차면 새 SYN이 버려져 클라이언트 쪽에서 연결이 느려지거나 타임아웃됩니다. 트래픽이 몰리는 서버라면 SOMAXCONN이나 수백 이상의 값을 주고, 무엇보다 accept를 빠르게 돌리는 구조가 중요합니다.
가장 단순한 멀티 클라이언트 구조는 accept 루프에서 연결마다 스레드를 하나씩 띄우는 것입니다. 이때 끝난 스레드의 std::thread 객체를 vector에 계속 쌓아 두면 메모리가 줄지 않고, detach하면 종료 시 정리할 방법이 없어집니다. 또 동시 접속이 ulimit -n(기본 1024인 경우가 많음)을 넘으면 accept가 EMFILE로 실패하는데, 이 에러를 무시하고 루프를 계속 돌면 대기 중인 연결이 그대로 남아 있어 accept가 즉시 실패를 반복하며 CPU를 100% 쓰는 현상으로 나타납니다. 수천 연결 이상이 예상되면 스레드 풀이나 위의 논블로킹 + poll/epoll 이벤트 루프로 가는 것이 맞습니다. Windows에서는 이 글의 POSIX 코드가 그대로 컴파일되지 않습니다. Winsock2(winsock2.h)를 쓰고 프로그램 시작과 끝에 WSAStartup/WSACleanup을 호출하며, close 대신 closesocket, errno 대신 WSAGetLastError()로 에러를 확인해야 하므로 크로스 플랫폼이 필요하면 Asio 같은 래퍼를 쓰는 편이 간단합니다.
자주 묻는 질문 (FAQ)
send가 요청한 바이트보다 적게 보내는 경우는 어떻게 처리하나요?
send는 커널 송신 버퍼에 들어갈 수 있는 만큼만 복사하고 실제로 보낸 바이트 수를 반환하므로 부분 전송이 흔히 발생합니다. 반환값만큼 오프셋을 옮기며 전부 보낼 때까지 반복하고, EINTR이면 같은 호출을 재시도해야 합니다. 논블로킹 소켓에서 EAGAIN이 나오면 남은 데이터를 보관해 두었다가 쓰기 가능 이벤트가 왔을 때 이어서 보냅니다.
Boost.Asio를 써야 하나요?
크로스 플랫폼, 비동기, 타이머, SSL 등이 필요하면 Asio가 편합니다. 소켓 동작 원리를 이해하려면 한 번은 POSIX 소켓으로 직접 구현해 보는 것이 좋습니다.