Django 실시간 채팅 튜토리얼

wodnr_P·2023년 8월 1일
3
post-thumbnail

최근 정처기 자격증을 준비하며 WebSocket 통신에 관심을 가졌고, 이전 프로젝트 이후 redis를 적용해보고 싶다는 막연한 생각으로 실시간 채팅 서비스를 배워보고자 간단한 튜토리얼을 진행 해보았습니다.

📌 상세과정은 Django 튜토리얼 참고

시작 전

  • Channels?

    • Channels는 Django에서 비동기 통신 뷰를 실현하는 기능
    • HTTP, HTTPS, WebSocket, MQTT 등 다양한 프로토콜을 지원
    • Channels를 활용하여 WebSocket을 사용한 채팅 앱을 만들 수 있습니다.
  • WebSocket?

    • 서버와 클라이언트 간 메시지 교환을 위한 프로토콜
    • 양방향 통신 : 기존 HTTP 통신은 클라이언트-서버가 요청-응답하는 단방향 통신
    • 실시간 네트워킹이 가능
    • 동작과정
      1. 브라우저에서 WebSocket 서버로 요청이 들어오면, HTTP -> WS로 프로토콜 전환 후 승인되었음을 반환 : Opening Handshake
      2. 데이터는 메시지 단위로 전달 : 메시지 = 여러 프레임(작은 헤더 + payload)의 모임
      3. WebSocket 서버에서 close를 반환하면, 브라우저에서 확인 후 반응 : Closing Handshake

전체 과정

1. 설치

$ pip install django
$ pip install channels
  • django 프로젝트를 만들고 project/settings.py 에서 INSTALLED_APPS 편집
INSTALLED_APPS  =  [ 
    'channels',  # 다른 앱과의 충돌 방지를 위해 맨 위에 추가
    ...
]

2. ASGI 설정

  • 비동기 통신을 위한 asgi.py 수정
import os
from channels.routing import ProtocolTypeRouter 
from django.core.asgi import get_asgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE' ,'<project이름>.settings')

application = ProtocolTypeRouter ({ 
    'http':get_asgi_application()
})
  • settings.py에서 ASGI Application 정의
# settings.py
...
ASGI_APPLICATION  =  '<project이름>.asgi.application'

3. Django 채팅 앱 만들기

  • chat이라는 django app 생성 후, INSTALLED_APPS에 추가
$ python3 manage.py startapp chat
  • chat(앱)/Templates/chat 디렉토리를 다음과 같이 생성하고, 채팅 입장 페이지 index.html 생성
// 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>

4. chat/Views.py 작성

from django.shortcuts import render

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

5. routing 설정

  • project/urls.py
from django.urls import path, include
from django.contrib import admin

urlpatterns = [
    path('chat/', include('chat.urls')),
    path('admin/', admin.site.urls),
]
  • chat/urls.py
from django.urls import path
from . import views

