Jungle

[TIL] C 네트워크 프로그래밍 - 소켓 생성하기

손가든 2023. 11. 21. 10:55

웹서버를 직접 구축해보자.

 

핀트OS를 시작하기 전에 웹 서버를 직접 만들어 보고 네트워크의 구성 요소와 어떻게 네트워크 방식이 가능한 것인지 확인해보겠다.

 

6주차 발제




네트워크 프로그래밍을 위해 코드 속 함수들을 이용하여 어떻게 네트워킹하는지 이해하고 echo 서버와 클라이언트를 통신시키기 위해 소켓들을 생성해보자.

hostinfo.c 코드 이해하기

먼저 이 파일에서는 실행 시 도메인 주소를 받아, 해당 도메인의 ip를 출력해주는 프로그램이다.

 

코드는 다음과 같다.

 

#include "../csapp.h"
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>

int main(int argc, char **argv) {
    struct addrinfo *p, *listp, hints;
    char buf[MAXLINE];
    int rc, flags;

    // if (argc != 2) {
    //     fprintf(stderr, "usage: %s <domain name>\n", argv[0]);
    //     exit(0);
    // }

    argv[1] = "naver.com";
    memset(&hints, 0, sizeof(struct addrinfo));
    hints.ai_family = AF_INET;
    hints.ai_socktype = SOCK_STREAM;
    if ((rc = getaddrinfo(argv[1], NULL, &hints, &listp)) != 0) {
        fprintf(stderr, "getaddrinfo error: %s\n", gai_strerror(rc));
        exit(1);
    }
 
    flags = NI_NUMERICHOST;
    for (p = listp; p; p = p->ai_next) {
        Getnameinfo(p->ai_addr, p->ai_addrlen, buf, MAXLINE, NULL, 0, flags);
        printf("%s\n", buf);
    }

    freeaddrinfo(listp);

    exit(0);
}

 

 

이 코드는 직접 실행하지 않고 디버깅으로 해당 인자를 파악하기 위해 argv[1] = "naver.com"를 직접 대입했다.

 

만약 터미널에서 실행할 경우

$ ./hostinfo www.naver.com

 

로 실행 시 argv 이중 포인터에 ./파일 실행 이후 개행 값들이 대입된다.

 

위처럼 실행할 경우 argv[1] = "naver.com" 이 main문의 인자로 들어갈 것이다.

 

 

아무튼 위 hostinfo.c 내의 구동 방식을 크게 한번 확인해보자.

 

1. memset로 hints 플래그 초기화 이후 특정 플래그 설정

 

이 코드로 인해서 아래에서 getaddrinfo()의 출력될 인자인 'listp linkedList' 안의 소켓 주소들을 제어할 수 있다.

 

일단 이 listp에 대한 설명은 getaddrinfo() 함수에서 얘기해보고 hints 설정 코드를 보면

 

hints.ai_family = AF_INET : 이 코드를 통해 도메인의 IP 리턴값을 IPv4의 IP로 제한한다.

 

hints.ai_socktype = SOCK_STREAM :  이 설정은 해당 소켓 통신을 TCP 소켓 스트림 형식으로 하겠다는 뜻이다. 만약 UDP 통신이라면 SOCK_DGRAM 으로 설정하여 데이터그램 설정을 사용한다.

 

 

 

2. getaddrinfo(const char *host , const char *service , const struct addrinfo *hints, struct addrinfo **result) 함수

 

이 함수를 이해하느라 시간을 너무 많이 썼다.

 

실제 이 함수는 netdb.h 헤더파일에 내장되어 있고, 이 파일의 코드를 확인하려니까

찾을 수 없었다..

(ChatGPT도 모르는데, 이는 시스템 콜 단위라서 볼 수 없거나 회사 내부 비공개 코드인 듯 했다. 코드 내부 아시는 분은 댓글 바랍니다.)

 

 

아무튼 해당 코드의 1~3번 인자는 입력인자, 4번 인자는 출력인자라고 생각하면 된다.

 

보통 함수는 return을 통해서 반환했는데, 이런식으로 입력으로 리스트를 넣고 리스트를 채우는 식으로 반환을 진행하는 것이 흥미로웠다.

 

