Today I Learned/django

장고 asgi(비동기) 채팅기능 구현하기

하나719 2023. 9. 6. 17:19
반응형

ASGI (Asynchronous Server Gateway Interface) & WSGI (Web Server Gateway Interface)

웹 애플리케이션과 웹 서버간에 표준화된 통신 인터페이스를 제공하는 통신 프로토콜인데, ASGI는 비동기적인  프로토콜이며 WSGI는 동기적인 프로토콜입니다.

  • 동기적: 작업이 끝날때까지 요청을 처리하지 않고 대기해서 I/O 작업이 있는 애플리케이션에서 성능이 저하될 수 있음 -> 실시간 기능을 지원하기 어려움 
  • 비동기적: 작업이 끝날때까지 요청을 블로킹하지 않고 다른 작업을 처리하여 더 빠른 응답 시간과 더 높은 동시성 제공 -> 실시간 기능을 구현할 수 있음 , 따라서 채팅, 실시간 업데이트 , 실시간 게임과 같은 애플리케이션 개발에 좋음

장고에서 ASGI 사용해서 채팅 기능 구현하기

장고 3.0부터 ASGI를 공식적으로 지원해서 비동기처리 및 실시간 기능을 구현할 수 있게 됨

 

<구현 순서>

  • 필요한 패키지 설치 및 설정
  • WebSocket 컨슈머 작성
  • WebSocket 라우팅 설정
  • WebSocket 클라이언트 구현
  • Redis 사용해서 채널 레이어 활성화 

1. 사전 준비 

  • 프로젝트에 chat 앱 생성 & 설정에 추가해주기 
django-admin startapp chat
INSTALLED_APPS = [
	..
    'chat',
]
  • 채팅 화면 보여줄 화면과 Url 및 view 작성

1) chat> views.py

여기 예제에서 course_id는 구현해놓은 다른 앱에서 가져와서 사용했는데,

채팅창을 띄워줄 화면을 간단하게 구성하는 view,url, templates 한쌍을 만들어주세요. 

from django.contrib.auth.decorators import login_required
from django.http import HttpResponseForbidden
from django.shortcuts import render

# Create your views here.
@login_required
def course_chat_room(request, course_id):
    try:
        course = request.user.courses_joined.get(id=course_id)
    except:
        return HttpResponseForbidden()
    return render(request, 'chat/room.html', {'course':course})

2) chat > urls.py

course_id = 1 이면, 해당 코스의 채팅창이 열립니다. (코스별로 새로운 채팅창 열리도록 설정)

from django.urls import path
from . import views

app_name='chat'
urlpatterns =[
    path('room/<int:course_id>/', views.course_chat_room, name='course_chat_room'),
]

3) chat > templates > chat > room.html

{% extends 'base.html' %}
{% block title %} chat room for {{ course.title}} {% endblock %}

{% block content %}
    <div id="chat">
    </div>
    <div id="chat-input">
        <input type="text" id="chat-message-input">
        <input type="submit" value="send" id="chat-message-submit">
    </div>
{% endblock %}

{% block include_js %}
	// 추후 채울 예정
{% endblock %}

{% block domready %}
	// 추후 채울 예정 
{% endblock %}

[빈 채팅창 생성 완료 ]

 

 

2. 채팅서버 구축 준비 

*웹소켓이란

WebSockets는 서버와 클라이언트 간에 실시간 양방향 통신을 제공합니다. 

웹 애플리케이션과 웹 서버간의 지속적인 연결을 통해 데이터를 양방향으로 전송할 수 있게 해주는 기술입니다.

WebSocket은 전화상태처럼 끊어지지 않고 상대가 말할때까지 계속 열려있는 상태를 유지하여 서버 또는 클라이언트에서 언제든 데이터를 보낼 수 있습니다. 

 

특징

  • 실시간 통신
  • 낮은 오버헤드: HTTP와 비교하여 상대적으로 적은 오버헤드를 가지며, 작은 헤더 크기로 데이터를 전송할 수 있음
  • 지속적인 연결: 클라이언트가 서버에 요청을 보내면 서버가 응답을 보내고 연결을 끊는 HTTP와 달리 지속적으로 연결을 유지함 
  • 양방향 통신: 서버와 클라이언트간에 양방향으로 데이터를 보낼 수 있으므로, 클라이언트가 서버에 요청을 보내면 서버는 실시간으로 응답할 수 있음 
  • 일반적으로 ws:// 또는 wss:// (암호화된 연결) 으로 시작 

따라서 웹소켓을 사용해서 채팅서버를 구현해보겠습니다. 

 

