C++ 소켓 프로그래밍 기초: TCP·UDP 서버와 클라이언트, 멀티 클라이언트 처리

이 글의 핵심

소켓 API는 몇 개의 시스템 호출로 이루어져 있지만, 서버를 재시작하면 포트가 바로 열리지 않거나 끊긴 연결에 쓰다가 프로세스가 종료되는 문제에서 처음 막히게 됩니다. TCP와 UDP 중 무엇을 쓸지 판단하는 기준, 블로킹 호출 때문에 여러 클라이언트를 처리하지 못하는 문제와 스레드로 푸는 방법, Windows에서 달라지는 점까지 예제로 정리했습니다.

C++에는 표준 네트워크 라이브러리가 아직 없어서, 소켓 통신을 하려면 운영체제가 제공하는 BSD 소켓 API를 직접 다뤄야 합니다. 이 글은 socket·bind·listen·accept 순서로 이어지는 TCP 서버와 클라이언트, 연결 없이 데이터그램을 주고받는 UDP 서버와 클라이언트를 최소한의 코드로 구현하고, 멀티 클라이언트 서버·간단한 HTTP 서버·채팅 서버 예시와 자주 부딪히는 문제(Address already in use, Broken pipe, 블로킹)를 정리합니다.


TCP 서버

여덟 단계로 나뉜 이 흐름(생성 → 주소 설정 → 바인드 → 리슨 → 수락 → 수신 → 송신 → 닫기)이 TCP 서버 소켓 API의 표준 순서이며, 각 단계는 그 다음 단계가 성립하기 위한 전제 조건을 만듭니다. socket()은 커널에게 “통신에 쓸 파일 디스크립터 하나를 달라”고 요청하는 것일 뿐 아직 어떤 주소와도 연결되어 있지 않고, bind()가 되어서야 그 소켓이 특정 IP와 포트(8080)에 고정됩니다. listen()은 이 소켓을 “연결을 받아들이는 수동적 상태”로 전환하고 대기 큐의 크기(5)를 설정하는데, 이는 곧 처리되지 않은 연결 요청이 동시에 몇 개까지 대기할 수 있는지를 의미합니다 — accept()가 실제로 그 큐에서 하나의 연결을 꺼내 새로운 소켓(clientSocket)을 만들어 반환하며, 이후의 recv/send는 원래의 serverSocket이 아니라 이 새 소켓을 통해 이루어진다는 점이 중요합니다. serverSocket은 계속 새 연결을 받는 역할만 하고, 실제 데이터 통신은 각 클라이언트마다 별도로 발급된 소켓이 담당하는 이 이원화 구조가 뒤에서 다룰 멀티 클라이언트 서버의 기반이 됩니다.

#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <cstring>
using namespace std;

int main() {
    // 1. 소켓 생성
    int serverSocket = socket(AF_INET, SOCK_STREAM, 0);
    if (serverSocket < 0) {
        cerr << "소켓 생성 실패" << endl;
        return 1;
    }
    
    // 2. 주소 설정
    sockaddr_in serverAddr{};  // {}로 0 초기화 (sin_zero 포함)
    serverAddr.sin_family = AF_INET;
    serverAddr.sin_addr.s_addr = INADDR_ANY;
    serverAddr.sin_port = htons(8080);
    
    // 3. 바인드
    if (bind(serverSocket, (sockaddr*)&serverAddr, sizeof(serverAddr)) < 0) {
        cerr << "바인드 실패" << endl;
        return 1;
    }
    
    // 4. 리슨
    if (listen(serverSocket, 5) < 0) {
        cerr << "리슨 실패" << endl;
        return 1;
    }
    
    cout << "서버 시작: 포트 8080" << endl;
    
    // 5. 연결 수락
    sockaddr_in clientAddr;
    socklen_t clientLen = sizeof(clientAddr);
    int clientSocket = accept(serverSocket, (sockaddr*)&clientAddr, &clientLen);
    
    if (clientSocket < 0) {
        cerr << "연결 수락 실패" << endl;
        return 1;
    }
    
    cout << "클라이언트 연결됨" << endl;
    
    // 6. 데이터 수신
    char buffer[1024] = {0};
    int bytesRead = recv(clientSocket, buffer, sizeof(buffer) - 1, 0);  // 널 종료 자리 확보
    cout << "받은 메시지: " << buffer << endl;
    
    // 7. 데이터 전송
    const char* response = "Hello from server!";
    send(clientSocket, response, strlen(response), 0);
    
    // 8. 소켓 닫기
    close(clientSocket);
    close(serverSocket);
    
    return 0;
}

