목록

소켓만들기

원활한 서버관련 플러그인을 사용하거나 서버플머분과의 협업을 위해

소켓부분 예제 공부

3. 버클리 소켓

(멀티플레이어 게임 프로그래밍 3챕터 정리)

버클리 소켓 API는 BSD4.2 운영체제의 일부로 배포 되었는데, 프로세스와 TCP/IP 스택의 여러 계층 사이에 표준 인터페이스로 쓰기 위해 제공되었다.

배포 이후 여러 운영체제에 언어 포팅되어 사실상 네트워크 프로그래밍의 표준!

1. 소켓 만들기

  • 소켓 생성 은 간단하다.

    SOCKET socket(int af, int type, int protocol);
    • af 파라미터는 주소 패밀리를 뜻하며, 소켓에 쓰일 네트워크 계층 프로토콜을 지정하는데 사용
      AF_UNSPEC : 지정하지 않음
      AF_INET : 인터넷 프로토콜(버전 4)
      AF_IPX : IPX(예전에 많이 쓰던 넷워크 프로토콜)
      AF_APPLETALK: 애플토크(구형 매킨토시용)
      AF_INET6 : 인터넷 프로토콜(버전 6)
      요즘 게임 만들 땐 대게 IPv4가 기준이므로 AF_INET쓰면 무난.

    • type 소켓을 주고 받을 패킷종류 지정, 이 값에 따라 소켓이 사용하는 전송 계층 프로토콜이 패킷을 처리하는 방식이 달라진다.
      SOCK_STREAM : 순서와 전달이 보장되는 데이터 스트림, 스트림의 각 세그먼트를 패킷으로 주고받음.
      SOCK_DGRAM : 각 데이터그램을 패킷으로 주고받음.
      SOCK_RAW : 패킷 헤더를 응용 계층에서 직접 만들 수 있음.
      SOCK_SEQPACKET: SOCK_STREAM과 유사하나 패킷 수신 시 항상 전체를 읽어 들여야 함.

      소켓 타입을 SOK_STREAM으로 하면 -> 상태유지형 연결을 만들게 되며(데이터 처리 할수 있게 필요 리소스 할당), 신뢰성 있고 순서 보장되므로 -> TCP프로토콜에 어울리는 소켓 형식. SOCK_DGRAM으로 사용 시 연결 상태 유지할 필요 없으므로 최소한의 리소스만 할당 -> 신뢰성 신경X, 패킷의 순서보장X -> UDP 프로토콜에 어울린다.

    • protocol 소켓이 데이터 전송에 실제로 사용할 프로토콜의 종류를 명시하는데 사용. 전송 계층 또는 각종 인터넷 유틸네트워크 계층 프로토콜 중 하나를 선택할 수 있으며, 보편적으로 protocol 파람 지정한 값은 외부로 나가는 IP 헤더의 프로토콜 필드에 직접 기록(노출될수 잇다?) 그러면 수신 측 운영체제가 이 값으로 패킷에 포함된 데이터를 어케 해석할지 판단.
      IPPROTO_UDP : 필요소켓 종류는 SOCKDGRAM이며, 의미는 UDP데이터그램 패킷.
      __IPPROO
      TCP__ : 필요소켓 종류는 SOCKSTREAM이며, 의미는 TCP 세그먼트 패킷.
      __IPPROTO
      IP 또는 0__ : 필요소켓 종류는 상관없고, 의미는 주어진 소켓 종류의 디폴트 프로토콜 사용.
      여기서 알아두면 편리한 것은 프로토콜을 0으로 지정하면 운영체제가 알아서 소켓 형식에 맞는 디폴트 프로토콜로 골라준다는 점.

    IPv4UDP 소켓을 만들려면 다음과 같이 호출

    SOCKET udpSocket = socket(AF_INET, SOCK_DGRM, 0);

    TCP 소켓은 이렇게..

    SOCKET tcpSocket = socket(AF_INET, SOCK_STREAM, 0);

    소켓 종류 상관없이 소켓을 닫으려면 closesocket()함수호출

    int closesocket(SOCKET sock);

    중요한 점은 TCP 소켓을 해제할 떈 나가고 들어오는 잔여 데이터 전부가 전송이 완료되고 확인 응답까지 마친상태에 끝내는게 중요!! 소켓을 닫기 전에 전송과 수진을 중단하려면 shutdown() 함수를 호출.

    int shutdown(SOCKET sock, int how);

    how 파람으로는 SD_SEND 를 넘겨 전송을 중단하며,

    SD_RECEIVE 로 수신을 중단한다.

    혹은, SD_BOTH 를 써서 송수신을 중단할 수 있다.

    더 자세히는 SD_SEND 를 지정하면 현재 전송 중인 데이터를 모두 전송 뒤 FIN 패킷을 보내는데,

    이로 써 상대방에게 이제 연결을 안정하게 닫고자 한다는 걸 알려줌.

    그러면 상대방도 FIN 패킷을 응답하게 된다. -> 서로 주고 받고 나면 소켓을 실제로 닫아도 안전하다.

    소켓을 닫으면 관련 리소스를 모두 운영체제에 반납하며, 사용 마친 소켓은 반드시 닫도록하자!!


