Programming

Beej's Guide to Network Programming 요약, Part 1

동건 2019. 7. 28. 15:33



최근에 (1년 전에?) 제일 정리하고 싶었던 글인데, 실제로 network programming이 절실할 때가 와서야 하게 되었다 (흑흑). 원문은 Network programming에 대해 모르는 개발자에게 딱 좋은 튜토리얼이라고 생각한다. 여러 미사여구와 필자의 썰은 가지쳐내고 핵심적인 내용만 정리해본다 (Part 2 대기 중).




2. What is a Socket?

  • Socket은 standard Unix file descriptor를 통해서 다른 프로그램과 소통하는 통로이다. 이 file descriptor는 socket() system routine을 호출해서 얻는다. 그리고 send()recv() socket call을 이용해 통신할 수 있다. 다른 file descriptor처럼 read()write()를 통해서도 가능하지만, send()recv()가 데이터 송수신에 대해 더 많은 제어권을 제공해준다.
  • Unix file descriptor, "Everything is a file!"에 대한 자세한 이야기는 원문을 참조 바란다.
  • 여러 종류의 socket이 있지만, 여기서는 Internet Socket (DARPA Internet address)에 대해서만 다룬다.

2.1. Two Types of Internet Sockets

  • Stream Sockets SOCK_STREAM, Datagram Sockets SOCK_DGRAM (connectionless sockets)

  • Stream sockets

    • 양방향으로 연결된 통신을 보장(reliable)하며, 데이터를 보낸 순서를 보존한다. 그리고 절대 오류가 일어나지 않는다.
    • 왜? TCP (Transmission Control Protocol, RFC 793 참조)를 사용하기 때문에.
    • 사용 예: telnet application, HTTP protocol
    • TCP/IP (RFC 791 참조)에서의 IP (Internet Protocol)는 Internet routing을 담당하며, 데이터 정합성에 대해서는 크게 책임이 없다.
  • Datagram sockets

    • Datagram을 보낸다면, 도착할 수도 안 할 수도 있다 (unreliable). 순서대로 오지 않을 수도 있다. 만약 도착했다면, 그 packet이 담고 있는 데이터에는 오류가 없다.
    • UDP (User Datagram Protocol, RFC 768 참조)를 사용하고, routing을 위해 IP도 사용한다.
    • Connectionless인 이유? (Stream socket과 다르게) 연결을 유지할 필요가 없기 때문. Packet을 만들어서 IP header에 목적지를 적고 보내버릴 뿐이다.
    • TCP를 사용할 수 없는 환경일 때, 혹은 몇 packet이 도착하지 못한다고 해서 세상이 망하지 않을 때 사용한다.
    • 사용 예: tftp (trivial file transfer protocol), dhcpcd (DHCP client), multiplayer game, streaming audi, video conferencing 등등.
      • tftp는 UDP를 기반으로 독자적인 protocol을 사용해서 packet이 도착했음을 송신자에게 알려준다 ("ACK" packet). ACK packet이 도착할 때까지 재시도를 하므로 데이터의 온전한 통신을 보장한다.
    • Unreliable의 단점이 있는 만큼, 빠르다는 장점이 있다.
  • 채팅 메세지를 보낸다면? 입력 순서를 지키는 TCP가 좋겠다, 전세계 플레이어들의 위치 정보를 1초에 40개 씩 업데이트할거라면? (그리고 그 중 몇 개는 업데이트를 실패해도 괜찮다면?) UDP가 훨씬 좋겠지?

  • TCP, UDP 외에 "Raw Socket"이라는 것도 있다고 한다.

2.2. Low Level Nonsense and Network Theory

  • 아래 그림은 SOCK_DGRAM이 어떻게 만들어지는지 표현한 그림이다.

Data EncapsulationData Encapsulation

  • Data Encapsulation

    • 태초에 한 packet이 있었고, 여기에 첫 번째 protocol에 의해 (위 그림에서는 TFTP protocol) header가 붙어서 (매우 드물게 footer도 붙는다) 포장이 된다 ("encapsulated").
    • 그렇게 포장된 packet은 다음 protocol (그림에서는 UDP protocol)에 의해 encapsulated 되고, 그 다음 protocol (IP), 그리고 마지막 protocol으로는 하드웨어 (물리적) layer (여기서는 Ethernet)에 의해서 encapsulated 된다.
    • 다른 컴퓨터가 이 packet를 받았다면, 우선 하드웨어가 Ethernet header를 풀고, kernel이 IP와 UDP header를 풀고, TFTP 프로그램이 TFTP header를 풀어서 드디어 데이터를 볼 수 있게 된다.
  • Layered Network Model (a.k.a. "ISO/OSI")

    • 본인이 집중하고자 하는 layer 이외에는 신경쓰지 않아도 된다는 이점이 있다. e.g., socket 프로그래머는 어떻게 데이터가 물리적으로 전송되는지 (serial, thin Ethernet, AUI, 뭐든지간에) 몰라도 socket 프로그램을 작성할 수 있다는 것이다.
    • Application / Presentation / Session / Transport / Network / Data Link / Physical
    • Unix 환경에는 이렇게 4계층으로 나누는 것이 좀 더 적절하다.
      • Application Layer (telnet, ftp, 등)
      • Host-to-Host Layer (TCP, UDP)
      • Internet Layer (IP 및 routing)
      • Network Access Layer (Ethernet, wi-fi, whatever)
    • 이런 layer들이 encapsulation과 연결이 됨을 알 수 있다.
  • IP와 routing에 대해서는 몰라도 된다. 정 알고 싶다면 IP RFC를 참조.




3. IP Adresses, structs, Data Munging

