Python WSGI 서버 직접 만들어보기

2025. 2. 3. 13:37·Python

안녕하세요. 오늘은 WSGI 서버를 직접 만들어보도록 하겠습니다. 이전에 WSGI가 무엇인지에 대해서 다뤘습니다. 이때에는 간단하게 WSGI 애플리케이션도 만들어보았었습니다.

 

GitHub에 전체코드를 올려두었습니다!

 

GitHub - fhdufhdu/MyGunicorn: WSGI 서버를 만들어보자!!! 최대한 Gunicorn 처럼 만드는 것이 목표임!

WSGI 서버를 만들어보자!!! 최대한 Gunicorn 처럼 만드는 것이 목표임! Contribute to fhdufhdu/MyGunicorn development by creating an account on GitHub.

github.com

 

 

2024.05.28 - [Python] - Python WSGI란?

 

Python WSGI란?

안녕하세요. 오늘은 파이썬의 웹 서버 인터페이스인 WSGI에 대해서 알아보도록 하겠습니다.WSGIWSGI(Web Server Gateway Interface) 정의 WSGI — WSGI.orgContributing Found a typo? Or some awkward wording? Want to add a link t

woodduru-madduru.tistory.com

 

소켓으로 HTTP 서버 만들기

import socket

## TCP로 소켓 연결
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as ss:
    # 소켓이 사용하던 포트가 종료되면, 기본적으로 해당 포트를 일정시간 동안 사용을 막음. 그 설정을 풀어주는 코드

    ss.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

    # 포트에서 사용할 host와 port
    ss.bind(('localhost', 1026))
    # 포트를 실제로 점유하고, accept 요청을 얼마나 저장할 지 설정(=100)
    ss.listen(100)

    while True:
        # 클라이언트 접속
        cc, address = ss.accept()

        # 클라이언트로부터 데이터 수신
        recv_data = cc.recv(1048576)

        # http request 양식대로 분류 작업
        recv_data = recv_data.replace(b'\r\n', b'\n')

        splited_recv_data = recv_data.split(b'\n\n', 1)
        if len(splited_recv_data) == 2:
            http_info, body = splited_recv_data
        else:
            http_info = recv_data
            body = b''
        # 분류 작업 끝
        
        # 받은 데이터 출력
        print("[http 정보]",  http_info.decode('utf-8'))
        print("body 정보", body)
        
        # 클라이언트에게 데이터 발신
        cc.sendall(b'HTTP/1.1 200 OK\nDate: Sat, 09 Oct 2010 14:28:02 GMT\nServer: Apache\nLast-Modified: Tue, 01 Dec 2009 20:18:22 GMT\nETag: "51142bc1-7449-479b075b2891b"\nAccept-Ranges: bytes\nContent-Length: 29769\nContent-Type: text/html\n\n<html><body>Hello World!!</body></html>')
        cc.close()

 

간단하게 소켓 서버를 열고, HTTP Request 양식에 맞게 정보를 분해하고 출력하고, 응답 값을 내려주는 형태입니다.

해당 코드를 실행시키고 http://localhost:1026으로 접속하면 아래와 같이 접속이 잘 되는 것을 볼 수 있습니다.

접속 시, 서버 로그

참고로 HTTP 프로토콜 명세는 여기에서 확인해보실 수 있습니다.

 

소켓 서버를 WSGI로 변경하기

소켓으로 HTTP 서버를 열어보았으니, 이제는 WSGI 서버 명세를 잘 녹여내어서 WSGI 서버를 만들어보겠습니다.

import importlib
import io
import multiprocessing
import re
import signal
import socket
from multiprocessing import Process


