소켓프로그래밍

소켓프로그래밍 정리

소켓 프로그래밍이란?

  • 소켓프로세스간에 데이터주고받기 위해 사용되는, OS단에서 추상화된 객체입니다.
  • 여기에서 말하는 프로세스는, 하나의 컴퓨터 안에 존재하는 프로세스일 수도, 멀리 떨어진 컴퓨터들 간의 프로세스일 수도 있습니다. (일반적으로 떨어진 컴퓨터 간의 통신)
  • 소켓 프로그래밍이란, 이 소켓을 사용하여 프로세스끼리 데이터를 빠르고 안정적으로 잘 주고받는 프로그램을 만드는 것입니다.

소켓 통신(Network I/O)

  • 소켓 통신은 입력과 출력으로 취급할 수 있으며, 이는 일반적인 콘솔 IO / 파일 IO과 똑같은 데이터(바이트) 입출력으로 볼 수 있습니다.
  • 네트워크 환경에 종속된다는 특징이 있습니다.(네트워크 품질에 따른 속도, 지연, 유실 문제 발생)
  • 소켓통신은 네트워크 계층 중 Transport 계층 위에서 동작하며, TCP나 UDP와 같은 프로토콜을 통해 송수신된 데이터를 다루게 됩니다. (일반적으로 TCP 환경을 가정)

소켓 통신의 특징

  • 기초레벨에서 다루는 입출력은 대부분 콘솔IO입니다.
    알고리즘 문제를 풀거나 간단한 프로그램을 만들 시에 정해져있는 입력 유형을 통해 정해져있는 타입으로 파싱하는 일들을 합니다.
  • scanf("%d", int), std::cin >> float, a, b = map(int, readline().split()). 각 C, C++, Python
  • 반면, 소켓통신은 주로 바이너리 데이터를 다룹니다. 특정한 메시지 프로토콜을 구현한 후, 직렬화를 통해 송신 후 수신 과정에서 각 바이트를 직접 조작하면서 유의미한 데이터(메시지)로 만들어내는 역직렬화 과정이 필요합니다.

서버와 클라이언트

  • 소켓 통신에서는 일반적으로 서버클라이언트라는 개념이 존재합니다.
  • 서버: 연결을 기다리고, 클라이언트의 요청을 받아 처리하는 역할을 합니다.
  • 클라이언트: 서버에 연결을 요청하고, 데이터를 전송하거나 수신하는 역할을 합니다.
  • 서버측에선 미리 소켓을 열어놓고서(서버 가동) 수동적으로 클라이언트의 연결 대기하는 방식으로 수동적인 동작을 하게 됩니다.

Network IO의 특징

  • 콘솔 입출력은 표준 입출력으로 기본값이 정해져있어 단순히 입출력 함수를 호출하기면 하면 되고, 파일 입출력은 파일 경로와 핸들 방식을 지정한 뒤 파일을 열면 되지만, 네트워크 IO는 사전작업이 필요합니다.
  • 서버측에선 소켓 생성, 포트 바인딩, 연결 대기 등의 작업을, 클라이언트는 소켓 생성, 서버 연결을 한 뒤 데이터 송수신이 시작됩니다.
  • 아주 간단한 채팅 프로그램의 경우 데이터를 아스키코드만 다룰거면 모든 데이터를 문자로 취급할 수 있겠지만, 복잡한 네트워크 프로그램의 경우 프로토콜 설계를 통해 데이터를 바이트 단위로 다뤄 직렬화역직렬화(파싱)을 해야합니다.
    프로토콜의 경우 페이로드(본문) 길이를 담은 고정 길이 헤더를 사용하는게 일반적입니다.

소켓 API

  • 각 운영체제가 네트워크 통신을 편하게 할 수 있도록 소켓 API를 제공합니다.(linux는 POSIX API, Windows는 Winsock API)
  • 대부분의 소켓 API는 버클리 대학에서 개발된 BSD 소켓 인터페이스를 따릅니다.(근데 어차피 윈도우랑 리눅스랑 따로 취급해야 함)
  • 소켓 API

