[Django] Django rest framework로 웹소켓 채팅 서버 구현하기(2)

이민재·2023년 11월 7일
2
post-thumbnail

저번 포스팅에 이어 장고로 채팅서비스를 구현하는것을 이어나가겠습니다.

📌asgi.py

settigs.py에 설정할 코드를 전부 작성 하였고 asgi로 동작한다는것도 저번 포스팅에서 이해 하였기때문에 asgi.py의 코드도 작성해주어야 합니다.

#asgi.py

import os
from django.core.asgi import get_asgi_application

# 환경변수 설정
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')

# Django ASGI 애플리케이션 초기화
django_asgi_app = get_asgi_application()

# channels 라우팅과 미들웨어는 Django 초기화 이후에 가져와야 합니다.
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.security.websocket import AllowedHostsOriginValidator
import chat.routing  # 이제 이 코드는 안전하게 실행될 수 있습니다.

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

이 코드에서 봤듯이 장고 channels는 원래 동기적인 wsgi를 계승하여 awgi를 만들어 웹소켓 통신을 구현하였기에 HTTP와 웹소켓 프로토콜 모두를 지원하기때문에 http 요청와 websocket요청을 분리하여 처리할수있는 장점이 있습니다.

이 코드에서 ProtocolTypeRouter는 들어오는 연결을 프로토콜 유형에 따라 처리하기 위해 사용됩니다. http는 Django의 ASGI application으로 보내져 일반적인 HTTP 요청을 처리하고, websocket은 웹소켓 연결을 처리합니다.

AuthMiddlewareStack은 Django의 인증 시스템을 웹소켓에 적용하여, 연결된 사용자가 인증되었는지 확인할 수 있게 해줍니다. 이를 통해 로그인한 사용자만 웹소켓을 통해 통신할 수 있게 제한할 수 있습니다.

AllowedHostsOriginValidator는 Django의 ALLOWED_HOSTS 설정을 사용하여 웹소켓 요청의 출처를 검증합니다. 이를 통해 허용된 호스트에서만 웹소켓 연결이 가능하게 됩니다.

마지막으로, URLRouter는 URL 패턴에 따라 웹소켓 요청을 라우팅합니다. 이를 통해 특정 URL 패턴에 따라 다른 핸들러를 실행할 수 있습니다.

따라서 이 코드는 HTTP 요청과 웹소켓 요청을 분리하고, 웹소켓 요청에 대해 인증 및 출처 검증, URL 라우팅을 수행하는 역할을 합니다. 이렇게 함으로써 웹소켓 통신을 안전하고 효율적으로 처리할 수 있습니다.

이제 urls.py에 chat 앱을 연결시켜주고 chat앱의 코드를 작성해보겠습니다.

#config/urls.py

from django.contrib import admin
from django.urls import path, include
from django.conf.urls.static import static
from chat.views import health
from django.conf import settings

urlpatterns = [
    path('admin/', admin.site.urls),
    path('chat/', include('chat.urls')),  # chat 앱의 urls 포함
]

urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

chat앱에는 이제 http와 websocket요청을 처리하는 로직을 구현해야합니다.

먼저 우리가 통상적으로는 http요청을 drf로 구현할때 serializers.py와 views.py를 필요로 합니다. 그러나 websocket요청을 처리하는 로직은 consumers.py와 routing.py에서 구현을 하게됩니다.

http와 websocket요청을 분리하였기에 처리하는로직도 당연히 다릅니다.

📌models.py

이제 시나리오를 가정하고 모델화를 하고 로직을 짜보겠습니다
여러 상점 주인과 여러 방문객이 있고 채팅서비스도 포함되어있는 어플리케이션이 있습니다. 1대1 상담이 목적이고 하나의 상점 이메일로 로그인한 유저는 여러 방문객의 이메일로 로그인한 유저와 1대1 채팅할수있고 그 반대도 가능해야합니다.

그렇기에 모델화를 아래처럼 구현하였습니다

# models.py
from django.db import models

class ShopUser(models.Model):
    shop_user_email = models.EmailField(unique=True)

class VisitorUser(models.Model):
    visitor_user_email = models.EmailField(unique=True)

class ChatRoom(models.Model):
    shop_user = models.ForeignKey(ShopUser, on_delete=models.CASCADE)
    visitor_user = models.ForeignKey(VisitorUser, on_delete=models.CASCADE)
    timestamp = models.DateTimeField(auto_now_add=True)
    class Meta:
        unique_together = ('shop_user', 'visitor_user')
        
