본문 바로가기

Network/Network Programming

[C++/Network] Berkeley Socket (버클리 소켓)

[이전 글]

[Network] TCP/IP 스택과 네트워크의 계층 구조

 

 네트워크 프로그래밍에서 빠질 수 없는 것이 바로 소켓입니다. 소켓은 버클리 대학에서 만들어져 Berkeley Socket 이라는 이름으로 BSD 운영체제(특히 UNIX)에서 처음 배포되었습니다. 그랬던 것이, 오늘날 네트워크 프로그래밍의 사실상의 표준으로 자리매김했습니다.

 

 네트워크 통신을 위해 프로세스는 실행 도중에 하나 이상의 소켓을 만들고 초기화해 소켓 API로 제어합니다. 이렇게 만들어진 소켓으로 데이터를 읽고 쓰게 됩니다. 즉, 소켓은 데이터가 통신할 수 있도록 연결해주는 인터페이스라고 할 수 있습니다. 

 

 서론은 여기까지. 바로 소켓의 사용법을 알아보겠습니다.

 

0. 소켓 라이브러리

 

 윈도우 운영체제에서는 C++로 소켓 프로그래밍을 하기 위해서, Winsock2.h라는 헤더 파일을 불러와야 합니다. 그런데, 선언하실 때 잘 보시면 Winsock헤더 파일도 있습니다. Winsock은 Winsock2와 함께 사용하면 충돌이 생긴다고 합니다. 기능도 부족하고 최적화도 덜되어있기 때문에 Winsock2 사용을 권장한다고 하네요.

 

  리눅스 운영체제에서는 sys/socket.h를 불러와서 소켓 프로그래밍을 할 수 있습니다. 지금 제가 사용하는 컴퓨터는 Windows 환경이므로 Winsock2를 사용하도록 하겠습니다.

 


 

1. 소켓 생성

 

 가장 먼저 소켓을 생성하기 위한 함수입니다.

 

SOCKET socket(int af, int type, int protocol);

 

각각의 인수들을 살펴보겠습니다.

 

af는 address family의 약자로 소켓에 사용할 네트워크 계층 프로토콜을 지정하는 데 사용합니다. OSI 7계층의 3계층인 네트워크 계층입니다. 아래는 af에 사용할 수 있는 값들입니다.

 

AF_UNSPEC : 지정하지 않음
AF_INET : IPv4
AF_IPX : IPX
AF_APPLETALK : 애플토크
AF_INET6 : IPv6

 

type에서는 소켓으로 주고받을 패킷의 종류(또는 소켓의 종류)를 지정합니다.

 

SOCK_STREAM : 순서와 전달이 보장되는 데이터 스트림으로 스트림의 각 세그먼트를 패킷으로 주고받습니다.
SOCK_DGRAM : 각 데이터그램을 패킷으로 주고받습니다.
SOCK_RAW : 패킷 헤더를 응용 계층에서 직접 만들 수 있습니다.
SOCK_SEQPACKET : SOCK_STREAM과 유사하지만, 패킷 수신 시 항상 전체를 읽어 들여야 합니다.

 

이 중에서 SOCK_STREAMSOCK_DGRAM을 잠깐 살펴보자면,

 

SOCK_STREAM은 TCP 프로토콜에 어울리는 것으로, 운영체제가 소켓을 만들 때 상태 유지형 연결을 만들게 됩니다. 그러면 신뢰성 있고 순서가 보장되는 스트림으로 데이터를 처리할 수 있도록 필요 리소스가 할당됩니다.

 

SOCK_DGRAM은 최소한의 리소스만 할당돼 개별 데이터그램 단위로만 주고받을 수 있게 합니다. 신뢰성을 신경 쓸 필요가 없고, 패킷의 순서를 보장할 필요도 없는 UDP 프로토콜에 어울리는 것입니다.

 

protocol에서는 소켓이 데이터 전송에 사용할 프로토콜을 지정합니다.

 

IPPROTO_TCP : TCP (type 파라미터를 SOCK_STREAM으로 지정해야 합니다.)
IPPROTO_UDP : UDP (type 파라미터를 SOCK_DGRAM으로 지정해야 합니다.)
IPPROTO_IP 또는 0 : type 파라미터에 맞는 프로토콜을 운영체제가 자동으로 결정합니다.

 

예시

 

SOCKET tcp_socket = socket(AF_INET, SOCK_STREAM, 0); // IPv4 TCP 소켓
SOCKET udp_socket = socket(AF_INET, SOCK_DGRAM, 0);  // IPv4 UDP 소켓

 

사용을 끝낸 소켓을 닫으려면 closesocket함수를 호출해 닫을 수 있습니다.

 

