Python HTTP server 개발
이번 글에서는 본격적으로 코딩을 합니다. 웹 온라인 게임을 브라우저에서 띄우려면 브라우저의 HTTP 요청에 응답해줄 웹 서버가 필요합니다. 브라우저의 주소창에 예로 http://simple-online-game.com 라고 쳤을 때 우리가 만든 게임으로 들어가야 하기 때문이죠.
서버사이드는 전부 파이썬3를 사용합니다. 만약 파이썬을 접해보지 않으셨다고 해도 걱정하지 마세요. 저도 이 프로젝트를 위해서 파이썬을 처음 시작하여 만들었으니까요. 공부를 위해 책을 살 것도 없이 조금 검색을 해보면 파이썬 기본강좌가 많이 나옵니다. 만약 적당한 자료를 찾지 못했다면 점프 투 파이썬을 추천합니다.
자, 그럼 파이썬으로 서버를 만들기 전에 알아야 할 것이 몇 가지 있습니다. 그중 하나는 웹 서버와 비즈니스 로직을 연결하는 인터페이스인 CGI(Common Gateway Interface)와 WSGI(Web Server Gateway Interface) 입니다.
무슨 말인가 하면 웹 서버는 기본적으로 브라우저에 정적인 리소스만을 전달해 줄 수 있습니다. 가장 유명한 웹 서버 중 하나인 아파치를 예로 들어보면, 아파치는 html, css, js, jpg, gif 등 정적인 파일들만 브라우저에 전달할 수 있고 실제 비즈니스 로직은 자바 인 경우 톰캣을 아파치와 연동하여 처리합니다. 이처럼 웹 서버와 비즈니스 로직을 처리하는 프로그램과의 연결을 위한 인터페이스 중 하나가 바로 CGI 입니다. 그럼 WSGI는 무엇일까요? WSGI는 파이썬에 종속된 개념으로 CGI보다 개선된 인터페이스입니다.
이 둘은 구현체가 아니라 그냥 스펙입니다. 하지만 파이썬에서는 간단하게 구현체를 제공하는데요, 잠깐 살펴보겠습니다.
간단한 CGI 서버의 예
from CGIHTTPServer importCGIHTTPRequestHandler
from BaseHTTPServer import HTTPServer
server_address = ( '', 80)
httpd = HTTPServer( server_address, CGIHTTPRequestHandler )
httpd.serve_forever()
간단한 WSGI 서버의 예
from wsgiref.simple_server import make_server
def hello_world( environ, start_response ):
start_response( '200 OK', [ ( 'Content-Type', 'text/json; charset=utf-8' ) ] )
httpd = make_server( '', 80, hello_world )
httpd.serve_forever()
CGI 서버는 위 샘플처럼 실행하면 cgi-bin 디렉터리 하위에 위치하는 *.py 파일들을 URL을 통해 접근하여 실행할 수 있는 구조입니다. 예) http://simple-online-game.com/cgi-bin/start.py
WSGI 서버는 요청이 들어오면 등록된 메서드( hello_world )로 제어권을 넘깁니다. 해당 메서드는 요청정보와 Response 객체를 받아 이를 처리하는 구조입니다.
파이썬에서는 이처럼 간단하게 서버를 구현할 수 있습니다. 그럼 우리는 CGI와 WSGI 의 구현체 중 뭘 사용해야 할까요? CGI는 매 요청마다 새로운 프로세스를 생성하여 이를 처리합니다. 따라서 사용량이 많을 경우 성능에 매우 취약해지는 약점을 가지고 있습니다. 그래서 저희는 WSGI 규약의 구현체를 사용하여 개발을 하겠습니다.
너무 간단하게 서버가 만들어지는 바람에 이제 바로 클라이언트 개발로 넘어가면 좋겠지만, 안타깝게도 현재 버전에서는 제공하지 않는 기능이 많습니다. 더 필요한 기능을 나열해보면..
1_ 멀티 쓰레드 지원
2_ POST 메서드 처리
3_ 로깅
4_ URL 라우팅
5_ 정적 리소스 처리
멀티 쓰레드 지원
파이썬의 기본 WSGI 구현체는 싱글 쓰레드만을 지원합니다. 하지만 게임에 동시 사용자가 증가할 경우 사용자는 렉으로 허덕이게 되므로 반드시 멀티 쓰레드를 지원해야 합니다. 우리 서버가 멀티 쓰레드로 동작하도록 하는 방법은 매우 간단합니다. 파이썬에서 제공하는 ThreadingMixIn을 WSGIServer와 함께 상속한 새로운 서버 클래스를 만들어 사용하면 됩니다.
from wsgiref.simple_server import make_server, WSGIServer
from socketserver import ThreadingMixIn
def hello_world( environ, start_response ):
start_response( '200 OK', [ ( 'Content-Type', 'text/json; charset=utf-8' ) ] )
class ThreadedWSGIServer( ThreadingMixIn, WSGIServer ):
pass
httpd = make_server( '', 80, hello_world, ThreadedWSGIServer )
httpd.serve_forever()
POST 메서드 처리
또한 우리 서버는 GET 메서드만 지원하기 때문에 다른 메서드에 대한 처리를 따로 구현해야 합니다. 우리는 POST 메서드만 추가 구현해보겠습니다. 이 처리는 서버의 상세 제어를 맡고 있는 핸들러에서 구현해야 합니다. 기본적으로 파이썬의 서버들은 핸들러를 하나씩 가지고 있고 각 핸들러에 따라 동작 방식이 변화합니다. 핸들러는 기본 WSGI 핸들러인 WSGIRequestHandler를 상속받아 만들고, 하위 메서드를 오버라이드 하여 동작을 변경하면 됩니다. 이렇게 만들어진 핸들러를 maker_server의 파라미터로 넘기면 서버에 마운트 되어 작동합니다.
from wsgiref.simple_server import make_server, WSGIServer, WSGIRequestHandler
class SimpleRequestHandler( WSGIRequestHandler ):
def get_environ( self ):
...
httpd = make_server( '', 80, hello_world, ThreadedWSGIServer, SimpleRequestHandler )
httpd.serve_forever()
WSGIRequestHandler의 get_environ 메서드는 클라이언트의 요청정보를 리턴 합니다. 이 메서드를 재정의하여 GET과 POST의 분기 처리를 넣으면 되고, 이렇게 만들어진 요청정보는 hello_world의 environ 파라미터로 전달받게 됩니다.
import json, urllib.parse
encoding = 'utf-8';
class SimpleRequestHandler( WSGIRequestHandler ):
def get_environ( self ):
environ = super( SimpleRequestHandler, self ).get_environ()
request_payload = {}
if self.command =='GET':
for item in environ.get( 'QUERY_STRING' ).split( '&' ):
if item: request_payload[ item.split( '=' )[ 0 ] ] = item.split( '=' )[ 1 ]
if 'parameters' in request_payload:
request_payload = json.loads( urllib.parse.unquote( request_payload[ 'parameters' ] ) )
elif self.command == 'POST':
length =int( self.headers.get( 'content-length' ) )
if length >0:
request_payload = json.loads( urllib.parse.unquote( self.rfile.read( length ).decode( encoding ) ) )
environ[ 'REQUEST_PAYLOAD' ] = request_payload
return environ
큰 흐름은 부모 클래스의 원본 메서드를 호출하여 요청정보를 받아와서 GET/POST에 따라 각각 요청 파라미터를 정제하고, 이를 기존 요청정보에 REQUEST_PAYLOAD 를 키로 데이터를 삽입하게 됩니다. GET/POST 구분은 핸들러의 commad 변수에 저장돼서 들어옵니다. HTTP 스펙에 따라, GET인 경우 요청 파라미터가 URL에 포함되어 쿼리스트링 형태로 들어오므로, QUERY_STRING 값을 뽑아 구분자( &, = )로 분리해 내면 간단합니다. 저는 추가적으로 parameters의 이름으로 요청 파라미터를 넘겼을 경우 클라이언트가 JSON형태로 인코딩 하여 전송했다는 가정하에 JSON으로 파싱 하여 저장했습니다.
http://simple-online-game.com/start?parameters=%7B%22a%22%3A1%2C%22b%22%3A2%2C%22c%22%3A3%7D
parameters = { a : 1, b : 2, c : 3 }
POST인 경우 요청 파라미터가 요청 본문에 포함되어 들어오는데, WSGIRequestHandler 에서는 아래와 같이 처리하여 값을 얻어올 수 있습니다.
length = int( self.headers.get( 'content-length' ) )
json.loads( urllib.parse.unquote( self.rfile.read( length ).decode( 'utf-8' ) ) )
이번에도 마찬가지로 클라이언트가 JSON 형태로 값을 넘겨준다는 가정하에 처리합니다. 저만의 서버이기 때문에 최소한 필요한 기능만 넣도록 했습니다.
로깅
로깅 기능은 기존 WSGIServer에서 콘솔로 내보내는 로그 문자열을 내가 원하는 형식으로 바꿔 파일로 출력하도록 변경해 보겠습니다. 간단하게 핸들러의 log_message 메서드를 재정의하면 됩니다.
log_file = open( 'log/simple.log', 'a' )
class SimpleRequestHandler( WSGIRequestHandler ):
def get_environ( self ):
...
def log_message( self, format, *args ):
log_file.write( '%s - - [%s] %s\n'% ( self.address_string(), self.log_date_time_string(), format%args ) )
try:
httpd = make_server( '', 80, hello_world, ThreadedWSGIServer, SimpleRequestHandler )
print( 'Starting simple_httpd on port '+str( httpd.server_port ) )
httpd.serve_forever()
except KeyboardInterrupt:
print( 'Shutting down simple_httpd' )
log_file.close()
httpd.socket.close()
로그가 남겨질 파일을 하나 만들어 log_message 내에서 알맞은 포맷으로 로그를 남기고 있으며, 서버를 띄우고 CTRL + C 를 눌렀을 경우 서버를 안전하게 종료하는 처리도 추가했습니다.
우선 여기까지를 하나의 파이썬 파일로 만들겠습니다. 이름은 simple_httpd.py 정도가 적당할 것 같네요. 이후 추가되어야 할 URL 라우팅과 정적 리소스 처리는 새로운 파일에 작성하겠습니다. 이름은 simple_router.py 정도 지어주면 될 것 같고요, 사실 지금 서버에서는 어떤 요청이 들어오던 hello_world 함수가 호출되어 항상 200 OK 를 내려주고 끝이 납니다. 따라서 hello_world 부분에 새롭게 만들게 될 simple_router의 route 함수를 넣어주고 simple_router를 구현해보겠습니다.
import json, urllib.parse, simple_router
...
try:
httpd = make_server( '', 80, simple_router.route, ThreadedWSGIServer, SimpleRequestHandler )
print( 'Starting simple_httpd on port '+str( httpd.server_port ) )
httpd.serve_forever()
except KeyboardInterrupt:
print( 'Shutting down simple_httpd' )
log_file.close()
httpd.socket.close()
우선 빈 깡통이지만 simple_router.route 함수를 넣었습니다. 이후 구현이 완료되면 요청 URL의 패턴에 따라 알맞은 xxx.py로 라우팅 하여 해당 파일에서 요청을 처리하도록 할 것입니다.
지금까지 파이썬으로 웹 서버를 만들어 멀티 쓰레드 지원, POST 메서드 처리, 로깅 기능을 추가해 보았습니다. 다음 글에서 URL 라우팅과 정적 리소스 처리를 이어서 해보겠습니다.
simple_httpd.py ( all source code )
import json, urllib.parse, simple_router
from wsgiref.simple_server import make_server, WSGIServer, WSGIRequestHandler
from socketserver import ThreadingMixIn
encoding = 'utf-8';
log_file = open( 'log/simple.log', 'a' )
class ThreadedWSGIServer( ThreadingMixIn, WSGIServer ):
pass
class SimpleRequestHandler( WSGIRequestHandler ):
def get_environ( self ):
environ = super( SimpleRequestHandler, self ).get_environ()
request_payload = {}
if self.command =='GET':
for item in environ.get( 'QUERY_STRING' ).split( '&' ):
if item: request_payload[ item.split( '=' )[ 0 ] ] = item.split( '=' )[ 1 ]
if 'parameters' in request_payload:
request_payload = json.loads( urllib.parse.unquote( request_payload[ 'parameters' ] ) )
elif self.command == 'POST':
length =int( self.headers.get( 'content-length' ) )
if length >0:
request_payload = json.loads( urllib.parse.unquote( self.rfile.read( length ).decode( encoding ) ) )
environ[ 'REQUEST_PAYLOAD' ] = request_payload
return environ
def log_message( self, format, *args ):
log_file.write( '%s - - [%s] %s\n'% ( self.address_string(), self.log_date_time_string(), format%args ) )
try:
httpd = make_server( '', 80, simple_router.route, ThreadedWSGIServer, SimpleRequestHandler )
print( 'Starting simple_httpd on port '+str( httpd.server_port ) )
httpd.serve_forever()
except KeyboardInterrupt:
print( 'Shutting down simple_httpd' )
log_file.close()
httpd.socket.close()
GitHub 주소
https://github.com/wedump/Simple-Online-Game
01 주제 / 대상 독자 / 개발 원칙
02 요구사항 정의 / 설계
03 Python HTTP Server 개발
03-01 멀티 쓰레드 지원 / POST 메서드 처리 / 로깅
03-02 URL 라우팅 / 정적 리소스 처리
04 Python web socket server 개발
04-01 웹 소켓 개요
04-02 웹 소켓 서버 구현
05 클라이언트 개발
05-01 캐릭터 스프라이트 개발
05-02 클라이언트 로직
05-03 서버 로직