장고 실시간 채팅(Channels와 redis server 이용)

Pywiki
둘러보기로 가기 검색하러 가기

1 개요

장고 채널 기능의 활용을 위해 간단한 채팅 앱 만들어보기.

1.1 사전 준비

과정 설명 방법
장고 장고가 설치되어 있다고 가정한다. 1. 장고 개요
채널 설치 채널이 설치되어 있다고 가정한다. 장고 channels

2 기초준비

2.1 채팅어플 만들기

패키지설치부터 진행하기엔 중간점검이 어려워 틀 만들기를 먼저 수행한다.

과정 설명 방법
어플리케이션생성 채팅을 위한 앱을 생성한다. django-admin startapp chat
앱 등록 settings.py에 추가.
INSTALLED_APPS = [
    ...
    'chat',
폴더 정리 - 앱을 생성하고 생성된 __init__.py와 views.py를 제외한 모든 것들을 지운다.

(채팅기능만을 위해선 나머지는 필요 없어, 지워도 된다. 근데 그냥 두자.)

- 탬플릿을 담기 위해 templates/chat 디렉토리를 생성해준다.

urls.py 생성 및 매핑 앱 내에서 사용되는 url을 다루기 위해 urls.py를 생성한다. chat/urls.py의 내용.
from django.urls import path
from . import views
app_name = 'chat'  # 보통 앱이름을 써서 url을 구분하지만, 

urlpatterns = [
    path('', views.index, name='index'),
]
기초 urls.py 수정
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path('chat/', include('chat.urls')),
    path('admin/', admin.site.urls),
]

여기까지 하고 runserver 후 제대로 작동하는지 확인하자.

2.2 채팅룸 구현

2.2.1 채팅 인덱스 구현

들어갈 채팅룸을 입력하는 공간.

과정 설명 방법
채팅 인덱스 탬플릿 작성 채팅룸을 입력하기 위한 인덱스 탬플릿.

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>
뷰 작성
from django.shortcuts import render

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

2.2.2 채팅룸 구현

과정 설명 방법
탬플릿 작성 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>
뷰 작성 뷰에 다음 함수를 추가한다.
def room(request, room_name):
    return render(request, 'chat/room.html', {
        'room_name': room_name
    })
URL 다음의 내용을 urls.py에 추가한다.
path('<str:room_name>/', views.room, name='room'),

2.2.3 참가자(컨슈머) 구현

과정 설명 방법
참가자 파일 작성 /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
        }))
라우팅 작성 /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()),
]
라우팅 등록 channels를 설치하며 작성한 agsi.py 안에 내용을 채워준다.

chat 안의 라우팅을 등록해준다.

그리고 이를 사용하기 위한 라이브러리를 불러온다.

......

from channels.routing import URLRouter
from channels.security.websocket import AllowedHostsOriginValidator
from channels.auth import AuthMiddlewareStack
import chat.routing

application = ProtocolTypeRouter(
    {
        "http": django_asgi_app,
        "websocket": AllowedHostsOriginValidator(
            AuthMiddlewareStack(URLRouter(chat.routing.websocket_urlpatterns))
        ),
    }
)

여기까지 하고 점검해보자. /chat/lobby/ 에 접속한 후 채팅을 치면 떠야 한다.

같은 브라우저를 띄워두고 한쪽에서 채팅을 치면 다른 쪽에서 안뜨는데, 다른 브라우저에선 안뜬다. 접속한 모두에게 반영될 수 있도록 채널레이어를 구현한다.

3 채널 레이어 구현

consumer 인스턴스는 자동으로 유일한 channel name을 구성하기 때문에 서로 소통하려면 layer가 필요하다.

3.1 레디스

레디스 서버를 이용한 실시간 채팅 구현.

꽤 오래된 버전인 5를 사용하는데, 아마도 이는.. 5에서 처음으로 consumer groups이 구현된 버전인데, 이걸로 구현했기 때문이 아닐까 싶다.(최신버전에서도 될진.. 검증이 필요하다.)

