미니 HTTP 서버 만들기
이제 세상에서 가장 널리 쓰이는 프로토콜을 직접 구현합니다. HTTP입니다. 우리가 매일 웹 브라우저로 접속하는 그 프로토콜을, 지금까지 배운 소켓만으로 만들어 봅니다. 놀랍게도 HTTP의 기본은 우리가 앞 장에서 다룬 텍스트 프로토콜과 똑같은 원리 위에 서 있습니다. 이 장을 마치면 우리가 만든 소켓 서버에 웹 브라우저로 접속할 수 있게 됩니다.
HTTP는 의외로 단순하다
HTTP라고 하면 복잡할 것 같지만, 가장 기본은 단순합니다. 클라이언트가 요청을 보내면 서버가 응답을 돌려주는, 한 번의 주고받음입니다. 그리고 그 요청과 응답이 모두 사람이 읽을 수 있는 텍스트입니다. 앞 장에서 배운 텍스트 프로토콜의 한 예인 셈입니다.
요청은 이렇게 생겼습니다. 첫 줄에 무엇을 원하는지가 담깁니다. GET /index.html HTTP/1.1 같은 형식인데, GET은 가져오겠다는 동작이고, 그다음이 원하는 자원의 경로, 마지막이 프로토콜 버전입니다. 그 아래로 헤더라 부르는 부가 정보가 줄줄이 따라옵니다.
응답도 비슷합니다. 첫 줄에 결과 상태가 담깁니다. HTTP/1.1 200 OK에서 200은 성공을 뜻하는 상태 코드이고, 찾는 것이 없으면 404가 옵니다. 그 아래 헤더가 오고, 빈 줄 하나를 사이에 두고, 실제 내용이 옵니다. 이 빈 줄이 헤더와 본문을 나누는 경계입니다. 앞 장에서 배운 구분자의 또 다른 모습입니다.
미니 HTTP 서버
요청을 받아 파일을 찾아 돌려주는 작은 웹 서버를 만듭니다.
# 새 파일: http_server.pyimport socketimport osHOST, PORT = "127.0.0.1", 9060ROOT = "www"def build_response(status, body, content_type="text/html; charset=utf-8"): headers = [ f"HTTP/1.1 {status}", f"Content-Type: {content_type}", f"Content-Length: {len(body)}", "Connection: close", ] return ("\r\n".join(headers) + "\r\n\r\n").encode() + bodydef handle(conn): request = conn.recv(4096).decode(errors="replace") if not request: return line = request.splitlines()[0] method, path, _ = line.split() if path == "/": path = "/index.html" filepath = os.path.join(ROOT, path.lstrip("/")) if os.path.isfile(filepath): with open(filepath, "rb") as f: body = f.read() conn.sendall(build_response("200 OK", body)) else: body = b"<h1>404 Not Found</h1>" conn.sendall(build_response("404 Not Found", body))server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)server.bind((HOST, PORT))server.listen(5)print(f"HTTP 서버 http://{HOST}:{PORT}")while True: conn, addr = server.accept() with conn: handle(conn)
서버의 뼈대는 지금까지 만든 TCP 서버와 똑같습니다. socket, bind, listen, accept의 흐름 그대로입니다. 달라진 것은 받은 데이터를 HTTP 약속에 따라 해석하고 응답한다는 점뿐입니다.
handle 함수가 요청을 처리합니다. recv로 요청을 받은 뒤, 첫 줄만 떼어 내 공백으로 나눕니다. 그러면 동작과 경로를 얻습니다. 경로가 그냥 빗금이면 기본 파일인 index.html로 바꿉니다. 우리가 웹 주소 끝에 아무것도 안 붙여도 첫 페이지가 뜨는 이유가 바로 이것입니다.
그다음 그 경로의 파일이 있는지 확인합니다. 있으면 파일을 읽어 200 응답에 실어 보내고, 없으면 404 응답을 보냅니다.
build_response 함수가 HTTP 응답을 조립합니다. 상태 줄과 헤더들을 줄바꿈으로 잇고, 헤더와 본문 사이에 빈 줄을 하나 넣습니다. 여기서 줄바꿈으로 \r\n을 쓴 점을 눈여겨보세요. HTTP는 줄의 끝을 캐리지 리턴과 라인 피드 두 글자로 표시하도록 약속되어 있습니다. 우리가 임의로 정하는 것이 아니라 HTTP라는 표준 약속을 따르는 것입니다. Content-Length 헤더로 본문의 길이를 미리 알려 주는 것도 보세요. PART 02의 길이 접두어와 같은 발상으로, 받는 쪽이 본문이 어디까지인지 알 수 있게 합니다.
준비물과 실행
서버를 띄우기 전에 보여 줄 파일을 하나 만듭니다. www라는 폴더를 만들고 그 안에 index.html을 둡니다.
# 파일: 터미널mkdir -p wwwecho '<!DOCTYPE html><html><body><h1>소켓으로 만든 서버</h1></body></html>' > www/index.html
서버를 실행합니다.
# 파일: 터미널python3 http_server.py
이제 진짜 웹 브라우저를 열어 주소창에 http://127.0.0.1:9060을 입력해 보세요. 우리가 만든 페이지가 나타납니다. 우리가 소켓으로 한 땀 한 땀 만든 서버에, 평범한 웹 브라우저가 아무것도 모른 채 접속해 페이지를 받아 간 것입니다. 우리가 HTTP라는 약속을 정확히 지켰기 때문입니다.
명령으로 확인하기
브라우저 대신 명령으로도 확인할 수 있습니다. curl은 HTTP 요청을 보내는 도구입니다.
# 파일: 터미널curl -i http://127.0.0.1:9060/
-i 옵션은 응답 헤더까지 함께 보여 달라는 뜻입니다. 결과는 이렇습니다.
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 78
Connection: close
<!DOCTYPE html>
<html><body><h1>소켓으로 만든 서버</h1></body></html>
우리가 build_response로 조립한 응답이 그대로 보입니다. 상태 줄, 헤더들, 빈 줄, 그리고 본문. HTTP 응답의 구조가 눈에 또렷이 들어옵니다. 없는 파일을 요청하면 404가 돌아오는 것도 확인해 보세요.
# 파일: 터미널curl -o /dev/null -w "status=%{http_code}\n" http://127.0.0.1:9060/nope.html
status=404
Connection close와 keep-alive
우리 서버의 응답 헤더에는 Connection: close가 들어 있습니다. 응답을 보낸 뒤 연결을 닫겠다는 뜻입니다. 그래서 우리 서버는 요청 하나를 처리하면 연결을 끊습니다.
실제 웹은 보통 다르게 동작합니다. 한 페이지를 열면 HTML, 이미지, 스타일 파일 등 여러 자원을 연달아 받아야 하는데, 그때마다 연결을 새로 맺으면 3-way 핸드셰이크를 매번 반복해 느려집니다. 그래서 한 번 맺은 연결을 닫지 않고 여러 요청에 재사용하는데, 이를 keep-alive라 부릅니다. PART 02에서 잠깐 만난 그 개념입니다. keep-alive를 제대로 구현하려면 한 연결에서 여러 요청을 줄줄이 받아 처리해야 하고, 각 요청의 끝을 정확히 알아내야 합니다. 우리 미니 서버는 단순함을 위해 연결을 닫는 방식을 택했지만, 더 발전시킨다면 이 keep-alive가 좋은 다음 과제입니다.
HTTP를 직접 구현해 봤습니다. 그런데 우리 서버에는 PART 04에서 본 그 약점이 또 있습니다. 한 번에 한 요청만 처리한다는 점입니다. 다음 장에서 PART 04의 동시성을 결합해, 여러 사람이 동시에 접속하는 웹 서버로 완성합니다.