코딩/Django

Django channels 실시간 채팅 기능 (websocket)

작은코딩 2022. 3. 6. 00:48

공식문서 + 구글링 + 유튜브를 통해 실시간 채팅 기능 구현(서버 api 동기식)

 

스파르타 내배캠 4번째 팀 프로젝트 실시간 채팅 기능 담당.

간단하게 프로젝트 소개를 하고 Django channels를 이용한 실시간 채팅 기능 구현 과정을 피드백 하겠다.

 

※ 스크롤 압박 주의


1. 프로젝트 소개

 

bondar는 유화 처리 시스템을 접목한 소개팅 앱으로 사용자의 프로필 이미지를 정해진 화풍으로 유화 처리를 하여 원본 이미지가 노출되는 걸 방지하고 재미 요소를 더한 앱입니다. 

 

프로젝트 bondar 레포지토리 / 우리는 마을을 본다.

 

https://github.com/GoHeeSeok00/bondar/tree/main

 

GitHub - GoHeeSeok00/bondar

Contribute to GoHeeSeok00/bondar development by creating an account on GitHub.

github.com


2. 요구사항 분석

 

이번 프로젝트에서 실시간 채팅 기능을 담당하게 되면서 몇가지로 요구사항 분석을 해 보았다. 

 

  • 실시간 채팅이 가능할 것
  • 채팅 내용이 db에 저장될 것
  • 채팅방에 들어갔을 때 이전 채팅 내역이 로드될 것
  • 유저가 서로 좋아요를 한 경우에만 채팅 기능을 이용할 수 있을 것

 

우리에게 친숙한 기능이지만 막상 구현을 해보려고 하니 막막해서 다른 채팅어플들은 어떻게 만들어졌는지 찾아보았다.

Slack - Java
Facebook Messenger - Erlang
Line - Java, C++, Erlang
Kakaotalk - C++
Snapchat - Java

유명한 채팅 어플이 어떤 언어로 만들어졌는지 찾긴 했지만 나는 python, django 유저이기에 전부 패스.. (출처)

 

그러던 중 Django channels를 찾게 되었고 마침 django 프로젝트를 진행하는 상황과도 맞아떨어져서 Django channels로 채팅 기능을 구현하기로 결정하였다.


3. 시스템 명세 / 튜토리얼 이해하기

 

방향이 정해지고 가장 먼저 한 일은 Django channels 라이브러리 튜토리얼 공략이었다. (Django channels 튜토리얼)

다행히 여러 블로그와 유튜브에서 튜토리얼을 설명해 주었기에 실시간 채팅을 구현하는데 까지는 큰 어려움이 없었다. 

(그냥 복붙 몇 번 하니까 일단 채팅은 되었다.)

 

문제는 여기부터였다. Django channels 튜토리얼에서는 방 이름을 입력하면 해당 이름의 채팅방으로 들어가게 되고 같은 이름으로 들어와 있는 그룹끼리 채팅이 가능한 구조였는데 우리 프로젝트와는 맞지 않아서 튜닝이 필요했다. 

 

- 일단 bondar 프로젝트에서는 유저가 서로 좋아요 할 경우 채팅방을 생성할 수 있게 되고(버튼 활성화) 채팅방 생성 버튼을 누르면 chatroom을 만들어서 db의 room테이블에 저장하고 채팅방으로 redirect 한다.

- 채팅방은 채팅방 목록에서 조회가 가능하며 허가된 유저만 접근할 수 있다. 

- 채팅방에서 채팅을 하게 되면 db의 message테이블에 저장하는 동시에 websocket을 이용해서 바로 띄워준다.

 

몇 줄 안 되는 과정이지만 처음 접하게 된 websocket 방식 중간중간에 로직을 추가해야 돼서 여러 시행착오를 거치던 중

한 유튜브 영상을 발견하게 되어 프로젝트 기한 내에 기능 구현을 완료할 수 있었다. 

 

참고 유튜브 링크

https://www.youtube.com/watch?v=xrKKRRC518Y 

(Django channels를 이용해서 실시간 채팅 기능을 구현하고 이후 여러 가지 추가적인 기능 업데이트를 다루는 영상이니 시간이 되면 내 채팅 어플을 업그레이드시켜 봐야겠다.)

 


4. 설계

 

대략적인 시스템 작동 방식을 생각해 봤으니 이제 db설계를 할 차례이다.

 

