iBetter Books
수정

실습 — 동시 접속 HTTP 서버

앞 장에서 만든 미니 HTTP 서버는 한 번에 한 요청만 처리했습니다. 한 사람이 접속해 페이지를 받는 동안 다른 사람은 기다려야 했습니다. PART 04에서 이미 이 문제를 푸는 법을 배웠으니, 이제 그 동시성을 HTTP 서버에 결합해 여러 사람이 동시에 접속하는 진짜 웹 서버로 완성합니다.

어떤 동시성을 고를까

PART 04에서 동시성의 세 가지 길을 배웠습니다. 스레드와 fork, select, poll입니다. 어느 것을 써도 되지만, 여기서는 스레드를 택합니다. 이유가 있습니다.

HTTP 요청 처리는 짧고 독립적입니다. 한 요청을 받아 파일을 읽어 응답하면 끝입니다. 각 요청을 별도 스레드에 맡기면, 앞 장의 handle 함수를 거의 그대로 재사용하면서 동시성만 얹을 수 있습니다. 코드 변화가 가장 적은 길입니다. 다만 스레드 방식은 PART 04에서 배웠듯 동시 접속이 아주 많아지면 부담이 커지므로, 그런 극한 상황을 위한 더 강력한 방법은 PART 06에서 다룹니다.

스레드로 동시 처리하기

앞 장의 HTTP 서버를 확장합니다. 바뀌는 곳은 많지 않습니다.

# 수정: http_server.pyimport socketimport osimport threadingHOST, 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):    with 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:            conn.sendall(build_response("404 Not Found", b"<h1>404 Not Found</h1>"))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()    threading.Thread(target=handle, args=(conn,), daemon=True).start()

바뀐 곳은 세 군데뿐입니다.

먼저 threading을 불러옵니다. 그리고 handle 함수 안을 with conn:으로 감쌌습니다. 앞 장에서는 주 흐름이 with conn:으로 소켓을 닫았지만, 이제는 각 요청이 별도 스레드에서 처리되니 그 스레드가 자기 소켓을 책임지고 닫도록 한 것입니다. 마지막으로 accept로 연결을 받으면 곧바로 새 스레드에게 handle을 맡기고, 주 흐름은 즉시 다음 연결을 받으러 돌아갑니다.

이 작은 변화로 서버는 여러 요청을 동시에 처리하게 됩니다. PART 04에서 배운 패턴을 그대로 가져온 것입니다.

동시성 확인하기

서버를 띄우고 여러 요청을 한꺼번에 보내 봅니다.

# 파일: 터미널for i in 1 2 3 4 5; do curl -s -o /dev/null -w "req$i=%{http_code} " http://127.0.0.1:9060/ & done; wait; echo

이 명령은 다섯 개의 요청을 동시에 보냅니다. 결과는 이렇습니다.

req5=200 req2=200 req1=200 req3=200 req4=200

다섯 요청이 모두 성공했습니다. 끝나는 순서가 보낸 순서와 다른 것은, 다섯 요청이 동시에 처리되어 누가 먼저 끝날지 그때그때 다르기 때문입니다. 앞 장의 서버였다면 한 줄로 차례를 기다렸을 요청들이, 이제는 함께 처리됩니다.

우리가 만든 것의 의미

작은 프로그램이지만, 우리는 방금 진짜 웹 서버의 축소판을 완성했습니다. 소켓으로 연결을 받고, HTTP 약속에 따라 요청을 해석하고, 파일을 찾아 응답하고, 여러 요청을 동시에 처리합니다. 우리가 평소에 쓰는 거대한 웹 서버들도 본질은 이와 같습니다. 단지 더 많은 기능과 더 정교한 동시성, 더 강한 견고성을 갖췄을 뿐입니다.

이제 우리는 프레임워크가 뒤에서 무슨 일을 하는지 짐작할 수 있습니다. FastAPI나 Express가 요청을 받아 우리 함수를 호출해 주기까지, 그 아래에서 바로 이런 일이 벌어지고 있습니다. PART 00에서 던졌던 질문, 프레임워크 아래에는 무엇이 있는가에 대한 답을 우리 손으로 만들어 확인한 셈입니다.

지금까지 우리가 만든 통신은 모두 보이지 않게 흘렀습니다. 다음 장에서는 이 흐름을 실제로 눈으로 들여다보는 진단 도구들을 배웁니다. 우리가 짠 코드가 네트워크에서 정말로 무슨 일을 하는지 확인하는 법입니다.