해당 코드에서는 `getaddrinfo(argv[1], NULL, &hints, &listp)` 이렇게 인자들을 채워 사용하는데,

 

첫번째 인자에는 도메인 문자나 호스트 이름을 넣고,

 

두번째 인자에는 서비스 이름 혹은 10진수의 포트 번호 문자열을 넣는다.

 

세번째 인자에는 hints의 값을 넣어서 결과 리스트인 listp속 각 구조체에 소켓 IP 설정을 채워준다.

 

네번째 인자는 최종적으로 생성한 IP주소들이 들어있는 구조체의 연결 리스트를 제작한다.

 

이 함수의 목적은 최종적으로 listp 연결 리스트 안에 각각 들어있는 요소들인 구조체가 ai_addr 와 hints 설정을 가지고 있는데

그 구조체들을 이용하여 통신을 진행할 수 있게 하기 위함이다.

 

이 getaddrinfo() 함수를 통해서 IP의 버전과 도메인의 표현방식, 서비스의 표현 방식이 다른 것에 따라

각각 hints 설정을 바꾸어 원하는 소켓 연결을 진행할 수 있게 해준다.

 

*getaddrinfo의 동작원리

 

이 함수가 대체 도메인 문자만 보고 DNS 서버가 알고 있는 IP주소를 어떻게 출력해주는가? 에 대한 궁금증 때문에 동작 원리를 파 보았다.

 

getaddrinfo의 동작원리는 4단계로 진행된다.

 

1. 로컬 캐시 확인 : 이전에 성공적으로 해결한 호스트명에 대한 정보가 캐시에 들어있는지 확인한다.

 

이때 만약 캐시에 정보가 있다면 이후에 있을 DNS 통신을 진행하지 않고 4번을 진행한다.

 

2. 로컬 설정파일 확인 : 로컬의 etc/hosts 과 같은 로컬 설정 파일에서 함수에서 요청받은 호스트명에 대한 추가적인 정보를 찾아낸다.

 

3. DNS 서버와 네트워크 통신 : 1~2번에서 원하는 수행에 실패하면 DNS와 네트워크 통신하여 host의 IP 주소를 받아온다.

 

만약 이 3번을 거친다면 통신을 진행하기에 시간이 비교적 오래 걸릴 것이다.

 

4. **result 안의 linkedlist속 구조체에 host의 정보를 담아 반환한다.

 

 

더보기

+++ 왜 host가 하나인데 ip를 구조체의 연결리스트에 여러개로 저장하는가?

 

이는 결론부터 말하자면 해당 도메인에 여러개의 서버를 두었기 때문이다.

 

이는 부하 방지, 특정 서버 다운 시 전환을 통한 예방 , 네트워크 장애 대응 을 위한 해당 웹 서비스측의 대비책이라고 할 수 있다.

 

 

3. getnameinfo()

 

이 함수에 대해서는 간단히 작동 방식만 설명하겠다. (정확한 동작 방식을 이해하지 못했다.. 위와 같은 이유로)

 

flags = NI_NUMERICHOST;
    for (p = listp; p; p = p->ai_next) {
        Getnameinfo(p->ai_addr, p->ai_addrlen, buf, MAXLINE, NULL, 0, flags);
        printf("%s\n", buf);
    }

 

먼저 플래그를 NI_NUMERICHOST 로 설정하여 원래는 도메인 이름을 리턴하는 기본 설정에서 IP 숫자 주소를 리턴하도록 한다.

 

이후 for문으로 listp 연결리스트를 순회하며 IP의 정보를 buf에 담고, print로 해당 IP를 출력하게 한다.

 

 

이는 clientfd 소켓을 생성하는 함수인 open_clientfd()에서 비슷한 동작을 수행할텐데

 

이 방식으로 for문을 돌며 printf 대신 connect를 수행하여 성공 시 소켓을 return 하도록 한다.

 

 


 

open_clientfd() 함수 동작 이해하기

 

에코 서버를 이해하기 전에 간단히 해당 함수의 전체적인 구동 방식만 파악해보자.

 