코드에서 두 줄을 짚어 둘 필요가 있습니다. 첫째, sockaddr_in serverAddr{};처럼 구조체를 0으로 초기화하지 않으면 sin_zero 패딩 필드에 쓰레기 값이 남는데, 일부 플랫폼(BSD 계열)에서는 이 값 때문에 bind가 EADDRNOTAVAIL로 실패하기도 합니다. 둘째, recv에 sizeof(buffer) - 1을 넘기는 이유는 받은 데이터를 C 문자열로 출력하기 때문입니다. 버퍼 크기만큼 꽉 차게 받으면 끝에 널 문자가 들어갈 자리가 없어 cout이 버퍼 밖까지 읽게 됩니다. 실제 서비스 코드라면 recv의 반환값을 길이로 삼아 std::string(buffer, bytesRead)처럼 다루는 편이 더 안전합니다.

listen의 두 번째 인자(backlog) 5도 오해가 많은 값입니다. “동시에 접속할 수 있는 클라이언트 수”가 아니라, 커널이 3-way 핸드셰이크를 마쳤지만 애플리케이션이 아직 accept하지 않은 연결을 쌓아 둘 큐의 크기입니다. Linux에서는 이 값이 net.core.somaxconn으로 상한이 잘리고, 큐가 가득 차면 새 SYN을 버려 클라이언트 쪽에서 연결이 느려지거나 타임아웃됩니다. 트래픽이 몰리는 서버라면 SOMAXCONN이나 수백 이상의 값을 주고, 무엇보다 accept를 빠르게 돌리는 구조가 중요합니다.

TCP 클라이언트

클라이언트 쪽 흐름이 서버보다 단순한 이유는 bind/listen/accept가 필요 없기 때문입니다 — 클라이언트는 특정 포트에서 연결을 “기다리는” 쪽이 아니라 이미 알고 있는 서버 주소로 “먼저 접속을 시도하는” 쪽이므로, connect() 하나로 서버의 리슨 큐에 접속을 요청하고 그 결과가 확립되면 바로 통신을 시작합니다. inet_pton이 문자열 "127.0.0.1"을 sockaddr_in 구조체가 요구하는 이진 네트워크 바이트 순서 형식으로 변환하는 이유는, IP 주소가 실제로는 4바이트 정수로 표현되고 네트워크 프로토콜이 항상 빅 엔디안(네트워크 바이트 순서)을 쓰기로 약속되어 있기 때문입니다 — 이 변환 없이 문자열을 그대로 구조체에 넣으면 커널이 그것을 올바른 주소로 해석할 수 없습니다. htons(8080)도 같은 이유로 포트 번호를 호스트 바이트 순서에서 네트워크 바이트 순서로 변환하는 함수이며, 이 변환을 빠뜨리면 리틀 엔디안 시스템에서 포트 번호의 바이트가 뒤바뀐 채 전송되어 완전히 다른 포트로 연결을 시도하게 됩니다.

#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
using namespace std;

int main() {
    // 1. 소켓 생성
    int clientSocket = socket(AF_INET, SOCK_STREAM, 0);
    if (clientSocket < 0) {
        cerr << "소켓 생성 실패" << endl;
        return 1;
    }
    
    // 2. 서버 주소 설정
    sockaddr_in serverAddr{};  // {}로 0 초기화 (sin_zero 포함)
    serverAddr.sin_family = AF_INET;
    serverAddr.sin_port = htons(8080);
    
    if (inet_pton(AF_INET, "127.0.0.1", &serverAddr.sin_addr) <= 0) {
        cerr << "주소 변환 실패" << endl;
        return 1;
    }
    
    // 3. 연결
    if (connect(clientSocket, (sockaddr*)&serverAddr, sizeof(serverAddr)) < 0) {
        cerr << "연결 실패" << endl;
        return 1;
    }
    
    cout << "서버에 연결됨" << endl;
    
    // 4. 데이터 전송
    const char* message = "Hello from client!";
    send(clientSocket, message, strlen(message), 0);
    
    // 5. 응답 수신
    char buffer[1024] = {0};
    int bytesRead = recv(clientSocket, buffer, sizeof(buffer) - 1, 0);  // 널 종료 자리 확보
    cout << "서버 응답: " << buffer << endl;
    
    // 6. 소켓 닫기
    close(clientSocket);
    
    return 0;
}

