C언어로 작은 웹서버 만들기 | HTTP GET method

설현아·2025년 5월 6일

클라이언트 HTTP GET 요청에 따라, 미리 정의된 이 home.html(정적 컨텐츠) 혹은 동적 컨텐츠를 반환하는 서버를 만드는 것이 목적이다.
※ 전체 코드는 다루지 않고 일부 핵심 함수와 피어난 궁금증에 대해 다룰 것이다.


정적 컨텐츠

동적 컨텐츠

이를 tiny 서버라고 통칭하며, tiny 서버를 구성하는 주요 서브 루틴을 정리해보자.

Tiny 서버의 동작

우선, Tiny 서버의 큰 흐름 먼저 이해하자.

1. 클라이언트의 HTTP GET 요청

클라이언트가 Tiny 서버에 HTTP GET 요청을 보낸다. 요청 형식은 두 가지로 나뉠 수 있다.

1️⃣ 정적 컨텐츠(static) : /home.html 다음과 같은 요청은 그저 home.html을 요청하는 것이라서 서버에 저장된 파일을 찾고, 해당 파일 형식으로 클라이언트에 반환해주면 된다.

2️⃣ 동적 컨텐츠(dynamic) : /cgi-bin/adder?1421&13 다음과 같은 요청은 CGI 프로그램을 실행하고, ? 뒤에 붙은 쿼리스트링을 인자로 넘겨준다. 그 실행 결과를 클라이언트에 반환해야 한다.

2. 서버 내부의 응답 생성

정적 컨텐츠 처리

디스크(물리 메모리)에서 타겟하는 파일(filename) 을 찾고 반환해야 해야 한다.
따라서, 타겟 파일에 접근할 파일 디스크립터srcfd를 생성한다.

mmap() 으로 파일 내용을 메모리처럼 읽으며 바로 소켓을 통해 클라이언트로 전송한다.

동적 컨텐츠 처리

CGI 프로그램을 실행한 결과를 클라이언트 응답으로 전송해야 한다.
따라서, 현재 프로세스에서 fork()를 통해 자식 프로세스를 생성한다.

자식 프로세스로 QUERY STRING 정보를 넘겨준다.
쿼리 스트링 정보란, ? 이후의 정보로, 1421&13를 의미한다.

자식 프로세스는 CGI 프로그램을 실행하며 출력을 클라이언트에 직접 전송한다.

'CGI Common Gateway Interface?'
서버가 외부 프로그램을 실행해서 그 결과를 클라이언트에게 전달할 수 있게 해주는 방식이다.

매번 같은 응답이 아닌,
사용자 요청에 따라 동적으로 변경되는 응답을 만들어낸다.

보통 cgi-bin 디렉토리에 실행 파일을 포함하여 동작시킨다.
예컨대, add.c adder.c 등의 실행 파일이 될 수 있다.

큰 흐름을 이해했다면 세부적인 주요 함수를 살펴보자!

doit()

소켓 accept 이후에 클라이언트에 응답을 생성하기 위한 함수이다.
연결 완료와 함께 요청 헤더를 아래와 같은 형식으로 출력하고, 그 헤더에 맞는 응답 처리를 한다.

헤더에는 method, uri, version 정보가 있다.

  1. method가 GET이 아니라면 에러 처리 한다.(본 실습에서는 GET만 사용한다.)
  2. uri를 확인하고 단순 파일을 요청하는 정적 컨텐츠 요청인지, 인자를 통한 동적 컨텐츠 요청인지 판단한다.
  3. 컨텐츠 타입에 따른 처리를 하도록 해당 서브루틴으로 넘겨준다.

/* doit 함수
 *
 * 클라이언트와 연결된 소켓 디스크립터, connfd를 인자로 받는다.
 * fd를 통해 클라이언트의 요청이 정적 컨텐츠인지, 동적 컨텐츠인지 판별하고 해당 루틴으로 연결한다.
 *
 * 다음의 경우 예외 처리를 한다.
 * 1. GET 요청이 아닐 경우
 * 2. 정상적인 파일명이 아닐 경우
 * 3. 정적 컨텐츠일 경우
 * 4. 읽기 권한을 가지지 않은 경우
 */
