본문 바로가기

Develop/기타

Channels 라이브러리를 사용해서 WebSocket 연동

1. WebSocket

 

웹소켓에 대해 알아보자

웹 개발을 처음 배우기 시작했다면 서버와 클라이언트의 통신은 모두 HTTP 프로토콜만 이용해서 이루어진다고 생각할 수 있습니다. 하지만 웹 개발을 하면서 채팅, 게임, 주식 차트 등의 실시간

woowacourse.github.io

 기존 HTTP 프로토콜은 클라이언트의 요청이 있을 때만 서버가 응답할 수 있었다. 그래서 게임/채팅/주식과 같이 실시간 통신을 구현하기에는 적절하지 않다. 예를 들어 User1이 메시지를 보내지 않았더라도(요청) User2가 보낸 메시지를 받을 수 있어야 한다.(응답) 또한 HTTP 프로토콜은 매 요청/응답 마다 새롭게 연결을 수립하고 끊는 과정을 반복해야 하기 때문에 비효율성의 문제도 있다. 따라서 웹소켓이 등장했다.

 

웹소켓은 아래와 같은 특징을 가진다.

  • 실시간 양방향 통신을 지원한다.
  • 한번 연결이 수립되면 클라이언트와 서버가 자유롭게 데이터를 주고받는다.

2. Channels

 

Django Channels — Channels 3.0.3 documentation

Channels is a project that takes Django and extends its abilities beyond HTTP - to handle WebSockets, chat protocols, IoT protocols, and more. It’s built on a Python specification called ASGI. Channels builds upon the native ASGI support available in Dja

channels.readthedocs.io

Channels 라이브러리는 장고가 HTTP 뿐만 아니라 WebSockets, chat protocols, IoT protocols와 같은 다양한 프로토콜을 처리할 수 있게 도와준다. 또한 장고 자체는 동기식으로 실행하지만, Channels를 사용하여 기타 연결과 소켓을 비동기식으로 처리할 수 있다.

 

이러한 ChannelsASGI를 기반으로 만들어졌다. ASGIWSGI를 계승하여 만들어졌으며, 기본적으로 요청을 비동기 방식으로 처리한다. ASGI는 비동기 요청인 웹소켓을 처리하는 이벤트로서 connect, send, receive, disconnect가 있다.  Django 3.0부터는 ASGI를 주로 지원하며 Java처럼 비동기 처리에도 관심을 가지고 있다. 

Channel Layer가 만들어진다.

3. Consumer

장고가 HTTP 요청을 받으면 URLconf를 찾아서 해당 요청을 처리하기 위한 View 함수를 실행한다.

이때 Consumer는 View와 같은 역할을 한다. Channels가 웹소켓 연결 요청을 수락하면 root routing configuration에서 Consumer를 찾아서 해당 웹소켓 연결로부터 온 이벤트를 처리할 메소드를 호출한다. 대략적으로 아래 코드와 같은 형태를 가진다.

 

(모든 웹소켓 요청을 수락한 뒤 해당 클라이언트로부터 메시지를 수신하고 그대로 같은 클라이언트로 전달하는 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
        }))

4. Routing

장고의 URLconf와 비슷한 역할을 한다. urlpatterns -> websocket_urlpatterns를 사용한다. 또한 웹소켓 연결마다 개별 Consumer 인스턴스를 가지기 위해서 .as_asgi() 메소드를 호출한다. 

# 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 개발 서버가 연결을 수립할 때 먼저 ProtocolTypeRouter를 검사하여 어떤 타입의 연결인지 구분한다.