이 클라이언트는 IP 주소를 직접 넣지만, 실제로는 호스트 이름을 getaddrinfo()로 해석해 나온 주소 목록을 차례로 시도하는 것이 일반적입니다. getaddrinfo는 IPv4와 IPv6 주소를 모두 돌려주므로, AF_INET과 sockaddr_in을 하드코딩한 코드는 IPv6만 있는 환경에서 동작하지 않습니다. 또 connect는 서버가 응답하지 않으면 OS 기본 타임아웃(Linux에서 SYN 재전송 횟수에 따라 1~2분 이상)까지 블로킹되므로, 사용자 앞에서 동작하는 클라이언트라면 논블로킹 connect와 poll로 직접 타임아웃을 거는 처리가 필요합니다. 연결 거부(ECONNREFUSED)는 즉시 실패하지만, 방화벽이 패킷을 조용히 버리는 경우에는 이 긴 대기가 그대로 드러납니다.

UDP 서버

socket(AF_INET, SOCK_DGRAM, 0)처럼 두 번째 인자만 SOCK_STREAM에서 SOCK_DGRAM으로 바뀐 것이 이 코드가 TCP와 근본적으로 다른 프로토콜로 동작하게 만드는 전부입니다. UDP 서버 코드에 listen()과 accept()가 아예 없다는 점이 두 프로토콜의 성격 차이를 그대로 드러냅니다 — TCP는 두 지점 사이에 지속적인 “연결”이라는 상태를 먼저 확립한 뒤 그 연결을 통해 데이터를 주고받지만, UDP는 그런 연결 개념 자체가 없어 bind로 포트만 고정해 두면 곧바로 어느 발신자로부터든 개별 패킷(datagram)을 받을 준비가 됩니다. recvfrom이 일반 recv와 다른 점도 이 무연결성에서 나옵니다 — TCP는 이미 accept로 상대방이 누구인지 확정된 소켓을 쓰므로 그냥 recv면 충분하지만, UDP는 매 패킷마다 발신자가 다를 수 있으므로 recvfrom이 데이터를 받는 동시에 “이 패킷을 누가 보냈는지”(clientAddr)까지 함께 알려줘야 그 발신자에게 sendto로 응답을 보낼 수 있습니다.

#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <cstring>
using namespace std;

int main() {
    // UDP 소켓 생성
    int serverSocket = socket(AF_INET, SOCK_DGRAM, 0);
    
    sockaddr_in serverAddr{};  // {}로 0 초기화 (sin_zero 포함)
    serverAddr.sin_family = AF_INET;
    serverAddr.sin_addr.s_addr = INADDR_ANY;
    serverAddr.sin_port = htons(8080);
    
    bind(serverSocket, (sockaddr*)&serverAddr, sizeof(serverAddr));
    
    cout << "UDP 서버 시작: 포트 8080" << endl;
    
    char buffer[1024];
    sockaddr_in clientAddr;
    socklen_t clientLen = sizeof(clientAddr);
    
    // 데이터 수신
    int bytesRead = recvfrom(serverSocket, buffer, sizeof(buffer) - 1, 0,
                             (sockaddr*)&clientAddr, &clientLen);
    if (bytesRead < 0) { cerr << "recvfrom 실패" << endl; return 1; }
    buffer[bytesRead] = '\0';
    cout << "받은 메시지: " << buffer << endl;
    
    // 응답 전송
    const char* response = "UDP response";
    sendto(serverSocket, response, strlen(response), 0,
           (sockaddr*)&clientAddr, clientLen);
    
    close(serverSocket);
    return 0;
}

UDP 클라이언트