void doit(int fd)
{
  int is_static; // 단순 flag이면 왜 int형을 쓰지? short 쓰면 안 되나? bool이나.
  struct stat sbuf;
  char buf[MAXLINE], method[MAXLINE], uri[MAXLINE], version[MAXLINE];
  char filename[MAXLINE], cgiargs[MAXLINE];
  rio_t rio;

  /* Read reauest line and headers */
  Rio_readinitb(&rio, fd);
  Rio_readlineb(&rio, buf, MAXLINE); // buf로 읽음
  printf("Request headers:\n");
  printf("%s", buf); // GET /home.html HTTP/1.1
  sscanf(buf, "%s %s %s", method, uri, version);
  if (strcasecmp(method, "GET"))
  {
    clienterror(fd, method, "501", "Not implemented", "Tiny dose not implement this method");
    return;
  }
  read_requesthdrs(&rio); // GET 요청이라면 헤더 확인(User-Agent: curl/7.68.0)

  /* Pars URI from GET request */
  is_static = parse_uri(uri, filename, cgiargs);
  if (stat(filename, &sbuf) < 0) // file의 모든 속성을 buf에 덮어씀
  {
    clienterror(fd, filename, "404", "Not found", "Tiny couldn't find this file");
    return;
  }

  if (is_static) // 정적 컨텐츠 여부(cgi-bin으로 판별)
  {
    if (!(S_ISREG(sbuf.st_mode)) || !(S_IRUSR & sbuf.st_mode)) // 권한 확인
    {
      clienterror(fd, filename, "403", "Forbidden", "Tiny couldn't read this file");
      return;
    }
    serve_static(fd, filename, sbuf.st_size); // 정적 컨텐츠 서비스
  }
  else // 동적 컨텐츠
  {
    if (!(S_ISREG(sbuf.st_mode)) || !(S_IXUSR & sbuf.st_mode)) // 권한 확인
    {
      clienterror(fd, filename, "403", "Forbidden", "Tiny couldn't run the CGI program");
      return;
    }
    serve_dynamic(fd, filename, cgiargs); // 동적 컨텐츠 서비스
  }
}

의문점

int is_static;

단순 0과 1만 가지는 flag 역할을 하는데 4바이트의 크기를 가져야 할 이유가 있을까?
short 자료형을 쓰는 편이 훨씬 메모리 효율적이지 않은가?

short 자료형이 효율적인 경우

  1. 구조체(struct)를 사용할 때

    struct short_stuct {
    	short type;
    	short flag;
    	short length;
    };

    이렇게 사용한다면 총 3개의 변수를 6바이트(2bytes*3)의 공간에 저장할 수 있다.
    하지만 int형이었다면 12바이트(4bytes*3)의 공간이 필요했을 것이다.

    4바이트 정렬까지 고려한다면 각각 8바이트, 12바이트로 저장된다. 내부 단편화로 인한 메모리 누수가 발생한다.

    따라서, 구조체를 사용할 때는 short 자료형으로 메모리 효율성을 높이는 것이 좋다.

  2. 하드웨어 레벨 데이터와 직접 대응할 때

    예컨대, 포트번호(uint16_t)의 경우 short 자료형을 사용해야 한다.
    네트워크 프토토콜이 16비트 단위로 데이터를 다룬다면 short 자료형으로 정확히 맞춰줘야 한다.

short 자료형이 오히려 비효율적인 경우

  1. 함수 내 로컬 변수로 사용하는 경우

    대부분의 시스템에서는 short가 int로 자동 승격되는 경우가 많다. C 언어에서는 short를 함수의 인자로 사용하거나, 산술 연산을 하는 경우 int로 승격한다. 연산할 때 결국 int처럼 쓰이기 때문에 성능 차이가 없다.

    오히려 short를 사용하면,

    • 표준 라이브러리 함수와 충돌이 생길 수 있다. → 대부분 int 기준으로 만들어져 있기 때문
    • 플랫폼 간에 short 크기가 다르게 해석될 여지가 있다.
  2. 메모리 절약이 필요없는 상황

    현대 시스템은 몇 바이트 절약하는 것 보다, 속도와 일관성을 중시한다.

파일 권한 퍼미션 마스크

모든 유닉스 파일은 9개의 권한 비트를 가진다.

각 블록은 비트 단위이다.

r = read(읽기 권한)
w = write(쓰기 권한)
x = execute(실행 권한)

아래의 퍼미션 마스크를 보자.

소유자는 읽고 쓰고 실행하는 모든 권한이 있고,
소유자의 그룹 사용자는 읽고 실행하는 권한이 있다.
그외 사용자는 쓰기 권한만 있다.

이는 chmod로 752로 표현된다.

chmod권한
7rwx
6rw-
5r-x
4r--
3-wx
2-w-
1--x
0---

다시 돌아와서, doit 함수 내부에서 권한 확인을 할 때의 S_@@@ 로 정의된 매크로는 다음과 같은 의미를 가진다.