3.1.1 사전설정

과정 설명 방법
서버 설치 설치 후 딱히 옵션을 변경할 내용은 없다.

어차피 로컬에서 모든 작동이 수행되기 때문에.

네이티브로 설치하는 게 아니라 도커를 사용할 거라면 아래로 넘어가자.

단순 apt-get install redis로 설치해 5 이상의 버전이 되어도 작동한다.

Redis 문서 참조.
도커 도커가 설치되어 있다면 아래 명령으로 바로 설치, 서비스가 시작된다.

docker run -p 6379:6379 -d redis:5

아래 문서를 통해 도커 설치를 참고하자.

http://id8436.iptime.org:8080/mediawiki/index.php/Docker

연동 라이브러리 설치 서버와 채널을 연동할 라이브러리를 설치한다. pip install channels_redis
세팅 settings.py 안에 추가.
ASGI_APPLICATION = "mysite.asgi.application"  # 이부분은 채널을 설치할 때 작성한 부분.
CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels_redis.core.RedisChannelLayer",
        "CONFIG": {
            "hosts": [("127.0.0.1", 6379)],
        },
    },
}
을 추가해준다.

3.2 기존 컨슈머 변형

위에서 작성한 컨슈머는 중간 확인을 위한 동기식 함수인데, 비동기식으로 다시 쓰이면 성능이 좋아진다. 아래와 같이 변형하자.

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 = "chat_%s" % 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}))

4 서버에 반영

튜토리얼엔 나오지 않지만(공식문서 한참 뒤에 나온다..), 서버에 반영하게 되면 ws/ 경로에 대하여 새로운 설정을 해주어야 한다.(필자는 이걸 몰라서 이것저것 건드리다가 sql 날려먹었다.. 젠장)

일반적으로 WSGI 통신을 gunicorn으로 처리한다면, ASGI통신은 daphne으로 처리한다.(daphne에서도 wsgi통신이 가능하지만, 보안상 권장되진 않는다.)

과정 설명 방법
설치 daphne은 channels를 설치하면 자동으로 설치가 된다.
확인 실행 명령은 우측과 같다.(asgi의 경로를 지정해주면 된다.)

포트번호는 필요에 따라 지정하면 된다.

해당 포트로 접속해보면 사이트와 채팅이 잘 작동한다.

daphne -b 0.0.0.0 -p 8001 config.asgi:application
서버에서 실행 서비스를 위해선 백그라운드로 진행해주어야 한다. nohup daphne -b 0.0.0.0 -p 8001 config.asgi:application &
웹서버에 반영 /static을 반영하듯, /ws로 시작되는 주소는 다핀으로 넘겨준다.

이후 관련 서비스들을 재시작하면 설정들이 반영된다.

채팅도 물론 성공적...!

location /ws {
                proxy_pass http://localhost:8001;
                proxy_http_version 1.1;
                proxy_set_header Upgrade $http_upgrade;
                proxy_set_header Connection "upgrade";
        }

,

웹소켓에서도 https 인증을 적용할 수 있다.(추후에 반영해보자.)

5 마무리. 각 요소들이 어떤 역할을 하는지.

순서대로 어떤 경로를 거쳐 작동하는지 살펴보자.

5.1 asgi.py

항목 설명
ProtocolTypeRouter 연결의 종류를 파악하여 처리. http로 넘길지, 웹소켓 처리로 넘길지.

5.2 routing.py

들어온 경로와 컨슈머를 연결해준다. urls.py와 view.py의 관계.

5.3 Consumer.py

항목 설명
self.scope["url_route"] args와 kargs를 키로 갖는 사전.
self.scope["url_route"]["kargs"] routing에서 정규표현식으로 사용한 변수.
self.scope["url_route"]["kargs"]["변수키"] 변수의 값을 얻어온다.