본문으로 바로가기
반응형

주소체계와 데이터 정렬

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

IP는 Internet Protocol의 약자로 인터넷상에서 데이터를 송수신할 목적으로 컴퓨터에게 부여하는 값을 의미한다.

반면 PORT 번호는 컴퓨터에게 부여하는 값이 아닌, 프로그램상에서 생성되는 소켓을 구분하기위해 소켓에 부여되는 번호를 뜻한다.

 

 

인터넷 주소(Internet Address)

인터넷에 컴퓨터를 연결해서 데이터를 주고받기 위해서는 IP주소를 부여받아야한다. 이러한 IP주소체계는 2종류이다.

 

- IPv4(Internet Protocol version 4) : 4바이트 주소체계

- IPv6(Internet Protocol viersion 6)  : 16바이트 주소체계

 

IPv4와 IPv6의 차이점은 IP주소의 표현에 사용되는 바이트 크기에 있다. IPv4 기준의 4바이트 IP주소는  네트워크 주소와 호스트(컴퓨터를 의미함) 주소로 나뉘며, 주소의 형태에 따라서 A, B, C, D, E 클래스로 분류가 된다.

 

네트워크 주소(네트워크 ID)란 네트워크 구분을 위한 IP 주소의 일부를 말한다.

네트워크를 구성하려면 외부로부터 수신된 데이터를 호스트에 전달하고, 호스트가 전달하는 데이터를 외부로 송신해주는 물리적 장치가 필요하다. 이를 가리켜 라우터 또는 스위치라 하는데, 이것도 그냥 컴퓨터에 지나지 않는다.

다만 특수한 목적을 가지고 설계 및 운영되는 컴퓨터이기 때문에 라우터 또는 스위치라는 별도의 이름을 붙인것이다.

때문에 우리가 사용하는 컴퓨터도 적절한 S/W만 설치 및 구성하면 라우터로 동작시킬 수 있다.

그리고 라우터보다 기능적으로 작은 것을 가리켜 스위치라 부르는데, 사실상 이 둘은 같은 의미로 사용된다.

 

 

클래스 별 네트워크 주소와 호스트 주소의 경계

IP주소의 첫 번째 바이트만 딱 보면 네트워크 주소가 몇 바이트인지 판단이 가능하다. 왜냐하면 다음과 같이 클래스 별 IP주소의 경계를 나눠놓았기 때문이다.

 

- 클래스 A의 첫번째 바이트 범위 : 0~127

- 클래스 B의 첫번째 바이트 범위 : 128~191

- 클래스 C의 첫번째 바이트 범위 : 192~223

 

이는 다음과 같이 표현할 수도 있다.

 

- 클래스 A의 첫번째 비트는 항상 0으로 시작

- 클래스 B의 첫번째 비트는 항상 10으로 시작

- 클래스 C의 첫번째 비트는 항상 110으로 시작

 

이러한 기준이 정해져있기 때문에 소켓을 통해서 데이터를 송수신할때, 우리가 별도로 신경 쓰지 않아도 네트워크로 데이터가 이동하고 이어서 최종 목적지인 호스트로 데이터가 전송되는 것이다.

 

 

소켓의 구분에 활용되는 PORT 번호

IP는 컴퓨터를 구분하기 위한 목적으로 존재한다.

때문에 IP만 있다면 목적지 컴퓨터로 데이터를 전송할 수 있다. 그러나 이것만 가지고 데이터를 수신해야 하는 최종 목적지인 응용프로그램에까지 데이터를 전송할 순 없다. 예를들어 우리가 동영상을 시청하면서 인터넷 서핑을 함께하고 있다고 가정해보자.

 

그렇다면 동영상 데이터의 수신을 위한 소켓 하나와 인터넷 정보의 수신을위한 소켓 하나가 최소한으로 필요하다.

그런데 이 둘을 어떻게 구분할 것인가? 둘 이상의 컴퓨터로부터 데이터를 전송받으려면 둘 이상의 소켓이 생성되어야한 다는것 까지는 유추 할 수 있을 것이다. 그렇다면 이들 소켓은 어떻게 구분할 것인가?

 

우리 컴퓨터에는 NIC(네트워크 인터페이스 카드)라 불리는 데이터 송수신 장치가 하나씩 달려있다.

IP는 데이터를 NIC를 통해 컴퓨터 내부로 전송하는데 사용된다.