class MyGunicornHandler:
    def __init__(self, ss: socket.socket, app_path: str):
        self.ss = ss
        self.app_path = app_path
        self.status_and_headers = {"status": 200, "headers": []}
    

    def run(self):
        try:
            c_proc = multiprocessing.current_process()
            _from, _import = self.app_path.split(":", 1)

            module = importlib.import_module(_from)
            app = getattr(module, _import)

            while True:
                print("START: ", c_proc.name, " || ", "PID: ", c_proc.pid)
                conn, address = self.ss.accept()

                raw_data = conn.recv(1048576)
                if not raw_data:
                    conn.close()
                    break

                raw_data = raw_data.replace(b"\r\n", b"\n")
                splited_raw_data = raw_data.split(b"\n\n", 1)

                if len(splited_raw_data) == 2:
                    b_headers, b_body = splited_raw_data
                else:
                    b_headers, b_body = (raw_data, b"")

                headers = b_headers.decode("utf-8")
                headers = headers.rsplit("\n")

                method, path, version_of_protocol = headers[0].split(" ")
                if "?" in path:
                    path, query = path.split("?", 1)
                else:
                    path, query = path, ""
                environ = {
                    "REQUEST_METHOD": method,
                    "SERVER_PROTOCOL": version_of_protocol,
                    "SERVER_SOFTWARE": "WOOSEONG_WSGI",
                    "PATH_INFO": path,
                    "QUERY_STRING": query,
                    "REMOTE_HOST": address[0],
                    "REMOTE_ADDR": address[0],
                    "wsgi.input": io.BytesIO(b_body),
                    "wsgi.url_scheme": "http",
                    "wsgi.version": (1, 0),
                }

                for idx, header in enumerate(headers):
                    if idx == 0:
                        continue
                    key, value = re.split(r"\s*:\s*", header, 1)

                    key = key.replace("-", "_").upper()
                    value = value.strip()

                    make_key = lambda x: "HTTP_" + x
                    if key in ("CONTENT_TYPE", "CONTENT_LENGTH"):
                        environ[key] = value
                    elif make_key(key) in environ:
                        environ[make_key(key)] += "," + value
                    else:
                        environ[make_key(key)] = value

                def start_response(status, headers):
                    self.status_and_headers["status"] = status
                    self.status_and_headers["headers"] = headers

                response_body = app(environ, start_response)
                # 응답 첫번째 라인 구성
                response_first = (
                    f"{version_of_protocol} {self.status_and_headers['status']}"
                )
                # 응답 헤더부분 구성
                response_headers = "\r\n".join(
                    list(
                        map(
                            lambda x: f"{x[0]}: {x[1]}",
                            self.status_and_headers["headers"],
                        )
                    )
                )
                # 응답 첫번째 라인 + 헤더 부분
                response = (
                    response_first
                    + ("\r\n" if response_headers else "")
                    + response_headers
                    + "\r\n\r\n"
                )
                # byte로 인코딩
                response = response.encode("utf-8")
                # response_body 붙이기
                for b in response_body:
                    response += b

                conn.send(response)
                conn.close()

                print("END: ", c_proc.name, " || ", "PID: ", c_proc.pid)
        except KeyboardInterrupt:
            pass


class MyGunicorn:
    def __init__(self):
        # 소켓 생성
        self.ss = self.__init_socket__()
        # 프로세스 리스트
        self.ps = []
        # graceful shutdown 추가
        signal.signal(signal.SIGINT, self.close)
        signal.signal(signal.SIGTERM, self.close)

    # 소켓 생성
    def __init_socket__(self):
        ss = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        ss.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        return ss

    def run(
        self,
        app_path: str,
        host: str = "localhost",
        port: int = 1026,
        backlog: int = 100,
        worker=4,
    ):
        self.ss.bind((host, port))
        self.ss.listen(backlog)

        for _ in range(worker):
            handler = MyGunicornHandler(self.ss, app_path)
            Process(target=handler.run).start()

    def close(self, signum, frame):
        print(f"shutdown: {signum}")
        self.ss.close()


if __name__ == "__main__":
    MyGunicorn().run(app_path="wsgiserver.wsgi:application", worker=16, backlog=1000)

 

프로그램 시작 부분

if __name__ == "__main__":
    MyGunicorn().run(app_path="wsgiserver.wsgi:application", worker=16, backlog=1000)

 

MyGunicorn이라는 객체를 생성하고 run을 통해 서버를 시작합니다.

  • app_path
    • WSGI 애플리케이션 경로입니다.
    • 저는 Django를 사용했기 때문에 Django의 app부분을 지정했습니다.
  • worker
    • 프로세스의 갯수입니다. (스레드 x)
  • backlog
    • 소켓의 큐 크기입니다.(추정)

MyGunicorn 클래스

class MyGunicorn:
    def __init__(self):
        # 소켓 생성
        self.ss = self.__init_socket__()
        # 프로세스 리스트
        self.ps = []
        # graceful shutdown 추가
        signal.signal(signal.SIGINT, self.close)
        signal.signal(signal.SIGTERM, self.close)

    # 소켓 생성
    def __init_socket__(self):
        ss = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        ss.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        return ss

    def run(
        self,
        app_path: str,
        host: str = "localhost",
        port: int = 1026,
        backlog: int = 100,
        worker=4,
    ):
        self.ss.bind((host, port))
        self.ss.listen(backlog)

        for _ in range(worker):
            handler = MyGunicornHandler(self.ss, app_path)
            Process(target=handler.run).start()

    def close(self, signum, frame):
        print(f"shutdown: {signum}")
        self.ss.close()

 

소켓을 초기화하고, 프로세스 개수에 맞게, 자식 프로세스를 생성하고 대기시키는 클래스입니다.

프로세스를 생성할 때, 실제로 소켓에서 데이터를 받고, 처리하는 Handler 클래스를 생성해서 전달합니다.

 

MyGunicornHandler 클래스

