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

이민재·2023년 11월 8일
3
post-thumbnail

저번 포스팅에 이어 http 요청에 대한 처리로직을 구현해 보겠습니다.

📌views.py

Django REST Framework를 이용해 채팅 API를 CBV 방식으로 구현한 코드입니다.

# views.py

from rest_framework import generics, serializers, status
from rest_framework.response import Response
from .models import ChatRoom, Message, ShopUser, VisitorUser
from .serializers import ChatRoomSerializer, MessageSerializer
from rest_framework.exceptions import ValidationError
from django.http import Http404
from django.http import JsonResponse
from django.conf import settings


# 사용자 정의 예외 클래스, 예외 발생 시 즉각적인 HTTP 응답을 위해 사용됩니다.
class ImmediateResponseException(Exception):
    # 예외 인스턴스를 생성할 때 HTTP 응답 객체를 받습니다.
    def __init__(self, response):
        self.response = response

# 채팅방 목록 조회 및 생성을 위한 뷰 클래스로, DRF의 generics.ListCreateAPIView를 상속받습니다.
class ChatRoomListCreateView(generics.ListCreateAPIView):
    # 이 뷰에서 사용할 시리얼라이저를 지정합니다.
    serializer_class = ChatRoomSerializer

    # GET 요청에 대한 쿼리셋을 정의하는 메소드입니다.
    def get_queryset(self):
        try:
            # 요청의 쿼리 파라미터에서 'email' 값을 가져옵니다. 없다면 None을 반환합니다.
            user_email = self.request.query_params.get('email', None)

            # 이메일 파라미터가 없으면 ValidationError 예외를 발생시킵니다.
            if not user_email:
                raise ValidationError('Email 파라미터가 필요합니다.')

            # 채팅방 객체를 필터링하여, 해당 이메일을 가진 사용자가 속한 채팅방을 찾습니다.
            return ChatRoom.objects.filter(
                shop_user__shop_user_email=user_email
            ) | ChatRoom.objects.filter(
                visitor_user__visitor_user_email=user_email
            )
        except ValidationError as e:
            # ValidationError 발생 시, 상태 코드 400과 함께 에러 상세 정보를 반환합니다.
            content = {'detail': e.detail}
            return Response(content, status=status.HTTP_400_BAD_REQUEST)
        except Exception as e:
            # 다른 종류의 예외 발생 시, 상태 코드 400과 함께 에러 상세 정보를 반환합니다.
            # 여기에서 예외 정보를 로깅할 수 있습니다.
            content = {'detail': str(e)}
            return Response(content, status=status.HTTP_400_BAD_REQUEST)

    # 시리얼라이저의 컨텍스트를 설정하는 메소드입니다.
    def get_serializer_context(self):
        # 부모 클래스의 컨텍스트 가져오기 메소드를 호출합니다.
        context = super(ChatRoomListCreateView, self).get_serializer_context()
        # 컨텍스트에 현재의 요청 객체를 추가합니다.
        context['request'] = self.request
        return context

    # POST 요청을 처리하여 새로운 리소스를 생성하는 메소드입니다.
    def create(self, request, *args, **kwargs):
        # 요청 데이터로부터 시리얼라이저를 생성합니다.
        serializer = self.get_serializer(data=request.data)
        # 시리얼라이저의 유효성 검사를 수행합니다. 유효하지 않을 경우 예외가 발생합니다.
        serializer.is_valid(raise_exception=True)
        try:
            # 시리얼라이저를 통해 데이터 저장을 수행합니다.
            self.perform_create(serializer)
        except ImmediateResponseException as e:
            # 즉각적인 응답이 필요할 경우 예외를 통해 응답을 반환합니다.
            return e.response
        # 성공 헤더를 생성합니다.
        headers = self.get_success_headers(serializer.data)
        # 상태 코드 201를 반환하며 새로 생성된 데이터를 응답합니다.
        return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)

    # 시리얼라이저를 통해 데이터베이스에 객체를 저장하는 메소드입니다.
    def perform_create(self, serializer):
        # 요청 데이터에서 shop_user_email과 visitor_user_email을 가져옵니다.
        shop_user_email = self.request.data.get('shop_user_email')
        visitor_user_email = self.request.data.get('visitor_user_email')

        # 해당 이메일로 ShopUser와 VisitorUser를 가져오거나 없으면 생성합니다.
        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)
        
        # 동일한 shop_user_email과 visitor_user_email을 가진 채팅방이 이미 있는지 확인합니다.
        existing_chatroom = ChatRoom.objects.filter(shop_user__shop_user_email=shop_user_email, visitor_user__visitor_user_email=visitor_user_email).first()

        # 이미 존재하는 채팅방이 있다면 해당 채팅방의 정보를 시리얼라이즈하여 응답합니다.
        if existing_chatroom:
            serializer = ChatRoomSerializer(existing_chatroom, context={'request': self.request})
            raise ImmediateResponseException(Response(serializer.data, status=status.HTTP_200_OK))

        # 새 채팅방 객체를 저장합니다.
        serializer.save(shop_user=shop_user, visitor_user=visitor_user)

