layered

3 주소 체계와 데이터 정렬 본문

네트워크/열혈 TCP IP 소켓 프로그래밍

3 주소 체계와 데이터 정렬

스윗푸들 2023. 4. 29. 14:32

3-1 소켓에 할당되는 IP 주소와 PORT 번호


인터넷 주소

Internet Protocol은 인터넷 상에서 데이터를 송수신할 목적으로 컴퓨터에게 부여하는 값이다.

즉, 네트워크 상에서 컴퓨터를 잘 찾아가기 위한 주소라고 할 수 있다.

 

IP 주소는 IPv4와 IPv6로 나뉘는데 둘의 차이점은 사용하는 바이트 수이다. 원래 사용하는 것은 IPv4인데, 인터넷이 워낙 규모가 커지다 보니 4바이트로는 한계가 있어 16바이트의 IPv6가 점점 도입되는 추세이다.

 

IPv4 기준으로 IP 주소는 네트워크 주소와 호스트 주소로 나뉜다. 그래서 데이터는 네트워크(= 라우터)에 먼저 전송되고, 이 라우터에서 호스트로 전송하는 것이다.

 

메모

아주 간단하게 말하면 네트워크는 호스트와 라우터들의 집합이다.

호스트는 서버나 클라이언트를 말한다. 쉽게 말해서 디바이스나 앱 등신호등도 호스트라고 한다을 말하며 모든 데이터들의 출발지/종착지라고 할 수 있다.

통신을 하기 위해 호스트들을 서로 연결해야 한다면 어떻게 해야 할까? 호스트 간에 일일이 링크를 부여하는 건 규모가 커질수록 부담스러울 것이다.

그래서 나온 것이 라우터로, 호스트들을 하나의 라우터로 연결함으로써 기본적인 네트워크(= access network)를 구성하게 된다. 라우터는 네트워크 상에서 데이터가 목적지를 잘 찾아갈 수 있도록 IP 주소와 경로를 설정하는 역할을 한다.

 

포트 번호

IP 주소를 이용해 컴퓨터까지는 도착했는데, 어떤 프로세스인지를 모르겠다! 해서 나온 게 포트 번호이다.

프로세스마다 성격이 다른 것처럼 소켓도 각각 생성되어야 하므로, 포트 번호는 하나의 운영체제 내에서 소켓을 구분하는 용도로 사용된다.

다만 한 프로세스가 포트 번호를 두 개 이상 사용하거나, 둘 이상의 프로세스가 한 포트 번호를 사용하는 경우도 있으므로 프로세스를 구별한다기보다는 통신의 종착점을 나타낸다고 봐도 좋다.

 

포트 번호는 16비트의 부호 없는 정수로, 0 ~ 65,535의 범위를 가지며 다음과 같다.

 

포트 번호 분류
0 ~ 1023 알려진 포트 Well-known Port
1024 ~ 49151 등록된 포트 Registered Port
49152 ~ 65535 동적/사설 포트 Dynamic and/or Private Port

 

알려진 포트는 함부로 사용하면 안 된다. 일반적으로는 등록된 포트 범위에서 선택해서 사용하는 것이 옳다.

포트 번호는 중복이 불가능하지만, TCP와 UDP의 경우는 같은 포트 번호를 할당할 수 있다.

 

3-2 주소 정보의 표현


데이터를 목적지에 정확히 전송하기 위해 IP 주소와 포트 번호를 이용한다는 것을 알았다.

이제 궁금해지는 건 이걸 어떻게 표현할까?일 것이다.

 

그래서 다음과 같이 소켓 주소 구조체가 정의되어 있다.

(지금부터 나오는 코드는 윈도우 기준이지만 사용할 때 리눅스와의 차이는 없다.)

 

struct sockaddr {
    u_short sa_family;
    char    sa_data[14];
}

 

sa_family

주소 체계(AF_)를 나타내는 16비트 정수값이다. 예를 들어 TCP/TP 프로토콜을 사용하고자 한다면 AF_INET 또는 AF_INET6 값을 가진다.

 

sa_data

주소를 담는 부분이다. 주소 체계에 따라 필요한 정보가 다르므로 일반적인 바이트 배열로 선언되어 있다.

 

이 sockaddr은 가장 기본이 되는 구조체이며 실제 응용프로그램이 사용할 프로토콜에 따라 별도의 소켓 주소 구조체가 정의되어 있다. TCP/IP에서 사용하는 구조체는 다음과 같다.

 

struct sockaddr_in {
        short   sin_family;
        u_short sin_port;
        struct  in_addr sin_addr;
        char    sin_zero[8];
};

 

sin_family