class MyGunicornHandler:
    def __init__(self, ss: socket.socket, app_path: str):
        self.ss = ss
        self.app_path = app_path
        self.status_and_headers = {"status": 200, "headers": []}

    def run(self):
        try:
            c_proc = multiprocessing.current_process()
            _from, _import = self.app_path.split(":", 1)

            module = importlib.import_module(_from)
            app = getattr(module, _import)

            while True:
                print("START: ", c_proc.name, " || ", "PID: ", c_proc.pid)
                conn, address = self.ss.accept()

                raw_data = conn.recv(1048576)
                if not raw_data:
                    conn.close()
                    break

                raw_data = raw_data.replace(b"\r\n", b"\n")
                splited_raw_data = raw_data.split(b"\n\n", 1)

                if len(splited_raw_data) == 2:
                    b_headers, b_body = splited_raw_data
                else:
                    b_headers, b_body = (raw_data, b"")

                headers = b_headers.decode("utf-8")
                headers = headers.rsplit("\n")

                method, path, version_of_protocol = headers[0].split(" ")
                if "?" in path:
                    path, query = path.split("?", 1)
                else:
                    path, query = path, ""
                environ = {
                    "REQUEST_METHOD": method,
                    "SERVER_PROTOCOL": version_of_protocol,
                    "SERVER_SOFTWARE": "WOOSEONG_WSGI",
                    "PATH_INFO": path,
                    "QUERY_STRING": query,
                    "REMOTE_HOST": address[0],
                    "REMOTE_ADDR": address[0],
                    "wsgi.input": io.BytesIO(b_body),
                    "wsgi.url_scheme": "http",
                    "wsgi.version": (1, 0),
                }

                for idx, header in enumerate(headers):
                    if idx == 0:
                        continue
                    key, value = re.split(r"\s*:\s*", header, 1)

                    key = key.replace("-", "_").upper()
                    value = value.strip()

                    make_key = lambda x: "HTTP_" + x
                    if key in ("CONTENT_TYPE", "CONTENT_LENGTH"):
                        environ[key] = value
                    elif make_key(key) in environ:
                        environ[make_key(key)] += "," + value
                    else:
                        environ[make_key(key)] = value

                def start_response(status, headers):
                    self.status_and_headers["status"] = status
                    self.status_and_headers["headers"] = headers

                response_body = app(environ, start_response)
                # 응답 첫번째 라인 구성
                response_first = (
                    f"{version_of_protocol} {self.status_and_headers['status']}"
                )
                # 응답 헤더부분 구성
                response_headers = "\r\n".join(
                    list(
                        map(
                            lambda x: f"{x[0]}: {x[1]}",
                            self.status_and_headers["headers"],
                        )
                    )
                )
                # 응답 첫번째 라인 + 헤더 부분
                response = (
                    response_first
                    + ("\r\n" if response_headers else "")
                    + response_headers
                    + "\r\n\r\n"
                )
                # byte로 인코딩
                response = response.encode("utf-8")
                # response_body 붙이기
                for b in response_body:
                    response += b

                conn.send(response)
                conn.close()

                print("END: ", c_proc.name, " || ", "PID: ", c_proc.pid)
        except KeyboardInterrupt:
            pass

 

실제로 소켓에게서 데이터를 받고, 해당 데이터를 HTTP 요청 명세에 맞게 분해합니다.

이후 WSGI 명세에 맞게 데이터를 정제하고 WSGI 애플리케이션을 실행합니다.

WSGI 애플리케이션에게서 응답을 받으면 해당 응답으로 HTTP 응답 명세에 맞게 재조립한 다음 소켓에 전달합니다.

 

WSGI 상세 명세는 아래에서 확인해볼 수 있습니다.

https://wsgi.readthedocs.io/en/latest/definitions.html

 

Definitions of keys and classes — WSGI.org

Variables corresponding to the client-supplied HTTP request headers (i.e., variables whose names begin with HTTP_). The presence or absence of these variables should correspond with the presence or absence of the appropriate HTTP header in the request.

wsgi.readthedocs.io

 

성능 테스트

성능테스트 결과

테스트 환경 조건은 아래와 같습니다.

  • 0.1초 단위로 요청 전송
  • 동시에 1000명 접속
  • worker=16
  • backlog =1000

많이 사용하는 WSGI 서버인 Gunicorn과 비교해 보았습니다. 결과를 보니 그래프상 큰 차이가 없었습니다. 나름대로 높은 성능을 기록한 것 같아서 성취감을 많이 느낄 수 있었습니다.

 

'Python' 카테고리의 다른 글

Python에서 멀티 스레드를 사용하지 않는 이유(Java와 비교, GIL)  (0) 2025.02.03
Python WSGI란?  (0) 2025.02.03
Python 추상클래스 만들기  (0) 2025.02.03
'Python' 카테고리의 다른 글
  • Python에서 멀티 스레드를 사용하지 않는 이유(Java와 비교, GIL)
  • Python WSGI란?
  • Python 추상클래스 만들기
우띵이
우띵이
코딩해요~
  • 우띵이
    ChODING
    우띵이
  • 전체
    오늘
    어제
    • 분류 전체보기 (11)
      • JVM (6)
        • Java (4)
        • Kotlin (2)
      • Python (4)
      • JavaScript (0)
      • Computer Science (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Monitor
    Virtual Thread
    CS
    spring security
    CORS
    Spring
    WSGI
    binary
    computer science
    kotlin
    hash
    Metaclass
    jdk21
    java
    synchronize
    Python
    complement
    Thread
    concurrency
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
우띵이
Python WSGI 서버 직접 만들어보기
상단으로

티스토리툴바