2. 운영체제별 API차이

크로스 플랫폼 소켓 개발 시 미리 알아두면 좋은 것들

  • 플랫폼별로 소켓자체를 나타내는 자료형이 다르므로 주의해야한다.

    • 윈10이나 XBox등 윈도 기반 플랫폼은 SOCKET이 UINT_PTR에 대한 typedef인 반면, POSIX기반 리눅스나 맥os는 소켓함수는 그냥 int 값 하나에 불과하다. socket() 함수가 정수를 리턴하는 것에는 큰 단점 이 있는데, 자료형 안전성이 많이 부족하다는것 -> 아무 정숫값(5 X 4같이) 넣어도 컴파일러는 전혀 알아채지 못한다.
  • 라이브러리를 쓰기 위해 사용하는 헤더 파일이 다르다는 것.

    • 윈도용 소켓 라이브러리는 보통 Winsock2를 사용해서 Winsock2.h 를 #include해야 한다. 하지만 예전 구식 라이브러리인 Windows.h가 자동 포함 되어 있으므로 구식 헤더파일에서 소켓 기본 함수의 이름을 선점해 버리므로 충돌 발생 이러한 혼란을 피하려면 WIndows.h를 인클루 전에 먼저 WinSock2를 인클루드하거나, Windows.h를 인클루하기 앞서 WIN32LEANAND_MEAN 매크로 를 #define해야한다.(이 매크로 정의해두면 전처리기가 Winsock관련 내용 및 문제 있는 부분을 포함하지 않게 한다.)

    • POSIX 플랫폼sys/socket.h 를 인클루드하여 쓴다. IPv4 전용 기능을 사용하려면 netinet.in.h 를 인클루해야 할것. 주소변환 기능 사용 시 arpa/inet.h 인클루드하자. 네임 리졸루션 사용 시 netdb.h 인클루드

    • 소켓 라이브러리를 초기화/마무리 하는 방법도 플랫폼마다 다르다 POSIX 플랫폼에선 라이브러리가 항상 활성화 상태로, 딱히 뭔가 먼저 해줄 필요는 없다. 하지만 Winsock2는 명시적으로 초기화와 마무리를 해야한다. 또한 어느 버전의 라이브러리를 쓸지 지정해야하며 윈도 소켓 라이브러리 활성화 하려면 WSAStartup()함수 호출해야한다.

      //소켓 라이브러리 활성화.
      int WSAStartup(WORD wVersionRequested, LPWSADATA lpWSAData);

      wVersionRequested 는 Winsock 구현 버전 선택이므로, 현재 높은 버전은 2.2로 MAKEWORD(2, 2)넘겨준다.

    lpWSAData 는 윈도 전용 구조체로, WSAStartup()이 활성화된 라이브러리에 대한 정보로 값을 채워준다.(버전 정보 등..)

    WSAStartup() 는 성공 시 0리턴 하거나 에러코드 리턴한다.(대부분 에러 이유는 Winsock2함수가 제대로 동작하지 않아서이다..)

    라이브러리 사용을 종료하고 마무리 할 떄는 WSACleanup() 호출

    int WSACleanup();

    WSACleanup() 는 파람값이 없고 리턴값은 에러코드이다. 호출 시 현재 진행 중이던 모든 소켓 동작이 강제 종료되고 소켓리소스는 모두 소멸 된다. -> Winsock을 마무리 할 떄는 모든 소켓이 닫혀있고 사용이 끝났는지 체크 주의! 또한 WSACleanup는 레퍼 카운트 되므로 호출 횟수만큼 WSACleanup을 호출해야 실제 마무리 작업이 일어난다.

    • 플랫폼 마다 에러통보 방식이 좀 다른데, 대부분 함수는 에러 시 -1을 리턴하고, 윈도에는 -1 대신 SOCKET_ERROR를 사용한다. 더 자세히 알려면 Winsock2의 WSAGetLastError()함수를 쓰면 된다.

      // 현재 실행중인 스레드에서 마지막으로 발생한 에러 코드만 저장해 둔다.
      // 즉, 소켓 라이브러리 결과로 -1 받으면 즉시 확인해야 한다.
      int WSAGetLastError();

      POSIX 소켓 라이브러리는 별도 함수를 따로 두지 않고 C표준라이브러리 errno 전역 변수를 같이 사용한다. 이 에러 값을 확인 하려면 errno.h를 인클루드하자.


