Tornado를 사용하여 간단한 Python WebSocket 서버를 만드는 방법
게시 됨: 2022-03-11실시간 웹 애플리케이션의 인기가 높아짐에 따라 WebSocket은 구현의 핵심 기술이 되었습니다. 서버에서 업데이트를 수신하기 위해 계속 새로고침 버튼을 눌러야 했던 시대는 지났습니다. 실시간 업데이트를 제공하려는 웹 응용 프로그램은 더 이상 변경 사항에 대해 서버를 폴링할 필요가 없습니다. 대신 서버는 변경 사항이 발생하면 스트림을 아래로 푸시합니다. 강력한 웹 프레임워크가 기본적으로 WebSocket을 지원하기 시작했습니다. 예를 들어 Ruby on Rails 5는 더 나아가 액션 케이블에 대한 지원을 추가했습니다.
Python의 세계에는 많은 인기 있는 웹 프레임워크가 존재합니다. Django와 같은 프레임워크는 웹 애플리케이션을 구축하는 데 필요한 거의 모든 것을 제공하며, Django에서 사용할 수 있는 수천 개의 플러그인 중 하나로 부족한 부분을 보완할 수 있습니다. 그러나 Python 또는 대부분의 웹 프레임워크가 작동하는 방식으로 인해 수명이 긴 연결을 처리하는 것은 빠르게 악몽이 될 수 있습니다. 스레드 모델과 전역 인터프리터 잠금은 종종 Python의 아킬레스건으로 간주됩니다.
그러나 그 모든 것이 바뀌기 시작했습니다. Python 3의 특정 새로운 기능과 Tornado와 같이 Python용으로 이미 존재하는 프레임워크를 사용하면 수명이 긴 연결을 처리하는 것이 더 이상 어려운 일이 아닙니다. Tornado는 특히 수명이 긴 연결을 처리하는 데 유용한 Python의 웹 서버 기능을 제공합니다.
이 기사에서는 Tornado를 사용하여 Python에서 간단한 WebSocket 서버를 구축하는 방법을 살펴보겠습니다. 데모 애플리케이션을 사용하면 탭으로 구분된 값(TSV) 파일을 업로드하고 이를 구문 분석하고 고유한 URL에서 해당 콘텐츠를 사용할 수 있습니다.
토네이도와 웹소켓
Tornado는 비동기식 네트워크 라이브러리이며 이벤트 기반 네트워킹을 전문으로 처리합니다. 자연스럽게 수만 개의 열린 연결을 동시에 보유할 수 있기 때문에 서버는 이를 활용하여 단일 노드 내에서 많은 WebSocket 연결을 처리할 수 있습니다. WebSocket은 단일 TCP 연결을 통해 전이중 통신 채널을 제공하는 프로토콜입니다. 개방형 소켓이므로 이 기술은 웹 연결을 스테이트풀(Stateful)로 만들고 서버와의 실시간 데이터 전송을 용이하게 합니다. 클라이언트의 상태를 유지하는 서버는 WebSocket을 기반으로 하는 실시간 채팅 애플리케이션이나 웹 게임을 쉽게 구현할 수 있도록 합니다.
WebSocket은 웹 브라우저와 서버에서 구현되도록 설계되었으며 현재 모든 주요 웹 브라우저에서 지원됩니다. 연결은 한 번 열리며 연결이 닫히기 전에 메시지가 여러 번 앞뒤로 이동할 수 있습니다.
Tornado를 설치하는 것은 다소 간단합니다. PyPI에 나열되어 있으며 pip 또는 easy_install을 사용하여 설치할 수 있습니다.
pip install tornado
Tornado는 WebSocket의 자체 구현과 함께 제공됩니다. 이 기사의 목적을 위해 이것이 우리가 필요로 하는 거의 전부입니다.
작동 중인 웹소켓
WebSocket 사용의 장점 중 하나는 상태 저장 속성입니다. 이것은 우리가 일반적으로 클라이언트-서버 통신에 대해 생각하는 방식을 바꿉니다. 이것의 한 가지 특정 사용 사례는 서버가 길고 느린 프로세스를 수행하고 점차적으로 결과를 클라이언트로 다시 스트리밍해야 하는 경우입니다.
예제 애플리케이션에서 사용자는 WebSocket을 통해 파일을 업로드할 수 있습니다. 연결의 전체 수명 동안 서버는 구문 분석된 파일을 메모리에 유지합니다. 요청 시 서버는 파일의 일부를 프런트 엔드로 다시 보낼 수 있습니다. 또한 파일은 여러 사용자가 볼 수 있는 URL에서 사용할 수 있습니다. 동일한 URL에 다른 파일이 업로드되면 해당 파일을 보는 모든 사람이 즉시 새 파일을 볼 수 있습니다.
프론트 엔드의 경우 AngularJS를 사용합니다. 이 프레임워크와 라이브러리를 사용하면 파일 업로드와 페이지 매김을 쉽게 처리할 수 있습니다. 그러나 WebSocket과 관련된 모든 것에는 표준 JavaScript 함수를 사용합니다.
이 간단한 응용 프로그램은 세 개의 개별 파일로 나뉩니다.
- parser.py: 요청 핸들러가 있는 Tornado 서버가 구현된 곳
- Templates/index.html: 프런트 엔드 HTML 템플릿
- static/parser.js: 프론트엔드 JavaScript용
웹소켓 열기
프런트 엔드에서 WebSocket 개체를 인스턴스화하여 WebSocket 연결을 설정할 수 있습니다.
new WebSocket(WEBSOCKET_URL);
이것은 페이지 로드 시 수행해야 하는 작업입니다. WebSocket 객체가 인스턴스화되면 세 가지 중요한 이벤트를 처리하기 위해 핸들러를 연결해야 합니다.
- open: 연결이 설정되면 시작됩니다.
- message: 서버로부터 메시지를 받았을 때 발생
- 닫기: 연결이 닫힐 때 발생
$scope.init = function() { $scope.ws = new WebSocket('ws://' + location.host + '/parser/ws'); $scope.ws.binaryType = 'arraybuffer'; $scope.ws.onopen = function() { console.log('Connected.') }; $scope.ws.onmessage = function(evt) { $scope.$apply(function () { message = JSON.parse(evt.data); $scope.currentPage = parseInt(message['page_no']); $scope.totalRows = parseInt(message['total_number']); $scope.rows = message['data']; }); }; $scope.ws.onclose = function() { console.log('Connection is closed...'); }; } $scope.init();
이러한 이벤트 핸들러는 AngularJS의 $scope 라이프사이클을 자동으로 트리거하지 않으므로 핸들러 함수의 내용은 $apply로 래핑되어야 합니다. 관심이 있는 경우 AngularJS 응용 프로그램에 WebSocket을 더 쉽게 통합할 수 있도록 하는 AngularJS 특정 패키지가 있습니다.
삭제된 WebSocket 연결은 자동으로 다시 설정되지 않으며 닫기 이벤트 처리기가 트리거될 때 응용 프로그램에서 다시 연결을 시도해야 한다는 점을 언급할 가치가 있습니다. 이것은 이 기사의 범위를 약간 벗어납니다.
업로드할 파일 선택
AngularJS를 사용하여 단일 페이지 애플리케이션을 구축 중이므로 오래된 방식으로 파일과 함께 양식을 제출하려고 시도하면 작동하지 않습니다. 일을 더 쉽게 하기 위해 Danial Farid의 ng-file-upload 라이브러리를 사용할 것입니다. which를 사용하여 사용자가 파일을 업로드할 수 있도록 하려면 특정 AngularJS 지시문을 사용하여 프런트 엔드 템플릿에 버튼을 추가하기만 하면 됩니다.
<button class="btn btn-default" type="file" ngf-select="uploadFile($file, $invalidFiles)" accept=".tsv" ngf-max-size="10MB">Select File</button>
라이브러리를 사용하면 수용 가능한 파일 확장자와 크기를 설정할 수 있습니다. <input type=”file”>
요소와 마찬가지로 이 버튼을 클릭하면 표준 파일 선택기가 열립니다.
파일 업로드
바이너리 데이터를 전송할 때 어레이 버퍼와 블롭 중에서 선택할 수 있습니다. 이미지 파일과 같은 원시 데이터라면 blob을 선택하여 서버에서 적절하게 처리합니다. 어레이 버퍼는 고정 길이 바이너리 버퍼용으로 TSV와 같은 텍스트 파일을 바이트 스트링 형식으로 전송할 수 있다. 이 코드 조각은 배열 버퍼 형식으로 파일을 업로드하는 방법을 보여줍니다.
$scope.uploadFile = function(file, errFiles) { ws = $scope.ws; $scope.f = file; $scope.errFile = errFiles && errFiles[0]; if (file) { reader = new FileReader(); rawData = new ArrayBuffer(); reader.onload = function(evt) { rawData = evt.target.result; ws.send(rawData); } reader.readAsArrayBuffer(file); } }
ng-file-upload 지시문은 uploadFile 기능을 제공합니다. 여기에서 FileReader를 사용하여 파일을 배열 버퍼로 변환하고 WebSocket을 통해 보낼 수 있습니다.

