Channels는 Django를 확장해 웹소켓과 같이 HTTP가 아닌 프로토콜을 핸들링할 수 있게 돕고 비동기적인 처리를 가능하게 해주는 ASGI의 구현체로, 장고를 이용한 실시간 채팅 구현 등에 활용할 수 있습니다. 이 글은 채널즈의 공식 문서를 최대한 원어를 살려 번역한 글입니다. 다소 의역하거나 생략한 부분이 있을 수 있음을 너그러이 양해해주시고, 잘못을 자유롭게 지적해주시면 감사하겠습니다.
튜토리얼 1에서 이어집니다.
이제 채팅방 페이지를 구현해 같은 방 안에 있는 사람들끼리 채팅할 수 있도록 만들어 봅시다.
이제 두번째 뷰인 채팅방 뷰(room view)를 만들어 특정 방에 게시된 메시지를 볼 수 있게 할겁니다.
chat/templates/chat/room.html
파일을 만들어주세요. 그러면 앱 디렉토리는 다음과 같습니다:
chat/
__init__.py
templates/
chat/
index.html
room.html
urls.py
views.py
채팅방 뷰의 템플릿을 만들어 줍니다. 다음 코드를 chat/templates/chat/room.html
에 붙여넣어 주세요:
<!-- 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
에서 채팅방 뷰를 만들어 줍니다:
# 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
에서 채팅방 뷰를 위한 url 경로를 다음과 같이 지정해 줍니다:
# 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'),
]
다시 채널즈 개발 서버를 켜볼까요!
$ python3 manage.py runserver
브라우저를 열고 http://127.0.0.1:8000/chat/
로 들어가 보세요. 초기 화면에서 "lobby"를 방 이름으로 타이핑하고 엔터를 칩니다. 그러면 http://127.0.0.1:8000/chat/lobby/
로 리다이렉트 되고, 빈 채팅 로그가 나타납니다.
"안녕!"이라고 메시지를 타이핑하고 엔터를 눌러보세요. 아무 일도 일어나지 않습니다. 특히 방금 타이핑한 메시지도 채팅 로그에 나타나지 않습니다. 왜일까요?
엔터를 누르면 채팅방 뷰는 URL ws://127.0.0.1:8000/ws/chat/lobby/
로 향하는 WebSocket을 열려 합니다. 하지만 해당하는 웹소켓 연결을 받아들이는 consumer
를 아직 만들지 않았죠. 따라서 브라우저의 자바스크립트 콘솔을 열면 다음과 같은 에러가 나타납니다:
WebSocket connection to 'ws://127.0.0.1:8000/ws/chat/lobby/' failed: Unexpected response code: 500
장고는 HTTP 요청을 받으면 최상단 URLconf를 확인해 대응하는 뷰 함수를 찾고, 이 뷰가 이 요청을 처리할 수 있도록 호출합니다. 마찬가지로, 채널즈가 웹소켓 요청을 받으면 최상단 라우팅 설정을 확인해 대응하는 컨슈머(Consumer)를 찾고, 이 컨슈머의 다양한 함수를 호출해 요청을 처리하도록 합니다.
한 번 /ws/chat/ROOM_NAME/
로 들어오는 웹소켓 연결을 처리하는 기본적인 컨슈머를 하나 작성해 봅시다. 이 컨슈머는 들어오는 메시지를 받아들이고, 같은 웹소켓으로 다시 보내줘야 합니다.
주의사항
웹소켓 연결을 다른 일반적인 HTTP 연결과 구분하기 위해 웹소켓용 경로에는 앞에
/ws/
같은 것을 붙여주는 것이 좋습니다. 이를 통해 채널즈를 프로덕션 환경에 배포할 때 특정 설정을 더욱 쉽게 할 수 있습니다.특히 대형 사이트 등에서
nginx
와 같은 프로덕션 수준 HTTP 서버를 설정해 요청을 경로에 따라 나누어 (1) 일반 HTTP 요청은Gunicorn
+Django
와 같은 프로덕션의 WSGI 서버로 전달하고 (2) 웹소켓 요청은Daphne
+Channels
와 같은 프로덕션의 ASGI 서버로 전달해 처리하는 것이 가능합니다.다만 작은 규모의 사이트에서는 더 단순하게 별도의 WSGI 서버를 두지 않고
Daphne
서버가 혼자 모든 요청-HTTP와 웹소켓 둘 다-을 처리하도록 할 수도 있습니다. 이런 배포환경에서는/ws/
와 같은 공통된 경로 앞에 붙는 단어가 필요하지 않습니다.
컨슈머를 만들기 위해 chat/consumers.py
파일을 만들어 줍니다. 앱 디렉토리 구조는 다음과 같습니다:
chat/
__init__.py
consumers.py
templates/
chat/
index.html
room.html
urls.py
views.py
다음 코드를 chat/consumers.py
에 붙여 넣으세요:
# 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
}))
이 ChatConsumer
는 동기적인 웹소켓 컨슈머로, 모든 연결을 승낙하고(accept), 클라이언트로부터 메시지를 받고(receive), 동일한 클라이언트에게 이 메시지들을 다시 돌려줍니다(send). 아직은 이 컨슈머가 같은 채팅방의 다른 클라이언트에게 메시지를 전파해주지는 못합니다.
주의사항
채널즈에서는 더 나은 성능을 위해 비동기적인 컨슈머를 만들 수 있습니다. 하지만 비동기적인 컨슈머를 사용할 때는 장고 모델에 접근하는 것과 같이 동작을 차단하는(blocking) 작업을 직접 수행하지 않도록 주의해야 합니다. 비동기적인 컨슈머를 작성하는 법을 더 알고 싶다면 컨슈머를 참조하세요.
이제 chat
앱을 위한 라우팅 설정을 만들어 요청을 이 컨슈머로 전달해줘야 합니다. chat/routing.py
파일을 만드세요. 앱 디렉토리 구조는 이제 다음과 같습니다:
chat/
__init__.py
consumers.py
routing.py
templates/
chat/
index.html
room.html
urls.py
views.py
다음 코드를 chat/routing.py
에 붙여넣으세요:
# 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()),
]
ASGI 애플리케이션을 얻기 위해 클래스 메서드인 as_asgi()
를 호출했는데, 이 애플리케이션이 각 유저별 연결을 처리하는 컨슈머 인스턴스를 만들어줍니다. 이 메서드는 요청별로 장고 뷰 인스턴스를 만들어주는 장고의 as_view()
와 유사합니다.
(여기선 URLRouter의 한계 때문에 re_path()
를 사용했습니다.)
다음 단계는 최상단 라우팅 설정이 chat.routing
모듈을 가리키도록 하는 것입니다. mysite/asgi.py
에서, AuthMiddlewareStack
, URLRouter
, 그리고 chat.routing
를 임포트하세요; 그리고 'websocket' 키를 ProtocolTypeRouter
리스트에 다음과 같은 형식으로 추가합니다:
# 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
)
),
})
주의사항
장고 2.2에서는
ProtocolTypeRouter
의http
키가 채널즈의AsgiHandler
를 사용함에 주의하세요. 그 외엔 동일합니다. 웹소켓 키는 기존에 없는 것이어서 모든 버전에서 동일합니다.
이 최상단 라우팅 설정은 채널즈 개발 서버에 연결이 구성될 때, ProtocolTypeRouter
가 제일 먼저 연결 타입을 확인하도록 합니다. 만약 연결이 웹소켓 타입이라면 (ws://나 wss://), 이 연결은 AuthMiddlewareStack
으로 전달됩니다.
AuthMiddlewareStack
은 현재 인증된 유저에 대한 참조를 연결의 scope
에 추가하는데, 이는 장고에서 AuthenticationMiddleware
가 뷰 함수의 request
객체에 현재 인증된 유저를 추가하는 방식과 비슷합니다. (Scope
는 튜토리얼 후반부에 다룹니다.) 그 후 이 연결은 URLRouter
에게 전달됩니다.
URLRouter
는 연결의 HTTP 경로를 확인해 적절한 컨슈머에게 연결해 줍니다.
한 번 /ws/chat/ROOM_NAME/
에 지정한 컨슈머가 잘 작동하는지 확인해 봅시다. DB 변경사항을 반영하기 위해 마이그레이션을 진행합니다(장고의 세션 프레임워크가 DB를 사용하기 때문입니다), 그리고 채널즈 개발 서버를 실행합니다:
$ python manage.py migrate
Operations to perform:
Apply all migrations: admin, auth, contenttypes, sessions
Running migrations:
Applying contenttypes.0001_initial... OK
Applying auth.0001_initial... OK
Applying admin.0001_initial... OK
Applying admin.0002_logentry_remove_auto_add... OK
Applying admin.0003_logentry_add_action_flag_choices... OK
Applying contenttypes.0002_remove_content_type_name... OK
Applying auth.0002_alter_permission_name_max_length... OK
Applying auth.0003_alter_user_email_max_length... OK
Applying auth.0004_alter_user_username_opts... OK
Applying auth.0005_alter_user_last_login_null... OK
Applying auth.0006_require_contenttypes_0002... OK
Applying auth.0007_alter_validators_add_error_messages... OK
Applying auth.0008_alter_user_username_max_length... OK
Applying auth.0009_alter_user_last_name_max_length... OK
Applying auth.0010_alter_group_name_max_length... OK
Applying auth.0011_update_proxy_permissions... OK
Applying auth.0012_alter_user_first_name_max_length... OK
Applying sessions.0001_initial... OK
$ python3 manage.py runserver
브라우저를 열고 채팅방 페이지 http://127.0.0.1:8000/chat/lobby/
로 가면 빈 채팅 로그가 나타납니다.
이제 "안녕!"이라고 타이핑하고 엔터를 치면, 채팅 로그에 다시 "안녕!"이라는 메시지가 나타나는걸 확인할 수 있습니다!
하지만 아직입니다. 또다른 브라우저 탭을 열어서 동일한 채팅방 페이지 http://127.0.0.1:8000/chat/lobby/
로 이동해 메시지를 타이핑하면, 방금 연 두번째 탭의 채팅 로그에서는 메시지가 뜨지만 원래 첫번째 탭의 채팅 로그에서는 메시지가 나타나지 않습니다. 이것이 가능하려면, 우리는 동일한 ChatConsumer
의 여러 인스턴스들이 서로 소통할 수 있게 해야합니다. 채널즈는 컨슈머 간에 이러한 종류의 통신이 가능하도록 추상화된 '채널 레이어'를 제공합니다.
터미널로 돌아가 Control-C
로 서버를 중지하세요.
채널 레이어는 일종의 통신 체계입니다. 이를 통해 여러 컨슈머들끼리 서로 소통하고, 또한 장고의 다른 부분들과도 소통하게 할 수 있습니다.
채널 레이어는 다음과 같은 추상화된 대상을 제공합니다:
채널
은 메시지가 전달되는 우편함입니다. 각각의 채널은 자기 이름을 갖고 있습니다. 채널 이름을 알고있는 누구든 해당 채널로 메시지를 보낼 수 있습니다.그룹
은 관련있는 채널들의 모임입니다. 그룹은 이름을 갖고 있습니다. 그룹의 이름을 아는 누구든 새 채널을 그룹에 추가하거나, 그룹에서 제거하거나, 그룹에 등록된 모든 채널에 메시지를 보낼 수 있습니다. 하지만 특정 그룹에 있는 채널들을 쭉 나열하는 것은 불가능합니다.모든 컨슈머 인스턴스는 자동으로 생성된 유니크한 채널명을 갖고 있고, 따라서 채널 레이어를 통해 통신할 수 있습니다.
우리 채팅 앱에서 우리는 여러 개의 ChatConsumer
가 서로 통신을 주고받게 하고 싶습니다. 그러기 위해선 각 ChatConsumer
가 자신의 채널을 (채팅방 이름을 바탕으로 하는 이름의) 그룹에 등록해야합니다. 그러면 ChatConsumers
가 같은 방 안의 모든 ChatConsumers
들에게 메시지를 전파할 수 있습니다.
우리는 Redis
를 저장소로 하는 채널 레이어를 사용할 겁니다. 도커를 이용해 Redis
를 포트 6379에서 실행하려면, 다음 커멘드를 입력합니다:
$ docker run -p 6379:6379 -d redis:5
채널즈가 레디스에 접근할 수 있게 하려면 다음 커맨드로 channels_redis
를 설치해야 합니다:
$ python3 -m pip install channels_redis
채널 레이어를 사용하려면 먼저 몇 가지 설정을 해야 합니다. mysite/settings.py
파일 끝에 CHANNEL_LAYERS
설정을 다음과 같이 추가해주세요:
# mysite/settings.py
# Channels
ASGI_APPLICATION = 'mysite.asgi.application'
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
"hosts": [('127.0.0.1', 6379)],
},
},
}
주의사항
여러 개의 채널 레이어를 갖도록 설정할 수도 있습니다. 하지만 대부분의 프로젝트는
default
채널 레이어 하나만을 사용할 것입니다.
채널 레이어가 Redis
와 통신할 수 있는지 확인해 봅시다. 장고 쉘을 열고 다음 커맨드를 실행합니다:
$ python3 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'}
Control-D
를 눌러 장고 쉘에서 나옵니다.
이제 채널 레이어가 준비됐으니, ChatConsumer
에서 사용해봅시다. 다음 코드를 chat/consumers.py
파일에 붙여넣어 기존 코드를 덮어 씌웁니다:
# 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
}))
유저가 메시지를 전송하면, 자바스크립트 함수는 이 메시지를 웹소켓을 통해 ChatConsumer
로 전송합니다. ChatConsumer
는 메시지를 받아, 채팅방 이름에 해당하는 그룹으로 전파합니다. 같은 그룹안에 있는 (즉, 같은 채팅방 안에 있는) 모든 ChatConsumer
는 그룹으로부터 메시지를 전달받아, 웹소켓을 통해 자바스크립트로 이를 돌려주고, 따라서 채팅 로그에 이 메시지가 추가됩니다.
ChatConsumer
코드의 몇몇 부분은 추가적인 설명이 필요합니다:
self.scope['url_route']['kwargs']['room_name']
chat/routing.py
의 URL route로부터room_name
인자를 얻습니다. scope
를 갖습니다. scope
안에는 모든 URL route 인자들과, 만약 존재한다면 현재 인증된 유저 정보가 들어 있습니다.self.room_group_name = 'chat_%s' % self.room_name
async_to_sync(self.channel_layer.group_add)(...)
async_to_sync(…)
는 ChatConsumer가 동기적인 WebsocketConsumer
이지만 채널 레이어의 비동기적인 메서드를 호출하고 있기 때문에 필요합니다(채널 레이어의 메서드들은 모두 비동기적입니다.)self.accept()
connect()
메서드 안에서 accept()
를 호출하지 않는다면, 연결 요청은 거부되고 종료됩니다. 요청하는 유저가 해당 동작을 수행하기 위한 권한이 없는 등의 이유로 연결을 거부할 필요가 있을 수 있습니다.accept()
를 connect()
의 마지막 동작으로 호출하도록 권장합니다.async_to_sync(self.channel_layer.group_discard)(...)
async_to_sync(self.channel_layer.group_send)
type
키를 갖고 있습니다. 이 이벤트를 전달받는 컨슈머들은 대응되는 메서드명의 메서드를 실행하게 됩니다.이제 /ws/chat/ROOM_NAME/
에 해당하는 새 컨슈머가 잘 동작하는지 확인해 봅시다. 채널즈 개발 서버 실행을 위해 다음 커맨드를 입력합니다:
$ python3 manage.py runserver
브라우저를 열고 채팅방 페이지 http://127.0.0.1:8000/chat/lobby/
로 이동합니다. 다른 탭을 하나 더 열어 동일한 채팅방 페이지로 이동합니다.
이제 두번째 브라우저 탭에서, “정말로 안녕!”이라고 타이핑하고 엔터를 눌러보세요. 그러면 두번째 탭과 첫번째 탭 모두에서 "정말로 안녕!"이라는 메시지가 채팅 로그에 나타나는 것을 확인하실 수 있습니다.
방금 기본적인 모든 기능이 잘 동작하는 채팅 서버를 만드신 겁니다!
이 튜토리얼은 튜토리얼 3으로 이어집니다.