주요 API

  • socket: 소켓 객체(file-descripter) 생성
  • send: 연결된 소켓을 통해 데이터 송신
  • recv: 연결된 소켓으로부터 데이터 수신
  • bind, accept: 서버측에서 OS에 소켓 바인딩 요청 및 연결 수락
  • connect: 클라이언트측에서 목적 주소에 연결 요청

socket(int af, int type, int protocol)

  • 소켓 생성할 때 사용합니다. 소켓(파일 디스크립터)를 반환합니다.
  • af: 주소 체계(Address Family), 통신 방식을 지정합니다. 주로 AF_INET(IPv4)나 AF_INET6(IPv6) 혹은 AF_UNIX를 사용합니다.
  • type: 소켓 유형을 지정합니다. 예를 들어, TCP는 SOCK_STREAM, UDP는 SOCK_DGRAM입니다.
  • protocol: 사용할 프로토콜을 지정합니다. 0을 지정하면 type에 따라 자동으로 지정됩니다.
  • 예시: int sockfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

bind(int socket, const struct sockaddr *address, socklen_t address_len)

  • 서버측에서 소켓에 IP 주소와 포트를 할당할 때 사용합니다.
  • socket: 소켓 파일 디스크립터입니다.
  • address: 소켓에 할당할 주소 정보를 담은 구조체입니다. 일반적으로 struct sockaddr_in을 사용하여 IPv4 주소와 포트를 지정합니다.
  • address_len: 주소 구조체의 크기입니다. ipv4 주소인지 ipv6주소인지를 분간하기 위해 사용합니다.

listen(int socket, int backlog)

  • 서버측에서 소켓이 클라이언트의 연결 요청을 받을 수 있도록 설정합니다. OS에 연결 요청이 들어오면 3-way handshake를 통해 연결이 성립되고, 해당 요청을 큐에 저장해둡니다. accept 함수를 통해 큐에 저장된 연결 요청을 처리할 수 있습니다.
  • backlog: 클라이언트의 연결 요청 큐의 크기를 지정합니다.

connect(int socket, const struct sockaddr *address, socklen_t address_len)

  • 클라이언트측에서 서버에 연결을 시도할 때 사용합니다.
  • address: 연결하려는 서버의 주소 정보를 담은 구조체입니다.

accept(int socket, struct sockaddr *address, socklen_t *address_len)

  • 서버측에서 클라이언트의 연결 요청을 수락할 때 사용합니다. 뒤의 주소 인자들은 클라이언트의 주소 정보를 받기 위한 인자입니다.
  • address: 클라이언트의 주소 정보를 저장할 구조체입니다. 연결이 수락되면 클라이언트의 IP 주소와 포트 정보가 이 구조체에 저장됩니다.
  • address_len: 주소 구조체의 크기를 저장하는 변수입니다. accept 함수가 반환되면 이 변수에는 클라이언트 주소 구조체의 실제 크기가 저장됩니다.

send(int socket, const void *buffer, size_t length, int flags)

  • 소켓을 통해 데이터를 전송할 때 사용합니다. 연결이 수립된 이후 서버나 클라이언트 양 측에서 모두 사용할 수 있습니다.
  • buffer: 전송할 데이터가 저장된 버퍼의 포인터입니다.
  • length: 전송할 데이터의 크기(바이트 단위)입니다.
  • flags: 전송 옵션을 지정하는 플래그입니다. 일반적으로 0을 사용하지만, 특정 상황에서는 MSG_DONTWAIT(비동기 전송) 등의 플래그를 사용할 수 있습니다.

recv(int socket, void *buffer, size_t length, int flags)

  • 소켓을 통해 데이터를 수신할 때 사용합니다.
  • buffer: 수신한 데이터를 저장할 버퍼의 포인터입니다.
  • length: 수신할 데이터의 최대 크기(바이트 단위)입니다.
  • flags: 수신 옵션을 지정하는 플래그입니다. 일반적으로 0을 사용하지만, 특정 상황에서는 MSG_DONTWAIT(비동기 수신) 등의 플래그를 사용할 수 있습니다.

