프로그래밍/생각나는대로 프로젝트

[프로젝트] 08. 한글 학습 퀴즈 서비스 - 한 (Part 2. 웹소켓)

Churnobyl 2023. 7. 20. 18:14
728x90
반응형

한글 학습 퀴즈 서비스 - 한

 

 최종 프로젝트인 만큼 리뷰할 게 많아 글을 두 개 세 개로 나눴다.


주요 코드 리뷰


04. 웹소켓

 

서론

 이번 프로젝트에서 처음으로 웹소켓을 사용했다. django의 서드파티인 channels를 이용해 웹소켓을 구성할 수 있다는 말을 듣고 공식문서 예제를 따라 채팅 기능을 만들어 놓고 보니 대략적인 감이 왔다.

 

 기존의 stateless한 HTTP통신과는 달리 웹소켓은 handshake를 통해 HTTP로 통신을 개시한 뒤 연결이 유지되는 동안 양방향 통신이 가능하다는 점에서 달랐다.

 

 channels에서 웹소켓을 구성하는 Consumer클래스는 크게 connect, receive, disconnect 세 가지 함수로 구성되어 있는데 각각 연결을 개시하고, 클라이언트에서 오는 메세지를 받고, 연결을 종료하는 것이다. 우리는 이 웹소켓 기능을 배틀 초대 메세지를 해당 유저에게 알리는 Notification 기능과 실시간 배틀에 사용하기로 했다. 특히 배틀 초대 메세지는 어느 페이지에 있던지 실시간으로 받을 수 있어야 하므로 모든 페이지에서 웹소켓 연결이 필요했다.

 

 이 부분에서 조금 우려스러운 것은 모든 유저가 웹페이지를 이동할 때마다 웹소켓 연결이 수행되어야 하므로 자원 낭비가 심할 것 같다는 것이다. 이 부분에 대한 해결책으로 일정 시간 수행되는 작업이 없을 시 연결을 끊거나, 서비스 워커를 이용해 웹소켓 자원을 관리하는 것을 고려했으나 시도하지는 못했다.

 

 결국 한번 접속한 유저는 항상 웹소켓에 연결되어 있는 상태가 되도록 프로젝트를 마무리했다. 하지만 추후 코드를 리팩토링할 때 이 부분도 고려하고 있다.

 

  

본론

 내가 처음 배틀을 구성할 때 고려했던 것은 모델이다. 스타크래프트 배틀넷을 상상했을 때, 방을 만들어야 하고 새롭게 만들어진 방은 실시간으로 로비에 접속한 다른 유저에게 보여져야 한다. 그래서 현재 방 정보를 저장하는 CurrentBattleList라는 모델을 만들었다. 

 

# HAN/battle/models.py

class CurrentBattleList(models.Model):
    """겨루기 방 모델

    생성된 겨루기 방 정보를 담아주는 모델

    Attributes:
        btl_title (CharField) : 방 제목

        btl_created_at (DateTimeField) : 방 생성 시간

        btl_updated_at (DateTimeField) : 방 시작 시간

        host_user (ForeignKey) : 방장

        max_users (IntegerField) : 최대 인원

        is_private (BooleanField) : 비공개방 여부

        room_password (IntegerField) : 비공개방 비밀번호

        btl_start (BooleanField) : 게임 시작 여부
    """

    btl_category_choices = (
        ("A", "종합"),
        ("C", "십자말풀이"),
        ("D", "단어맞추기"),
    )

    btl_title = models.CharField(max_length=60, unique=True)
    btl_category = models.CharField(max_length=5, choices=btl_category_choices)
    btl_created_at = models.DateTimeField(auto_now_add=True)
    btl_updated_at = models.DateTimeField(auto_now=True)
    host_user = models.OneToOneField(
        User, related_name="host_user", on_delete=models.CASCADE
    )
    max_users = models.IntegerField(default=2)
    is_private = models.BooleanField(null=False, default=False)
    room_password = models.IntegerField(null=True)
    btl_start = models.BooleanField(default=False)

    def __str__(self):
        return str(self.btl_title)

 

 이 모델은 방 제목, 방장, 최대 인원 등 방을 구성하기 위한 정보를 담고 있다. 누군가 방을 새롭게 만들면 그 정보는 이 모델에 저장된다.

 

 

 방이 생성됐다는 정보는 로비에 있는 유저들이 실시간으로 봐야 한다. 따라서 이 부분에 웹소켓이 사용됐는데, 이미 Notification 기능으로 인해 유저가 어떤 페이지에 있든 웹소켓이 연결되어 있으므로 내가 할 일은 이 로비에 있는 유저만을 찾아서 정보가 담긴 메세지를 보내주면 됐다. 이를 구현하기 위해서는 프론트엔드로부터 유저가 어떤 페이지에 접속해 있는지를 알 수 있는 방법이 필요했다.

 