/* $begin open_clientfd */
int open_clientfd(char *hostname, char *port) {
    int clientfd, rc;
    struct addrinfo hints, *listp, *p;

    /* Get a list of potential server addresses */
    memset(&hints, 0, sizeof(struct addrinfo));
    hints.ai_socktype = SOCK_STREAM;  /* Open a connection */
    hints.ai_flags = AI_NUMERICSERV;  /* ... using a numeric port arg. */
    hints.ai_flags |= AI_ADDRCONFIG;  /* Recommended for connections */
    if ((rc = getaddrinfo(hostname, port, &hints, &listp)) != 0) {
        fprintf(stderr, "getaddrinfo failed (%s:%s): %s\n", hostname, port, gai_strerror(rc));
        return -2;
    }
  
    /* Walk the list for one that we can successfully connect to */
    for (p = listp; p; p = p->ai_next) {
        /* Create a socket descriptor */
        if ((clientfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) < 0) 
            continue; /* Socket failed, try the next */

        /* Connect to the server */
        if (connect(clientfd, p->ai_addr, p->ai_addrlen) != -1) 
            break; /* Success */
        if (close(clientfd) < 0) { /* Connect failed, try another */  //line:netp:openclientfd:closefd
            fprintf(stderr, "open_clientfd: close failed: %s\n", strerror(errno));
            return -1;
        } 
    } 

    /* Clean up */
    freeaddrinfo(listp);
    if (!p) /* All connects failed */
        return -1;
    else    /* The last connect succeeded */
        return clientfd;
}
/* $end open_clientfd */

 

 

위에서 수행한 것을 제외하고 특별히 수행하는 것을 확인하며 해당 함수가 무엇을 수행하고, 또 어떤 값을 return하는지 파악해보자.

 

hints 모드 설정에서 `hints.ai_flags = AI_NUMERICSERV` 와 함께 or 연산자를 이용하여 `hints.ai_flags |= AI_ADDRCONFIG`가 추가되었다.

 

이는 각각 서비스 인자에 포트번호를 받겠다는 설정 / 로컬 호스트가 IPv4로 설정된 경우에만 IPv4 주소를 리턴하도록 하는 설정이다.

 

여기서는 따로 아까 해주었던 AF_INET을 설정하지 않았는데, 사실 이 family 설정값이 0일 경우(기본값) AF_UNSPEC 으로 설정되어 v4와 v6 중 어떤 버전의 주소 체계든 사용 가능하도록 한다고 한다. (그럼 왜 설정해줄까? 에 대해서 궁금증을 가졌지만 아시는 분은 댓글 바랍니다.)

 

이후 for문으로 생성한 listp를 순회하는데, 먼저 소켓을 생성하여 clientfd에 대입한다.

 

소켓 함수는 socket.h 에 선언된 함수이다.

 

 

인자로 도메인 , type , protocol을 받는다고 되어있다.

 

해당 함수는 우리 코드에서는 family 필드와 socktype , 마지막으로 protocol을 대입하고 있는데

 

socktype를 제외하고는 기본값을 대입하여 나는 빈 소켓을 생성했다고 이해했다.

 

해당 사진을 보면 프로토콜이 0이라면 자동으로 적절한 프로토콜을 선택한다는 것을 알 수 있다.

 

 

아무튼 소켓을 생성 한 뒤 connect함수를 통해 해당 clientfd에 서버의 통신을 요청한다.

 

해당 통신이 완료된다면 listp는 이제 필요없으므로 반환처리를 해주고 통신이 가능해진 clientfd 소켓을 반환한다.

 

 

 

결론적으로 이 open_clientfd() 함수는

 

client단에서 연결할 포트번호와 서버 IP를 선택하여 getaddrinfo를 사용한 뒤

 

해당 포트번호로 서버가 통신이 가능하다면,

 

연결 가능한 client 소켓을 반환해주는 역할을 한다고 이해할 수 있었다.

 


 

open_listenfd() 함수 동작 이해하기

 

이 함수는 서버쪽에서 통신 소켓을 생성하기 위한 목적을 가진다.

 

open_clientfd()와 비슷하게 hint를 설정한 뒤 getaddrinfo로 리스트를 생성한 뒤

 

순회하며 생성한 소켓을 bind()함수를 이용하여 소켓에 ai_addr 속 서버 소켓 주소를 묶는다.

 