shutdown(int socket, int how)

  • 소켓의 송수신을 종료할 때 사용합니다. 4-way handshake를 수행하는 과정입니다.
  • how: 종료 방식입니다. 0은 송신 종료, 1은 수신 종료, 2는 송수신 모두 종료를 의미합니다.

close(int socket)

  • 소켓을 닫을 때 사용합니다. 소켓 파일 디스크립터를 해제하여 시스템 자원을 반환합니다. shutdown 호출없이 바로 호출해도 문제 없습니다.

소켓 통신 프로그램의 대략적인 흐름

  • 서버측: 소켓 생성 -> 포트 바인딩 -> 클라이언트 연결 수락 -> 데이터 송수신 -> 소켓 종료
    socket -> bind -> accept -> recv/send -> shutdown/close
  • 클라이언트측: 소켓 생성 -> 서버 연결 -> 데이터 송수신 -> 소켓 종료
    socket -> connect -> recv/send -> shutdown/close
  • 송수신 시 데이터 이동 경로: 유저 메모리 -> 커널 메모리 -> 네트워크 인터페이스(랜카드)

Address Family (AF_...)

Socket Type (SOCK_...)

Protocol (IPPROTO_...)

비동기 입출력

네트워크 동기 API의 한계

  • 입출력 동작은 CPU가 일을 하는 것(cpu bound)이 아닌, 외부 이벤트에 종속되는 비결정적 동작입니다. 이를 I/O bound 작업이라고 합니다.
  • 일반적인 입력 함수, 소켓 api의 send, recv를 포함한 각종 콘솔 입출력, 파일 입출력들은 함수 호출 시 입력이 감지될 때까지 스레드를 블로킹하도록 동작합니다.
  • 언제 처리될지 모르는 입출력 동작을 위해 한 없이 스레드를 할당(생성)하고 각 스레드를 블로킹 한다면, 자원 낭비가 심해지고 프로그램을 느려지게 하는 원인이 됩니다. (메모리, 컨텍스트 스위칭)
  • 특히나 서버측에선 수많은 소켓과의 통신을 다뤄야 하는데, 동기 api를 사용한다면 연결 수천 수만개에 대응되는 스레드를 하나씩 만들어줘야하는 문제가 생깁니다.

비동기 api의 기본 원리

  • 이를 위해 비동기 입출력(Asynchronous I/O)을 활용하여 입출력 완료시점까지 스레드를 대기(blocking)하는 것이 아닌,
    io 요청을 보낸 뒤 나중에 해당 동작에 대한 완료/준비 시그널을 받아오는 형태로 동작합니다.
  • 이 때, 시그널을 받는 것은 한 스레드에서 하나의 비동기 요청에 대한 응답을 받는 1:1 방식이 아닌, 하나의 스레드에서 여러개 입출력에 대한 시그널을 받는 일대다 관계입니다.
  • 즉, 하나의 스레드에서 수백 수천개의 소켓에 대한 IO 동작을 처리하는 것이 비동기 입출력의 목적입니다.

비동기 API 종류

  • Event Select
  • Poll
  • Epoll(Linux)
  • IOCP(Windows)
  • IO_uring(Linux)
  • RIO(Windows)
  • Kqueue(Apple) <- 다루지 않음

NonBlocking flag

  • 소켓을 논블로킹 모드로 설정하면, recvsend와 같은 함수들이 블로킹되지 않고 즉시 반환됩니다.
  • 이 때 함수가 반환하는 값은 error code로, EWOULDBLOCK이나 EAGAIN이 반환되면 아직 데이터가 준비되지 않았다는 것을 의미합니다.
  • 단순히 논블로킹 모드만 설정한다면 지속적인 폴링을 통해 데이터가 준비됐는지 확인해야합니다.
c
int n;
while (n = recv(sock, buf, sizeof(buf), 0)) {
    if (n == EWOULDBLOCK || n == EAGAIN) {
        continue;
    }
    if(n > 0){
        // process recv
    }
}

