[번역] HTTP 서버: 밑바닥부터 만들어보기
Coding/Unreal, C++

[번역] HTTP 서버: 밑바닥부터 만들어보기

이 포스트는 다음 포스트를 번역/의역한 글입니다. 가독성을 위해 임의적으로 편집한 부분이 있습니다.

출처

https://medium.com/from-the-scratch/http-server-what-do-you-need-to-know-to-build-a-simple-http-server-from-scratch-d1ef8945e4fa

 

HTTP Server: Everything you need to know to Build a simple HTTP server from scratch

Vote for next post or mention what do you want to see next here:

medium.com

서론

HTTP는 어디에나 있습니다. 우리가 방문하는 모든 웹사이트가 HTTP 서버 위에서 돌아가죠. HTTPS 서버는 그럼 뭐냐고 생각할 수 있는데, HTTPS는 그저 HTTP에 보안을 덧붙인 것 뿐입니다.

많은 프로그래머들은 어느 지점에서 HTTP 서버가 정확히 어떻게 돌아가는지, 어떻게 외부 라이브러리 없이 밑바닥부터 만드는지 궁금증을 갖습니다. 저도 그 프로그래머 중 하나입니다.

최근에 node.js 프로젝트를 하면서 이런 의문이 들었습니다. "HTTP 서버가 어떻게 빌드되는거지?", "HTTP 서버는 어떻게 돌아가지?" 이 물음에 이어지는 질문도 생깁니다. "HTTP 서버를 밑바닥부터 만들 수 있을까?", "초보도 이게 가능할까?"

답은 YES.

HTTP 서버를 만들기 위해 처음 해야 하는 질문은 "어디서부터 시작해야 할까?" 일 겁니다. 우리는 OSI부터 시작할 겁니다.

어디서부터 시작할까?

OSI

OSI(Open Systems Interconnection) 모형은 컴퓨터 네트워크 프로토콜 디자인과 통신을 계층으로 나누어 설명한 것입니다. 총 7계층이 있어 OSI 7계층이라고 부르기도 합니다. 이 7계층은 물리 - 데이터 링크 - 네트워크 - 전송 - 세션 - 표현 - 응용, 이렇게 7개로 나뉩니다.

HTTP 서버를 만들 때는 4번째 계층인 전송(Transport) 계층만을 생각하면 됩니다.

전송 계층(Transport)
전송 계층은 양 끝단(End to End)의 사용자들이 신뢰성있는 데이터를 주고 받을 수 있도록 해줍니다. 이를 통해 상위 계층(세션, 표현, 응용)은 데이터 전달의 유효성이나 효율성을 생각하지 않아도 됩니다. 
전송 계층은 flow control과 error handling 등 기능이 있습니다.

우리는 전송계층에서 TCP를 쓸 겁니다. UDP를 통해서도 HTTP 서버를 만들 수도 있지만 보통 그러지 않습니다. 그 이유를 따지는 건 주제에 어긋날 수 있으니까 RFC 문서로 대체하겠습니다.

RFC 2616:
HTTP 통신은 대개 TCP/IP 연결로 이뤄집니다. 기본 포트는 80이지만 다른 포트를 쓸 수도 있습니다. 이 특징은 HTTP를 인터넷 상의 다른 프로토콜이나 네트워크에서 구현하는데에 전혀 방해가 되지 않습니다. HTTP는 신뢰할 수 있는 전송만을 가정하기 때문이죠. 어떤 프로토콜이라도 신뢰할 수 있다면 상관없습니다. 각 프로토콜마다 HTTP/1.1의 Request나 Response 구조를 매핑하는 것은 본 명세의 범위를 벗어납니다. 

RFC 2616이 명시적으로 말하진 않지만 UDP는 reliable transport가 아니기 때문에 쓰지 않습니다. 유명한 HTTP 서버, 그러니까 아파치 톰캣이나 엔진엑스도 TCP 위에서 동작하기 때문에 이 포스트에서 TCP를 사용하겠습니다.

이 때, 당신은 이렇게 생각할 수도 있습니다. "아니, RFC가 뭔데?"

RFC

RFC(Request for Comments)는 비평을 기다리는 문서라는 의미로, 인터넷 기술에 적용 가능한 새로운 연구, 혁신, 기법 등을 아우르는 메모입니다. 2017년 8월까지 8200개 정도의 RFC가 출판되어 있습니다.

표준으로 정립된 RFC는 다음과 같습니다:

HTTP/1.1
처음에는 RFC 2616이었지만 이후 RFC 7230~RFC 7235로 대체되었습니다. 그러니까 HTTP를 적용하려면 이 6개 문서를 읽어야 합니다.

HTTP/2
RFC 7540과 RFC 7541

FTP
RFC959

자 이제, 쉬는 시간을 가지세요. 이후부터는 드디어 코딩의 시간이니까요.

이제 우리가 배운 것들을 적용해봅시다

TCP 구현

먼저 HTTP의 전송 계층에 해당하는 TCP를 구현하겠습니다.

* 프로그래밍 언어로는 C를 사용합니다. '밑바닥부터'라는 이번 주제에 가장 알맞는 언어라고 생각했기 때문입니다.

TCP를 구현하려면 TCP 소켓 프로그래밍을 배워야 합니다.

소켓이 뭐야?

소켓은 운영체제가 프로그램이 네트워크에 접근할 수 있도록 해주는 매커니즘입니다. 소켓을 통해 서로 다른 기계의 어플리케이션들이 메세지를 주고 받을 수 있습니다.

소켓은 네트워크 유형에 독립적입니다. 하지만 IP가 네트워크에서 지배적인만큼 보통 소켓이 여기서 쓰입니다.

TCP/IP 소켓 프로그래밍

소켓을 쓰는 단계는 다음과 같습니다.

  1. 소켓을 만든다.
  2. 소켓에 이름을 짓는다.
  3. (서버라면) 연결 요청을 기다린다.
  4. 메세지를 주고 받는다.
  5. 소켓을 닫는다.

1단계: 소켓을 만든다.

socket 시스템 콜을 통해 socker과 server_fd를 만들 수 있습니다.

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

 모든 매개변수와 리턴값은 int 형입니다:

domain, or address family --

communication domain은 소켓이 어느 영역(domain)에서 만들어야 하는지 정의합니다. address families의 예시는 AF_INET(IP), AF_INET6 (IPv6), AF_UNIX(local channel, similar to pipes), AF_ISO(ISO protocols), AF_NS(Xerox Network Systems protocols) 등이 있습니다.

type --

서비스 유형을 정의합니다. 보통 어플리케이션의 특성에 따라 결정됩니다: SOCK_STREAM(가상 회선 서비스), SOCK_DGRAM(데이터그램 서비스), SOCK_RAW(직접 IP 서비스). address family에 따라 특정 서비스를 선택하지 못할 수도 있습니다.

protocol --

보조 프로토콜을 정의합니다. 특정 address family의 특정 service가 보조 프로토콜을 요구할 때 사용합니다.

소켓 생성 함수의 결과값은 파일 디스크립터입니다. 이렇게 소켓을 만드는 것을 비유하자면 전화 회사에 전화 회선을 요청하는 것이라고 이해할 수 있겠네요!

우리는 address family로 IP(AF_INET)을, 서비스는 가상회선 서비스(SOCK_STREAM)으로 특정하겠습니다. 이 방식에서는 따로 보조 프로토콜이 필요 없으므로 protocol에 0을 넣습니다. 그러면 코드는 다음과 같을 겁니다.

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

if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
{
	perror("cannot create socket");
    return 0;
}

 2단계: socket에 이름을 지어준다.

socket에 이름을 지어준다는 것은 socket에 전송 주소(IP에서는 포트 번호)를 할당해주는 것을 의미합니다. 소켓에서는 이 방식을 bind라고 부릅니다. 아까의 비유를 들어서 설명해볼 수 있겠네요. bind는 우리가 전화 회사에서 받은 전화 회선이 잘 작동할 수 있도록 우리쪽 전화번호를 받는 것입니다.

전송 주소는 socket address struct로 정의됩니다. 아까 말에 따르면 포트번호 하나면 되지 않냐고 생각할 수 있습니다. 그런데도 굳이 구조체방식으로 주는 것은 소켓이 다양한 전송 인터페이스에서 작동해야 하기 때문입니다. 그래서 sockaddr이라는 구조체를 통해 포트넘버와 address family 등을 정의해서 넘겨줘야 하는 것입니다. 

bind를 하기 위한 시스템 콜은 다음과 같습니다.

int bind(int socket, const struct sockaddr *address, socklen_t address_len);

첫번째 매개변수인 socket은 아까 우리가 만든 그 소켓입니다. 두번째 매개변수인 sockaddr은 generic container로 OS가 처음 2바이트를 읽어서 address family가 무엇인지 파악할 수 있게 해줍니다. 우리는 IP 네트워크를 쓸거니까, struct sockaddr_in을 쓸겁니다. 이건 netinet/in.h에 정의되어 있습니다. 구조체는 다음과 같습니다.

