오늘은 'SW 아카데미 정글'에서 진행한 웹 서버 제작 프로젝트에 대해 정리해보려고 한다. 평소에 웹 서버가 어떻게 만들어졌는지 궁금한 분들이 계셨다면 잘 읽어주셨으면 좋겠다.
웹 서버를 이야기하기 위해선 먼저 네트워크와 클라이언트-서버에 대해 알고 넘어가야 한다. 아래에서 간단하게 클라이언트-서버 모델을 살펴보자.
address:port
) 우리는 두 개의 소켓 주소와 매칭하여 연결을 완료할 수 있다.128.2.210.175
라고 알고 있는게 다 IP주소라고 볼 수 있다.[www.twitter.com](http://www.twitter.com)
같은 것이 있다.아래는 웹 서버를 구현하기 위해 필요한 개념들이다. 여기 나오는 개념들은 간략하게 몇 줄로만 끝나기에는 하나하나가 모두 깊이를 가지고 있다.
그렇기 때문에 필요한 개념들은 직접 더 찾아보고 공부를 하면 좋을 것 같다.
보통 TCP를 활용한 네트워크 애플리케이션은 아래 그림과 같이 구성된다.
출처 : 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로 받는다던데?
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)
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
함수를 만들어보자.
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
함수이다.
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이라는 언어로 작성된 콘텐츠를 주고 받는다.
HTTP에 관련된 내용은 내용이 아주 방대해, 여기서 모두 다룰 수 없다.좀 더 찾아보길 권한다. 가장 좋은건, 이 가이드를 읽어보는 것이다.
웹서버 구현에 대한 개략적인 설명을 했으니, 실제 함수를 짜보자.
아래는 웹 서버를 위해 필요한 주요 함수 목록이다.
#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
함수를 통해 연결 요청을 닫는다.
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 메소드는 구분해서 처리한다.
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이기 때문에, 적절한 상태 코드와 메세지를 클라이언트에 보낸다.
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)를 리턴한다.
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
이라고 가정한다.
/
만 존재하면, "home.html" 이라는 파일을 서빙한다고 판단한다. ?
뒤에 오는 문자열은 동적 콘텐츠를 위해 파싱해 cgiargs
라는 배열에 담는다.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
에 받은 타입을 다시 클라이언트에게 메세지로 보내야 하는 용도로만 사용된다.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);
}
}
우리가 만든 웹서버는 아래의 콘텐츠 타입을 지원한다.
우선, HTTP response에 필요한 버전, 메세지 등을 담은 header 내용을 만들고, 연결 식별자 fd
로 그 내용을 복사한다.
그리고 response body를 만들기 위해 Open 함수를 이용해 받아온 filename
을 열고 식별자를 리턴한다.
그 후 Mmap
함수를 이용해 요청한 파일을 가상 메모리 영역으로 매핑한다. 이제 파일 내용은 메모리에 담기게 된다.
메모리 매핑이 끝나면 식별자가 필요없으니, Close를 통해 식별자와 파일을 닫는다.
그 후 요청한 파일이 매핑되어있는 메모리 주소를 fd
로 복사한다.(여기서 fd
는 클라이언트와 연결 되어있는 connfd
를 의미한다. 이 과정을 통해 클라이언트와 데이터를 주고 받을 수 있다고 이해하면 된다.)
마지막으로 매핑된 가상 메모리 주소를 반환한다.
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 등은 운영체제가 제어하는 시스템 콜이기 때문에 여기서 자세한 설명을 하진 않겠다. 대략적인 플로우는 설명했으니, 더 궁금한 분들은 리눅스 매뉴얼을 좀 더 참고하기 바란다.
#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에 담는다.content
문자열 버퍼에 담는다.dynamic_serve
함수에서 사용한 Dup2
를 통해 클라이언트로 리다이렉트 된다.)fflush
로 stdout 버퍼를 비운다.(다른 내용을 버퍼에 담는걸 준비하기 위해)한번 정리하면, dynamic_serve
함수에서 이 프로그램을 실행해 HTTP response를 만들어서 클라이언트로 그 결과를 보낸다. 라고 볼 수 있다.
여기까지 웹 서버 구현에 필요한 모든 함수를 정리해보았다. 모든 코드는 깃헙에 가면 볼 수 있으니 참고바란다.
참고로, 이번에 만든 웹서버는 HTTP 통신을 할 수 있는, 아주 기본적인 웹서버이다. 시중에 있는 웹서버보다 견고하지 못하고 보안도 취약하니 당연히 연습용으로만 참고하길 바란다.
방대한 내용을 글에 담으려다보니 빠뜨린 부분이나 논리적 비약이 있을 수도 있다. 읽다가 궁금한 점이나 틀린 점은 댓글 혹은 메일로 필히 피드백 주시면 좋을 것 같다.
아래의 내용은 웹 서버를 개발하면서 얻은 추가적인 궁금증이다. 이에 대한 답을 나름대로 정리했는데, 추후에 한번 더 정리해서 올릴 예정이다.
좋은 글 잘봤습니다. 말씀하신대로 해당 코드가 아주 기본적인 웹서버라고 하셨는데 시중에 나와있는 웹서버라는것이 apache나 엔진엑스 같은 것들이라고 보면될까요?