나만의 웹서버 구현해보기!

승톨·2021년 2월 13일
13
post-thumbnail

오늘은 'SW 아카데미 정글'에서 진행한 웹 서버 제작 프로젝트에 대해 정리해보려고 한다. 평소에 웹 서버가 어떻게 만들어졌는지 궁금한 분들이 계셨다면 잘 읽어주셨으면 좋겠다.

Why Server?

  • 본격적으로 프로젝트를 설명하기 전에, 시스템 디자이너 관점에서 왜 웹서버를 만들었는지 설명해보고자 한다.

웹 서버를 이야기하기 위해선 먼저 네트워크와 클라이언트-서버에 대해 알고 넘어가야 한다. 아래에서 간단하게 클라이언트-서버 모델을 살펴보자.

  • 현대의 모든 네트워크 애플리케이션은 클라이언트-서버에 기초하고 있다.
  • 애플리케이션은 한개의 서버 프로세스와 한 개 이상의 클라이언트 프로세스로 구성된다.
  • 서버는 일부 리소스를 관리하고, 이 리소스를 조작해서 클라이언트를 위한 서비스를 제공한다.(디스크 파일 관리, 클라이언트 로직 실행)
  • 클라이언트는 서버에 필요한 요청을 보내는 역할을 한다. 예를 들어 웹 브라우저가 파일을 필요로 할 때 서버로 요청을 보낸다.
  • 서버는 받은 요청을 조작해 응답을 클라이언트로 보낸다.
  • FTP이든, 이메일이든, 인터넷이든 현대의 네트워크는 모두 클라이언트 서버 모델에 기반하고 있기 때문에 우리가 internet을 구축하기 위해선 서버가 필요하다.
  • Web이라는건 글로벌 IP Internet이라는, internet을 구현한 네트워크 위에서 돌아간다고 볼 수 있는데, 그 기저에 internet이 있기 때문에 우리는 internet에 대해 간략하게 알아볼 필요가 있다.
  • internet은 LAN - WAN 기반 내에서 TCP/IP 소프트웨어를 거쳐 호스트-호스트 간 통신을 구현한 네트워크이다.
    • 아래 이미지는 internet 위에서 호스트(ex.내 컴퓨터)에서 다른 호스트(ex.옆집 컴퓨터)로 데이터가 어떻게 이동하는지를 설명한 그림이다.
    • 하나의 호스트가 프로토콜 소프트웨어를 통해 다른 호스트에 보내는 걸 '패킷'이라고 하며, 패킷에는 데이터와 어디로 보낼지를 담은 라우팅 데이터를 같이 담아 보낸다.

  • 호스트와 호스트는 서로 통신을 위해 '소켓'이라는 인터페이스를 사용한다.
  • 소켓은 클라이언트 호스트와 서버 호스트의 연결 끝단(endpoint)을 의미하며, 인터넷 주소와 16비트 정수 포트로 이루어진 소켓 주소를 가지고 있다.(address:port) 우리는 두 개의 소켓 주소와 매칭하여 연결을 완료할 수 있다.
    • 참고로 port는 통신을 위한 식별자 역할을 하기 때문에 이미 잘 알려진 서비스 프로토콜과 연관되어있는 경우가 많다. 대표적으로 웹의 HTTP는 80포트, HTTPS는 443 포트를 가지고 있다.
  • 위의 internet 위에 엄청나게 많은 호스트들이 존재하는 네트워크가 우리가 아는 '인터넷'(Internet)이라고 볼 수 있다.
    • Internet을 네트워크 관점에서보면 다음과 같다.
    • 호스트 = 32비트 IP 주소 집합
    • 우리가 보통 ip라고 부르고, 128.2.210.175 라고 알고 있는게 다 IP주소라고 볼 수 있다.
    • IP주소 집합 = Internet Domain Name이라는 식별자 집합
    • 위의 IP주소를 사람들이 알아보기 쉽게 변환한것이 domain name이다.
    • 예를 들어 [www.twitter.com](http://www.twitter.com) 같은 것이 있다.
    • domain name에는 first, second, third라는 계층 구조가 존재한다. 관심이 있으면 좀 더 공부해보기 바란다.

Why Web Server?

  • 위에서 간략하게 인터넷에 대해 알아보았으니, 이제 왜 웹 서버가 필요한지 좀 더 살펴보자.
  • 인터넷이라는 네트워크를 가지고 무언가 통신할 수 있는 건 알겠는데, 이제 무엇을 주고 받고 어떻게 주고받을지에 대한 규약이 필요하다. 그걸 위해 만들어진게 WWW와 HTTP라고 볼 수 있다.
  • WWW는 World Wide Web의 약자로서 Internet을 통해 웹 리소스를 주고 받는 시스템이라고 볼 수 있다.
    • 이 때, 데이터를 보여주고, 받아오는 클라이언트를 '웹 브라우저'라고 부르며,
    • 요청데이터를 처리하고 다시 클라이언트에 보내주는 서버를 '웹 서버'라고 부른다.
  • HTTP는 Hyper Text Transfer Protocol의 약자로서 애플리케이션 레이어 프로토콜이다.(레이어 개념은 추후 다뤄보겠다.)
    • 따라서, 웹브라우저(클라이언트)가 HTTP 요청을 보내면 웹서버는 요청을 받아 HTTP 응답을 다시 클라이언트에게 보낸다.
  • 결론을 내보면, 우리가 Internet 위에서 웹 브라우저 요청에 의해 어떤 데이터를 서빙하기 위해선 '서버'가 필요하기 때문에 웹 서버를 만들어야한다고 볼 수 있다.
  • 이제 대략, 왜 웹서버가 필요한지 알게 되었을 것이다. 이제 웹 서버 구현 단계로 넘어가보자.

사전 지식

아래는 웹 서버를 구현하기 위해 필요한 개념들이다. 여기 나오는 개념들은 간략하게 몇 줄로만 끝나기에는 하나하나가 모두 깊이를 가지고 있다.

그렇기 때문에 필요한 개념들은 직접 더 찾아보고 공부를 하면 좋을 것 같다.

보통 TCP를 활용한 네트워크 애플리케이션은 아래 그림과 같이 구성된다.

https://docs.oracle.com/cd/E19683-01/816-5042/images/7099.epsi.gif

출처 : oracle

대부분의 네트워크 애플리케이션이 위의 플로우와 유사하기 때문에 중요한 것 위주로 뜯어보겠다.

소켓 인터페이스

  • 위의 그림에서 보여주듯이 네트워크 구성을 위해서는 소켓 인터페이스 구현이 필요하다. 구성은 아래와 같다.
    소켓 주소 구조체(Socket Address Structure, SA)

  • SA에는 연결을 위해 프로토콜에 특화된 주소를 저장한다.

  *struct sockaddr_in {
  	uint16_t sin_family; 
    /** IP 타입을 의미한다. 대개 IPv4를 사용하기 때문에 AF_INET을 사용한다고 볼 수 있다.**/
    	uint16_t sin_port; 
        /** 포트 넘버를 의미한다. network byte order로 저장된다. **/
        struct in_addr sin_addr; 
        /** IP 주소를 의미한다. network byte order로 저장된다. **/
        unsigned char sin_zero[8]; 
        /** sin_family로 들어오는 인자가 다를 수 있으니 사이즈를 일괄적으로 맞추기 위한 pad라고 보면 된다.  **/
        };*

sin_zero 란? : 해당 을 참고하기 바란다.

SA는 void로 받는다던데?

  • 소켓 주소 구조체는 시대가 흘러가면서 종류가 더 다양해졌기 때문에, 네트워크 입장에선 어떤 종류의 소켓 주소 구조체라도 받아들일 수 있어야했다.
    (sin_family가 IPv4인지 IPv6인지 등등)
  • 따라서, ANSI C가 정립된 이후부터는 소켓 주소 구조체를 (void *)로 캐스팅해서 사용하기 시작했다.

socket 함수

클라이언트와 서버가 소켓 식별자를 생성하기 위한 함수이다.

왜 소켓 식별자인가? = UNIX 프로그래밍의 관점에서, 소켓은 식별자를 가진 파일의 일종이다. 네트워크는 CPU 입장에서 파일 같이 처리되기 때문에, 하나의 unique한 통신을 위해 파일의 형태를 띈 매개체가 필요하다. 소켓이 그 역할을 한다고 보면 된다.

int socket(int domain, int type, int protocol);

예시 : clientfd = Socket(AF_INET, SOCK_STREAM, 0);

이렇게 리턴된 clientfd 는 아직 열리기만하고, 읽거나 쓰는 작업을 할 수 없다.

connect 함수

 int connect(int clientfd, const struct sockaddr *addr, socklen_t addrlen);

해당 함수는 클라이언트 프로세스 입장에서 socket 함수를 통해 open한 소켓 식별자를 가지고 서버와의 연결을 시도하는 함수이다.

  • addrlen 은 addr의 길이이며, addr을 식별하는데 사용된다.

소켓 주소 addr의 서버와 internet 연결을 시도하며, 연결이 성공할 때까지 블록되어 있는다.
연결이 성공하면, 소켓 식별자는 데이터를 주고받을 준비가 되며, 하나의 소켓 페어를 가진다.

(x:y, addr.sin_addr:addr.sin_port)

  • x는 클라이언트의 IP 주소를 의미하며, y는 클라이언트 호스트의 포트를 의미한다.

bind 함수

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • 해당 함수는 서버 프로세스 입장에서, 클라이언트와의 연결 수립 전 서버 쪽의 소켓 주소(포트 번호)를 소켓 식별자 sockfd 와 매핑 시키는 함수이다.

  • 예를 들어, 우리가 만든 모든 소켓이 5000 이라는 동일한 포트 번호를 사용하게 된다면, internet을 통해 5000 포트로 데이터를 받을 때 어떤 5000 소켓으로 처리해야 하는지 결정할 수 없는 문제가 발생할 수 있다.

  • 그렇기 때문에 우리는 소켓이 중복된 포트 번호를 사용하지 않도록, 내부적으로 포트 번호와 소켓 연결 정보를 묶어 하나의 unique한 소켓을 만든다.

listen 함수

int listen(int sockfd, int backlog);
  • 서버가 클라이언트의 연결 요청을 대기할 수 있는 상태로 만들어주는 함수이다.
  • listen 을 호출하면, 서버가 만든 소켓은 클라이언트의 연결 요청을 승락할 수 있는 listening socket 으로 변환된다. 인자로 받는 sockfd 는 우리가 bind시킨 sockfd 라고 보면 된다. 다수의 클라이언트가 하나의 서버 소켓으로 연결 요청을 할 수 있기 때문에 서버는 내부적으로 Queue를 만든다.
  • backlog 인자는 큐에 저장해야 하는 연결 요청 capacity라고 할 수 있다. 대개 이 값은 1024와 같은 큰 값으로 설정된다.

accept 함수

int accept(int listenfd, struct sockaddr *addr, int *addrlen);
  • accept 함수를 호출하면 서버는 listen 함수에서 만든 listening socket 을 가지고 클라이언트로부터의 연결 요청이 도달할 때를 기다린다.

  • 이때 쓰는 서버쪽의 식별자를 listenfd 라고 한다.

  • 이 시점에 대기 queue에 쌓여있던, clientfd 를 꺼내 인자 addr 내에 클라이언트 소켓 주소를 채우고, 클라이언트와 통신을 위해 사용하는 새로운 식별자인 connfd 를 만들어 리턴한다.
    여기서 헷갈릴 수 있는 부분이 하나 있는데 서버는 이제 listenfd 라는 식별자와 connfd 라는 2개의 식별자를 가진다는 것이다.

  • 차이점을 설명해보자면,

    • listenfd 는 계속 클라이언트 연결 요청에 대한 엔드포인트 역할을 해야하는 식별자이다. 한번만 만들어지며, 서버가 살아있는 동안 계속 존재한다.
    • connfd 는 요청을 한 클라이언트와 서버 사이의 연결을 위해 만든 식별자이다. 서버가 연결 요청을 수락할 때마다 생성되며, 해당 클라이언트와 연결을 종료하면 사라진다.

즉, 서버는 listenfd 를 통해 클라이언트 연결을 계속 받으며, connfd를 통해 요청한 클라이언트와의 개별 연결을 수립한다고 보면 된다.
(보통 fork() 시스템 콜로 동일한 connfd 를 가진 자식 프로세스를 만들어서 클라이언트 요청 내용을 수행한다. )

아래 그림을 보면 좀더 이해가 쉬울 것이다.

그림에서 listenfd 괄호 안에 있는 숫자는 식별자 번호이다. 식별자 3이 연결 요청을 받으면, accept 함수는 식별자 4라는 connfd 를 오픈하고, 연결을 수립한다.

getaddrinfo 함수

int getaddrinfo(
	const char *host, 
	const char *service,
	const struct addrinfo *hints,
    	struct addrinfo **result
   );
   void freeaddrinfo(struct addrinfo *result);

리눅스에서 사용하는 함수로서 우리가 인자로 받는 호스트 이름, 호스트 주소 등등을 매칭되는 IP 주소와 포트로 찾아 변환해주는 함수라고 생각하면 된다.

소켓 주소 구조체 리스트를 반환하는데, 여기서 반환하는 것은 앞에서 설명한 SA라고 이해하면 된다.

예시 : getaddrinfo("www.example.com", NULL, NULL, &result);

여기서 최종적으로 반환하는 것은 result 라고 보면 된다. result 는 소켓 주소 구조체를 가리키는 addrinfo 구조체 리스트를 가리키는 포인터이다. 아래 그림을 보면 이해가 잘 될 것이다.

클라이언트는 getaddrinfo 를 호출해서 result 리스트를 방문해, 각 소켓 주소와 연결이 성립할 때까지 시도한다. 사용이 끝나면 freeaddrinfo 를 통해 메모리를 반환한다.

참고로, getaddrinfo 에서 사용하는 addrinfo 구조체는 아래와 같다.

    struct addrinfo{
    	int     ai_flags;               /* 입력 플래그 (AI_* 상수) */
        int     ai_family;              /* 주소 패밀리 : AF_INET, AF_INET6 */ 
        int     ai_socktype;            /* 종류 : SOCK_STREAM, SOCK_DGRAM */
        int     ai_protocol;            /* 소켓 프로토콜 */
        size_t  ai_addrlen;             /* ai_addr 이 가르키는 구조체 크기 */
        char *  ai_canonname;           /* 공식 호스트 명 */
        struct  sockaddr *ai_addr;      /* 소켓 주소 구조체를 가르키는 포인터 */
        struct  addrinfo *ai_next;      /* 연결 리스트에서 다음 구조체 */
   	}; 

getaddrinfo 에 관한 추가적인 정보는 해당 을 참고하기 바란다.


이제 대략적인 개념을 이해했으니, 본격적으로 프로그램을 짜볼 것이다. 우선, 클라이언트가 서버와 연결을 설정하는 함수인 open_clientfd 함수를 만들어보자.

open_clientfd

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

    /* potential server addresses 리스트 설정 */
    memset(&hints, 0, sizeof(struct addrinfo));
    hints.ai_socktype = SOCK_STREAM;  /* 연결 설정 */
    hints.ai_flags = AI_NUMERICSERV;  /* port를 숫자로 받기. */
    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;
    }
  
    /* 리스트 돌면서 연결 요청하기 */
    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 만들기 실패하면, 다시 */

        /* Connect to the server */
        if (connect(clientfd, p->ai_addr, p->ai_addrlen) != -1) 
            break; 
        if (close(clientfd) < 0) { /* 연결 닫기 */
            fprintf(stderr, "open_clientfd: close failed: %s\n", strerror(errno));
            return -1;
        } 
    } 

    /* 구조체 정리 */
    freeaddrinfo(listp);
    if (!p) /* All connects failed */
        return -1;
    else    /* The last connect succeeded */
        return clientfd;
}