WebSocket을 통해 대용량 파일을 어레이 버퍼로 읽어 전송하는 것은 많은 메모리를 빠르게 차지하여 좋지 않은 경험을 초래할 수 있으므로 업로드하는 가장 최적의 방법이 아닐 수 있습니다.
서버에서 파일 수신
Tornado는 4비트 opcode를 사용하여 메시지 유형을 결정하고 이진 데이터의 경우 str을 반환하고 텍스트의 경우 unicode를 반환합니다.
if opcode == 0x1: # UTF-8 data self._message_bytes_in += len(data) try: decoded = data.decode("utf-8") except UnicodeDecodeError: self._abort() return self._run_callback(self.handler.on_message, decoded) elif opcode == 0x2: # Binary data self._message_bytes_in += len(data) self._run_callback(self.handler.on_message, data)
Tornado 웹 서버에서 배열 버퍼는 str 형식으로 수신됩니다.
이 예에서 예상하는 콘텐츠 유형은 TSV이므로 파일이 구문 분석되고 사전으로 변환됩니다. 물론 실제 응용 프로그램에는 임의 업로드를 처리하는 더 합리적인 방법이 있습니다.
def make_message(self, page_no=1): page_size = 100 return { "page_no": page_no, "total_number": len(self.rows), "data": self.rows[page_size * (page_no - 1):page_size * page_no] } def on_message(self, message): if isinstance(message, str): self.rows = [csv.reader([line], delimiter="\t").next() for line in (x.strip() for x in message.splitlines()) if line] self.write_message(self.make_message())
페이지 요청
업로드된 TSV 데이터를 작은 페이지 단위로 보여주는 것이 목표이므로 특정 페이지를 요청하는 수단이 필요합니다. 일을 단순하게 유지하기 위해 동일한 WebSocket 연결을 사용하여 페이지 번호를 서버로 보내기만 하면 됩니다.
$scope.pageChanged = function() { ws = $scope.ws; ws.send($scope.currentPage); }
서버는 이 메시지를 유니코드로 수신합니다.
def on_message(self, message): if isinstance(message, unicode): page_no = int(message) self.write_message(self.make_message(page_no))
Tornado WebSocket 서버에서 dict로 응답을 시도하면 자동으로 JSON 형식으로 인코딩됩니다. 따라서 100행의 콘텐츠가 포함된 사전을 보내는 것만으로 완전히 괜찮습니다.
다른 사람과 액세스 공유
동일한 업로드에 대한 액세스를 여러 사용자와 공유할 수 있으려면 업로드를 고유하게 식별할 수 있어야 합니다. 사용자가 WebSocket을 통해 서버에 연결할 때마다 임의의 UUID가 생성되어 연결에 할당됩니다.
def open(self, doc_uuid=None): if doc_uuid is None: self.uuid = str(uuid.uuid4())
uuid.uuid4()
는 임의의 UUID를 생성하고 str()은 UUID를 표준 형식의 16진수 문자열로 변환합니다.
UUID를 가진 다른 사용자가 서버에 연결하면 FileHandler의 해당 인스턴스가 UUID를 키로 사용하여 사전에 추가되고 연결이 닫힐 때 제거됩니다.
@classmethod @tornado.gen.coroutine def add_clients(cls, doc_uuid, client): with (yield lock.acquire()): if doc_uuid in cls.clients: clients_with_uuid = FileHandler.clients[doc_uuid] clients_with_uuid.append(client) else: FileHandler.clients[doc_uuid] = [client] @classmethod @tornado.gen.coroutine def remove_clients(cls, doc_uuid, client): with (yield lock.acquire()): if doc_uuid in cls.clients: clients_with_uuid = FileHandler.clients[doc_uuid] clients_with_uuid.remove(client) if len(clients_with_uuid) == 0: del cls.clients[doc_uuid]
클라이언트를 동시에 추가하거나 제거할 때 클라이언트 사전에서 KeyError가 발생할 수 있습니다. Tornado는 비동기식 네트워킹 라이브러리이므로 동기화를 위한 잠금 메커니즘을 제공합니다. 코루틴이 있는 간단한 잠금은 클라이언트 사전을 처리하는 이 경우에 적합합니다.
사용자가 파일을 업로드하거나 페이지 간에 이동하면 동일한 UUID를 가진 모든 사용자가 동일한 페이지를 봅니다.
@classmethod def send_messages(cls, doc_uuid): clients_with_uuid = cls.clients[doc_uuid] message = cls.make_message(doc_uuid) for client in clients_with_uuid: try: client.write_message(message) except: logging.error("Error sending message", exc_info=True)
Nginx 뒤에서 실행
WebSocket을 구현하는 것은 매우 간단하지만 프로덕션 환경에서 사용할 때 고려해야 할 몇 가지 까다로운 사항이 있습니다. Tornado는 웹 서버이므로 사용자의 요청을 직접 받을 수 있지만 Nginx 뒤에 배포하는 것이 여러 가지 이유로 더 나은 선택일 수 있습니다. 그러나 Nginx를 통해 WebSocket을 사용할 수 있으려면 훨씬 더 많은 노력이 필요합니다.
http { upstream parser { server 127.0.0.1:8080; } server { location ^~ /parser/ws { proxy_pass http://parser; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } } }
두 개의 proxy_set_header
지시문은 Nginx가 WebSocket으로의 연결을 업그레이드하는 데 필요한 백엔드 서버에 필요한 헤더를 전달하도록 합니다.
무엇 향후 계획?
이 기사에서는 WebSocket을 사용하여 서버와 각 클라이언트 간의 지속적인 연결을 유지하는 간단한 Python 웹 애플리케이션을 구현했습니다. Tornado와 같은 최신 비동기식 네트워킹 프레임워크를 사용하면 Python에서 동시에 수만 개의 열린 연결을 유지하는 것이 완전히 가능합니다.
이 데모 애플리케이션의 특정 구현 측면은 다르게 수행될 수 있지만 https://www.toptal.com/tornado 프레임워크에서 WebSocket의 사용을 시연하는 데 여전히 도움이 되었기를 바랍니다. 데모 애플리케이션의 소스 코드는 GitHub에서 사용할 수 있습니다.