만약 websocket 연결(ws:// 또는 wss://)이라면 AuthMiddlewareStack로 연결을 보낸다. AuthMiddlewareStack은 현재 인증된 사용자에 대한 참조로 scope를 결정한 뒤, 다시 연결을 URLRouter로 보낸다.

더보기

HTTP 연결과 웹소켓 연결을 구분하기 위해 /ws/와 같은 path prefix를 사용하는 게 좋다. Channels를 배포할 경우 특정 설정을 더욱 쉽게 만들어준다.

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

5. Channel Layer

Channel Layer는 커뮤니케이션 시스템의 일종이다.

모든 Consumer 인스턴스는 자동으로 생성된 고유한 Channel 이름을 가진다. Channel Layer는 이러한 Consumer 인스턴스가 서로 또는 장고의 다른 부분과 통신할 수 있게 한다. Channel Layer는 아래와 같은 추상화를 제공한다.

 

1) Channel

Channel은 메시지를 보낼 수 있는 우편함과 같다.

각 채널은 이름을 가지며 누구든지 해당 채널에 메시지를 보낼 수 있다.

 

2) Group

Group은 Channel과 관련된 그룹이다.

그룹 또한 이름을 가지며, 그룹 이름을 가진 사용자는 누구나 그룹에 채널을 추가/삭제 가능하고, 그룹에 속한 모든 채널에 메시지를 보낼 수 있다. 하지만 그룹에 속한 채널들을 나열할 수는 없다.

더보기

만약 같은 채팅방에 있는 사람들이 서로 대화할 수 있게 하려면

room name(ex. hobby)을 기반으로 한 Group에 각각의 채널을 추가해야 한다.

# mysite/settings.py
# Channels
ASGI_APPLICATION = 'mysite.asgi.application'
CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels_redis.core.RedisChannelLayer',
        'CONFIG': {
            "hosts": [('127.0.0.1', 6379)],
        },
    },
}
# 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
        }))

다음과 같이 Redis를 백업 저장소로 사용하는 Channel Layer를 구성할 수 있다.

 

클라이언트가 메시지를 작성하면, JS 함수가 웹소켓을 통해 해당 메시지를 ChatConsumer에게 전송한다.

그러면 ChatConsumer는 해당 메시지를 수신한 뒤, room 이름에 대응하는 Group으로 전달한다.

동일한 Group에 있는 모든 ChatConsumer는 메시지를 수신하고, 웹소켓을 통해 해당 메시지를 JS를 전달하여 채팅 로그에 추가한다.

 

더보기

1. self.scope[‘url_route’][‘kwargs’][‘room_name’]

chat/routing.py에서 정의된 URL 파라미터에서 room_name을 얻는다.

참고로 모든 소비자들은 현재 인증된 유저와 URL의 인자를 포함하여, 연결에 대한 정보를 갖는 scope를 갖는다.

 

2. self.room_group_name = ‘chat_%s’ % self.room_name

 

유저가 작성한 room_name으로 Channel의 Group 이름을 생성한다.

 

3. async_to_sync(self.channel_layer.group_add)(…)

 

그룹에 가입한다.

이때 ChatConsumer는 동기 WebsocketConsumer이지만, 비동기 Channel Layer 메소드를 호출하기 때문에 async_to_sync(...) 같은 wrapper를 사용하여 동기적으로 받아야 한다.

 

4. self.accept()

 

웹소켓 연결을 수락한다.

 

5. async_to_sync(self.channel_layer.group_discard)(...)

 

그룹에서 탈퇴한다.

 

6. async_to_sync(self.channel_layer.group_send)

 

그룹에게 이벤트를 보낸다.

이벤트를 받는 Consumer에서 호출해야 하는 메소드 이름에 대응하는 특별한 type 키를 가진다.

 

위에서 작성한 ChatConsumer는 동기식으로 작성되었다. 이는 특별한 코드를 작성하지 않아도 동기 I/O 함수를 호출할 수 있어서 편리하다. (ex. 장고 Model에 접근) 하지만 비동기 Consumer는 요청을 처리할 때 추가 쓰레드를 만들 필요가 없어서 성능을 향상시킬 수 있다.

물론 비동기 Consumer도 asgiref.sync.sync_to_async  channels.db.database_sync_to_async를 통해서 동기 코드를 호출할 수 있다.

 

# chat/consumers.py (비동기)
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
        }))