- base_model.py

from django.db import models


class BaseModel(models.Model):
    updated_at = models.DateTimeField(auto_now=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        abstract = True

 

- room.py

from chat.models.base_model import BaseModel


class Room(BaseModel):
    class Meta:
        db_table = "room"

 

- room_join.py

from django.db import models

from chat.models.base_model import BaseModel
from chat.models.room import Room
from user.models import UserModel


class RoomJoin(BaseModel):

    user_id = models.ForeignKey(UserModel, on_delete=models.CASCADE, related_name="roomJoin", db_column="user_id")
    room_id = models.ForeignKey(Room, on_delete=models.CASCADE, related_name="roomJoin", db_column="room_id")

    class Meta:
        db_table = "roomJoin"

 

- message.py

from django.db import models

from chat.models.base_model import BaseModel
from chat.models.room import Room
from user.models import UserModel


class Message(BaseModel):
    message = models.CharField(max_length=500)
    user_id = models.ForeignKey(UserModel, on_delete=models.CASCADE, related_name="message", db_column="user_id")
    room_id = models.ForeignKey(Room, on_delete=models.CASCADE, related_name="message", db_column="room_id")

    class Meta:
        db_table = "message"

    def __str__(self):
        return self.user_id.email

    def last_30_messages(self, room_id):
        return Message.objects.filter(room_id=room_id).order_by('created_at')[:30]

 

db를 설계하기 위해서 구글링을 했을 때 room테이블에 user1, user2 필드를 만들어서 유저를 관리하게 되면 여러 명이 참여하는 채팅방을 만들 때 문제가 될 거 같다 생각해서 roomJoin테이블을 만들어 채팅방에 참여 가능한 유저를 따로 관리했지만 솔직히 현재 프로젝트의 요구 사항은 1:1 채팅 기능이기에 room테이블에서 관리하는 게 훨씬 효율적인 방법이다. (요구사항을 구체적으로 생각하지 않은 내 실수,,)

괜히 확장성을 고려했다가 채팅방 생성, 유효성 검사 등에서 쿼리를 무더기로 날리는 상황이 발생하게 되었다. ㅠ

 


5. 프로그래밍

 

여기서는 consumers.py 와 room.html을 중점으로 리뷰하니 django channels 사용 시 기본적인 세팅은 튜토리얼을 참고 바란다.

 

- consumers.py

# websocket 요청을 처리하는 함수는 consumers.py 에입력
import json

from asgiref.sync import async_to_sync
from channels.generic.websocket import WebsocketConsumer

from chat.models import Room, Message

# 동기식 방법으로 진행
from user.models import UserModel


class ChatConsumer(WebsocketConsumer):

    def fetch_messages(self, data):
        room_id = int(self.room_name)
        messages = Message.last_30_messages(self, room_id=room_id)
        content = {
            'command': 'messages',
            'messages': self.messages_to_json(messages)
        }
        self.send_message(content)

    def new_message(self, data):
        user_id = data['user_id']
        room_id = int(self.room_name)
        user_contact = UserModel.objects.filter(id=user_id)[0]
        room_contact = Room.objects.filter(id=room_id)[0]
        message_creat = Message.objects.create(
            user_id=user_contact,
            room_id=room_contact,
            message=data['message']
        )
        content = {
            'command': 'new_message',
            'message': self.message_to_json(message_creat)
        }
        return self.send_chat_message(content)

    def messages_to_json(self, messages):
        result = []
        for message in messages:
            result.append(self.message_to_json(message))
        return result

    def message_to_json(self, message):
        return {
            'author': message.user_id.username,
            'content': message.message,
            'timestamp': str(message.created_at)
        }

    commands = {
        'fetch_messages': fetch_messages,
        'new_message': new_message
    }

    # websocket 연결
    def connect(self):
        # room_name 파라미터를 chat/routing.py URl 에서 얻고, 열러있는 websocket에 접속
        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
        )
        # websocket 연결을 수락 / connect() 메서드 내에서 accept()를 호출하지 않으면 연결이 거부되고 닫힌다.
        self.accept()

    # websocket 연결 해제
    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):
        data = json.loads(text_data)
        self.commands[data['command']](self, data)

    def send_chat_message(self, message):
        async_to_sync(self.channel_layer.group_send)(
            self.room_group_name,
            {
                "type": "chat_message",
                "message": message
            }
        )

    def send_message(self, message):
        self.send(text_data=json.dumps(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))

 