getaddrinfo 를 위해 addrinfo 세팅을 하고, 함수를 호출해 addrinfo 구조체 리스트를 반환한다. 이 리스트를 탐색하며, client socket을 만들고 connect를 시도한다. 만약 connect가 실패하면, socket을 닫는다.

connect가 성공하면 메모리를 반환하고, clientfd를 리턴한다.

open_listenfd

다음은, 서버가 연결을 받기 위한 함수인 open_listenfd 함수이다.

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

    /* potential server addresses 리스트 설정*/
    memset(&hints, 0, sizeof(struct addrinfo));
    hints.ai_socktype = SOCK_STREAM;             /* SOCK_STREAM으로 설정 */
    hints.ai_flags = AI_PASSIVE | AI_ADDRCONFIG; /* 모든 IP Address 받음 */
    hints.ai_flags |= AI_NUMERICSERV;            
    if ((rc = getaddrinfo(NULL, port, &hints, &listp)) != 0) {
        fprintf(stderr, "getaddrinfo failed (port %s): %s\n", port, gai_strerror(rc));
        return -2;
    }

    /* 리스트 탐색, 소켓 만들고 bind 실행 */
    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 */

        /* "Address already in use" error 해제 */
        setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, 
                   (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;
        }
    }

    /* 메모리 반환 */
    freeaddrinfo(listp);
    if (!p) /* No address worked */
        return -1;

    /* listen 호출 */
    if (listen(listenfd, LISTENQ) < 0) {
        close(listenfd);
	return -1;
    }
    return listenfd;
}

