컴퓨터 시스템 : CSAPP 11장 정리 - 11.6 종합설계 :소형 웹 서버 Part.1

11.6절 Putting It Together: The Tiny Web Server

이 절에서는 지금까지 배운 내용을 종합하여 작동 가능한 소형 웹 서버 Tiny를 구현한다. 이 서버는 다음을 처리할 수 있다:

  • 정적 콘텐츠 (HTML, 이미지 등)
  • 동적 콘텐츠 (CGI 프로그램 실행 결과)

Tiny는 단 250줄 내외의 코드로 구성되어 있지만, 프로세스 제어, 소켓 인터페이스, Unix I/O, HTTP 프로토콜의 핵심 개념을 모두 포함하고 있다.


1. main() 함수 (그림 11.29)

전체 코드 확인하기

더보기
더보기
/* $begin tinymain */
/*
 * tiny.c - A simple, iterative HTTP/1.0 Web server that uses the
 *     GET method to serve static and dynamic content.
 *
 * Updated 11/2019 droh
 *   - Fixed sprintf() aliasing issue in serve_static(), and clienterror().
 */
#include "csapp.h"

void doit(int fd);
void read_requesthdrs(rio_t *rp);
int parse_uri(char *uri, char *filename, char *cgiargs);
void serve_static(int fd, char *filename, int filesize, int is_head);
void get_filetype(char *filename, char *filetype);
void serve_dynamic(int fd, char *filename, char *cgiargs);
void clienterror(int fd, char *cause, char *errnum, char *shortmsg,
                 char *longmsg);

int main(int argc, char **argv)
{
  int listenfd, connfd; // listenfd: 서버가 클라이언트 연결을 기다리는 리스닝 소켓
                        // connfd: 수락된 클라이언트와 통신하는 전용 연결 소켓
  char hostname[MAXLINE], port[MAXLINE]; // hostname, port: 클라이언트 주소 정보 출력을 위한 문자열 버퍼
  socklen_t clientlen; // clientlen: accept() 에서 주소 크기 전달 및 갱신용
  struct sockaddr_storage clientaddr; // clientaddr: 클라이언트의 주소 정보 저장용 구조체

  /* Check command line args */
  if (argc != 2) // 포트 번호를 명령줄 인자로 하나 받지 않으면 메시지 출력 후 종료
  {
    fprintf(stderr, "usage: %s <port>\n", argv[0]);
    exit(1);
  }

  /* 
    Open_listenfd()는 CSAPP에서 제공하는 에러 처리를 포함한 레퍼 함수
    주어진 포트 번호로 소켓 생성 + 바인딩 + 리스닝을 한 번에 수행
    반환된 listenfd는 클라이언트 연결 요청을 수락할 준비가 된 소켓
  */
  listenfd = Open_listenfd(argv[1]);
  while (1)
  {
    clientlen = sizeof(clientaddr); // accept()에 넘길 주소 버퍼의 크기를 설정
    connfd = Accept(listenfd, (SA *)&clientaddr, // 클라이언트 연결 요청을 수락하고 전용 소켓(connfd)을 생성
                    &clientlen); // line:netp:tiny:accept
    /*
      clientaddr에 저장된 바이너리 주소를 문자열 형태로 변환
      hostname: 클라이언트 IP 또는 호스트명
      port: 클라이언트가 사용한 포트
      로그 및 디버깅용
    */
    Getnameinfo((SA *)&clientaddr, clientlen, hostname, MAXLINE, port, MAXLINE, 0);
    printf("Accepted connection from (%s, %s)\n", hostname, port); // 클라이언트가 연결한 주소와 포트를 출력
    doit(connfd);  // 핵심 처리 함수 호출, 클라이언트가 보낸 HTTP 요청을 읽고 분석하여 적절한 응답을 생성
    Close(connfd); // connfd는 이 클라이언트 요청 하나만을 위해 열려 있던 소켓, 처리 끝나면 반드시 닫아야 함
  }
}

Tiny 웹 서버 동작 흐름 설명

먼저 선언한 변수이다.

int listenfd, connfd; // listenfd: 서버가 클라이언트 연결을 기다리는 리스닝 소켓
                      // connfd: 수락된 클라이언트와 통신하는 전용 연결 소켓
char hostname[MAXLINE], port[MAXLINE]; // hostname, port: 클라이언트 주소 정보 출력을 위한 문자열 버퍼
socklen_t clientlen;  // clientlen: accept() 에서 주소 크기 전달 및 갱신용
struct sockaddr_storage clientaddr; // clientaddr: 클라이언트의 주소 정보 저장용 구조체

1. 명령줄 인자 확인 