- room.html

{% extends 'base.html' %}
{% block css %}
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"
          integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z" crossorigin="anonymous">
    <link rel="stylesheet" href="../../static/css/chatting.css"/>
{% endblock css %}
{% block page_title %}
    Chat room
{% endblock page_title %}

{% block content %}
<div class="container">
    <div class="row d-flex justify-content-center">
        <div class="col-6">
            <div class="form-group">
                <img src="{{ user_profile }}" alt=""/>
                <label for="exampleFormControlTextarea1" class="h4 pt-5">
                    {{ user.username }}</label>
                <textarea class="form-control" id="chat-log" cols="100" rows="20" readonly></textarea><br>
            </div>
            <div class="form-group">
                <input class="form-control" id="chat-message-input" type="text" size="100"><br>
            </div>
            <input class="btn btn-secondary btn-lg btn-block" id="chat-message-submit" type="button" value="Send">
        </div>
    </div>
</div>
<!--room_name 을 핸들링 하는 json script-->
{{ room_name|json_script:"room-name" }}
{{ user.username|json_script:"user_email" }}
{{ user.id|json_script:"user_id" }}

<script>
    <!--roomName 변수에 room_name 할당-->
    // JSON.parse() 메소드 : json 문자열의 구문을 분석하고 결과로 javascript 값이나 객체 생성
    const roomName = JSON.parse(document.getElementById('room-name').textContent);
    const user_email = JSON.parse(document.getElementById('user_email').textContent);
    const user_id = JSON.parse(document.getElementById('user_id').textContent);

    // chatSocket 변수에 생선된 webSocket 할당 / ws://ws/chat/roomName
    const chatSocket = new WebSocket(
        'ws://'
        + window.location.host
        + '/ws/chat/'
        + roomName
        + '/'
    );

    // chatSocket에 onopen 메소드 지정
    chatSocket.onopen = function (e) {
        fetchMessages();
    }

    // chat-log id를 통해서 기존 message 에 추가해서 message 를 onmessage 해줌
    chatSocket.onmessage = function (e) {
        const data = JSON.parse(e.data);
        if (data['command'] === 'messages') {
            for (let i = 0; i < data['messages'].length; i++) {
                createMessage(data['messages'][i]);
            }
        } else if (data['command'] === 'new_message') {
            createMessage(data['message']);
        }
    };

    // 에러났을 때는 onclose
    chatSocket.onclose = function (e) {
        console.error('Chat socket closed unexpectedly');
    };

    // 엔터를 눌러도 click 이벤트가 발생하게 처리
    document.querySelector('#chat-message-input').onkeyup = function (e) {
        if (e.keyCode === 13) {  // enter, return
            document.querySelector('#chat-message-submit').click();
        }
    };

    // onclick 이벤트가 발생하면 input value를 message에 저장해서 json형태로 chatSocket으로 전송
    // chatSocket 전송이 완료되면 input box value 를 공백으로 초기화
    document.querySelector('#chat-message-submit').onclick = function (e) {
        const messageInputDom = document.querySelector('#chat-message-input');
        const message = messageInputDom.value;
        chatSocket.send(JSON.stringify({
            'message': message,
            'user': user_email,
            'user_id': user_id,
            'command': 'new_message'
        }));
        messageInputDom.value = '';
    };

    function fetchMessages() {
        chatSocket.send(JSON.stringify({'command': 'fetch_messages'}))
    }

    function createMessage(data) {
        const author = data['author'];
        document.querySelector('#chat-log').value += (author + ': ' + data.content + '\n');
    }
</script>
{% endblock %}

 

- views.py

from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect, render

from chat.models import RoomJoin
from chat.services.chat_room_service import get_an_chat_room_list, get_chat_room_user, confirm_user_chat_room_join, \
    creat_an_chat_room, creat_an_room_join
from chat.services.message_service import get_an_message_list

from user.models import UserModel

from collections import Counter

from userprofile.models import UserProfile