그러나 컴퓨터 내부로 전송된 데이터를 소켓에 적절히 분배하는 작업은 운영체제가 담당한다. 이때 운영체제는 PORT 번호를 활용한다. 즉, NIC을 통해 수신된 데이터 안에는 PORT 번호가 새겨져 있다.

운영체제는 바로 이 정보를 참조해서 일치하는 PORT번호의 소켓에 데이터를 전달하는 것이다.

이렇듯 PORT 번호는 하나의 운영체제 내에서 소켓을 구분하는 목적으로 사용되기 때문에 하나의 운영체제 내에서 동일한 PORT번호를 둘 이상의 소켓에 할당할 수 없다. 그리고 PORT 번호는 16비트로 표현된다.

때문에 할당할 수 있는 PORT번호의 범위는 0~65535이다. 그러나 0부터 1023번까지는 잘 알려진 PORT(Well-known PORT)라 해서, 특정 프로그램에 할당하기로 예약되있기 때문에, 이 범위의 값을 제외한 다른 값을 할당해야 한다.

 

그리고 PORT 번호는 중복이 불가능하지만, TCP, UDP 소켓은 PORT 번호를 공유하지 않기 때문에 중복되어도 상관없다.

즉, TCP 소켓을 생성할때 9190 PORT 번호를 할당했다면, 다른 TCP 소켓에서는 9190 PORT 번호를 할당할 수 없지만, UDP 소켓에는 할당할 수 있다.

 

정리하면 우리가 흔히 말하는 데이터 전송의 목적지 주소에는 IP 주소 뿐만아니라 PORT 번호도 포함이된다.

그래야 최종 목적지에 해당하는 응용프로그램에까지(응용프로그램의 소켓까지) 데이터를 전달할 수 있기 때문이다.

 

 

주소 정보의 표현

응용프로그램상에서의 IP주소와 PORT 번호 표현을 위한 구조체가 정의되어있다.

따라서 이 구조체를 중심으로 목적지 주소의 표현방법에 대해 살펴보자.(참고로 IPv4를 중심으로 할 예정이다)

 

 

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

주소정보를 담을때는 3가지 물음에 답이 되도록 담아야한다.

 

- 질문 1 : 어떠한 주소체계를 사용하는가?

- 답변 1 : IPv4 기반 주소체계를 사용한다.

 

- 질문 2 : IP 주소가 어떻게 되나요?

- 답변 2 : 211.204.asd.re 입니다.

 

- 질문 3 : PORT번호는 어떻게 되나요?

- 답변 3 :  2048번 입니다.

 

그리하여 위의 질문에 답이 될 수 있도록 다음의 형태로 구조체가 정의되었다. 이 구조체는 곧 소개할 bind 함수에 주소정보를 전달하는 용도로 사용된다.

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

구조체 정의에 사용된 또 다른 구조체 in_addr은 다음과 같이 정의되어 있다. 이는 32비트 IP주소정보를 담을 수 있도록 정의되어있다.

struct in_addr
{
    int_addr_t   s_addr;   // 32비트 IPv4 인터넷 주소
}

 

 

구조체 sockaddr_in 멤버에 대한 분석

이제 구조체의 멤버별로 어떠한 의미를 지니고 어떠한 정보로 채워지는지 하나씩 살펴보자.

 

- sin_family

프로토콜 체계마다 적용하는 주소체계가 다르다. 예를들어 IPv4에서는 4바이트 주소체계를 사용하고 IPv6에서는 16바이트 주소체계를 사용한다. 따라서 아래처럼 참고하여 멤버 sin_family에 적용할 주소체계 정보를 저장해야한다.

 

AF_INET    : IPv4 인터넷 프로토콜에 적용하는 주소체계

AF_INET6  : IPv6 인터넷 프로토콜에 적용하는 주소체계

AF_LOCAL : 로컬 통신을 위한 유닉스 프로토콜의 주소체계

 

위 표에서 AF_LOCAL은 다양한 주소체계가 있음을 알리기위해 삽입한 것이니 당황하지는 말자.

 

- sin_port

 16비트 PORT번호를 저장한다. 단, '네트워크 바이트 순서'로 저장해야 하는데, 이 멤버에 대해서는 PORT번호를 저장한다는 사실보다 네트워크 바이트 순서로 저장해야 한다는 사실이 더 중요하다.

 