class Message(models.Model):
    room = models.ForeignKey(ChatRoom, on_delete=models.CASCADE, related_name="messages")
    sender_email = models.EmailField()
    text = models.TextField()
    timestamp = models.DateTimeField(auto_now_add=True)

'ShopUser''VisitorUser' 모델은 각각 고유의 이메일 필드를 가지고 있습니다. 이는 각 유저가 고유의 이메일을 가지고 있음을 의미합니다.
'ChatRoom' 모델은 'ShopUser''VisitorUser'외래 키로 가지고 있습니다. 이는 각 채팅방이 특정 'ShopUser'와 'VisitorUser'에 속함을 의미합니다. 이 관계는 일대다 관계입니다. 왜냐하면 한 'ShopUser' 또는 'VisitorUser'가 여러 'ChatRoom'을 가질 수 있기 때문입니다.
'ChatRoom'의 메타 클래스에서 'shop_user'와 'visitor_user'를 함께 unique로 설정하였습니다. 이는 각 채팅방이 특정 'ShopUser'와 'VisitorUser'의 조합으로 유일해야 함을 의미합니다.
'Message' 모델은 'ChatRoom'을 외래 키로 가지고 있습니다. 이는 각 메세지가 특정 'ChatRoom'에 속함을 의미합니다. 이 관계도 일대다 관계입니다. 왜냐하면 한 'ChatRoom'이 여러 'Message'를 가질 수 있기 때문입니다.
따라서 이 코드는 'ShopUser'와 'VisitorUser' 간에 다대다 관계를 생성하고, 'ChatRoom'과 'Message' 간에 일대다 관계를 생성합니다.

요약 하자면 각 'ShopUser'와 'VisitorUser'가 여러 'ChatRoom'을 가질 수 있고, 각 'ChatRoom'이 여러 'Message'를 가질 수 있게 됩니다.

📌consumers.py

http의 요청을 views.py에서 처리하는것처럼 websocket요청은 consumers.py에서 처리한다고 이야기 했었습니다. 아래는 websocket요청에대한 Django channels를 사용하여 구현된 비동기 웹소켓 채팅 소비자 클래스를 구현한 consumers.py의 코드입니다.

# consumers.py

from channels.generic.websocket import AsyncJsonWebsocketConsumer
from channels.db import database_sync_to_async
from .models import ChatRoom, Message