주소 체계(AF_)를 저장한다.

 

sin_port

16비트 포트 번호를 저장한다. 네트워크 바이트 순서로 저장해야 한다.

 

sin_addr

32비트 IP 주소를 저장한다. 역시 네트워크 바이트 순서로 저장해야 하며, 여기에 사용되는 in_addr 구조체는 다음과 같이 정의되어 있다.

 

struct in_addr {
        union {
                struct { u_char s_b1,s_b2,s_b3,s_b4; } S_un_b;
                struct { u_short s_w1,s_w2; } S_un_w;
                u_long S_addr;
        } S_un;
#define s_addr  S_un.S_addr
// 등등 여러 매크로 변수들
}

 

이 구조체를 살펴보면, 주소를 여러 비트 단위로 접근할 수 있게 공용체로 선언되어 있다.  IP 주소는 주로 32비트로 접근하므로 이 중에서 S_addr을 사용하게 되는데, 매번 S_un.S_addr로 쓰기는 귀찮으니까 편하게 매크로로 정의되어 있는 걸 볼 수 있다.

 

struct in_addr {
    u_long s_addr;
}

 

이런 느낌으로 생각하면 된다.

 

sin_zero

네트워크에서는 소켓을 대부분 sockaddr 타입으로 다룬다. 따라서 어떤 소켓 구조체를 사용할 때에는 형변환을 해 주어야 하며, 크기가 크기 때문에 주소값을 넘겨 줘야 한다.

보면 sockaddr은 16바이트(2 + 14)이고, sockaddr_in은 8바이트(2 + 2 + 4)이므로 크기가 맞지 않는 걸 볼 수 있다. 그래서 sin_zero를 통해 남는 8바이트를 0으로 채워 주는 것이다.

 

C에서는 소켓 구조체를 typedef 키워드를 이용해서 정의한 것도 있다. 그래서 다음과 같이 사용하는 것도 가능하다.

 

struct sockaddr_in addr;
SOCKADDR_IN addr;

 

하지만 리눅스와의 호환성을 고려한다면 그냥 struct를 쓰는 게 좋다.

 

메모

주소를 설정할 때 어차피 32비트 정수형인데 굳이 in_addr 구조체를 써야 하나? 싶어서 찾아본 바로는 이렇다.

 

전통적으로 IP 주소는 네트워크와 호스트가 차지하는 바이트에 따라 여러 개의 클래스로 구분이 되었기 때문에, 주소를 표현할 때에도 각각의 바이트에 대해 접근할 수 있어야 했다. 그래서 union을 이용해 정의한 in_addr 구조체는 단순한 in_addr_t 이상의 의미를 가졌다.

그런데 서브넷(= 네트워크를 쪼개는 것)이 발전하고 클래스의 구분이 희미해지면서 union을 이용한 주소 표현에 대한 필요성이 사라지게 되고, in_addr 구조체를 단지 하나의 in_addr_t로 정의하게 된 것이다.

 

그래서 리눅스는 다음과 같이 정의되어 있다.

 

struct in_addr {
    in_addr_t s_addr;
};

 

3-3 네트워크 바이트 순서와 인터넷 주소 변환


CPU에 따라서 데이터를 저장하는 바이트의 순서가 달라질 수 있다. 이를 호스트 바이트 순서(또는 정렬, Host Byte Order)라고 한다.

 

빅 엔디안 Big endian

데이터를 최상위 바이트(MSB, Most Significant Bit)부터 차례로 저장한다. 쉽게 말하자면 평소 우리가 숫자를 사용하는 방법과 같다.

 

리틀 엔디안 Little endian

데이터를 최하위 바이트(LSB, Least Significant Bit)부터 차례대로 저장한다. 인텔 계열의 CPU에서 사용하는 방식이다.

 

간단한 예시로 4바이트 정수 0x12345678를 0x20번지부터 저장한다고 해보자. 참고로 0x12 부분이 MSB이다.

 

0x12 0x34 0x56 0x78  빅 엔디안

0x78 0x56 0x34 0x12 리틀 엔디안

0x20 0x21 0x22 0x23 주소

 

이렇듯 두 방식에 따라 데이터가 저장되는 형태가 다르기 때문에, 미리 약속을 하지 않고 저마다의 방식으로 보낸다면 데이터가 완전히 달라질 수 있다는 위험성이 존재한다.

그래서 네트워크에서는 빅 엔디안 방식으로 통일하기로 정하고, 이를 네트워크 바이트 순서(Network Byte Order)라고 한다. 

 

바이트 순서의 변환

네트워크 바이트 순서를 위한 여러 가지 변환 함수들이 있다. 

 