urlpatterns = [
    path('', views.index, name='index'),

6. 채팅방 생성

  • index.html에서 submit시 이동하는 채팅방 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 = (e) => {
            const data = JSON.parse(e.data);
            document.querySelector('#chat-log').value += (data.message + '\n');
        };

        chatSocket.onclose = (e) => {
            console.error('Chat socket closed unexpectedly');
        };

        document.querySelector('#chat-message-input').focus();
        document.querySelector('#chat-message-input').addEventListener("keyup",(e) => {
            if (e.key === 13) {  // enter, return
                document.querySelector('#chat-message-submit').click();
            }
        });

        document.querySelector('#chat-message-submit').addEventListener("click",(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와 urls.py 수정
# views.py
from django.shortcuts import render

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

# room.html을 보여주는 함수 추가
def room(request, room_name):
    return render(request, "chat/room.html", {"room_name": room_name})
# 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 -> 에러발생 -> 브라우저 console에서도 확인 가능
WebSocket connection to 'ws://127.0.0.1:8000/ws/chat/<채팅방이름>/' failed: 
Chat socket closed unexpectedly

Why?

  • WebSocket 연결을 받아들이면, root routing configuration에서 consumer를 찾은 후 이벤트 처리를 위한 함수들을 호출하기에 consumer 작성 필요

7. 비동기로 consumers.py 작성

  • consumer는 django 모델에 특별한 코드 없이 동기적 접근이 가능하지만, 비동기로 작성할 경우 추가 스레드를 생성할 필요가 없어서 성능이 더 뛰어남 (단, django 모델 접근에서 문제가 발생할 수 도 있음)
# consumers.py
from channels.generic.websocket import AsyncWebsocketConsumer
import json

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
        # send 등 과 같은 동기적인 함수를 비동기적으로 사용하기 위해서는 async_to_sync 로 감싸줘야함
        # Join room group
        await self.channel_layer.group_add(
            self.room_group_name,
            self.channel_name
        )
        # WebSocket 연결
        await self.accept()

    async def disconnect(self, close_code):
        # 그룹에서 Leave
        # Leave room group
        await self.channel_layer.group_discard(
            self.room_group_name,
            self.channel_name
        )
    # WebSocket 에게 메세지 receive
    # Receive message from WebSocket
    async def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json['message']

        # room group 에게 메세지 send
        # Send message to room group
        await self.channel_layer.group_send(
            self.room_group_name,
            {
                'type': 'chat_message',
                'message': message
            }
        )
    # room group 에서 메세지 receive
    # Receive message from room group
    async def chat_message(self, event):
        message = event['message']
        
        # WebSocket 에게 메세지 전송
        # Send message to WebSocket
        await self.send(text_data=json.dumps({
            'message': message
        }))

8. routing configuration 설정

  • chat/routing.py 생성
from django.urls import re_path
from . import consumers

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

re_path : 내부 라우터가 미들웨어에 감싸져 있을 경우, path()가 제대로 동작하지 않을 수 있기에 사전 방지하고자 사용

  • project/asgi.py 수정
import os
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.security.websocket import AllowedHostsOriginValidator 
from chat.routing import websocket_urlpatterns
from django.core.asgi import get_asgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', '<project이름>.settings')
django_asgi_app = get_asgi_application()
# 클라이언트와 Channels 개발 서버가 연결 될 때, 어느 protocol 타입의 연결인지

application = ProtocolTypeRouter({ 
    'http': django_asgi_app,
    # (http->django views is added by default)
    # 만약에 websocket protocol 이라면, AuthMiddlewareStack
    'websocket': AllowedHostsOriginValidator(
        AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
        # URLRouter 로 연결, 소비자의 라우트 연결 HTTP path를 조사
    ),
})

9. Channel Layer 활성화

  • ChatConsumer의 여러 인스턴스가 서로 통신할 수 있도록 Channel layer를 활성화
  • 이때, 비동기 통신, 백업 저장소의 역할을 할 수 있는 redis 활용

1) redis 설치 (MAC)

brew install redis

2) redis 실행

redis-server 

// background로 실행
brew services start redis
  • channels와 redis연결 라이브러리 설치
$ pip install channels_redis
  • settings.py 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()

10. 최종 확인

  • runserver로 확인
    127.0.0.1:8000/chat

    127.0.0.1:8000/chat/test

튜토리얼 과정 중 에러 해결

Error 상황

: ValueError: No route found for path 'ws/chat/test/'.
--> Web socket connection error -> 웹소켓 라우팅 경로 문제

해결

: routing.py에서 경로 수정

# before
from django.urls import re_path
from . import consumers
>
websocket_urlpatterns = [
    re_path('ws/chat/<room_name>', consumers.ChatConsumer.as_asgi()),
]
# after
from django.urls import re_path
from . import consumers
>
websocket_urlpatterns = [
    re_path(r'^ws/chat/(?P<room_name>[^/]+)/$', consumers.ChatConsumer.as_asgi()),
]

회고

📌 전체 코드는 Github에 있습니다.

이번 튜토리얼을 바탕으로 웹 소켓 통신에 대해 공부할 수 있었고, 튜토리얼을 진행하며 100%의 이해는 되지 않았지만, 블로그에 정리함으로써 좀 더 이해할 수 있었습니다. 이후에는 이를 응용한 토이 프로젝트를 기획하고 개발 할 수 있도록 더 공부해보겠습니다.
틀린부분, 의문점 등 질문은 언제나 환영합니다 :D

profile
발전하는 꿈나무 개발자 / 취준생

2개의 댓글

comment-user-thumbnail
2023년 8월 1일

유익한 글이었습니다.

1개의 답글

관련 채용 정보