- sin_addr

32비트 IP주소정보를 저장한다. 이 역시 '네트워크 바이트 순서'로 저장해야 한다. 이 멤버를 정확히 파악하기 위해서는 구조체 in_addr도 함께 살펴봐야한다. 

- sin_zero

특별한 의미는 지니지 않는다. 단순히 구조체 sockaddr_int 의 크기를 구조체 sockaddr와 일치시키기위해 삽입된 멤버이다. bind 함수에 대한 설명은 나중에 진행되니 일단은 인자전달과 형변환 위주로만 코드를 살펴보자.

 

여기서 중요한 것은 두번째 인자이다. 사실 bind 함수는 sockaddr 구조체 변수의 주소값을 요구한다.

앞서 설명한 주소체계, PORT번호, IP 주소정보를 담고있는 sockaddr 구조체 변수의 주소 값을 요구하는 것이다.

그런데 반드시 0으로 채워야한다. 만약에 0으로 채우지 않으면 원하는 결과를 얻지 못한다. sockaddr에 대해서는 이어서 별도로 알아보도록 하자.

 

sockaddr_in 구조체 변수의 주소 값은 bind 함수의 인자로 다음과 같이 전달된다.

struct sockaddr_in serv_addr;

if(bind(serv_sock, (struct sockaddr*) &serv_addr, sizeof(serv_addr)) == -1)
     error_handling("bind() error");

여기서 중요한 것은 두번째 전달인자이다. bind 함수는 sockaddr 구조체 변수의 주소 값을 요구한다.

앞서 설명한 주소체계, PORT번호, IP주소정보를 담고있는 sockaddr 구조체 변수의 주소 값을 요구하는 것이다.

그런데 아래에서 보이듯이 구조체 sockaddr은 이들 정보를 담기에 다소 불편하게 정의되어 있다.

struct sockaddr
{
    sa_family_t    sin_family    // 주소체계(Address Family)
    char           sa_data[14];  // 주소정보
}

구조체 멤버 sa_data에 저장되는 주소정보에는 IP주소와 PORT번호가 포함되어야하고, 이 두가지 정보를 담고 남은 부분은 0으로 채울 것으로 bind 함수는 요구하고 있다. 그런데 이는 주소정보를 담기에 매우 불편한 요구사항이다.

그래서 구조체 sockaddr_int이 등장한 것이다. sockaddr_in 구조체 멤버를 설명한대로 채우면, 이때 형성되는 구조체 변수의 바이트 열이 bind 함수가 요구하는 바이트열이 된다.

 

결국 인자전달을위한 형변환을 통해서 sockaddr 구조체 변수에 bind 함수가 요구하는 반대로 데이터를 채워넣은 효과를 볼 수 있다.

 

 

sin_family을 다시 알아보자

sockaddr_in인 IPv4의 주소정보를 담기위해 정의된 구조체이다. 그럼에도 주소체계 정보를 구조체 멤버 sin_family에 별도로 저장하는 이유가 궁금할 수도 있다. 하지만 이는 앞서 설명한 sockaddr과 관련이 있다.

 

구조체 sockaddr은 IPv4의 주소정보만을 담기 위해 정의된 구조체가아니다. 주소정보를 담는 배열 sa_data의 크기가 14바이트인 이유가 있는 것이다.

 

따라서 구조체 sockaddr에서는 주소체계 정보를 구조체 멤버 sin_family에 저장할 것을 요구하고 있다. 때문에 구조체 sockaddr과 동일한 바이트열을 편히 구성하기 위해서 정의된 구조체 sockaddr_in에도 주소체계 정보를 담기 위한 멤버가 존재하는 것이다.

 

 

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

CPU에 따라서 4바이트 정수 1을 메모리 공간에 저장하는 방식이 달라질 수 있다.

4바이트 정수 1을 2진수로 표현하면 다음과 같다.

 

00000000 00000000 00000000 00000001

 

이 순서 그대로 메모리에 저장하는 CPU가 있는가 하면, 다음과 같이 거꾸로 저장하는 CPU도 있다.

 

00000001 00000000 00000000 00000000 

 

때문에 이러한 부분을 고려하지 않고서 데이터를 송수신하면 문제가 발생할 수 있다. 저장순서가 다르다는 것은 전송되어온 데이터의 해석순서가 다름을 뜻하기 때문이다.

 

 