3. 소켓 주소

모든 넷워크 계층 패킷에는 발신지 주소와 목적지 주소가 필요하다. 전송 계층 패킷은 여기에 더해 발신지 포트와 목적지 포트가 필요. 이러한 주소 정보를 소켓 라이브러리와 주고받기 위해 API에 sockaddr 자료형이 정의되어있다.__

struct sockaddr
{
    uint16_t sa_family;
    char     sa_data[14];
};
  • sa_family는 주소의 종류를 나타내는 상숫값. 소켓 주소를 소켓에 사용하려면 sa_family 값이 소켓을 만들 떄 썻던 af파람과 일치해야한다.
  • sa_data 필드는 주소 패밀리에 따라 다양한 포맷의 주소를 담아야 하므로 바이트의 배열로되어 있다.

이걸 좀 더 보완한 패킷 구조체가 아래와 같은 형식.

struct sockaddr_in
{
    short           sin_family;
    uint16_t        sin_port;
    struct  in_addr sin_addr;
    char            sin_zero[8];
};
  • sin_familysa_family 와 같은 위치며 같은 기능을 한다.

  • sin_addr 는 4바이트의 IPv4 주소를 나타내며 in_addr 자료형은 소켓 라이브러리 마다 조금씩 다르다. 보통 마침표로 구분된 각 숫자를 네 개의 바이트로 쓰는 경우가 많다.

struct in_addr
{
    union
    {
        struct
        {
            unit8_t s_b1, s_b2, s_b3, s_b4;
        }S_un_b;
        struct
        {
            uint16_t sw1, s_w2;
        }S_un_w;
        uint32_t S_addr;
    }S_un;
};
  • S_un 유니온 중 Sunb 구조체의 값 sb1, sb2, sb3, sb4 필드에 값을 넣으면 사람이 읽기 쉬운 형태로 주소를 지정할 수 있다.

  • sin_zero 는 사용하지 않으며 sockaddr_in 구조체의 크기를 sockaddr과 맞추기 위한 패딩값이다. 일관성을 위해 0으로 모두 채워야 한다.

IP주소를 지정할 떄 이렇게 여러 바이트를 묶어서 쓰려면 TCP/IP 스택과 호스트 컴퓨터 바이트 순서 체계가 서로 다를수 있다는 점에 유의 해야 한다. 나중 챕터에 플랫폼마다 순서가 어떻게 다른지 알아보겠지만, 여기선 일단 소켓 주소 구조체에서 여러 바이트로 된 숫자를 호스트의 순서가 아닌 네트워크의 순서 체계로 변환해야 한다는 점.

라이브러리에서 제공하는 htons()htonl() 함수 사용.

//호스트바이트 순서 -> 네트워크 바이트 순서로 변환.
//부호 없는 16비트 정수 받는다.
uint16_t    htons(uint16_t hostshort);
//부호 없는 32비트 정수 받는다.
uint32_t    htonl(uint32_t hostlong);

참고로 호스트 순서와 네트워크 순서가 같은 플랫폼 에서는 이들 함수는 동작하지 않는다. -> 컴파일러가 인지해 함수 호출 자체가 없는 것처럼 코드를 생성하지 않는다. 또한 바이트 순서가 바꾸는 과정에 sockaddr_in 구조체의 sa_port 필드 값이 원래 지정한게 아니라 다른 값처럼 보이는데, 이는 바이트 순서가 뒤집힌채 십진수로 보이기 떄문이므로 혼돈 하지 말자.

패킷 수신하는 등 몇몇 경우엔, 즉, sockaddr_in 구조체 내용을 채워 주는 경우 받은 시점에 sockaddr_in 의 각 필드는 네트워크 바이트 순서로 채워져 있으므로, 이를 다시 호스트 바이트 순서로 변환해야한다.