이후 listen 함수를 사용하여 클라이언트가 열어둔 포트로 연결 요청(connect()함수)을 할 수 있는 상태로 만든다.

 

/* $begin open_listenfd */
int open_listenfd(char *port) 
{
    struct addrinfo hints, *listp, *p;
    int listenfd, rc, optval=1;

    /* Get a list of potential server addresses */
    memset(&hints, 0, sizeof(struct addrinfo));
    hints.ai_socktype = SOCK_STREAM;             /* Accept connections */
    hints.ai_flags = AI_PASSIVE | AI_ADDRCONFIG; /* ... on any IP address */
    hints.ai_flags |= AI_NUMERICSERV;            /* ... using port number */
    if ((rc = getaddrinfo(NULL, port, &hints, &listp)) != 0) {
        fprintf(stderr, "getaddrinfo failed (port %s): %s\n", port, gai_strerror(rc));
        return -2;
    }

    /* Walk the list for one that we can bind to */
    for (p = listp; p; p = p->ai_next) {
        /* Create a socket descriptor */
        if ((listenfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) < 0) 
            continue;  /* Socket failed, try the next */

        /* Eliminates "Address already in use" error from bind */
        setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR,    //line:netp:csapp:setsockopt
                   (const void *)&optval , sizeof(int));

        /* Bind the descriptor to the address */
        if (bind(listenfd, p->ai_addr, p->ai_addrlen) == 0)
            break; /* Success */
        if (close(listenfd) < 0) { /* Bind failed, try the next */
            fprintf(stderr, "open_listenfd close failed: %s\n", strerror(errno));
            return -1;
        }
    }


    /* Clean up */
    freeaddrinfo(listp);
    if (!p) /* No address worked */
        return -1;

    /* Make it a listening socket ready to accept connection requests */
    if (listen(listenfd, LISTENQ) < 0) {
        close(listenfd);
	return -1;
    }
    return listenfd;
}
/* $end open_listenfd */

 

listenfd 함수에서의 특징은 

 

hints.ai_flags = AI_PASSIVE | AI_ADDRCONFIG;

 

이부분이다.

 

AI_PASSIVE를 통해 해당 소켓 주소는 듣기 전용으로 만든다.

 

이는 서버를 듣기 전용 소켓으로 만들기 위한 작업이라고 할 수 있다.

 

이후 

rc = getaddrinfo(NULL, port, &hints, &listp)

 

를 통해 소켓과 묶을 리스트를 생성한다.

 

서버의 host는 주체적으로 연결요청을 하지 않기 때문에 NULL로 둔다.

 

port는 해당 연결을 받을 포트를 설정한다. 이후 클라이언트는 이 포트를 통해 열려있는 이 서버로 connect() 요청을 할 수 있다.

listenfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)

 

서버 소켓을 생성하더라도, 똑같은 방식이라는 것에 유의하자.

/* Eliminates "Address already in use" error from bind */
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, //line:netp:csapp:setsockopt
(const void *)&optval , sizeof(int));

 

이 코드를 bind와 소켓 생성 사이에 두었는데, 이 코드에 대한 자세한 설명은 책에 나오지 않았다.

 

서버의 소켓이 bind()함수에서 오류를 발생했더라도 시도한 캐시가 완전히 삭제되지 않아 다음 리스트와의 bind에서 오류를 발생시키는 경우가 존재한다고 한다.

 

따라서 이 코드를 통해 이전 요청에 대한 정보를 삭제함으로써, 다음 bind 함수가 독립적으로 수행될 수 있게 한다.

bind(listenfd, p->ai_addr, p->ai_addrlen)

 

이후 이 코드를 통해 ai_addr의 소켓 주소 정보와 서버의 listenfd 소켓을 연결한다.

listen(listenfd, LISTENQ)

 

마지막으로 bind에 성공하면 listen() 함수를 통해 소켓을 연결 요청을 받을 수 있는 상태로 만든다.

 

LISTENQ는 1개의 클라이언트와 연결 중에 다른 클라이언트가 connect() 요청을 보낼 시 무시하지 않고 해당 LISTENQ 크기의 큐에 대기시키도록 한다.

 

책에서는 1028의 크기로 설정한다.