Select

  • 파일디스크립터들의 세트를 두고서 해당 세트 안에서의 이벤트 발생 여부를 살핍니다.
  • OS는 입출력 이벤트가 발생하면 비트 표시를 통해 송수신 여부를 기록한 뒤 select 콜백 함수를 호출시킵니다.
  • 사용자는 select함수가 종료되면 등록한 fd세트를 하나씩 순회하면서 송수신 여부를 확인하고서 accept, recv, send 함수를 호출합니다.
  • 여러개의 소켓을 감시하면서 입출력 준비가 되면 소켓 세트를 순회하면서 확인 후 recv/send 연산을 하는 방식으로, 하나의 스레드에서 여러개의 소켓을 감시할 수 있습니다.
  • 다만, 소켓 셋을 매번 순회 해야한다는 점, fd셋 또한 초기화 해야하는 점, 이후 동기적으로 recv호출 하면서 뒤늦게 IO 연산이 이뤄지는 방식이기에 느리다는 단점이 있습니다.
c
// 약식 코드
FD_SET fdRead;
while(1)
{
    // fd_set 초기화
    FD_ZERO(&fdRead);
    for (var socket in sockets)
        FD_SET(*it, &socket);
    // fd set에 변화가 발생할 때까지 동기적으로 대기
    ::select(0, &fdRead, NULL, NULL, NULL);
 
    for(int i=0; i < sockets.length(); ++i)
    {
        // 비트 검사
        if (!FD_ISSET(fdRead.fd_array[i], &fdRead))
            continue;
        
        // recv 호출 
        char szBuffer[1024] = { 0 };
        int nReceive = ::recv(fdRead.fd_array[i],
            (char*)szBuffer, sizeof(szBuffer), 0);
    }
}

poll

  • poll 방식도 select방식과 비슷하지만, set에 들어갈 수 있는 파일 디스크립터의 한계가 늘어났다는 것과 fdset을 매번 초기화해줘야했던 동작이 없어졌습니다.
c
// pollfd 구조체 배열 준비
struct pollfd fds[MAX_CLIENTS];
 
// 초기 설정 (루프 밖에서 한 번만 해도 됨)
for (int i = 0; i < MAX_CLIENTS; i++) {
    fds[i].fd = client_sockets[i];
    fds[i].events = POLLIN; // 읽기 이벤트에 관심 있음!
    fds[i].revents = 0;     // 결과값 초기화
}
 
while(1) {
    // fd_set을 매번 다시 채울 필요 없이 배열과 개수만 넘김
    // 타임아웃 5000ms
    int ret = poll(fds, MAX_CLIENTS, 5000);
 
    if (ret <= 0) continue;
 
    for (int i = 0; i < MAX_CLIENTS; i++) {
        // 비트 검사 대신 구조체의 revents 필드 확인
        if (fds[i].revents & POLLIN) {
            
            // 데이터 수신 (recv)
            char buf[1024];
            int n = recv(fds[i].fd, buf, sizeof(buf), 0);
            
            // 작업 완료 후 revents는 다음 poll 호출 시 커널이 알아서 초기화함
        }
    }
}

Epoll(Linux)

  • epoll은 전체 순회하는게 아닌 송수신 이벤트 존재하는 소켓 셋만 반환해주기에 좀 더 빠릅니다.
c
// 1. epoll 인스턴스 생성
int epfd = epoll_create(1);
 
// 2. 서버 소켓 등록 (최초 1회)
struct epoll_event event;
event.events = EPOLLIN; // 읽기 이벤트 감시
event.data.fd = serv_sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);
 
// 이벤트가 발생한 소켓들을 담을 배열 준비
struct epoll_event revents[MAX_EVENTS];
 
while(1) {
    // 3. 신호가 온 놈들의 '개수'를 반환하며, revents 배열에 그 명단을 채워줌
    int n = epoll_wait(epfd, revents, MAX_EVENTS, -1);
 
    // 4. 전수 조사가 필요 없음! 딱 신호 온 개수(n)만큼만 루프 돌면 됨
    for (int i = 0; i < n; i++) {
        if (revents[i].data.fd == serv_sock) {
            // 신규 연결 처리 (accept)
            // 신규 클라이언트도 epoll_ctl로 딱 한 번만 등록해주면 됨
        } else {
            // 데이터 수신 처리 (recv)
            // 명단에 있는 놈들이므로 즉시 작업 수행
        }
    }
}