인자로 받은 port를 가지고 getaddrinfo 를 호출해서 반환된 result 리스트를 탐색한다.

socket과 bind 호출이 성공할 때까지 탐색한다.

중간에 나오는 setsockopt 함수는 socket options을 세팅하는 함수이다.
(여기선 " listenfd라는 소켓의 socket layer level에서 SO_REUSEADDR 옵션을 건드릴 건데 value에 담긴 1을 넣을거다." 라는 의미로 받아들이면 된다.)

마지막으로 listen을 호출해 listening socket으로 변환하고, 리턴한다.

이제 클라이언트-서버 연결을 준비하는 함수를 만들었으니, 이 함수들을 활용해 클라이언트 요청을 받는 main 함수를 만들어보자.

웹 서버인만큼 HTTP 프로토콜을 사용할 것이다.

좀 더 구체적으로 설명해보면, 클라이언트가 HTTP request를 보내면, 서버가 request를 받아 HTTP response를 다시 클라이언트에게 보내는 로직을 짤 것이다.

request에는 method, URI, version 등이 들어가며,
response에는 version, status code, status message 등이 들어간다.

그리고 클라이언트와 서버는 HTML이라는 언어로 작성된 콘텐츠를 주고 받는다.

  • 이 때, 디스크 파일(정적 콘텐츠)도 같이 제공할 수 있는데 정해진 MIME 타입 콘텐츠만 송수신 할 수 있다.
  • 또한 웹 클라이언트는 실행 파일을 서버에 전송할 수 있는데, 서버는 실행파일을 돌려서 클라이언트에게 결과를 보낸다. 실행파일이 런타임에 만든 아웃풋을 동적 콘텐츠라고 한다.
  • 우리가 아는 javascript 파일이 실행파일에 해당하며, 전통적으로 cgi 파일도 실행파일로 볼 수 있다. 이번 웹서버에서는 cgi 파일을 통해 동적콘텐츠를 처리할 것이다.

