앗! 광고가 차단되었어요!

글 내용이 방문자께 도움이 되었다면, 광고 차단 프로그램 해제를 고려해주세요 😀.

공돌이

C, TCP 기반으로 간단한 HTTP 서버 작성하기

this-gpa 2020. 11. 22. 13:02

이 글에서는 대학교 수업 중 "네트워크 프로그래밍"의 단골 과제인, C와 TCP를 기반으로 HTTP 서버를 작성한다.

(안타깝게도) "C, C++를 이용한 웹서버"라는 키워드로 블로그 유입이 많이 되고 있어 복습할 겸 작성해봤다.

 

오늘 필자가 개발하고자 하는 HTTP 서버는 리눅스에서 동작할 수 있는 간단한 서버로, 

서버 프로그램(a.out)이 존재하는 디렉터리를 기준으로 파일을 접근할 수 있는 서버다.

 

예를 들어

a.out이 /home/user/c-serv/에 있고, 8000번으로 bind 한다면

브라우저에서 localhost:8000/index.html을 요청하면 /home/user/c-serv/index.html 파일을 반환하고,

브라우저에서 localhost:8000/index.css를 요청하면 /home/user/c-serv/index.css 파일을 반환하는 웹서버다.

 

HTTP 서버를 작성하기 위해서 다음의 개발 순서를 생각할 수 있다.

  1. socket(), bind(), listen() 등을 활용하여 TCP 소켓을 준비한다.
  2. accept() 후에 HTTP 프로토콜로 처리(요청을 읽고 적절히 응답)하는 함수를 준비한다.
  3. 처리 중 에러가 발생하면 404, 500의 상태 코드로 응답하는 것을 잊지 않는다.

TCP 소켓 준비

먼저 main 함수를 보이겠다. 아래 코드는 HTTP 관련 함수를 제외한 코드이다.

#define BUF_SIZE 1000
#define HEADER_FMT "HTTP/1.1 %d %s\nContent-Length: %ld\nContent-Type: %s\n\n"

#define NOT_FOUND_CONTENT       "<h1>404 Not Found</h1>\n"
#define SERVER_ERROR_CONTENT    "<h1>500 Internal Server Error</h1>\n"

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/socket.h>
#include <arpa/inet.h>


/*
    @func   assign address to the created socket lsock(sd)
    @return bind() return value
*/
int bind_lsock(int lsock, int port) {
    struct sockaddr_in sin;

    sin.sin_family = AF_INET;
    sin.sin_addr.s_addr = htonl(INADDR_ANY);
    sin.sin_port = htons(port);

    return bind(lsock, (struct sockaddr *)&sin, sizeof(sin));
}

/* ...skip: codes related to HTTP... */

int main(int argc, char **argv) {
    int port, pid;
    int lsock, asock;

    struct sockaddr_in remote_sin;
    socklen_t remote_sin_len;

    if (argc < 2) {
        printf("Usage: \n");
        printf("\t%s {port}: runs mini HTTP server.\n", argv[0]);
        exit(0);
    }

    port = atoi(argv[1]);
    printf("[INFO] The server will listen to port: %d.\n", port);

    lsock = socket(AF_INET, SOCK_STREAM, 0);
    if (lsock < 0) {
        perror("[ERR] failed to create lsock.\n");
        exit(1);
    }

    if (bind_lsock(lsock, port) < 0) {
        perror("[ERR] failed to bind lsock.\n");
        exit(1);
    }

    if (listen(lsock, 10) < 0) {
        perror("[ERR] failed to listen lsock.\n");
        exit(1);
    }

    // to handle zombie process
    signal(SIGCHLD, SIG_IGN);

    while (1) {
        printf("[INFO] waiting...\n");
        asock = accept(lsock, (struct sockaddr *)&remote_sin, &remote_sin_len);
        if (asock < 0) {
            perror("[ERR] failed to accept.\n");
            continue;
        }

        pid = fork();
        if (pid == 0) {
            close(lsock); http_handler(asock); close(asock);
            exit(0);
        }

        if (pid != 0)   { close(asock); }
        if (pid < 0)    { perror("[ERR] failed to fork.\n"); }
    }
}

TCP, 소켓 프로그래밍을 배운 학생이라면 이 코드가 TCP 소켓을 생성한다는 것을 알 것이다.

몇 가지 강조하면:

  • 멀티 스레드를 채택하지 않고 멀티 프로세스를 사용했다. 이는 필자의 개인적인 선택이다. 스레드 형식을 사용해도 된다.
  • SIGCHLD에 SIG_IGN를 사용했다. 보통 waitpid 하는 signal handler를 사용하곤 하는데, 대신 SIG_IGN 방법을 사용할 수도 있다. 자세한 내용은 아래 링크를 참고하자.
 