라이브러리에서 제공하는 ntohs()ntohl() 함수 사용.( htons(), htonl() 함수와 반대로 작동. )

uint16_t ntohs(uint16_t networkshort);
uint32_t ntohl(uint32_t networklong);

소켓 주소 만들고 IP주소 지정하는 짤막한 예제.

//IP 주소는 65.254.248.180, 포트는 80번
sockaddr_in myAddr;
memset(myAddr.sin_zero, 0, sizeof(myAddr.sin_zero));
myAddr.sin_family = AF_INET;
myAddr.sin_port = htons(80);    //포트 번호 변환.
myAddr.sin_addr.S_un.S_un_b.s_b1 = 65;
myAddr.sin_addr.S_un.S_un_b.s_b2 = 254;
myAddr.sin_addr.S_un.S_un_b.s_b3 = 248;
myAddr.sin_addr.S_un.S_un_b.s_b4 = 180;

3.1 자료형 안정성

소켓 라이브러리는 안정성에 대해서 첨부터 고민 없이 구현된 상태이므로 사용자 편의성 및 안정성을 위해 객체 지향 형태로 감싸(wrapping) 구현해 두면 유용하다. 또한 특정 소켓 API에 고착되지 않고 나중에 다른 넷워크 라이브러리랑 교체 할 떄 유용하다.

class SocketAddress
{
public:
    //기본 inAddress, inPort를 받는 소켓 생성 초기화.
    SocketAddress(uint32_t inAddress, uint16_t inPort)
    {
        GetAsSockAddrIn()->sin_family = AF_INET;    //기본으로 IPv4 지정
        GetAsSockAddrIn()->sin_addr.S_un.S_addr = htonl(inAddress);
        GetAsSockAddrIn()->sin_prot = htons(inPort);
    }

    //sockaddr 받았는데 SocketAddress랩핑 하고 싶을 떄 이용할 생성자.
    SocketAddress(const sockaddr& inSockAddr)
    {
        memcpy(&mSockAddr, &inSockAddr, sizeof(sockaddr));
    }

    size_t GetSize() const { return sizeof(sockaddr); }

private:
    sockaddr mSockAddr;

    sockaddr_in* GetAsSockAddrIn()
    {
        return reinterpret_cast<sockaddr_in*>(&mSockAddr);
    }
};

//shared_ptr 선언해 여러 곳에서 공유하며 레퍼런스 카운터 관리.
using SocketAddressPtr = shared_ptr<SocketAddress>;

3.2 문자열로 sockaddr 초기화하기

IP 주소와 포트값을 일일히 채우기 번거롭고 설정 파일등에서 문자열로 받는 경우 이를 처리해야하는데 물론 라이브러리에서 지원하는 함수가 있다. 윈도에선 inetPton()함수를 쓰고 POSIX계열은 inet_pton() 함수를 사용하면 된다.

  • 문자열을 IP주소로 바꾸는 경우

    //@param af = 패밀리 주소로 보통 AF_INET이나 AF_INET6지정
    //@param src = 문자열로된 IP 주소
    //@param dst = 변환된 sin_addr주소 필드의 포인터
    int inet_pton(int af, const char* src, void* dst);
    int InetPton(int af, const char* src, void* dst);
    // 성공시 1, 문자열 해석 오류시 0, 시스템 에러 시 -1리턴

    초기화 예제.

    sockaddr_in myAddr;
    myAddr.sin_family = AF_INET;
    myAddr.sin_port = htons(80);
    InetPton(AF_Inet, "65.254.248.180", &myAddr.sin_addr);