HTTP에 관련된 내용은 내용이 아주 방대해, 여기서 모두 다룰 수 없다.좀 더 찾아보길 권한다. 가장 좋은건, 이 가이드를 읽어보는 것이다.

웹서버 구현에 대한 개략적인 설명을 했으니, 실제 함수를 짜보자.

아래는 웹 서버를 위해 필요한 주요 함수 목록이다.

  • I/O 함수 : 데이터를 읽고 쓰기 위한 함수로서 Linux I/O 함수를 기반으로 가공한 wrapper 함수
    • rio_readinitb : read 버퍼를 초기화 하는 함수
    • rio_readlineb : 파일에서 텍스트 라인을 읽어 버퍼에 담는 함수
    • rio_readnb : 파일에서 지정한 바이트 크기를 버퍼에 담는다.
    • rio_writen : 버퍼에서 파일로 지정한 바이트를 전송하는 함수
  • main : 웹 서버 메인 로직
    • open_listenfd : 위에서 설명
    • op_transaction : 한 개의 HTTP 트랜잭션을 처리하는 함수
    • read_requesthdrs : request header를 읽는 함수
    • parse_uri : 클라이언트가 요청한 URI를 파싱하는 함수
    • static_serve : 정적 콘텐츠를 제공하는 함수
    • dynamic_serve : 동적 콘텐츠를 제공하는 함수
    • get_filetype : 받아야 하는 filetype을 명시하는 함수
    • client_error : 에러 처리를 위한 함수

