코승호딩의 메모장

[소켓 주소 구조체] 본문

네트워크 프로그래밍

[소켓 주소 구조체]

코승호딩 2023. 9. 12. 12:48

01 소켓 주소 구조체

소켓 주소 구조체는 네트워크 프로그램에 필요한 주소 정보를 담는 구조체이다. 다양한 소켓 함수의 인수로 사용되며 프로토콜들에 따라 주소 지정 방식이 다르기 때문에 다양한 소켓 주소 구조체가 존재한다. 가장 기본은 sockaddr 구조체이다.

struct sockaddr{
    unsigned short 	sa_family;
    char 		sa_data[14];
};
  • sa_family : 주소 체계를 나타내는 16비트 정수값. TCP/IP 프로토콜 사용 시 AF_INET 또는 AF_INET6을 갖는다.
  • sa_data : 주소 체계에서 사용할 주소 정보를 담는다. 주소 체계마다 필요한 정보가 다르기 때문에 가장 일반적인 형태인 바이트 배열로 선언되어 있다. TCP/IP 프로토콜 사용 시 IP 주소와 포트 번호를 저장한다.

위 sockaddr은 대표 구조체 타입이고 실제 응용 프로그램이 사용하는 프로토콜은 종류에 따라 소켓 구조체가 별도이다. 예를 들어 TCP/IP에서는 sockaddr_in(IPv4) 또는 sockaddr(IPv6) 또는 sockaddr_bth(블루투스)를 사용한다. 

 

다음은 이번 포스팅에서 주로 사용할 TCP/IP 프로토콜을 위한 소켓 주소 구조체이다. 

// IPv4 
struct sockaddr_in{
    short 		sin_family;
    unsigned short 	sin_port;
    struct in_addr 	sin_addr;
    char 		sin_zero[8]; // 항상 0으로 설정
};


// IPv6 
struct sockaddr_in6{
    short 		sin6_family;
    unsigned short 	sin6_port;
    u_long		sin6_flowinfo; // 보통 0으로 설정
    struct in6_addr 	sin6_addr;
    u_long 		sin6_scope_id; // 보통 0으로 설정
};
  • sin_family, sin6_family : 주소 체계를 의미하며, 각각 AF_INET과 AF_INIT6 값을 사용한다.
  • sin_port, sin6_port : 포트 번호를 의미하며, 부호 없는 16비트 정수값이다.
  • sin_addr, sin6_addr : IP 주소를 의미하며, 각각 32비트 in_addr(IPv4) 구조체와 128비트 in6_addr(IPv6)에 해당한다.

 

다음은 in_addr 구조체와 in6_addr 구조체를 알아보자.

 

자세히 보면 in_addr은 32비트이므로 union을 통해 1바이트에 해당하는 u_char 타입 4개 혹은 2바이트에 해당하는 u_short 타입 2개 혹은 4바이트에 해당하는 u_long 타입 1개로 이루어져 있다. 그리고 in6_addr은 1바이트에 해당하는 크기 16의 UCHAR 타입의 배열 혹은 2바이트에 해당하는 크기 8의 USHORT 타입의 배열로 이뤄져 있다.

다음과 같이 대표 타입에 해당하는 sockaddr 구조체와 나머지 소켓 주소 구조체가 크기가 다르다는 것을 알 수 있다. 그리고 소켓 주소 구조체의 크기가 크다는 것을 알 수 있다. 또한 소켓 주소 구조체의 크기가 크기 때문에 소켓 함수 인자로 전달할 때는 항상 주솟값을 사용해야 한다. 그리고 반드시 sockaddr 포인터 타입으로 캐스팅해야 한다.

 

한 가지 궁금한 점이 왜 반드시 타입 캐스팅을 해서 넘겨줘야 하냐는 것이다. 소켓은 void 포인터 개념이 없던 시기에 등장했던 API로 sockaddr이라는 대표 타입이 존재한다. 특정 네트워크 프로토콜을 선택하면 그에 맞는 구조체를 사용하되, 소켓 함수에 전달할 때 sockaddr 타입으로 캐스팅하도록 설계되어 있다. 따라서 그냥 타입 캐스팅을 하라면 해야 한다.

