이 글은 Django Channles 공식 문서를 참고하여 정리한 것입니다. ( 링크 )

튜토리얼이 잘 나와있어서 쉽게 적용할 수가 있었는데, 한글로 된 문서가 없는 것 같아서 정리를 하려고 합니다.

번역기의 힘을 빌려가며 해석을 했지만, 맞지 않는 부분이 있을 수 있기 때문에 원문과 참고하여 보시길 바랍니다.


또한 이전 글과 내용이 이어집니다. ( 링크 )


이전 글에서는 Channels를 설치하고 기본 설정까지 해보았습니다.

이 글에서는 클라이언트의 연결을 처리하는 소비자( Consumer )에 대해 알아보고, Channel layer을 통해 Room을 생성하여 클라이언트들이 소통하는 방법에 대해 알아보겠습니다.




1. Room View 추가

먼저 채팅방 화면인 roob.html을 만들어보겠습니다.

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"/>
</body>

<script>
var roomName = {{ room_name_json }};

var chatSocket = new WebSocket(
'ws://' + window.location.host +
'/ws/chat/' + roomName + '/');

chatSocket.onmessage = function(e) {
var data = JSON.parse(e.data);
var message = data['message'];
document.querySelector('#chat-log').value += (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) {
var messageInputDom = document.querySelector('#chat-message-input');
var message = messageInputDom.value;
chatSocket.send(JSON.stringify({
'message': message
}));

messageInputDom.value = '';
};
</script>

</html>


다음으로 View에서 room 함수를 추가합니다.

chat/views.py

from django.shortcuts import render
from django.utils.safestring import mark_safe
import json

def index(request):
return render(request, 'chat/index.html', {})

def room(request, room_name):
return render(request, 'chat/room.html', {
'room_name_json': mark_safe(json.dumps(room_name))
})


또한 URL 매핑을 urls.py에 추가합니다.

chat/urls.py

from django.conf.urls import url
from . import views

urlpatterns = [
url(r'^$', views.index, name='index'),
url(r'^(?P<room_name>[^/]+)/$', views.room, name='room'),
]




2. 테스트 

$ python manage.py runserver

서버를 실행한 후, 브라우저에서 http://127.0.0.1:8000/chat/ 에 접속해봅니다.


입력 창에 " lobby "를 작성하여 엔터를 누르면, http://127.0.0.1:8000/chat/lobby/ URL로 이동이 되고, 비어있는 채팅 창을 볼 수 있습니다.



여기서 메시지를 보내면 아무런 동작이 발생하지 않는데 그 이유는, room view가 웹 소켓 URL인 ws://127.0.0.1:8000/ws/chat/lobby/를 open하려고 하기 때문입니다.

지금은 이에 대한 연결을 맺는 웹 소켓 소비자를 만들지 않았기 때문에 아무런 동작이 발생하지 않는 것입니다.


개발자 도구에서 console을 보면 다음과 같은 에러가 발생한 것을 확인할 수 있습니다.






3. 첫 번째 소비자 작성

Django는 HTTP 요청을 받아들이면, URL conf를 찾아서 요청을 처리하기 위한 view 함수를 실행합니다.

마찬가지로 Channels가 WebSocket 연결을 받아들이면, root routing configuration에서 소비자를 찾은 후에, 이벤트를 처리하기 위한 함수들을 호출합니다.

여기서는 /ws/chat/ROOM_NAME/ 경로로 연결된 WebSocket을 받아들이는 소비자를 작성할 것입니다. 


** 참고 **

URL을 보면 /ws/가 있는데, 이는 HTTP 요청과 WebSocket을 구분 짓기 위한 좋은 방법입니다.

Nginx같은 웹 서버에서는 배포 모드에서 /ws/로 HTTP 요청과 WebScoket을 쉽게 구분하는 것이 가능합니다.

이에 따라 HTTP 요청은 uWSGI로 처리를 하고, WebSocet 요청은 ASGI로 처리를 하면 됩니다.

이 부분은 배포 단계에서 매우 중요한 역할을 합니다 !



이제 소비자를 구현해보겠습니다.

chat/consumers.py

# chat/consumers.py
from channels.generic.websocket import WebsocketConsumer
import json

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
}))

이것은 모든 요청을 받아들이는 비동기적인 WebSocket 소비자 역할을 할 것입니다.

즉, 메시지를 클라이언트로부터 받아서 그대로 클라이언트에게 전달하는 기능을 합니다.


그런데 현재는 같은 방에 있는 다른 클라이언트들에게 메시지가 전송되지 않는 상황입니다.

뒤에서 Channel layer를 통해 이 문제를 해결할 것입니다.





4. routing 작성

우선 소비자 라우팅을 처리하기 위해 chat 앱에 있는 routing configuration 파일을 생성해야 합니다.

chat/routing.py

# chat/routing.py
from django.conf.urls import url
from . import consumers

websocket_urlpatterns = [
url(r'^ws/chat/(?P<room_name>[^/]+)/$', consumers.ChatConsumer),
]


