Programming

Beej's Guide to Network Programming 요약, Part 2. Slightly Advanced Techniques

동건 2019. 9. 14. 15:48


마지막 한 섹션이지만, 그 내용도 어려워지고 번역하기도 어려워져서 Part 2로 따로 쓰게 되었다. 역시나 미사여구는 쳐 내고 핵심 내용 중심으로 옮겼으니, 원문도 꼭 읽어보시기 바란다.



7. Slightly Advanced Tenchniques


7.1. Blocking

  • "Block"은 "sleep"을 기술적으로 멋지게 하는 말이다.
  • listener를 실행할 때 보면 알 수도 있을텐데, recvfrom()을 실행했을 때 들어오는 데이터가 없다면 recvfrom()은 거기서 데이터가 올 때 까지 "block"하고 있다 (잠깐 자고 있다)는 것이다.
  • 많은 함수들이 block 한다. accept()이나 모든 recv() 함수들이 그렇다.
  • Block이 가능한 이유는? 그렇게 할 수 있도록 kernel의 허락을 받았기 때문. socket()으로 처음 socket descriptor를 생성할 때, kernel은 이 socket descriptor가 block할 수 있도록 설정해준다.
  • Socket에 block하지 않도록 하고 싶다면 fcntl()을 쓰면 된다.
#include <unistd.h>
#include <fcntl.h>
.
.
.
sockfd = socket(PF_INET, SOCK_STREAM, 0);
fcntl(sockfd, F_SETFL, O_NONBLOCK);
.
.
.
  • 이렇게 non-blocking socket을 설정하면 우리는 socket을 "polling"할 수 있다.
  • Non-blocking socket으로부터 데이터를 받으려고 했는데 아직 데이터가 오지 않았다면, block할 수가 없으므로 -1을 반환하고 errnoEAGAIN이나 EWOULDBLOCK이 저장될 것이다.
  • 하지만 일반적으로 이런 busy-wait POLLING은 좋은 생각이 아니다. 왜냐하면 이렇게 지속적으로 socket이 데이터를 받았는지 말았는지 확인하는 것은 CPU에 부하를 많이 주기 때문이다. 더 좋은 방법은 select()를 사용하는 것이다.


7.2. select() - Synchronous I/O Multiplexing

  • 서버가 incoming connection을 계속 기다리면서도, 이미 들어온 연결이 가지고 있는 데이터도 동시에 읽고 싶은 상황을 생각해보자.
  • 앞서 설명했던 accept()recv()는 사실 그렇게 빠르지 않다. accept()가 block한다면, 동시에 recv()를 통해 데이터를 읽을 수가 없는 것이다. 그렇다고해서 non-blocking socket을 사용하기엔 CPU 파워를 너무 잡아먹는다.
  • select()는 여러 socket을 동시에 점검할 수 있게 해준다. 어느 socket이 데이터를 읽을 준비가 됐는지, 어느 socket이 데이터를 쓸 준비가 됐는지, 아니면 오류를 내보낼 준비가 됐는지를 알려준다.
  • 반면, 요즘 시대엔 select()가 여러 socket을 모니터링하는 가장 느린 방법 중 하나이기도 하다 (매우 호환성이 좋기는 하지만).
  • 가능한 대안의 하나로 libevent나 그 비슷한 것을 사용할 수도 있다. 이것은 socket으로부터 알림을 받는 모든 system-dependent한 것들을 한 단위로 모아놓은 것(encapsulation)이다.
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

