Django 채팅 앱 만들기

2021. 8. 11. 23:14·SW개발/Web
반응형

Django 채팅 앱 만들기

  • channels 공식 문서에 정리가 잘 되어있다.

    • https://channels.readthedocs.io/en/latest/tutorial/part_1.html

장고 기본 페이지 생성

개발 환경

  • 윈도우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.py 의 INSTALLED_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.
    • http://127.0.0.1:8000/chat/ 으로 접속하면 url 매핑이 정상적으로 이루어진 것을 볼 수 있다.

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.py 의 INSTALLED_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'
    • chat/consumers.py 를 chat/consumer.py로 오타내서 발생한 문제여서, 파일명을 변경하여 해결했다.

      • python manage.py shell 로 파이썬 쉘에 진입한 뒤 from mysite.routing import application 로 직접 import 했을 때 뜨는 에러 문구로 어디서 잘못했는지 짐작할 수 있다.

      • 참고 : https://stackoverflow.com/questions/51634522/cannot-import-asgi-application-module-while-runserver-using-channels-2

  • 이제 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.py 의 ASGI_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_name은 chat/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 이벤트에 의해 처리된다.

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

반응형
저작자표시 (새창열림)

'SW개발 > Web' 카테고리의 다른 글

Nginx SPA에서 새로고침 시 404 Not Found 에러 발생  (0) 2021.03.09
'SW개발/Web' 카테고리의 다른 글
  • Nginx SPA에서 새로고침 시 404 Not Found 에러 발생
Caniro
Caniro
  • Caniro
    Minimalism
    Caniro
  • 전체
    오늘
    어제
    • 전체보기 (318) N
      • SW개발 (268)
        • Java Spring (6)
        • C++ (186)
        • Python (21)
        • Linux (16)
        • 알고리즘 (13)
        • Git (4)
        • Embedded (1)
        • Raspberrypi (9)
        • React (3)
        • Web (2)
        • Windows Device Driver (6)
      • IT(개발아님) (45)
        • Windows (25)
        • MacOS (7)
        • Utility (11)
      • 챗봇 짬통 (0)
      • 일상 (2) N
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    windows
    로지텍 마우스 제스처
    윈도우
    unix
    mspaint
    스프링 프레임워크 핵심 기술
    윈도우 명령어
    SFC
    백기선
    spring
    알림
    citrix workspace
    SunOS 5.1
    java
    시스템 복구
    EXCLUDE
    logi options
    MacOS
    Solaris 10
    스프링
    Workspace
    맥북 카카오톡 알림 안뜸
    KakaoTalk
    제외
    vscode
    그림판
    Windows 11
    dism
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
Caniro
Django 채팅 앱 만들기
상단으로

티스토리툴바