Web/Django 2021. 8. 11. 23:14

Django 채팅 앱 만들기


장고 기본 페이지 생성

개발 환경

  • 윈도우10, Anaconda3 가상환경에서 수행했다.

    $ conda --version
    conda 4.10.1
    $ conda create -n chat python=3.8
    $ conda activate chat
    $ python --version
    Python 3.8.11
  • 기본적으로 필요한 라이브러리들을 설치한다.

    $ pip install Django
    $ python -m django --version
    3.2.6
    $ pip install channels
    $ python -c 'import channels; print(channels.__version__)'
    3.0.4

프로젝트 생성

  • 작업할 chat 폴더로 들어가서 프로젝트를 생성한다.

    $ django-admin startproject mysite .
    • .은 현재 폴더를 프로젝트의 루트 디렉토리로 구성하라는 의미이다.

      • 생략하면 프로젝트명으로 하위 폴더가 만들어진다.
  • chat 앱을 생성한다.

    $ python manage.py startapp chat
  • mysite/settings.pyINSTALLED_APPS에 방금 생성한 앱을 추가한다.

    mysite/settings.py

    INSTALLED_APPS = [
      'chat',
      'django.contrib.admin',
      'django.contrib.auth',
      'django.contrib.contenttypes',
      'django.contrib.sessions',
      'django.contrib.messages',
      'django.contrib.staticfiles',
    ]
  • 제공되는 기본 html 파일을 chat/templates/chat/index.html 에 입력한다.

    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.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>
  • chat/views.py 에서 index.html을 렌더링하는 함수를 생성하고 URLconf를 설정한다.

    chat/views.py

    from django.shortcuts import render
    
    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'),
    ]

    mysite/urls.py

    from django.contrib import admin
    from django.urls import path, include
    
    urlpatterns = [
        path('chat/', include('chat.urls')),
        path('admin/', admin.site.urls),
    ]
  • 서버를 돌리면 마이그레이션 하라고 뜨는데, 일단 정상적으로 작동하는지 확인한다.

    $ python3 manage.py runserver
    Django version 3.2.6, using settings 'mysite.settings'
    Starting development server at http://127.0.0.1:8000/
    Quit the server with CTRL-BREAK.

Channels 라이브러리 적용

  • Channels의 라우팅은 장고의 URLconf와 비슷하다.

    • HTTP 요청이 들어오면 장고는 root URLconf에서 view 함수를 찾아서 요청을 처리한다.

    • 비슷하게, Channels는 웹 소켓 연결을 받아서 root routing configuration에서 consumer를 찾는다.

      • consumer의 다양한 함수를 통해 연결에서 오는 이벤트들을 처리한다.
  • mysite/asgi.py를 다음과 같이 수정한다.

    mysite/asgi.py

    import os
    
    from channels.routing import ProtocolTypeRouter
    from django.core.asgi import get_asgi_application
    
    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')
    
    application = ProtocolTypeRouter({
        "http": get_asgi_application(),
        # Just HTTP for now. (We can add other protocols later.)
    })
  • Channels 라이브러리를 mysite/settings.pyINSTALLED_APPS 에 추가하고, 루트 라우팅 설정도 해준다.

    mysite/settings.py

    INSTALLED_APPS = [
        'channels',
        'chat',
        'django.contrib.admin',
        'django.contrib.auth',
        'django.contrib.contenttypes',
        'django.contrib.sessions',
        'django.contrib.messages',
        'django.contrib.staticfiles',
    ]
    # Channels
    ASGI_APPLICATION = 'mysite.asgi.application'
    • 맨 위에 추가하지 않으면 충돌이 발생한다고 한다.
  • 서버를 실행하면 아까와 다른 문구가 뜨는 것을 볼 수 있다.

    python manage.py runserver
    Django version 3.2.6, using settings 'mysite.settings'
    Starting ASGI/Channels version 3.0.4 development server at http://127.0.0.1:8000/
    Quit the server with CTRL-BREAK.
  • http://127.0.0.1:8000/chat/ 사이트는 여전히 정상적으로 작동한다.