int select(int numfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);
  • 이 함수는 file descriptor의 여러 "묶음들(sets)"을 지켜보는데, readfds, writefds, exceptfds가 그 묶음들이다.
  • Standard input과 다른 어떤 socket descriptor sockfd에서 데이터를 읽을 수 있는지 알고 싶다면 readfds0sockfd를 넣어놓으면 된다.
  • numfds는 가장 큰 file descriptor 숫자에 1을 더한 값을 넣어야 한다. 바로 위 예에서는 sockfd+1이 될 것이다 (sockfd0보단 클테니까).
  • select()readfds의 어느 file descriptor가 데이터를 읽을 준비가 되었는지를 표시하도록 바꿔준다. 그 결과는 macro FD_ISSET()을 통해 확인해볼 수 있다. 이런 기능을 하는 macro들을 나열해보면
    • FD_SET(int fd, fd_set *set); Add fd to the set.
    • FD_CLR(int fd, fd_set *set); Remove fd from the set.
    • FD_ISSET(int fd, fd_set *set); Return true if fd is in the set.
    • FD_ZERO(fd_set *set); Clear all entries from the set.
  • struct timeval은 어디서 튀어나온 것이냐?
    • 누군가에게 데이터를 보내주기 위해서 무한정 기다리게 하지는 않고 싶을 것이다. 최소한 "진행중..." 이라고 96초마다 한 번씩 터미널에 한 줄 출력이라도 해주고 싶을 것이다.
    • 이 time structure는 timeout 기간을 설정하도록 한다. select()가 이 시간이 지나도록 무언가 준비가 된 file descriptor를 찾지 못했다면, 그 다음 작업을 하도록 함수를 끝낼 것이다.
  • struct timeval은 아래와 같은 구조이다.
struct timeval {
    int tv_sec;     // seconds
    int tv_usec;    // microseconds
};
  • 함수가 값을 반환하고 끝났을 때, timeout은 원래 입력했던 timeout 시간에서 얼마나 남았는지에 대한 정보로 업데이트될 것이다 (그렇지 않은 Unix 계열 시스템도 있다).
  • timeout 값을 0으로 준다면 select()는 그 즉시 timeout으로 끝난다. 이는 실제로 polling하는 것과 같은 행동을 하는 것이다.
  • timeout 값을 NULL로 준다면, select()는 절대 timeout되지 않는다. 무언가 준비가 된 file descriptor가 나타날 때까지 기다리는 것이다.
  • Argument로 들어가는 세 가지 file descriptor 묶음 중에서 무시하고 싶은 것이 있다면 NULL로 줘버리면 된다.
  • 아래 예제는 standard input에 무언가 들어오는지 2.5초 동안 기다리고 있는 코드다.
/*
** select.c -- a select() demo
*/

#include <stdio.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

#define STDIN 0  // file descriptor for standard input

int main(void)
{
    struct timeval tv;
    fd_set readfds;

    tv.tv_sec = 2;
    tv.tv_usec = 500000;

    FD_ZERO(&readfds);
    FD_SET(STDIN, &readfds);

    // don't care about writefds and exceptfds:
    select(STDIN+1, &readfds, NULL, NULL, &tv);

    if (FD_ISSET(STDIN, &readfds))
        printf("A key was pressed!\n");
    else
        printf("Timed out.\n");

    return 0;
}
  • select() 방법은 datagram socket에 데이터가 오는 것을 기다릴 때 좋은 방법이라고 생각할 수 있다. 그런데 그럴 수도 아닐 수도 있다. 자신이 사용 중인 기계의 local man page를 보고 가능한지 여부를 확인해보아야 한다.
  • readfds에 있는 socket의 연결이 끊어진다면 어떻게 되나?
    • select()는 그 socket descriptor가 데이터를 읽을 준비가 되었다고 판단한다.
    • 그래서 실제로 recv()를 해보면 0 값을 반환할 것이다. 그리고 이는 클라이언트가 연결을 끊었다는 의미임을 우리는 이미 알고 있다.
  • 만약 listen()하고 있는 socket이 있다면, 그 socket descriptor를 readfds에 넣어서 새로운 연결이 있는지 확인해 볼 수도 있다.

  • 아래에 실제 사용에 가까운 복잡한 예제를 꼭 보자. 이후 설명도 이어질 것이다. 채팅 서버 코드인데 이를 한 곳에서 실행시킨 후에, 다른 여러 창에서 telnet으로 접속하면 된다 ("telnet hostname 9034"). 한 클라이언트에서 뭔가 입력하면, 다른 클라이언트에게도 보일 것이다.

/*
** selectserver.c -- a cheezy multiperson chat server
*/

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

#define PORT "9034"   // port we're listening on