UDP 클라이언트에도 connect()가 없다는 것이 눈에 띕니다 — TCP의 connect는 3방향 핸드셰이크로 실제 연결 상태를 확립하는 과정이지만, UDP는 애초에 그런 지속적 연결 개념이 없으므로 sendto를 호출할 때마다 매번 목적지 주소(serverAddr)를 함께 지정하는 것으로 충분합니다. 이는 UDP가 TCP보다 가벼운 이유이기도 합니다 — 핸드셰이크 왕복 시간이 없고, 연결 상태를 유지하기 위한 커널 자원도 필요 없어 게임처럼 매 프레임 짧은 패킷을 보내야 하는 상황에서 지연 시간이 훨씬 낮습니다. 그 대가는 신뢰성입니다 — TCP는 패킷 손실 시 자동으로 재전송하고 순서를 보장하지만, UDP는 이런 보장이 전혀 없어 sendto로 보낸 패킷이 실제로 도착했는지, 도착한 순서가 보낸 순서와 같은지를 애플리케이션 계층에서 직접 신경 써야 합니다.

#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
using namespace std;

int main() {
    int clientSocket = socket(AF_INET, SOCK_DGRAM, 0);
    
    sockaddr_in serverAddr{};  // {}로 0 초기화 (sin_zero 포함)
    serverAddr.sin_family = AF_INET;
    serverAddr.sin_port = htons(8080);
    inet_pton(AF_INET, "127.0.0.1", &serverAddr.sin_addr);
    
    // 데이터 전송
    const char* message = "UDP message";
    sendto(clientSocket, message, strlen(message), 0,
           (sockaddr*)&serverAddr, sizeof(serverAddr));
    
    // 응답 수신
    char buffer[1024];
    socklen_t serverLen = sizeof(serverAddr);
    int bytesRead = recvfrom(clientSocket, buffer, sizeof(buffer) - 1, 0,
                             (sockaddr*)&serverAddr, &serverLen);
    if (bytesRead < 0) { cerr << "recvfrom 실패" << endl; return 1; }
    buffer[bytesRead] = '\0';
    cout << "서버 응답: " << buffer << endl;
    
    close(clientSocket);
    return 0;
}

UDP에서 자주 겪는 문제는 응답을 영원히 기다리는 것입니다. 서버가 꺼져 있거나 패킷이 도중에 사라지면 위 코드의 recvfrom은 절대 반환하지 않습니다. setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, ...)로 수신 타임아웃을 걸고, 시간 안에 응답이 없으면 재전송하거나 실패로 처리하는 로직이 필요합니다. 또 UDP 데이터그램은 한 번의 recvfrom에 통째로 들어오며, 버퍼보다 큰 데이터그램은 남는 부분이 잘려서 버려집니다(TCP처럼 다음 호출로 이어지지 않습니다). 경로 MTU를 넘는 큰 데이터그램은 IP 단편화를 거치는데, 조각 하나만 잃어도 전체가 버려지므로 실무에서는 페이로드를 1200바이트 안팎으로 유지하는 경우가 많습니다.

실전 예시

예시 1: 멀티 클라이언트 서버

앞서 다룬 기본 TCP 서버는 accept()를 단 한 번만 호출해 클라이언트 하나만 처리하고 종료되는데, 이 예시는 while(true) 루프로 accept를 반복 호출해 여러 클라이언트를 순서대로 받아들이는 구조로 확장한 것입니다. 각 accept가 반환하는 clientSocket을 별도의 스레드(handleClient)에 넘기는 것이 핵심 설계 결정인데, 이렇게 하지 않고 메인 스레드에서 그대로 recv/send를 처리하면 한 클라이언트가 데이터를 보내지 않고 가만히 있는 동안 accept 루프 자체가 멈춰 다른 클라이언트의 연결 요청을 받을 수 없게 됩니다 — “연결마다 스레드 하나(thread-per-connection)“는 이 문제를 해결하는 가장 직관적인 방법이지만, 동시 접속자가 수천 명 이상으로 늘어나면 스레드 생성·컨텍스트 스위칭 비용 자체가 병목이 되므로, 그런 규모에서는 뒤에서 언급하는 select/poll/epoll 기반의 이벤트 루프 방식이 더 적합합니다.

#include <iostream>
#include <thread>
#include <vector>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
using namespace std;

void handleClient(int clientSocket) {
    char buffer[1024];
    int bytesRead = recv(clientSocket, buffer, sizeof(buffer) - 1, 0);  // 널 종료 자리 확보
    
    if (bytesRead > 0) {
        buffer[bytesRead] = '\0';
        cout << "받은 메시지: " << buffer << endl;
        
        send(clientSocket, buffer, bytesRead, 0);  // 에코
    }
    
    close(clientSocket);
}