class ChatConsumer(AsyncJsonWebsocketConsumer):

    async def connect(self):
        try:          
            self.room_id = self.scope['url_route']['kwargs']['room_id'] # URL 경로에서 방 ID를 추출합니다.

            if not await self.check_room_exists(self.room_id): # 방이 존재하는지 확인합니다.
                raise ValueError('채팅방이 존재하지 않습니다.')
                        
            group_name = self.get_group_name(self.room_id) # 방 ID를 사용하여 그룹 이름을 얻습니다.

            await self.channel_layer.group_add(group_name, self.channel_name) # 현재 채널을 그룹에 추가합니다.                       
            await self.accept()# WebSocket 연결을 수락합니다.

        except ValueError as e: # 값 오류가 있을 경우 (예: 방이 존재하지 않음), 오류 메시지를 보내고 연결을 종료합니다.           
            await self.send_json({'error': str(e)})
            await self.close()

    async def disconnect(self, close_code):
        try:            
            group_name = self.get_group_name(self.room_id) # 방 ID를 사용하여 그룹 이름을 얻습니다.
            await self.channel_layer.group_discard(group_name, self.channel_name) # 현재 채널을 그룹에서 제거합니다.

        except Exception as e: # 일반 예외를 처리합니다 (예: 오류 기록).         
            pass

    async def receive_json(self, content):
        try:
            # 수신된 JSON에서 필요한 정보를 추출합니다.
            message = content['message']
            sender_email = content['sender_email']
            shop_user_email = content.get('shop_user_email')
            visitor_user_email = content.get('visitor_user_email')

            # 두 이메일이 모두 제공되었는지 확인합니다.
            if not shop_user_email or not visitor_user_email:
                raise ValueError("상점 및 방문자 이메일이 모두 필요합니다.")

            # 제공된 이메일을 사용하여 방을 가져오거나 생성합니다.
            room = await self.get_or_create_room(shop_user_email, visitor_user_email)
            
            # room_id 속성을 업데이트합니다.
            self.room_id = str(room.id)
            
            # 그룹 이름을 가져옵니다.
            group_name = self.get_group_name(self.room_id)
            
            # 수신된 메시지를 데이터베이스에 저장합니다.
            await self.save_message(room, sender_email, message)

            # 메시지를 전체 그룹에 전송합니다.
            await self.channel_layer.group_send(group_name, {
                'type': 'chat_message',
                'message': message,
                'sender_email': sender_email  # 발신자 이메일 정보 추가
            })

        except ValueError as e:
            # 값 오류가 있을 경우, 오류 메시지를 전송합니다.
            await self.send_json({'error': str(e)})

    async def chat_message(self, event):
        try:
            # 이벤트에서 메시지와 발신자 이메일을 추출합니다.
            message = event['message']
            sender_email = event['sender_email']  # 발신자 이메일 정보 추출
            
            # 추출된 메시지와 발신자 이메일을 JSON으로 전송합니다.
            await self.send_json({'message': message, 'sender_email': sender_email})
        except Exception as e:
            # 일반 예외를 처리하여 오류 메시지를 보냅니다.
            await self.send_json({'error': '메시지 전송 실패'})

    @staticmethod
    def get_group_name(room_id):
        # 방 ID를 사용하여 고유한 그룹 이름을 구성합니다.
        return f"chat_room_{room_id}"
        
	@database_sync_to_async
	def get_or_create_room(self, shop_user_email, visitor_user_email):
    	shop_user, _ = ShopUser.objects.get_or_create(shop_user_email=shop_user_email)
    	visitor_user, _ = VisitorUser.objects.get_or_create(visitor_user_email=visitor_user_email)

    	room, created = ChatRoom.objects.get_or_create(
        	shop_user=shop_user,
        	visitor_user=visitor_user
    	)
    	return room

    @database_sync_to_async
    def save_message(self, room, sender_email, message_text):
        # 발신자 이메일과 메시지 텍스트가 제공되었는지 확인합니다.
        if not sender_email or not message_text:
            raise ValueError("발신자 이메일 및 메시지 텍스트가 필요합니다.")
        
        # 메시지를 생성하고 데이터베이스에 저장합니다.
        # timestamp 필드는 auto_now_add=True 속성 때문에 자동으로 현재 시간이 저장됩니다.
        Message.objects.create(room=room, sender_email=sender_email, text=message_text)

    @database_sync_to_async
    def check_room_exists(self, room_id):
        # 주어진 ID로 채팅방이 존재하는지 확인합니다.
        return ChatRoom.objects.filter(id=room_id).exists()
  1. connect(self): 클라이언트가 웹소켓에 연결하려고 할 때 호출됩니다. URL 경로에서 방 ID를 추출하고, 해당 방이 실제로 존재하는지 확인한 후, 해당 그룹에 현재 채널을 추가합니다. 연결이 성공하면 이를 클라이언트에 알리기 위해 연결을 수락합니다.

  2. disconnect(self, close_code): 클라이언트가 웹소켓 연결을 종료할 때 호출됩니다. 해당 그룹에서 현재 채널을 제거합니다.

  3. receive_json(self, content): 클라이언트로부터 JSON 메시지를 받았을 때 호출됩니다. 수신한 메시지를 데이터베이스에 저장하고, 동일한 그룹 내의 모든 클라이언트에 메시지를 전송합니다.

  4. chat_message(self, event): 그룹 내의 다른 클라이언트로부터 메시지를 받았을 때 호출됩니다. 받은 메시지를 현재 채널(클라이언트)에 전송합니다.

  5. get_group_name(room_id): 주어진 방 ID를 이용하여 그룹 이름을 생성합니다.

  6. get_or_create_room(self, shop_user_email, visitor_user_email): 주어진 이메일을 이용하여 채팅방을 가져오거나 존재하지 않을 경우 생성합니다.

  7. save_message(self, room, sender_email, message_text): 주어진 정보를 이용하여 새 메시지를 생성하고 데이터베이스에 저장합니다.

  8. check_room_exists(self, room_id): 주어진 ID로 채팅방이 존재하는지 확인합니다.

📌AsyncJsonWebsocketConsumer