// get sockaddr, IPv4 or IPv6:
void *get_in_addr(struct sockaddr *sa)
{
    if (sa->sa_family == AF_INET) {
        return &(((struct sockaddr_in*)sa)->sin_addr);
    }

    return &(((struct sockaddr_in6*)sa)->sin6_addr);
}

int main(void)
{
    fd_set master;    // master file descriptor list
    fd_set read_fds;  // temp file descriptor list for select()
    int fdmax;        // maximum file descriptor number

    int listener;     // listening socket descriptor
    int newfd;        // newly accept()ed socket descriptor
    struct sockaddr_storage remoteaddr; // client address
    socklen_t addrlen;

    char buf[256];    // buffer for client data
    int nbytes;

    char remoteIP[INET6_ADDRSTRLEN];

    int yes=1;        // for setsockopt() SO_REUSEADDR, below
    int i, j, rv;

    struct addrinfo hints, *ai, *p;

    FD_ZERO(&master);    // clear the master and temp sets
    FD_ZERO(&read_fds);

    // get us a socket and bind it
    memset(&hints, 0, sizeof hints);
    hints.ai_family = AF_UNSPEC;
    hints.ai_socktype = SOCK_STREAM;
    hints.ai_flags = AI_PASSIVE;
    if ((rv = getaddrinfo(NULL, PORT, &hints, &ai)) != 0) {
        fprintf(stderr, "selectserver: %s\n", gai_strerror(rv));
        exit(1);
    }

    for(p = ai; p != NULL; p = p->ai_next) {
        listener = socket(p->ai_family, p->ai_socktype, p->ai_protocol);
        if (listener < 0) { 
            continue;
        }

        // lose the pesky "address already in use" error message
        setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(int));

        if (bind(listener, p->ai_addr, p->ai_addrlen) < 0) {
            close(listener);
            continue;
        }

        break;
    }

    // if we got here, it means we didn't get bound
    if (p == NULL) {
        fprintf(stderr, "selectserver: failed to bind\n");
        exit(2);
    }

    freeaddrinfo(ai); // all done with this

    // listen
    if (listen(listener, 10) == -1) {
        perror("listen");
        exit(3);
    }

    // add the listener to the master set
    FD_SET(listener, &master);

    // keep track of the biggest file descriptor
    fdmax = listener; // so far, it's this one

    // main loop
    for(;;) {
        read_fds = master; // copy it
        if (select(fdmax+1, &read_fds, NULL, NULL, NULL) == -1) {
            perror("select");
            exit(4);
        }

        // run through the existing connections looking for data to read
        for(i = 0; i <= fdmax; i++) {
            if (FD_ISSET(i, &read_fds)) { // we got one!!
                if (i == listener) {
                    // handle new connections
                    addrlen = sizeof remoteaddr;
                    newfd = accept(listener,
                        (struct sockaddr *)&remoteaddr,
                        &addrlen);

                    if (newfd == -1) {
                        perror("accept");
                    } else {
                        FD_SET(newfd, &master); // add to master set
                        if (newfd > fdmax) {    // keep track of the max
                            fdmax = newfd;
                        }
                        printf("selectserver: new connection from %s on "
                            "socket %d\n",
                            inet_ntop(remoteaddr.ss_family,
                                get_in_addr((struct sockaddr*)&remoteaddr),
                                remoteIP, INET6_ADDRSTRLEN),
                            newfd);
                    }
                } else {
                    // handle data from a client
                    if ((nbytes = recv(i, buf, sizeof buf, 0)) <= 0) {
                        // got error or connection closed by client
                        if (nbytes == 0) {
                            // connection closed
                            printf("selectserver: socket %d hung up\n", i);
                        } else {
                            perror("recv");
                        }
                        close(i); // bye!
                        FD_CLR(i, &master); // remove from master set
                    } else {
                        // we got some data from a client
                        for(j = 0; j <= fdmax; j++) {
                            // send to everyone!
                            if (FD_ISSET(j, &master)) {
                                // except the listener and ourselves
                                if (j != listener && j != i) {
                                    if (send(j, buf, nbytes, 0) == -1) {
                                        perror("send");
                                    }
                                }
                            }
                        }
                    }
                } // END handle data from client
            } // END got new incoming connection
        } // END looping through file descriptors
    } // END for(;;)--and you thought it would never end!

    return 0;
}
  • 여기선 2개의 file descriptor 묶음을 다룬다. 하나는 master이고 다른 하나는 read_fds이다.
  • master는 현재 연결된 모든 socket descriptor를 담을 뿐 아니라 새로운 연결을 기다리는 (listen하고 있는) socket descriptor도 포함하고 있다.
  • master set을 쓰는 이유는 select()가 받은 socket descriptor 묶음의 내용이 변하기 때문이다. 계속 select()를 실행하면서도 연결들을 계속 추적하려면 이를 별도로 안전하게 보관하는 곳이 필요하다.
  • 그리고 결국엔 read_fdsmaster를 복사해서 select()를 호출한다.
  • 새로운 연결이 생기거나 끊길 때마다, 이는 master 묶음에 반영된다.
    • listener socket이 데이터를 읽을 준비가 되었다면, 이는 새롭게 들어와서 기다리고 있는 연결이 있음을 의미한다. 그러므로 이를 accept()하고 master set에 추가해준다.
    • 클라이언트 연결이 들어왔는데, recv()를 했더니 그 반환값이 0이라면 이는 클라이언트에서 접속을 끊은 것이므로 master에서도 빼준다.
  • recv() 반환값이 0이 아니라면 서버가 실제로 받은 데이터가 있다는 말이다. 따라서 다시 master set을 돌면서 연결되어 있는 나머지 클라이언트에게 그 데이터를 전달해준다.

  • select()와 거의 비슷한 작업을 하지만 다른 시스템 위에서 돌아가는 poll() 함수도 있다는 것을 언급하면서 마무리한다.