int main() {
    int serverSocket = socket(AF_INET, SOCK_STREAM, 0);
    
    sockaddr_in serverAddr{};  // {}로 0 초기화 (sin_zero 포함)
    serverAddr.sin_family = AF_INET;
    serverAddr.sin_addr.s_addr = INADDR_ANY;
    serverAddr.sin_port = htons(8080);
    
    bind(serverSocket, (sockaddr*)&serverAddr, sizeof(serverAddr));
    listen(serverSocket, 10);
    
    cout << "멀티 클라이언트 서버 시작" << endl;
    
    vector<thread> threads;
    
    while (true) {
        sockaddr_in clientAddr;
        socklen_t clientLen = sizeof(clientAddr);
        int clientSocket = accept(serverSocket, (sockaddr*)&clientAddr, &clientLen);
        
        if (clientSocket >= 0) {
            threads.emplace_back(handleClient, clientSocket);
        }
    }
    
    for (auto& t : threads) {
        if (t.joinable()) t.join();
    }
    
    close(serverSocket);
    return 0;
}

이 코드에는 장시간 실행하면 드러나는 문제가 있습니다. 끝난 스레드도 threads 벡터에 std::thread 객체로 계속 남아 있으므로, 연결이 들어올 때마다 벡터가 커지기만 하고 줄지 않습니다. 게다가 while (true)를 빠져나오는 경로가 없어 아래의 join 루프에는 영원히 도달하지 않습니다. 간단히 해결하려면 std::thread(handleClient, clientSocket).detach();로 스레드를 떼어 내면 되지만, 그러면 종료 시점에 스레드를 정리할 방법이 없습니다. 실무에서는 스레드 풀에 작업을 넘기거나, 뒤에서 말하는 이벤트 루프 방식으로 바꾸는 것이 일반적입니다. 또 accept가 EINTR(시그널에 의한 중단)나 EMFILE(파일 디스크립터 한도 초과)로 실패할 수 있는데, 특히 EMFILE은 ulimit -n(기본 1024인 경우가 많음)을 넘는 동시 접속에서 나타나며 이때 루프가 빠르게 헛돌며 CPU를 100% 쓰는 현상으로 보이기도 합니다.

예시 2: HTTP 간단 서버

이 예시가 보여주는 중요한 사실은 HTTP가 TCP 위에 얹힌 텍스트 기반 프로토콜에 불과하다는 것입니다 — 특별한 “HTTP 소켓 API” 같은 것은 없고, 그저 TCP 소켓으로 클라이언트가 보낸 바이트를 받아 그것이 GET / HTTP/1.1\r\n...처럼 HTTP 규격을 따르는 텍스트라고 해석하고, 응답도 createHTTPResponse처럼 HTTP가 요구하는 형식(상태 줄, 헤더, 빈 줄, 본문)의 텍스트를 그대로 조립해 같은 TCP 소켓으로 돌려보낼 뿐입니다. Content-Length 헤더가 본문의 정확한 바이트 길이를 명시하는 것도 중요한데, HTTP/1.1이 기본적으로 하나의 TCP 연결을 여러 요청·응답에 재사용하는 “지속 연결(keep-alive)“을 지원하므로, 클라이언트가 응답 본문이 정확히 어디서 끝나는지 알아야 다음 요청을 준비할 수 있기 때문입니다 — 이 예시처럼 매 요청마다 close(clientSocket)으로 연결을 끊는 것은 HTTP/1.0 스타일의 단순화이며, 실제 프로덕션 서버는 이 지속 연결과 요청 파싱(메서드, 경로, 헤더)까지 훨씬 정교하게 처리합니다.

#include <iostream>
#include <sstream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
using namespace std;

string createHTTPResponse(const string& content) {
    ostringstream response;
    response << "HTTP/1.1 200 OK\r\n";
    response << "Content-Type: text/html\r\n";
    response << "Content-Length: " << content.length() << "\r\n";
    response << "\r\n";
    response << content;
    return response.str();
}