// HAN_FRONT/script/api.js

const token = localStorage.getItem("access");
if (token) {
  const nowPage = window.location.pathname;
  const pageSplit = nowPage.split("/");
  const pageName = pageSplit[pageSplit.length - 1].split(".")[0];
  socket = new WebSocket(
    `ws://${BACK_WEBSOCKET_URL}/ws/battle/?token=${token}&page=${pageName}`
  );
}

 

 우리는 프론트엔드에서 바닐라 자바스크립트를 사용하고 있으므로 페이지의 path가 그대로 노출되어 있다. 따라서 pathname으로부터 현재 페이지 이름만을  웹소켓 핸드쉐이크 시 page정보를 쿼리 스트링에 함께 넘겨 주었다. 이제 백엔드에서는 넘어오는 쿼리스트링을 파싱해 사용하면 된다.

 

# HAN/config/asgi.py

import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.security.websocket import AllowedHostsOriginValidator
import battle.routing
from urllib.parse import parse_qs
from rest_framework_simplejwt.authentication import JWTAuthentication
from channels.db import database_sync_to_async
from channels.middleware import BaseMiddleware

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")


class JwtTokenAuthMiddleware(BaseMiddleware):
    """JWT토큰인증 미들웨어

    웹소켓 요청에 대해 JWT토큰 사용자 인증을 처리합니다.
    """

    async def __call__(self, scope, receive, send):
        await self.authenticate(scope)
        return await super().__call__(scope, receive, send)

    @database_sync_to_async
    def authenticate(self, scope):
        queries = parse_qs(scope["query_string"].strip().decode())
        raw_token = queries["token"][0]
        page = queries["page"][0]
        auth = JWTAuthentication()
        validated_token = auth.get_validated_token(raw_token)
        user = auth.get_user(validated_token)
        scope["user"] = user
        scope["page"] = page


application = ProtocolTypeRouter(
    {
        "http": get_asgi_application(),
        "websocket": AllowedHostsOriginValidator(
            JwtTokenAuthMiddleware(URLRouter(battle.routing.websocket_urlpatterns))
        ),
    }
)

 

 우리 프로젝트는 인증 방식으로 JWT Token을 사용하는데 django channels는 쿠키와 세션의 authentication만 지원하고 있었다. 따라서 JWT Token 인증을 위한 AuthMiddleware를 따로 만들어 주었다. 사실 이 부분도 access token이 HTTP통신 중 탈취될 가능성이 있기 때문에 다른 방법을 찾아볼 필요가 있다. 실제 우리가 런칭한 웹서비스에서는 HTTP대신 HTTPS를 통해 데이터가 암호화되므로 꽤 안전할 수도 있다.

 

 어쨌거나 위 인증 시 쿼리스트링에 담겨 온 access token과 page이름에서 각각 user정보와 page정보를 scope에 추가해준다. 이제 웹소켓이 연결될 때 scope의 page가 "lobby"라는 정보를 담겨 있을 경우 lobby라는 채널 그룹에 해당 유저를 추가로 넣어준다.

 

# HAN/battle/consumers.py