7.3. Handling Partial send()s

  • 나는 512 byte를 send()하고 싶었는데, 그 반환값으로 412가 올 수도 있다. 미처 보내지 못한 100 byte는 어떡하지?
  • 우리가 제어할 수 없는 어떤 문제로 인해서 kernel은 한 묶음에 모든 데이터를 보내지 못한 것이다.
  • 다행히 당신의 buffer에 보내지 못한 내용이 아직 남아있다. 아래와 같은 함수를 만들어서 사용할 수 있다.
#include <sys/types.h>
#include <sys/socket.h>

int sendall(int s, char *buf, int *len)
{
    int total = 0;        // how many bytes we've sent
    int bytesleft = *len; // how many we have left to send
    int n;

    while(total < *len) {
        n = send(s, buf+total, bytesleft, 0);
        if (n == -1) { break; }
        total += n;
        bytesleft -= n;
    }

    *len = total; // return number actually sent here

    return n==-1?-1:0; // return -1 on failure, 0 on success
}
  • 여기서 s는 당신이 데이터를 보내고자 하는 socket이고
  • buf는 데이터를 담고 있는 buffer이고
  • len은 buffer의 길이를 담고 있는 int 변수를 가리키는 pointer이다.
  • 이 함수는 오류 시에 -1을 반환한다. 그리고 send()의 결과로 설정된 errno는 변하지 않는다.
  • 실제로 전송된 데이터 길이는 len에 반영된다. 오류가 없다면 sendall()은 본래 보내고자 하는 데이터의 길이와 len은 같게 될 것이다. 데이터를 모두 보내기 위해 분투하는 함수인 것이다.
  • 아래는 sendall()의 사용 예제이다.
char buf[10] = "Beej!";
int len;