1) channel 라이브러리 설치 및 셋팅

장고에서 비동기 및 실시간 기능 구현을 할 수 있도록 도와주는 라이브러리로 웹소켓 통신을 비롯한 다양한 비동기 작업을 처리할 수 있습니다. (channels와 함께 daphne 웹서버 설치)

  • daphne: 장고 애플리케이션을 위한 ASGI 웹서버로 웹소켓 지원함 
pip install -U 'channels[daphne]'
# Channels가 여기 추가되면, runserver 명령을 대체해서 표준 장고 개발 서버 대신 channels 개발 서버를 제공하며 
# HTTP 요청은 이전과 동일하게 처리되지만 Channels를 통해 라우팅 됨 
INSTALLED_APPS = [
    'daphne',
    'channels',
    ...
]

# Channels가 루트 라우팅 구성을 찾을 수 있도록 함 
ASGI_APPLICATION = 'educa.asgi.application'

2) 프로젝트 메인 폴더의 asgi.py 파일 편집

  • ProtocolTypeRouter 클래스를 사용하여 http, websocket 을 위한 프로토콜을 추가 
    • websocket에 정의된 URLRouter는 아래에서 생성할 예정 
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', 'educa.settings')

django_asgi_app = get_asgi_application()
application = ProtocolTypeRouter({
    'http':django_asgi_app,
    'websocket': AuthMiddlewareStack(
        URLRouter(chat.routing.websocket_urlpatterns)
    ),
})

3) 개발서버 실행해서 Channels 개발서버 사용중인지 확인하기

  • Starting ASGI/Daphne version development server at 주소 문장이 출력되면 channels 개발 서버 사용하는것 

3. 채팅 서버 구축 

구축 단계 별 역할

  • 컨슈머 설정: webSocket 연결을 처리하고 WebSocket을 통해 받은 메시지를 다시 전송 
  • 라우팅 구성: 채팅 컨슈머의 URL 라우팅 구성
  • 웹소켓 클라이언트 구현: 브라우저에서 WebSocket에 연결하고 JavaScript를 사용해서 메시지를 보내거나 받을 수 있도록 구현
  • 채널 레이어 활성화: 다른 인스턴스간에 통신할 수 있도록 구성 (Redis 사용) , 하나의 채팅창에 여러명이 메시지를 주고 받을 수 있도록 

 

1) 컨슈머 작성

컨슈머는 비동기 애플리케이션에서 장고 view의 역할을 함 (로직 처리)

  • AsyncWebSocketConsumer 클래스 상속(비동기소켓)
    • 함수 앞에 async 붙여서 비동기 함수임을 명시
    • 함수 안에 클래스의 함수 실행 앞에 await을 붙여서 비동기적으로 실행 

chat > consumers.py

import json

from asgiref.sync import async_to_sync
from channels.generic.websocket import AsyncWebsocketConsumer
from django.utils import timezone


class ChatConsumer(AsyncWebsocketConsumer):

	# 새 연결이 수신되었을 때 호출 됨 
    async def connect(self):

        #연결 수락
        await self.accept()

	# 소켓이 닫힐 때 호출 
    async def disconnect(self, close_code):
    	#추후 구현 
        pass

    # 클라이언트에서 데이터를 수신할 때 마다 호출됨 
    async def receive(self, text_data):
    	# 수신된 데이터를 json으로 처리 
        text_data_json = json.loads(text_data)
        
        # message 로 받아올 것을 미리 규약 
        message = text_data_json['message']
        
        # 메시지를 에코해서 다시 WebSocket으로 보내줌 
        await self.send(text_data=json.dumps({'message':message}))

여기서 receive 함수가 헷갈렸는데, 기존에 뷰에서는 서버에서 클라이언트에 응답을 주는 로직이었는데 여기서는 반대로 클라이언트에서 주는 데이터를 받아와서 다시 웹소켓에 전달하는 로직으로 받아올 때 로직을 미리 정해 둔 것 

2) 라우팅 구성

장고 앱의 urls.py 와 비슷함 

위에 작성한 ChatConsumer 클래스를 아래 라우팅 파일에서 정의한 URL 패턴과 매핑합니다.

-> 준비단계에서 작성한 asgi.py 파일에서 해당 코드줄에 사용됨 

 

 'websocket': AuthMiddlewareStack(URLRouter(chat.routing.websocket_urlpatterns)

 

chat > routing.py 

from django.urls import re_path
from . import consumers

websocket_urlpatterns = [
    re_path(r'ws/chat/room/(?P<course_id>\d+)/$',
            consumers.ChatConsumer.as_asgi()),
]

 

3) 클라이언트 웹소켓 작성

  • window.location.host : 브라우저의 현재 위치 
  • onmessage: 웹소켓을 통해 데이터가 수신될 때 발생
  • onclose: 웹소켓과의 연결이 닫힐 때 발생 
  • send: 웹소켓을 통해 json 콘텐츠 전송 