바이트 순서(Order)와 네트워크 바이트 순서

CPU가 데이터를 메모리에 저장하는 방식은 두가지로 나뉜다. 참고로 CPU가 데이터를 메모리에 저장하는 방식이 두가지로 나뉜다는 것은 CPU가 데이터를 해석하는 방식도 두 가지로 나뉜다는 뜻이다.

 

- 빅 엔디안(Big Endian)       : 상위 바이트의 값을 작은 번지수에 저장하는 방식

- 리틀 엔디안(Little Endian)  : 상위 바이트의 값을 큰 번지수에 저장하는 방식

 

이렇게만보면 감이 잘 오지않으니 예를 하나 들어보자.

0x20 번지를 시작으로 4바이트 int형 정수 0x12345678 을 저장한다고 가정해보자.

빅 엔디안 방식의 CPU는 다음의 형태로 메모리에 저장된다.

정수 0x12345678 중에서 0x12가 최상위 바이트, 0x78이 최하위 바이트이다.

따라서 빅 엔디안 방식에서는 최상위바이트인 0x12부터 저장된다.(최상위 바이트 0x12가 작은 번지수에 저장된다)

반면 리틀 엔디안 방식은 위의 그림과 반대의 순서로 저장된다.

 

 

위 그림에서 보이듯 최하위 바이트인 0x78이 먼저 저장되고 있다. 이렇듯 데이터 저장방식은 CPU마다 다르다.

그래서 CPU의 데이터 저장방식을 의미하는 '호스트 바이트 순서(Host Byte Order)'는 CPU에 따라서 차이가 난다.

 

다음 그림은 호스트 바이트 순서가 다른 두 CPU가 데이터를 주고받을때 발생하는 문제점을 나타낸 것이다.

빅엔디안 시스템에서 0x12, 0x34의 조합으로 만들어지는 값은 리틀 엔디안 시스템에서 0x34, 0x12의 조합으로 만들어지는 값과 같다. 즉, 저장되는 순서가 바뀌어야 동일한 값으로 인식된다.

그런데 위 그림에서는 빅엔디안 시스템에 저장된 값 0x1234를 리틀 엔디안 시스템에 전송하는데,

바이트 순서에 대한 문제를 고려하지 않고 0x12, 0x34의 순으로 데이터를 전송하고 있다.  결국 리틀 엔디안 시스템은 전송되는 순서대로 데이터를 저장한다. 때문에 전송된 값은 리틀 엔디안 입장에서 0x1234가 아닌 0x3412가 되버린다.

 

이러한 문제점 때문에 네트워크를 통해 데이터를 전송할때는 통일된 기준으로 데이터를 전송하기로 약속했으며, 이 약속을 가리켜 '네트워크 바이트 순서(Network Byte Order)라 한다. 네트워크 바이트 순서의 약속은 매우 간단하다.

 

"빅 엔디안 방식으로 통일합시다!"

 

즉, 네트워크 상으로 데이터를 전송할때에는 데이터의 배열을 빅 엔디안 기준으로 변경해서 송수신하기로 약속한 것이다. 때문에 모든 컴퓨터는 수신된 데이터가 네트워크 바이트 순서로 정렬되어 있음을 인식해야 하며, 리틀 엔디안 시스템에서는 데이터를 전송하기에 앞서 빅 엔디안의 정렬방식으로 데이터를 재정렬해야한다.

 

 

바이트 순서의 변환(Endian Conversions)

이제 sockaddr_in 구조체 변수에 값을 채우기 앞서 네트워크 바이트 순서로 변환해서 저장해야하는 이유를 알았을 것이다. 그럼 이번에는 바이트 순서의 변환을 돕은 함수를 알아보자.

 

- unsigned short htons(unsigned short);

- unsigned short ntohs(unsigned short);

- unsigned long htonl(unsigned long);

- unsigned long ntohl(unsigned long);

 

갑자기 모르는 함수이름이 나왔지만, 다음 사실만 알면 함수 이름을 보고 기능을 파악할 수 있을 것이다.

 

- htons에서의 h는 호스트(host) 바이트 순서를 의미한다.

- htons에서의 n은 네트워크(network) 바이트 순서를 의미한다.

 

그리고 s는 short, l은 long을 의미한다. 따라서 htons는 다음과 같이 해석할 수 있다.

 