# Create your views here. / views 호출하려면 매핑되는 URLconf 필요
# chat_view 함수를 호출하면 chat.html 을 렌더해주는 함수
@login_required
def chat_view(request: HttpRequest) -> HttpResponse:
    # 사용자가 있는지 없는지 판단
    user = request.user.is_authenticated
    # 사용자가 있으면 사용자가 속해있는 채팅방 list 표시
    if user:
        # 유저가 참여하고 있는 채팅방 목록(roomJoin 쿼리)
        chat_room_list = get_an_chat_room_list(request.user.id)

        chat_info = {}
        for chat_room in chat_room_list:
            room_id = chat_room.room_id.id
            # 채팅방에 참여중인 유저 list(roomJoin 쿼리)
            chat_user_list = get_chat_room_user(room_id)

            username_list = []
            for chat_user in chat_user_list:
                username = chat_user.user_id.username
                username_list.append(username)

            # chat_info 변수에 딕셔너리 형태로 저장
            chat_info[room_id] = username_list

        if chat_info == {}:
            chat_info = None

        return render(request, "chat/chat.html", {'chat_info': chat_info})
    # 사용자가 없으면 로그인화면
    else:
        return redirect(("/welcome/sign-in"))


# room 함수를 호출하면 room.html 을 렌더해주는 함수 / dict 형태로 room_name value 를 전송
@login_required
def room_view(request: HttpRequest, room_name: str) -> HttpResponse:
    room_id = int(room_name)
    try:
        confirm_user_chat_room_join(request.user.id, room_id)

        message = get_an_message_list(room_id)

        user_profile = UserProfile.objects.get(user_id=request.user).nst_image_url

        return render(request, "chat/room.html", {"room_name": room_name, "message": message, "user_profile": user_profile})

    except:
        return redirect(("/chat"))


@login_required
def api_create_room(request: HttpRequest, user_id: int) -> HttpResponse:
    user1 = UserModel.objects.get(id=request.user.id)
    user2 = UserModel.objects.get(id=user_id)

    find_room_qs = RoomJoin.objects.filter(user_id__in=[user1.id, user2.id])
    #이러면 1번 유저가 참여한 모든 방, 2번 유저가 모두 참여한 방 가져옴

    find_room_list = []
    for find_room in find_room_qs:
        find_room_list.append(find_room.room_id)

    result = Counter(find_room_list)
    for key,value in result.items():
        if value >= 2:
            return redirect(("/chat/"+str(key.id)))

    room = creat_an_chat_room()
    room_id = room.id
    creat_an_room_join(user1, user2, room)

    return redirect(("/chat/"+str(room_id)))

 


사용자가 채팅방을 만들고 채팅하기까지의 과정을 순서대로 설명하겠다. 

 

먼저 유저가 서로 좋아요를 할 경우 views.py 내의 api_create_room 함수를 호출할 수 있다. 

(이 부분은 like 담당자분이 필터링해주었다.)

@login_required
def api_create_room(request: HttpRequest, user_id: int) -> HttpResponse:
    user1 = UserModel.objects.get(id=request.user.id)
    user2 = UserModel.objects.get(id=user_id)

    find_room_qs = RoomJoin.objects.filter(user_id__in=[user1.id, user2.id])
    #이러면 1번 유저가 참여한 모든 방, 2번 유저가 모두 참여한 방 가져옴

    find_room_list = []
    for find_room in find_room_qs:
        find_room_list.append(find_room.room_id)

    result = Counter(find_room_list)
    for key,value in result.items():
        if value >= 2:
            return redirect(("/chat/"+str(key.id)))

    room = creat_an_chat_room()
    room_id = room.id
    creat_an_room_join(user1, user2, room)

    return redirect(("/chat/"+str(room_id)))

 

request정보와 상대 프로필의 유저 id 정보를 인자로 api_create_room 함수가 호출되면 먼저 두 사용자 모델을 user 변수에 저장하고 RoomJoin 테이블에서 각 유저가 참여 권한을 가지고 있는 roomJoin 모델의 쿼리셋을 가져온다. 

 

그다음 쿼리셋에서 room모델을 빼내어 find_room_list에 저장하고 collections 라이브러리의 Counter 함수를 이용해서 중복 값을 찾아준다. 

Counter 함수에 리스트를 인자로 넣을 경우 중복된 내용을 찾아 dictionary 형태로 반환한다.

ex) {room1: 1, room2: 2, room3: 1, room4: 1}

 

items를 이용해서 for문을 돌려준다. 이때 value에 2 이상의 값이 들어있으면 두 유저가 참여하고 있는 채팅방이 존재함으로 해당 채팅방 URL로 redirect 시켜준다. 

 