int main() {
    int serverSocket = socket(AF_INET, SOCK_STREAM, 0);
    
    sockaddr_in serverAddr{};  // {}로 0 초기화 (sin_zero 포함)
    serverAddr.sin_family = AF_INET;
    serverAddr.sin_addr.s_addr = INADDR_ANY;
    serverAddr.sin_port = htons(8080);
    
    bind(serverSocket, (sockaddr*)&serverAddr, sizeof(serverAddr));
    listen(serverSocket, 5);
    
    cout << "HTTP 서버 시작: http://localhost:8080" << endl;
    
    while (true) {
        sockaddr_in clientAddr;
        socklen_t clientLen = sizeof(clientAddr);
        int clientSocket = accept(serverSocket, (sockaddr*)&clientAddr, &clientLen);
        
        char buffer[4096];
        recv(clientSocket, buffer, sizeof(buffer) - 1, 0);  // 널 종료 자리 확보
        
        string html = "<html><body><h1>Hello, World!</h1></body></html>";
        string response = createHTTPResponse(html);
        
        send(clientSocket, response.c_str(), response.length(), 0);
        close(clientSocket);
    }
    
    close(serverSocket);
    return 0;
}

예시 3: 채팅 서버

이 예시는 앞서 다룬 “연결마다 스레드 하나” 패턴에 “여러 스레드가 공유하는 상태”라는 새로운 문제를 더합니다 — 전역 clients 벡터는 현재 접속 중인 모든 클라이언트의 소켓을 담고 있는데, 이 벡터에 여러 스레드(각 클라이언트를 처리하는 스레드들)가 동시에 push_back이나 erase를 호출하면 벡터 내부 자료구조가 손상되는 데이터 경쟁이 발생합니다. clientsMutex로 clients에 대한 모든 접근(추가, 순회하며 브로드캐스트, 제거)을 감싸는 것이 이 문제를 해결하는데, broadcast 함수가 sender(메시지를 보낸 클라이언트)를 제외한 모든 클라이언트에게 전송하는 것도 채팅 서버의 흔한 요구사항(자신이 보낸 메시지를 자신에게 다시 에코하지 않음)을 반영합니다. 클라이언트가 연결을 끊으면(recv가 0 이하를 반환하면) 그 소켓을 clients 벡터에서 제거하는 정리 코드가 없다면, 이미 닫힌 소켓에 계속 send를 시도하다 오류가 누적되는 문제로 이어졌을 것입니다.

#include <iostream>
#include <string>
#include <vector>
#include <thread>
#include <mutex>
#include <algorithm>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
using namespace std;

vector<int> clients;
mutex clientsMutex;

void broadcast(const string& message, int sender) {
    lock_guard<mutex> lock(clientsMutex);
    
    for (int client : clients) {
        if (client != sender) {
            send(client, message.c_str(), message.length(), 0);
        }
    }
}

void handleClient(int clientSocket) {
    {
        lock_guard<mutex> lock(clientsMutex);
        clients.push_back(clientSocket);
    }
    
    char buffer[1024];
    
    while (true) {
        int bytesRead = recv(clientSocket, buffer, sizeof(buffer) - 1, 0);  // 널 종료 자리 확보
        
        if (bytesRead <= 0) break;
        
        buffer[bytesRead] = '\0';
        string message(buffer);
        
        cout << "메시지: " << message << endl;
        broadcast(message, clientSocket);
    }
    
    {
        lock_guard<mutex> lock(clientsMutex);
        clients.erase(remove(clients.begin(), clients.end(), clientSocket), clients.end());
    }
    
    close(clientSocket);
}

int main() {
    int serverSocket = socket(AF_INET, SOCK_STREAM, 0);
    
    sockaddr_in serverAddr{};  // {}로 0 초기화 (sin_zero 포함)
    serverAddr.sin_family = AF_INET;
    serverAddr.sin_addr.s_addr = INADDR_ANY;
    serverAddr.sin_port = htons(8080);
    
    bind(serverSocket, (sockaddr*)&serverAddr, sizeof(serverAddr));
    listen(serverSocket, 10);
    
    cout << "채팅 서버 시작" << endl;
    
    vector<thread> threads;
    
    while (true) {
        sockaddr_in clientAddr;
        socklen_t clientLen = sizeof(clientAddr);
        int clientSocket = accept(serverSocket, (sockaddr*)&clientAddr, &clientLen);
        
        threads.emplace_back(handleClient, clientSocket);
    }
    
    return 0;
}