접근할 파일에 대하여 ‘소유자가 read 권한이 있는 지’, ‘소유자가 execute 권한이 있는 지’ 확인한다.

parse_uri()

클라이언트 요청 uri를 통해 정적/동적 컨텐츠를 확인하고 응답 서브루틴을 위한 cgiargs , filename 을 설정한다.

/* parse_uri 함수
 *
 * 클라이언트 요청의 uri를 통해 정적/동적 컨텐츠 여부를 확인한다.
 * 정적 컨텐츠라면 그저 filename을 '.uri' 파일 직접 참조 형식으로 덮어쓴다.
 * 동적 컨텐츠라면 인자를 cgiargs에 담은 후 filename을 '.uri' 파일 직접 참조 형식으로 덮어쓴다.
 */
int parse_uri(char *uri, char *filename, char *cgiargs)
{
  char *ptr;

  /* cgi-bin은 전통적으로 동적 컨텐츠 (CGI 프로그램)이 들어있는 디렉토리 이름 */
  if (!strstr(uri, "cgi-bin")) // 정적 컨텐츠라면
  {
    strcpy(cgiargs, "");
    strcpy(filename, ".");
    strcat(filename, uri);
    if (uri[strlen(uri) - 1] == '/')
      strcat(filename, "home.html");
    return 1;
  }
  else // 동적 컨텐츠라면
  {
    ptr = index(uri, '?'); // '?' 위치를 찾음
    if (ptr)               // 파일명 외에도 인자가 있을 경우
    {
      strcpy(cgiargs, ptr + 1); // cgiargs에 '?' 이후 문자열(인자)을 복사
      *ptr = '\0';              // '?' 을 '\0' 로 바꿔서 uri 문자열 절단(아래에서 경로만 filename에 담기 때문)
    }
    else // 인자가 없을 경우
      strcpy(cgiargs, "");
    strcpy(filename, ".");
    strcat(filename, uri);
    return 0;
  }
}

cgi-bin

전통적으로 동적 컨텐츠는 /cgi-bin 디렉토리에 저장되도록 구성한다.

웹 서버에서 CGI(Common Gateway Interface) 프로그램을 실행하기 위해 사용되는 특별한 디렉토리의 일반적인 이름이다.

query string

GET 요청에서 인자들은 URI로 전달된다.

? 문자는 파일 이름과 인자를 구분하며, & 문자는 각 인자를 구분한다.

https://search.naver.com/search.naver?where=nexearch&sm=top_hty&fbm=0&ie=utf8&query=당근&ackey=ujgh8wfw

네이버 검색창에 당근을 검색하면 다음의 URL로 이동한다. ? 를 기준으로 & 로 구분되며 where, sm, fbm, query, ackey의 인자가 포함된 모습을 볼 수 있다.

serve_static()

클라이언트의 정적 컨텐츠 요청에 따른 서브 루틴이다.

물리 메모리에 저장된 파일을 찾아, 반환해주어야 하므로 이를 처리하는 파일 디스크립터를 서버 내부에서 생성하여 메모리에 접근한다.

  1. srcfd를 사용하여 물리 메모리에 저장된 요청 파일을 읽어온다.
  2. mmap()으로 파일 내용을 메모리에 매핑하여 srcp 포인터로 파일을 메모리처럼 접근한다.
  3. srcp에서 filesize만큼의 데이터를 파싱하고, 소켓을 통해 클라이언트로 전송한다.

/* serve_static 함수
 *
 * 정적 컨텐츠인 경우 응답 헤더를 세팅한 후, 해당 컨텐츠를 반환한다.
 * filename을 '.uri' 파일 직접 참조 형식으로 덮어쓴다.
 */
void serve_static(int fd, char *filename, int filesize)
{
  int srcfd; // 서버 내부에서 디스크의 파일을 읽어오기 위한 파일 디스크립터 생성
  char *srcp, filetype[MAXLINE], buf[MAXLINE];

  /* Send response headers to client */
  get_filetype(filename, filetype);
  sprintf(buf, "HTTP/1.0 200 OK\r\n");
  sprintf(buf, "%sServer: Tiny Web Server\r\n", buf);
  sprintf(buf, "%sConnection: close\r\n", buf);
  sprintf(buf, "%sContent-length: %d\r\n", buf, filesize);
  sprintf(buf, "%sContent-type: %s\r\n\r\n", buf, filetype);
  Rio_writen(fd, buf, strlen(buf));
  printf("Response headers: \n");
  printf("%s", buf);

  /* Send response body to client */
  srcfd = Open(filename, O_RDONLY, 0);                        // 클라이언트에서 요청한 파일을 읽어옴
  srcp = Mmap(0, filesize, PROT_READ, MAP_PRIVATE, srcfd, 0); // Mmap()으로 파일 내용을 메모리에 매핑 → srcp 포인터로 파일을 메모리처럼 읽음
  Close(srcfd);
  Rio_writen(fd, srcp, filesize); // 해당 메모리에 접근하여 소켓을 통해 클라이언트로 전송
  Munmap(srcp, filesize);         // 매핑 해제
}

