Django 채팅 앱 만들기
channels 공식 문서에 정리가 잘 되어있다.
장고 기본 페이지 생성
개발 환경
윈도우10, Anaconda3 가상환경에서 수행했다.
$ conda --version conda 4.10.1 $ conda create -n chat python=3.8 $ conda activate chat $ python --version Python 3.8.11
기본적으로 필요한 라이브러리들을 설치한다.
$ pip install Django $ python -m django --version 3.2.6 $ pip install channels $ python -c 'import channels; print(channels.__version__)' 3.0.4
프로젝트 생성
작업할 chat 폴더로 들어가서 프로젝트를 생성한다.
$ django-admin startproject mysite .
.
은 현재 폴더를 프로젝트의 루트 디렉토리로 구성하라는 의미이다.- 생략하면 프로젝트명으로 하위 폴더가 만들어진다.
chat 앱을 생성한다.
$ python manage.py startapp chat
mysite/settings.py 의
INSTALLED_APPS
에 방금 생성한 앱을 추가한다.mysite/settings.py
INSTALLED_APPS = [ 'chat', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', ]
제공되는 기본 html 파일을 chat/templates/chat/index.html 에 입력한다.
chat/templates/chat/index.html
<!DOCTYPE html> <html> <head> <meta charset="utf-8"/> <title>Chat Rooms</title> </head> <body> What chat room would you like to enter?<br> <input id="room-name-input" type="text" size="100"><br> <input id="room-name-submit" type="button" value="Enter"> <script> document.querySelector('#room-name-input').focus(); document.querySelector('#room-name-input').onkeyup = function(e) { if (e.keyCode === 13) { // enter, return document.querySelector('#room-name-submit').click(); } }; document.querySelector('#room-name-submit').onclick = function(e) { var roomName = document.querySelector('#room-name-input').value; window.location.pathname = '/chat/' + roomName + '/'; }; </script> </body> </html>
chat/views.py 에서 index.html을 렌더링하는 함수를 생성하고 URLconf를 설정한다.
chat/views.py
from django.shortcuts import render def index(request): return render(request, 'chat/index.html')
chat/urls.py
from django.urls import path from . import views urlpatterns = [ path('', views.index, name='index'), ]
mysite/urls.py
from django.contrib import admin from django.urls import path, include urlpatterns = [ path('chat/', include('chat.urls')), path('admin/', admin.site.urls), ]
서버를 돌리면 마이그레이션 하라고 뜨는데, 일단 정상적으로 작동하는지 확인한다.
$ python3 manage.py runserver Django version 3.2.6, using settings 'mysite.settings' Starting development server at http://127.0.0.1:8000/ Quit the server with CTRL-BREAK.
- http://127.0.0.1:8000/chat/ 으로 접속하면 url 매핑이 정상적으로 이루어진 것을 볼 수 있다.
Channels 라이브러리 적용
Channels의 라우팅은 장고의 URLconf와 비슷하다.
HTTP 요청이 들어오면 장고는 root URLconf에서 view 함수를 찾아서 요청을 처리한다.
비슷하게, Channels는 웹 소켓 연결을 받아서 root routing configuration에서 consumer를 찾는다.
- consumer의 다양한 함수를 통해 연결에서 오는 이벤트들을 처리한다.
mysite/asgi.py를 다음과 같이 수정한다.
mysite/asgi.py
import os from channels.routing import ProtocolTypeRouter from django.core.asgi import get_asgi_application os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings') application = ProtocolTypeRouter({ "http": get_asgi_application(), # Just HTTP for now. (We can add other protocols later.) })
Channels 라이브러리를 mysite/settings.py 의
INSTALLED_APPS
에 추가하고, 루트 라우팅 설정도 해준다.mysite/settings.py
INSTALLED_APPS = [ 'channels', 'chat', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', ] # Channels ASGI_APPLICATION = 'mysite.asgi.application'
- 맨 위에 추가하지 않으면 충돌이 발생한다고 한다.
서버를 실행하면 아까와 다른 문구가 뜨는 것을 볼 수 있다.
python manage.py runserver Django version 3.2.6, using settings 'mysite.settings' Starting ASGI/Channels version 3.0.4 development server at http://127.0.0.1:8000/ Quit the server with CTRL-BREAK.
http://127.0.0.1:8000/chat/ 사이트는 여전히 정상적으로 작동한다.
서버 실행
위에서 생성한 http://127.0.0.1:8000/chat/ 페이지에서 방 제목을 입력하면 해당 방으로 들어가서 채팅하는 앱을 만드는 것이 목표이다.
방에 해당하는 템플릿과 뷰를 작성하고 URLconf를 설정한다.
chat/templates/chat/room.html
<!DOCTYPE html> <html> <head> <meta charset="utf-8"/> <title>Chat Room</title> </head> <body> <textarea id="chat-log" cols="100" rows="20"></textarea><br> <input id="chat-message-input" type="text" size="100"><br> <input id="chat-message-submit" type="button" value="Send"> {{ room_name|json_script:"room-name" }} <script> const roomName = JSON.parse(document.getElementById('room-name').textContent); const chatSocket = new WebSocket( 'ws://' + window.location.host + '/ws/chat/' + roomName + '/' ); chatSocket.onmessage = function(e) { const data = JSON.parse(e.data); document.querySelector('#chat-log').value += (data.message + '\n'); }; chatSocket.onclose = function(e) { console.error('Chat socket closed unexpectedly'); }; document.querySelector('#chat-message-input').focus(); document.querySelector('#chat-message-input').onkeyup = function(e) { if (e.keyCode === 13) { // enter, return document.querySelector('#chat-message-submit').click(); } }; document.querySelector('#chat-message-submit').onclick = function(e) { const messageInputDom = document.querySelector('#chat-message-input'); const message = messageInputDom.value; chatSocket.send(JSON.stringify({ 'message': message })); messageInputDom.value = ''; }; </script> </body> </html>
chat/views.py
from django.shortcuts import render def index(request): return render(request, 'chat/index.html', {}) def room(request, room_name): return render(request, 'chat/room.html', { 'room_name': room_name })
chat/urls.py
from django.urls import path from . import views urlpatterns = [ path('', views.index, name='index'), path('<str:room_name>/', views.room, name='room'), ]
이대로 서버를 실행해서 http://127.0.0.1:8000/chat/ 에 접속하고 방 제목을 입력해보면 해당 페이지로 이동되는 것을 볼 수 있다.
하지만 이동하는 순간 서버에는 다음과 같은 오류가 발생한다.
ValueError: No application configured for scope type 'websocket'
클라이언트에서도 콘솔 창을 열어보면 다음과 같은 오류가 발생한 것을 볼 수 있다.
WebSocket connection to 'ws://127.0.0.1:8000/ws/chat/asd/' failed: Chat socket closed unexpectedly
html 파일의 자바스크립트에서 WebSocket을 연결하는 것에 실패했기 때문이다.
해당 URL에 대한 웹 소켓을 받아주는 consumer가 필요하다.
chat/consumers.py
import json from channels.generic.websocket import WebsocketConsumer class ChatConsumer(WebsocketConsumer): def connect(self): self.accept() def disconnect(self, close_code): pass def receive(self, text_data): text_data_json = json.loads(text_data) message = text_data_json['message'] self.send(text_data=json.dumps({ 'message': message }))
receive()
위의 chat/templates/chat/room.html 파일에서
document.querySelector('#chat-message-submit').onclick
이벤트를 설정했었다.- 이 함수에서 보낸 json 데이터를 다시 클라이언트에게 되돌려주는 역할을 한다.
클라이언트가 메시지를 다시 받으면 chat/templates/chat/room.html 파일의
chatSocket.onmessage
이벤트에서 로그를 추가하는 방식이다.
이 컨슈머는 동기적(synchronous)이다.
- 비동기적(asynchronous)으로 구현하는게 훨씬 성능이 좋지만, 장고 모델 접근에서 문제가 발생할 수 있으니 Consumer 문서를 잘 참고하라고 써있다.
이 consumer를 이용하기 위해서 routing configuration을 설정해야 한다.
chat/routing.py
from django.urls import re_path from . import consumers websocket_urlpatterns = [ re_path(r'ws/chat/(?P<room_name>\w+)/$', consumers.ChatConsumer.as_asgi()), ]
각각의 연결에 consumer의 인스턴스를 생성하기 위해
as_asgi()
클래스메서드를 사용했다.- 장고 뷰 인스턴스의 per-request와 같은 역할이라는데 이건 또 공부해봐야겠다.
re_path()
함수를 사용한 것은, 만약 내부 라우터가 미들웨어에 감싸져 있을 경우path()
함수는 제대로 동작하지 않을 수 있기 때문이다.
위에서 작성한 설정을 root routing configuration에서 지정한다.
mysite/asgi.py
import os from channels.auth import AuthMiddlewareStack from channels.routing import ProtocolTypeRouter, URLRouter from django.core.asgi import get_asgi_application import chat.routing os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") application = ProtocolTypeRouter({ "http": get_asgi_application(), "websocket": AuthMiddlewareStack( URLRouter( chat.routing.websocket_urlpatterns ) ), })
서버로 연결이 생성되면 먼저
ProtocolTypeRouter
가 연결의 종류를 탐색한다.ws:// 혹은 wss:// 형식의 웹 소켓 연결이면, 해당 연결은
AuthMiddlewareStack
으로 메시지를 group의 모든 channel에게 보낸다.
chat_message()
channel layer의 group으로부터 전달된다.
AuthMiddlewareStack
은 현재 인증된 유저를 참조하여 연결의 scope를 채운다.- 장고의
AuthenticationMiddleware
이 현재 인증된 유저를 참조하여 view 함수의 request 객체를 채우는 것과 비슷하다고 한다.
- 장고의
그 후 연결은
URLRouter
로 전달된다.
여기까지 하고 migration 후 서버를 돌렸더니 오류가 발생했다.
$ python manage.py migrate $ python manage.py runserver django.core.exceptions.ImproperlyConfigured: Cannot import ASGI_APPLICATION module 'mysite.asgi'
chat/consumers.py 를 chat/consumer.py로 오타내서 발생한 문제여서, 파일명을 변경하여 해결했다.
python manage.py shell
로 파이썬 쉘에 진입한 뒤from mysite.routing import application
로 직접 import 했을 때 뜨는 에러 문구로 어디서 잘못했는지 짐작할 수 있다.
이제 http://127.0.0.1:8000/chat/a 등의 주소로 이동해서 메시지를 입력해보면 해당 메시지가 성공적으로 되돌아오는 것(echo)을 볼 수 있다.
같은 주소로 창을 두 개 열어서 확인해보면 다른 쪽에는 메시지가 전송되지 않는 것을 볼 수 있다.
이를 해결하려면
ChatConsumer
의 여러 인스턴스가 서로 통신하도록 해야 한다.Channels는 컨슈머끼리 이런 통신을 할 수 있도록 하는 channel layer를 제공한다.
channel layer 활성화
channel layer는 다음과 같은 추상화를 제공한다.
channel
메시지가 전달될 수 있는 우체통이다.
각 channel은 이름을 가지며, 다른 channel에게 메시지를 전송할 수 있다.
group
연관된 channel들의 그룹이다.
group은 이름을 가지며, channel의 이름을 통해 추가하고 삭제할 수 있다.
그룹의 모든 channel에게 메시지를 전송할 수 있다.
특정 그룹에 있는 channel들을 나열할 수는 없다.
모든 consumer 인스턴스는 자동으로 유일한 channel name을 생성하기 때문에, 서로 channel layer를 통해 통신할 수 있다.
지금 진행 중인 앱에서는 같은 방에 있는
ChatConsumer
의 여러 인스턴스들끼리 통신하는게 목표이다.이를 위해 각
ChatConsumer
인스턴스는 room name을 이름으로 하는 group에 channel을 추가한다.이러면 같은 방에 있는 channel들은 같은 group에 속하므로 서로 통신할 수 있게 된다.
도커의 redis 컨테이너를 backing store로 사용하는 channel layer를 생성할 것이다.
$ docker run -p 6379:6379 -d redis:5
redis컨테이너는 6379 포트를 사용한다.
redis 5.0 버전을 사용하는 이유는 아마...
https://redis.io/download#other-versions
Redis 5.0 is the first version of Redis to introduce the new stream data type with consumer groups, ...
Channels와 redis를 연결하기 위해 라이브러리를 설치한다.
$ pip install channels_redis
channel layer를 사용하기 위해 mysite/settings.py 의
ASGI_APPLICATION
밑에 다음과 같이 백엔드와 호스트에 대한 설정을 추가한다.# Channels ASGI_APPLICATION = 'mysite.asgi.application' CHANNEL_LAYERS = { 'default': { 'BACKEND': 'channels_redis.core.RedisChannelLayer', 'CONFIG': { "hosts": [('127.0.0.1', 6379)], }, }, }
channel layer와 redis의 연결을 확인하기 위해 다음과 같이 입력해본다.
$ python manage.py shell >>> import channels.layers >>> channel_layer = channels.layers.get_channel_layer() >>> from asgiref.sync import async_to_sync >>> async_to_sync(channel_layer.send)('test_channel', {'type': 'hello'}) >>> async_to_sync(channel_layer.receive)('test_channel') {'type': 'hello'} >>> exit()
이제 channel layer가 있으므로,
ChatConsumer
를 수정하여 group 내의 channel에게 보내도록 설정한다.chat/consumers.py
import json from asgiref.sync import async_to_sync from channels.generic.websocket import WebsocketConsumer class ChatConsumer(WebsocketConsumer): def connect(self): self.room_name = self.scope['url_route']['kwargs']['room_name'] self.room_group_name = 'chat_%s' % self.room_name # Join room group async_to_sync(self.channel_layer.group_add)( self.room_group_name, self.channel_name ) self.accept() def disconnect(self, close_code): # Leave room group async_to_sync(self.channel_layer.group_discard)( self.room_group_name, self.channel_name ) # Receive message from WebSocket def receive(self, text_data): text_data_json = json.loads(text_data) message = text_data_json['message'] # Send message to room group async_to_sync(self.channel_layer.group_send)( self.room_group_name, { 'type': 'chat_message', 'message': message } ) # Receive message from room group def chat_message(self, event): message = event['message'] # Send message to WebSocket self.send(text_data=json.dumps({ 'message': message }))
connect()
연결 시 channel layer의 group에 channel을 추가한다.
self.room_name
은 chat/urls.py에서 지정한 변수명을AuthMiddlewareStack
가 scope에 kwargs 형태로 채워준 것을 꺼내서 사용한다.실제로 channel layer에서 사용하는건
self.room_group_name
이다.- 굳이 이렇게 변환하는 이유는..?
disconnect()
- 연결 해제 시 channel layer의 group에서 channel을 삭제한다.
receive()
- 웹 소켓으로부터 메시지를 받을 시 메시지를 channel layer의 group 내 모든 channel에게 보낸다.
chat_message()
channel layer의 group으로부터 메시지를 받을 시 메시지를 클라이언트로 전달한다.
전과 마찬가지로
chatSocket.onmessage
이벤트에 의해 처리된다.
서버를 실행하고 클라이언트 두 개로 통신해보면 정상적으로 작동하는 것을 볼 수 있다.