이 채팅 서버를 실제로 여러 클라이언트로 테스트해 보면 두 가지 현상을 만나게 됩니다. 하나는 메시지가 붙거나 쪼개져서 도착하는 것입니다. TCP는 메시지 경계를 보존하지 않으므로, 클라이언트가 “안녕”과 “반가워”를 연달아 보내면 서버의 recv 한 번에 “안녕반가워”가 들어오거나, 긴 메시지가 두 번에 나뉘어 들어올 수 있습니다. 줄바꿈 문자로 메시지를 구분하거나 앞에 길이를 붙이는 프레이밍 규칙을 정하고, 수신 측에서 버퍼에 쌓아 두었다가 완성된 메시지만 꺼내야 합니다. 다른 하나는 느린 클라이언트 한 명이 전체를 멈추는 현상입니다. broadcast는 뮤텍스를 쥔 채 모든 클라이언트에게 블로킹 send를 호출하는데, 한 클라이언트의 수신 버퍼가 가득 차면 그 send가 멈추고, 그동안 다른 모든 스레드가 뮤텍스를 기다리게 됩니다. 클라이언트마다 송신 큐를 두고 전용 스레드나 이벤트 루프가 비워 가게 하는 구조로 바꿔야 이 문제가 사라집니다.

자주 발생하는 문제

문제 1: Address already in use

증상: bind 실패

원인: 포트가 이미 사용 중

해결법:

이 에러는 실제로 포트가 다른 프로그램에 의해 사용 중인 경우도 있지만, 개발 중 서버를 재시작할 때 훨씬 자주 마주치는 원인은 TCP의 TIME_WAIT 상태입니다 — 소켓이 정상적으로 닫혀도 커널은 그 포트를 일정 시간(보통 수십 초에서 몇 분) 동안 예약해 두어, 늦게 도착하는 패킷이 새로 시작된 무관한 프로그램에 잘못 전달되는 것을 방지합니다. 개발 중에는 서버를 자주 재시작하므로 이 대기 시간이 매번 답답한 지연으로 느껴지는데, SO_REUSEADDR 옵션은 커널에게 “이 포트가 TIME_WAIT 상태여도 즉시 재사용해도 좋다”고 명시적으로 허용하는 것입니다. 다만 이 옵션의 정확한 의미는 운영체제와 프로토콜에 따라 미묘하게 다를 수 있으므로(예: UDP 브로드캐스트와 함께 쓰일 때), 프로덕션 배포 환경에서는 대상 OS의 문서를 함께 확인하는 것이 안전합니다.

