Python web socket server 개발
이번에는 지난 글에서 알아봤던 웹 소켓 스펙을 가지고 실제 구현체를 만들어 보겠습니다. 먼저 웹 소켓 서버가 다 만들어졌다는 가정하에 서버를 실행시키는 코드는 다음과 같습니다.
try:
port = 8080
wsd = WebsocketServer( '0.0.0.0', port, WebsocketRequestHandler )
print( 'Starting simple_wsd on port ' + str( port ) )
wsd.serve_forever()
except KeyboardInterrupt:
print( 'Shutting down simple_wsd' )
wsd.socket.close()
전에 만들었던 HTTP 서버를 실행하는 방법과 큰 차이는 없습니다. 변경된 핵심은 서버와 핸들러입니다. 우리는 앞으로 WebsocketServer와 WebsocketRequestHandler의 내용을 채워가면 됩니다. 참고로 WebsocketServer의 host 파라미터 자리의 0.0.0.0은 클라이언트가 어떤 IP로 들어오던 다 받겠다는 의미입니다. 우선 위 내용을 simple_wsd.py 파일을 만들어 저장하고 계속 이어가겠습니다.
자, 그럼 먼저 WebsocketServer 클래스를 구현해 볼까요?
WebsocketServer
from socketserver import ThreadingMixIn, TCPServer
class WebsocketServer( ThreadingMixIn, TCPServer ):
allow_reuse_address = True
daemon_threads = True
client_id = 0
clients = []
all_data = {}
def __init__( self, host, port, handlerClass ):
TCPServer.__init__( self, ( host, port ), handlerClass )
def find_client( self, handler ):
for client in self.clients:
if client[ 'handler' ] == handler:
return client
def in_client( self, handler ):
self.client_id += 1
self.clients.append( { 'id' : str( self.client_id ), 'handler' : handler } )
print( 'In client ' + str( self.client_id ) )
def out_client( self, handler ):
for client in self.clients:
if client[ 'handler' ] == handler:
self.clients.remove( client )
del self.all_data[ client[ 'id' ] ]
handler.send_message( json.dumps( { 'code' : 0, 'message' : 'success' } ) )
print( 'Out client ' + client[ 'id' ] )
break
def receive_message( self, handler, message ):
pass
먼저 멀티쓰레드의 TCP 통신을 하는 서버를 만들어야 하기 때문에 ThreadingMixIn, TCPServer 두 클래스를 상속받아 WebsocketServer 클래스를 생성합니다.
allow_reuse_address는 TCPServer의 속성으로, 일반적으로 클라이언트가 접속 중에 서버를 재시작했다면 다시 서버가 시작되고 재접속할 때 이미 사용 중이라는 오류를 반환하게 됩니다. allow_reuse_address를 True로 설정하게 되면 사용 중인 소켓이라도 재사용함으로써 오류 없이 진행할 수 있습니다. 특히 수시로 서버를 재시작해야 하는 개발환경에서는 꼭 필요한 설정입니다.
daemon_threads는 ThreadingMixIn의 속성으로, 값이 True라면 메인 프로세스가 종료되어도 데몬 쓰레드는 계속 실행하게 해줍니다. 무슨 말인가 하면 서버가 종료되어도 현재 작업 중이던 쓰레드는 종료되지 않고 정상적으로 작업을 마무리를 하게 되는 것입니다.
client_id는 각 클라이언트에게 식별 값으로 주어지는 시퀀스로 사용하려고 제가 넣은 커스텀 속성입니다. clients는 현재 접속 중인 클라이언트의 리스트입니다. 역시 커스텀 속성입니다. clients에 들어갈 클라이언트의 형식은 { id : (client_id), handler : (handler object) } 입니다. all_data는 클라이언트들의 데이터를 가지고 있는 커스텀 속성입니다. 이 속성은 나중에 클라이언트를 구현하면서 함께 설명하겠습니다.
생성자는 TCPServer의 생성자에 맞춰 호출해주면 되고, 클라이언트가 들어왔을 때와 나갔을 때를 처리해 줄 in_client와 out_client 그리고 이를 보조하기 위한 find_client 메서드를 구성하였습니다. TCPServer에서 각 클라이언트 별로 핸들러 인스턴스를 새로 생성하여 처리하기 때문에 클라이언트를 식별하는데 핸들러를 사용하였습니다. receive_message 메서드는 클라이언트와 연계된 비즈니스 로직이 들어가는데 이 역시 클라이언트 개발 때 함께 보도록 하겠습니다.
다음은 WebsocketRequestHandler 클래스를 구현해 보겠습니다.
WebsocketRequestHandler
from socketserver import BaseRequestHandler
class WebsocketRequestHandler( BaseRequestHandler ):
# Override
def setup( self ):
self.socket = self.request
self.is_valid = True
self.is_handshake = False
# Override
def handle( self ):
while self.is_valid:
if not self.is_handshake:
self.handshake()
else:
self.receive_message()
# Override
def finish( self ):
self.server.out_client( self )
BaseRequestHandler를 상속받아 WebsocketRequestHandler를 만들고, 메서드 3가지를 재정의합니다. setUp 메서드는 handle 메서드에 들어서기 앞서 처리해야 할 로직을 담는 메서드입니다. 저는 BaseRequestHandler가 가지고 있는 request( socket 객체 ) 멤버 변수를 이해하기 쉽도록 socket이라는 이름으로 변경하였고, 현재 클라이언트의 유효성 체크 용도인 is_valid와 핸드쉐이크가 이루어졌는지 확인하기 위한 is_handshake를 선언하였습니다. handle 메서드는 우리 서버의 본격적인 로직이 들어가는 부분입니다. 여기서는 클라이언트가 처음 진입할 경우 핸드쉐이크를 하고 결과가 유효한 경우 클라이언트가 보내는 메시지를 받을 준비를 하게 됩니다. 마지막으로 finish 메서드는 클라이언트의 연결이 끊어지는 시점에 호출되므로 필요한 종료 처리를 해주면 됩니다.
이어서 클라이언트와의 핸드쉐이크를 처리하는 handshake 메서드를 보시겠습니다.
class WebsocketRequestHandler( BaseRequestHandler ):
def handshake( self ):
header = self.socket.recv( 1024 ).decode().strip()
request_key = ''
for each in header.split( '\r\n' ):
if each.find( ': ' ) == -1:
continue
( k, v ) = each.split( ': ' )
if k.strip().lower() == 'sec-websocket-key':
request_key = v.strip()
break
if not request_key:
self.is_valid = False
print( 'Not valid handshake request_key' )
return
response_key = b64encode( sha1( request_key.encode() + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'.encode() ).digest() ).strip().decode()
response = \
'HTTP/1.1 101 Switching Protocols\r\n'\
'Upgrade: websocket\r\n'\
'Connection: Upgrade\r\n'\
'Sec-WebSocket-Accept: %s\r\n'\
'\r\n' % response_key
self.is_handshake = self.socket.send( response.encode() )
self.server.in_client( self )
print( 'Handshake OK!' )
여기서 부터는 지난 글에서 알아봤던 웹 소켓 스펙에 대해 기억하시면서 코드를 봐야 합니다. 우선 소켓을 통해 클라이언트로부터 요청정보를 받아옵니다. UTF-8( 디폴트 )로 디코딩 하면 앞에 글에서 살펴보았던 요청정보 문자열이 나올 것입니다. 그 형식에 따라 요청 문자열을 잘 분리하여 sec-websocket-key를 얻습니다. 이 키를 sha1으로 해싱한 후 base64로 인코딩 하면 bytearray가 나오는데 디코딩 하면 응답 키 문자열을 얻을 수 있습니다. 이 키 값을 응답 헤더 형식에 맞춰 소켓으로 전송하면 핸드쉐이크가 완료됩니다.
다음은 핸드쉐이크 완료 후 클라이언트로부터 데이터를 받는 메서드입니다. 한번 살펴보겠습니다.
class WebsocketRequestHandler( BaseRequestHandler ):
def receive_message( self ):
byte1, byte2 = self.socket.recv( 2 )
opcode = byte1 & 15
is_mask = byte2 & 128
payload_length = byte2 & 127
if not byte1 or opcode == 8 or not is_mask:
self.is_valid = False
return
if payload_length == 126:
payload_length = struct.unpack( '>H', self.socket.recv( 2 ) )[ 0 ]
elif payload_length == 127:
payload_length = struct.unpack( '>Q', self.socket.recv( 4 ) )[ 0 ]
masks = self.socket.recv( 4 )
payload = self.socket.recv( payload_length )
message = ''
for byte in payload:
byte ^= masks[ len( message ) % 4 ]
message += chr( byte )
self.server.receive_message( self, message )
웹 소켓 메시지 프레임 기억나시나요? 정해져 있는 형식대로 데이터를 주고받아야 합니다. 바로 아래 그림에 나오는 형식이었습니다.
두고 보면 편할 것 같아서 다시 가져와 봤습니다. 먼저 최초 2바이트만큼 데이터를 읽어 필요한 부분을 확인합니다. 각 필요한 부분은 AND 비트 연산을 통해 가져올 수 있는데요, opcode를 가져오려면 opcode가 첫 번째 바이트의 앞 4비트를 차지하고 있기 때문에 00001111( 15 )를 AND 연산해주면 opcode가 아닌 부분은 0, opcode인 부분은 opcode의 값에 따라 0 또는 1이 되어 opcode만 정제해 가져올 수 있습니다. 이런 식으로 opcode, MASK, Payload len 부분을 가져옵니다.
opcode가 8이라면 이는 클라이언트에서 연결 종료를 요청한 것입니다. 따라서 is_valid의 값을 False로 변경해 handle 메서드에서 더 이상 루프가 돌지 않고 종료되도록 처리합니다. MASK는 보안을 위해 클라이언트는 반드시 처리해줘야 하는 것이 웹 소켓 스펙이기 때문에 MASK가 1이 아니면 이 또한 유효하지 않은 처리를 하도록 하겠습니다. Payload len이 125 이하라면 그 자체로 본문 데이터의 길이를 나타내고, 126이라면 뒤 2바이트에, 127이라면 뒤 4바이트를 통해 본문 데이터의 길이를 표현하도록 되어있습니다. 따라서 Payload len을 읽어 그 값이 126이라면 다음 2바이트를 읽어 struct.unpack 으로 바이트를 int로 바꿔주는데, 여기서 >H는 2바이트 int 형을 나타냅니다. 127일 경우 동일하지만 >Q로 4바이트 int 형으로 변경합니다.
그다음은 Masking-key 4바이트를 읽고, 앞에서 계산한 본문 데이터의 길이만큼 Payload Data( 본문 데이터 )를 읽습니다. 그리고 Payload Data의 각 바이트에 Masking-key를 순차적으로 XOR 연산하여 실제 데이터 값을 추출합니다. 그리고는 이 값을 서버의 비즈니스 로직을 처리하는 server.receive_message 메서드로 보내면서 마무리되었습니다.
마지막으로 클라이언트에게 데이터를 전송하는 메서드입니다.
class WebsocketRequestHandler( BaseRequestHandler ):
def send_message( self, message ):
header = bytearray()
payload = message.encode( 'UTF-8' )
payload_length = len( payload )
header.append( 129 )
if payload_length <= 125:
header.append( payload_length )
elif payload_length >= 126 and payload_length <= pow( 2, 16 ):
header.append( 126 )
header.extend( struct.pack( '>H', payload_length ) )
elif payload_length <= pow( 2, 64 ):
header.append( 127 )
header.extend( struct.pack( '>Q', payload_length ) )
else:
print( 'Not valid send payload_length' )
return
self.socket.send( header + payload )
보내는 것 역시 받는 것과 별반 다르지 않습니다. 처음 1바이트는 FIN을 1로 opcode도 1로 즉 129로 설정합니다. opcode 1은 전송할 데이터가 UTF-8로 인코딩 된 TEXT 임을 의미합니다. 그 후 데이터의 길이가 125면 그대로, 126이면 뒤 2바이트를 통해, 127이면 뒤 4바이트를 통해 데이터 길이를 보내줍니다. 받았던 것 과는 반대로 struct.pack을 이용하여 int값을 바이트 값으로 변경합니다. 마지막으로 헤더와 본문 데이터를 붙여 전송하면 끝이납니다. 참고로 서버가 보낼 때는 마스킹은 하지 않습니다.
지금까지 웹 소켓 서버를 구현해 보았습니다. 이제 게임 개발을 위한 모든 서버가 완성되었고, 다음 글에서 부터는 이 서버들을 활용하는 클라이언트 개발을 시작하겠습니다.
simple_wsd.py ( All source code )
import json
import struct
from hashlib import sha1
from base64 import b64encode
from socketserver import ThreadingMixIn, TCPServer, BaseRequestHandler
class WebsocketServer( ThreadingMixIn, TCPServer ):
allow_reuse_address = True
daemon_threads = True
client_id = 0
clients = []
all_data = {}
def __init__( self, host, port, handlerClass ):
TCPServer.__init__( self, ( host, port ), handlerClass )
def find_client( self, handler ):
for client in self.clients:
if client[ 'handler' ] == handler:
return client
def in_client( self, handler ):
self.client_id += 1
self.clients.append( { 'id' : str( self.client_id ), 'handler' : handler } )
print( 'In client ' + str( self.client_id ) )
def out_client( self, handler ):
for client in self.clients:
if client[ 'handler' ] == handler:
self.clients.remove( client )
del self.all_data[ client[ 'id' ] ]
handler.send_message( json.dumps( { 'code' : 0, 'message' : 'success' } ) )
print( 'Out client ' + client[ 'id' ] )
break
def receive_message( self, handler, message ):
pass
class WebsocketRequestHandler( BaseRequestHandler ):
def setup( self ):
self.socket = self.request
self.is_valid = True
self.is_handshake = False
def handle( self ):
while self.is_valid:
if not self.is_handshake:
self.handshake()
else:
self.receive_message()
def finish( self ):
self.server.out_client( self )
def handshake( self ):
header = self.socket.recv( 1024 ).decode().strip()
request_key = ''
for each in header.split( '\r\n' ):
if each.find( ': ' ) == -1:
continue
( k, v ) = each.split( ': ' )
if k.strip().lower() == 'sec-websocket-key':
request_key = v.strip()
break
if not request_key:
self.is_valid = False
print( 'Not valid handshake request_key' )
return
response_key = b64encode( sha1( request_key.encode() + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'.encode() ).digest() ).strip().decode()
response = \
'HTTP/1.1 101 Switching Protocols\r\n'\
'Upgrade: websocket\r\n'\
'Connection: Upgrade\r\n'\
'Sec-WebSocket-Accept: %s\r\n'\
'\r\n' % response_key
self.is_handshake = self.socket.send( response.encode() )
self.server.in_client( self )
print( 'Handshake OK!' )
def receive_message( self ):
byte1, byte2 = self.socket.recv( 2 )
opcode = byte1 & 15
is_mask = byte2 & 128
payload_length = byte2 & 127
if not byte1 or opcode == 8 or not is_mask:
self.is_valid = False
return
if payload_length == 126:
payload_length = struct.unpack( '>H', self.socket.recv( 2 ) )[ 0 ]
elif payload_length == 127:
payload_length = struct.unpack( '>Q', self.socket.recv( 4 ) )[ 0 ]
masks = self.socket.recv( 4 )
payload = self.socket.recv( payload_length )
message = ''
for byte in payload:
byte ^= masks[ len( message ) % 4 ]
message += chr( byte )
self.server.receive_message( self, message )
def send_message( self, message ):
header = bytearray()
payload = message.encode( 'UTF-8' )
payload_length = len( payload )
header.append( 129 )
if payload_length <= 125:
header.append( payload_length )
elif payload_length >= 126 and payload_length <= pow( 2, 16 ):
header.append( 126 )
header.extend( struct.pack( '>H', payload_length ) )
elif payload_length <= pow( 2, 64 ):
header.append( 127 )
header.extend( struct.pack( '>Q', payload_length ) )
else:
print( 'Not valid send payload_length' )
return
self.socket.send( header + payload )
try:
port = 8080
wsd = WebsocketServer( '0.0.0.0', port, WebsocketRequestHandler )
print( 'Starting simple_wsd on port ' + str( port ) )
wsd.serve_forever()
except KeyboardInterrupt:
print( 'Shutting down simple_wsd' )
wsd.socket.close()
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 서버 로직