임의의 소켓 함수가 소켓 주소 구조체를 입력으로 받아 출력 등의 목적으로 사용된다. 이러한 방식으로 타입 캐스팅을 할 수 있다. 보통 소켓 주소 구조체는 memset을 통해 모두 0으로 밀고 시작하는데 소켓 주소 구조체에 쓰레기 값이 들어 있을 수 있고 항상 또는 보통 0의 값을 갖는 매개 변수들이 존재하기 때문이다.


02 바이트 정렬 함수

CPU제조사에 따라 메모리 주소의 상위 부터 데이터를 쓰고 읽는 모델이 있고 하위 부터 데이터를 쓰고 읽는 모델이 있다. 

 

  • 빅 엔디언 : 최상위 바이트부터 차례로 저장하는 방식 ex) 0x12345678 -> 0x12 0x34 0x56 0x78
  • 리틀 엔디언 : 최하위 바이트부터 차례로 저장하는 방식 ex) 0x12345678 -> 0x78 0x56 0x34 0x12

빅 엔디언에서 리틀 엔디언 또는 리틀 엔디언에서 빅 엔디언으로 데이터를 보내면 의미 전달이 제대로 되지 않을 것이다.  네트워크를 통해 데이터를 송수신 할 때 바이트 정렬에 유의해야 한다. 따라서 이 문제를 해결하기 위해 바이트 정렬 함수가 등장하였다. 그렇다면 데이터의 주소 저장 배치 순서만 유의해야 할까? 그렇지 않다. 데이터를 네트워크를 통해 송수신할 경우 데이터에 붙는 포트 번호IP 주소와 같은 부가적인 정보들도 리틀 엔디언인지 빅 엔디언인지 순서에 유의해야 한다. 데이터 전송뿐만 아니라 라우터들이 받는 IP 주소 또한 바이트 정렬을 지켜줘야 한다. 또한 두 호스트가 포트 번호의 바이트 정렬 순서도 지켜줘야 데이터가 올바른 목적지 프로세스로 전달될 수 있다. 이러한 문제들은 시스템이 사용하는 호스트 바이트 정렬이 통일되지 않아 발생하는데 이를 해결하기 위해 IP 주소와 포트 번호를 빅 엔디언(네트워크 바이트 정렬)으로 통일한 것이다.

 

  • 호스트 정렬 방식(변수) : 호스트에서 사용되는 정렬 방식은 리틀 엔디언일 수 있고 빅 엔디언일 수 있다.
  • 네트워크 바이트 정렬 방식(상수) : 무조건 빅 엔디언 방식이다.

따라서 네트워크 프로그램을 올바르게 하기 위해서는 바이트 정렬을 서로 맞춰줘야 한다. 소켓 함수 중 응용 프로그램이 바이트 정렬 방식을 다음과 같이 편하게 변환할 수 있는 함수가 존재한다.

  • hton : 호스트에서 네트워크 정렬 방식으로 변경
  • ntoh : 네트워크에서 호스트로 정렬 방식으로 변경

데이터를 내보낼 때는 무조건 hton함수를 사용한다. 호스트 바이트 정렬이 빅 엔디언이든 리틀 엔디언이든 빅 엔디언으로 바뀌는 것이다. 호스트 정렬 방식은 빅 엔디언 혹은 리틀 엔디언일 수 있다. 빅 엔디언 -> 빅 엔디언은 값이 바뀌지 않고 리틀 엔디언 -> 빅 엔디언이면 값이 바뀐다. 그러나 컴퓨터가 리틀 엔디언인지 빅 엔디언인지는 알 수 없기 때문에 hton함수를 무조건 불러야 한다. 데이터 내보낼 때 hton함수, 데이터를 읽을 때 ntoh방식을 사용하면 된다.

오른쪽 그림은 TCP/IP에서 사용하는 소켓 주소 구조체의 매개 변수들이 따르는 정렬 방식인데 빨간색 부분이 네트워크 바이트 정렬이며 하얀색 부분이 호스트 바이트 정렬이다.

 

int main(int argc, char *argv[])
{
	// 윈속 초기화
	WSADATA wsa;
	if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0)
		return 1;

	u_short x1 = 0x1234;
	u_long  y1 = 0x12345678;
	u_short x2;
	u_long  y2;

	// 호스트 바이트 -> 네트워크 바이트
	printf("[호스트 바이트 -> 네트워크 바이트]\n");
	printf("%#x -> %#x\n", x1, x2 = htons(x1));
	printf("%#x -> %#x\n", y1, y2 = htonl(y1));

	// 네트워크 바이트 -> 호스트 바이트
	printf("\n[네트워크 바이트 -> 호스트 바이트]\n");
	printf("%#x -> %#x\n", x2, ntohs(x2));
	printf("%#x -> %#x\n", y2, ntohl(y2));

	// 잘못된 사용 예
	printf("\n[잘못된 사용 예]\n");
	printf("%#x -> %#x\n", x1, htonl(x1));

	// 윈속 종료
	WSACleanup();
	return 0;
}