이 클래스에서 상속받은 AsyncJsonWebsocketConsumer는 Django Channels 라이브러리의 일부로, JSON 메시지를 사용하여 웹소켓을 통해 비동기 통신을 수행하는 데 사용되는 클래스입니다. 이 클래스는 AsyncWebsocketConsumer를 상속합니다.

AsyncJsonWebsocketConsumer는 웹소켓을 통해 전달되는 메시지를 자동으로 JSON으로 직렬화하고 역직렬화합니다. JSON은 웹에서 데이터를 교환하는 데 널리 사용되는 텍스트 기반의 데이터 형식입니다.

다음은 AsyncJsonWebsocketConsumer를 사용할 때 직렬화와 역직렬화가 어떻게 이루어지는지에 대한 설명입니다.

  1. 직렬화: AsyncJsonWebsocketConsumer 클래스는 Python의 내장 json 모듈을 사용하여 데이터를 JSON 형식으로 직렬화합니다. 이는 self.send_json(content) 메소드를 호출하여 이루어집니다. content는 직렬화할 Python 객체입니다.

  2. 역직렬화: AsyncJsonWebsocketConsumer 클래스는 웹소켓을 통해 수신된 메시지를 자동으로 JSON 형식에서 Python 객체로 역직렬화합니다. 이는 receive_json(self, content) 메소드를 통해 이루어집니다. content는 역직렬화된 Python 객체입니다.

따라서 AsyncJsonWebsocketConsumer를 사용하면, 웹소켓을 통한 메시지 전송 시 JSON 형식의 직렬화와 역직렬화 과정을 이미 내장되어있는 함수들을 이용해 수월하게 처리할 수 있습니다.

📌@database_sync_to_async

데이터베이스에 저장하는 로직을 더 자세히 설명하겠습니다.
Django의 데이터베이스 연산은 기본적으로 동기적입니다. 즉, 데이터베이스 연산이 완료될 때까지 실행 흐름이 막히고 다른 작업을 진행할 수 없습니다.

하지만 Django Channels는 웹소켓과 같은 비동기 프로토콜을 처리하기 위해 비동기 코드를 사용합니다. 이러한 비동기 코드에서 동기적인 데이터베이스 연산을 직접 수행하면, 해당 연산이 완료될 때까지 전체 비동기 실행 흐름이 막히게 되어 성능 저하를 초래하게 됩니다.

따라서 Django Channels에서는 channels.db.database_sync_to_async 데코레이터를 제공합니다. 이 데코레이터는 동기적인 데이터베이스 연산을 비동기적으로 실행할 수 있게 해주는 역할을 합니다. 이렇게 하면 동기적인 데이터베이스 연산이 비동기 실행 흐름을 막는 것을 방지할 수 있습니다.

위의 코드에서 save_message 함수는 @database_sync_to_async 데코레이터로 감싸져 있습니다. 이 함수는 동기적인 데이터베이스 연산인 Message.objects.create(...)를 수행합니다. 이 함수가 호출되면, 이 연산은 별도의 스레드에서 비동기적으로 실행되며, 연산이 완료될 때까지 비동기 실행 흐름이 막히지 않게 됩니다.

즉, @database_sync_to_async 데코레이터를 사용하면, 비동기 코드에서 동기적인 데이터베이스 연산을 수행하면서도 성능 저하 없이 동시성을 유지할 수 있게 됩니다.

📌routing.py

이제 routing.py 파일에 아래의 코드를 작성하여 우리가 전에 asgi.py에 작성했던
URLRouter(
chat.routing.websocket_urlpatterns
)
이부분을 적어 websocket요청을 보낼 url경로를 생성 및 지정합니다.

from django.urls import path

from chat import consumers

websocket_urlpatterns = [
    path("ws/room/<int:room_id>/messages", consumers.ChatConsumer.as_asgi()),
]

오늘 포스팅은 웹소켓 연결에 대한 로직을 구현 및 설명 하였습니다.
다음 포스팅에서 http요청이 왔을때의 처리 로직을 구현하도록 하겠습니다.

2개의 댓글

comment-user-thumbnail
2024년 2월 16일

안녕하세요~

get_or_create_room메소드에서 get_or_created가 아닌 get을 사용하는것으로 확인되는데 create는 어떻게 트리거 되는건지 알 수 있을까요?

1개의 답글