main 함수

#include "csapp.h"
void op_transaction(int fd);
int read_requesthdrs(rio_t *rp, int log, char *method);
int parse_uri(char *uri, char *filename, char *cgiargs);
void static_serve(int fd, char *filename, int filesize, char *method);
void get_filetype(char *filename, char *filetype);
void dynamic_serve(int fd, char *filename, char *cgiargs);
void client_error(int fd, char *cause, char *errnum, 
		 char *shortmsg, char *longmsg);

int main(int argc, char **argv) 
{
    int listenfd, connfd;
    char hostname[MAXLINE], port[MAXLINE];
    socklen_t clientlen;
    struct sockaddr_storage clientaddr;
    /* Check command line args */
    if (argc != 2) {
			fprintf(stderr, "usage: %s <port>\n", argv[0]);
			exit(1);
    }
    listenfd = Open_listenfd(argv[1]);
    while (1) {
				clientlen = sizeof(clientaddr);
				connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen); 
        Getnameinfo((SA *) &clientaddr, clientlen, hostname, MAXLINE, 
                    port, MAXLINE, 0);
        printf("Accepted connection from (%s, %s)\n", hostname, port);
	op_transaction(connfd);                                             
	//echo(connfd);
	Close(connfd);                                            
    }
}

command line을 통해 넘겨받은 인자(port)로 listening socket 을 열어 연결 요청을 준비한다.(Open_listenfd)

Accept 함수를 통해 클라이언트 요청을 받아 연결을 수립한다. 연결이 되면, Getnameinfo 함수를 통해 소켓 주소 구조체를 풀어서 요청한 hostname과 port를 출력한다.

그 후 op_transaction 함수를 이용해 트랜잭션을 수행한다.

마지막으로 close 함수를 통해 연결 요청을 닫는다.

op_transaction 함수

void op_transaction(int fd) 
{
    int is_static;
    struct stat sbuf;
    char buf[MAXLINE], method[MAXLINE], uri[MAXLINE], version[MAXLINE];
    char filename[MAXLINE], cgiargs[MAXLINE];
    rio_t rio;
    int log;
    size_t n;
    /* Read request line and headers */
    Rio_readinitb(&rio, fd);
    Rio_readlineb(&rio, buf, MAXLINE);
    printf("Request headers:\n");
    printf("%s", buf);
  
    sscanf(buf, "%s %s %s", method, uri, version);       
    if ( !(strcasecmp(method, "GET")==0 || strcasecmp(method, "HEAD")==0 || strcasecmp(method,"POST")==0 ) ) {                     //line:netp:doit:beginrequesterr
        client_error(fd, method, "501", "Not Implemented", "Tiny does not implement this method");
        return;
    }                                                   
    int param_len = read_requesthdrs(&rio, log, method);
    Rio_readnb(&rio,buf,param_len); // content_length만큼 버퍼에 보내기
    /* Parse URI from GET request */
    
	  is_static = parse_uri(uri, filename, cgiargs);       
	  if (stat(filename, &sbuf) < 0) {                     
		    client_error(fd, filename, "404", "Not found", "Tiny couldn't find this file");
		    return;
	  }                                                    
	  if (is_static) { /* Serve static content */          
		if (!(S_ISREG(sbuf.st_mode)) || !(S_IRUSR & sbuf.st_mode)) { 
		    client_error(fd, filename, "403", "Forbidden",
				"Tiny couldn't read the file");
		    return;
		}
			/* connfd를 인자로 넘김. */
		static_serve(fd, filename, sbuf.st_size, method);        
	  }
	  else { /* Serve dynamic content */
		if (!(S_ISREG(sbuf.st_mode)) || !(S_IXUSR & sbuf.st_mode)) { 
			 client_error(fd, filename, "403", "Forbidden", "Tiny couldn't run the CGI program");
			return;
				}
			    // GET으로 할지 POST로 할지
	    	if (strcasecmp(method,"GET") == 0) /* connfd를 인자로 넘김. */
	        	dynamic_serve(fd, filename, cgiargs);            
	   	else 
	       	 	dynamic_serve(fd, filename, buf); // buf에 담긴걸 serve_dynamic 함수에 넘긴다.
	    }
}