위 코드는 바이트 정렬 함수를 사용하여 2바이트 데이터와 4바이트 데이터를 바이트 정렬하는 모습을 보여준다. 뒤에 s가 붙는 함수는 short로 16비트에 해당하고 l이 붙는 함수는 long으로 32비트에 해당한다. 위 코드와 같이 데이터를 내보내기 위해서 호스트에서 네트워크 바이트로 정렬하는 hton 함수를 사용하면 결과는 0x1234 -> 0x3412 처럼 뒤집히게 된다. 데이터를 받기 위해 네트워크에서 호스트로 바이트 정렬을 하는 ntoh 함수 또한 0x3412 -> 0x1234 처럼 뒤집히게 된다. 단 준의할 점은 다른 타입을 함수의 입력으로 줄 경우 예를 들어 0x1234 -> 0x34120000 처럼 이상한 값이 들어가게 된다. 따라서 16비트면 16비트를 사용하는 htons나 ntohs함수를 사용해야 하고 32비트면 htonl나 ntohl함수를 사용해야 한다.


03 IP 주소 변환 함수

ping이라는 유틸리티 커맨드는 자주 쓰는 명령어이다. ping 뒤에 아이피 주소를 넣으면(도메인 네임) 해당 IP와 도메인을 가지고 있는 호스트에게 테스트 패킷을 보내고 다녀 오는데 걸리는 시간을 측정한다. 총 4번을 보내며 돌아오는 평균 시간을 구하는데 이런 일을 하는 이유는 원격으로 연결하기 전 네트워크 연결상태를 파악하기 위해 사용한다.

 

네트워크 프로그램에서 IP 주소를 입력받을 때는 cmd 창이나 운영체제가 제공하는 위젯(컨트롤)을 사용해야 한다. 응용 프로그램은 IP 주소를 문자열 형태로 전달받기 때문에 이를 32비트 또는 128비트 숫자로 변환해야 한다. 

다음과 같이 IP 주소 전용 위젯을 사용하여 IP 주소를 입력받을 수 있다. 그러나 IPv6에는 사용할 수 없고 콘솔 응용 프로그램에서 사용하기에는 부적절하다는 단점이 있다. IP 주소는 입력받은 후 문자열 형태이다. 따라서 컴퓨터가 이해할 수 있는 2진수로 변경해야 하기 때문에 문자열 형태의 IP주소를 숫자 형태로 변경해야 한다.

 

응용 프로그램에서는 IP 주소를 편리하게 변환할 수 있도록 소켓 함수를 제공한다. 이 함수들은 IPv4와 IPv6에서 모두 사용 가능하다.

  • inet_pton : presentation(ip주소, 문자열 형태) to network(2진수로 바뀐 32 비트 or 128비트, 숫자 형태)를 의미하며 문자열 형태의 IP 주소를 입력받아 32/128비트의 숫자 형태로 리턴한다. 
  • inet_ntop : network to presentation를 의미하며 32/128비트의 숫자 형태를 입력받아 문자열 형태로 리턴한다.
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
inet_pton(AF_INET, "147.46.114.70", &addr.sin_addr);
addr.sin_port = htons(9000);

SocketFunc(..., (struct sockaddr *)&addr, sizeof(addr), ...);

위 코드는 소켓 주소 구조체를 초기화 하고 소켓 함수에 넘겨주는 코드이다. IPv4용 소켓 주소 구조체를 memset을 통해 0으로 채우고 문자열로 되어 있는 IP 주소를 숫자 형태로 변경한 다음 포트 번호 또한 내보내야 하기 때문에 호스트에서 네트워크 정렬로 변경한다. 그리고 소켓 함수를 사용하여 소켓 주소 구조체를 넘겨준다.

 

struct sockaddr_in addr;
int addrlen = sizeof(addr);
SocketFunc(..., (struct sockaddr *)&addr, &addrlen, ...);