DNS(query)를 수행해 도메인 네임을 IP주소로 변경하고 싶다면. getaddrinfo() 함수 사용.

  • 도메인 에임 IP주소로 바꾸는 경우

    int getaddrinfo(const char* hostname, const char* servname, const addrinfo* hints, addrinfo** res);

    hostname은 도메인 조회를 할 이름 문자열(ex. “live-shore.herokuiapp.com”) servname엔 포트 번호 또는 서비스 이름을 hints엔 호출자가 어떤 정보를 받고 싶은지를 기재해 둔 addrinfo 구조체의 포인터를 넘긴다. 그냥 모든 결과 받으려면 nullptr넘기면 됨. 결과는 res로 지정한 포인터 주소로 반환. 여러 결과인 경우 연결리스트로 반환되며 res는 그 첫쨰 원소가 된다.

    struct addrinfo
    {
        int     ai_flags;           
        int     ai_family;
        int     ai_socktype;
        int     ai_protocol;
        size_t  ai_addrlen;     //ai_addr이 가리키는 sockaddr의 길이.
        char*   ai_canon_name;  //리졸브된 호스트명의 대표이름.
        sockaddr* ai_addr;      //sockaddr을 담는다.
        addrinfo* ai_next;      //연결 리스트상 다음 addrinfo정보. 마지막일 시 nullptr.
    }

    여기서 주의점은 getaddrinfo()addrinfo구조체 반환할 떄, 자제적으로 메모리 할당하므로, 반환된 연결리스트 내용 다 사용 후 호출자가 직접 freeaddrinfo()를 호출해 메모리를 해제해 주어야 한다.

    이떄, 반드시 aigetaddrinfo()에서 받은 맨 첫 번쨰 addrinfo를 넘겨야한다. -> 자체 순회하면 메모리 해제함으로.

    void freeaddrinfo(addrinfo* ai);
    • 내부 대략적인 프로세스는

    호스트 네임을 IP주소로 리졸브(resolve) 해석하기 위해, getaddrinfo() 함수는 운영체제에 설정된 대로 DNS 프로토콜 패킷 만든 뒤 UDP나 TCP로 DNS 서버에 보낸다. 이후 응답 대기 후 파싱해서 addrinfo 구조체의 연결리스트를 만들어 호출자에게 돌려준다.

    이 과정에서 호스트 정보를 보내고 받으므로 시간이 지체 될 수 있는데 getaddrinfo() 는 비동기 동작하게 하는 옵션이 없어 호출 스레든느 응답 받을 때 까지 마냥 블로킹 되야한다. 이는 사용자 입장에서 좋지 않으므로 -> 해결책으로는 IP주소 리졸브 할 일이 있다면 별도의 스레드에서 돌리는 방안 을 모색해야함.

    -> GetAddrInfoEx() 윈도에서 지원하는 함수로 별도 스레드를 만들지 않아도 비동기식으로 동작하게하는 옵션이 있다.

    예제 작성... 107

3.3 소켓 바인딩하기

운영체제가 어떤 소켓이 특정 주소와 전송 계층 포트를 쓰겠다고 알려주는 절차를 바인딩 이라고 일컬는다.

int bind(SOCKET sock, const sockaddr* address, int address_len);

sock 은 바인딩할 소켓으로, 앞서 만든 socket()의 객체

address 는 소켓을 바인딩할 주소. - 회신 주소를 밝혀 두기 윈한 것. 또한, 어느 네트워크 인터페이스를 사용할지 명시 가능.

address_len 은 sockaddr의 길이.

성공시 0, 실패 시 -1리턴.

  • 소켓 바인딩의 의미

    1. 운영체제가 이 주소와 포트를 목적지로 발신된 패킷을 수신하면 운영체제는 이제 이 소켓으로 넘겨준다.
    2. bind() 에서 지정한 주소 및 포트를 이 소켓을 통해 나가는 패킷의 네트워크 계층과 전송 계층 해더의 발신 주소와 포트로 운영체제가 쓰게 된다.

일반적으로 주소와 포트 쌍 하나당 하나의 소켓만 바인딩 가능. 이미 사용중인 거 바인딩 할 시 에러 리턴. 이 경우 아직 사용 중이지 않은 포트를 찾을 떄 까지 반복해 바인딩 시도 해야하는데. bind() 호출 시 포트에 0지 정하면 자동으로 사용 중이지 않은 포트 하나를 골라 바인딩 한다.

데이터를 전송 및 수신 하려면 반드시 솤세이 바인딩 되어야 한다!! 만인 바인딩 되지 않은 소켓으로 데이터 보내려 하면 라이브러리는 자동으로 남은 포트에 바인딩 해준다. 따라서 주소와 포트를 확실히 고정 아니라면 굳이 바인딩 할 필요는 없다. (그래도 명시적으로 해주는게 낫지 않을까..)