struct sockaddr_in
{
	__uint8_t		sin_len;
    sa_family_t		sin_family;
    in_port_t		sin_port;
    struct in_addr	sin_addr;
    char			sin_zero[8];
}

bind를 하기 전에 우리는 이 struct를 잘 만들어놔야 합니다. 특히 3가지 부분이 중요합니다.

sin_family

socket을 만들 때, 사용했던 address family. 우린 AF_INET을 사용했었죠.

sin_port

할당할 포트 번호(전송 주소). 포트를 직접 써넣을 수도 있고 OS가 맘대로 정하도록 할 수도 있습니다. OS가 정하게 하려면 0을 넣으면 됩니다. 보통 서버를 만들 때는 특정 번호를 넣어서 클라이언트가 찾아서 들어올 수 있도록 합니다.

sin_addr

socket의 주소입니다. 이건 그냥 컴퓨터의 IP 주소라고 생각하면 됩니다. 컴퓨터는 각 네트워크 인터페이스마다 IP 주소를 가지고 있습니다. 예를 들어, Wi-Fi와 Ethernet을 함께하고 있는 컴퓨터라면 두 개의 ip 주소를 가지고 있는 것이죠. 왠만한 경우에 우리가 신경쓸 필요가 없어서 OS가 정하도록 하면 됩니다. 그러기 위해서 0.0.0.0 또는 INADDR_ANY를 넣습니다.

bind의 세번째 매개변수는 address_len인데요. address struct는 그 유형에 따라 크기가 다르기 때문입니다. 여기서는 간단히 sizeof 연산자를 활용하겠습니다.

#include <sys/socket.h> 
… 

struct sockaddr_in address;
const int PORT = 8080; //Where the clients can reach at

/* htonl converts a long integer (e.g. address) to a network representation */ 
/* htons converts a short integer (e.g. port) to a network representation */ 

memset((char *)&address, 0, sizeof(address)); 
address.sin_family = AF_INET; 
address.sin_addr.s_addr = htonl(INADDR_ANY); 
address.sin_port = htons(PORT); 

if (bind(server_fd,(struct sockaddr *)&address,sizeof(address)) < 0) 
{ 
    perror(“bind failed”); 
    return 0; 
}

3단계: (서버라면) 연결 요청을 기다린다.

클라이언트가 서버에 접속하기 전에 서버는 연결을 받아들일 수 있는 소켓이 있어야 합니다. listen() 시스템 콜은 해당 소켓이 연결을 받아들일 수 있는지 알려줍니다.

#include <sys/socket.h>

int listen(int socket, int backlog);

두번째 매개변수인 backlog는 소켓에 연결을 요청하면 큐에 넣어서 줄을 세우는 데, 그 때 몇개까지 할지 줄을 세울지 정하는 숫자입니다.

accept() 시스템 콜은 그 큐에서 첫번째 요청을 가져와서 그 연결만을 위한 새로운 소켓을 만들어줍니다. 

우리가 처음에 만든 소켓은 연결 요청을 받아들이기 위해서만 존재하는 것입니다. 데이터를 주고 받는데 이용하는 게 아닙니다. 기본적으로 소켓은 동기적으로 작동합니다. 그러니까 큐에 있는 요청은 현재 받아들이고 있는 요청이 끝날 때까지 기다려야 합니다.

accept()의 모양새는 다음과 같습니다:

#include <sys/socket.h>

int accept(int socket, struct sockaddr *restrict address, socklen_t *restrict address_len);

첫번째 매개변수는 연결을 받아들이기 위해 만들었던 socket을 의미합니다.

두번째 매개변수는 accept()가 채워서 돌려주는 것으로, 연결을 요청한 클라이언트의 주소 정보를 담은 address 구조체입니다. 이를 통해 받아들이는 클라이언트의 주소라던지, 포트넘버를 알 수 있습니다.

세번째는 accept()가 채워서 돌려주는 것으로, address 구조체의 길이를 의미합니다.

listen()과 accept()를 위한 코드는 다음과 같습니다.

if (listen(server_fd, 3) < 0)
{
	perror("In listen");
    exit(EXIT_FAILURE);
}

if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0)
{
	perror("In accept");
    exit(EXIT_FAILURE);
}

4단계: 메세지를 주고 받는다.

마침내! 서버와 클라이언트가 연결된 소켓을 만들었네요! 그렇다면 메세지를 주고 받는 건 굉장히 쉽습니다. 파일에 read()와 write()의 시스템 콜과 똑같기 때문이죠.

