공부/Server

윈도우 기반 소켓 프로그래밍 시작하기(socket, protocol 개념 및 기본적인 함수 정리)

sudo 2021. 7. 27. 06:52

소켓이란?

물리적으로 연결되어 있는 호스트간 데이터 송수신을 가능하게 하는 매개체. 

 

Server측에서 필요한 함수들

WSAStartup(프로그램에서 요구하는 소켓 버전을 알리고, 해당 버전의 라이브러리 초기화) -> socket(socket 생성) -> bind(socket 주소 정보 할당) -> listen(socket을 연결 요청 받을 수 있는 상태로 만듬) -> accept(연결, 클라이언트로 부터 연결 요청이 있을 때 까지 대기) -> send(연결 요청이 있는 클라이언트에게 메세지를 보냄)  -> closesocket(만약 연결을 종료할 것이라면 서버와 클라이언트 소켓을 닫는다) -> WSACleanup(윈속 라이브러리를 운영체제에 반환함으로써, 윈속 관련 함수 호출 불가)

- listen 인자중에 backlog라는 인자가 있다. 이것은 이미 connection이 되어 있는 중에 connection 요청이 또 다시 오면 그런 incoming connection을 queue에 저장해둔다. 그때 저장해 둘 수 있는 최대 개수를 의미함.

Client측에서 필요한 함수들

WSAStartup -> socket -> connect(서버에 연결 요청) -> recv(서버로부터 메세지 받음) -> closesocket(만약 연결 종료시 클라이언트 소켓 닫음) -> WSACleanup

 

윈도우에서 소켓 기반 프로그래밍을 하기 전에 기본적으로 두가지가 선행되어야 한다.

 

1. ws2_32.lib 라이브러리 링크

2. winsock2.h 헤더파일 include

 