IO_uring(Linux)

IOCP(Windows)

RIO(Windows)

네트워크 라이브러리 제작하기

  • 소켓 프로그래밍의 목적은 데이터를 통신하기 위함입니다. 웹이나 게임, 여타 프로그램들에서 네트워크 통신을 수행하기 위해 소켓 동작이 잘 추상화된 네트워크 라이브러리를 제작해야 합니다.
  • 예를 들어, 이 블로그를 제작한 Next.js에서 fetch 함수를 사용해 웹 API를 간편히 사용할 수 있습니다.
ts
  const response = await fetch(`${API_BASE_URL}/Auth/login`, {
    method: "POST",
    headers: {"Content-Type": "application/json",},
    body: JSON.stringify(data),
  });
  • 소켓 생성하고 연결, 송수신, 연결해제 구조가 완전히 추상화돼있음. await fetch를 수행하면 알아서 웹 요청을 보낸 컴포넌트가 메시지를 받아서 소비할 수 있는 구조.
  • 이처럼 네트워크 라이브러리를 제작할 때 라이브러리를 사용할 레이어의 특성을 생각하여 편하게 사용할 수 있는 api구조를 설계하는 것을 목표로하는게 좋을 것 같습니다.

주요 고려사항

멀티스레드 구조 캡슐화
  • 소켓 통신을 위해 돌아가는 여러 스레드를 신경 안 써도 되도록 감춰서, 응용 레벨에서 동시성을 신경쓰지 않아도 되도록 해야합니다.
  • 라이브러리는 하나의 메시지큐에 수신한 메시지들을 담고서 하나씩 소비할 수 있는 구조로 구성을 해주어야 합니다.
응용 환경 고려
  • 예를 들어 단순히 수동적으로 돌아가는 서버 측에서는 메시지 꺼내는 스레드가 블로킹이 되어도 문제가 없습니다만, 게임 프레임워크에서는 메인스레드에서 메시지 꺼낼 때 블로킹이되면 게임이 멈춰버리게 돼, 매 루프(tick)가 돌 때 소비할 메시지가 있는지 확인하는 함수를 제공하는 형태로 제작을 해야 합니다.

  • 메시지를 소비해야 할 컴포넌트 객체까지 메시지가 전달되는 구조가 매끄러워야 합니다. 특정 컴포넌트 객체가 서버에 질의를 보낸 후, 날라오는 응답에 의해 능동적으로 반응하는 것처럼 코드를 작성할 수 있는 구조로 만드는게 좋을 것 같습니다.
    예를 들면, 웹 프레임워크에서는 각 컴포넌트가 독자적으로 웹 요청(fetch)를 보낸 뒤 그에 대한 응답을 능동적으로 받아서 처리하는 것처럼 돌아가는데, 게임에 적용한 네트워크 라이브러리에서도 그런 구조가 될 수 있도록 하는게 좋을 것 같습니다.

스펙 요소
  • 서버측에서연결된 클라이언트를 구분하기 위한 세션 객체 혹은 세션 번호 같은 걸 같이 반환해줘야 합니다. 단순히 0부터 시작되는 번호가 아닌, 접속 시간 및 여러 메타 정보를 포함한 문자열을 활용해도 좋을 것 같습니다.
  • OnConnected, OnRecived, OnDisconnected와 같은 이벤트 핸들러를 제공해야 합니다.
  • 메시지 파싱 실패시 연결을 끊어 버리도록 해야합니다.

기타

프로토콜, 메시지 설계

  • TCP 환경 상에서, 상대방이 주어진 데이터가 순서대로, 손실 없이 도착하게 됩니다. 이는 연속적인 바이트 스트림으로 메시지가 송수신된다는 특징을 가집니다. 끊임없이 들어오는 바이트 스트림을 사용하기 위해서는 유의미한 단위로 구분지어 파싱하기 위해 자신만의 프로토콜을 구현해야 합니다.
  • 이를 위해 보통 고정 길이 기반 프로토콜을 사용하게 되며, 앞의 n 바이트 길이의 헤더를 읽고서 파싱 후 헤더에 있는 페이로드 길이 정보 m 바이트만큼을 파싱하는 방식으로 바이트 스트림을 처리합니다.
  • 혹은, http 프로토콜처럼 \n\r와 같은 구분자 설정을 통해 프로토콜을 설계할 수 있지만, 비효율적입니다.