사용자가 포트 번호를 인자로 주지 않으면 사용법을 출력하고 종료한다.

int main(int argc, char **argv){
  ...
  
  /* Check command line args */
  if (argc != 2) // 포트 번호를 명령줄 인자로 하나 받지 않으면 메시지 출력 후 종료
  {
    fprintf(stderr, "usage: %s <port>\n", argv[0]);
    exit(1);
  }
  ...
}
  • argc: argument count의 약자. 인자의 개수를 의미함.
  • argv: argument vector. 각 인자를 문자열로 저장한 배열.
    • argv[0]: 실행된 프로그램의 경로
    • argv[1] ~ argv[argc-1]: 사용자가 입력한 실제 인자
./tiny 8080
항목
argc 2
argv[0] "./tiny"
argv[1] "8080"

2. 리스닝 소켓 생성

listenfd = Open_listenfd(argv[1]);

Open_listenfd(argv[1])로 리스닝 소켓을 생성한다. 이 함수는 내부적으로 socket(), bind(), listen()을 수행한다. Open_listenfd() 와 책에 있는 open_listenfd() 와의 차이는 이미 프로젝트 상에 csapp.c 라는 파일이 들어있고 그 안에 open_listenfd의 에러 처리 기능을 추가한 매핑 함수가 Open_listenfd() 이다.

3. 무한 루프 - 클라이언트 요청 수락

while (1){
  ...
}

while (1) 루프를 통해 서버는 계속해서 클라이언트 연결 요청을 수락하고 처리한다.

4. Accept()로 연결 수락

clientlen = sizeof(clientaddr); // accept()에 넘길 주소 버퍼의 크기를 설정

// 클라이언트 연결 요청을 수락하고 전용 소켓(connfd)을 생성
connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);

클라이언트가 연결을 요청하면 connfd라는 전용 소켓이 생성된다. 이 소켓은 해당 클라이언트 요청을 처리하는 데 사용된다.

5. 클라이언트 정보 출력

Getnameinfo()를 이용해 접속한 클라이언트의 IP 주소와 포트를 문자열로 변환하고 출력한다.

/*
  clientaddr에 저장된 바이너리 주소를 문자열 형태로 변환
  hostname: 클라이언트 IP 또는 호스트명
  port: 클라이언트가 사용한 포트
  로그 및 디버깅용
*/
Getnameinfo((SA *)&clientaddr, clientlen, hostname, MAXLINE, port, MAXLINE, 0);
// 클라이언트가 연결한 주소와 포트를 출력
printf("Accepted connection from (%s, %s)\n", hostname, port);

6. 핵심 함수 호출 - doit(connfd)

연결된 소켓을 통해 클라이언트가 보낸 HTTP 요청을 처리하고 응답을 보내는 주 함수이다.

doit(connfd);  // 핵심 처리 함수 호출, 클라이언트가 보낸 HTTP 요청을 읽고 분석하여 적절한 응답을 생성

7. 소켓 닫기

해당 요청 처리가 끝나면 Close(connfd)로 연결을 종료한다.

Close(connfd); // connfd는 이 클라이언트 요청 하나만을 위해 열려 있던 소켓, 처리 끝나면 반드시 닫아야 함

2. doit() 함수 전체 코드 요약 및 설명

전체코드 확인하기

더보기
더보기

💡 주의 내 코드는 숙제 문제 코드도 있어 책의 코드와 다른 부분이 있을 수 있다.