3.1. IP Addresses, Versions 4 and 6

  • 기존 network routing system인 IPv4 (Internet Protocol Version 4)는 4 bytes (a.k.a. 4 "octets")으로 이루어진 주소를 사용했다. 숫자와 점으로 이루어진 형식으로 흔히 쓰인다, 192.0.2.111처럼.
  • Vint Cerf (Internet의 아버지)가 "IP 주소가 다 떨어지면 어떡할래?" 라고 의문을 제기해서 IPv6가 만들어짐.
  • IPv6는 128 bit의 주소를 사용하기 때문에 천문학적인 개수의 주소를 제공한다 (2의 128 제곱).
  • 2 byte 묶음을 16진법으로, colon으로 연결해서 표기한다, 2001:0db8:c9d2:aee5:73e3:934a:a5ae:9551처럼.
  • 숫자 0은 생략될 수 있어서, 다음 주소 쌍들은 같은 주소가 된다.
    • 2001:0db8:c9d2:0012:0000:0000:0000:0051 == 2001:db8:c9d2:12::51
    • 2001:0db8:ab00:0000:0000:0000:0000:0000 == 2001:db8:ab00::
    • 0000:0000:0000:0000:0000:0000:0000:0001 == ::1
    • ::1 주소는 _loopback address_로, "지금 작동하고 있는 이 컴퓨터"를 뜻한다. IPv4로는 127.0.0.1이다.
  • IPv6는 IPv4에 대해서 호환성이 있다. 192.0.2.33::ffff:192.0.2.33으로 표기할 수 있다.

3.1.1. Subnets

  • IP 주소의 첫 몇 bit는 network 부분이고 그 이후의 bit는 host 부분이라고 분리하는 것이 구조적인/조직적인 관리 면에서 (for organizational reasons) 편리할 때가 있다.
  • 192.0.2.12에서 첫 세 byte는 network이고 마지막 byte는 host라고 할 수 있다. Host 부분을 0으로 지운 192.0.2.0 network에서의 12번째 host라고 말할 수도 있다.
  • 고대에는 subnet의 "class"라는 것이 있었다. "Class A" network는 첫 byte가 network / 나머지 세 byte가 host 부분이고, "Class C" network는 첫 세 byte가 network / 나머지 한 byte가 host 부분이다. "Class B" network는 그 중간에 있다. 따라서 Class C network는 256개의 host를 가질 수 있고, Class A network는 2의 24 제곱 개의 host를 가질 수 있다.
  • IP 주소의 network 부분은 _netmask_라는 것으로 표기할 수 있는데, IP 주소와 netmask에 bitwise-AND를 적용시켜서 나오는 주소가 network 부분이 된다. IP 주소가 192.0.2.12이고 netmask가 255.255.255.0이라면, network 부분은 192.0.2.0이 된다.
  • 현재는 8, 16, 24 bit의 class 구분보다는 더 세밀하게 한 bit 단위로 network / host 부분을 나눌 수 있다. Netmask가 255.255.255.252라면, 30 bit가 network 부분이고 나머지 2 bit가 host 부분이 된다. Netmask는 항상 1의 연속 이후의 0의 연속이 된다.
  • 직관적으로 알아보기 어려운 netmask 대신 새로운 표기법으로 IP 주소 뒤에 network bit 개수를 192.0.2.12/30처럼 쓸 수도 있다. IPv6의 경우 2001:db8::/32 or 2001:db8:5413:4028::9db9/64와 같은 형식이 된다.

3.1.2. Port Numbers

  • Layered Network Model에서 Internet Layer (IP)와 Host-to-Host Transport Layer (TCP와 UDP)가 분리되어 있었던 것을 기억하자.
  • IP 주소에 더해서, TCP와 UDP가 사용하는 또 다른 주소가 있는데, 이것이 port number다.
  • Port number는 16 bit의 숫자로, 한 연결의 local address 같은 것이다.
  • IP 주소가 호텔의 복도 주소라면, port number는 해당 복도의 방 번호이다.
  • 하나의 컴퓨터에서 메일도 받고, 그리고 웹 서비스도 제공하고 싶은데, 하나의 IP 주소를 가진 컴퓨터가 어떻게 이 두 가지를 구분할 수 있을까?
  • Internet의 여러 서비스는 익히 알려진 port number를 사용하는데, the Big IANA Port List에서 이를 죄다 확인할 수도 있고, Unix 계열에서는 etc/services 파일에서 볼 수도 있다.
    • HTTP는 port 80, telnet은 port 23, SMTP는 port 25, 게임 DOOM)은 port 666을 사용한다.
    • 1024 이하의 port number는 특별한 것으로 종종 인식하며, 이를 사용하려면 대개 특별한 OS 권한을 필요로 한다.


3.2. Byte Order

  • 인터넷 세상에서 2-byte hex 숫자 b34f를 저장할 때,
  • Big-Endian은 순서대로 저장한다, 즉 b3을 먼저 저장하고 4f를 저장한다. 이는 Wilford Brimley가 말하길 제대로 된 방법이다.
  • Little-Endian은 반대로 저장한다. 즉 4f를 먼저, 그 다음에 b3을 저장한다. 인텔 계열의 프로세서가 주로 이렇게 한다.
  • Network Byte Order는 네트워크가 byte를 저장하는 순서이고, Host Byte Order는 우리가 쓰는 컴퓨터가 사용하는 순서이다.
  • Network Byte Order는 Big-Endian이다.
  • Host Byte Order는 기계에 따라 다르다. Intel 80x86이라면 Little-Endian, Motorola 68k라면 Big-Endian, PowerPC라면... 그 때 그 때 다르다!
  • 내 컴퓨터의 Host Byte Order가 뭔지 모르는데 어떻게 Network Byte Order에 맞는 packet을 만들 수 있을까? 이를 담당하는 함수들을 사용해서, endianness가 다른 문제를 해결할 수 있다.
  • 이 함수들이 변환하는 숫자로는 short (2 bytes)과 long (4 bytes) 이렇게 두 가지 형태만 가능하다. 그리고 unsigned 숫자이어야 한다.
  • 함수는 h (host), n (network), to, s (short), l (long)의 조합으로 이름지어져 있다. 예를 들어볼까
    • htons(): Host to Network Short
    • htonl(): Host to Network Long
    • ntohs(): Network to Host Short
    • ntohl(): Network to Host Long
  • 앞으로 이 문서에서 말하는 숫자들은 Host Byte Order를 따른다고 가정한다.