len = strlen(buf);
if (sendall(s, buf, &len) == -1) {
    perror("sendall");
    printf("We only sent %d bytes because of the error!\n", len);
}
  • 받는 입장에서는 어떨까. Packet의 길이가 정해져 있지 않다면 더더욱 어려운 상황이 된다. 이게 부분만 온 건지, 전체가 온 건지? 부분만 왔다면 그 나머지는 언제 오는지? Encapsulate를 해야만 할 지도 모른다.

  • 아주 드문 상황이지만 Linux의 select()는 실제로 읽을 준비가 안 된 socket를 읽을 준비가 되었다고 반환할 수가 있다. 이는 select()가 "이 socket은 block하지 않을거야"라고 말해서 read()했더니 실제로는 block된다는 말이다.

  • 이 경우에는 데이터를 받는 socket에 O_NONBLOCK flag를 주어서, read() 시 block하는 상황에서 EWOULDBLOCK 오류로 끝내게 해서 문제를 회피할 수 있다. 여기서 EWOULDBLOCK 오류는 무시하고 넘어가도 문제가 없다. Socket을 non-blocking하도록 설정하는 것에 대한 더 자세한 설명을 보려면 fcntl()를 참조하자.

7.4. Serialization - How to Pack Data

  • 텍스트 데이터가 아닌 intfloat과 같은 "binary" 데이터는 어떻게 전송해야 할까?
    • sprintf()같은 함수를 통해서 텍스트로 바꿔서 보낸다. 받는 쪽에서는 반대로 strtol()과 같은 함수를 통해 숫자로 다시 바꿔서 쓴다.
    • 그냥 바로 해당 데이터의 pointer를 send()에 담아 보낸다.
    • 호환성있는 binary form으로 변환시켜서 보내고, 받는 쪽에서도 이를 해독해서 사용한다.
  • 각자의 장단점이 있지만, 일반적으로 세 번째 방법이 좋단다.
  • 첫 번째 방법은 텍스트를 받아서 그대로 사용하면 될 때 좋다. 예를 들면 IRC (Internet Relay Chat)를 들 수 있겠다. 본래 데이터 타입으로 변환하려면 속도가 많이 느려지고, 데이터 크기를 생각해봐도 비효율적이다.
  • Raw data를 그대로 보내는 두 번째 방법은 쉽지만 위험하다.
double d = 3490.15926535;

send(s, &d, sizeof d, 0);  /* DANGER--non-portable! */
  • 받는 쪽에서도 마찬가지로
double d;

recv(s, &d, sizeof d, 0);  /* DANGER--non-portable! */
  • 무엇이 문제인가? 모든 컴퓨터가 double을 같은 bit 개수로 표현하지 않는다는 점이다. 거기에 더해서 byte ordering 역시 다를 수 있다! 따라서 위 코드는 절대로 호환성이 있지 않다 (호환성이 필요없다면, 위 코드는 매우 편하고 빠르다는 말이 되기도 한다).
  • 우리는 이미 htons()와 같은 클래스의 함수들을 통해서 Network Byte Order를 지키기 위해 숫자를 변환시키는 것을 봤다. 슬프게도 float 타입을 위한 함수는 없다. 어떡하지?
  • 데이터를 약속된 binary format으로 포장(pack, marshal, serialize, whatever)해서 보낸 후, 수신자가 이 포장을 뜯으면 된다. htons()ntohs()처럼 말이다.
#include <stdint.h>

uint32_t htonf(float f)
{
    uint32_t p;
    uint32_t sign;

    if (f < 0) { sign = 1; f = -f; }
    else { sign = 0; }

    p = ((((uint32_t)f)&0x7fff)<<16) | (sign<<31); // whole part and sign
    p |= (uint32_t)(((f - (int)f) * 65536.0f))&0xffff; // fraction

    return p;
}

float ntohf(uint32_t p)
{
    float f = ((p>>16)&0x7fff); // whole part
    f += (p&0xffff) / 65536.0f; // fraction

    if (((p>>31)&0x1) == 0x1) { f = -f; } // sign bit set

    return f;
}
  • 위 예제는 float을 32-bit 숫자로 포장하는 개념을 간단히 보이기 위해 작성한 것으로, 손 볼 곳이 무척 많음을 미리 말해둔다.
  • 가장 높은 31번째 bit는 부호를 저장하기 위해 쓰였다. 1이면 음수가 된다.
  • 그 다음 일곱 bit (30-16)는 정수 자리, 나머지 (15-0) bit는 소수 자리를 저장하는데 쓰였다.
  • 사용 예제를 보면 더욱 알기 쉽다.
#include <stdio.h>