버퍼 및 메시지 설계

  • 메모리 효율을 위해, 미리 할당받은 버퍼에 소켓으로부터 수신한 데이터를 쌓아두고서 해당 데이터를 파싱하는 식으로 소켓통신을 하게 됩니다.
  • 파싱을 하다보면 계속해서 버퍼 오프셋이 뒤로 밀리게 되고 그러다보면 남아있는 버퍼의 크기가 모자라게 됩니다.
  • 이 때, 새로 수신받은 데이터를 버퍼에 옮기기 전에 기존 버퍼에 남아있던 잔존 데이터들을 버퍼의 맨 앞 부분으로 이동해주고 뒤로 이어 수신하는 방식으로 메모리 절약을 해야 합니다.

NoDelay 옵션

  • TCP에서 데이터 크기가 작은 패킷을 자주 보낼 경우 오버헤드가 생길 수 있어(헤더 크기로 인한 데이터 늘어남, 패킷 수가 늘어나면 오버헤드 생김) Nagle 알고리즘을 통해 작은 패킷들을 하나로 묶어서 보내는 방식이 사용됩니다.
  • NoDelay 옵션을 켜면 Nagle 알고리즘이 비활성화되어 작은 패킷도 즉시 전송됩니다. 실시간성을 늘리기 위해 사용합니다.
  • 근데 직접 성능 테스트를 해본 적은 없어서 얼마나 유효한진 모르겠음.

메모리 복사는 몇 번까지 해도 될까

  • C++로 이악물고 만들면 억지로 zero-copy 까진 할 수 있을 것 같은데 어차피 네트워크 환경에서 패킷 송수신 되는 과정에서 데이터 복사 계속 발생할텐데 억지로 줄이려고 하는게 의미가 있나 싶음.

send, recv시에 메모리 복사 과정

  1. send요청을 통해 버퍼에 있는 데이터를 n바이트 전송해달라고 요청, OS는 해당 버퍼의 데이터를 커널 공간으로 복사
  2. OS가 커널 공간에서 데이터를 패킷으로 나누고, TCP 헤더를 붙이는 등의 작업을 수행하여 NIC로 데이터를 전송
  3. 네트워크 환경에서 송수신
  4. 수신측의 NIC에서 데이터를 받아 커널 공간으로 복사
  5. 커널 공간에서 사용자 공간으로 복사하여 애플리케이션이 데이터를 사용할 수 있도록 함
  • 유저 메모리 - 커널 메모리 - NIC 메모리 복사 과정을 생략하기 위한 기술도 존재함. 베어메탈, kafka

상용 네트워크 라이브러리

개인적인 소켓 프로그래밍 학습 기록

  • 2025년에 상반기에 진행해, C#에서 소켓을 통신을 활용한 채팅 프로그램을 만들어본 적이 있습니다. ASP.Net을 활용한 회원가입 시스템도 있습니다.
  • 제작한지 오래되기도 하고 너무 수준이 낮아서 따로 정리는 안 하였습니다.
  • C# 채팅 프로그램 깃허브 저장소
  • 2026년 1월 ~ 2월에 진행한 프로젝트입니다. C# 네트워크 라이브러리 제작을 하였으며, 채팅 프로그램 및 유니티 게임에 적용해 간단한 멀티플레이를 약식으로 구현해보았습니다.
  • Unity 2D Multi 블로그 설명
  • 2025년 하반기에 진행한 C++에서의 Winsock 활용한 채팅 프로그램 제작 프로젝트입니다.
    따로 정리를 하진 않았으며, Winsock 라이브러리에 대한 개괄적인 내용 정리 포스트르 작성할 계획이 있지만, 실행 의지는 아직 없습니다.
  • Winsock Programming 깃허브 저장소