char ipaddr[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &cliendaddr.sin_addr, ipaddr, sizeof(ipaddr));
printf("\n[TCP 서버] 클라이언트 접속: IP 주소 = %s, 포트 번호 = %d\n", ipaddr, ntohs(clientaddr.sin_port));

위 코드는 소켓 함수가 소켓 주소 구조체를 입력 받아 내용을 채우고 응용 프로그램에서 이를 출력하기 위한 코드이다. INET_ADDRSTRLEN은 문자열 형태의 IPv4 주소의 최대 길이를 나타내는 상수이다. IPv6는 INET6_ADDRSTRLEN이다. 

 

다음으로 IPv4의 주소를 변환하여 화면에 출력하는 예제를 작성해보자. 

int main(int argc, char *argv[])
{
	WSADATA wsa;
	if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0)
		return 1;

	const char *ipv4test = "147.46.114.70";
	printf("IPv4 주소(변환 전) = %s\n", ipv4test);

	struct in_addr ipv4num;
	inet_pton(AF_INET, ipv4test, &ipv4num);
	printf("IPv4 주소(변환 후) = %#x\n", ipv4num.s_addr);

	char ipv4str[INET_ADDRSTRLEN];
	inet_ntop(AF_INET, &ipv4num, ipv4str, sizeof(ipv4str));
	printf("IPv4 주소(다시 변환 후) = %s\n", ipv4str);
	printf("\n");

	WSACleanup();
	return 0;
}

04 DNS와 이름 변환 함수

도메인 네임은 IP 주소와 똑같은 호스트나 라우터의 고유한 식별자이다. IP 주소보다 기억하고 사용하기 쉬운 문자 위주로 구성되어 있다. TCP/IP 프로토콜은 내부적으로 숫자 형태의 IP 주소를 기반으로 동작하기 때문에 문자열 형태인 도메인 네임을 반드시 숫자 형태인 IP 주소로 변환해야 한다.

 

도메인 네임과 IP 주소의 정보는 인터넷에 존재하는 여러 도메인 네임 시스템 서버가 관리한다. 이 DNS 서버는 분산 데이터베이스로 한 DNS 서버가 모든 정보를 갖고 있는 것이 아닌 가지고 있지 않은 정보라면 다른 도메인 서버에 물어봐서 전달전달 해서 알게 된다.

 

응용 프로그램이 도메인 네임과 IP 주소를 변환할 수 있도록 소켓 함수를 제공한다.

우선 변환 함수가 hostent라는 구조체 포인터를 반환하는데 위 구조체의 정의는 다음과 같다. 

  • h_name : 호스트가 가지는 대표 도메인 네임
  • h_aliases : 호스트가 대표 도메인 네임 뿐만 아니라 다른 이름도 여러 개 가질 수 있는데 만약 스펠링이 헷갈리거나 틀려도 다른 스펠링을 넣었을 때, 해당 페이지로 갈 수 있는 기능이 있다.
  • h_addrtype : 주소 체계를 나타내는 값 AF_INET or AF_INET6
  • h_length : IP 주소의 길이 
  • h_addr_list : 네트워크 바이트 정렬된 IP 주소로 호스트가 여러 개의 IP 주소를 가진 경우 이 포인터를 따라가면 모든 IP 주소를 얻을 수 있다. 대부분 가장 앞에 있는 주소만 사용하기 때문에 매크로로 정의된 h_addr을 사용하면 된다.
struct hostent *gethostbyname(
    const char *name;	// 도메인 네임
);

struct hostent *gethostbyaddr(
    const char 	*addr;	// IP 주소
    int 	len;	// IP 주소 길이
    int 	type;	// 주소 체계
);

 

주의할 점은 도메인 네임을 넣어 IP 주소를 읽어오는 경우 100퍼센트 신뢰할 수 있다. 반대로 도메인 네임은 100퍼센트 신뢰할 수 없다. 정보가 맞지 않을 수 있다. 유명 사이트 경우 보안상 이유로 IP주소를 자주 바꾸기 때문에 업데이트를 자주해서 바뀔 수 있다. 따라서 IP 주소를 넣어 도메인 네임을 가져오는 경우 참고만 해야 한다.

 

bool GetIPAddr(const char* name, struct in_addr* addr)
{
    struct hostent* ptr = gethostbyname(name);
    if (ptr == nullptr){
        err_display("gethostbyname()");
        return false;
    }
    if (ptr->h_addrtype != AF_INET)
    	return false;
	memcpy(addr, ptr->h_addr, ptr->h_length);
    return true;
}