4 UDP 소켓

  • 데이터 송신

    UDP 소켓은 만든 즉시 데이터를 보낼 수 있다. 바인딩하지 않은 상태라면 넷워크 모듈이 동적포트 범위에 남아 있는 포트를 자동으로 바인딩해준다. sendto() 함수 이용.

    int sendto(SOCKET sock, const char* buf, int len, int flags, const sockaddr* to, int tolen);

    sock 는 데이터그램을 보낼 소켓. 바인딩한 주소와 포트는 외부로 나가는 패킷 헤더의 발신자 주소가 된다.

    buf 는 보낼 데이터의 시작 주소를 가리키는 포인터이며, 꼭 char형 데이터만 보낼 수 있는건 아니다. char로 캐스팅 할 수 있따면 어떤 것이든 보낼 수 있다.

    len 은 데이터의 길이. 패킷 최대 길이는 ㄹ이크 계층의 MTU로 결정되는데 이더넷의 MTU는 1,500바이트 이다. 근데 이 조차 여러 계층의 헤더와 여타 패킷 래퍼를 포함할 수 있게 공간이 필요하므로 분열을 피하려면 1,300바이트 내로 해야한다는걸 명심!!

    flags 는 데이터 전송을 제어하는 비트 플래그. 대부분 게임 코드에선 0으로 둔다.

    to 는 수신자의 목적지를 가리키는 sockaddr이다. 여기서 주소 패밀리는 소켓을 만들 떄 지정한 주소 패밀리와 일치해야 한다. 이 인자에 설정한 주소와 포트가 IP 헤더와 UDP 헤더로 복사되어 패킷의 IP 주소 및 UDP 포트가 된다.

    tolen 은 sockaddr의 길이를 지정한다. IPv4에선 그냥 sizeof(sockaddr_in)을 넣어준다.

    동작 성공시 송신 대기열에 넣은 데이터의 길이를 리턴하고, 이외의 경우는 -1을 리턴한다. 양수 값 막 리턴 되었다고 전송 완료된건 아니구 네트워크 모듈의 전송 대기열에 막 등록되었다는 정도로 이해.

  • 데이터 수신

    별다른 정차 없이 recvfrom() 함수를 호출.

    int recvfrom(SOCKET sock, char* buf, int len, int flags, sockaddr* from, int* fromlen);

    sock 은 데이터를 받으려는 소켓. 별달느 옵션 설정 없는 경우, 수신된 데이터그램이 없으면 스레드가 블로킹되어 데이터그램을 수신할 떄까지 기다린다.

    buf 는 수신한 데이터그램을 복사해 넣을 버퍼이다.

    len 은 buf 인자가 담을 수 있는 최대 바이트 길이를 지정. 버퍼 오버플로 에러를 방지하기 위해 지정한 숫자 이상의 바이트를 복사하지 않는다. 그러므로 넉넉히 잡도록.

    flag 는 데이터 수신을 제어하는 비트 플래그. 대부분 게임에선 0이면 충분.

    from 은 sockaddr 구조체의 포인터로, 데이터를 받았을 때 발신자의 주소와 포트를 채워줄 곳을 가리킨다. 이는 단지 어느 발신자로부터 수신되었는지 확인하는 용도.

    fromlen 은 위의 from 인자의 길이를 반환해 줄 정수 포인터. from이 안채워지면 이 또한 채워지지 않는다.

    동작 성공시 buf에 복사한 바이트의 길이를 리턴, 에러 시 -1을 리턴한다.

4.1 자료형 안전성을 보강한 UDP 소켓

UDPSocket 클래스 구현 - 주소를 바인딩하고 데이터그램을 송수신 하는 기능 구현.(111페이지)

ㅁㄴㅇ


5 TCP 소켓