chat > room.html

{% block include_js %}
	{{ course.id|json_script:'course-id' }}
{% endblock %}

{% block domready %}
	const couseId = JSON.parse(
    	document.getElementById('course-id').textContent
    );
    
    // courseid 넣은 websocket url 구성
    const url = 'ws://' + window.location.host + 
    			'/ws/chat/room/' + courseId + '/';
                
    // 해당 url에 연결되는 웹소켓 생성             
    const chatSocket = new WebSocket(url);
    
    // 서버에서 웹소켓으로 메시지 받기
    chatSocket.onmessage = function(event){
    	const data = JSON.parse(event.data);
        const chat = document.getElementById('chat');
        // 화면에 메시지 출력하기 위해 html 요소 만들기
        chat.innerHTML += '<div class="message">' +
        				data.message + '</div>';
       
    };
    
    // 클라이언트에서 웹소켓으로 메시지 보내기
    const input = document.getElementById('chat-message-input');
    const submitButton =document.getElementById('chat-message-submit');
    submitButton.addEventListener('click', function(event) {
   		if(message) {
        	//send message in json format
            chatSocket.send(JSON.stringify({'message':message}));
			// 작성 창 지워주기
            input.value = '';
            input.focus();
            }
     });

    // 소켓 닫힐 때 콘솔로그 출력
    chatSocket.onclose = function(event){
    	console.error('chat socket closed');
    };
    

                
{% endblock %}

 

웹소켓이 잘 연결되었다면, 해당 url에 접속했을때 콘솔에 WebSocket 관련해서 두가지가 출력되어야함 

위 예제에서 테스트할 url: http://127.0.0.1:8000/chat/room/1/

4) 채널 레이어 활성화

웹소켓을 통해 클라이언트/서버간의 커뮤니케이션을 구현했는데, 다른 클라이언트와 메시지를 공유할 수 없다. 

해당 웹소켓에 다른 클라이언트가 접속해서 메시지를 공유할수있도록 하려면 채널 레이어를 활성화 해줘야한다. 

 

channels-redis 패키지 설치

pip install channels-redis

settings.py 설정

# redis 사용한 channel layer
CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels_redis.core.RedisChannelLayer',
        'CONFIG': {
            "hosts": [('127.0.0.1', 6379)],
        },
    },
}

도커 실행

docker run -it --rm --name redis -p 6379:6379 redis

5) 컨슈머 코드 업데이트

  • channel_layer.group_add: 코스 id로 그룹을 만들고 채널에 그룹 추가
  • channel_layer.group_send:  따라서 id=1 인 코스방에 들어오는 클라이언트는 채널그룹이 같고 같은 메시지를 공유함 
import json
from channels.generic.websocket import AsyncWebsocketConsumer
from django.utils import timezone

class ChatConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        # 채널 그룹 만들기
        self.user = self.scope['user']
        self.id = self.scope['url_route']['kwargs']['course_id']
        self.room_group_name = f'chat_{self.id}'

        # 현재 채널을 그룹에 추가 : group_add
        # ChatConsumer는 동기적인 웹소켓이고, 채널 레이어에서는 비동기 메서드 작업을 해주어야함
        await self.channel_layer.group_add(
            self.room_group_name,
            self.channel_name
        )

        #연결 수락
        await self.accept()

    async def disconnect(self, close_code):

        #그룹에서 나가기
        await self.channel_layer.group_discard(
            self.room_group_name,
            self.channel_name
        )


    #websocket에서 메시지 수신
    async def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json['message']
        now = timezone.now()
        # 그룹에 메시지 보내기
        await self.channel_layer.group_send(
            self.room_group_name,
            {
                # 해당 이벤트를 받는 컨슈머에서 실행되어야 할 메서드의 이름과 일치해야함
                # 컨슈머에 동일한 이름의 메서드를 구현하면 해당 이벤트 타입이 수신될때마다 실행됨
                'type':'chat_message',
                'message': message,
                'user':self.user.username,
                'datetime': now.isoformat(),
            }
        )


        #chat_message 함수에서 실행 
        #self.send(text_data=json.dumps({'message':message}))

    async def chat_message(self, event):
        # webSocket으로 메시지 보내기
        await self.send(text_data=json.dumps(event))
반응형