value 값이 전부 2 미만이면 아래 두 함수를 사용해서 채팅방과 접근 권한을 만들어 주고 만들어진 채팅방 URL로 redirect 시켜준다. 

def creat_an_chat_room() -> Room:
    return Room.objects.create()


def creat_an_room_join(user_id1: int, user_id2: int, room_id: int) -> Tuple[RoomJoin, RoomJoin]:
    room_join1 = RoomJoin.objects.create(user_id=user_id1, room_id=room_id)
    room_join2 = RoomJoin.objects.create(user_id=user_id2, room_id=room_id)
    return room_join1, room_join2

 

채팅방 url로 이동하게 되면 room_view 함수를 호출하게 되는데 전달받은 user id와 room id로  confirm_user_chat_room_join() 함수를 통해  접근 권한이 있는지 확인하고 메시지와 프로필 사진 정보를 dictionary 형태로 render 해준다.

@login_required
def room_view(request: HttpRequest, room_name: str) -> HttpResponse:
    room_id = int(room_name)
    try:
        confirm_user_chat_room_join(request.user.id, room_id)

        message = get_an_message_list(room_id)

        user_profile = UserProfile.objects.get(user_id=request.user).nst_image_url

        return render(request, "chat/room.html", {"room_name": room_name, "message": message, "user_profile": user_profile})

    except:
        return redirect(("/chat"))
def confirm_user_chat_room_join(user_id: int, room_id: int) -> RoomJoin:
    return RoomJoin.objects.get(user_id=user_id, room_id=room_id)
피드백
1. 앞서 얘기 했듯이 roomJoin 테이블을 이용하다 보니 쓸데없이 코드가 길어지고 있다. 1:1 채팅 기능 구현이 목적이라면 room 테이블에서 두 유저를 관리하는 필드를 생성하고 Room모델에서 두 유저 아이디가 일치하는 모델만 호출하면 이미 생성된 방이 있는지 쉽게 찾을 수 있지 않을까?

2. room_view 함수에도 불필요한 코드가 들어있는데 채팅방에 저장되어 있던 메시지는 consumers.py에서 전부 처리했기 때문에 message = get_an_message_list(room_id)는 지워도 무방하다. 

 

채팅방 페이지로 이동하면 먼저 필요한 데이터를 json 스크립트로 저장한다.

<!--room_name 을 핸들링 하는 json script-->
{{ room_name|json_script:"room-name" }}
{{ user.username|json_script:"user_email" }}
{{ user.id|json_script:"user_id" }}

 

이후 각 데이터를 변수에 담아주고 chatSocket이라는 변수로 websocket을 생성하고 연결한다.

<!--roomName 변수에 room_name 할당-->
// JSON.parse() 메소드 : json 문자열의 구문을 분석하고 결과로 javascript 값이나 객체 생성
const roomName = JSON.parse(document.getElementById('room-name').textContent);
const user_email = JSON.parse(document.getElementById('user_email').textContent);
const user_id = JSON.parse(document.getElementById('user_id').textContent);

// chatSocket 변수에 생선된 webSocket 할당 / ws://ws/chat/roomName
const chatSocket = new WebSocket(
    'ws://'
    + window.location.host
    + '/ws/chat/'
    + roomName
    + '/'
);

 

클라이언트로부터 websocket 연결 요청이 오면 connet함수를 실행해서 room_name, room_group_name에 room id를 저장하고 해당 그룹으로 websocket 연결을 수락한다.

# websocket 연결
def connect(self):
    # room_name 파라미터를 chat/routing.py URl 에서 얻고, 열러있는 websocket에 접속
    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
    )
    # websocket 연결을 수락 / connect() 메서드 내에서 accept()를 호출하지 않으면 연결이 거부되고 닫힌다.
    self.accept()

 

onopen 속성을 통해 websocket에 연결되면 fetchMessages() 메소드를 실행한다.

    // chatSocket에 onopen 메소드 지정
    chatSocket.onopen = function (e) {
        fetchMessages();
    }

 

fetchMessages() 메소드는 send 속성을 이용해 websocket에 JSON 형태로 아래 내용을 보낸다.

function fetchMessages() {
    chatSocket.send(JSON.stringify({'command': 'fetch_messages'}))
}

 

consumers.py에는 commands가 아래처럼 정의되어있다.