UDP는 내부 상태 없고 연결 유지X, 신뢰성 보장하지 않는다. 따라서 호스트마다 하나의 소켓만 있으면 됨. 반면, TCP는 신뢰성을 보장하며, 데이터 주고받기 위해 두 호스트 사이에 연결을 맺어야 하며 또한 누락된 패킷을 재전송 하기 위해 상태 정보를 유지 해야한다. 버클리 소켓 API에선 socket 자체에 그 연결 정보를 기록한다. 이는 곧, 호스트가 각 TCP 연결마다 별개의 독자적 소켓을 하나씩 유지한다는 뜻.

  • TCP에서 클라와 서버 사이에 연결을 초기 수립하려면 3-웨이 핸드셰이킹 을 거쳐야한다.

    1. 첫단계 는 만들어둔 소켓에 특정 포트 바인딩 한 뒤 들어오는 핸드셰이킹을 리스닝 해야한다.

      socket() 만들고 bind() 바인딩 후, listen()으로 리스닝을 시작한다.

      int listen(SOCKET sock, int backlog);

      sock 은 리스닝 모드에 둘 소켓. 소켓이 리스닝 모드에 있으면외부에서 오는 TCP핸드셰이킹 첫 단계 여청을 받아 대기열에 저장해 둔다. 그 뒤 accept()호출 하면 그 다음 핸드셰이킹 단계 속행.

      backlog 는 들어오는 연결을 대기열에 둘 최대 숫자를 지정. 가득차면 그 이후에 들어오려는 연결은 끊긴다. 기본값 사용 시 SOMAXCONN 을 넣어주자.

      성공 시 0, 에러 시 -1을 리턴한다.

    2. 들어오는 연결을 받아 계속 TCP핸드셰이킹 진행하려면 accept() 를 호출한다.

      SOCKET accept(SOCKET sock, sockaddr* addr, int* addrlen);

      sock 은 리스닝 모드의 소켓으로, 여기서 들어오는 요청을 받게 된다.

      addraccept() 함수가 연결을 요청하는 원격 호스트의 주소를 채워줄 sockaddr 구조체 포인터.(그저 주소를 받는 용도로만 사용.)

      addrlen 은 addr 버퍼의 포인터 길이를 반환하는 용도로 사용. 이 또한 addr 내용이 채워질 떄 값이 채워진다.

      함수가 성공하면 내부적으로 새 소켓이 만들어져 리턴 되며 이 소켓은 이후 원격 호스트와 통신하는 용도로 쓸 수 있다. 새 소켓은 리스닝 소켓과 같은 포트에 바인딩 된다. TCP는 연결된 각 원격 호스트 마다 소켓을 하나씩 가지고 있어야 한다는 사실을 기억해두자!!

      리턴 된 새 소켓은 원격 호스트에 대응되며, 원격호스트의 주소와 포트가 기록되고 이 호스트로 보내는 패킷 전부가 저장되어 나중에 누락이 발생해도 재전송 할 수 있게 사용된다.

    3. 서버는 listen() 상태에서 accept() 를 호출해 접속을 기다려야 하지만 클라 는 일대일 대칭 관계가 아니므로 소켓만든 후 connect() 만 호출하면 된다. 그러면 바로 해당 원격 서버에 접속해 핸드셰이킹 절차를 시작하게 된다.

      int connect(SOCKET sock, const sockaddr* addr, int addrlen);

      sock 연결에 사용하고자 하는 소켓

      addr 연결하고자 하는 원격 호스트 주소를 가리키는 포인터

      addrlen addr 인자의 길이

      성공 시 0 리턴, 에러 시 -1 리턴

      connect() 호출하면 최초 SYN패킷을 대상 호스트에 전송해 TCP 핸드셰이킹을 개시. 원격 호스트에 해당 포트로 바인딩한 리스닝 모드 소켓이 있는경우, 서버 원격 호스트는 accept() 호출해 이 핸드셰이킹을 처리. 별다른 옵션 지정 안하면 connect()호출 시 호출 스레드는 연결이 수락되거나 시간 초과될 떄 까지 블로킹 된다.