int main(void)
{
    float f = 3.1415926, f2;
    uint32_t netf;

    netf = htonf(f);  // convert to "network" form
    f2 = ntohf(netf); // convert back to test

    printf("Original: %f\n", f);        // 3.141593
    printf(" Network: 0x%08X\n", netf); // 0x0003243F
    printf("Unpacked: %f\n", f2);       // 3.141586

    return 0;
}
  • 장점은? 작고, 간단하고 빠르다.
  • 단점은? 보낼 수 있는 숫자의 범위가 굉장히 제한적이다. 32767 이상의 숫자를 보낼 수가 없는 것이다. 게다가 위 예제를 보면 마지막 소수점 두 자리 수가 제대로 보존되지 않는 것도 볼 수 있다.

  • 그럼 어떡하지? 사실 부동 소수점을 저장하는 기준으로 IEEE-754가 있고, 대부분의 컴퓨터가 부동 소수점 계산을 위해 내부적으로 이 기준을 사용하고 있다. 그래서 위와 같은 변환 자체가 필요 없을 수도 있다. 하지만 당신의 코드가 호환성이 있어야 한다면, 이 가정을 절대적으로 믿을 수는 없는 것이다.

  • 원문에 floatdouble을 IEEE-754 형식으로 변환하는 예제 코드를 꼭 보자.

  • 그럼 struct는 어떻게 포장할까? 안 됐지만, 컴파일러는 struct 내부에 패딩(빈공간)을 맘대로 넣을 수 있기 때문에 한 방에 struct를 호환성있게 포장할 수는 없다.

  • struct의 각 필드를 포장해서 보내는 것이 최선의 방법이다. 매우 귀찮은 일이므로 이를 위한 helper 함수를 만들 수도 있다.

  • Kernighan과 Pike가 쓴 The Practice of Programming에서는 printf()와 비슷한 사용법으로 쓸 수 있는 pack()unpack() 함수를 구현했다.

  • 만약 C 언어로 packing 기능을 구현하려면 위와 같은 K&P의 방법이 꽤나 도움이 될 것이다. 원문에서 필자가 만든 코드를 살펴보기 바란다.
  • 추가적으로 BSD-licensed Typed Parameter Language C API도 언급하고 넘어가겠다. Python과 Perl 프로그래머라면 이 API 기능을 구현한 pack(), unpack() 함수를 확인해보자. Java에서도 비슷한 기능을 가진 Big-ol' Serializable interface가 있다.

  • 그래서 데이터를 포장할 때, 어떤 형식(format)을 사용하는 것이 좋은 걸까? 이에 대한 답은 RFC 4506 (The External Data Representation Standard에 여러가지 데이터 타입에 대한 binary format으로 정의되어 있다. 여기에 순응해도 되고, 굳이 따르지 않아도 된다.


7.5. Son of Data Encapsulation

  • Encapsulation을 간단히 말하자면, 데이터를 특정짓는 정보들을 담은 header를 붙이는 것이다.
  • SOCK_STREAM을 사용하는 채팅 프로그램의 예를 들어보겠다. 이 프로그램은 "누가", "무엇을" 보내는 지에 대한 정보를 교환할 것이다.
  • "tom"이란 사람이 "Hi"라고 보냈고, "Benjamin"이라는 사람이 "Hey guys what is up?"이라고 말했다고 한다.
  • 이 대화 내역을 곧이 곧대로 send()한다고 치면 아래와 같은 data stream이 나가게 될 것이다.
t o m H i B e n j a m i n H e y g u y s w h a t i s u p ?
  • 이 메세지를 받는 쪽에서는 어디까지가 사람에 대한 내용이고 어디가 대화의 내용인지 알 수 있을까?
  • 모든 메세지의 길이를 통일해서 위에서 구현했던 sendall()을 통해 보낼 수도 있겠지만, 이는 bandwidth를 심각하게 냉비하는 것이다! "tom"이 "Hi"라고 말한 메세지를 위해서 1024 byte를 보내고 싶지는 않다.
  • 그래서 이런 정보를 위해 packet 구조에 조그마한 header를 덧붙여서 encapsulate 하는 것이다. 그러면 서버와 클라이언트 모두 데이터를 어떻게 포장하고 포장을 푸는지 ("marshal"과 "unmarshal"이라고 부르기도 한다) 알고 있게 된다. 이런 식으로 클라이언트와 서버가 소통하는 방식을 protocol 로 정의한다고 한다!

  • 이제 포장하는 법을 정해보겠다.

  • 사용자 이름을 8글자로 고정하고, '\0'으로 패딩을 주기로 한다.
  • 채팅 데이터는 가변적인 길이이지만, 최대 128글자로 한다.
  • len (1 byte, unsigned) - Packet의 총 길이로, 8-byte의 사용자 이름과 채팅 데이터를 포함한다.
  • name (8 bytes) - 사용자 이름, 길이가 남을 경우 NULL-padding을 준다.
  • chatdata (n-bytes) - 채팅 데이터, 128 byte를 넘을 수 없다.
  • 이 packet 정의에 따라서 첫 번째 packet은 아래와 같이 구성될 것이다. (hex와 ASCII 표기로 보면)
   0A     74 6F 6D 00 00 00 00 00      48 69
(length)  T  o  m    (padding)         H  i
  • 두 번째 packet도
   18     42 65 6E 6A 61 6D 69 6E      48 65 79 20 67 75 79 73 20 77 ...
(length)  B  e  n  j  a  m  i  n       H  e  y     g  u  y  s     w  ...
  • Packet 길이는 Network Byte Order로 저장된다. 위 예제에서는 1 byte이므로 사실 byte order가 뭐든지 간에 상관이 없긴 하지만, 일반적으로 생각해보면 packet 내의 모든 binary integer들은 Network Byte Order로 저장돼야 할 것이다.
  • sendall()같은 함수를 사용해서 보내고자 하는 데이터 전부가 확실하게 전송시킬 수 있을 것이다. 여러 번의 send() 호출을 할 가능성이 있더라도 말이다.
  • 받는 쪽에서도 마찬가지다. Partial packet을 받을 가능성이 있는 것이다(한 번의 recv() 호출로 Benjamin"18 42 65 6E 6A"만을 받을 수도 있다). 그렇다면 여러 번 recv()를 호출해서 데이터를 전부 받아와야 한다. 어떻게?
  • Packet의 첫 머리에 적혀있는 packet 길이를 참고하면 될 것이다. 또한 Packet의 최대 길이가 1+8+128 = 137 byte라는 것도 도움이 된다.
  • 좀 더 구체적으로, 두 가지 방법을 생각해볼 수 있다.
    • 모든 packet의 시작은 packet 길이임을 이용해서, 첫 recv() 호출을 통해 packet 길이를 얻는다. 그리고는 그 길이만큼의 데이터를 얻을 때 까지 계속 recv()를 호출하는 것이다.
    • 이 방법의 장점은 하나의 packet을 받기 위해 충분히 큰 buffer 하나만 있으면 된다는 것이고, 단점은 최소 두 번의 recv() 호출이 필요하다는 것이다.
    • 두 번째 방법으로는 한 번의 recv() 호출로 데이터를 받아서 그 길이가 packet의 최대 길이만큼을 받았는지 체크하는 것이다. 얼마나 데이터를 받았든지간에 일단 buffer에 추가하고, packet 데이터가 완전히 들어왔는지 검사하면 된다. 물론 그 다음 packet의 일부 데이터가 들어올 수 있으므로, 이를 위한 buffer의 여분 공간이 있어야 할 것이다.
  • 두 번째 방법에 대한 자세한 설명은 생략한다. 원문을 참조바란다.

7.6. Broadcast Packets - Hello, World!

  • UDP와 표준 IPv4를 기반으로, broadcasting 이라는 기법을 통해 데이터를 여러 host에 동시에 보낼 수 있다. TCP는 안 된다.
  • IPv6의 경우 broadcasting은 지원되지 않지만, 그보다 상위 기법인 multicasting 을 통해서 해결할 수 있다.
  • Socket 옵션 SO_BROADCAST를 설정하고 보내면 된다.
  • Broadcast packet을 받는 시스템은 들어오는 데이터가 어느 포트로 들어오는지를 알기 위해 (즉, broadcast packet이 맞는지 알기 위해) 들어오는 모든 데이터를 까봐야한다. 그리고나서 broadcast packet이 맞다면 이를 사용하고, 아니면 버린다. 어찌됐든 컴퓨터에겐 쓸데없이 많은 작업이 필요하게 된다. 게임 Doom이 처음 나왔을 때, 네트워크 코드때문에 많은 불평불만이 있었다고 한다.
  • Broadcasting을 위한 목적지 주소는 두 가지가 있다.

    • 첫 번째 방법으로, 특정 subnet의 broadcast 주소로 보낸다. 이 주소는 subnet의 host 부분을 모두 1로 정했을 때의 주소이다. 예를 들어, 우리 집 network가 192.168.1.0 이라면, 나의 netmask는 255.255.255.0 가 된다. 그러면 나의 broadcast 주소는 192.168.1.255가 된다.
    • Unix 계열 시스템에서는, ifconfig 명령어를 통해 우리가 원하는 모든 정보를 알 수 있다.
    • Broadcast 주소를 얻는 로직은 network_number OR (NOT netmask) 이다.
    • Local network 뿐만 아니라 remote network에도 똑같이 broadcast packet을 보낼 수 있다. 하지만 목적지의 router에서 packet이 전달되지 못하고 떨어져나갈 (being dropped) 위험이 생긴다.
    • 두 번째 방법으로, "global" broadcast 주소로 보낼 수도 있다. 이 주소는 255.255.255.255로, INADDR_BROADCAST라고 불린다.
    • 모순스럽게도 이러한 broadcast packet은 당신의 local network 바깥으로 나갈 수가 없다.
  • 만약 SO_BROADCAST socket 옵션을 주지 않고 데이터를 보내려고 한다면 어떻게 될까? 앞서 만들었던 talker와 listener를 사용해보겠다.

$ talker 192.168.1.2 foo
sent 3 bytes to 192.168.1.2
$ talker 192.168.1.255 foo
sendto: Permission denied
$ talker 255.255.255.255 foo
sendto: Permission denied
  • SO_BROADCAST 를 주지 않았더니 결과가 영 좋지 않다.
  • 실제로 broadcast를 하느냐 못 하느냐의 차이는 오직 이 옵션을 주었느냐에 달려있다. 원문의 broadcaster.c 파일을 참조하자. 이 파일은 옵션을 추가로 준 것 이외에 어떤 점도 변한 것이 없다!
  • 이제 기존 UDP listener를 가동해놓고 다른 쪽에서 broadcaster 를 사용해보면, 아까 전송에 실패했던 데이터를 보낼 수 있다.
$ broadcaster 192.168.1.2 foo
sent 3 bytes to 192.168.1.2
$ broadcaster 192.168.1.255 foo
sent 3 bytes to 192.168.1.255
$ broadcaster 255.255.255.255 foo
sent 3 bytes to 255.255.255.255
  • 그리고 listener 에서 실제로 packet을 받았는지 확인해야 한다. (실패했다면, listener 가 IPv6에 묶여있는 것은 아닌지 확인해보자. listener.c 파일에서 AF_UNSPEC 대신 AF_INET으로 바꿔주면, IPv4에 묶이도록 강제할 수 있다).
  • 이제 같은 network에서 기기 한 대를 더 띄우고, 똑같이 listener 가 broadcast packet을 받을 수 있는지 확인해보자.
  • 만일 일반적인 통신로는 데이터 수신이 되지만 broadcast로는 데이터를 받을 수 없다면, local 기기의 방화벽이 문제가 되는 것은 아닌지 확인해보자.
  • Broadcast 할 때에는 조심하기를 다시 한 번 당부한다. LAN 위의 모든 기기는 recvfrom() 하든지 말든지 broadcast packet을 받아서 처리하도록 강요되기 때문이다. 그래서 전체 network의 계산 능력을 저하시킬 수 있다.
  • They (Broadcasts) are definitely to be used sparingly and appropriately.




반응형