commands = {
    'fetch_messages': fetch_messages,
    'new_message': new_message
}

 

클라이언트로부터 websocket통신을 받아서 data에 내용을 저장하고 정의된 commads를 통해 self와 data를 인자로 받아 fetch_messages() 함수가 실행된다.

# Receive message from WebSocket
def receive(self, text_data):
    data = json.loads(text_data)
    self.commands[data['command']](self, data)

 

room id를 통해 db에 저장된 최근 30개 메세지 내용을 가져오고 (쿼리셋) messages_to_json(), message_to_jason() 함수를 통해 json형태가 담긴 메세지 리스트를 반환받는다.

send_message() 함수를 호출한다.

def fetch_messages(self, data):
    room_id = int(self.room_name)
    messages = Message.last_30_messages(self, room_id=room_id)
    content = {
        'command': 'messages',
        'messages': self.messages_to_json(messages)
    }
    self.send_message(content)
def messages_to_json(self, messages):
    result = []
    for message in messages:
        result.append(self.message_to_json(message))
    return result

def message_to_json(self, message):
    return {
        'author': message.user_id.username,
        'content': message.message,
        'timestamp': str(message.created_at)
    }

 

json.dumps()로 메시지를 json형태로 인코딩한 후 send() 함수를 통해 클라이언트로 전송

def send_message(self, message):
    self.send(text_data=json.dumps(message))

 

클라이언트는 서버로부터 온 메시지를 onmessage 함수로 전달받는다. 

messages 커맨드를 전달받았기에 if 조건문을 수행하여 for문을 통해 전달받은 메시지 수 만큼 createMessage()함수를 실행한다.

// chat-log id를 통해서 기존 message 에 추가해서 message 를 onmessage 해줌
chatSocket.onmessage = function (e) {
    const data = JSON.parse(e.data);
    if (data['command'] === 'messages') {
        for (let i = 0; i < data['messages'].length; i++) {
            createMessage(data['messages'][i]);
        }
    } else if (data['command'] === 'new_message') {
        createMessage(data['message']);
    }
};

 

#chat-log id를 가지고 있는 textarea 태그의 value에

사용자: 메세지 (줄 바꿈)을 추가해준다.

function createMessage(data) {
    const author = data['author'];
    document.querySelector('#chat-log').value += (author + ': ' + data.content + '\n');
}

 

 

여기까지가 채팅방을 만들고 채팅방에 입장했을 때의 과정.

 

이제 채팅을 입력해볼 차례이다.

 

input 박스에 채팅을 입력하고 enter를 누르면 onkeyup이벤트가 발생한다.

정확하게는 onkeyup 이벤트는 키를 누르고 떼는 순간 발생하는 이벤트이고 keycode 13은 엔터의 키 코드이다. 

결국 엔터를 누르고 떼는 순간 #chat-message-submit를 클릭하는 이벤트가 발생하게 된다.

// 엔터를 눌러도 click 이벤트가 발생하게 처리
document.querySelector('#chat-message-input').onkeyup = function (e) {
    if (e.keyCode === 13) {  // enter, return
        document.querySelector('#chat-message-submit').click();
    }
};

 

#chat-message-submit 클릭 이벤트가 발생하게 되면 input 박스에 담겨있던 value와 user id, username(user_email), 'new_message'를 각각 변수에 담아서 websocket 통신으로 서버에 전달한다.

그리고 input박스의 value는 초기화

// onclick 이벤트가 발생하면 input value를 message에 저장해서 json형태로 chatSocket으로 전송
// chatSocket 전송이 완료되면 input box value 를 공백으로 초기화
document.querySelector('#chat-message-submit').onclick = function (e) {
    const messageInputDom = document.querySelector('#chat-message-input');
    const message = messageInputDom.value;
    chatSocket.send(JSON.stringify({
        'message': message,
        'user': user_email,
        'user_id': user_id,
        'command': 'new_message'
    }));
    messageInputDom.value = '';
};

 

그러면 서버에서는 receive 함수를 통해 데이터를 받게 되는데 아까 봤던 함수가 맞다. 

다만 이번 커맨드는 new_message 이기에  self와 data를 인자로 받는 new_message 함수가 실행된다.

# Receive message from WebSocket
def receive(self, text_data):
    data = json.loads(text_data)
    self.commands[data['command']](self, data)

 