5.1 연결된 소켓으로 데이터 보내고 받기

  • 데이터 보내기

    연결된 TCP 소켓은 원격 호스트의 주소 정보를 간직하므로 덕분에 매 데이터 전송 시 주소 정보를 일일이 넘겨주지 않아도 된다. 전송 할떄는 sendto() 대신 sned() 함수를 호출.

    int send(SOCKET sock, const char* buf, int len, int flags);

    sock 데이터를 보내는데 사용할 소켓.

    buf 는 스트림에 기록할 데이터가 담긴 버퍼. UDP와 달리 데이터그램이 아니며 한 번에 전송된다는 보장이 없다. 대신 데이터는 소켓 외부 전송요 버퍼에 추가 되었다가 적당한 시기에 전송된다.

    len 전송할 데이터의 바이트 수. UDP와 달리 링크 계층의 MTU보다 작게 잡을 필요 없고, 소켓의 전송 버퍼에 자리가 있는 한 네트워크 라이브러리는 데이터 보두 보낼 수 있다.(적당한 크기로 잘라서 보내게 될 것.)

    flags 데이터 전송을 제어하는 비트 플래그 대개 게임에서는 0으로 설정.

    함수 호출 성공시 전송한 데이터의 길이를 리턴한다. -> 이 값이 len에 지정한 값보다 작다면 소켓 전송 버퍼가 전부 보내기에는 모자라서 여유 공간만큼 잘라 보냈다는 뜻 공간이 아예 없다면 호출 스레드는 블로킹되 계속 기다리게 된다. 에러가 있다면 -1을 리턴한다. 아참 양수리턴되었다고 전송 완료가 아니라 전송 대기열에 등록되었다는 의미로 이해.

  • 데이터 받기

    recv() 함수 호출한다.

    int recv(SOCKE osck, char* buf, int len, int flags);

    buf 는 데이터를 복사해 넣을 버퍼. 복사하고 나면 해당 데이터는 소켓 내부의 수신 버퍼에서 제거된다.

    len 은 버퍼에 넣을 수 있는 데이터 크기의 상한선.

    flags 는 데이터 수신을 제어하는 비트 플래그 항상 그랬듯 0으로..

    호출 성공 시 수신한 바이트의 길이를 리턴 이는 len 보다 작거나 같은 값이 된다.

    또한, send() 한 번 호출해서 일정 길이의 바이트를 보내도, 상대편이 recv() 호출 시 똑같은 길이를 받는다고 보장할 수 없다. 왜냐면 보내는 측의 넷워크 라이브러리가 적당한 크기가 될 떄까지 데이터 보관 후 보낼 수 있기 떄문.

    한가지 팁으로는 len 에 0을 넣어 recv호출 해 0이 리턴되면 -> 이는 소켓에서 읽을 것이 있다는 뜻이므로. 이 방법으로 소켓이 읽을 준비가 되었는지 확인해 볼 수 있다. 데이터가 있을 떄만 버퍼 할당하고 recv호출 하면된다.

    -1 리턴인 경우 에러.

    디폴트로 수신 버퍼에 데이터가 없는 경우, 데이터 조각이 수신될 떄 까지 혹은 시간 초과될 떄지 기다린다.

5.2 자료형 안정성을 보강한 TCP 소켓

117쪽 참고


6. 블로킹 I/O와 논블로킹 I/O

소켓 관련 함수는 대부분 블로킹 호출이며, 받을 데이터가 없을 떄 스레드가 블로킹 되어 데이터가 수신될 떄 까지 기다린다. 앞서 배운 send(), accept(), connect() 함수 모두 블로킹 호출 이다.

실시간 게임에서는 이같은 지연은 바람직하지 않다. 서버 스레드는 여러 소켓 중 하나 recv()를 호출하고 해당 클라가 데이터 보냈는지 체크하는 동안 블로킹 되어있으면, 다른 소켓을 검사할 수 없다. -> 세가지 방법으로 해경 가능한데, 바로 멀티스레딩, 논블로킹I/O, select() 함수가 그것이다.

멀티 스레딩 은 말그대로 스레드 여러개 둬서 극복하는 방법.

논블로킹 I/O 는 소켓을 논블로킹 모드로 설정(지원함).

int ioctlsocket(SOCKET sock, long cmd, u_long* argp);

sock은 논블로킹 하고자하는 소켓.

cmd는 제어하고자 하는 소켓 파라미터로 보통 FIONBIO를 지정.

argp는 파라미터에 설정하련느 값, 0이 아닌값이면 논블로킹, 0이면 블로킹모드 지정.

-> 작업이 완료되기 전까지 블로킹 걸던 함수들이 이제 기다리지 않고 즉시 리턴한다.

논블로킹 소켓은 간편하면서도 스레드를 블로킹하지 않고 데이터 수긴된 것이 있는지 확인해 직관적인 방법.

하지만, 폴링 해야 할 소켓의 수가 많다면 이 방법도 비효율적이다.(병목현상 등)

select()함수 는 소켓을 한꺼번에 확인 하고 그중 하나라도 준비되면 즉시 대응되는 방법.

int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, const timeval* timeout);

nfds 는 POSIX플랫폼에서 소켓 식별자 확인용으로 넣는 인자.(윈도에선 무시해도 됨)

readfds 는 소켓 컬렉션을 가리키는 포인터 fd_set형 이다. 읽을 준비가 되었는지 확인 할 소켓을 넣는다.

writefds 는 쓰기용으로 체크하고 싶은 소켓을 담는다.

exceptfds 는 에러 검사하고자 한느 소켓을 담는다.

timeout 은 최대 제한 시간ㅇ르 지정.

select() 함수는 리턴 시점에 readfds, writefds, exceptfds에 남아있는 소켓 개수를 리턴 즉 세 집합의 총 나은 개수 리턴. 시간 초과인 경우엔 0을 리턴한다.