3.3. Structs

  • Socket descriptor는 int 타입이다.

  • addrinfo structure는 socket address structure를 자주 사용할 수 있도록 만들어진 것이다. Host name이나 service name을 찾거나 다른 여러 용도에 쓰이지만, 일단은 네트워크 연결을 위해서 가장 처음으로 만들어야 할 것이라고만 알아두자.

struct addrinfo {
    int              ai_flags;     // AI_PASSIVE, AI_CANONNAME, etc.
    int              ai_family;    // AF_INET, AF_INET6, AF_UNSPEC
    int              ai_socktype;  // SOCK_STREAM, SOCK_DGRAM
    int              ai_protocol;  // use 0 for "any"
    size_t           ai_addrlen;   // size of ai_addr in bytes
    struct sockaddr *ai_addr;      // struct sockaddr_in or _in6
    char            *ai_canonname; // full canonical hostname

    struct addrinfo *ai_next;      // linked list, next node
};
  • 이 struct를 가져와서 getaddrinfo()를 실행하면, 우리가 필요한 모든 정보들이 담은 structure들의 linked list를 만들어서 그 pointer를 반환한다.
  • ai_family를 통해 IPv4 / IPv6를 사용할지, 아니면 AF_UNSPEC으로 제한을 두지 않을지 설정할 수 있다. 이는 코드가 IP 버전에 따라 다르게 행동을 하도록 할 수 있다는 좋은 점이다.
  • 우리가 받은 것이 linked list이므로 여러 structure가 들어올 수 있음을 항상 생각하자. 어떤 주소를 사용하는지는 당신이 판단할 몫이다.
  • 대부분의 경우 getaddrinfo()를 호출해서 이미 내용물이 채워진 struct addrinfo를 받기 때문에 이 structure를 손수 만들 필요는 없다.

  • struct sockaddr은 여러 타입의 socket address 정보를 담고 있다.

struct sockaddr {
    unsigned short    sa_family;    // address family, AF_xxx
    char              sa_data[14];  // 14 bytes of protocol address
};
  • sa_family는 여러 값이 올 수 있지만, 여기서는 AF_INET (IPv4)와 AF_INET6 (IPv6)만 사용할 것이다.
  • sa_data는 socket의 목적지 주소와 포트를 담는 곳이다. 이를 손수 만드는 것은 매우 더러운 일이다.
  • struct sockaddr를 다루기 위해서는 이에 병행하는 struct sockaddr_in ("Internet"의 "in")을 만들어야 한다.
  • 여기서 매우 중요한 점은, struct sockaddr_in의 pointer는 struct sockaddr의 pointer로 바꿔서(type casting) 쓸 수 있고, 그 반대도 마찬가지라는 점이다. 그래서 connect()struct sockaddr*을 필요로 하더라도, 우리는 계속 struct sockaddr_in을 쓰다가 마지막에 cast해서 넘겨주면 된다!
// (IPv4 only--see struct sockaddr_in6 for IPv6)
struct sockaddr_in {
    short int          sin_family;  // Address family, AF_INET
    unsigned short int sin_port;    // Port number
    struct in_addr     sin_addr;    // Internet address
    unsigned char      sin_zero[8]; // Same size as struct sockaddr
};
  • 이 structure는 socket address의 원소들을 가리키는 것을 쉽게 해준다.
  • sin_zerostruct sockaddr과 그 길이를 똑같이 맞춰주기 위한 필드로, 반드시 memset()을 통해 모두 0으로 값을 가지도록 해야 한다.
  • sin_familystruct sockaddrsa_family와 일치해야 하므로, 여기서는 AF_INET이어야 한다.
  • sin_port는 반드시 Network Byte Order를 따라야 한다. htons()를 쓰면 된다!
  • sin_addrstruct in_addr이라고 나와있는데, 이것은 역대 최고로 무시무시한 union 중 하나였지만, 다행히도 바뀌었다.
// (IPv4 only--see struct in6_addr for IPv6)

// Internet address (a structure for historical reasons)
struct in_addr {
    uint32_t s_addr; // that's a 32-bit int (4 bytes)
};
  • inastruct sockaddr_in타입으로 선언했다면, ina.sin_addr.s_addr은 4-byte의 IP 주소를 (Network Byte Order에 따라서) 나타낸다.
  • 위 structure들의 IPv6 버전은 원문을 참조하길 바란다.

  • struct sockaddr_storage는 IPv4와 IPv6 구조를 모두 담을 수 있도록 크게 만들어진 것이다.

struct sockaddr_storage {
    sa_family_t  ss_family;     // address family

    // all this is padding, implementation specific, ignore it:
    char      __ss_pad1[_SS_PAD1SIZE];
    int64_t   __ss_align;
    char      __ss_pad2[_SS_PAD2SIZE];
};
  • IPv4일지 IPv6일지 모르는 상황에서 우선 이 parallel structure를 사용하면 된다. ss_familyAF_INET인지 AF_INET6인지 확인했다면, 이에 맞게 struct sockaddr_in이나 struct sockaddr_in6로 cast하면 되는 것이다.