Do I need to do anything with a SIGCHLD handler if I am just using wait() to wait for 1 child to finish at a time?

I've got a program that is forking to a child to do some work, but I am only doing one child at a time at this time. I am using wait() to wait for the child to finish, do I need to do anything with

stackoverflow.com

HTTP 프로토콜 살펴보기

HTTP가 처음이라면 MDN을 참조하자. 아래 링크를 읽기 바란다.

 

An overview of HTTP

HTTP is the foundation of any data exchange on the Web and it is a client-server protocol, which means requests are initiated by the recipient, usually the Web browser. A complete document is reconstructed from the different sub-documents fetched, for inst

developer.mozilla.org

 

HTTP response status codes

HTTP response status codes indicate whether a specific HTTP request has been successfully completed. Responses are grouped in five classes: Informational responses (100–199) Successful responses (200–299) Redirects (300–399) Client errors (400–499)

developer.mozilla.org

정리하면, HTTP 프로토콜은 TCP 소켓을 바탕으로 특정한 형식, 포맷으로 데이터를 주고받는 것이라고 할 수 있다.

(브라우저가 보내는) 요청과 (서버가 보내는) 응답은 다음과 같아야 한다.

GET / HTTP/1.1
Host: developer.mozilla.org
Accept-Language: fr
HTTP/1.1 200 OK
Date: Sat, 09 Oct 2010 14:28:02 GMT
Server: Apache
Last-Modified: Tue, 01 Dec 2009 20:18:22 GMT
ETag: "51142bc1-7449-479b075b2891b"
Accept-Ranges: bytes
Content-Length: 29769
Content-Type: text/html

<!DOCTYPE html... (here comes the 29769 bytes of the requested web page)

 

요청은 브라우저에서 보내주므로, 우리가 구현할 서버의 책임은 이 요청을 읽고 적절한 응답을 보내주는 것이다.

그러므로:

  • 요청에서 Path "/"를 읽어 적절한 리소스를 반환해야 한다. "/index.html"이라면 index.html 파일 내용으로 응답해야 한다.
  • 응답에 적절한 상태 코드(200, OK)와 헤더를 적어줘야 한다. 우리가 필수적으로 포함할 헤더는 Content-Length, Content-Type이다. Content-Length에는 리소스 또는 파일의 크기를, Content-Type은 파일 확장자에 따른 MIME Type 값을 적어준다.
  • Content-Length, Content-Type은 중요하다. Content-Length는 브라우저가 헤더 다음 몇 바이트만큼 읽어야 하는지 알려주고, Content-Type은 body가 어떤 타입인지, 브라우저에서 어떻게 보여줘야 하는지 알려준다. 예를 들어 HTML 파일을 보내주는데 Content-Type을 text/plain으로 알려주면, 브라우저는 단순히 HTML 코드 그대로를 화면에 보여준다.

HTTP 구현 함수들

필자가 작성한 함수는 총 5개다.

  • fill_header(char *, ...): 상태 코드, 헤더 내용 등을 주어진 포인터에 채운다. 처음 보인 코드에 HTTP 헤더 포맷 매크로(HEADER_FMT)를 정의한 것을 확인할 수 있다. 이 함수에서 매크로를 사용한다.
  • find_mime(char *, ...): 파일의 확장자를 참조하여 적절한 Content Type 값을 주어진 포인터에 채운다.
  • handle_404(int), handle_500(int): 상태 코드 404, 500로 응답하고자 할 때 사용한다.
  • http_handler(...): main 함수에서 호출되는 대표 handler 함수다. 요청된 파일을 읽으려고 하며, 파일 접근에 성공하면 상태 코드 200으로 파일의 내용을 정상적으로 보낸다. 도중에 실패하면 handle_404(), handle_500()을 호출한다.

그럼 5개 함수의 구현을 보이겠다.

 

/*
    @func   format HTTP header by given params
    @return
*/
void fill_header(char *header, int status, long len, char *type) {
    char status_text[40];
    switch (status) {
        case 200:
            strcpy(status_text, "OK"); break;
        case 404:
            strcpy(status_text, "Not Found"); break;
        case 500:
        default:
            strcpy(status_text, "Internal Server Error"); break;
    }
    sprintf(header, HEADER_FMT, status, status_text, len, type);
}

/*
    @func   find content type from uri
    @return
*/
void find_mime(char *ct_type, char *uri) {
    char *ext = strrchr(uri, '.');
    if (!strcmp(ext, ".html"))
        strcpy(ct_type, "text/html");
    else if (!strcmp(ext, ".jpg") || !strcmp(ext, ".jpeg"))
        strcpy(ct_type, "image/jpeg");
    else if (!strcmp(ext, ".png"))
        strcpy(ct_type, "image/png");
    else if (!strcmp(ext, ".css"))
        strcpy(ct_type, "text/css");
    else if (!strcmp(ext, ".js"))
        strcpy(ct_type, "text/javascript");
    else strcpy(ct_type, "text/plain");
}