char buffer[1024] = {0};

int valread = read(new_socket, buffer, 1024);
printf("%s\n", buffer);
if (valread < 0)
{
	printf("No bytes are there to read");
}

char *hello = "Hello from the server"; // 중요! 이 부분은 나중에 바뀔 예정입니다.
write(new_socket, hello, strlen(hello));

 5단계: 소켓을 닫는다.

모든 연결이 끝났으면 소켓을 닫습니다.

close(new_socket);

서버에서 돌아가는 TCP 소켓을 완성했습니다!
서버의 TCP 소켓 코드

// Server side C program to demonstrate Socket programming
#include <stdio.h>
#include <sys/socket.h>
#include <unistd.h>
#include <stdlib.h>
#include <netinet/in.h>
#include <string.h>

#define PORT 8080
int main(int argc, char const *argv[])
{
    int server_fd, new_socket; long valread;
    struct sockaddr_in address;
    int addrlen = sizeof(address);
    
    char *hello = "Hello from server";
    
    // Creating socket file descriptor
    if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0)
    {
        perror("In socket");
        exit(EXIT_FAILURE);
    }
    

    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons( PORT );
    
    memset(address.sin_zero, '\0', sizeof address.sin_zero);
    
    
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address))<0)
    {
        perror("In bind");
        exit(EXIT_FAILURE);
    }
    if (listen(server_fd, 10) < 0)
    {
        perror("In listen");
        exit(EXIT_FAILURE);
    }
    while(1)
    {
        printf("\n+++++++ Waiting for new connection ++++++++\n\n");
        if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen))<0)
        {
            perror("In accept");
            exit(EXIT_FAILURE);
        }
        
        char buffer[30000] = {0};
        valread = read( new_socket , buffer, 30000);
        printf("%s\n",buffer );
        write(new_socket , hello , strlen(hello));
        printf("------------------Hello message sent-------------------\n");
        close(new_socket);
    }
    return 0;
}

 이 서버 코드를 테스트하려면 클라이언트 코드도 작성해야 합니다.

클라이언트의 TCP 소켓 코드

// Client side C/C++ program to demonstrate Socket programming
#include <stdio.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <unistd.h>
#include <netinet/in.h>
#include <string.h>
#include <arpa/inet.h>

#define PORT 8080

int main(int argc, char const *argv[])
{
    int sock = 0; long valread;
    struct sockaddr_in serv_addr;
    char *hello = "Hello from client";
    char buffer[1024] = {0};
    if ((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0)
    {
        printf("\n Socket creation error \n");
        return -1;
    }
    
    memset(&serv_addr, '0', sizeof(serv_addr));
    
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(PORT);
    
    // Convert IPv4 and IPv6 addresses from text to binary form
    if(inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr)<=0)
    {
        printf("\nInvalid address/ Address not supported \n");
        return -1;
    }
    
    if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0)
    {
        printf("\nConnection Failed \n");
        return -1;
    }
    send(sock , hello , strlen(hello) , 0 );
    printf("Hello message sent\n");
    valread = read( sock , buffer, 1024);
    printf("%s\n",buffer );
    return 0;
}

HTTP

먼저 HTTB 서버와 웹 브라우저가 어떻게 작동하는지 살펴봅시다.

출처: https://medium.com/from-the-scratch/http-server-what-do-you-need-to-know-to-build-a-simple-http-server-from-scratch-d1ef8945e4fa

여기서 HTTP 부분만 더 자세히 살펴보면 다음과 같습니다.

출처: https://medium.com/from-the-scratch/http-server-what-do-you-need-to-know-to-build-a-simple-http-server-from-scratch-d1ef8945e4fa

  1. HTTP 클라이언트가 HTTP 서버에게 HTTP Request를 보냅니다.
  2. HTTP 서버는 받은 HTTP Request를 보고 HTTP Response를 HTTP 클라이언트에게 보냅니다.

이제, 클라이언트와 서버가 대체 뭘 받고 보내는지 알아봅시다.

HTTP Client (웹 브라우저)

클라이언트가 서버에 연결을 요청해야 합니다. 서버가 클라이언트에게 연결을 요청하는 게 아니에요. 그러니까 연결 요청은 클라이언트의 몫입니다.

서버에 연결 요청을 보내기 위해, 우리는 뭘 하나요?

우리는 URL, 그러니까 웹사이트의 주소를 웹브라우저에 씁니다.

출처: https://medium.com/from-the-scratch/http-server-what-do-you-need-to-know-to-build-a-simple-http-server-from-scratch-d1ef8945e4fa