위 코드는 gethostbyname 함수를 사용하여 도메인 네임을 IPv4 주소로 변환하는 사용자 정의 함수이다. 

 

bool GetDomainName(struct in_addr* addr, char* name, int namelen)
{
    struct hostent* ptr = gethostbyaddr((const char*)&addr, sizeof(addr), AF_INET);
    if (ptr == nullptr){
        err_display("gethostbyname()");
        return false;
    }
    if (ptr->h_addrtype != AF_INET)
    	return false;
	strncpy(name, ptr->h_name, namelen);
    return true;
}

위 코드는 gethostbyaddr 함수를 사용하여 IPv4 주소를 도메인 네임으로 변환하는 사용자 정의 함수이다. 

문자열 복사 시 strcpy 함수보다 strncpy 함수를 사용하는 것이 좀 더 안전하다고 한다.

 

그렇다면 위 함수들을 사용하여 이름 변환을 수행하는 예제를 작성해보자.

#define TESTNAME "www.google.com"

int main(int argc, char *argv[])
{
	WSADATA wsa;
	if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0)
		return 1;

	printf("도메인 이름(변환 전) = %s\n", TESTNAME);

	struct in_addr addr;
	if (GetIPAddr(TESTNAME, &addr)) {
		char str[INET_ADDRSTRLEN];
		inet_ntop(AF_INET, &addr, str, sizeof(str));
		printf("IP 주소(변환 후) = %s\n", str);

		char name[256];
		if (GetDomainName(addr, name, sizeof(name))) {
			printf("도메인 이름(다시 변환 후) = %s\n", name);
		}
	}

	// 윈속 종료
	WSACleanup();
	return 0;
}

위 코드에서는 www.google.com test 도메인 네임을 우선 GetIPAddr을 통해 IP 주소로 변환한다. 만약 성공했다면 숫자 형태의 IP 주소를 문자 형태로 변환한다. 그리고 다시 문자열 형태의 IP 주소를 도메인 네임으로 변환하는 코드이다.

 

입력한 도메인 네임이 맞는 도메인 네임인지 그리고 어떤 별칭들과 IP 주소를 여러 개 가지고 있을 경우 어떤 IP 주소들이 있는지를 확인하고 싶을 경우가 있을 것이다. 그렇다면 다음과 같이 코드를 작성하여 hostent 구조체에 채워진 정보를 활용할 수 있을 것이다.

int main(int argc, char* argv[])
{
	WSADATA wsa;
	if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0)
		return 1;
	
	printf("도메인 이름(변환 전) = %s\n", argv[1]);
	
	struct hostent* ptr = gethostbyname(argv[1]);
	if (ptr == nullptr) {
		std::cout << "해당 도메인 이름은 없는 도메인 이름입니다.";
		return 0;
	}
	
	for (int i = 0; ptr->h_aliases[i] != nullptr; ++i) {
		std::cout<< "Aliases-" << i << ": " << ptr->h_aliases[i] << std::endl;
	}
	
	char str[INET_ADDRSTRLEN];
	for (int i = 0; ptr->h_addr_list[i] != nullptr; ++i) {
		std::cout << "IPv4-" << i << ": " << inet_ntop(AF_INET, &ptr->h_addr_list[i], str, sizeof(str)) << std::endl;
	}
	
	WSACleanup();
}

다음과 같이 ptr에 gethostbyname을 이용하여 명령 인수를 인자로 받아서 해당 도메인에 대한 정보를 담은 구조체 hostent를 반환하기 때문에 이 ptr을 참고하여 출력하면 된다. 그러면 다음과 같은 결과가 나올 것이다.

 

명령 프롬프트의 nslookup 명령어를 사용하면 해당 도메인 네임과 IP 주소를 확인할 수 있다. 위 코드와 nslookup 명령어를 통해 비교하면 실제로 도메인 네임과 IP 주소가 일치한다는 것을 알 수 있을 것이다.

'네트워크 프로그래밍' 카테고리의 다른 글

[멀티스레드]  (0) 2023.10.11
[데이터 전송하기]  (0) 2023.09.27
[TCP 서버-클라이언트]  (0) 2023.09.26
[소켓 시작하기]  (0) 2023.09.10
[네트워크와 소켓 프로그래밍]  (0) 2023.09.09