int closesocket(SOCKET sock);

 

소켓을 닫기 전에 전송과 수신을 중단하려면 shutdown함수를 호출하면 됩니다.

 

int shutdown(SOCKET sock, int how);

 

how 파라미터에는 다음 값들 중 하나를 사용해 중단할 작업을 선택할 수 있습니다.

SD_SEND : 송신을 중단합니다.
SD_RECEIVE : 수신을 중단합니다.
SD_BOTH : 송신과 수신 모두 중단합니다.

 

성공적으로 중단된다면 0을 반환합니다. 송신과 수신 양쪽에서 0을 반환했다면 소켓을 안전하게 닫을 수 있는 상태라는 것이므로, 이때 소켓을 닫는 것이 최선의 선택이 될 수 있습니다.

 

소켓을 닫으면 관련 리소스들은 운영체제에 반납합니다. 그러므로, 사용을 마친 소켓은 닫아주는 것이 좋습니다.

 


 

2. 소켓 주소 할당

 

네트워크 계층에서는 발신지 주소와 목적지 주소가 필요합니다. 전송 계층에서는 포트가 추가됩니다. 이런 주소 정보를 소켓과 주고받기 위한 sockaddr 구조체가 있습니다.

 

struct sockaddr {
    unsigned short sa_family; // 주소의 종류. 소켓을 만들 때의 af 파라미터와 같습니다.
    char sa_data[14]; // 실제 주소가 들어가는 부분입니다.
};

 

IPv4 패킷용 주소를 만들려면 아래 구조체를 사용합니다.

 

struct sockaddr_in {
    short sin_family; // 위의 sa_family와 같지만 항상 AF_INET으로 고정해야합니다.
    unsigned short sin_port; // 포트값을 저장합니다.
    struct in_addr sin_addr; // IPv4 주소를 저장합니다.
    char sin_zero[8]; // sockaddr_in 구조체의 크기를 sockaddr과 맞추기 위한 패딩값입니다.
                      // 항상 0으로 모두 채워줘야합니다.
};

struct in_addr {
    union {
        struct {
            unsigned char s_b1, s_b2, s_b3, s_b4; // 주소가 저장됩니다.
        } S_un_b;
        struct {
            unsigned short s_w1, s_w2;
        } S_un_w;
        unsigned int S_addr;
    } S_un;
};

 

여기서 한 가지 짚고 넘어가야 할 부분이 있습니다.네트워크와 호스트 컴퓨터는 서로 사용하는 바이트 순서 체계가 다를 수 있습니다. 예를 들어, 호스트에서는 1234를 집어넣었는데, 네트워크에서는 4321로 인식할 수도 있다는 뜻입니다. 그래서 호스트의 순서 체계가 아닌 네트워크의 순서 체계를 따르도록 변환시켜줘야합니다. 이를 도와주는 함수가 htons htonl입니다.

 

unsigned short htons(unsigned short hostshort); // 부호 없는 16비트 정수를 변환합니다.
unsigned long htonl(unsigned long hostlong); // 부호 없는 32비트 정수를 변환합니다.

 

이를 반대로 수행하는 함수인 ntohsntohl도 있습니다.

 

unsigned short ntohs(unsigned short networkshort); // 부호 없는 16비트 정수를 변환합니다.
unsigned long ntohl(unsigned long networklong); // 부호 없는 32비트 정수를 변환합니다.

 

 

이제 주소를 소켓에 저장하는 방법을 알아보겠습니다.

 

가장 무식(?)한 방법으로 구조체에 하나하나 주소를 저장하는 방법이 있습니다.

 

sockaddr_in myAddr;
memset(myAddr.sin_zero, 0, sizeof(myAddr.sin_zero));
myAddr.sin_family = AF_INET;
myAddr.sin_port = htons(80);
// 127.0.0.1
myAddr.sin_addr.S_un.S_un_b.s_b1 = 127;
myAddr.sin_addr.S_un.S_un_b.s_b2 = 0;
myAddr.sin_addr.S_un.S_un_b.s_b3 = 0;
myAddr.sin_addr.S_un.S_un_b.s_b4 = 1;

 

 너무 번거롭죠? 이를 간단히 처리해줄 수 있는 함수가 InetPton 또는 inet_pton 입니다. 주소를 문자열로 받아서 sin_addr에 주소를 저장해주는 함수입니다. 사용 방법은 아래와 같습니다.

 

// af에는 AF_INET과 AF_INET6중 하나로 지정합니다.
// src에는 주소를 문자열로 입력합니다.
// dst에는 변환된 sin_addr 주소 필드의 포인터를 지정합니다.
int InetPton(int af, const PCTSTR src, void* dst); // only windows
int inet_pton(int af, const char* src, void* dst);

 