다음으로 위에서 작성한 routing 파일을 Django가 인식 할 수 있도록 추가합니다.

# mysite/routing.py
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
import chat.routing

application = ProtocolTypeRouter({
# (http->django views is added by default)
'websocket': AuthMiddlewareStack(
URLRouter(
chat.routing.websocket_urlpatterns
)
),
})

위의 root routing configuration 파일은 클라이언트와 Channels 개발 서버와 연결이 맺어질 때, ProtocolTypeRouter를 가장 먼저 조사하여 어떤 타입의 연결인지 구분합니다.

만약에 WebSocket 연결이라면, 이 연결은 AuthMiddlewareStack으로 이어집니다.


AuthMiddlewareStack은 현재 인증된 사용자에 대한 참조로 scope를 결정합니다. ( scope는 나중에 다루도록 하겠습니다. )

이는 Django에서 현재 인증된 사용자의 view 함수에서 request 요청을 결정하는 AuthenticationMiddleware와 유사한 방식이며, 그 결과 URLRouter로 연결됩니다.


URLRouter는 작성한 url 패턴을 기반으로, 특정 소비자의 라우트 연결 HTTP path를 조사합니다.





5. 테스트

이제 소비자가 /ws/chat/ROOM_NAME 경로를 잘 처리하는지 확인해보도록 하겠습니다.

$ python manage.py runserver

( **  ERROR - server - Exception inside application: no such table: django_session 에러가 발생하면 # manage.py migrate 명령어를 입력합니다. )


서버를 실행한 후, 브라우저에서 http://127.0.0.1:8000/chat/lobby/ 경로에 접속한 다음, hello 메시지를 보내면 hello 메시지가 응답됩니다.


그런데 브라우저를 하나 더 켜고 메시지를 보내보면, 다른 브라우저에서는 메시지를 볼 수 없습니다.

즉, 같은 채널에 존재하지만 자신 외에 다른 사용자는 메시지를 볼 수 없습니다.


Channels는 Channel layer를 제공하며, 이를 이용하면 소비자들끼리 소통을 할 수 있게 됩니다.






6. Channel layer

Channel layer는 의사소통 시스템입니다.

이는 많은 소비자들 또는 Django의 다른 부분들과 의사소통을 할 수 있게 해줍니다.


Channel layer에는 다음과 같은 추상화를 제공합니다.

1) channel

channel은 메시지를 보낼 수 있는 우편함 개념입니다.

각 채널은 이름을 갖고 있으며, 누구든지 채널에 메시지를 보낼 수 있습니다.


2) group

group은 채널과 관련된 그룹입니다.

그룹은 이름을 가지며, 그룹 이름을 가진 사용자는 누구나 그룹에 채널을 추가/삭제가 가능하고, 그룹의 모든 채널에게 메시지를 보낼 수 있습니다.

그러나 그룹에 속한 채널을 나열할 수는 없습니다.



모든 소비자들은 유일한 채널 이름을 자동으로 생성 받으며, Channel layer를 통해 의사소통을 할 수 있습니다.


지금 예제에서는 채팅 방안에 있는 소비자들이 서로 대화를 할 수 없기 때문에, 이를 가능하게 해야 합니다.

그러기 위해서는 room 이름을 기반으로 한 group에 채널을 추가해야 합니다.

그래야 채팅 소비자들은 같은 방안에 있는 다른 소비자들에게 메시지를 보낼 수 있습니다.





7. channel layer 구현하기

이제 channel layer를 구현해보겠습니다.

Redis를 백업 저장소로 사용하는 Channel layer를 사용할 것입니다.


이후의 과정을 진행하기 위해서는 Redis가 설치되어 있어야 합니다.


또한 Channels가 Redis 인터페이스를 알 수 있도록 Channels_redis 패키지를 설치해야 합니다.

$ pip install channels_redis



Channel layer를 구현하기 전에 settings.py 파일에서 설정을 잡아줘야 합니다.

mysite/settings.py

# Channels
ASGI_APPLICATION = 'mysite.routing.application'
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
"hosts": [('127.0.0.1', 6379)],
},
},
}


다음으로 channel layer와 Redis가 의사소통 할 수 있는지 테스트를 해보겠습니다.

Django shell을 열어서 아래의 명령어를 실행합니다.

$ 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'} 가 출력되면 정상적으로 테스트가 된 것입니다.

이제 channel layer을 사용할 수 있습니다.



다음으로 chat/consumers.py를 다음과 같이 수정합니다.

# chat/consumers.py
from asgiref.sync import async_to_sync
from channels.generic.websocket import WebsocketConsumer
import json

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
}))


클라이언트가 메시지를 등록하면, JS 함수가 WebSocket을 통해 소비자에게 메시지를 전송합니다.

그러면 소비자는 메시지를 받고, room 이름에 대응되는 그룹으로 forward 합니다.

따라서 같은 그룹에 있는 모든 소비자는 메시지를 받을 수 있게 됩니다.



각 함수의 코드 설명은 다음과 같습니다.