한 개의 HTTP 트랜잭션을 처리하는 함수이다. Rio_readlineb 함수를 통해 request header를 사용해서 요청 라인을 읽고 분석한다.

이번에 만드는 웹서버는 GET, HEAD, POST 메소드만 지원한다. strcasecmp 함수를 활용해 다른 메소드가 들어오면 에러 메세지를 내보내게 한다.

parse_uri 로 리턴한 is_static 을 통해 클라이언트 요청을 정적 콘텐츠로 서빙해야 할지 동적 콘텐츠로 서빙해야 할지 판단한다.

정적 콘텐츠로 서빙해야 한다면, S_ISREG, S_ISREG 등을 통해 파일이 보통 파일이고, 읽기 권한을 가지고 있는지 검증한다.

동적 콘텐츠로 서빙해야 한다면, S_IXUSR 을 통해 파일이 실행권한을 가지고 있는지 검증한다. GET과 POST 메소드는 구분해서 처리한다.

client_error 함수

void clienterror(int fd, char *cause, char *errnum, 
		 char *shortmsg, char *longmsg) 
{
    char buf[MAXLINE];
    /* HTTP response headers 출력 */
    sprintf(buf, "HTTP/1.1 %s %s\r\n", errnum, shortmsg);
    Rio_writen(fd, buf, strlen(buf));
    sprintf(buf, "Content-type: text/html\r\n\r\n");
    Rio_writen(fd, buf, strlen(buf));
    /* HTTP response body 출력 */
    sprintf(buf, "<html><title>Tiny Error</title>");
    Rio_writen(fd, buf, strlen(buf));
    sprintf(buf, "<body bgcolor=""ffffff"">\r\n");
    Rio_writen(fd, buf, strlen(buf));
    sprintf(buf, "%s: %s\r\n", errnum, shortmsg);
    Rio_writen(fd, buf, strlen(buf));
    sprintf(buf, "<p>%s: %s\r\n", longmsg, cause);
    Rio_writen(fd, buf, strlen(buf));
    sprintf(buf, "<hr><em>The Tiny Web server</em>\r\n");
    Rio_writen(fd, buf, strlen(buf));
}

서버에서 에러가 발생하면 출력할 메세지를 만드는 함수이다. 에러 또한 response이기 때문에, 적절한 상태 코드와 메세지를 클라이언트에 보낸다.

read_requesthdrs 함수

int read_requesthdrs(rio_t *rp, int log, char* method) 
{
    char buf[MAXLINE];
    int len=0;
    size_t n;
    do {
        Rio_readlineb(rp,buf,MAXLINE);
        printf("%s",buf);
        if (strcasecmp(method,"POST")==0 && strncasecmp(buf,"Content-Length:",15)==0) 
	// content-length 뒤에 있는거 숫자 가져오기
            printf("buf: %s",buf);
            printf("len: %d",len);
            sscanf(buf,"Content-length: %d",&len);
    }while(strcmp(buf,"\r\n")); // 버퍼가 한 줄 다 읽을때까지
    return len; // 읽은 길이 리턴
}

클라이언트의 requesthdrs를 읽고 POST 메소드 처리를 위해 읽은 길이(content-length)를 리턴한다.

  • 리턴한 길이는 길이만큼 form으로 넘어온 메세지를 읽어서 콘텐츠를 처리하는데 사용된다.

parse_uri 함수

int parse_uri(char *uri, char *filename, char *cgiargs) 
{
    char *ptr;
    if (!strstr(uri, "cgi-bin")) {  /* Static content */ 
	strcpy(cgiargs, "");                             
	strcpy(filename, ".");                           
	strcat(filename, uri);                           
	if (uri[strlen(uri)-1] == '/')                   
	 strcat(filename, "home.html");              
	return 1;
    }
    else {  /* Dynamic content */                        
		ptr = index(uri, '?');                          
		if (ptr) {
			strcpy(cgiargs, ptr+1);
			*ptr = '\0';
		}
		else 
			strcpy(cgiargs, "");                        
			strcpy(filename, ".");                           
			strcat(filename, uri);                           
			return 0;
		}
}

이번에 만든 서버는 정적 콘텐츠를 위한 홈 디렉토리가 현재 디렉토리 이고, 동적 콘텐츠를 위한 실행 파일의 홈 디렉토리는 /cgi-bin 이라고 가정한다.

  1. URI에서 /만 존재하면, "home.html" 이라는 파일을 서빙한다고 판단한다.
  2. ? 뒤에 오는 문자열은 동적 콘텐츠를 위해 파싱해 cgiargs 라는 배열에 담는다.

get_filetype 함수

void get_filetype(char *filename, char *filetype) 
{
    if (strstr(filename, ".html"))
	strcpy(filetype, "text/html");
    else if (strstr(filename, ".gif"))
	strcpy(filetype, "image/gif");
    else if (strstr(filename, ".png"))
	strcpy(filetype, "image/png");
    else if (strstr(filename, ".jpg"))
	strcpy(filetype, "image/jpeg");
    else if (strstr(filename, ".mp4"))
	strcpy(filetype,"video/mp4");
    else
	strcpy(filetype, "text/plain");
}