int opt = 1;
setsockopt(serverSocket, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

문제 2: Broken pipe

증상: send 시 크래시

원인: 클라이언트가 연결을 끊음

해결법: SIGPIPE 무시(MSG_NOSIGNAL) + send/recv 반환값 체크

// Linux: 이 send 호출에서 SIGPIPE를 발생시키지 않고 -1/EPIPE로 반환
ssize_t n = send(sock, data, len, MSG_NOSIGNAL);
if (n < 0 && errno == EPIPE) { /* 상대가 연결을 끊음: 정리 */ }

// 또는 프로세스 전체에서 SIGPIPE 무시 (main 시작 시 한 번)
signal(SIGPIPE, SIG_IGN);
// macOS/BSD는 MSG_NOSIGNAL 대신 소켓 옵션 SO_NOSIGPIPE 사용

send가 크래시(정확히는 SIGPIPE 시그널로 인한 프로세스 종료)를 일으키는 이유는, 상대방이 이미 연결을 끊었는데도 그 사실을 모른 채 계속 데이터를 쓰려고 시도하기 때문입니다 — TCP 연결은 상대방이 close()를 호출하는 시점에 그쪽에서 즉시 끊어지지만, 이쪽에서는 그 사실을 다음 recv나 send 호출을 통해서만 알 수 있습니다. recv의 반환값이 0이면 상대방이 정상적으로 연결을 종료했다는 뜻이고, 음수면 오류가 발생했다는 뜻이므로, 이 반환값을 확인하지 않고 계속 send를 호출하면 이미 죽은 연결에 계속 쓰기를 시도하다 이 문제를 겪게 됩니다. 앞서 채팅 서버 예시의 if (bytesRead <= 0) break;가 정확히 이 검사를 구현한 것이며, 이런 검사 없이 네트워크 코드를 작성하면 클라이언트가 예기치 않게 연결을 끊는(브라우저 탭을 닫거나, 네트워크가 끊기는) 흔한 상황에서 서버 전체가 죽는 취약점이 됩니다.

다만 recv 반환값 검사만으로는 충분하지 않습니다. 채팅 서버의 broadcast처럼 다른 스레드가 이미 끊긴 소켓에 send하는 경우는 recv 쪽 검사가 끝나기 전에 일어날 수 있고, 그 순간 기본 동작인 SIGPIPE가 프로세스 전체를 종료시킵니다. 로그에 아무것도 남지 않고 서버가 “조용히 사라지는” 증상이 이것이며, 셸에서 실행했다면 종료 코드 141(128+13)로 확인할 수 있습니다. 그래서 서버 코드는 위처럼 SIGPIPE를 무시하고 send의 EPIPE/ECONNRESET 에러를 반환값으로 처리하는 것이 표준 관행입니다.

문제 3: 블로킹

증상: accept/recv에서 멈춤

원인: 블로킹 소켓

해결법: 논블로킹 소켓 또는 select/poll 사용

이 글의 모든 예제가 쓰는 소켓은 기본적으로 “블로킹” 모드입니다 — accept()는 실제로 연결이 들어올 때까지, recv()는 실제로 데이터가 도착할 때까지 그 호출 지점에서 스레드 실행을 완전히 멈추고 대기합니다. 단일 클라이언트만 다루는 간단한 예제에서는 이것이 문제가 되지 않지만, 이 대기가 예상보다 길어지거나(클라이언트가 응답하지 않는 경우) 여러 소켓을 동시에 감시해야 하는 상황에서는 한 소켓에서의 블로킹이 다른 모든 작업을 멈춰 세웁니다. 앞서 다룬 “스레드 per 연결” 패턴은 각 블로킹 호출을 별도 스레드에 격리해 이 문제를 우회하지만, 진짜 근본적인 해법은 소켓을 논블로킹으로 설정하고 select/poll/epoll 같은 API로 “지금 어느 소켓이 준비되었는지”를 하나의 스레드에서 효율적으로 감시하는 이벤트 기반 모델로 전환하는 것입니다 — 이는 수천~수만 개의 동시 연결을 다뤄야 하는 고성능 서버에서 표준적으로 쓰이는 접근이며, I/O 멀티플렉싱 글에서 더 자세히 다룹니다.

FAQ

Q1: TCP vs UDP 언제 사용하나요?

A: 데이터가 빠짐없이 순서대로 도착해야 하면(HTTP, 파일 전송, DB 프로토콜) TCP, 늦게 도착한 데이터가 쓸모없어지는 실시간 데이터(게임 위치 동기화, 음성·영상 통화)라면 UDP가 맞습니다. TCP는 패킷 하나를 잃으면 재전송이 올 때까지 뒤의 데이터도 전달을 멈추는데(head-of-line blocking), 실시간 음성에서는 이 대기가 끊김으로 나타납니다. 다만 UDP를 고르면 재전송·순서·혼잡 제어를 필요한 만큼 직접 구현해야 하므로, 요즘은 그 일을 대신해 주는 QUIC 같은 UDP 기반 프로토콜을 쓰는 경우도 많습니다.

Q2: 작은 메시지를 보낼 때 응답이 40ms 정도 늦게 오는 이유는?

A: Nagle 알고리즘과 상대방의 지연 ACK(delayed ACK)가 맞물린 전형적인 증상입니다. Nagle은 아직 확인되지 않은 작은 데이터가 있으면 다음 작은 쓰기를 모아 두고, 상대방은 ACK를 잠시 미루므로 서로를 기다리게 됩니다. 요청-응답을 짧은 메시지 여러 번의 send로 나눠 보내는 코드에서 자주 보이며, 메시지를 한 번의 send로 모아 보내거나 setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, ...)로 Nagle을 끄면 해결됩니다.

Q3: 1024 미만 포트에 bind하면 Permission denied가 나는 이유는?

A: Linux를 포함한 대부분의 유닉스 계열에서 0~1023번 포트는 특권 포트라 root 권한(또는 CAP_NET_BIND_SERVICE 권한)이 있어야 바인드할 수 있습니다. 서버를 root로 실행하기보다 8080 같은 높은 포트에서 실행하고 앞단 리버스 프록시나 로드밸런서가 80/443을 받게 하거나, 실행 파일에 setcap 'cap_net_bind_service=+ep'로 해당 권한만 주는 방식을 씁니다.


같이 보면 좋은 글