/*
    @func handler for not found
    @return
*/
void handle_404(int asock) {
    char header[BUF_SIZE];
    fill_header(header, 404, sizeof(NOT_FOUND_CONTENT), "text/html");

    write(asock, header, strlen(header));
    write(asock, NOT_FOUND_CONTENT, sizeof(NOT_FOUND_CONTENT));
}

/*
    @func handler for internal server error
    @return
*/
void handle_500(int asock) {
    char header[BUF_SIZE];
    fill_header(header, 500, sizeof(SERVER_ERROR_CONTENT), "text/html");

    write(asock, header, strlen(header));
    write(asock, SERVER_ERROR_CONTENT, sizeof(SERVER_ERROR_CONTENT));
}

/*
    @func main http handler; try to open and send requested resource, calls error handler on failure
    @return
*/
void http_handler(int asock) {
    char header[BUF_SIZE];
    char buf[BUF_SIZE];

    if (read(asock, buf, BUF_SIZE) < 0) {
        perror("[ERR] Failed to read request.\n");
        handle_500(asock); return;
    }

    char *method = strtok(buf, " ");
    char *uri = strtok(NULL, " ");
    if (method == NULL || uri == NULL) {
        perror("[ERR] Failed to identify method, URI.\n");
        handle_500(asock); return;
    }

    printf("[INFO] Handling Request: method=%s, URI=%s\n", method, uri);

    char safe_uri[BUF_SIZE];
    char *local_uri;
    struct stat st;

    strcpy(safe_uri, uri);
    if (!strcmp(safe_uri, "/")) strcpy(safe_uri, "/index.html");

    local_uri = safe_uri + 1;
    if (stat(local_uri, &st) < 0) {
        perror("[WARN] No file found matching URI.\n");
        handle_404(asock); return;
    }

    int fd = open(local_uri, O_RDONLY);
    if (fd < 0) {
        perror("[ERR] Failed to open file.\n");
        handle_500(asock); return;
    }

    int ct_len = st.st_size;
    char ct_type[40];
    find_mime(ct_type, local_uri);
    fill_header(header, 200, ct_len, ct_type);
    write(asock, header, strlen(header));

    int cnt;
    while ((cnt = read(fd, buf, BUF_SIZE)) > 0)
        write(asock, buf, cnt);
}

지금까지 간단하게 서버를 작성해보았다.

하루 정도 투자해서 코드를 작성한 것이라 부족한 점이 있을 수 있다. 결과는 다음과 같다.

 

(이미지는 lena라고 불리는 이미지로, 비전 분야에서 가끔 사용된다. Hello, World 같은)

 

c_serv.tar
0.47MB

 

요즘 C를 기반으로 HTTP 서버를 작성하는 소스는 널리고 널려서, 다음과 같은 심화 질문을 던져보는 것도 좋겠다.

  • 현재는 fork로 멀티 프로세스를 채택했다. 멀티 스레드 형식으로 바꿔보자.
  • http://localhost:8000//root 로 접속하면 Internal Server Error가 발생한다. 보안을 위해 접근가능한 디렉터리를 한정시켜보자. 또는 요청할 수 있는 확장자를 제한해보자. 디렉터리를 요청한 경우 어떻게 처리할 것인지 고민해보자.
  • handle_404, handle_500에서 응답의 body로 짧은 text을 넘겨주고 있다. 이 대신 에러 페이지 HTML을 작성해보고, handle_*에서 이를 넘겨주도록 만들어보자. 필요하면 코드의 간결성도 고려하자.
  • 현재는 method를 고려하지 않고 있다. POST 메서드 및 요청의 form data를 처리하려면 어떻게 코드를 수정해야 하는지 고민해보자. 그리고 form data에 따라 응답 HTML 내용을 바꿀 수 있을까?

마지막 질문은 구현을 할 필요는 없다. C에서 이를 처리하려면 복잡해질 것이고,

Python, Ruby 계열의 서버 프레임워크에서 이미 이 기능을 쉽게 제공하고 있다.

'공돌이' 카테고리의 다른 글

문서화를 위한 drf-yasg 적용하기  (2) 2020.10.26
Python: Decorator  (0) 2020.10.26
Python: Context Manager  (0) 2020.10.26
Python: Generator  (0) 2020.10.26
Coursera 재정지원 (Financial Aid) 요청하기  (0) 2020.07.26