이 함수는 인자로 받은 filename이 해당 웹서버가 허가하는 파일 형식을 가지고 있는지 체크하고 파일 타입을 채워넣는 함수이다.

  • strstr 함수를 통해 받은 filename에 해당 문자열이 있는지 체크하고, 있으면 filetype 배열(빈 배열)에 해당 MIME 타입을 복사한다.
  • 현재 이 함수 내에서 파일타입 허가를 강제하진 않는다. 웹서버가 filetype 에 받은 타입을 다시 클라이언트에게 메세지로 보내야 하는 용도로만 사용된다.
  • 파일 타입 허가를 강제하는 것도 필요할 수 있다. 필요 시 별도의 로직을 추가하기 바란다.

static_serve 함수

void static_serve(int fd, char *filename, int filesize, char* method )
{
    int srcfd;
    char *srcp, filetype[MAXLINE], buf[MAXBUF];
    /* Send response headers to client */
    get_filetype(filename, filetype);    
    sprintf(buf, "HTTP/1.0 200 OK\r\n"); 
    Rio_writen(fd, buf, strlen(buf));
    sprintf(buf, "Server: Tiny Web Server\r\n");
    Rio_writen(fd, buf, strlen(buf));
    sprintf(buf, "Content-length: %d\r\n", filesize);
    Rio_writen(fd, buf, strlen(buf));
    sprintf(buf, "Content-type: %s\r\n\r\n", filetype);
    Rio_writen(fd, buf, strlen(buf));    
    if (!(strcasecmp(method, "GET"))){
	/* Send response body to client */
	srcfd = Open(filename, O_RDONLY, 0); 
	 srcp = Mmap(0, filesize, PROT_READ, MAP_PRIVATE, srcfd, 0); 
	Close(srcfd);                       
	Rio_writen(fd, srcp, filesize);  
	Munmap(srcp, filesize);             
	}
}

우리가 만든 웹서버는 아래의 콘텐츠 타입을 지원한다.

  • HTML 파일
  • 텍스트 파일
  • GIF, PNG,JPEG, MP4 파일

우선, HTTP response에 필요한 버전, 메세지 등을 담은 header 내용을 만들고, 연결 식별자 fd로 그 내용을 복사한다.

그리고 response body를 만들기 위해 Open 함수를 이용해 받아온 filename 을 열고 식별자를 리턴한다.

그 후 Mmap 함수를 이용해 요청한 파일을 가상 메모리 영역으로 매핑한다. 이제 파일 내용은 메모리에 담기게 된다.

메모리 매핑이 끝나면 식별자가 필요없으니, Close를 통해 식별자와 파일을 닫는다.

그 후 요청한 파일이 매핑되어있는 메모리 주소를 fd 로 복사한다.(여기서 fd 는 클라이언트와 연결 되어있는 connfd 를 의미한다. 이 과정을 통해 클라이언트와 데이터를 주고 받을 수 있다고 이해하면 된다.)

마지막으로 매핑된 가상 메모리 주소를 반환한다.

dynamic_serve 함수

void dynamic_serve(int fd, char *filename, char *cgiargs) 
{
    char buf[MAXLINE], *emptylist[] = { NULL };
    /* Return first part of HTTP response */
    sprintf(buf, "HTTP/1.0 200 OK\r\n"); 
    Rio_writen(fd, buf, strlen(buf));
    sprintf(buf, "Server: Tiny Web Server\r\n");
    Rio_writen(fd, buf, strlen(buf));
    if (Fork() == 0) { /* 여기는 자식 프로세스 로직 */ 
	/* Real server would set all CGI vars here */
      setenv("QUERY_STRING", cgiargs, 1); 
       /* Redirect stdout to client */
       Dup2(fd, STDOUT_FILENO); 
       Execve(filename, emptylist, environ); /* Run CGI program */ 
    }
    Wait(NULL); /* 부모가 자식을 기다려야 함. */ 
}

웹 서버 프로세스는 동적 콘텐츠 처리를 위해 자식 프로세스를 만든다. 이때 fork() 시스템 콜을 활용하는데, 부모 프로세스는 listenfd 를 통해 클라이언트 연결 요청을 받아야 하기 때문에, 개별 클라이언트 요청 처리는 connfd 를 가진 자식 프로세스를 만들어 처리하기 위함이다.

먼저, HTTP response header를 위해 version, status, message를 fd에 복사한다. `(여기서 fd는 클라이언트와 연결 되어있는connfd` 를 의미한다.)