3.4. IP Address, Part 2

  • IP 주소를 다루는 함수들이 여럿 있어서 손수 조작할 필요는 없다.
  • struct sockaddr_in ina가 있고, 여기에 넣고 싶은 IP 주소가 10.12.110.57 또는 2001:db8:63b3:1::3490이라고 하자.
  • inet_pton()은 위 주소를 struct in_addr이나 struct in6_addr에 맞게 넣어준다. pton은 "presentation (또는 printable) to network"을 의미한다. 아래 코드 예를 보면 더 쉽게 이해할 수 있다.
struct sockaddr_in sa; // IPv4
struct sockaddr_in6 sa6; // IPv6

inet_pton(AF_INET, "10.12.110.57", &(sa.sin_addr)); // IPv4
inet_pton(AF_INET6, "2001:db8:63b3:1::3490", &(sa6.sin6_addr)); // IPv6
  • 위 예제는 오류를 확인하지 않으므로 좋은 코드는 아니다. inet_pton() 함수는 오류일 때 -1을, 주소값이 이상할 때는 0을 반환하므로, 이를 항상 먼저 확인하도록 하자!

  • 그 반대의 경우는 inet_ntop() ("network to presentation/printable")를 쓰면 된다.

// IPv4:
char ip4[INET_ADDRSTRLEN];  // space to hold the IPv4 string
struct sockaddr_in sa;      // pretend this is loaded with something

inet_ntop(AF_INET, &(sa.sin_addr), ip4, INET_ADDRSTRLEN);

printf("The IPv4 address is: %s\n", ip4);

// IPv6:
char ip6[INET6_ADDRSTRLEN]; // space to hold the IPv6 string
struct sockaddr_in6 sa6;    // pretend this is loaded with something

inet_ntop(AF_INET6, &(sa6.sin6_addr), ip6, INET6_ADDRSTRLEN);

printf("The address is: %s\n", ip6);
  • INET_ADDRSTRLENINET6_ADDRSTRLEN은 macro로 지정되어 있는 상수이므로 이를 사용하는 것이 매우 편리하다.
  • 지금까지 봤던 함수들은 오직 숫자형 IP 주소에 대해서만 제대로 작동한다. "www.example.com"과 같은 주소를 DNS에서 찾아서 hostname을 가져오지는 않는다는 것이다. 이를 위해서는 getaddrinfo()를 사용하게 될 것이다.

3.4.1. Private (또는 Disconnected) Networks

  • 바깥 세상으로부터 보호하기 위해 네트워크를 숨기는 방화벽을 사용하는 곳이 많이 있다.
  • 그리고 방화벽은 Network Address Translation (NAT)라는 과정을 통해 "내부 (internal)" IP 주소를 "외부 (external)" IP 주소로 바꿔주기도 한다.
  • 질문: 우리 집에 방화벽이 있다. 두 개의 정적 IPv4 주소를 DSL 회사로부터 할당받아서 쓰고 있는데, 네트워크 안에는 7개 컴퓨터가 돌고 있다. 이게 어떻게 가능할까? 같은 IP 주소를 여러 컴퓨터가 동시에 가질 수 없지 않나? 만약 동시에 가지게 된다면, 데이터가 어느 컴퓨터로 갈지 알 수가 없다!
  • 답: 하나의 IP 주소를 동시에 여러 컴퓨터가 가질 수 없는 것이 맞다. 이 컴퓨터들은 이천 사백만 IP 주소를 할당할 수 있는 private network 위에 있었다. 이 네트워크는 오로지 나만을 위한 것이다.
  • 외부 컴퓨터에 접속을 해보면, 나는 192.0.2.33에서 접속하는 것으로 나타나는데, 이 IP 주소는 나의 ISP가 제공한 public IP 주소이다. 하지만 실제로 접속을 시도한 내 컴퓨터의 IP는 10.0.0.5이다. 방화벽이 NAT를 해서 IP 주소를 변환해준 것이다!
  • 10.x.x.x는 완전히 단절된(fully disconnected) 네트워크나 방화벽 뒤의 네트워크에 사용할 수 있도록 지정된(reserved) 네트워크 중 하나이다. RFC 1918에 보면 이러한 네트워크 목록을 모두 볼 수 있다. 10.x.x.x, 192.168.x.x, 172.16-31.x.x 등이 있다.
  • NAT를 하는 방화벽 뒤의 네트워크는 위에 나열한 지정된 네트워크일 필요는 없지만, 보통은 그 지정된 네트워크를 사용한다.
  • IPv6에서도 역시 private network가 있다. RFC 4193에 따르면 그 주소는 fdxx:로 (혹은 미래에 fcxx:일 수도) 시작한다.
  • 보통 NAT와 IPv6는 같이 쓰이지 않는다.



4. Jumping from IPv4 to IPv6

  • 원문을 참조 바란다.



5. System Calls or Bust

  • 시스템의 네트워크 기능을 사용할 수 있도록 하는 system call (+ library call)을 어떤 순서로 써야하는지 정리한다.

5.1. getaddrinfo() - 발사 준비!

  • 많은 옵션을 가진 레알 참 일꾼이지만, 사용법은 꽤 간단하다. 이 함수는 우리가 필요한 struct들을 세팅해준다.
  • 옛날 얘기를 잠깐 하자면, DNS lookup을 위해서 gethostbyname() 함수를 사용했었어야 했다. 그렇게 받은 정보를 struct sockaddr_in 안에 손수 넣어줘서 사용해야 했다. 다행히도 이제는 getaddrinfo()가 그 모든 것을 해준다. 그리고 이는 IPv4와 IPv6 모두에 문제 없는 코드를 위해서는 이렇게 되어야 한다!
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>