u_short htons(u_short hostshort); // host-to-network-short
u_long  htonl(u_long hostlong);   // host-to-network-long
u_short ntohs(u_short netshort);  // network-to-host-short
u_long  ntohs(u_long netlong);    // network-to-host-long

 

이름에서도 알 수 있듯이 호스트-네트워크 간의 변환을 의미하며 s가 붙은 것은 포트 번호의 변환에, l이 붙은 것은 IP 주소의 변환에 사용된다.

 

"엇 제 시스템은 빅 엔디안으로 동작하니까 변환할 필요가 없겠네요!"

 

틀린 말은 아니지만 바이트 순서에 상관없이 돌아가는 코드를 위해서는 역시 변환 과정을 거치는 것이 좋다(응용프로그램에서 자동으로 함수를 호출해 준다!). 물론 이 경우에는 아무런 변화가 일어나지 않는다.

 

3-4 인터넷 주소의 초기화와 할당


이제 sockaddr_in 구조체에 주소가 빅 엔디안 방식으로 저장된다는 것까지 알았다. 하지만 우리가 일반적으로 사용하는 IP 주소 형태는 192.168.10.0 같이 문자열의 형태이기 때문에 이를 정수로 변환해 주는 과정을 거쳐야 한다.

 

원래는 IPv4에 대해 다음의 두 함수를 사용했다.

 

#include <arpa/inet.h> // 리눅스

in_addr_t inet_addr(const char *string); // 성공: 32비트 정수, 실패: INADDR_NONE
int inet_aton(const char *string, struct in_addr *addr); // 성공: 1, 실패: 0

 

inet_addr 함수는 문자열 주소를 전달받아 빅 엔디안 방식의 32비트 정수값으로 반환한다. 하지만 이 함수는 반환값을 이용해 구조체 변수에 대입하는 과정을 한 번 더 거쳐야 했다.

그래서 inet_aton 함수는 소켓 주소 구조체의 변수도 같이 전달받은 다음, 변환된 주소를 저장해 준다.

 

반대로 네트워크 바이트 순서로 정렬된 정수 IP 주소를 문자열로 바꿔주는 함수도 있다.

 

char* inet_ntoa(struct in_addr addr);

 

하지만 이 함수의 반환값을 보면 char형 포인터인데, 이는 함수에서 내부적으로 메모리 공간을 할당한 다음 그 주소를 반환한다는 것이다. 따라서 저장된 문자열을 다른 곳에 바로 복사해 두지 않으면, 다른 호출에 의해 내용이 변경될 수 있다는 문제가 발생한다.

 

그래서 새롭게 등장한 inet_ntop의 경우 아예 문자열 버퍼를 넘겨 주는 것으로 바뀌었다.

 

#include <WS2tcpip.h> // 윈도우

INT inet_pton(INT family, PCSTR pszAddrString, PVOID pAddrBf); // 성공: 1, 실패: 0, -1
PCSTR inet_ntop(INT family, const VOID *pAddr, PSTR pStringBuf, size_t StringBufSize);

 

#include <arpa/inet.h> // 리눅스

int inet_pton(int af, const char *src, void *dst);
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);

 

요즘은 이 두 함수를 사용한다.

조금 복잡해 보이지만 문자열을 정수로 변환할 때(pton)에는 주소 체계, 변환하고자 하는 IP, 구조체 주소 변수를 넘겨 준다.

정수를 문자열로 변환할 때(ntop)에는 주소 체계, 구조체 주소 변수, 문자열 버퍼, 문자열 버퍼 크기를 넘겨 준다.

 

여기서 주의할 점은 주소 체계로 AF_INET을 사용할 경우 버퍼가 16자 이상을 저장할 수 있을 만큼 커야 한다는 것이다(AF_INET6는 46자 이상).

 

간단한 출력의 경우는 inet_ntoa를 사용하는 게 더 좋은 것 같다. 굳이 버퍼를 생성하지 않아도 되니까.

 

메모

정리를 해보면...

일단 소켓을 만들고 프로토콜을 할당한다.

그다음 소켓에 주소를 부여하기 위해 주소 구조체를 초기화해야 한다.

인터넷을 위한 주소 구조체 sockaddr_in의 변수는 네 개이다. 순서대로 주소 체계, 포트 번호, IP 주소, 0.

그런데 문자열 형태의 IP 주소는 그냥 구조체에 바로 저장할 수가 없어서, inet_pton 함수를 이용해 따로 저장해 준다.

 

inet_ntop에서 크기가 16 이상이어야 하는 이유는 IP 주소의 최대 길이가 15여서인 것 같다(255.255.255.255 같이 각 바이트가 세 자리 숫자인 경우).