예시

 

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

 

POSIX 계열 운영체제에서 도메인 네임으로 연결하려면?

더보기

 

IP 주소를 이진 IP 주소로 변환하는 방법은 알았습니다. 하지만, inet_pton 함수는 오직 문자열로 된 IP 주소만 처리할 수 있는 함수입니다. 도메인 네임이나, DNS 조회 등의 작업은 수행되지 않습니다. 그렇기 때문에, DNS 질의를 통해 도메인 네임을 IP 주소로 변환하는 작업이 필요하다면 getaddrinfo 함수를 추가로 사용해줘야 합니다.

 

// hostname은 도메인 조회를 할 문자열입니다. 즉, 도메인 주소입니다.
// servname에는 포트 또는 서비스 이름입니다. 
// 예를들어, 포트 번호 80이나 http를 입력하면 포트 80에 맞는 sockaddr_in을 얻습니다.
// hints에는 받고 싶은 정보들을 기재한 addrinfo 구조체의 포인터를 넘깁니다. nullptr을 넘기면 모두 받습니다.
// 결과는 res로 반환됩니다. 결과는 연결 리스트로 반환되므로, 첫 번째 원소를 가리킵니다.
int getaddrinfo(const char* hostname, const char* servname, const addrinfo* hints, addrinfo** res);

// ai_flags, ai_socktype, ai_protocol은 추가 옵션을 지정할 때 사용합니다.
// ai_family는 addrinfo에 관련된 주소 패밀리입니다. AF_INET이면 IPv4입니다.
// ai_addrlen은 ai_addr이 가리키는 sockaddr의 길이입니다.
// ai_canonname은 호스트의 대표 이름(canonical name)입니다.
// ai_addr은 해당 주소 패밀리의 sockaddr구조체 입니다.
// ai_next는 연결 리스트상의 다음 addrinfo를 가리킵니다.
struct addrinfo {
    int ai_flags;
    int ai_family;
    int ai_socktype;
    int ai_protocol;
    size_t ai_addrlen;
    char* ai_canon_name;
    sockaddr* ai_addr;
    addrinfo* ai_next;
};

// getaddrinfo 함수로 불러온 구조체는, 자체적으로 메모리를 할당받습니다.
// 그러므로, 사용을 마친 다음에는 freeaddrinfo함수를 통해 메모리를 반환해줘야 합니다.
// ai에는 getaddrinfo를 통해 받은 구조체의 첫 번째 주소를 넣어야합니다.
// 처음 주소부터 시작해 순회하면서 하나씩 할당을 해제해줍니다.
void freeaddrinfo(struct addrinfo *ai);

 

 

마지막으로 bind는 주소를 소켓에 바인딩을 하는 함수입니다. 바인딩이란, 운영체제에 어떤 소켓이 특정 주소와 전송 계층의 포트를 사용하겠다고 알려주는 것입니다. 시스템 내의 네트워크 주소에 바인딩이 되어야 서버에서 클라이언트 연결을 허용할 수 있습니다.

 

// 데이터를 송수신하기 위해서는 반드시 소켓이 바인딩되어 있어야 합니다.
// 그래서, 바인딩 되지 않은 소켓으로 데이터를 보내면 네트워크 라이브러리가 자동으로
// 빈 소켓을 찾아 자동으로 바인딩해줍니다.
// 클라이언트를 만들 때에는 운영체제가 자동으로 선택해주기에
// 굳이 바인딩을 할 필요가 없습니다.

// sock은 바인딩할 소켓입니다.
// address는 바인딩할 주소입니다.
// address_len은 주소로 넘긴 sockaddr의 길이를 넣어야합니다.
int bind(SOCKET sock, const sockaddr* address, int address_len);
// 바인딩에 성공하면 0을, 실패하면 -1을 반환합니다.

 

만약 바인딩을 할 때, 이미 사용중인 주소와 포트의 조합에 바인딩을 시도하면 오류(-1)을 반환합니다. 만약 포트를 0으로 지정하면 사용 중이 아닌 포트 중 하나를 자동으로 골라서 바인딩을 합니다.

 

 

다음에는 UDP 소켓과 TCP 소켓을 연결해 데이터를 주고받는 방법을 알아보겠습니다.

 


 

 

 

'Network > Network Programming' 카테고리의 다른 글

[C++/Network] 객체 리플리케이션  (0) 2022.05.19
[C++/Network] 객체 직렬화  (0) 2022.05.19
[C++/Network] UDP 소켓과 TCP 소켓  (0) 2022.05.11