int getaddrinfo(const char *node,     // e.g. "www.example.com" or IP
                const char *service,  // e.g. "http" or port number
                const struct addrinfo *hints,
                struct addrinfo **res);
  • node는 host name이나 IP 주소이면 된다.
  • service는 "80"처럼 port number이거나, 특정 service 이름("ftp", "telnet" 등 The IANA Port List에 기록된 것들)이 들어간다.
  • hints는 관련된 정보가 이미 채워져 있는 struct addrinfo를 가리키는 pointer를 넣어줄 수 있다.
  • 그 결과로 res에 linked list의 pointer가 할당되게 된다.
  • 아래 예제는 서버가 당신의 host IP 주소 및 포트 3490을 기다리고자 할 때 쓸 수 있는 코드이다.
int status;
struct addrinfo hints;
struct addrinfo *servinfo;  // will point to the results

memset(&hints, 0, sizeof hints); // make sure the struct is empty
hints.ai_family = AF_UNSPEC;     // don't care IPv4 or IPv6
hints.ai_socktype = SOCK_STREAM; // TCP stream sockets
hints.ai_flags = AI_PASSIVE;     // fill in my IP for me

if ((status = getaddrinfo(NULL, "3490", &hints, &servinfo)) != 0) {
    fprintf(stderr, "getaddrinfo error: %s\n", gai_strerror(status));
    exit(1);
}

// servinfo now points to a linked list of 1 or more struct addrinfos

// ... do everything until you don't need servinfo anymore ....

freeaddrinfo(servinfo); // free the linked-list
  • 위 예제에서는 ai_familyAF_UNSPEC으로 두어서 IP 버전에 상관없음을 나타냈지만, 필요에 따라 AF_INET이나 AF_INET6를 사용해도 된다.
  • AI_PASSIVE flag가 보인다. 이는 getaddrinfo()로 하여금 나의 localhost의 IP를 알아서 socket structure에 할당하도록 한다. 다른 주소를 넣고 싶다면 getaddrinfo()의 첫 번째 argument를 NULL 대신 넣어주면 된다.
  • 함수를 호출해서 오류가 있다면 (getaddrinfo() 반환값이 0이 아니라면) gai_strerr()를 사용해서 오류 내용을 출력할 수 있다.
  • 오류 없이 함수 작업이 끝났다면 servinfostruct addrinfo의 linked list를 가리킬 것이다. 그리고 이 struct addrinfo는 우리가 나중에 사용하게 될 struct sockaddr를 담고 있다!
  • getaddrinfo()가 우리를 위해서 linked list가 필요한 메모리를 할당해 줬듯이, freeaddrinfo()를 통해 그 메모리를 해제시킬 수 있다.

  • 아래 예제는 클라이언트 입장에서 특정 서버의 포트 3490에 접속하고 싶을 때 사용할 수 있는 코드이다.

int status;
struct addrinfo hints;
struct addrinfo *servinfo;  // will point to the results

memset(&hints, 0, sizeof hints); // make sure the struct is empty
hints.ai_family = AF_UNSPEC;     // don't care IPv4 or IPv6
hints.ai_socktype = SOCK_STREAM; // TCP stream sockets

// get ready to connect
status = getaddrinfo("www.example.net", "3490", &hints, &servinfo);

// servinfo now points to a linked list of 1 or more struct addrinfos

// etc.
  • 아래 예제는 명령줄에서 당신이 입력하는 IP 주소나 host name을 받아서 그 정보를 출력하는 작은 프로그램의 코드이다.
/*
** showip.c -- show IP addresses for a host given on the command line
*/

#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
#include <arpa/inet.h>
#include <netinet/in.h>

int main(int argc, char *argv[])
{
    struct addrinfo hints, *res, *p;
    int status;
    char ipstr[INET6_ADDRSTRLEN];

    if (argc != 2) {
        fprintf(stderr,"usage: showip hostname\n");
        return 1;
    }

    memset(&hints, 0, sizeof hints);
    hints.ai_family = AF_UNSPEC; // AF_INET or AF_INET6 to force version
    hints.ai_socktype = SOCK_STREAM;

    if ((status = getaddrinfo(argv[1], NULL, &hints, &res)) != 0) {
        fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(status));
        return 2;
    }

    printf("IP addresses for %s:\n\n", argv[1]);

    for(p = res;p != NULL; p = p->ai_next) {
        void *addr;
        char *ipver;

        // get the pointer to the address itself,
        // different fields in IPv4 and IPv6:
        if (p->ai_family == AF_INET) { // IPv4
            struct sockaddr_in *ipv4 = (struct sockaddr_in *)p->ai_addr;
            addr = &(ipv4->sin_addr);
            ipver = "IPv4";
        } else { // IPv6
            struct sockaddr_in6 *ipv6 = (struct sockaddr_in6 *)p->ai_addr;
            addr = &(ipv6->sin6_addr);
            ipver = "IPv6";
        }

        // convert the IP to a string and print it:
        inet_ntop(p->ai_family, addr, ipstr, sizeof ipstr);
        printf("  %s: %s\n", ipver, ipstr);
    }

    freeaddrinfo(res); // free the linked list

    return 0;
}
  • 실제 사용 예는 아래처럼 나올 것이다.
$ showip www.example.net
IP addresses for www.example.net:

  IPv4: 192.0.2.88

$ showip ipv6.example.com
IP addresses for ipv6.example.com:

  IPv4: 192.0.2.101
  IPv6: 2001:db8:8c00:22::171


5.2. socket() - File Descriptor를 받아라!

#include <sys/types.h>
#include <sys/socket.h>

