정글 [week8] WIL + HTTP API 구현
CS:APP Web Proxy Lab을 시작하기 전에 Echo 서버, Tiny 웹 서버, HTTP 요청/응답, 정적/동적 컨텐츠 처리 흐름을 정리한다.
1. 이번 주 목표
이번 주 목표는 CS:APP Web Proxy Lab을 본격적으로 시작하기 전에, 네트워크 프로그램의 기본 흐름을 이해하고 Tiny 웹 서버를 직접 구현해 보는 것이었다.
단순히 코드를 따라 치는 것이 아니라, 클라이언트가 요청을 보내면 서버가 어떤 순서로 읽고, 해석하고, 응답하는지를 흐름 중심으로 이해하는 데 집중했다.
구체적인 목표는 다음과 같았다.
- Echo 서버와 Echo 클라이언트를 통해 소켓 통신 흐름 복습하기
listenfd와connfd의 역할 차이 이해하기- CS:APP의 Rio I/O가 왜 필요한지 이해하기
- HTTP 요청 메시지와 응답 메시지 구조 이해하기
- Tiny 웹 서버에서 정적 컨텐츠와 동적 컨텐츠 처리 구현하기
stat,mmap,fork,execve,dup2, CGI 흐름 이해하기
2. 시도한 접근 방식
처음부터 Tiny 서버 전체를 한 번에 구현하려고 하지 않고, Echo 서버의 흐름부터 다시 확인했다.
Echo 서버에서는 기본적으로 다음 흐름이 반복된다.
```plain text Open_listenfd -> Accept -> echo(connfd) -> Close(connfd)
이 구조를 먼저 이해한 뒤, Tiny 서버에서는 `echo(connfd)` 자리에 `doit(connfd)`가 들어간다고 생각했다.
```plain text
Open_listenfd
-> Accept
-> doit(connfd)
-> Close(connfd)
이렇게 접근한 이유는 Tiny 웹 서버도 결국 “클라이언트와 연결된 소켓에서 요청을 읽고, 그 요청에 맞는 응답을 다시 쓰는 프로그램”이기 때문이다.
그다음에는 Tiny 서버를 기능 단위로 나누어 구현했다.
| 함수 | 역할 |
|---|---|
doit |
요청 하나를 처리하는 중심 함수 |
read_requesthdrs |
HTTP 요청 헤더를 끝까지 읽는 함수 |
parse_uri |
URI를 파일 경로와 CGI 인자로 나누는 함수 |
clienterror |
에러 상황에서 HTTP 에러 응답을 만드는 함수 |
serve_static |
정적 파일을 클라이언트에게 보내는 함수 |
get_filetype |
파일 확장자에 맞는 MIME type을 결정하는 함수 |
serve_dynamic |
CGI 프로그램을 실행해 동적 응답을 만드는 함수 |
이렇게 나눈 이유는 HTTP 요청 처리 과정이 단계별로 분명하기 때문이다. 요청 라인을 읽고, 헤더를 소비하고, URI를 해석하고, 파일 상태를 검사한 뒤, 정적/동적 응답으로 분기한다. 각 단계를 함수로 분리하면 전체 흐름을 따라가기 쉽고, 문제가 생겼을 때 어느 단계에서 깨졌는지도 확인하기 쉽다.
3. 문제와 해결 과정
listenfd와 connfd의 차이
가장 먼저 헷갈렸던 부분은 listenfd와 connfd의 차이였다.
처음에는 둘 다 파일 디스크립터이기 때문에 비슷한 역할이라고 생각했지만, 실제로는 역할이 완전히 달랐다.
listenfd: 클라이언트의 연결 요청을 기다리는 소켓connfd: 실제 클라이언트 한 명과 통신하기 위해Accept가 만들어 주는 연결 소켓
즉, 서버는 listenfd로 문을 열어두고, 클라이언트가 들어오면 connfd를 통해 그 클라이언트와 대화한다. 이 차이를 이해하니 Accept -> 처리 함수 -> Close 구조가 훨씬 명확해졌다.
Rio I/O가 필요한 이유
두 번째로 어려웠던 부분은 Rio I/O였다.
처음에는 그냥 read, write를 쓰면 되는데 왜 Rio_readinitb, Rio_readlineb, Rio_writen 같은 래퍼를 쓰는지 헷갈렸다.
하지만 네트워크 I/O에서는 한 번의 read나 write가 요청한 만큼 항상 처리된다고 보장할 수 없다. 일부만 읽히거나 일부만 쓰일 수 있다. 그래서 Rio는 내부 버퍼를 두고, 중간에 끊길 수 있는 I/O를 더 안정적으로 처리하기 위해 사용한다.
특히 HTTP 요청처럼 줄 단위로 읽어야 하는 데이터에서는 Rio_readlineb가 흐름을 단순하게 만들어 준다.
HTTP 요청 헤더의 끝을 판단하는 방식
세 번째 문제는 HTTP 요청 메시지의 끝을 판단하는 방식이었다.
HTTP 요청은 요청 라인 다음에 여러 헤더가 오고, 헤더의 끝은 빈 줄 \r\n으로 표시된다.
```plain text GET /home.html HTTP/1.1 Host: localhost:8000 User-Agent: curl/8.0
따라서 `read_requesthdrs`에서는 헤더 내용을 실제로 사용하지 않더라도 빈 줄이 나올 때까지 계속 읽어야 했다. 이 과정을 생략하면 아직 소켓에 남아 있는 헤더 때문에 이후 처리 흐름이 꼬일 수 있다는 점을 알게 되었다.
### URI 파싱
네 번째로는 URI 파싱이 중요했다.
예를 들어 `/` 요청은 실제 파일 시스템에서는 `./home.html`로 바꿔야 한다. `/cgi-bin/adder?a=123&b=456` 같은 요청은 실행 파일 경로와 쿼리 문자열을 분리해야 한다.
```plain text
/cgi-bin/adder?a=123&b=456
-> filename: ./cgi-bin/adder
-> cgiargs: a=123&b=456
이 흐름을 구현하면서 URI는 단순한 문자열이 아니라, 서버가 어떤 리소스를 제공할지 결정하는 핵심 입력값이라는 것을 배웠다.
Content-length가 필요한 이유
다섯 번째로는 정적 컨텐츠 응답에서 Content-length가 왜 필요한지 이해했다.
HTTP 응답은 다음 순서로 구성된다.
```plain text 상태 라인 헤더 빈 줄 본문
클라이언트는 `Content-length`를 보고 본문을 몇 바이트 읽어야 하는지 판단한다. 그래서 에러 응답을 만들 때도 HTML body를 먼저 만들고, 그 길이를 계산한 뒤 헤더에 넣어야 했다.
`sprintf`의 역할도 여기서 더 분명해졌다. `sprintf`는 화면에 바로 출력하는 함수가 아니라, 문자열을 메모리 버퍼 안에 조립하는 함수다. HTTP 응답 body를 먼저 버퍼에 만들고, 그 길이를 계산한 뒤 헤더를 구성할 수 있다.
### CGI와 동적 컨텐츠 처리
마지막으로 동적 컨텐츠 처리에서 CGI 흐름이 가장 인상적이었다.
Tiny 서버는 직접 계산 결과를 만들지 않고, `fork`로 자식 프로세스를 만든 뒤 `execve`로 CGI 프로그램을 실행한다. 이때 `QUERY_STRING` 환경변수에 인자를 넣고, `dup2(fd, STDOUT_FILENO)`를 사용해 CGI 프로그램의 표준 출력이 클라이언트 소켓으로 나가게 만든다.
즉, CGI 프로그램 입장에서는 그냥 `printf`를 하는 것처럼 보이지만, 실제 출력은 브라우저로 전달된다.
이 구조를 통해 "프로세스의 표준 출력도 파일 디스크립터를 바꾸면 네트워크 응답이 될 수 있다"는 점을 배웠다.
## 4. 새롭게 배운 점
이번 주 가장 큰 학습 포인트는 웹 서버가 특별한 마법이 아니라는 점이었다.
웹 서버는 결국 다음 일을 반복하는 프로그램이었다.
```plain text
연결을 받는다
-> 요청을 읽는다
-> 요청을 파싱한다
-> 파일 또는 프로그램을 찾는다
-> HTTP 형식으로 응답한다
-> 연결을 닫는다
또한 소켓도 파일 디스크립터로 다룰 수 있다는 점이 중요했다. 파일을 읽고 쓰듯이 네트워크 연결에도 read, write 계열 함수를 사용할 수 있었다. 이 개념을 이해하니 connfd, Rio_writen, dup2 같은 함수들이 하나의 흐름으로 연결되었다.
정적 컨텐츠와 동적 컨텐츠의 차이
정적 컨텐츠와 동적 컨텐츠의 차이도 명확해졌다.
정적 컨텐츠는 이미 존재하는 파일을 읽어서 그대로 보내는 방식이다. 그래서 stat으로 파일 존재 여부와 권한을 확인하고, mmap으로 파일 내용을 메모리에 매핑한 뒤 Rio_writen으로 전송한다.
반면 동적 컨텐츠는 서버가 CGI 프로그램을 실행해서 그 결과를 응답으로 보내는 방식이다. 그래서 파일 읽기보다 프로세스 제어가 중요해진다. fork, execve, wait, setenv, dup2가 함께 사용되는 이유가 여기에 있었다.
| 구분 | 정적 컨텐츠 | 동적 컨텐츠 |
|---|---|---|
| 응답 대상 | 이미 존재하는 파일 | CGI 프로그램 실행 결과 |
| 핵심 작업 | 파일 상태 확인 후 전송 | 자식 프로세스 생성 후 프로그램 실행 |
| 주요 함수 | stat, mmap, Rio_writen |
fork, execve, wait, setenv, dup2 |
| 예시 | HTML, 이미지, CSS 파일 | /cgi-bin/adder 실행 결과 |
자주 헷갈렸던 부분
이번 학습에서 자주 헷갈렸던 부분은 다음과 같다.
listenfd는 연결을 기다리는 소켓이고,connfd는 실제 통신용 소켓이다.- HTTP 헤더는 사용하지 않더라도 빈 줄까지 반드시 읽어야 한다.
strcmp는 두 문자열이 같을 때0을 반환한다.sprintf는 출력하는 함수가 아니라 문자열을 메모리 버퍼에 조립하는 함수다.Content-length를 보내려면 body의 길이를 먼저 알아야 한다.mmap후에는 파일 디스크립터를 닫아도 매핑된 메모리를 통해 파일 내용을 보낼 수 있다.- CGI 프로그램의
printf결과가 클라이언트로 가는 이유는dup2로 표준 출력을 소켓 fd에 연결했기 때문이다.
정리
이번 주에는 Web Proxy Lab으로 바로 들어가기 전에 Tiny 웹 서버의 기본 흐름을 정리했다.
처음에는 listenfd, connfd, Rio I/O, HTTP 메시지, CGI 흐름이 각각 따로 떨어진 개념처럼 보였다. 하지만 Tiny 서버를 구현하면서 이 개념들이 모두 “요청을 읽고 응답을 쓰는 흐름” 안에서 연결된다는 것을 알게 되었다.
특히 소켓도 파일 디스크립터이고, 표준 출력도 파일 디스크립터라는 관점이 중요했다. 이 관점을 잡고 나니 read, write, Rio_writen, dup2, execve가 단순한 함수 목록이 아니라 하나의 서버 동작 흐름으로 이해되기 시작했다.