"short형 데이터를 호스트 바이트 순서에서 네트워크 바이트 순서로 변환해라"

 

한가지만 더 보자면, ntohs는 다음과 같이 해석할 수 있다.

 

"short형 데이터를 네트워크 바이트 순서에서 호스트 바이트 순서로 변환해라"

 

일반적으로 뒤에 s가 붙는 함수는 s가 2바이트 short를 의미하므로 PORT 번호의 변환에 사용되고, 뒤에 1이 붙는 함수는 1이 4바이트를 의미하므로 IP 주소의 변환에 사용된다.

 

그리고 혹 시스템이 빅엔디안으로 동작한다고해서 sockaddr_in 구조체 변수에 값을 넣을때 네트워크 바이트 순서로 변환할 필요가 없다고 느낄 수 있다.

 

틀린말은 아니지만 리틀 엔디안, 빅 엔디안에 상관없이 동일하게 동작하는 코드를 작성할 필요가 있다.

따라서 빅 엔디안 시스템에서도 호스트 바이트 순서를 네트워크 바이트 순서로 변환하는 과정을 거치는 것이 좋다. 물론 이 경우에는 호스트 바이트 순서와 네트워크 바이트 순서가 동일하기 때문에 아무런 변환도 일어나지 않을 것이다.

그럼 간단한 예제를 통해 위에서 설명한 함수를 사용해보자.

// endian_cov.c
#include <stdio.h>
#include <arpa/inet.h>

int main(int argc, char *arvg[])
{
    unsigned short host_port = 0x1234;
    unsigned short net_port;
    unsigned long host_addr = 0x12345678;
    unsigned long net_addr;

    net_port = htons(host_port);
    net_addr = htonl(host_addr);

    printf("Host ordered port : %#x \n", host_port);
    printf("Network ordered port : %#x \n", net_port);
    printf("Host ordered address : %#lx \n", host_addr);
    printf("Network ordered address : %#lx \n", net_addr);
    return 0;
}

// 호스트의 값을 네트워크 방식으로 바꿔줘야함.
// 네트워크방식은 빅엔디안 방식임.
// printf로 뽑아보니 데이터가 바뀌어있다! 를 알면됨

리틀 엔디안 기준으로 정렬하는 CPU에서의 실행결과이다. 만약 빅 엔디안 기준으로 실행했다면 변환 이후에도 달라지지 않을 것이다. 참고로 인텔이나 AMD 계열의 CPU는 모두 리틀 엔디안을 기준으로 정렬하기때문에 이런 결과가 출력된다.

 

 

인터넷 초기 주소 할당

문자열로 표현된 IP주소를 32비트 정수형으로 변환해주는 함수가 있다.

뿐만아니라, 이 함수는 변환과정에서 네트워크 바이트 순서로의 변환도 동시에 진행한다.

#include <arpa/inet.h>
int_addr_t inet_addr(const char * string);
// 성공 시 빅엔디안으로 변환된 32비트 정수 값, 실패 시 INADDR_NONE 반환

위 함수의 인자로 "211.214.107.99" 와 같이 점이 찍신 10진수로 표현된 문자열을 전달하면, 해당 문자열 정보를 참조해서 IP주소 정보를 32비트 정수형으로 반환한다. 이때 반환되는 정수는 네트워크 바이트 순서로 정렬되어있다.

그리고 위 함수선언의 반환형인 in_addr_t는 현재 32비트 정수형으로 정의되어있다.

// inet_addr.c
#include <stdio.h>
#include <arpa/inet.h>

int main(int argc, char *argv[])
{
   char *addr1 = "1.2.3.4";
   char *addr2 = "1.2.3.256"; // 255까지인데 256으로 바이트 범위를 넘어서 에러뜰것임

   unsigned long conv_addr = inet_addr(addr1);
   if(conv_addr == INADDR_NONE)
      printf("Error ouccured! \n");
   else
      printf("Network ordered integer addr : %#lx \n", conv_addr);


   conv_addr = inet_addr(addr2);
   if(conv_addr == INADDR_NONE)
      printf("Error ouccured! \n");
   else
      printf("Network ordered integer addr : %#lx \n", conv_addr);

   return 0;
}

1.2.3.4 는 정상적으로 출력,       1.2.3.256에서 256은 1바이트 범위인 255를 넘어서 에러가 뜬다.

 

