최종 프로젝트인 만큼 리뷰할 게 많아 글을 두 개 세 개로 나눴다.
주요 코드 리뷰
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.models의 signals 기능을 사용했다.
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
FE
https://github.com/Churnobyl/han_front
'프로그래밍 > 생각나는대로 프로젝트' 카테고리의 다른 글
[프로젝트] 08. 한글 학습 퀴즈 서비스 - 한 (Part 1. 퀴즈) (0) | 2023.07.17 |
---|---|
[프로젝트] 07. 가계부 SNS - 리치메이커 (0) | 2023.05.31 |
[프로젝트] 06. DRF 경매 서비스 - 20세기 박물관 (0) | 2023.05.16 |
[프로젝트] 05. Django SNS CaMu (0) | 2023.04.16 |
[프로젝트] 04. Django 무신사 재고 관리 시스템 (0) | 2023.04.10 |