[Channels] 공식문서 번역 📘 - 5: 튜토리얼 (2) - 채팅 서버 구현

Eunsung Lim·2021년 1월 15일
1
post-thumbnail

Channels는 Django를 확장해 웹소켓과 같이 HTTP가 아닌 프로토콜을 핸들링할 수 있게 돕고 비동기적인 처리를 가능하게 해주는 ASGI의 구현체로, 장고를 이용한 실시간 채팅 구현 등에 활용할 수 있습니다. 이 글은 채널즈의 공식 문서를 최대한 원어를 살려 번역한 글입니다. 다소 의역하거나 생략한 부분이 있을 수 있음을 너그러이 양해해주시고, 잘못을 자유롭게 지적해주시면 감사하겠습니다.

튜토리얼 파트 2: 채팅 서버 만들기

튜토리얼 1에서 이어집니다.

이제 채팅방 페이지를 구현해 같은 방 안에 있는 사람들끼리 채팅할 수 있도록 만들어 봅시다.

채팅방 뷰 추가

이제 두번째 뷰인 채팅방 뷰(room view)를 만들어 특정 방에 게시된 메시지를 볼 수 있게 할겁니다.

chat/templates/chat/room.html 파일을 만들어주세요. 그러면 앱 디렉토리는 다음과 같습니다:

chat/
    __init__.py
    templates/
        chat/
            index.html
            room.html
    urls.py
    views.py

채팅방 뷰의 템플릿을 만들어 줍니다. 다음 코드를 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">
    {{ 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에서 채팅방 뷰를 만들어 줍니다:

# 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에서 채팅방 뷰를 위한 url 경로를 다음과 같이 지정해 줍니다:

# 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'),
]

다시 채널즈 개발 서버를 켜볼까요!

$ python3 manage.py runserver

브라우저를 열고 http://127.0.0.1:8000/chat/로 들어가 보세요. 초기 화면에서 "lobby"를 방 이름으로 타이핑하고 엔터를 칩니다. 그러면 http://127.0.0.1:8000/chat/lobby/로 리다이렉트 되고, 빈 채팅 로그가 나타납니다.

"안녕!"이라고 메시지를 타이핑하고 엔터를 눌러보세요. 아무 일도 일어나지 않습니다. 특히 방금 타이핑한 메시지도 채팅 로그에 나타나지 않습니다. 왜일까요?

엔터를 누르면 채팅방 뷰는 URL ws://127.0.0.1:8000/ws/chat/lobby/로 향하는 WebSocket을 열려 합니다. 하지만 해당하는 웹소켓 연결을 받아들이는 consumer를 아직 만들지 않았죠. 따라서 브라우저의 자바스크립트 콘솔을 열면 다음과 같은 에러가 나타납니다:

WebSocket connection to 'ws://127.0.0.1:8000/ws/chat/lobby/' failed: Unexpected response code: 500

컨슈머 만들기

장고는 HTTP 요청을 받으면 최상단 URLconf를 확인해 대응하는 뷰 함수를 찾고, 이 뷰가 이 요청을 처리할 수 있도록 호출합니다. 마찬가지로, 채널즈가 웹소켓 요청을 받으면 최상단 라우팅 설정을 확인해 대응하는 컨슈머(Consumer)를 찾고, 이 컨슈머의 다양한 함수를 호출해 요청을 처리하도록 합니다.

한 번 /ws/chat/ROOM_NAME/로 들어오는 웹소켓 연결을 처리하는 기본적인 컨슈머를 하나 작성해 봅시다. 이 컨슈머는 들어오는 메시지를 받아들이고, 같은 웹소켓으로 다시 보내줘야 합니다.

주의사항

웹소켓 연결을 다른 일반적인 HTTP 연결과 구분하기 위해 웹소켓용 경로에는 앞에 /ws/ 같은 것을 붙여주는 것이 좋습니다. 이를 통해 채널즈를 프로덕션 환경에 배포할 때 특정 설정을 더욱 쉽게 할 수 있습니다.

특히 대형 사이트 등에서 nginx와 같은 프로덕션 수준 HTTP 서버를 설정해 요청을 경로에 따라 나누어 (1) 일반 HTTP 요청은 Gunicorn+Django와 같은 프로덕션의 WSGI 서버로 전달하고 (2) 웹소켓 요청은 Daphne+Channels와 같은 프로덕션의 ASGI 서버로 전달해 처리하는 것이 가능합니다.

다만 작은 규모의 사이트에서는 더 단순하게 별도의 WSGI 서버를 두지 않고 Daphne 서버가 혼자 모든 요청-HTTP와 웹소켓 둘 다-을 처리하도록 할 수도 있습니다. 이런 배포환경에서는 /ws/와 같은 공통된 경로 앞에 붙는 단어가 필요하지 않습니다.

컨슈머를 만들기 위해 chat/consumers.py 파일을 만들어 줍니다. 앱 디렉토리 구조는 다음과 같습니다:

chat/
    __init__.py
    consumers.py
    templates/
        chat/
            index.html
            room.html
    urls.py
    views.py

다음 코드를 chat/consumers.py에 붙여 넣으세요:

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

ChatConsumer는 동기적인 웹소켓 컨슈머로, 모든 연결을 승낙하고(accept), 클라이언트로부터 메시지를 받고(receive), 동일한 클라이언트에게 이 메시지들을 다시 돌려줍니다(send). 아직은 이 컨슈머가 같은 채팅방의 다른 클라이언트에게 메시지를 전파해주지는 못합니다.

주의사항

채널즈에서는 더 나은 성능을 위해 비동기적인 컨슈머를 만들 수 있습니다. 하지만 비동기적인 컨슈머를 사용할 때는 장고 모델에 접근하는 것과 같이 동작을 차단하는(blocking) 작업을 직접 수행하지 않도록 주의해야 합니다. 비동기적인 컨슈머를 작성하는 법을 더 알고 싶다면 컨슈머를 참조하세요.


이제 chat 앱을 위한 라우팅 설정을 만들어 요청을 이 컨슈머로 전달해줘야 합니다. chat/routing.py 파일을 만드세요. 앱 디렉토리 구조는 이제 다음과 같습니다:

chat/
    __init__.py
    consumers.py
    routing.py
    templates/
        chat/
            index.html
            room.html
    urls.py
    views.py

다음 코드를 chat/routing.py에 붙여넣으세요:

# 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()),
]