Winsock(윈도우 소켓)을 사용하는 어플리케이션은 모두 ws2_32.lib를 링크해야 한다고 한다.(https://docs.microsoft.com/en-us/windows/win32/winsock/creating-a-basic-winsock-application)

방법은 visual studio에서 프로젝트 우클릭 > 속성 > 링커 > 입력 > 추가 종속성 > 편집을 누르고 ws2_32.lib를 따로 입력해줘도 되고 코드 맨 위에 #pragma comment(lib, "Ws2_32.lib") 한줄을 추가해줘도 된다.

 

다양한 Winsock API들이 포함되어 있는 winsock2.h 헤더파일도 include해줘야 한다.

 

프로토콜이란?

컴퓨터 상호간의 대화에 필요한 통신 규약( ex. IPv4 vs IPv6, 연결지향형 소켓(SOCK_STREAM) vs 비 연결지향형 소켓(SOCK_DGRAM) )

프로토콜 체계(protocol family)

socket함수의 첫번째 인자로 넣어줄 수 있는 것들이다.

이름 프로토콜 체계
PF_INET IPv4 프로토콜 체계
PF_INET6 IPv6 프로토콜 체계
PF_LOCAL 로컬 통신을 위한 UNIX 프로토콜 체계
PF_PACKET Low Level 소켓을 위한 프로토콜 체계
PF_IPX IPX 노벨 프로토콜 체계

 

소켓의 (데이터 전송) 타입

1. 연결 지향형(SOCK_STREAM) 

socket함수의 두번째 인자로 SOCK_STREAM을 넘겨주면 연결지향형 소켓이 만들어진다. 연결지향형 소켓의 특징은

a. 중간에 데이터가 소멸되지 않는다 -> 신뢰성

b. 보낸 순서대로 데이터 전송을 보장

c. 전송되는 데이터의 boundary가 존재하지 않음

d. 소켓과 소켓의 연결은 1대 1로 연결되어야 한다

2. 비 연결 지향형(SOCK_DGRAM)

socket함수의 두번째 인자로 SOCK_DGRAM을 넘겨주면 비 연결지향형 소켓이 만들어진다. 비 연결지향형 소켓의 특징은

a. 데이터 손실의 우려가 있다

b. 보낸 순서대로 전송된다는 보장이 없다

c. 전송되는 데이터의 boundary가 존재

d. 한번에 전송될 수 있는 데이터 크기가 제한된다

 

IP와 port번호

IP는 인터넷 상에서 호스트를 구별하기 위해 존재하고, port번호는 호스트 내 2개 이상 존재하는 소켓간의 구별을 위해 존재한다.

 

IP주소는 첫번째 바이트의 범위에 따라 3가지 클래스로 나뉜다.

0 ~ 127 -> 클래스 A

128 ~ 191 -> 클래스 B

192 ~ 223 -> 클래스 C

 

port번호는 0 ~ 65535까지 할당할 수 있는데 0 ~ 1023까지는 특별한 용도로 이미 할당되어 있다. 이 번호들을 Well-known PORT라고 부르며 여기에는 HTTP(80), HTTPS(443)이 있다.

 

IPv4기반 주소 표현을 위한 구조체

server에서 bind를 할 때나 client에서 connect 할 때 2번째인자로 struct sockaddr_in 구조체를 사용한다. 

struct sockaddr_in
{
    sa_familiy_t	sin_family; // 주소체계
    uint16_t		sin_port; // 16비트 TCP/UDP PORT번호
    struct in_addr	sin_addr; // 32비트 IP주소
    char		sin_zero[8] // 사용되지 않음
};

typedef struct sockaddr_in SOCKADDR_IN;

struct in_addr
{
	in_addr_t	s_addr;	// in_addr_t는 uint32_t로 정의되어 있음
}
  • 멤버 sin_family에 넘겨주는 주소 체계

여기서 헷갈리지 말아야 할 것은 socket 함수에서 첫번째 인자로 넘겨주는 프로토콜 체계와 다르다는 것이다. 프로토콜 체계마다 주소체계가 존재하는 것이다. 

주소체계(Address Family) 의미
AF_INET IPv4 인터넷 프로토콜에 적용하는 주소체계
AF_INET6 IPv6 인터넷 프로토콜에 적용하는 주소체계
AF_LOCAL 로컬 통신을 위한 유닉스 프로토콜의 주소체계
  • 멤버 sin_port

16비트 PORT번호를 저장. 단 네트워크 바이트 순서로 저장해야 하는데 네트워크 바이트 순서는 빅엔디안 방식이다.

  • 멤버 sin_addr

32비트 IP주소를 저장, 역시 네트워크 바이트 순서로 저장해야 한다.

 

bind함수나 connect함수의 2번째 인자 타입은 SOCKADDR* 타입이다. 그럼에도 SOCKADDR_IN을 사용하는 이유는 SOCKADDR_IN이 PORT번호와 IP주소를 넣어주기 편하고, 넣어주고나서 SOCKADDR* 타입으로 강제 형변환하기도 쉽기 때문이다.

 

네트워크 바이트 순서로 변환 

위에서 IP주소나 PORT번호는 항상 네트워크 바이트 순서로 변환해서 저장해야 한다고 했다. 호스트 바이트 순서에서 네트워크 바이트 순서로 변환해주는 함수 2가지, 반대로 네트워크 바이트 순서에서 호스트 바이트 순서로 변환해주는 함수 2가지가 있다.

unsigned short htons(unsigned short); // host byte -> network byte
unsigned long htonl(unsigned long);  // host byte -> network byte
unsigned short ntohs(unsigned short); // network byte -> host byte
unsigned long ntohl(unsigned long);  // network byte -> host byte

 위에서 볼 수 있듯이, s는 short를 의미하고, l은 long을 의미한다. 여기서 short는 2바이트이고, long은 4바이트이다. 그래서 htons는 주로 PORT번호에 이용하고, htonl은 IP주소에 이용한다.

IP주소 문자열을 정수로 변환과 동시에 네트워크 바이트주소로 변환

윈도우 & 리눅스 모두 존재

in_addr_t inet_addr(const char*);
char* inet_ntoa(struct in_addr adr);

IP주소를 struct sockaddr_in의 s_addr에 할당해주기 위해서는 inet_addr을 이용해서 IP주소 문자열을 32비트 정수로 바꿔줘야 한다. inet_addr 함수는 IP주소 문자열을 정수로 바꿔줄 뿐만 아니라 네트워크 바이트 순서로 바꿔주기까지 한다. 즉 inet_addr함수를 사용하면 htonl함수까지 쓰지 않아도 된다. 반대로 32비트 정수로 표현된 IP주소를 프로그래머가 알아보기 쉽게 문자열로 바꿔주는 함수는 inet_ntoa 함수이다.

 

 inet_addr과 inet_ntoa와 기능이 똑같지만 윈도우에만 존재하는 함수가 있다.

// 성공시 0, 실패시 SOCKET_ERROR 반환
INT WSAStringToAddress(LPTSTR AddressString, INT AddressFamily, LPWSAPROTOCOL_INFO lpProtocolInfo,
LPSOCKADDR lpAddress, LPINT lpAddressLength)

AddressString : IP와 Port번호를 담고 있는 문자열 주소

AddressFamily : 첫번째 인자로 전달된 주소정보가 속하는 주소체계

IpProtocolInfo : 일반적으로 NULL 전달

lpAddress : 주소정보를 담을 구조체 주소값 전달

lpAddressLength : 네번째 인자로 전달된 주소값 변수 크기. sizeof(SOCKADDR_IN타입 변수)로 보통 전달함

 

WSAStringToAddress는 1번째 인자로 넣어준 IP와 PORT를 포함한 문자열을 4번째 인자로 넣어준 SOCKADDR 타입의 구조체 포인터를 통해 sin_addr과 sin_port에 각각 넣어준다.

 // 성공시 0, 실패시 SOCKET_ERROR 반환
INT WSAAddressToString(LPSOCKADDR lpsaAddress, DWORD dwAddressLength, LPWSAPROTOCOL_INFO lpProtocolINfo,
LPTSTR lpszAddressString, LPDWORD lpdwAddressStringLength)

lpsaAddress : 문자열로 변환할 주소정보를 지니는 구조체 변수의 주소값

dwAddresssLength : 첫번째 인자로 전달된 구조체 변수 크기. 보통 sizeof(SOCKADDR_IN타입 변수)로 전달함

lpProtocolInfo : 일반적으로 NULL 전달

lpszAddressString : 문자열로 변환된 결과를 저장할 char 배열 주소값

lpdwAddressStringLength : 4번째 인자의 크기

 

WSAAddressToString은 반대로 1번째 인자로 넣어준 SOCKADDR 타입의 구조체 포인터를 통해 IP주소와 PORT번호를 알아서 4번째 인자인 문자열에 프로그래머가 알아보기 쉽게 변환해서 저장해준다.

 

사용 예시는 아래와 같다.

#define _WINSOCK_DEPRECATED_NO_WARNINGS
#pragma comment(lib, "Ws2_32.lib")

#undef UNICODE
#undef _UNICODE
#include <stdio.h>
#include <WinSock2.h>

int main()
{
	char* strAddr = "203.211.218.102:9190";
	char strAddrBuf[50];

	SOCKADDR_IN servAddr;
	int size;

	WSADATA wsaData;
	WSAStartup(MAKEWORD(2, 2), &wsaData);

	size = sizeof(servAddr);
	WSAStringToAddress(strAddr, AF_INET, NULL, (SOCKADDR*)&servAddr, &size);

	size = sizeof(strAddrBuf);
	WSAAddressToString((SOCKADDR*)&servAddr, sizeof(servAddr), NULL, strAddrBuf, &size);

	printf("conversion result : %s\n", strAddrBuf);
	WSACleanup();

	return 0;
}

유니코드의 #define을 해제한 이유는 매개변수로 넘겨준 문자열이 유니코드 기반으로 바뀌어서 의도와 다르게 함수가 호출될까봐 해제 해준 것이다.

 

 

* 이 글은 윤성우 열혈 TCP/IP 소켓 프로그래밍 책을 읽고 공부한 내용을 정리한 글입니다.