int socket(int domain, int type, int protocol);
  • Argument인 domain, type, protocol은 원하는 socket이 각각 IPv4/IPv6인지, stream/datagram인지, TCP/UDP인지를 의미한다.
    • domainPF_INET이나 PF_INET6
    • typeSOCK_STREAM이나 SOCK_DGRAM
    • protocol0을 주면 type에 따라 적절한 것으로 알아서 정한다. 아니면 getprotobyname()을 통해 "tcp"/"udp"에 맞는 값을 얻어서 넣을 수도 있다.
    • PF_INETAF_INET가 거의 같다는 옛날 이야기에 대해선 원문을 참조바란다.
  • 아래 예제처럼 getaddrinfo()로 얻은 값을 socket()에 집어넣어주면 된다.
int s;
struct addrinfo hints, *res;

// do the lookup
// [pretend we already filled out the "hints" struct]
getaddrinfo("www.example.com", "http", &hints, &res);

// [again, you should do error-checking on getaddrinfo(), and walk
// the "res" linked list looking for valid entries instead of just
// assuming the first one is good (like many of these examples do.)
// See the section on client/server for real examples.]

s = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
  • socket()은 다음 단계에 사용할 _socket descriptor_을 반환해줄 뿐이다. 오류일 경우 -1을 반환하고, 전역 변수 errno에 오류값이 기록된다 (errno man page를 참조바란다).

5.3. bind() - 어느 포트에서 기다려야 하죠?

  • 위에서 얻은 socket file descriptor를 당신의 local machine의 어떤 포트에 연결하고 싶을 때가 있다 (보통 listen()을 통해 특정 포트로 들어오는 연결을 받고자 할 때가 그렇다).
  • kernel은 들어오는 packet을 어느 process의 socket descriptor에 주어야 하는지를 이 포트 숫자를 통해 판단한다.
  • 반대로 당신이 서버가 아니고 클라이언트라서 connect()를 하는 상황이라면 bind()는 아마도 필요하지 않을 것이다.
#include <sys/types.h>
#include <sys/socket.h>

int bind(int sockfd, struct sockaddr *my_addr, int addrlen);
  • sockfdsocket()에서 받은 socket file descriptor이다.
  • my_addr은 당신의 IP주소와 포트 정보를 가지고 있는 struct sockaddr의 pointer이다.
  • addrlen은 그 주소의 byte 길이이다.
struct addrinfo hints, *res;
int sockfd;

// first, load up address structs with getaddrinfo():
memset(&hints, 0, sizeof hints);
hints.ai_family = AF_UNSPEC;  // use IPv4 or IPv6, whichever
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE;     // fill in my IP for me

getaddrinfo(NULL, "3490", &hints, &res);

// make a socket:
sockfd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);

// bind it to the port we passed in to getaddrinfo():
bind(sockfd, res->ai_addr, res->ai_addrlen);
  • bind()은 오류가 났을 경우 -1을 반환하고 errno에 오류값을 저장한다.
  • 손수 struct sockaddr_in을 만들어서 사용하는 옛날 이야기가 원문에 있으니 참조바란다.
  • 1024 이하의 포트 숫자는 별도의 용도를 위해 보존된다 (superuser가 아니라면). 따라서 1024부터 65535까지의 포트 숫자를 사용하는 것이 안전하다.
  • 서버를 재실행할 때면 가끔 "Address already in use."라며 bind()가 실패할 때가 있다. 이는 연결되어 있었던 socket이 아직 완전히 포트를 떠나지 못하고 kernel 안에서 떠돌고 있는 상황이다. 이럴 때는 잠시 정리되기를 기다리거나 (1-2분 정도), 아래 코드처럼 포트를 재사용하도록 할 수도 있다.
int yes=1;
//char yes='1'; // Solaris people use this

// lose the pesky "Address already in use" error message
if (setsockopt(listener,SOL_SOCKET,SO_REUSEADDR,&yes,sizeof yes) == -1) {
    perror("setsockopt");
    exit(1);
}
  • 반드시 bind()를 하지 않아도 되는 상황이 있음을 명심하자. telnet에서는 접속하고자 하는 원격 포트만이 중요한 것처럼, connect()로 원격 서버에 접속하고자 하는 상황에서는 당신의 local port가 무엇인지는 신경쓸 필요가 없는 것이다. 이 상황에서 connect()는 알아서 비어있는 포트를 찾아서 사용한다.

5.4. connect() - Hey, You!

#include <sys/types.h>
#include <sys/socket.h>

int connect(int sockfd, struct sockaddr *serv_addr, int addrlen);
  • sockfdsocket()을 통해 받은 socket file descriptor
  • serv_addr은 목적지의 IP 주소와 포트 정보를 담고 있는 struct sockaddr
  • addrlen은 서버 주소 strucutre의 byte 길이이다.
  • 이 argument들은 모두 getaddrinfo()를 통해 가져올 수 있다. 멋지다.
  • 아래 예제는 "www.example.com"의 포트 3490에 연결하는 예제이다.
struct addrinfo hints, *res;
int sockfd;

// first, load up address structs with getaddrinfo():
memset(&hints, 0, sizeof hints);
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;

getaddrinfo("www.example.com", "3490", &hints, &res);

// make a socket:
sockfd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);

// connect!
connect(sockfd, res->ai_addr, res->ai_addrlen);
  • 오류가 났을 경우 -1을 반환하고, errno에 오류값을 넣어준다. 오류 여부를 꼭 확인하자.
  • 위 예제에서 bind()를 사용하지 않은 것도 알아두자. Local 포트 따위 신경쓰지 않았다.

5.5. listen() - 연락 좀 주세요

  • 연결이 들어오길 기다렸다가, 들어오면 이를 원하는 대로 처리하는 서버의 입장에서 보면 두 과정을 거쳐야 한다. 일단 listen()을 하고 있다가 accept()를 하면 된다.