class BattleConsumer(AsyncWebsocketConsumer):
    """배틀 웹소켓 연결 클래스

    배틀 연결 및 연결 해제
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.quizzes = []
        self.quiz_participant = {}
        self.quiz_count = 0

    async def connect(self):
        """웹소켓 연결"""
        self.page = self.scope["page"]
        await self.accept()
        self.room_group_name = "user_%s" % self.scope["user"].id
        await self.channel_layer.group_add(self.room_group_name, self.channel_name)

        # 로비
        if self.page == "lobby":
            await self.channel_layer.group_add("lobby", self.channel_name)

        notifications = await self.get_notification()
        message = {
            "type": "send_message",
            "method": "notification",
            "message": notifications,
        }
        await self.channel_layer.group_send(self.room_group_name, message)

 

 # 로비 부분을 보면 만약 self.page가 "lobby"일 경우는 channels의 channel_layer 중 lobby라는 그룹에 해당 유저를 추가해주는 것을 알 수 있다.

 

 이제 해당 유저는 lobby라는 채널 그룹을 통해 메세지를 전달 받을 수 있다. 다음으로 우리가 해야할 일은 방 정보에 변동이 있을 시 lobby 채널 그룹을 통해 변동 사항을 메세지로 보내는 것이다. DB에 변동사항이 있을 때 바로바로 보내주기 위해서 django.db.modelssignals 기능을 사용했다.

 

 signals은 DB에 어떠한 변동사항이 있으면 특정 함수를 실행시켜 준다. 따라서 이 경우 방이 추가되거나 삭제됐을 때(CurrentBattleList라는 모델에 변동사항이 있을 때) 웹소켓을 통해 lobby라는 채널 그룹에 새로운 방 리스트를 보내주면 된다.

 

HAN/battle/signals.py

from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
from .models import CurrentBattleList
from battle.serializers import BattleListSerializer


@receiver(post_save, sender=CurrentBattleList)
def lobby_room_add(sender, instance, created, **kwargs):
    """로비에서 방 추가 웹소켓"""

    channel_layer = get_channel_layer()
    cnt_battle_list = CurrentBattleList.objects.all()
    serializer = BattleListSerializer(cnt_battle_list, many=True)
    if created:
        async_to_sync(channel_layer.group_send)(
            "lobby",
            {
                "type": "send_message",
                "method": "lobby_room_add",
                "message": serializer.data,
            },
        )
    else:
        async_to_sync(channel_layer.group_send)(
            "lobby",
            {
                "type": "send_message",
                "method": "lobby_room_updated",
                "message": serializer.data,
            },
        )


@receiver(post_delete, sender=CurrentBattleList)
def lobby_room_delete(sender, instance, **kwargs):
    """로비에서 방 삭제 웹소켓"""
    cnt_battle_list = CurrentBattleList.objects.all()
    serializer = BattleListSerializer(cnt_battle_list, many=True)
    channel_layer = get_channel_layer()
    async_to_sync(channel_layer.group_send)(
        "lobby",
        {
            "type": "send_message",
            "method": "lobby_room_delete",
            "message": serializer.data,
        },
    )

 

 이렇게 receiver 데코레이터를 이용해 post_save(저장된 후에), post_delete(삭제된 후에) 해당 함수를 실행하도록 했다.

 

 지금 보니 lobby_room_add함수에서는 created 파라미터를 통해 방이 생성됐을 때 뿐만 아니라 방이 단순히 일부 업데이트됐을 때를 구분했는데, 원래는 변경된 방 정보만을 메세지로 보내줘서 자원을 줄여보고자 했으나 결국은 구분없이 전체 방 데이터를 보내주고 프론트엔드에서는 새로 그려주도록 해서 프로젝트를 마무리 지었다. 나중에 이 부분도 리팩토링할 때 고려해보면 좋을 것 같다.

 

 

결론

여기까지 해서 방 정보를 웹소켓으로 실시간 업데이트하는 기능 코드 리뷰를 마무리했다. 실제 실시간 배틀과 참여하는 유저 정보에 관한 내용은 다음 글에서 써보려고 한다.

 


레포지토리

 

BE

https://github.com/Churnobyl/han

 

GitHub - Churnobyl/han: 2023.06.05 - 2023.07.10

2023.06.05 - 2023.07.10. Contribute to Churnobyl/han development by creating an account on GitHub.

github.com

 

 

FE

https://github.com/Churnobyl/han_front

 

GitHub - Churnobyl/han_front

Contribute to Churnobyl/han_front development by creating an account on GitHub.

github.com

 

반응형