개발일기

[channels] 실시간 단체채팅방 구현하기 (3) 본문

Project Portfolio

[channels] 실시간 단체채팅방 구현하기 (3)

츄98 2023. 6. 23. 03:38

오늘은 models, views에 대해 살펴보고, 프론트에서 어떻게 채팅 통신이 이루어지는지까지 살펴보겠다!

 

# models.py
from django.db import models
from django.contrib.auth.hashers import make_password
from config.models import CommonModel
from users.models import User


class ChatRoom(CommonModel):
    author = models.ForeignKey(User, on_delete=models.CASCADE)
    name = models.CharField(unique=True, max_length=10, blank=False, null=False)
    desc = models.CharField(max_length=50, blank=False)
    # 비밀 채팅방 기능을 위해 추가함
    password = models.CharField(max_length=128, null=True, blank=True)

    class Meta:
        ordering = ['-created_at']

    def __str__(self):
        return self.name
    
    def save(self, *args, **kwargs):
        if self.password:
            self.password = make_password(self.password)
        super(ChatRoom, self).save(*args, **kwargs)


class RoomMessage(models.Model):
    author = models.ForeignKey(User, on_delete=models.CASCADE)
    room = models.ForeignKey(ChatRoom, on_delete=models.CASCADE)
    content = models.TextField(max_length=1000, blank=False)
    is_read = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)