서버 실행

  • 위에서 생성한 http://127.0.0.1:8000/chat/ 페이지에서 방 제목을 입력하면 해당 방으로 들어가서 채팅하는 앱을 만드는 것이 목표이다.

  • 방에 해당하는 템플릿과 뷰를 작성하고 URLconf를 설정한다.

    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

    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'),
    ]
  • 이대로 서버를 실행해서 http://127.0.0.1:8000/chat/ 에 접속하고 방 제목을 입력해보면 해당 페이지로 이동되는 것을 볼 수 있다.

    • 하지만 이동하는 순간 서버에는 다음과 같은 오류가 발생한다.

      ValueError: No application configured for scope type 'websocket'  
    • 클라이언트에서도 콘솔 창을 열어보면 다음과 같은 오류가 발생한 것을 볼 수 있다.

      WebSocket connection to 'ws://127.0.0.1:8000/ws/chat/asd/' failed: 
      Chat socket closed unexpectedly
    • html 파일의 자바스크립트에서 WebSocket을 연결하는 것에 실패했기 때문이다.

  • 해당 URL에 대한 웹 소켓을 받아주는 consumer가 필요하다.

    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
            }))
    • receive()

      • 위의 chat/templates/chat/room.html 파일에서 document.querySelector('#chat-message-submit').onclick 이벤트를 설정했었다.

        • 이 함수에서 보낸 json 데이터를 다시 클라이언트에게 되돌려주는 역할을 한다.
      • 클라이언트가 메시지를 다시 받으면 chat/templates/chat/room.html 파일의 chatSocket.onmessage 이벤트에서 로그를 추가하는 방식이다.

    • 이 컨슈머는 동기적(synchronous)이다.

      • 비동기적(asynchronous)으로 구현하는게 훨씬 성능이 좋지만, 장고 모델 접근에서 문제가 발생할 수 있으니 Consumer 문서를 잘 참고하라고 써있다.
  • 이 consumer를 이용하기 위해서 routing configuration을 설정해야 한다.

    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()),
    ]
    • 각각의 연결에 consumer의 인스턴스를 생성하기 위해 as_asgi() 클래스메서드를 사용했다.

      • 장고 뷰 인스턴스의 per-request와 같은 역할이라는데 이건 또 공부해봐야겠다.
    • re_path() 함수를 사용한 것은, 만약 내부 라우터가 미들웨어에 감싸져 있을 경우 path() 함수는 제대로 동작하지 않을 수 있기 때문이다.

  • 위에서 작성한 설정을 root routing configuration에서 지정한다.

    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
            )
        ),
    })
    • 서버로 연결이 생성되면 먼저 ProtocolTypeRouter가 연결의 종류를 탐색한다.

    • ws:// 혹은 wss:// 형식의 웹 소켓 연결이면, 해당 연결은 AuthMiddlewareStack으로 메시지를 group의 모든 channel에게 보낸다.

  • chat_message()

    • channel layer의 group으로부터 전달된다.

    • AuthMiddlewareStack은 현재 인증된 유저를 참조하여 연결의 scope를 채운다.

      • 장고의 AuthenticationMiddleware이 현재 인증된 유저를 참조하여 view 함수의 request 객체를 채우는 것과 비슷하다고 한다.
    • 그 후 연결은 URLRouter로 전달된다.

  • 여기까지 하고 migration 후 서버를 돌렸더니 오류가 발생했다.

    $ python manage.py migrate
    $ python manage.py runserver
    django.core.exceptions.ImproperlyConfigured: Cannot import ASGI_APPLICATION module 'mysite.asgi'
  • 이제 http://127.0.0.1:8000/chat/a 등의 주소로 이동해서 메시지를 입력해보면 해당 메시지가 성공적으로 되돌아오는 것(echo)을 볼 수 있다.

    • 같은 주소로 창을 두 개 열어서 확인해보면 다른 쪽에는 메시지가 전송되지 않는 것을 볼 수 있다.

    • 이를 해결하려면 ChatConsumer의 여러 인스턴스가 서로 통신하도록 해야 한다.

    • Channels는 컨슈머끼리 이런 통신을 할 수 있도록 하는 channel layer를 제공한다.

channel layer 활성화

  • channel layer는 다음과 같은 추상화를 제공한다.

    • channel

      • 메시지가 전달될 수 있는 우체통이다.

      • 각 channel은 이름을 가지며, 다른 channel에게 메시지를 전송할 수 있다.

    • group

      • 연관된 channel들의 그룹이다.

      • group은 이름을 가지며, channel의 이름을 통해 추가하고 삭제할 수 있다.

      • 그룹의 모든 channel에게 메시지를 전송할 수 있다.

      • 특정 그룹에 있는 channel들을 나열할 수는 없다.

  • 모든 consumer 인스턴스는 자동으로 유일한 channel name을 생성하기 때문에, 서로 channel layer를 통해 통신할 수 있다.

  • 지금 진행 중인 앱에서는 같은 방에 있는 ChatConsumer의 여러 인스턴스들끼리 통신하는게 목표이다.

    • 이를 위해 각 ChatConsumer 인스턴스는 room name을 이름으로 하는 group에 channel을 추가한다.

    • 이러면 같은 방에 있는 channel들은 같은 group에 속하므로 서로 통신할 수 있게 된다.

  • 도커의 redis 컨테이너를 backing store로 사용하는 channel layer를 생성할 것이다.

    $ docker run -p 6379:6379 -d redis:5
    • redis컨테이너는 6379 포트를 사용한다.

    • redis 5.0 버전을 사용하는 이유는 아마...

      https://redis.io/download#other-versions

      Redis 5.0 is the first version of Redis to introduce the new stream data type with consumer groups, ...
  • Channels와 redis를 연결하기 위해 라이브러리를 설치한다.

    $ pip install channels_redis
  • channel layer를 사용하기 위해 mysite/settings.pyASGI_APPLICATION 밑에 다음과 같이 백엔드와 호스트에 대한 설정을 추가한다.

    # Channels
    ASGI_APPLICATION = 'mysite.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()
  • 이제 channel layer가 있으므로, ChatConsumer를 수정하여 group 내의 channel에게 보내도록 설정한다.

    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
            }))
    • connect()

      • 연결 시 channel layer의 group에 channel을 추가한다.

      • self.room_namechat/urls.py에서 지정한 변수명을 AuthMiddlewareStack가 scope에 kwargs 형태로 채워준 것을 꺼내서 사용한다.

      • 실제로 channel layer에서 사용하는건 self.room_group_name이다.

        • 굳이 이렇게 변환하는 이유는..?
    • disconnect()

      • 연결 해제 시 channel layer의 group에서 channel을 삭제한다.
    • receive()

      • 웹 소켓으로부터 메시지를 받을 시 메시지를 channel layer의 group 내 모든 channel에게 보낸다.
    • chat_message()

      • channel layer의 group으로부터 메시지를 받을 시 메시지를 클라이언트로 전달한다.

      • 전과 마찬가지로 chatSocket.onmessage 이벤트에 의해 처리된다.

  • 서버를 실행하고 클라이언트 두 개로 통신해보면 정상적으로 작동하는 것을 볼 수 있다.