# 메시지 목록을 조회하는 뷰 클래스로, DRF의 generics.ListAPIView를 상속받습니다.
class MessageListView(generics.ListAPIView):
    # 이 뷰에서 사용할 시리얼라이저를 지정합니다.
    serializer_class = MessageSerializer

    # GET 요청에 대한 쿼리셋을 정의하는 메소드입니다.
    def get_queryset(self):
        # URL 파라미터에서 'room_id' 값을 가져옵니다.
        room_id = self.kwargs.get('room_id')
        
        # room_id가 제공되지 않았을 경우 에러 메시지와 함께 400 상태 코드 응답을 반환합니다.
        if not room_id:
            content = {'detail': 'room_id 파라미터가 필요합니다.'}
            return Response(content, status=status.HTTP_400_BAD_REQUEST)

        # room_id에 해당하는 메시지 객체들을 쿼리셋으로 가져옵니다.
        queryset = Message.objects.filter(room_id=room_id)
        
        # 해당 room_id의 메시지가 존재하지 않을 경우 404 Not Found 예외를 발생시킵니다.
        if not queryset.exists():
            raise Http404('해당 room_id로 메시지를 찾을 수 없습니다.')

        # 쿼리셋을 반환합니다.
        return queryset

📌ImmediateResponseException` 클래스:

사용자 정의 예외 클래스로, 예외가 발생할 경우 즉시 HTTP 응답을 반환하기 위해 사용됩니다.

📌ChatRoomListCreateView 클래스:

채팅방 목록을 조회하거나 새로운 채팅방을 생성하는 API 뷰를 정의하는 클래스입니다. generics.ListCreateAPIView를 상속받습니다.

  • get_queryset: GET 요청에 대한 쿼리셋을 정의하는 메소드입니다. 요청의 쿼리 파라미터에서 'email' 값을 가져와 해당 이메일을 가진 사용자가 속한 채팅방을 반환합니다.
  • get_serializer_context: 시리얼라이저의 컨텍스트를 설정하는 메소드입니다. 컨텍스트에 현재의 요청 객체를 추가합니다.
  • create: POST 요청을 처리하여 새로운 리소스를 생성하는 메소드입니다. 요청 데이터로부터 시리얼라이저를 생성하고, 유효성 검사 후 데이터 저장을 수행합니다.
  • perform_create: 시리얼라이저를 통해 데이터베이스에 객체를 저장하는 메소드입니다. 요청 데이터에서 shop_user_email과 visitor_user_email을 가져와서 새 채팅방 객체를 저장합니다.

📌MessageListView 클래스:

메시지 목록을 조회하는 API 뷰를 정의하는 클래스입니다. generics.ListAPIView를 상속받습니다.

  • get_queryset: GET 요청에 대한 쿼리셋을 정의하는 메소드입니다. URL 파라미터에서 'room_id' 값을 가져와 해당 'room_id'에 해당하는 메시지 객체들을 쿼리셋으로 반환합니다.

📌serializers.py

# serializers.py

# rest_framework의 serializers 모듈을 임포트합니다.
from rest_framework import serializers
# 현재 디렉토리의 models 모듈에서 ChatRoom, Message 모델을 임포트합니다.
from .models import ChatRoom, Message

# Message 모델에 대한 시리얼라이저 클래스입니다.
class MessageSerializer(serializers.ModelSerializer):
    class Meta:
        model = Message  # Message 모델을 기반으로 합니다.
        fields = "__all__"  # 모든 필드를 포함시킵니다.

# ChatRoom 모델에 대한 시리얼라이저 클래스입니다.
class ChatRoomSerializer(serializers.ModelSerializer):
    latest_message = serializers.SerializerMethodField()  # 최신 메시지 필드를 동적으로 가져옵니다.
    opponent_email = serializers.SerializerMethodField()  # 상대방 이메일 필드를 동적으로 가져옵니다.
    shop_user_email = serializers.SerializerMethodField()  # 상점 사용자의 이메일을 가져오는 필드입니다.
    visitor_user_email = serializers.SerializerMethodField()  # 방문자 사용자의 이메일을 가져오는 필드입니다.
    messages = MessageSerializer(many=True, read_only=True, source="messages.all")  # 해당 채팅방의 메시지 목록을 가져옵니다.

    class Meta:
        model = ChatRoom  # ChatRoom 모델을 기반으로 합니다.
        # 시리얼라이즈할 필드들을 지정합니다.
        fields = ('id', 'shop_user_email', 'visitor_user_email', 'latest_message', 'opponent_email', 'messages')

    # 최신 메시지를 가져오는 메소드입니다.
    def get_latest_message(self, obj):
        latest_msg = Message.objects.filter(room=obj).order_by('-timestamp').first()  # 최신 메시지를 조회합니다.
        if latest_msg:
            return latest_msg.text  # 최신 메시지의 내용을 반환합니다.
        return None  # 메시지가 없다면 None을 반환합니다.

    # 요청 사용자와 대화하는 상대방의 이메일을 가져오는 메소드입니다.
    def get_opponent_email(self, obj):
        request_user_email = self.context['request'].query_params.get('email', None)
        # 요청한 사용자가 상점 사용자일 경우, 방문자의 이메일을 반환합니다.
        if request_user_email == obj.shop_user.shop_user_email:
            return obj.visitor_user.visitor_user_email
        else:  # 그렇지 않다면, 상점 사용자의 이메일을 반환합니다.
            return obj.shop_user.shop_user_email

    # shop_user의 이메일을 반환하는 메소드입니다.
    def get_shop_user_email(self, obj):  
        return obj.shop_user.shop_user_email

    # visitor_user의 이메일을 반환하는 메소드입니다.
    def get_visitor_user_email(self, obj):  
        return obj.visitor_user.visitor_user_email

📌MessageSerialize 클래스:

MessageSerializer는 Message 모델에 대한 시리얼라이저 클래스로, 모델 인스턴스를 JSON 형태로 변환하거나 JSON 데이터를 모델 인스턴스로 변환하는 역할을 합니다. Meta 내부 클래스에서는 Message 모델을 지정하고, fields에 "all"을 사용하여 모든 모델 필드를 시리얼라이징에 포함시킵니다.

📌ChatRoomSerializer는 클래스:

ChatRoomSerializer는 ChatRoom 모델에 대한 시리얼라이저로, 몇 가지 추가적인 필드를 정의합니다. SerializerMethodField는 특정 메소드를 호출하여 필드 값을 동적으로 생성합니다. latest_message, opponent_email, shop_user_email, visitor_user_email 필드는 이 방식으로 값을 설정합니다. 또한, messages 필드는 MessageSerializer를 사용하여 ChatRoom 모델에 연결된 모든 메시지를 시리얼라이즈합니다. ChatRoomSerializer의 Meta 내부 클래스에서는 ChatRoom 모델을 지정하고, 시리얼라이즈할 필드들을 리스트로 지정합니다.

  • get_latest_message 메소드는 해당 ChatRoom 인스턴스의 최신 Message를 찾아 그 내용을 반환합니다.

  • get_opponent_email, get_shop_user_email, get_visitor_user_email 메소드는 각각 상대방의 이메일, 상점 사용자의 이메일, 방문자 사용자의 이메일을 반환합니다.

    이 시리얼라이저들은 DRF에서 API를 통해 데이터를 전송할 때 사용됩니다. 예를 들어, 클라이언트에게 채팅방 목록을 JSON 형태로 제공하거나, 클라이언트가 보낸 메시지 데이터를 Message 인스턴스로 변환할 때 사용됩니다.

urls.py에 각요청에대한 경로를 지정해줍니다.

📌urls.py

#urls.py

from django.urls import path
from . import views


urlpatterns = [
    path('rooms/', views.ChatRoomListCreateView.as_view(), name='chat_rooms'),
    path('<int:room_id>/messages', views.MessageListView.as_view(), name='chat_messages'),
]

지금까지 채팅서비스를 구현하기위한 장고 백엔드를 DRF로 구현을 해보았습니다. 실제 동작이 되는지 확인을 해봐야하기 때문에 간단한 프론트를 작성하여 시험을 해보겠습니다. DRF만을 다루는 포스팅이기때문에 프론트에대한설명은 생략하겠습니다. 프론트는 html, css, js로 작성하였습니다.

아래는 프론트코드입니다

📌프론트

#index.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat Application</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="chat-container">
    <!-- 로그인 버튼 -->
    <button onclick="loginAsUser('qw@naver.com')">User 1 (qw@naver.com)</button>
    <button onclick="loginAsUser('er@naver.com')">User 2 (er@naver.com)</button>
    
    <!-- 채팅방 메시지 -->
    <div id="chat-messages"></div>
    
    <!-- 메시지 입력 및 전송 -->
    <input type="text" id="message-input" placeholder="메시지를 입력하세요">
    <button id="send-btn">보내기</button>
</div>
<script src="script.js"></script>
</body>
</html>

#styles.css

/* 전체 채팅 컨테이너 스타일 */
#chat-container {
    margin: 0 auto;
    width: 80%;
    max-width: 600px; /* 채팅방의 최대 너비 */
    border: 1px solid #ddd;
    padding: 20px;
    background: #f9f9f9;
    box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}

/* 채팅 메시지 리스트 스타일 */
#chat-messages {
    height: 500px; /* 채팅 메시지 영역의 높이 */
    overflow-y: auto;
    border: 1px solid #ccc;
    margin-bottom: 10px;
    padding: 10px;
    background: #fff;
}

/* 메시지 입력 영역 스타일 */
#message-input {
    width: calc(100% - 90px); /* 전송 버튼 너비를 고려하여 조정 */
    padding: 10px;
    border: 1px solid #ccc;
    margin-right: 10px; /* 버튼과의 간격 */
}

/* 전송 버튼 스타일 */
#send-btn {
    width: 80px;
    padding: 10px;
    background: #5cb85c;
    color: white;
    border: none;
}

/* 버튼 스타일 */
button {
    cursor: pointer;
    padding: 10px;
    border: none;
    background: #337ab7;
    color: white;
}

/* 메시지 말풍선 스타일 */
.message-bubble {
    padding: 10px 15px;
    border-radius: 20px;
    margin-bottom: 10px;
    display: inline-block;
    max-width: 70%;
}

/* 발신자 메시지 스타일 */
.message-bubble.sent {
    background: #e7f3ff;
    align-self: flex-end;
}

/* 수신자 메시지 스타일 */
.message-bubble.received {
    background: #f0f0f0;
    align-self: flex-start;
}

/* 메시지가 추가될 때의 정렬 */
#chat-messages {
    display: flex;
    flex-direction: column;
}

/* 로그인 버튼 스타일 */
.login-btn {
    padding: 10px 15px;
    margin-bottom: 20px;
}

/* 적용할 클래스를 자바스크립트에서 메시지 요소에 추가해야 합니다. 
예를 들어, 발신 메시지에는 'sent', 수신 메시지에는 'received' 클래스를 추가합니다. */

#scripts.js

let currentUserEmail = null;
let currentRoomId = null;
let socket = null;
let visitorUserEmail = null; 
let sortedEmails = null; 

document.addEventListener('DOMContentLoaded', () => {
    const chatMessages = document.getElementById('chat-messages');
    const messageInput = document.getElementById('message-input');
    const sendBtn = document.getElementById('send-btn');

    messageInput.addEventListener('keyup', (event) => {
        if (event.key === 'Enter' && !event.shiftKey) {
            sendMessage(currentUserEmail);
        }
    });

    window.loginAsUser = async (email) => {
        currentUserEmail = email;
        visitorUserEmail = email === "qw@naver.com" ? "er@naver.com" : "qw@naver.com";
        await openOrCreateRoom();
    };

    async function openOrCreateRoom() {
        if (socket) {
            socket.close();
        }

        sortedEmails = [currentUserEmail, visitorUserEmail].sort();

        const response = await fetch('http://127.0.0.1:8000/chat/rooms/', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({
                shop_user_email: sortedEmails[0],
                visitor_user_email: sortedEmails[1]
            })
        });

        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }

        const roomData = await response.json();
        currentRoomId = roomData.id;
        displayMessages(roomData.messages);
        setupWebSocket(currentRoomId);

        sendBtn.onclick = () => sendMessage(currentUserEmail);
    }

    function displayMessages(messages) {
        chatMessages.innerHTML = '';
        messages.forEach((message) => {
            if (message.sender_email && message.text) {
                const messageElem = document.createElement('div');
                messageElem.classList.add('message-bubble');
                messageElem.textContent = `${message.sender_email}: ${message.text}`;

                if (message.sender_email === currentUserEmail) {
                    messageElem.classList.add('sent');
                } else {
                    messageElem.classList.add('received');
                }

                chatMessages.appendChild(messageElem);
            }
        });
        chatMessages.scrollTop = chatMessages.scrollHeight; 
    }

    function setupWebSocket(roomId) {
        socket = new WebSocket(`ws://127.0.0.1:8000/ws/room/${roomId}/messages`);

        socket.onmessage = (event) => {
            const data = JSON.parse(event.data);
            const messageElem = document.createElement('div');
            messageElem.classList.add('message-bubble');
            messageElem.textContent = `${data.sender_email}: ${data.message}`;

            if (data.sender_email === currentUserEmail) {
                messageElem.classList.add('sent');
            } else {
                messageElem.classList.add('received');
            }

            chatMessages.appendChild(messageElem);
            chatMessages.scrollTop = chatMessages.scrollHeight; 
        };
    }

    function sendMessage(userEmail) {
        const message = messageInput.value;
        if (message) {
            const messagePayload = {
                'sender_email': userEmail,
                'message': message,
                'shop_user_email': sortedEmails[0],
                'visitor_user_email': sortedEmails[1]
            };

            socket.send(JSON.stringify(messagePayload));
            messageInput.value = ''; 
        }
    }
});

위코드는 로그인을 한가정을 하는 2개의 user1, user2 버튼이 있고 각 버튼을 누르면 로그인을 한 상황이 됩니다. 그리고 왼쪽페이지에는 user1 오른쪽페이지는 user2을 누르면 프론트에서 웹소켓 요청을 보내고 이 요청을 장고에서 받아 레디스에서 그룹을 만들어주고 각 user 에게 채널을 주어 같은 그룹에 있는 user1과 user2가 서로 메시지를 주고받는 상황을 만들수 있어 1대1 채팅서비스를 구현할 수 있는 것 입니다.

여기까지 모든 채팅서비스의 구현과정을 포스팅해보았습니다.
감사합니다~

0개의 댓글