1.2.3.4 에서 4는 하위바이트인데 출력은 0x04030201이 출력된다.(04의 0은 생략됬다)

엔디안으로인해 값이 변화되서(바이트 순서가 바껴서) 출력되는 것을 알 수 있다.

 

위 실행결과에서 봤듯이 inet_addr 함수는 32비트 정수형태로 IP 주소를 변환할 뿐만 아니라, 유효하지 못한 IP주소에 대한 오류 검출 능력도 갖고있다. 또, 출력결과를 통해 네트워크 바이트 순서로 정렬되는 것도 확인할 수 있다.

 

이어서 사용할 inet_aton 함수도 기능상으로는 inet_addr 함수와 동일하다. 즉, 문자열 형태의 IP 주소를 32비트 정수로 네트워크 바이트 순서로 정렬해서 반환한다. 다만 구조체 변수 in_addr을 이용하는 형태라는 점에서 차이가 있다.

참고로 활용도는 inet_aton 함수가 더 높다고 한다.

 

#include <arpa/inet.h>
int inet_aton(const char * string, struct in_addr * addr); // 성공 시 1(true), 실패 시 0(false) 반환
// string : 변환할 IP주소 정보를 담고있는 문자열의 주소 값 전달
// addr 변환된 정보를 저장할 in_addr 구조체 변수의 주소 값 전달

코드 작성과정에서 inet_addr 함수를 사용할 경우, 변환된 IP 주소 정보를 구조체 sockaddr_in 에 선언되어있는 in_addr 구조체 변수에 대입하는 과정을 추가로 거쳐야한다.
그러나 위 함수를 사용할 경우 별도의 대입과정을 거칠 필요가 없다. 인자로 in_addr 구조체 변수의 주소 값을 전달하면 변환된 값이 자동으로 in_addr 구조체 변수에 저장된다.

// inet_aton.c
#include <stdio.h>
#include <stdlib.h>
#include <arpa/inet.h>
void error_handling(char *message);

int main(int argc, char *argv[])
{
   char *addr = "127.232.124.79";
   struct sockaddr_in addr_inet;

   if(!inet_aton(addr, &addr_inet.sin_addr))
      error_handling("Conversion error");
   else
      printf("Network ordered integer addr : %#x \n",
         addr_inet.sin_addr.s_addr);
   return 0;
}

void error_handling(char *message)
{
   fputs(message, stderr);
   fputc('\n', stderr);
   exit(1);
}

 

다음은 inet_aton 함수의 반대기능을 제공하는 함수 하나를 알아보자.

이 함수는 네트워크 바이트 순서로 정렬된 정수형 IP주소 정보를 우리가 눈으로 쉽게 인식할 수 있는 문자열의 형태로 변환해준다.

#include <arpa/inet.h>
char * inet_ntoa(struct in_addr adr);  // 네트워크->주소 (정수->문자)
// 성공 시 변환된 문자열의 주소 값, 실패 시 -1 반환

위 함수는 인자로 전달된 정수 형태의 IP 정보를 참조하여 문자열 형태의 IP 정보로 변환해서 변환된 문자열의 주소 값을 반환한다.

 

inet_ntoa 함수는 반환형이 char형 포인터라서 문자열의 주소 값이 반환된다. 이는 문자열이 이미 메모리공간에 저장되었다는 뜻이다. 대신 함수 내부적으로 메모리공간을 할당해서 변환된 문자열 정보를 저장한다.

따라서 이 함수 호출 후에는 가급적 반환된 문자열 정보를 다른 메모리 공간에 복사해두는 것이 좋다.

또 inet_ntoa 함수가 호출되면 전에 저장된 문자열 정보가 지워질 수 있기 때문이다.

 

정리하자면 inet_ntoa 함수가 재호출되기 전까지만 변환된 문자열의 주소 값이 유효하다.

따라서 오랫동안 문자열 정보를 유지해야한다면 별도의 메모리 공간에 복사를 해두는것이 좋다.

// inet_ntoa.c
#include <stdio.h>
#include <string.h>
#include <arpa/inet.h>