void doit(int fd){
  int is_static; // 요청이 정적 콘텐츠인지 동적 콘텐츠인지 구분
  int is_head = 0;
  struct stat sbuf; //파일의 메타데이터를 저장하기 위한 구조체 (특정 파일에 대한 다양한 정보)
  /*
    buf: 한 줄씩 읽는 버퍼
    method, uri, version: 요청 라인의 구성 요소 ex) GET /index.html HTTP/1.1 
  */
  char buf[MAXLINE], method[MAXLINE], uri[MAXLINE], version[MAXLINE]; 
  char filename[MAXLINE], cgiargs[MAXLINE]; // 실제 서버가 처리할 파일 경로와 CGI 인자
  rio_t rio; //robust I/O 를 구현하기 위한 버퍼 기반의 I/O 상태를 담는 구조체

  Rio_readinitb(&rio, fd); // 클라이언트 소켓 fd를 rio 구조체와 연결
  Rio_readlineb(&rio, buf, MAXLINE); // 클라이언트가 보낸 첫 줄 요청 라인 (예: GET /index.html HTTP/1.1) 읽기
  printf("Request headers:\n"); // 디버깅 출력용
  printf("%s", buf);
  sscanf(buf, "%s %s %s", method, uri, version); //요청 라인을 method, uri, version으로 분리
  if (strcasecmp(method, "GET") != 0 && strcasecmp(method, "HEAD") != 0) { 
    // GET외 다른 HTTP 메소드는 지원하지 않는다. 대소문자 구분 없이 비교 strcasecmp
    clienterror(fd, method, "501", "NOT implemented", "Tiny does not implement this method");
    return;
  }
  read_requesthdrs(&rio); // 요청 헤더는 사용하지 않고 그냥 읽기만 한다.
  if (strcasecmp(method, "HEAD") == 0) {
    is_head = 1;
  }
  is_static = parse_uri(uri, filename, cgiargs); // URI 가 정적이면 filename만 동적이면 cgiargs까지 추출
  if (stat(filename, &sbuf) < 0){ // 실제로 해당 파일이 존재하는지 확인 실패하면 404 Not Fount 반환
    clienterror(fd, filename, "404", "Not found", "Tiny couldn't find this file");
    return;
  }

  if (is_static){ // 정적 콘텐츠 처리
    if (!(S_ISREG(sbuf.st_mode)) || !(S_IRUSR & sbuf.st_mode)){ // 정규 파일이고 읽기 원한이 있을 경우만 제공
      clienterror(fd, filename, "403", "Forbidden", "Tiny couldn't read the file");
      return;
    }
    serve_static(fd, filename, sbuf.st_size, is_head); // 문제 없으면 serve_static() 호출
  }
  else{ // 동적 콘텐츠 처리
    if (!(S_ISREG(sbuf.st_mode)) || !(S_IXUSR & sbuf.st_mode)){ // 실행 권한이 있는 정규 파일이어야 CGI 프로그램 실행 가능
      clienterror(fd, filename, "403", "Forbidden", "Tiny couldn't run the CGI program");
      return;
    }
    serve_dynamic(fd, filename, cgiargs); // serve_dynamic() 호출
  }
}

doit(int fd)는 다음과 같은 순서로 작동한다:

  1. 요청 라인을 파싱 한다.
  2. 지원하지 않는 메서드(GET/HEAD 외)는 거부한다.
  3. 요청 헤더를 읽는다.
  4. URI를 분석해 요청이 정적인지 동적인지 판별한다.
  5. 파일의 존재 및 권한을 확인한다.
  6. 적절한 핸들러(serve_static or serve_dynamic)를 호출해 응답을 생성한다.

1. 변수 선언 및 초기화

int is_static, is_head = 0;
struct stat sbuf;
char buf[MAXLINE], method[MAXLINE], uri[MAXLINE], version[MAXLINE];
char filename[MAXLINE], cgiargs[MAXLINE];
rio_t rio;
  • is_static: 정적/동적 콘텐츠 구분용 플래그
  • is_head: HEAD 요청 구분 플래그
  • sbuf: 파일 메타정보용 구조체 (stat() 함수 결과 저장)
  • buf: 한 줄씩 읽기 위한 버퍼
  • filename, cgiargs: 실제 파일 경로, CGI 인자 저장용
  • rio: robust I/O 상태 저장용 구조체

💡 부록 : Robust I/O 란?

갑자기 rio_t 등과 같이 rio가 붙은 함수들이 무엇인지 궁금하다면 더 보기를 클릭하여 확인하면 좋을 것이다.

더보기
더보기

왜 Robust I/O가 필요한가?

Unix의 저수준 I/O 함수들(read, write)은 다음과 같은 문제를 가진다:

  • read는 요청한 바이트 수보다 적은 수의 바이트를 반환할 수 있다 (예: 네트워크, 터미널 등).
  • 반복 호출하여 전체 데이터를 받아야 하므로 번거롭고 오류 가능성이 있음.

👉 이런 문제를 피하고, 입출력 오류, 부분 전송, 버퍼 처리 등을 자동으로 처리하기 위해 csapp.c와 csapp.h에서 rio_ 접두어가 붙은 함수들을 제공한다.

Robust I/O 인터페이스 구성

CSAPP에서 제공하는 robust I/O는 두 가지 방식으로 나뉜다:

1. Unbuffered I/O (무버퍼) 함수

  • 내부 버퍼를 사용하지 않으며, 정확히 지정한 바이트 수를 처리
함수 이름 설명
rio_readn 지정한 바이트 수만큼 읽기, 모두 읽을 때까지 반복 호출
rio_writen 지정한 바이트 수만큼 쓰기, 모두 쓸 때까지 반복 호출

2. Buffered I/O (버퍼드) 함수

  • 내부 버퍼를 사용하여 효율적으로 줄 단위 또는 특정 크기만큼 읽기

필요한 구조체

