CS:APP Tiny Web Server: HTTP 요청을 읽고 정적 파일을 응답하기까지
echo 서버와 Tiny 서버의 차이를 바탕으로 doit, HTTP 요청 파싱, 응답 메시지, 정적 콘텐츠 제공 흐름을 정리한다.
CS:APP의 echo 서버와 Tiny 서버는 겉으로 보면 비슷한 구조를 가진다.
둘 다 listenfd를 열고, 클라이언트 연결을 accept()한 뒤, 연결된 소켓 디스크립터 connfd를 처리 함수에 넘긴다.
listenfd = Open_listenfd(port);
while (1) {
connfd = Accept(listenfd, ...);
처리함수(connfd);
Close(connfd);
}
하지만 처리 함수의 역할은 완전히 다르다.
- echo 서버:
echo(connfd) - Tiny 서버:
doit(connfd)
echo 서버는 받은 바이트를 해석하지 않고 그대로 다시 보낸다. 반면 Tiny 서버는 HTTP 요청을 해석하고, 그에 맞는 HTTP 응답을 만들어야 한다.
echo 서버와 Tiny 서버의 차이
echo 서버의 핵심은 단순하다.
Rio_readinitb(&rio, connfd);
while ((n = Rio_readlineb(&rio, buf, MAXLINE)) != 0) {
Rio_writen(connfd, buf, n);
}
클라이언트가 "hello"를 보내면 서버는 "hello"를 그대로 돌려준다. 메시지의 의미를 해석하지 않는다.
Tiny 서버는 다르다. 클라이언트가 다음과 같은 HTTP 요청을 보내면:
```plain text GET /home.html HTTP/1.1
Tiny는 이 한 줄을 해석해야 한다.
```plain text
method = GET
uri = /home.html
version = HTTP/1.1
그리고 다음 질문들에 답해야 한다.
GET요청인가?/home.html파일이 실제로 있는가?- 읽을 수 있는 정적 파일인가?
- 어떤
Content-type으로 보내야 하는가? - 성공 또는 실패에 대해 어떤 HTTP 응답을 보내야 하는가?
정리하면 다음과 같다.
| 구분 | echo 서버 | Tiny 서버 |
|---|---|---|
| 처리 함수 | echo(connfd) |
doit(connfd) |
| 입력 | 일반 문자열 | HTTP 요청 |
| 해석 여부 | 해석하지 않음 | 요청 라인, URI, 헤더 해석 |
| 출력 | 받은 내용 그대로 반환 | HTTP 응답 생성 |
| 예시 출력 | hello |
HTTP/1.0 200 OK ... |
doit의 첫 번째 역할: 요청 라인 읽기
Tiny 서버에서 doit()는 클라이언트 요청 하나를 처리한다.
처음 구현할 때는 요청 라인을 읽고, method, uri, version으로 나누는 흐름부터 잡으면 된다.
void doit(int connfd)
{
char buf[MAXLINE];
char method[MAXLINE], uri[MAXLINE], version[MAXLINE];
rio_t rio;
Rio_readinitb(&rio, connfd);
if (Rio_readlineb(&rio, buf, MAXLINE) <= 0) {
return;
}
if (sscanf(buf, "%s %s %s", method, uri, version) != 3) {
printf("Bad request line: %s", buf);
return;
}
printf("Request line: %s", buf);
printf("method=%s, uri=%s, version=%s\n", method, uri, version);
if (strcmp(method, "GET")) {
printf("Tiny only supports GET for now: %s\n", method);
return;
}
read_requesthdrs(&rio);
}
핵심 단계는 네 가지다.
connfd에서 요청 라인 한 줄을 읽는다.- 그 줄을
method,uri,version으로 나눈다. GET요청인지 확인한다.- 남은 요청 헤더를 읽어 비운다.
sscanf는 이미 읽은 문자열을 나눈다
scanf()는 표준 입력에서 값을 읽어 변수에 넣는다.
반면 sscanf()는 이미 메모리에 있는 문자열을 읽어서 변수에 넣는다.
sscanf(buf, "%s %s %s", method, uri, version);
예를 들어 buf에 다음 문자열이 들어 있다면:
```plain text GET /home.html HTTP/1.1\r\n
`%s`는 공백 전까지 읽기 때문에 다음처럼 나뉜다.
| 변수 | 값 | 의미 |
| --- | --- | --- |
| `method` | `GET` | 클라이언트가 원하는 동작 |
| `uri` | `/home.html` | 서버에서 요청한 대상 |
| `version` | `HTTP/1.1` | HTTP 버전 |
## RIO는 HTTP 요청을 줄 단위로 읽기 좋다
HTTP 요청은 줄 단위 구조를 가진다.
```plain text
GET /home.html HTTP/1.1\r\n
Host: localhost:8000\r\n
User-Agent: curl/8.0\r\n
\r\n
첫 줄은 요청 라인이고, 그 뒤는 헤더다. 빈 줄 \r\n이 나오면 헤더가 끝난다.
이런 형식은 Rio_readlineb()로 읽기 좋다.
void read_requesthdrs(rio_t *rp)
{
char buf[MAXLINE];
do {
if (Rio_readlineb(rp, buf, MAXLINE) <= 0) {
return;
}
printf("%s", buf);
} while (strcmp(buf, "\r\n"));
}
Tiny의 기본 구현에서는 Host, User-Agent, Accept 같은 헤더를 적극적으로 사용하지 않는다. 그래도 헤더 끝까지 읽어야 다음 처리 흐름이 깔끔해진다.
HTTP 응답은 상태 라인, 헤더, 본문으로 구성된다
브라우저가 /home.html을 요청했고, 서버가 파일을 정상적으로 찾았다면 Tiny는 대략 다음 응답을 보낸다.
```plain text HTTP/1.0 200 OK Server: Tiny Web Server Connection: close Content-length: 120 Content-type: text/html
...
응답은 세 부분으로 나눌 수 있다.
| 부분 | 예시 | 의미 |
| --- | --- | --- |
| 상태 라인 | `HTTP/1.0 200 OK` | 요청 처리 결과 |
| 헤더 | `Content-length`, `Content-type` | 본문을 해석하는 데 필요한 정보 |
| 본문 | `<html>...</html>` | 실제로 클라이언트에게 보낼 데이터 |
`Content-length`는 빈 줄 다음에 오는 본문 크기를 알려 준다. 브라우저는 이 값을 보고 앞으로 몇 바이트를 읽으면 응답 본문이 끝나는지 알 수 있다.
`Content-type`은 본문을 어떻게 해석해야 하는지 알려 준다.
```plain text
home.html -> text/html
godzilla.gif -> image/gif
godzilla.jpg -> image/jpeg
plain text -> text/plain
정적 콘텐츠 제공 흐름
Tiny 서버가 정적 파일을 제공하는 흐름은 다음 순서로 정리할 수 있다.
```plain text
- 요청 URI를 파일 경로로 바꾼다.
- stat()으로 파일이 존재하는지 확인한다.
- 정규 파일인지, 읽기 권한이 있는지 확인한다.
- 확장자를 보고 MIME type을 정한다.
- HTTP 응답 헤더를 보낸다.
- 파일 본문을 클라이언트에게 보낸다. ```
이 흐름에서 주요 함수는 다음과 같다.
parse_uri()stat()S_ISREG()S_IRUSRget_filetype()serve_static()
parse_uri: URI를 파일 경로로 바꾸기
브라우저 요청 라인은 이런 형태다.
```plain text GET /home.html HTTP/1.0
`doit()`는 여기서 `uri`를 꺼낸다.
```plain text
uri = /home.html
하지만 Unix 파일 시스템에서 실제 파일을 찾으려면 서버 기준의 파일 경로가 필요하다.
```plain text /home.html –parse_uri()–> ./home.html
정적 콘텐츠 요청이라면 CGI 인자는 필요 없으므로 `cgiargs`는 빈 문자열이 된다.
```plain text
uri = /home.html
filename = ./home.html
cgiargs = ""
stat: 파일이 실제로 있는지 확인하기
parse_uri()가 만든 filename은 문자열일 뿐이다. 그 파일이 실제로 존재하는지는 아직 모른다.
그래서 stat()으로 파일 정보를 가져온다.
struct stat sbuf;
if (stat(filename, &sbuf) < 0) {
clienterror(connfd, filename, "404", "Not found",
"Tiny could not find this file");
return;
}
stat()이 성공하면 sbuf 안에 파일 타입, 크기, 권한 같은 정보가 들어간다.
실패하면 해당 파일을 찾을 수 없거나 접근할 수 없는 것이므로 404 Not found 응답을 보내면 된다.
S_ISREG와 S_IRUSR: 보낼 수 있는 파일인지 확인하기
파일이 존재한다고 해서 무조건 보내면 안 된다.
예를 들어 디렉터리일 수도 있고, 읽기 권한이 없을 수도 있다.
if (!(S_ISREG(sbuf.st_mode)) || !(S_IRUSR & sbuf.st_mode)) {
clienterror(connfd, filename, "403", "Forbidden",
"Tiny could not read the file");
return;
}
여기서 검사하는 내용은 두 가지다.
S_ISREG(sbuf.st_mode): 정규 파일인가?S_IRUSR & sbuf.st_mode: 파일 소유자에게 읽기 권한이 있는가?
둘 중 하나라도 만족하지 않으면 Tiny는 파일을 보내지 않고 403 Forbidden을 응답한다.
get_filetype: 확장자로 MIME type 정하기
브라우저는 응답 본문만 보고 타입을 확정하지 않는다. 서버가 보내는 Content-type 헤더를 보고 본문을 해석한다.
Tiny는 파일 이름의 확장자를 보고 MIME type을 정한다.
get_filetype(filename, filetype);
예시는 다음과 같다.
| 파일 | MIME type |
|---|---|
home.html |
text/html |
godzilla.gif |
image/gif |
godzilla.jpg |
image/jpeg |
serve_static: 응답 헤더와 파일 본문 보내기
마지막 단계는 실제 응답을 보내는 것이다.
serve_static(connfd, filename, sbuf.st_size);
serve_static()은 먼저 응답 헤더를 보낸다.
```plain text HTTP/1.0 200 OK Server: Tiny Web Server Connection: close Content-length: 120 Content-type: text/html
그다음 파일 본문을 보낸다.
```html
<html>
<head><title>test</title></head>
<body>
<img align="middle" src="godzilla.gif">
Dave O'Hallaron
</body>
</html>
결국 브라우저가 받는 것은 HTTP 형식으로 포장된 파일 데이터다.
정리
echo 서버와 Tiny 서버는 accept() 이후 처리 함수가 다르다.
echo 서버는 받은 데이터를 그대로 돌려주는 프로그램이다.
Tiny 서버는 HTTP 요청을 해석하고, 파일 시스템을 확인하고, 브라우저가 이해할 수 있는 HTTP 응답을 만들어 보내는 프로그램이다.
전체 흐름은 이렇게 묶을 수 있다.
plain text
connfd
↓
Rio_readlineb()로 요청 라인 읽기
↓
sscanf()로 method, uri, version 분리
↓
GET 요청인지 확인
↓
read_requesthdrs()로 헤더 끝까지 읽기
↓
parse_uri()로 파일 경로 만들기
↓
stat()으로 존재 여부 확인
↓
S_ISREG, S_IRUSR로 파일 타입과 권한 확인
↓
get_filetype()으로 Content-type 결정
↓
serve_static()으로 HTTP 응답 전송
Tiny 서버를 이해할 때는 doit() 하나만 외우기보다, 요청을 해석하고 응답을 조립하는 전체 흐름으로 보는 것이 가장 중요하다.