여기에 문자열을 저장할 배열의 마지막에는 NULL을 넣어야 하니까 16이 된다!

 

간단한 연습용 코드.

 

struct sockaddr_in _sockaddr;
memset(&_sockaddr, 0, sizeof(_sockaddr));
const char* ip = "192.168.10.1";
int port = 9000;
char ipaddr[20];
_sockaddr.sin_family = AF_INET;
_sockaddr.sin_port = htons(port);
inet_pton(AF_INET, ip, &_sockaddr.sin_addr);
printf("pton(presentation to numeric): %#x\n", _sockaddr.sin_addr); // 0x10aa8c0
inet_ntop(AF_INET, &_sockaddr.sin_addr, ipaddr, sizeof(ipaddr));
printf("ntop(numeric to presentation): %s", ipaddr); // 192.169.10.1

 

처음에는 문자열 버퍼를 IP 주소의 크기로 설정했다가 한자가 나와서 당황했다. 20으로 설정해 주니 제대로 되었다.

 

위와 같은 초기화 과정은 서버 프로그램에서 주로 사용하는 방법이다. 이렇게 초기화한 다음 bind 함수를 호출하면 데이터를 받기 위한 주소를 모두에게 알려주게 되는 것이다.

클라이언트는 소켓 주소 구조체에 서버의 주소를 저장한 다음 connect 함수를 호출한다.

 

INADDR_ANY

컴퓨터에는 네트워크에 연결하기 위한 장치인 NIC(Network Interface Card)가 있다. 이 NIC마다 IP 주소가 부여되기 때문에 어떤 컴퓨터가 여러 개의 NIC를 가진다면 IP 주소도 여러 개가 된다.

그러므로 굳이 서버의 IP 주소를 초기화해야 하는 이유는 이렇게 여러 개의 IP 주소를 가지는 컴퓨터로 인한 것이다.

 

그런데 하나가 아니라 모든 IP 주소를 서버에게 할당하고 싶다면 어떻게 해야 할까? 또는 IP 주소가 하나뿐이긴 하지만 일일이 설정하기가 귀찮다면?

그래서 INADDR_ANY는 소켓이 동작하는 컴퓨터의 IP 주소를 자동으로 할당한다.

 

#define INADDR_ANY (u_long)0x00000000

 

헤더 파일에는 이렇게 정의되어 있는데 문자열로 변환해서 출력하면 0.0.0.0이다. IP 주소에 상관없이 들어오는 요청을 모두 받으라는 의미이다.

따라서 IP 주소가 한 개든 여러 개든 지정한 서버로 데이터를 받을 수 있게 된다. 이제 INADDR_ANY를 이용해 sockaddr_in의 주소 변수를 다음과 같이 설정할 수 있다.

 

_sockaddr.sin_addr.S_un.S_addr = htonl(INADDR_ANY);

 

hello_server.c와 hello_client.c의 실행에 대한 고찰

 

./hserver 9190
./hclient 127.0.0.1 9190

 

우선 IP 주소와 포트 번호를 같이 전달하는 이유는 코드 내에서 정해 버리면 다른 시스템에서 실행할 경우 코드를 매번 바꿔 주어야 하기 때문이다.

서버를 실행할 때에는 포트 번호만 넘겨 주었다. 이는 INADDR_ANY를 사용하는 것으로부터 비롯된다.

클라이언트를 실행할 때에 넘겨 준 127.0.0.1은 루프백 주소(loopback address, 또는 localhost)로 자기자신을 가리킨다. 서버와 클라이언트가 같은 컴퓨터 내에서 실행되는 상황을 가정했기 때문이다.

 

소켓에 인터넷 주소 할당하기

소켓 생성도 했고, 소켓 주소 구조체도 초기화했으니 이제 둘을 연결해 주는 일이 남았다.

 

int bind(int sockfd, struct sockarrd *myaddr, socklen_t addrlen); // 성공: 0, 실패: -1
int bind(SOCKET s, const sockaddr *name, int namelen); // 성공: 0, 실패: SOCKET_ERROR

 

소켓 주소 구조체를 넘겨 줄 때 (struct sockaddr*)로 형변환을 해 줘야 한다는 것을 잊지 말자.

 

SOCKET sock = socket(PF_INET, SOCK_STREAM, 0);
int port = 9000;
struct sockaddr_in serv_addr = { AF_INET, htons(port), htonl(INADDR_ANY) };
// 이렇게 초기화하는 건 별로 좋은 방법은 아닌 것 같긴 하다
if (!bind(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)))
	printf("bind!");

 

이렇게 해서 소켓을 생성하고 주소를 할당하는 것까지 되었다!