self.scope[‘url_route’][‘kwargs’][‘room_name’]

chat/routing.py에 정의된 URL 파라미터에서 room_name을 얻습니다.



즉 소비자에게 WebSocket 연결을 열어줍니다.

참고로 모든 소비자들은 현재 인증된 유저, URL의 인자를 포함하여 연결에 대한 정보를 갖는 scope를 갖습니다.



self.room_group_name = ‘chat_%s’ % self.room_name

사용자가 작성한 room 이름으로부터 채널의 그룹 이름을 짓습니다.



async_to_sync(self.channel_layer.group_add)(…)

그룹에 join합니다.

소비자들은 비동기 channel layer 메서드를 호출할 때 동기적으로 받아야 하기 때문에, async_to_sync(...) 같은 wrapper가 필요합니다.

( 모든 channel layer 메서드는 비동기입니다. )


self.accept()

WebSocket 연결을 받아들입니다.


async_to_sync(self.channel_layer.group_discard)(…)

그룹을 떠납니다.


async_to_sync(self.channel_layer.group_send)

그룹에게 이벤트를 보냅니다.

이벤트에는 이벤트를 수신하는 소비자가 호출해야 하는 메서드 이름에 대응하는 특별한 type 키가 있습니다.





8. 테스트

$ python manage.py runserver

브라우저 2개를 켜서 http://127.0.0.1:8000/chat/lobby/ 경로에 접속한 후 메시지를 전송해보면, 두 브라우저에 모두 메시지가 출력되는 것을 확인할 수 있습니다.

브라우저를 1개 더 열어서 다른 room name으로 접속하면, lobby의 사용자들과 의사소통을 할 수 없습니다.

즉 이로써 채팅 방에 존재하는 클라이언트에게만 메시지가 전송된다는 것을 확인할 수 있습니다.




이상으로 Django Channels 튜토리얼 Part 2를 마치겠습니다.

이 글을 통해 실시간 채팅 구현은 끝났다고 봐도 됩니다.

Part 3에서는 소비자를 비동기로 재작성해볼 것입니다.


[ 참고 ]

https://channels.readthedocs.io/en/latest/index.html#django-channels

  1. 2018.12.17 16:47

    비밀댓글입니다

    • 2018.12.17 21:57

      비밀댓글입니다

    • 2018.12.19 14:53

      비밀댓글입니다

    • 2018.12.19 16:30

      비밀댓글입니다

  2. 장고입문 2019.03.17 09:51

    좋은 글 너무 감사합니다.
    템플릿에서 {{ myStringValue|safe }} 문법만 알다가 mark_safe까지 알아가네요.
    그런데 url을 통해 넘겨 받은 걸 mark_safe에 넣어서 렌더링하는 건 위험하지 않나요?

    host/chat/"";<script>alert("xss-attack!")/

    예를 들어 이런 식으로 보낸다면 유저의 html에 스크립트가 들어가지 않나요?
    웹 영역 자체가 아직 초보자인지라 미숙한 질문일까봐 질문이 주저되지만,
    실례를 무릅쓰고 질문드립니다.

    ps. 제가 든 예시는 csrf 공격이라기보다는 xss 공격이 맞겠죠?

    • Favicon of https://victorydntmd.tistory.com victolee 우르르응 2019.03.18 21:55 신고

      안녕하세요~ 감사합니다 ㅎㅎ

      mark_safe()는 예제의 코드를 그대로 사용해서 확인을 못했었네요.
      예제는 mark_safe()를 호출하기 전에 json_dumps()를 사용했는데요.
      json_dumps()를 사용하면 데이터(room_name)가 ""으로 한번 더 감싸지게 되어서, xss 공격을 방어하는 것 같습니다.

      실제로 테스트를 해보았는데요.
      def index(request, room_name):
      return render(request, 'polls/index.html', {
      #1) 'room_name_json': mark_safe(json.dumps(room_name))
      #2) 'room_name_json': mark_safe(room_name)
      })

      http://127.0.0.1:8000/polls/'chat';alert('xss attack!!')
      URL을 위와같이 호출했을 때, 1번의 경우는 xss를 방어하고 2번의 경우는 스크립트가 실행되었습니다.

      결론은 문의주신 mark_safe()함수 자체는 xss를 주의해야겠지만, 이 예제에서는 json_dumps()를 호출해서 방어한 것 같습니다.

      좋은 질문해주셔서 감사합니다!
      덕분에 저도 많이 알아가네요 ㅎㅎ


      [참고] mark_safe()
      Django는 기본적으로 auto HTML escaping이 켜져있는데,
      mark_safe() 또는 템플릿에 {% autoescape off %}가 작성되어 있는 경우 auto escaping 처리를 하지 않는다고 합니다.
      ( 참고: https://www.kevinlondon.com/2015/10/16/answers-to-django-security-questions.html#answers )
      즉, mark_safe() 함수를 사용하려면 개발자가 알아서 안전한 데이터인지 체크해야 할 것 같습니다.

+ Recent posts