int listen(int sockfd, int backlog);
  • listen() signature는 단순하지만 설명이 좀 필요하다.
  • backlog는 incoming queue로부터 허락된 연결 개수이다.
  • 무슨 말이냐? 당신이 accept()하기 전까지, 들어오는 연결들은 이 queue에서 계속 대기하고 있다는 것이다. 그리고 backlog 숫자는 이렇게 대기하는 연결들의 한계점을 의미한다.
  • 대부분의 시스템에서 이 숫자는 대략 20 정도로 제한하지만, 직접 5나 10 정도로 바꿀 수도 있다.
  • listen()도 역시 오류 시 -1 값을 반환하고, errno에 오류값을 넣어준다.
  • listen()하기 전에 bind()가 필요하다. 그래야 서버가 특정 포트에 대해서 돌아갈 수가 있다. 결국 다음과 같은 순서로 system call을 사용하게 된다.
getaddrinfo();
socket();
bind();
listen();
/* accept() goes here */


5.6. accept() - 3490 포트로 연락주셔서 감사합니다

  • 서버사이드 스토리
    • 누군가 멀리서 특정 포트를 listen()하고 있는 나의 서버에 connect()하러 온다.
    • 이 연결은 accept()되기를 기다리며 queue에 쌓인다.
    • 내 서버는 이 연결을 accept()해서 queue에서 가져온다.
    • accept()는 이 연결 하나를 위한 새로운 socket file descriptor로 반환해준다!
    • 그렇다, 갑자기 내 서버는 2개의 socket file descriptor를 가지게 된다. 하나는 기존에 새로운 연결을 기다리는 것이고, 새로 만들어진 다른 하나는 드디어 send()recv()를 할 수 있는 단계에 온 것이다.
#include <sys/types.h>
#include <sys/socket.h>

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • sockfdlisten()하고 있는 socket descriptor이다.
  • addr은 주로 local struct sockaddr_storage를 가리키는 pointer이다. 여기서 접속해 오는 연결에 대한 정보가 들어오는데, 이 struct를 통해 어느 host가 어떤 port를 통해서 연결해오고 있는지 알 수 있다.
  • addrlen은 local integer 변수로, accept()가 실행되기 전에 sizeof(struct sockaddr_storage)로 들어가야 한다. accept()는 이 숫자만큼의 byte만을 addr에 입력한다. 만약 그보다 적은 byte가 입력됐다면, 그 숫자를 addrlen에 반영시켜준다.
  • accept()는 오류 시 -1 값을 반환하고, errno에 오류값을 넣어준다.
  • 예제를 보면서 다시 곱씹어보자.
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>

#define MYPORT "3490"  // the port users will be connecting to
#define BACKLOG 10     // how many pending connections queue will hold