ASGI 애플리케이션을 얻기 위해 클래스 메서드인 as_asgi()를 호출했는데, 이 애플리케이션이 각 유저별 연결을 처리하는 컨슈머 인스턴스를 만들어줍니다. 이 메서드는 요청별로 장고 뷰 인스턴스를 만들어주는 장고의 as_view()와 유사합니다.

(여기선 URLRouter의 한계 때문에 re_path()를 사용했습니다.)


다음 단계는 최상단 라우팅 설정이 chat.routing 모듈을 가리키도록 하는 것입니다. mysite/asgi.py에서, AuthMiddlewareStack, URLRouter, 그리고 chat.routing를 임포트하세요; 그리고 'websocket' 키를 ProtocolTypeRouter 리스트에 다음과 같은 형식으로 추가합니다:

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

주의사항

장고 2.2에서는 ProtocolTypeRouterhttp 키가 채널즈의 AsgiHandler를 사용함에 주의하세요. 그 외엔 동일합니다. 웹소켓 키는 기존에 없는 것이어서 모든 버전에서 동일합니다.

이 최상단 라우팅 설정은 채널즈 개발 서버에 연결이 구성될 때, ProtocolTypeRouter가 제일 먼저 연결 타입을 확인하도록 합니다. 만약 연결이 웹소켓 타입이라면 (ws://나 wss://), 이 연결은 AuthMiddlewareStack으로 전달됩니다.

AuthMiddlewareStack은 현재 인증된 유저에 대한 참조를 연결의 scope에 추가하는데, 이는 장고에서 AuthenticationMiddleware가 뷰 함수의 request 객체에 현재 인증된 유저를 추가하는 방식과 비슷합니다. (Scope는 튜토리얼 후반부에 다룹니다.) 그 후 이 연결은 URLRouter에게 전달됩니다.

URLRouter는 연결의 HTTP 경로를 확인해 적절한 컨슈머에게 연결해 줍니다.


한 번 /ws/chat/ROOM_NAME/에 지정한 컨슈머가 잘 작동하는지 확인해 봅시다. DB 변경사항을 반영하기 위해 마이그레이션을 진행합니다(장고의 세션 프레임워크가 DB를 사용하기 때문입니다), 그리고 채널즈 개발 서버를 실행합니다:

$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying admin.0003_logentry_add_action_flag_choices... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying auth.0012_alter_user_first_name_max_length... OK
  Applying sessions.0001_initial... OK
$ python3 manage.py runserver

브라우저를 열고 채팅방 페이지 http://127.0.0.1:8000/chat/lobby/로 가면 빈 채팅 로그가 나타납니다.

이제 "안녕!"이라고 타이핑하고 엔터를 치면, 채팅 로그에 다시 "안녕!"이라는 메시지가 나타나는걸 확인할 수 있습니다!

하지만 아직입니다. 또다른 브라우저 탭을 열어서 동일한 채팅방 페이지 http://127.0.0.1:8000/chat/lobby/로 이동해 메시지를 타이핑하면, 방금 연 두번째 탭의 채팅 로그에서는 메시지가 뜨지만 원래 첫번째 탭의 채팅 로그에서는 메시지가 나타나지 않습니다. 이것이 가능하려면, 우리는 동일한 ChatConsumer의 여러 인스턴스들이 서로 소통할 수 있게 해야합니다. 채널즈는 컨슈머 간에 이러한 종류의 통신이 가능하도록 추상화된 '채널 레이어'를 제공합니다.

터미널로 돌아가 Control-C로 서버를 중지하세요.

채널 레이어 활성화하기

채널 레이어는 일종의 통신 체계입니다. 이를 통해 여러 컨슈머들끼리 서로 소통하고, 또한 장고의 다른 부분들과도 소통하게 할 수 있습니다.

채널 레이어는 다음과 같은 추상화된 대상을 제공합니다:

  • 채널은 메시지가 전달되는 우편함입니다. 각각의 채널은 자기 이름을 갖고 있습니다. 채널 이름을 알고있는 누구든 해당 채널로 메시지를 보낼 수 있습니다.
  • 그룹은 관련있는 채널들의 모임입니다. 그룹은 이름을 갖고 있습니다. 그룹의 이름을 아는 누구든 새 채널을 그룹에 추가하거나, 그룹에서 제거하거나, 그룹에 등록된 모든 채널에 메시지를 보낼 수 있습니다. 하지만 특정 그룹에 있는 채널들을 쭉 나열하는 것은 불가능합니다.

모든 컨슈머 인스턴스는 자동으로 생성된 유니크한 채널명을 갖고 있고, 따라서 채널 레이어를 통해 통신할 수 있습니다.

우리 채팅 앱에서 우리는 여러 개의 ChatConsumer가 서로 통신을 주고받게 하고 싶습니다. 그러기 위해선 각 ChatConsumer가 자신의 채널을 (채팅방 이름을 바탕으로 하는 이름의) 그룹에 등록해야합니다. 그러면 ChatConsumers가 같은 방 안의 모든 ChatConsumers들에게 메시지를 전파할 수 있습니다.


우리는 Redis를 저장소로 하는 채널 레이어를 사용할 겁니다. 도커를 이용해 Redis를 포트 6379에서 실행하려면, 다음 커멘드를 입력합니다:

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

채널즈가 레디스에 접근할 수 있게 하려면 다음 커맨드로 channels_redis를 설치해야 합니다:

$ python3 -m pip install channels_redis

채널 레이어를 사용하려면 먼저 몇 가지 설정을 해야 합니다. mysite/settings.py 파일 끝에 CHANNEL_LAYERS 설정을 다음과 같이 추가해주세요:

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

주의사항

여러 개의 채널 레이어를 갖도록 설정할 수도 있습니다. 하지만 대부분의 프로젝트는 default 채널 레이어 하나만을 사용할 것입니다.


채널 레이어가 Redis와 통신할 수 있는지 확인해 봅시다. 장고 쉘을 열고 다음 커맨드를 실행합니다:

$ python3 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'}

Control-D를 눌러 장고 쉘에서 나옵니다.

이제 채널 레이어가 준비됐으니, ChatConsumer에서 사용해봅시다. 다음 코드를 chat/consumers.py 파일에 붙여넣어 기존 코드를 덮어 씌웁니다:

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

유저가 메시지를 전송하면, 자바스크립트 함수는 이 메시지를 웹소켓을 통해 ChatConsumer로 전송합니다. ChatConsumer는 메시지를 받아, 채팅방 이름에 해당하는 그룹으로 전파합니다. 같은 그룹안에 있는 (즉, 같은 채팅방 안에 있는) 모든 ChatConsumer는 그룹으로부터 메시지를 전달받아, 웹소켓을 통해 자바스크립트로 이를 돌려주고, 따라서 채팅 로그에 이 메시지가 추가됩니다.

ChatConsumer 코드의 몇몇 부분은 추가적인 설명이 필요합니다:

  • self.scope['url_route']['kwargs']['room_name']
    • 웹소켓 연결을 컨슈머에게 전달한 chat/routing.py의 URL route로부터room_name 인자를 얻습니다.
    • 모든 컨슈머는 자신의 연결에 대한 정보가 담긴 scope를 갖습니다. scope 안에는 모든 URL route 인자들과, 만약 존재한다면 현재 인증된 유저 정보가 들어 있습니다.
  • self.room_group_name = 'chat_%s' % self.room_name
    • 채널즈 그룹명을 유저가 지정한 채팅방 이름에서 다른 처리 없이 직접 구성합니다.
    • 그룹명은 알파벳, 숫자, 하이픈, 그리고 온점만으로 구성돼야 합니다. 따라서 이 예제 코드는 그 외 문자가 들어간 채팅방 이름 입력에 대해 실패함에 유의해야 합니다.
  • async_to_sync(self.channel_layer.group_add)(...)
    • 그룹에 들어갑니다.
    • async_to_sync(…)는 ChatConsumer가 동기적인 WebsocketConsumer이지만 채널 레이어의 비동기적인 메서드를 호출하고 있기 때문에 필요합니다(채널 레이어의 메서드들은 모두 비동기적입니다.)
    • 그룹명은 아스키 영문자, 숫자, 하이픈, 온점으로 제한됩니다. 유효하지 않은 문자가 포함돼있다면 에러가 발생합니다.
  • self.accept()
    • 웹소켓 연결을 승낙합니다(Accept).
    • connect() 메서드 안에서 accept()를 호출하지 않는다면, 연결 요청은 거부되고 종료됩니다. 요청하는 유저가 해당 동작을 수행하기 위한 권한이 없는 등의 이유로 연결을 거부할 필요가 있을 수 있습니다.
    • 연결을 승낙하려는 경우 accept()connect()의 마지막 동작으로 호출하도록 권장합니다.
  • async_to_sync(self.channel_layer.group_discard)(...)
    • 그룹을 떠납니다.
  • async_to_sync(self.channel_layer.group_send)
    • 그룹에 이벤트를 전송합니다.
    • 이벤트는 메서드명에 대응되는 특별한 type 키를 갖고 있습니다. 이 이벤트를 전달받는 컨슈머들은 대응되는 메서드명의 메서드를 실행하게 됩니다.

이제 /ws/chat/ROOM_NAME/에 해당하는 새 컨슈머가 잘 동작하는지 확인해 봅시다. 채널즈 개발 서버 실행을 위해 다음 커맨드를 입력합니다:

$ python3 manage.py runserver

브라우저를 열고 채팅방 페이지 http://127.0.0.1:8000/chat/lobby/로 이동합니다. 다른 탭을 하나 더 열어 동일한 채팅방 페이지로 이동합니다.

이제 두번째 브라우저 탭에서, “정말로 안녕!”이라고 타이핑하고 엔터를 눌러보세요. 그러면 두번째 탭과 첫번째 탭 모두에서 "정말로 안녕!"이라는 메시지가 채팅 로그에 나타나는 것을 확인하실 수 있습니다.

방금 기본적인 모든 기능이 잘 동작하는 채팅 서버를 만드신 겁니다!


이 튜토리얼은 튜토리얼 3으로 이어집니다.

profile
Strong belief in connecting the dots. 찬찬히 배우고 있는 학생 개발자입니다.

0개의 댓글