int main(int argc, char *argv[])
{
   struct sockaddr_in addr1, addr2;
   char *str_ptr;
   char str_arr[20];

   addr1.sin_addr.s_addr = htonl(0x1020304);  // htonl : 호스트 주소를 네트워크 타입으로 가져옴
   addr2.sin_addr.s_addr = htonl(0x1010101);

   str_ptr = inet_ntoa(addr1.sin_addr);   // ntoa 함수 호출
   strcpy(str_arr, str_ptr);              // 함수를 또 호출할꺼라서 다른 메모리에 기존의 문자열 복사
   printf("Dotted-Decimal notation1 :%s \n", str_ptr);

   inet_ntoa(addr2.sin_addr);             // ntoa 함수 호출
   printf("Dotted-Decimal notation2 :%s \n", str_ptr);
   printf("Dotted-Decimal notation3 :%s \n", str_ptr);
   return 0;
}

 

 

인터넷 주소의 초기화

다음은 소켓생성과정에서 흔히 등장하는 인터넷 주소 정보의 초기화 방법이다.

struct sockaddr_int addr;         
char* serv_ip = "211.217.168.13"; // IP주소 문자열 선언
char* serv_port = "9190";         // Port 번호 문자열 선언
memset(&addr, 0, sizeof(addr));   // 구조체 변수 addr의 모든 멤버를 0으로 초기화
addr.sin_family = AF_INET;        // 주소 체계 지정
addr.sin_addr.s_addr = inet_addr(serv_ip);  // 문자열 기반의 IP 주소 초기화
addr.sin_port = htons(atoi(serv_port));     // 문자열 기반의 Port 번호 초기화

위 코드에서 memset 함수는 동일한 값으로 바이트 단위 초기화를 할때 호출하는 함수이다.

첫번째 인자로 구조체 변수 addr의 주소 값이 전달되었으니 초기화의 대상은 변수 addr이 된다.

두번째 인자로 0이 전달되었으니 0으로 초기화가 이루어진다.

세번째 인자로 addr의 바이트 크기가 전달되었으니 addr 전체가 0으로 초기화된다.

 

이렇듯 이 함수를 호출해서 addr을 전부 0으로 초기화하는 이유는 0으로 초기화해야하는 sockaddr_in 구조체 멤버 sin_zero를 0으로 초기화하기 위함이다.

 

마지막 문장에서 사용한 atoi 함수는 문자열로 표현되어 있는 값을 정수로 변환해서 반환한다.

결론적으로 위으코드에서는 문자열로 표현된 IP주소와 PORT 번호를 기반으로하는 sockaddr_in 구조체 변수의 초기화 과정을 보인 것이다.

 

참고로 위의 코드에서는 IP와 PORT 번호를 코드에 정적으로 직접 넣어줬는데 이는 다른 컴퓨터에서 실행할때마다 코드를 변경해야하기 때문에 좋은 방법은 아니다. 그래서 프로그램 실행 시 main 함수에 IP와 PORT 번호를 전달하도록 예제를 작성하고 있다.

 

 

클라이언트의 주소 정보 초기화

앞서 했던 인터넷 주소정보와 초기화 과정은 클라이언트 프로그램이 아닌 서버 프로그램에서 주로 등장한다.

이는 소켓에 IP와 PORT 번호를 할당해서 다음과 같이 외치기 위함이다.

 

"IP ~~~, PORT ~~~으로 들어오는 데이터를 전부 나에게 보내라."

 

반면 클라이언트 프로그램에서 생성하는 연결요청용 소켓은 다음과 같이 외친다.

 

"IP ~~~, PORT ~~~ 으로 연결을 해라."

 

외치는 형태가 다르다는 것은 호출하는 함수가 다름을 의미한다.

서버 프로그램의 외침은 bind 함수를 통해 이뤄지고, 클라이언트 프로그램의 외침은 connect 함수를 통해 이뤄진다.

때문에 외치기 전에(함수 호출 전에) 준비해야할 주소 값의 유형도 다를 것이다.

서버 프로그램에서는 sockaddr_in 구조체 변수를 하나 선언해서 이를 서버 소켓이 동작하는 컴퓨터의 IP와 소켓에 부여할 PORT 번호로 초기화한 다음에 bind 함수를 호출한다.

 

반면에 클라이언트 프로그램에서는 sockaddr_in 구조체 변수를 하나 선언해서 이를 연결할 서버 소켓의 IP와 PORT번호로 초기화한 다음에 connect 함수를 호출한다.

 

 

INADDR_ANY

서버 소켓의 생성과정에서 매번 서버의 IP주소를 입력하는게 귀찮을 수 있다. 그렇다면 다음과 같이 주소 정보를 초기화할 수도 있다.