int main(void)
{
    struct sockaddr_storage their_addr;
    socklen_t addr_size;
    struct addrinfo hints, *res;
    int sockfd, new_fd;

    // !! don't forget your error checking for these calls !!

    // first, load up address structs with getaddrinfo():
    memset(&hints, 0, sizeof hints);
    hints.ai_family = AF_UNSPEC;  // use IPv4 or IPv6, whichever
    hints.ai_socktype = SOCK_STREAM;
    hints.ai_flags = AI_PASSIVE;     // fill in my IP for me

    getaddrinfo(NULL, MYPORT, &hints, &res);

    // make a socket, bind it, and listen on it:
    sockfd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
    bind(sockfd, res->ai_addr, res->ai_addrlen);
    listen(sockfd, BACKLOG);

    // now accept an incoming connection:
    addr_size = sizeof their_addr;
    new_fd = accept(sockfd, (struct sockaddr *)&their_addr, &addr_size);

    // ready to communicate on socket descriptor new_fd!
    .
    .
    .


5.7. send()recv() - Talk to Me, Baby!

  • 이 두 함수는 stream socket이나 connected datagram socket 위에서 통신할 때 쓰인다. Unconnected datagram socket의 경우는 아래 sendto()recvfrom()을 사용하게 된다.
int send(int sockfd, const void *msg, int len, int flags);
  • sockfd는 데이터를 보내고자 하는 목적지의 socket descriptor로, socket()이나 accept()의 반환물이 사용된다.
  • msg는 보내고자하는 데이터를 가리키는 pointer
  • len은 그 데이터의 byte 길이이다.
  • flags는 그냥 0으로 주면 된다 (궁금하다면 send() man page를 보라).

  • 예제

char *msg = "Beej was here!";
int len, bytes_sent;
.
.
.
len = strlen(msg);
bytes_sent = send(sockfd, msg, len, 0);
.
.
.
  • send()는 반환값으로 실제로 보내진 데이터의 byte 길이를 준다. 따라서 이 값은 len argument보다 작을 수도 있다! 당신이 엄청난 양의 데이터를 한 방에 보내려고 해서, 시스템이 이를 완수해낼 수 없을 수도 있는 것이다. 이럴 때는 최대한 보낼 수 있는 만큼만 보내고 그 보낸 양을 알려주어서, 시스템은 당신이 그 나머지를 나중에 다시 잘 처리하리라 믿는다.
  • 다행히도, packet이 작다면 (1K 이하 정도) 대부분의 경우는 한 방에 모든 데이터가 전송될 것이다.
  • send()는 오류 시 -1 값을 반환하고, errno에 오류값을 넣어준다.
int recv(int sockfd, void *buf, int len, int flags);
  • sockfd는 읽고자 하는 socket descriptor
  • buf는 읽어낸 정보가 들어가는 buffer
  • len은 buffer의 최대 byte 길이
  • flags0이면 된다 (궁금하다면 recv() man page를 보라).
  • recv()는 받은 데이터를 읽어서 buffer에 넣은 byte 길이를 반환한다. 오류일 경우 -1를 반환하고 errno에 오류값을 넣어준다.
  • recv()0을 반환한다면? 이는 접속을 시도한 상대방이 연결을 끊었다는 의미이다!

5.8. sendto()recvfrom() - Talk to Me, DGRAM-style

  • Datagram socket은 원격 host와 연결되어 있지 않으므로, packet를 보내기 전에 어떤 정보가 꼭 필요할까? 답은 목적지의 주소이다!
int sendto(int sockfd, const void *msg, int len, unsigned int flags,
           const struct sockaddr *to, socklen_t tolen);
  • send()와 거의 비슷한데, 추가적으로 두 개의 argument가 더 있다.
    • tostruct sockaddr을 가리키는 pointer로, 목적지의 IP 주소와 port 정보를 담고 있어야 한다.
    • tolen은 정수형 변수로, 그저 sizeof *to 아니면 sizeof(struct sockaddr_storage)로 넣어주면 된다.
  • 이 목적지 정보를 알기 위해서는 getaddrinfo()를 사용하거나, 뒤에 설명할 recvfrom()을 통해서도 가능하다.
  • send()와 똑같이, sendto() 역시 반환값으로 실제로 보내진 데이터의 byte 길이를 준다. 오류 시 -1 값을 반환하고, errno에 오류값을 넣어준다.
int recvfrom(int sockfd, void *buf, int len, unsigned int flags,
             struct sockaddr *from, int *fromlen);
  • Argument에 대한 설명은 많은 내용이 겹치므로 생략한다. 원문을 참조 바란다.

  • Datagram socket에 connect()한 상황이라면, 데이터 송수신을 위해 send()recv()를 사용하면 된다는 점을 명심하자. UDP protocol을 사용하는 datagram socket이더라도, 연결되어 있는 socket interface가 이미 목적지와 발신지 정보를 알고 있는 것이다.


5.9. close()shutdown() - 이제 꼬졍

  • Socket 연결을 닫고 싶다면, 일반적인 Unix file descriptor인 경우와 마찬가지로 close()를 사용하면 된다.
close(sockfd);
  • 이제 이 socket에 모든 읽고 쓰는 작업이 금지된다. 누군가 그런 시도를 한다면 오류를 받아갈 것이다.

  • Socket을 닫는 과정에서 좀 더 세밀한 조정을 필요로 한다면 shutdown() 함수를 사용하면 된다. 이 함수는 close()처럼 양방향 연결을 끊을 수도 있고, 단방향만을 끊을 수도 있게 해준다.

int shutdown(int sockfd, int how);
  • how 값은
    • 수신만 끊겠다면 0
    • 송신만 끊겠다면 1
    • close()와 같이 송수신 모두 끊겠다면 2로 주면 된다.
  • shutdown()은 반환값으로 성공 시 0, 오류 시 -1 값을 반환하고, errno에 오류값을 넣어준다.
  • Unconnected datagram socket에 shutdown()을 적용시킨다면, 이는 단순하게 해당 socket에 대해 send()recv() 작업을 하지 못하도록 만들 뿐이다.
  • 중요한 점. shutdown()은 socket의 사용을 불가능하게 할 뿐 실제로 file descriptor를 닫지는 않는다. Socket descriptor를 해제하려면 close()해야 한다 (Windows에서 Winsock을 사용할 경우에는 close() 대신 closesocket()을 사용해야 한다).

5.10. getpeername() - 넌 누구니

#include <sys/socket.h>

int getpeername(int sockfd, struct sockaddr *addr, int *addrlen);
  • sockfd는 연결되어 있는 stream socket의 descriptor
  • addrstruct sockaddr (또는 struct sockaddr_in) pointer로, 연결 상대방이 정보를 담을 곳이다.
  • addrlen은 정수 pointer로, sizeof *addr이나 sizeof(struct sockaddr)로 초기화되어 있어야 한다.
  • 오류 시 -1을 반환하고, errno에 오류값을 넣어준다.
  • 이렇게해서 상대방의 주소 정보를 알았다면, inet_ntop()getnameinfo(), gethostbyaddr()를 이용해서 더 자세한 정보를 알아낼 수 있다.

5.11. gethostname() - 난 누구니

  • gethostname()은 이 프로그램을 돌리고 있는 컴퓨터의 hostname을 받아낸다. 이렇게 받은 hostname은 gethostbyname()을 통해서 IP 주소를 알아내는데 쓰일 수 있다.
#include <unistd.h>

int gethostname(char *hostname, size_t size);
  • hostname이 자신의 hostname을 받을 char pointer이다.
  • sizehostname의 byte 길이이다.
  • 성공 시 0, 오류 시 -1을 반환하고 errno에 오류값을 넣어준다.




6. Client-Server Background

  • 네트워크 상에서 일어나는 모든 일은 서버와 클라이언트 간에 오고가는 대화 과정이다.
  • telnet의 예를 보면
    • 클라이언트에서 원격 host의 23 포트로 연결을 시도한다.
    • 그 원격 host (telnetd라고 불리는 서버)는 깨어나서
    • 들어오는 telnet connection을 받아 로그인 프롬프트를 띄워주는 등의 일을 한다.

Client-Server InteractionClient-Server Interaction


  • 클라이언트와 서버 커플은 SOCK_STREAM이나 SOCK_DGRAM, 아니면 다른 무엇이든지 간에 서로 같은 종류의 socket이라면 통신할 수 있다. 예를 몇 가지 들어보면

    • telnet/telnetd
    • ftp/ftpd
    • Firefox/Apache
    • 당신이 ftp를 사용할 때마다, 여기에 응대하는 ftpd가 어딘가 멀리에 있는 것이다.
  • 원문에 TCP stream server-client의 예제 코드와 datagram socket의 예제 코드가 있으니 꼭 보길 바란다 (원문에도 코드 자체에 대한 설명이 없으므로 그대로 옮겨와봤자 큰 의미가 없다고 생각해서 생략하지만, 반드시 보고 이해하시길 바란다).





반응형