serve_dynamic()

동적 컨텐츠 요청에 따른 응답을 처리하는 서브 루틴이다.


void serve_dynamic(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) // fork()의 2가지 return [1. 부모 프로세스는 자식 프로세스의 PID], [2. 자식 프로세스는 0]
  /* 자식 프로세스에서만 아래 루틴을 실행한다. */
  {
    setenv("QUERY_STRING", cgiargs, 1);   // QUERY_STRING 환경변수 설정(CGI 스크립트가 실행될 때 자동으로 읽을 수 있음)
    Dup2(fd, STDOUT_FILENO);              // 표준 출력(터미널)을 소켓 fd와 연결함으로써 printf() 등의 출력이 클라이언트에게 직접 전송
    Execve(filename, emptylist, environ); // 기존 프로세스를 완전히 CGI 프로그램으로 덮어씀
  }
  Wait(NULL); /* Parent waits for and reaps child */
}

환경 변수 env

운영체제가 관리하고 제공하는 일종의 키-값 문자열 목록이다.
운영체제는 프로세스를 생성할 때 두 가지 정보를 초기화한다.

  1. argv[] → 명령줄 인자
  2. envp[] → 환경 변수 목록

envp는 메모리 상에서 data 영역의 최상단에 위치하게 되며, export 설정을 한다면 자식 프로세스에게 전달할 수 있다.

MODE=debug   # export 하지 않으면, 자식에게 전달되지 않는다.
export MODE=debug  # 부모 프로세스의 환경변수를 포함하여 메모리 공간이 복사된다.

따라서, 아래의 기능을 수행할 수 있다.

  • 전역 변수와 달리, 부모 프로세스에서 자식 프로세스로 전달이 가능하다.
  • 운영체제가 제공하기에, 런타임과 관계없이 쉘에서도 설정할 수 있다.
  • getenv(), setenv(), putenv() 로 어디에서든 수정, 참조할 수 있다.

자식 프로세스 생성 및 실행

자식 프로세스는 부모 프로세스의 복제본이다.

실제로 메모리의 동작을 이야기해보자면,

  1. fork() 직후에는 자식과 부모가 같은 물리 메모리를 공유한다.(copy-on-write)
  2. 둘 중 하나가 메모리를 변경하려 할 때, 해당 페이지가 복사된다.

현재 serve_dynamic() 루틴에서는 부모 프로세스를 복제하고, 곧바로 exec()을 호출하여 다른 프로그램을 자기 메모리에 덮어쓴다.

exec() 실행 시점에서 부모 프로세스와 완전히 분리된 주소 공간이 할당 되며, 새로운 실행 파일로 대체된다.

다만, CGI 프로그램이 실행되기 전에 setenv()로 설정한 환경 변수는 exec에 인자로 넘긴 environ을 통해 전달되므로, 새로운 프로그램에서도 여전히 접근이 가능하다.

Execve(filename, emptylist, environ);

execve() 인자

위에서 왜 저런 구조로 전달한 걸까? execve() 함수에서의 인자를 조금 더 살펴보자.

  • filename: 실행할 프로그램 경로 (./home.html)
  • argv[]: 실행 파일에 전달할 인자 리스트 (argv[0] = "add", argv[1] = "1", argv[2] = NULL)
  • envp[]: 실행 시 함께 넘길 환경 변수 배열(envp[0] = "QUERY_STRING=x=1&y=2", envp[1] = NULL ****)

tiny 프로그램에서는 환경 변수(envp[])에 클라이언트 요청의 쿼리스트링을 저장하여 자식 프로세스로 전달한다.

왜 인자가 아닌 환경변수로 전달하는가, 궁금증이 생겨났다면 그저 HTTP 서버와 CGI 프로그램의 표준 규약 때문이다. 클라이언트의 요청 정보(QUERY_STRING)는 환경 변수로 CGI에 전달하는 것이 표준이다!

profile
어서오세요! ☺️ 후회 없는 내일을 위해 오늘을 열심히 살아가는 개발자입니다.

0개의 댓글