response body는 fork() 한 후 자식의 컨텍스트에서 동적 콘텐츠 서빙을 위한 CGI 실행 파일 실행을 통해 만든다.

  • setenv 함수를 통해 QUERY_STRING 환경변수에 받아온 cgiargs(parse_uri 에서 만든 문자열)를 overwrite 한다.
  • 여기서 환경변수가 궁금해질텐데, 우리 웹서버에서는 environ 이라는 문자열 extern 변수를 활용한다. extern은 파일 외부에서 사용할 수 있기 때문에 environ 변수에 "QUERY_STRING={cgi_args 내용}" 이 담기고, 추후 서술할 cgi 프로그램에서 해당 변수를 사용한다고 보면 된다.

    좀 더 자세한 내용은 해당 을 참고하기 바란다.

  • Dup2 시스템 콜을 통해 표준 출력을 fd 식별자로 재지정한다. 이제 표준 출력에 담길 내용이 fd = connfd 식별자로 리다이렉트 되기 때문에 클라이언트에서 해당 내용을 볼 수 있다.
  • Execve 시스템 콜을 통해 filename 을 실행한다. (여기서 environ 변수를 넘기는걸 볼 수 있다.)
    • 자식 컨텍스트에서 실행되기 때문에 시스템 콜 호출하기 전에 존재하던 파일 식별자와 환경변수에 부모와 동일하게 접근할 수 있다.)
  • 자식 프로세스가 프로그램 실행을 종료되어 정리될 때까지 부모 프로세스는 Wait 시스템 콜을 통해 대기한다.

fork, wait, Execve 등은 운영체제가 제어하는 시스템 콜이기 때문에 여기서 자세한 설명을 하진 않겠다. 대략적인 플로우는 설명했으니, 더 궁금한 분들은 리눅스 매뉴얼을 좀 더 참고하기 바란다.

cgi program

#include "../csapp.h"

int main(void){
    char *buf, *p, *p1;
    char arg1[MAXLINE], arg2[MAXLINE],content[MAXLINE];
    int n1=0, n2=0;

    /* extract the two arguments*/
    if ((buf=getenv("QUERY_STRING")) != NULL){
        p = strchr(buf, '&');
        *p = '\0';
        sscanf(buf, "first=%d", &n1);
        sscanf(p+1, "second=%d", &n2);	
    }

    /* Make the response body*/
    sprintf(content, "QUERY_STRING=%s",buf);
    sprintf(content, "Welcome to add.com: ");
    sprintf(content, "%sTHE Internet addition portal. \r\n<p>", content);
    sprintf(content,"%sThe answer is: %s + %s\r\n<p>", content, n1,n2, n1+n2);
    sprintf(content,"%sThanks for visiting!\r\n",content);

    /* Generate the HTTP response*/
    printf("Connection: close\r\n");
    printf("Content-length : %d\r\n", (int)strlen(content));
    printf("Content-type: text/html\r\n\r\n");
    printf("%s",content);
    fflush(stdout);

    exit(0);
}

동적 콘텐츠를 서빙하기 위해 실행하는 cgi 프로그램이다. dynamic_serve 함수에서 보았듯이 environ 변수를 통해 설정한 환경변수를 넘겨 받는다.

  • getenv 를 통해 "QUERY_STRING"의 value 를 buf에 담는다.
  • 담은 내용을 형식에 맞게 변수에 담는다.
  • HTTP response body에 담을 메세지 내용을 content 문자열 버퍼에 담는다.
  • 그리고 response header에 담을 내용을 표준 출력에 출력한다. (다만, dynamic_serve 함수에서 사용한 Dup2 를 통해 클라이언트로 리다이렉트 된다.)
  • fflush 로 stdout 버퍼를 비운다.(다른 내용을 버퍼에 담는걸 준비하기 위해)

한번 정리하면, dynamic_serve 함수에서 이 프로그램을 실행해 HTTP response를 만들어서 클라이언트로 그 결과를 보낸다. 라고 볼 수 있다.

마치며

여기까지 웹 서버 구현에 필요한 모든 함수를 정리해보았다. 모든 코드는 깃헙에 가면 볼 수 있으니 참고바란다.

참고로, 이번에 만든 웹서버는 HTTP 통신을 할 수 있는, 아주 기본적인 웹서버이다. 시중에 있는 웹서버보다 견고하지 못하고 보안도 취약하니 당연히 연습용으로만 참고하길 바란다.

방대한 내용을 글에 담으려다보니 빠뜨린 부분이나 논리적 비약이 있을 수도 있다. 읽다가 궁금한 점이나 틀린 점은 댓글 혹은 메일로 필히 피드백 주시면 좋을 것 같다.

추가적인 궁금증

아래의 내용은 웹 서버를 개발하면서 얻은 추가적인 궁금증이다. 이에 대한 답을 나름대로 정리했는데, 추후에 한번 더 정리해서 올릴 예정이다.

  • 소켓은 파일인가?
  • 클라이언트와 서버는 어떻게 소켓 버퍼로 데이터를 주고 받는가?
profile
소프트웨어 엔지니어링을 연마하고자 합니다.

3개의 댓글

comment-user-thumbnail
2022년 12월 2일

좋은 글 잘봤습니다. 말씀하신대로 해당 코드가 아주 기본적인 웹서버라고 하셨는데 시중에 나와있는 웹서버라는것이 apache나 엔진엑스 같은 것들이라고 보면될까요?

1개의 답글
comment-user-thumbnail
2024년 9월 2일

포스팅 너무 좋아요! 잘 봤습니다!
혹시 웹서버 만드실 때 관련 자료는 어디서 보고 하셨나요? 저도 한번 구축해보고 싶어서요!

답글 달기