class RoomChatParticipant(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    room = models.ForeignKey(ChatRoom, on_delete=models.CASCADE)

super(ChatRoom, self).save(*args, **kwargs)

  • super()는 파이썬에서 부모 클래스의 메소드나 속성을 직접 참조하기 위해 사용하는 내장 함수 
  • super(ChatRoom, self)는 ChatRoom 클래스의 부모 클래스를 나타낸다.
  • super(ChatRoom, self).save(*args, **kwargs)는 ChatRoom 클래스의 부모 클래스인 Model 클래스의 save() 메소드를 호출하는 것.
  • 즉, 코드에서 save() 메소드가 호출되면 먼저 password 필드에 저장된 값을 암호화한 후, 부모 클래스인 Model 클래스의 save() 메소드를 호출하여 해당 ChatRoom 객체를 데이터베이스에 저장하는 과정을 수행한다.
  • 이를 통해 코드의 중복을 줄이고 상속 구조에 따른 코드의 확장성과 유연성을 제공할 수 있다.

 

# urls.py
from django.urls import path
from chat import views

urlpatterns = [
    path('', views.ChatViewSet.as_view(
        {'get': 'list'}), name='chat_room_list'),
    path('<int:room_id>/',
         views.ChatViewSet.as_view({'get': 'retrieve'}), name='chat_room'),
    path('room/', views.ChatRoomView.as_view(), name='chat_room_post'),
    path('room/<int:room_id>/', views.ChatRoomView.as_view(), name='chat_room_new'),
    path('room/<int:room_id>/<str:password>/',views.ChatViewSet.as_view({'get': 'checkpassword'}), name='chat_room_password'),
]

 

# views.py
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.generics import get_object_or_404
from django.contrib.auth.hashers import check_password
from rest_framework.viewsets import ViewSet
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from .models import ChatRoom, RoomMessage, RoomChatParticipant
from .serializers import ParticipantSerializer, MessageSerializer, ChatRoomSerializer


class ChatViewSet(ViewSet):
    permission_classes = [IsAuthenticated]
    
    # 톡방 정보 보여주기
    def list(self, request):
        queryset = ChatRoom.objects.all()
        serializer = ChatRoomSerializer(queryset, many=True)
        return Response(serializer.data, status=status.HTTP_200_OK) 
    
    # 특정 방의 요청이 'retrive' 오면 채팅방의 채팅 보여주기
    def retrieve(self, request, room_id=None):
        room = get_object_or_404(ChatRoom, pk=room_id)
        self.check_object_permissions(request, room)
        RoomMessage.objects.filter(room_id=room.id).exclude(author_id = request.user.id).update(
            is_read=True
        )
        queryset = RoomMessage.objects.filter(room_id=room.id).select_related('author').order_by('created_at')
        serializer = MessageSerializer(queryset, many=True)
        
        return Response(serializer.data, status=status.HTTP_200_OK) 
    
    def checkpassword(self, request, room_id, password):
        room = get_object_or_404(ChatRoom, pk=room_id)
        if check_password(password, room.password):
            return Response(status=status.HTTP_200_OK)
        else:
            return Response(status=status.HTTP_403_FORBIDDEN)
        

# 채팅방 생성(삭제), 특정 채팅방 정보 보여주기
class ChatRoomView(APIView):
    permission_classes = [IsAuthenticated]
    
    def get(self, request, room_id):
        room = get_object_or_404(ChatRoom, id=room_id)
        room_serializer = ChatRoomSerializer(room)
        participants = RoomChatParticipant.objects.filter(room_id=room_id)
        participants_serializer = ParticipantSerializer(participants, many=True)
        
        data = {
            "room":room_serializer.data,
            "participants":participants_serializer.data
        }
        return Response(data, status=status.HTTP_200_OK)
        
    def post(self, request):
        queryset = ChatRoom.objects.filter(author = request.user)
        if queryset.count() >= 3:
            return Response(status=status.HTTP_406_NOT_ACCEPTABLE)
        else:
            serializer = ChatRoomSerializer(data=request.data)
            if serializer.is_valid():
                serializer.save(author = request.user)
                return Response(status=status.HTTP_201_CREATED)
            else:
                return Response(status=status.HTTP_400_BAD_REQUEST)
    
    def delete(self, request, room_id):
        room = get_object_or_404(ChatRoom, id=room_id)
        check_participants = RoomChatParticipant.objects.filter(room_id=room_id)
        # print(check_participants)
        if request.user == room.author:
            if not check_participants:
                room.delete()
                return Response(status=status.HTTP_204_NO_CONTENT)
            else:
                return Response(status=status.HTTP_400_BAD_REQUEST)
        else:
            return Response(status=status.HTTP_403_FORBIDDEN)

하나의 코드를 작성하더라도 다양한 시도를 해보고 싶어서, 

그동안 써왔던 APIView, GenericAPIView외에 ViewSet이라는 친구를 사용해보았다.

 

ViewSet은 REST의 반복적인 코딩 패턴을 줄여주는 이점이 있다.
우리는 일반적으로 REST API를 구현할 때 모델을 기준으로 List와 Detail URL에 대한 API를 구현한다.

이 때 List API는 GET, POST 메소드를 구현하며 Detail API 는 GET, PUT, DELETE메소드를 구현한다.

이 각 2개의 URL별로 모두 5개의 메소드 구현을 REST의 일관된 패턴으로 볼 수도 있을 것이다.

 

DRF에서는 모델을 기준으로 하나의 ViewSet으로 묶어서 위에서 언급한 패턴을 한방에 구현할 수 있다.

ViewSet에 queryset과 serializer를 지정만 해주고 Router클래스로 url에 추가만 해주면 된다.

 

 

chocothecoo_frontend : chat.js

이제 프론트엔드에서의 코드들을 살펴보자~!

채팅을 구현하면서 수많은 블로그와 영상을 살펴보았는데, 대부분 장고 내부 탬플릿을 사용하여 채팅을 구현해 내가 하고 있는 프로젝트(백엔드와 프론트앤드를 분리하여 한 플젝)와는 코드가 맞지 않는 것이 많았다.

다행히 깃허브를 온종일 찾아서 발견한 소스코드들이 나를 구제해주었다..ㅎㅎㅎ 

 

혹시 나와 같은 상황에 놓인 사람들을 위해 코드를 공개한다!!

각자의 상황에 맞게 코드를 커스텀해서 사용하길 바란당 ㅎㅎ

 

처음 채팅 카테고리를 입력하면, 채팅방 대기공간이 나온다.

여기서는 현존하는 채팅방 목록들이 나오면서 원하는 채팅방을 클릭하면 입장할 수 있다.

(이때, 비밀채팅방의 경우 비밀번호를 입력해야 한다.)

 

 

아래는 실시간 채팅 코드이다.

 

# chatroom.js
let chatSocket
let nowPage = 1
let username_set = new Set();

# 채팅방 정보 가져오기
const roominfo = await getChatroominfo(roomId)
const roomname = document.getElementById("chatname")
roomname.innerText = "채팅방: " + roominfo.room["name"]

const message_list = document.getElementById("chat_messages")

# 채팅방 채팅기록 불러오기
async function get_chat_log() {
    const chatlog = await getChatLogAPI(roomId);

    chatlog.forEach(e => {
        const element = document.createElement("div");
        element.className = "chat-message";

        let message = e['content'];
        let sender = e['author_name'];
        let created_at = e['created_at']
        let time = e['created_at_time'];
        let profile = e['author_image'];

        if (sender == payload.nickname) {
            element.className += " me";
        }

        if (message) {
            const wrapper = document.createElement("div");
            wrapper.textContent = message.trimStart();

            const content = document.createElement("li");
            content.setAttribute("class", "image")

            const profile_image = document.createElement("img")
            profile_image.setAttribute("class", "profile_image")
            if (profile != null) {
                profile_image.setAttribute("src", profile)
            } else {
                profile_image.setAttribute("src", "static/images/기본상품.png")
            }

            const message_time = document.createElement("li")

            if (sender == payload.nickname) {
                message_time.setAttribute("class", "message_time")
                message_time.innerText = created_at.slice(0, 10) + ' ' + time
            } else {
                message_time.setAttribute("class", "message_time")
                message_time.innerText = created_at.slice(0, 10) + ' ' + time + ' ' + sender;
            }

            content.appendChild(profile_image);
            element.appendChild(content);
            element.appendChild(wrapper);
            element.appendChild(message_time);

            message_list.appendChild(element);
            message_list.scrollTop = message_list.scrollHeight;
        }
    })
}

# 웹소켓 실시간 채팅 코드
function socketSwap(roomId) {
    if (chatSocket) {
        chatSocket.close()
        message_list.empty()
        nowPage = 1
    }

    let backurl = BACK_BASE_URL.substring(7,)
    if (roomId != null) {
    
    # 여기서도 로컬과 배포환경에 따른 url에 차이가 있다.

        // 로컬
        chatSocket = new WebSocket(
            'ws://' + backurl + '/ws/chat/' + roomId + '/?id=' + payload.user_id
        );

        //배포
        // chatSocket = new WebSocket(
        //     'wss://' + backurl + '/ws/chat/' + roomId + '/?id=' + payload.user_id
        // );

	# 내 경우, 채팅참가자이름을 띄워주었기 때문에, 아래와 같은 코드를 추가하였다.
        function update_user_list() {
            const html = Array.from(username_set).map(sender => `<li>${sender}</li>`).join('');
            document.querySelector("#user_list").innerHTML = html;
            document.querySelector("#user_count").textContent =
                `(${username_set.size}명)`;
        };

        chatSocket.onopen = function (e) {
            get_chat_log(roomId)
            roominfo.participants.forEach(e => {
                username_set.add(e["author_name"]);
            })
            update_user_list();
        }

        chatSocket.onmessage = function (e) {
            let data = JSON.parse(e.data);

            let sender = data['sender_name']
            let message = data['message']
            let time = data['time'];
            let profile = data['profile']

            if (data['response_type'] == 'enter') {
                const alarm = document.createElement("li")
                if (sender != payload.nickname) {
                    alarm.setAttribute("class", "enter-alarm")
                    alarm.innerText = `${sender}님이 들어오셨습니다.`;
                    message_list.appendChild(alarm);
                }

                username_set.add(sender);
                update_user_list();
            }

            if (data['response_type'] == 'out') {
                const alarm = document.createElement("li")
                if (sender != payload.nickname) {
                    alarm.setAttribute("class", "out-alarm")
                    alarm.innerText = `${sender}님이 나가셨습니다.`;
                    message_list.appendChild(alarm);
                }

                username_set.delete(sender);
                update_user_list();
            }

            const element = document.createElement("div");
            element.className = "chat-message";

            if (sender == payload.nickname) {
                element.className += " me";
            }

            if (message) {
                const wrapper = document.createElement("div");
                wrapper.textContent = message;

                const content = document.createElement("li");
                content.setAttribute("class", "image")

                const message_time = document.createElement("li")
                message_time.setAttribute("class", "message_time")
                if (sender == payload.nickname) {
                    message_time.innerText = time
                } else {
                    message_time.innerText = time + ' ' + sender
                }

                const profile_image = document.createElement("img")
                profile_image.setAttribute("class", "profile_image")
                if (profile != null) {
                    profile_image.setAttribute("src", profile)
                } else {
                    profile_image.setAttribute("src", "static/images/기본상품.png")
                }

                content.appendChild(profile_image);
                element.appendChild(content);
                element.appendChild(wrapper);
                element.appendChild(message_time);
                message_list.appendChild(element);
                message_list.scrollTop = message_list.scrollHeight;
            }
        }

        chatSocket.onclose = function (e) {
            console.error('Chat socket closed unexpectedly');
            alert("채팅방이 존재하지 않거나 로그인하지 않은 유저입니다.")
        }

        let chatMessageInput = document.querySelector("#chatMessageInput");

        // 엔터키(keyCode 13)를 누르면 전송 버튼(chatMessageSend)을 클릭
        chatMessageInput.focus();
        chatMessageInput.onkeyup = function (e) {
            if (e.keyCode === 13) {  // enter, return
                chatMessageSend.click();
            }
        };

        chatMessageSend.onclick = function (e) {
            const messageInputDom = chatMessageInput;
            const message = messageInputDom.value;
            if (message === '') {
                return
            }
            if (message.replace(/\s/g, "") === '') {
                return
            }
            chatSocket.send(JSON.stringify({
                'user_id': payload['user_id'],
                'room_id': `${roomId}`,
                'message': message.trimStart()
            }));
            // 메세진 전송후 입력창에 빈값 넣어주기
            messageInputDom.value = '';
        };
    } else {
        alert("채팅방을 찾을 수 없습니다.")
    }
}

socketSwap(roomId);

 

이렇게~ 채팅방이 완성되었다!!!