공식 문서: https://channels.readthedocs.io/en/latest/tutorial/part_1.html
장고를 통해 데이터를 주고받는 메시지 기능을 만들고자 했다. 그러기 위해서 webSocket을 이용해서 통신을 하고자 했다.
WebSocket통신이란?
WebSocket은 서버와 클라이언트 간에 Socket Connection을 유지해서 언제든 양방향 통신 또는 데이터 전송이 가능하도록 하는 기술이다.
Real-time web application구현에 적합하다.
<URL형태>
먼저 가상환경을 만들고 django, chennels를 설치합니다.
python3 -m venv (가상환경 이름)
pip install django chennels
django-admin startproject (프로젝트 이름) -> 프로젝트 생성
pip install daphne
=> daphne란 ASGI서버 입니다.쉽게 말해, Django 애플리케이션을 비동기적으로 실행할 수 있게 해주는 서버입니다.
그리고 python3 manage.py startapp (이름) 명령어를 입력해 앱 디렉토리를 만들어 줍니다.
mysite/
manage.py
chat/
mysite/
__init__.py
asgi.py
settings.py
urls.py
wsgi.py
형태로 만들어 주면 개발을 시작할 준비가 된것입니다.
마지막으로 mysite/setting.py에 들어가서 INSTALLED_APPS에 방금 만든 앱(chat)를 추가해 줍니다.
이제 본격적으로 채팅방을 구현해 보도록하겠습니다. 먼저 chat앱에 templates/chat 디렉토리를 만들어 줍니다.
<!-- 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.key === 'Enter') { // 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>
코드를 간략하게 설명하자면
쿼리 샐럭터는 DOM에서 특정 요소를 선택하는 JS메서드입니다. 따라서 id를 가진 요소를 찾아내 선택합니다.
사용자로부터 roomName을 받으면 window.location.pathname을 통해 그 URL로 이동하게 됩니다.
템플릿을 만들었기 때문에 view.py에 뷰를 만들고 이를 매핑하기 위해서 urls.py 파일을 생성해줍니다.
#chat/views.py
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"),
]
chat 디렉토리에 urls를 한것이기 때문에 root URLconf를 chat.urls를 가르키게 합니다.
# mysite/urls.py
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("chat/", include("chat.urls")),
path("admin/", admin.site.urls),
]
지금까지는 기본적인 Django의 앱이였습니다. 이제는 본격적으로 Channel라이브러리를 사용해 보도록 하겠습니다.
Channels의 라우팅 구성은 Django URLconf와 유사한 ASGI 애플리케이션 입니다.
ASGI는 비동기 처리를 지원하며 여러 요청을 동시에 처리할 수 있습니다.
ASGI 애플리케이션은 비동기 함수로 정의되며, scope, receive, send의 세 가지 매개변수를 사용합니다.
# mysite/asgi.py
import os
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.security.websocket import AllowedHostsOriginValidator
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")
# Initialize Django ASGI application early to ensure the AppRegistry
# is populated before importing code that may import ORM models.
django_asgi_app = get_asgi_application()
from chat.routing import websocket_urlpatterns
application = ProtocolTypeRouter(
{
"http": django_asgi_app,
"websocket": AllowedHostsOriginValidator(
AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
),
}
)
(아직 routing을 만들지 않았습니다. 추후에 하도록 하겠습니다.)
이제 룸에 대한 뷰 템플릿을 만들도록 하겠습니다.
<!-- 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.key === 'Enter') { // 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"),
]
<str:room_name> 을 했기때문에 room_name이라는 변수에 위치에 맞는 URL값이 자동으로 할당이 되게 됩니다. 할당이 된 room_name은 룸 뷰에서 room_name이라는 변수에 다시 할당이 되고, 이는 뷰 템플릿에서 {{ room_name|json_script:"room-name" }} 형태로 가져와지게 됩니다.
Django: HTTP 요청 → URLconf(urls.py) 참조 → 뷰 함수 조회 → 뷰 함수 호출
Django Channels: WebSocket 연결 → 라우팅 구성 참조 → 소비자 조회 → 소비자에서 이벤트 처리 함수 호출
chat 디렉토리 하위에 routing.py와 consumer.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()),
]
re_path는 Django에서 URL 패턴을 정의할 때 사용하는 함수 중 하나로, 정규 표현식을 기반으로 매칭하는데 사용됩니다.
# 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 클래스는 WebsocketConsumer를 상속받아 WebSocket 프로토콜을 통해 클라이언트와의 통신을 관리합니다.
self.accept() : 클라이언트의 연결을 수락합니다. 이 메서드를 호출하지 않으면 클라이언트는 연결이 거부됩니다.
json.loads(text_data): 수신한 텍스트 데이터를 JSON 형식으로 파싱하여 Python 객체로 변환합니다.
message = text_data_json["message"]: 파싱된 JSON 객체에서 "message" 키에 해당하는 값을 추출합니다.
self.send(...): 클라이언트에게 응답 메시지를 보냅니다. 여기서는 수신한 메시지를 그대로 클라이언트에게 다시 보내는 에코 기능을 구현하고 있습니다. json.dumps(...)를 사용하여 Python 객체를 JSON 문자열로 변환합니다.
#mysite/asgi.py
application = ProtocolTypeRouter(
{
"http": django_asgi_app,
"websocket": AllowedHostsOriginValidator(
AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
),
}
)
ProtocolTypeRouter는 Django Channels에서 제공하는 라우터로, 들어오는 요청의 프로토콜에 따라 적절한 처리기를 선택합니다. HTTP 요청과 WebSocket 요청을 구분하여 처리할 수 있습니다.
AllowedHostsOriginValidator는 WebSocket 요청의 출처를 검증하여 허용된 호스트에서만 요청을 처리하도록 합니다. 이는 보안상의 이유로 중요합니다.
AuthMiddlewareStack은 WebSocket 연결에 대한 인증을 처리합니다. 이 미들웨어는 사용자가 인증된 상태인지 확인하고, 인증된 사용자 정보를 WebSocket 연결에 추가합니다.
URLRouter(websocket_urlpatterns)는 WebSocket 요청을 처리할 URL 패턴을 정의합니다. websocket_urlpatterns는 WebSocket 요청에 대한 라우팅을 설정하는 리스트입니다.
여기까지 왔다면 runserver를 시켜줍니다. 방에 들어가고, 채팅을 치는 모습까지 문제 없이 구현되고 있다는 사실을 알 수 있습니다. (통신은 안됨)
=> 동일한 여러 인스턴스(ChatConsumer)가 서로 통신 할 수 있어야 합니다. Channels는 소비자 간에 이런 종류의 통신을 가능하게 하는 채널 계층 추상화를 제공합니다.
채널 계층은 일종의 커뮤니케이션 시스템입니다. 여러 소비자 인스턴스가 서로, 그리고 Django의 다른 부분과 통신할 수 있도록 합니다.
채널 계층은 다음과 같은 추상화를 제공합니다.
Channel : 각 채널에는 이름이 있습니다. 채널 이름을 가진 사람은 누구나 채널에 메시지를 보낼 수 있습니다.
Group : 그룹에는 이름이 있습니다. 그룹 이름을 가진 사람은 누구나 이름으로 그룹에 채널을 추가/제거하고 그룹의 모든 채널에 메시지를 보낼 수 있습니다. 단, 특정 그룹에 어떤 채널이 있는지 열거하는 것은 불가능합니다.
채팅 애플리케이션에서 같은 방에 있는 여러 인스턴스(ChatConsumer)가 서로 통신하도록 하려고 합니다. 그러기 위해 각 ChatConsumer가 방 이름을 기반으로 하는 그룹에 채널을 추가하도록 합니다. 그러면 ChatConsumer가 같은 방에 있는 다른 모든 ChatConsumer에게 메시지를 전송할 수 있습니다.
<정리>
1. 모든 컨슈머는 자동으로 생성된 고유한 채널 이름을 가진다.
2. 하나의 컨슈머가 메시지를 자신의 채널로 보내면 다른 컨슈머들은 해당 채널의 이름을 모르기 때문에 메시지를 전달 받을 수 없다.
3. 그래서 생긴게 '그룹'이라는 추상화 개념이다.
4. 같은 채팅룸에 있는 각각의 컨슈머들이 가지고 있는 고유한 채널 이름을 하나의 그룹으로 묶는다.
5. 메시지를 보내는 목표 지점을 그룸으로 설정해주면, 나머지 컨슈머들에게 메시지가 전송이 된다.
redis : Redis는 주로 인메모리 데이터 저장소로 사용됩니다. Redis는 빠른 읽기 및 쓰기 성능을 제공하며, 다양한 데이터 구조를 지원합니다.
Channels가 Redis와 인터페이스하는 방법을 알 수 있도록 channels_redis를 설치해야 합니다. : pip install channels_redis
명령어를 통해 redis 서버를 실행해 줍니다.
docker run --rm -p 6379:6379 redis:7
or
redis-server : 로컬 실행
채널 레이어를 사용하기 위해 setting을 먼저 해줍니다.
# mysite/settings.py
# Channels
ASGI_APPLICATION = "mysite.asgi.application"
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [("127.0.0.1", 6379)],
},
},
}
이제 채널레이어가 있으니 이를 이용해 컨슈머를 수정하도록 하겠습니다.
# 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를 사용해 class인스턴스의 변수로 지정
self.room_name = self.scope["url_route"]["kwargs"]["room_name"]
self.room_group_name = f"chat_{self.room_name}"
# Join room group
# 비동기 함수를 동기적으로 호출할 수 있게 해주는 유틸리티
# 첫번째 인자 = 메서드 이름
# 두번째 인가 = 그 메서드의 인자들
async_to_sync(self.channel_layer.group_add)(
self.room_group_name, self.channel_name
)
#클라이언트의 연결을 수락함 -> 이걸 호출해야 클라이언트가 websocket을 사용해 데이터를 주고받을 수 있음
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):
#json.loads = 수신한 JSON 문자열을 파이썬 객체로 변환합니다.
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
# json.dumps를 이용해 파이썬 객체 -> json문자열로 반환
self.send(text_data=json.dumps({"message": message}))
async_to_sync: 비동기 함수를 동기적으로 호출할 수 있게 해주는 유틸리티입니다. Django Channels는 비동기 프로그래밍을 지원하므로, 이 기능을 통해 비동기 작업을 동기적으로 처리할 수 있습니다.
첫 번째 괄호) 비동기 함수를 동기적으로 호출할 수 있는 함수로 변환합니다.
두 번째 괄호) 변환된 함수에 실제 인자를 전달하여 호출합니다.
self.scope["url_route"]["kwargs"]["room_name"]
-> kwargs때문에 키- 값 형태로 됨 "room_name"이라는 키를 가진 값을 반환
이제 runserver를 해보면 채널 레이어를 공유하므로 잘 작동하는 모습을 볼 수 있습니다.
우리가 작성한 것은 ChatConsumer현재 동기식입니다. 그러나 비동기식 소비자는 요청을 처리할 때 추가 스레드를 만들 필요가 없으므로 더 높은 수준의 성능을 제공할 수 있습니다.
chatConsumes 수정해줍니다.
# chat/consumers.py
import json
from channels.generic.websocket import AsyncWebsocketConsumer
class ChatConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.room_name = self.scope["url_route"]["kwargs"]["room_name"]
self.room_group_name = f"chat_{self.room_name}"
# Join room group
await self.channel_layer.group_add(self.room_group_name, self.channel_name)
await self.accept()
async def disconnect(self, close_code):
# Leave room group
await self.channel_layer.group_discard(self.room_group_name, self.channel_name)
# Receive message from WebSocket
async def receive(self, text_data):
text_data_json = json.loads(text_data)
message = text_data_json["message"]
# Send message to room group
await self.channel_layer.group_send(
self.room_group_name, {"type": "chat.message", "message": message}
)
# Receive message from room group
async def chat_message(self, event):
message = event["message"]
# Send message to WebSocket
await self.send(text_data=json.dumps({"message": message}))
채팅서버 작동을 테스트 하기 위해서 Selenium을 설치하도록 하겠습니다.
pip install Selenium
chat/tests.py 파일을 새로 생성합니다.
# chat/tests.py
from channels.testing import ChannelsLiveServerTestCase
from selenium import webdriver
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support.wait import WebDriverWait
class ChatTests(ChannelsLiveServerTestCase):
serve_static = True # emulate StaticLiveServerTestCase
@classmethod
def setUpClass(cls):
super().setUpClass()
try:
# NOTE: Requires "chromedriver" binary to be installed in $PATH
cls.driver = webdriver.Chrome()
except:
super().tearDownClass()
raise
@classmethod
def tearDownClass(cls):
cls.driver.quit()
super().tearDownClass()
def test_when_chat_message_posted_then_seen_by_everyone_in_same_room(self):
try:
self._enter_chat_room("room_1")
self._open_new_window()
self._enter_chat_room("room_1")
self._switch_to_window(0)
self._post_message("hello")
WebDriverWait(self.driver, 2).until(
lambda _: "hello" in self._chat_log_value,
"Message was not received by window 1 from window 1",
)
self._switch_to_window(1)
WebDriverWait(self.driver, 2).until(
lambda _: "hello" in self._chat_log_value,
"Message was not received by window 2 from window 1",
)
finally:
self._close_all_new_windows()
def test_when_chat_message_posted_then_not_seen_by_anyone_in_different_room(self):
try:
self._enter_chat_room("room_1")
self._open_new_window()
self._enter_chat_room("room_2")
self._switch_to_window(0)
self._post_message("hello")
WebDriverWait(self.driver, 2).until(
lambda _: "hello" in self._chat_log_value,
"Message was not received by window 1 from window 1",
)
self._switch_to_window(1)
self._post_message("world")
WebDriverWait(self.driver, 2).until(
lambda _: "world" in self._chat_log_value,
"Message was not received by window 2 from window 2",
)
self.assertTrue(
"hello" not in self._chat_log_value,
"Message was improperly received by window 2 from window 1",
)
finally:
self._close_all_new_windows()
# === Utility ===
def _enter_chat_room(self, room_name):
self.driver.get(self.live_server_url + "/chat/")
ActionChains(self.driver).send_keys(room_name, Keys.ENTER).perform()
WebDriverWait(self.driver, 2).until(
lambda _: room_name in self.driver.current_url
)
def _open_new_window(self):
self.driver.execute_script('window.open("about:blank", "_blank");')
self._switch_to_window(-1)
def _close_all_new_windows(self):
while len(self.driver.window_handles) > 1:
self._switch_to_window(-1)
self.driver.execute_script("window.close();")
if len(self.driver.window_handles) == 1:
self._switch_to_window(0)
def _switch_to_window(self, window_index):
self.driver.switch_to.window(self.driver.window_handles[window_index])
def _post_message(self, message):
ActionChains(self.driver).send_keys(message, Keys.ENTER).perform()
@property
def _chat_log_value(self):
return self.driver.find_element(
by=By.CSS_SELECTOR, value="#chat-log"
).get_property("value")
테스트는 실행은 다음과 같은 명령어로 할 수 있습니다.
python3 manage.py test chat.tests
앞서 정리한 내용을 바탕으로 전체적인 플로우에 대해 알려드리겠습니다.
1. 클라이언트가 Websocket을 생성하고 서버에 연결합니다.
2. routing.py 에서 정의한 URL에 따라 어떤 Consumer에 연결할지 결정합니다.
3. asgi.py 를 이용해 http요청과 websocket에 대한 routing을 설정합니다.
4. consumer.py에서는 websocket을 관리하는 로직들이 있으며, connect메서드를 이용해 연결을 수락합니다.
5. 클라이언트가 메시지를 보내면, 컨슈머의 receive메서드가 실행이 된다.
6. 클라이언트는 서버로 부터 메시지를 수신하고 응답한다.
7. 서버는 send를 통해 보내고, 클라이언트는 onMessage를 이용해 수신받는다.
이렇게가 기본적인 플로우다.