typedef struct {
    int rio_fd;                 // 읽기 대상 디스크립터
    int rio_cnt;                // 남은 바이트 수
    char *rio_bufptr;           // 버퍼 내 현재 위치
    char rio_buf[RIO_BUFSIZE];  // 내부 버퍼
} rio_t;

함수 이름 설명
rio_readinitb() rio_t 구조체를 파일 디스크립터에 연결하고 초기화
rio_readlineb() 한 줄(개행 문자 기준)을 robust하게 읽기
rio_readnb() 지정한 바이트 수를 robust하게 읽기

예시: 소켓에서 줄 단위 읽기

rio_t rio;
Rio_readinitb(&rio, connfd);
while (Rio_readlineb(&rio, buf, MAXLINE) != 0) {
    printf("%s", buf);
}
  • 클라이언트가 보낸 줄 단위 데이터를 중단 없이 안정적으로 읽어 들임
  • read를 직접 쓰면 위 코드와 같은 안정성을 보장하기 어렵다.

웹서버와 같이 정확한 내용을 읽고 쓰기를 하기 위해 안정성 보당 도구인 Robust I/O를 사용한다.

Robust I/O(Rio 패키지)에 대한 설명은 CSAPP 10.5절 “Robust Reading and Writing with the Rio Package”에서 다뤄진다.


2.  요청 라인 읽기 및 파싱

Rio_readinitb(&rio, fd);
Rio_readlineb(&rio, buf, MAXLINE);
sscanf(buf, "%s %s %s", method, uri, version);
  • HTTP 요청의 첫 줄을 읽고 "GET /index.html HTTP/1.1" 같은 형식을 파싱
  • method, uri, version으로 분리

3. 메서드 유효성 검사

if (strcasecmp(method, "GET") != 0 && strcasecmp(method, "HEAD") != 0) { 
  // GET외 다른 HTTP 메소드는 지원하지 않는다. 대소문자 구분 없이 비교 strcasecmp
  clienterror(fd, method, "501", "NOT implemented", "Tiny does not implement this method");
  return;
}
  • GET과 HEAD만 지원
  • 다른 메서드(POST, PUT 등)는 501 오류 반환

4. 헤더 읽기

read_requesthdrs(&rio);
  • 헤더 내용은 실제로 사용되진 않지만, 프로토콜 상 읽어야 함
  • rio_readlineb()로 줄 단위 읽기

5. HEAD 요청 처리 구분

if (strcasecmp(method, "HEAD") == 0) {
    is_head = 1;
}
  • HEAD는 본문 없이 응답 헤더만 보내는 요청
  • 이후 serve_static() 호출 시 이 플래그로 본문 전송 여부 제어

6. URI 분석

is_static = parse_uri(uri, filename, cgiargs);
  • /cgi-bin/ 포함 여부로 정적/동적 콘텐츠 판단
  • 정적이면 filename만 설정
  • 동적이면 filename + cgiargs 설정

7. 파일 존재 확인

if (stat(filename, &sbuf) < 0){ // 실제로 해당 파일이 존재하는지 확인 실패하면 404 Not Fount 반환
    clienterror(fd, filename, "404", "Not found", "Tiny couldn't find this file");
    return;
  }
  • 실제 디스크에 파일이 있는지 검사
  • 실패 시 404 오류 응답

8. 정적 콘텐츠 처리

if (is_static){ // 정적 콘텐츠 처리
    if (!(S_ISREG(sbuf.st_mode)) || !(S_IRUSR & sbuf.st_mode)){ // 정규 파일이고 읽기 원한이 있을 경우만 제공
      clienterror(fd, filename, "403", "Forbidden", "Tiny couldn't read the file");
      return;
    }
    serve_static(fd, filename, sbuf.st_size, is_head); // 문제 없으면 serve_static() 호출
  }
  • 정규 파일이고 사용자 읽기 권한이 있는 경우에만 응답 가능
  • 문제가 없으면 serve_static() 호출하여 파일 내용을 응답

9. 동적 콘텐츠 처리

else{ // 동적 콘텐츠 처리
    if (!(S_ISREG(sbuf.st_mode)) || !(S_IXUSR & sbuf.st_mode)){ // 실행 권한이 있는 정규 파일이어야 CGI 프로그램 실행 가능
      clienterror(fd, filename, "403", "Forbidden", "Tiny couldn't run the CGI program");
      return;
    }
    serve_dynamic(fd, filename, cgiargs); // serve_dynamic() 호출
  }
  • 정규 파일이고 사용자 실행 권한이 있는 경우에만 CGI 실행 가능
  • serve_dynamic()을 통해 CGI 프로그램 실행 및 응답 전송

다음 포스팅에 이어서 나머지 함수들도 설명하겠다.