new_message() 함수에서는 user id와 room id정보를 가지고 id가 일치하는 모델을 각각 불러와서 message 모델을 만들어 대화 내용을 저장하고 content라는 변수에 담아 send_chat_message() 함수를 실행한다.

(user id는 data에 담겨있으며 room id는 websocket 연결 시 room_name에 저장해놨다. / message_to_join() 함수는 위에서 설명했기에 생략)

def new_message(self, data):
    user_id = data['user_id']
    room_id = int(self.room_name)
    user_contact = UserModel.objects.filter(id=user_id)[0]
    room_contact = Room.objects.filter(id=room_id)[0]
    message_creat = Message.objects.create(
        user_id=user_contact,
        room_id=room_contact,
        message=data['message']
    )
    content = {
        'command': 'new_message',
        'message': self.message_to_json(message_creat)
    }
    return self.send_chat_message(content)

 

send_chat_message() 함수는 동일한 group name을 가진 채널 레이어에 새로 저장된 메시지를 전송한다.

def send_chat_message(self, message):
    async_to_sync(self.channel_layer.group_send)(
        self.room_group_name,
        {
            "type": "chat_message",
            "message": message
        }
    )

 

room group으로부터 메시지를 전달받으면 chat_message() 함수 이벤트가 실행이 되며 message를 json.dumps로 인코딩하여 클라이언트로 전달한다.

# Receive message from room group
def chat_message(self, event):
    message = event["message"]

    # Send message to WebSocket
    self.send(text_data=json.dumps(message))

 

위에 설명했듯이 클라이언트는 서버로부터 온 메시지를 onmessage 함수로 전달받게 되며 이번 commad는 new_message이기에 else 구문이 실행된다. 

그러면 아까 봤던 createMessage() 함수가 호출이 되면서 새로 입력한 메시지가 textarea에 입력되는 걸 확인할 수 있다.

// chat-log id를 통해서 기존 message 에 추가해서 message 를 onmessage 해줌
chatSocket.onmessage = function (e) {
    const data = JSON.parse(e.data);
    if (data['command'] === 'messages') {
        for (let i = 0; i < data['messages'].length; i++) {
            createMessage(data['messages'][i]);
        }
    } else if (data['command'] === 'new_message') {
        createMessage(data['message']);
    }
};
ction createMessage(data) {
    const author = data['author'];
    document.querySelector('#chat-log').value += (author + ': ' + data.content + '\n');
}
피드백

눈치 빠른 분은 이미 알겠지만 이 코드는 동기식으로 작성되어있다. 사실 비동기식으로 구현을 하고 싶었는데 (단순히 성능이 더 좋을 거란 생각에) 비동기식으로 진행하자 두 가지 이슈가 발생했다. 

첫 번째 이슈는 비동기식으로 작동되는 클래스, 함수 안에서는 동기식으로 작동하는 함수를 호출할 수 없는 문제였다. 이 문제는 sync_to_async() 함수를 통해 해결했다.

두 번째 이슈는 코루틴 타입으로 모델을 생성할 수 없는 문제였다. 앞서 첫 번째 이슈에서 실행한 함수는 room 모델이나 user 모델을 가져오는 함수였다. 원래라면 model타입을 반환해야 하지만 sync_to_async 함수를 통해 비동기식으로 만드는 과정에서 반환값이 코루틴 타입으로 변환이 되었다. 
이렇게 변환된 값을 foreign key로 참조하여 message 모델을 생성하려 하자 error님을 만나게 되었다. 
error님을 달래기 위해 폭풍 구글링을 했지만 솔루션을 찾지 못했고 결국 동기식으로 전환을 하게 되었다.
(솔루션을 아신다면 댓글로 알려주시는 센스! 기대합니다~ )

 


 

코딩을 시작한 지 3개월,, 배우지 않은 기능을 구글링과 유튜브 공부로 구현할 수 있다니,, 감회가 새롭다.

대단한 기능을 만든 것은 아니지만 이렇게 하나 둘 경험을 쌓다 보면 큰 프로젝트에 기여하는 개발자가 될 수 있지 않을까?

 

 

채팅방 목록 이미지
실시간 채팅 테스트 이미지

 

CSS는 너무나도 어려운 것,,,

'코딩 > Django' 카테고리의 다른 글

[Django] F( )표현식  (0) 2022.04.26
[Django] 역참조 테이블 필드로 정렬하기 (order_by)  (0) 2022.03.19