웹 브라우저는 웹페이지를 보여주기 위해서 HTTP 서버로부터 index.html 파일을 가져옵니다.

웹 브라우저에 www.example.com이라고만 쳐도 웹 브라우저는 URL을 다음처럼 재구성합니다.

http://www.example.com:80

만약 서버가 default pages를 설정해뒀다면 그 page를 줄 것입니다. 또는 각 폴더마다 default page를 가지고 있을 것입니다.

웹 페이지는 파일의 이름으로 결정됩니다. public.html이나 index.html처럼 말이죠.

웹 브라우저는 재구성한 URL을 토대로 HTTP Request를 만들어서 Socket으로 보냅니다.

정말로 그럴까요?

이를 확인하는 방법은 쉽습니다.

  1. TCP 서버 코드를 빌드해서 터미널에서 실행시킵니다.
  2. 웹 브라우저를 열고 localhost:8080/index.html 으로 접속합니다.
  3. 터미널에 나온 결과값을 확인하세요.

터미널의 결과값:

+++++++ Waiting for new connection ++++++++
GET /index.html HTTP/1.1
Host: localhost:8080
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.162 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
DNT: 1
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
------------------Hello message sent-------------------
+++++++ Waiting for new connection ++++++++

아마 결과값은 대충 위에 것과 비슷할 겁니다.

잠시만요, 웹 브라우저는 보셨나요?

웹 브라우저는 다음과 같을 겁니다.

뭐가 문제일까요? 왜 서버가 보낸 데이터를 볼 수 없을까요?

위에서 4단계: 메세지 주고받기에서 char *hello를 기억하시나요? 기억이 안나면 다시 보고 오시면 됩니다.

HTTP Methods (Verbs):

GET은 HTTP에서 기본 메소드입니다. 총 9개의 HTTP 메소드가 있고 그 중 일부는 아래와 같습니다.

  1. GET - URL에서 파일을 가져옵니다.
  2. HEAD - URL에 대한 정보를 가져옵니다.
  3. PUT - URL에 정보를 저장합니다.
  4. POST - URL에 데이터를 보내고 대답을 받습니다.
  5. DELETE - URL GET과 POST를 제거 합니다.

REST APIs 는 GET, PUT, POST, DELETE를 사용합니다.

HTTP Server:

이제 Client에게 그들이 원하는 방식으로 메세지를 보내봅시다.

Client는 서버로 헤더를 보냈습니다. 서버도 이것과 비슷하게 클라이언트에게 보내야 합니다. 그래서 처음에 TCP 구현과 HTTP 구현은 완전히 독립되어 있다고 말한 겁니다.

웹 브라우저가 원하는 방식, Form은 다음과 같습니다.

 그러니까 우리가 단지 Hello from server 라고 보내고 싶어도 우리는 먼저 헤더 파일을 만들어줘야 합니다. 그리고 헤더와 내용 사이에 빈줄을 하나더 넣습니다. 이후에나 내용을 넣으면 됩니다.

위에 있는 HTTP Header는 예시에 불과합니다. 실제로는 더 많은 헤더 프로퍼티가 있습니다. 이에 대해서 자세히 알고 싶으면 RFC를 참조하시면 됩니다.

최소한으로 HTTP Response를 구성하면 다음과 같습니다.

char *hello = "HTTP/1.1 200 OK\nContent-Type: text/plain\nContent-Length: 12\n\nHello world!";

최소 요구조건인 3개 헤더는 다음과 같습니다.

  1. HTTP/1.1 200 OK
    우리가 쓰는 HTTP 버전을 명시하고 Status code와 Status message를 적습니다.
  2. Content-Type: text/plain
    서버가 보내는 정보가 그냥 텍스트에 불과(HTML 등으로 해석하지 말것)하다고 명시하는 것입니다. text/plain 말고도 다양한 Content-Type이 있습니다.
  3.  Content-Length: 12
    서버가 보내는 메세지의 길이를 씁니다. 웹 브라우저는 여기에 쓰인 만큼만 읽습니다.

다음 부분은 HTTP Response의 Body에 해당합니다. 우리가 보내는 데이터죠.

Status Code and Status Messages:

Status Code는 서버가 보낸 Response의 상태를 의미합니다. Status Code의 첫번째 숫자는 5가지 표준 클래스 중 하나입니다. 우리가 쓸 수 있는 코드는 다음과 같습니다.

이제 hello 부분만 고치면 HTTP 서버가 바르게 작동할 겁니다!