struct sockaddr_int addr;
char* serv_port = "9190";       
memset(&addr, 0, sizeof(addr));  
addr.sin_family = AF_INET;        
addr.sin_addr.s_addr = htol(serv_ip); 
addr.sin_port = htons(atoi(serv_port));   

위에서 설명했던 인터넷 주소의 초기화 방식과의 차이점은 INADDR_ANY라는 이름의 상수를 통해 서버의 IP 주소를 할당하고 있는 점이다. 소켓의 IP 주소를 이렇게 초기화할 경우 소켓이 동작하는 컴퓨터의 IP주소로 할당되기 때문에 IP 주소를 직접 입력하는 수고를 덜 수 있다. 

뿐만아니라 컴퓨터 내에 2개 이상의 IP를 할당받아서 사용하는 경우, 할당 받은 IP중 어떤 주소를 통해서 데이터가 들어오더라도 PORT 번호만 일치하면 수신할 수 있게 된다. 

따라서 서버 프로그램의 구현에 많이 선호되는 방법이다. 반대로 클라이언트가 서버의 기능을 일부 포함하는 경우가 아니라면, 클라이언트 프로그램의 구현에서는 사용될일이 별로 없다.

 

 

hello_server.c, hello_client.c 실행에 대한 고찰

세번째 포스팅에 서버에 해당하는 hello_server.c 실행을위해 다음의 명령문을 실행하였다.

./hserver 9190

main 함수에 전달된 9190은 PORT 번호이다. 서버 소켓의 생성에 필요한 PORT 번호를 전달하면서 프로그램을 실행하는 것이다. 반대로 소켓의 IP 주소는 전달하지 않았는데, 그 이유는 INADDR_ANY를 통한 IP 주소의 초기화에서 찾을 수 있다. 

 

다음은 클라이언트에 해당하는 hello_client.c의 실행을 위한 명령문이다. 서버 프로그램의 실행 방식과 비교해서 가장 두드러진 차이점은 IP 주소 정보를 전달하고 있다는 점이다.

./hclient 127.0.0.1 9190

이 예제에서는 서버와 클라이언트가 하나의 컴퓨터에서 실행했기 때문에 클라이언트가 연결할 주소로 127.0.0.1 (로컬호스트)를 전달했다.

물론 이를 대신해서 실제 컴퓨터의 IP 주소를 입력해도 프로그램은 동작한다. 뿐만아니라, 서버와 클라이언트를 서로 다른 두 대의 컴퓨터에서 각각 실행할 경우에는 이를 대신해서 서버의 IP주소를 입력하면된다.

 

 

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

구조체 sockaddr_in의 변수 초기화 방법에 대해서 살펴봤으니 이제는 초기화된 주소 정보를 소켓에 할당하는 일만 남았다. 앞서 소개했던 bind 함수가 이런 역할을 담당한다.

#include <stdio.h>
int bind(int sockfd, struct sockaddr *myaddr, socklen_t addrlen); // 성공 시 0, 실패시 -1 반환
// sockfd : 주소정보를(IP와 PORT를) 할당할 소켓의 파일 디스크립터
// myaddr : 할당하고자 하는 주소정보를 지니는 구조체 변수의 주소 값
// addrlen : 두번째 인자로 전달된 구조체 변수의 길이 정보

이 함수 호출이 성공하면 첫번째 인자에 해당하는 소켓에 두번째 인자로 전달된 주소 정보가 할당된다.

그럼 지금까지 설명한 내용을 바탕으로 서버 프로그램에서 흔히 사용하는 서버 소켓 초기화과정을 대략적으로 정리해보면 다음과 같다.

int serv_sock;
struct sockaddr_int serv_addr;
char* serv_port = "9190";

/*서버 소켓(리스닝 소켓) 생성*/
serv_sock = socket(PF_INET, SOCK_STREAM, 0);

/* 주소 정보 초기화*/
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htol(INADDR_ANY);
serv_addr.sin_port = htons(atoi(serv_port));

/*주소 정보 할당*/
bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr));

클라이언트 프로그램이 아닌, 서버 프로그램이라면 위의 코드 구성을 기본적으로 갖추게 된다.

물론 위에서 보이지 않은 오류처리에 대한 코드는 추가로 포함이 된다.

반응형