슈코딩
[Django] Channels 채팅기능 도전하기! 본문
오늘은 이거대여. 프로젝트에서 사용한 채팅기능을 복습할겸 정리해보려고 합니다. 우선 비동기적, 실시간 채팅을 구현하기 위해서 WebSocket을 사용했고 Django에서는 Channels 라는 라이브러리를 이용하면 WebSocket을 사용할 수 있게 됩니다. 여기서 데이터를 받는 비동기와 동기에 대해서도 알아야 하기때문에 제가 참고했던 블로그를 공유하겠습니다.
https://velog.io/@alicia-mkkim/%EB%8F%99%EA%B8%B0-%EB%B9%84%EB%8F%99%EA%B8%B0%EB%9E%80
Channels는 Django를 확장해 WebSocket과 같이 HTTP가 아닌 프로토콜을 핸들링할 수 있게 돕고 비동기적인 처리를 가능하게 해주는 ASGI의 구현체로, 장고를 이용한 실시간 채팅 구현 등에 활용할 수 있습니다.
https://asgi.readthedocs.io/en/latest/
WebSocket은 프로토콜로서, 실시간으로 데이터를 양방향으로 통신 할 수 있게 해주는 기술입니다. Socket 은 네트워크상에서 동작하는 프로그램 간 통신의 종착점이라는 개념인데, 프로그램이 네트워크에서 데이터를 통신할 수 있도록 해주는 연결부라고 합니다. 기존에 사용하던 HTTP 기반 request/response 로 데이터를 주고받는것은 네트워크의 연결을 유지하지 않는 특징을 가지고 있습니다. 그래서 실시간으로 채팅과같은 기능을 주고받기 위해서는 WebSocket이 필요합니다.
Django Channels를 사용하게 되면 위 그림과 같은 방식으로 HTTP 외에 WebSocket 통신도 channel layer를 통해서 구분하고 HTTP 는 기존방식대로 View로 가서 이를 처리하고 WebSocket은 consumer로가서 요청을 처리합니다.
WebSocket은 HTTP와 다르게 wsgi가 아닌 asgi 이기 때문에 asgi.py에도 따로 설정을 해두고 url이 들어가는곳을 chat app의 urls.py가 아닌 routing.py 로 설정을 해줬습니다.
asgi.py
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'egodaeyeo.settings')
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from django.core.asgi import get_asgi_application
import chat.routing
application = ProtocolTypeRouter({
'http': get_asgi_application(),
'websocket': AuthMiddlewareStack(
URLRouter(
chat.routing.websocket_urlpatterns
)
)
})
routing.py
from django.urls import re_path, path
from . import consumers
websocket_urlpatterns = [
path('chats/<int:room_id>', consumers.ChatConsumer.as_asgi()),
path('chats/contracts/<int:room_id>', consumers.ContractConsumer.as_asgi()),
path('chats/alerts/<int:user_id>', consumers.AlertConsumer.as_asgi()),
]
consumers.py
# 채팅 컨슈머
class ChatConsumer(AsyncWebsocketConsumer):
async def connect(self):
# url에 room_id를 받아서 가져온다.
self.room_id = self.scope['url_route']['kwargs']['room_id']
self.room_group_name = 'chat_%s' % self.room_id
# Join room group
await self.channel_layer.group_add(
self.room_group_name,
self.channel_name
)
await self.accept()
async def disconnect(self, close_code):
await self.channel_layer.group_discard(
self.room_group_name,
self.channel_name
)
async def receive(self, text_data):
received_data = json.loads(text_data)
message = received_data.get('message')
sender_id = received_data.get('sender')
receiver_id = received_data.get('receiver')
room_id = received_data.get('room_id')
item_id = received_data.get('item_id')
if not message:
print('Error:: empty message')
return False
sender = await self.get_user_object(sender_id)
receiver = await self.get_user_object(receiver_id)
room_obj = await self.get_chatroom(room_id)
if not sender:
print('Error:: sent by user is incorrect')
if not receiver:
print('Error:: send to user is incorrect')
if not room_obj:
print('Error:: Header id is incorrect')
await self.create_chat_message(room_obj, sender, message)
self_user = sender
now_date = datetime.now().strftime('%Y년 %m월 %d일 %A')
now_time = datetime.now().strftime('%p %I:%M')
response = {
'message': message,
'sender': self_user.id,
'room_id': room_id,
'date': now_date,
'time': now_time,
'item_id': item_id,
}
# 현재그룹에 send
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'chat_message',
'text': json.dumps(response)
}
)
async def chat_message(self, event):
text = json.loads(event['text'])
message = text['message']
now_time = text['time']
now_date = text['date']
room_id = text['room_id']
item_id = text['item_id']
sender = text['sender']
# Send message to WebSocket
await self.send(text_data=json.dumps({
'message': message,
'time': now_time,
'date': now_date,
'room_id': room_id,
'item_id': item_id,
'sender': sender
}))
@database_sync_to_async
def get_user_object(self, user_id):
qs = User.objects.filter(id=user_id)
if qs.exists():
obj = qs.first()
else:
obj = None
return obj
@database_sync_to_async
def get_chatroom(self, room_id):
qs = ChatRoom.objects.filter(id=room_id)
if qs.exists():
obj = qs.first()
else:
obj = None
return obj
@database_sync_to_async
def create_chat_message(self, room, sender, content):
ChatMessage.objects.create(room=room, user=sender, content=content)
@database_sync_to_async 데코레이터를 활용하면 비동기적으로 빠르게 들어오는 통신속에서 DB에 접근해서 정보를 수정하거나, 생성, 조회가 가능하게 됩니다. 이를 사용하기 위해서 WebSocketConsumer가 아닌 AsyncWebSocketConsumer 를 활용해서 조금더 비동기적 처리에 용이할것이라 생각하고 사용을 했습니다.
채팅기능을 구현하기 위해서 Channels와 WebSocket을 처음 접하고 개념들이 이해하는데 어려움이 있었지만, 영상도 찾아보고 관련자료도 열심히 찾아보고 캠퍼분의 도움도 받아서 하나 둘씩 구현해나가던중 점점 consumer에서 일어나는 일들의 순서가 머릿속에 들어오기 시작했습니다. 프론트에서 WebSocket을 연결하면 consumer 의 connect로 들어와서 연결을 하고 메세지를 send할때 receive로 들어와서 처리를 하고 self.send를 통해 다시 프론트로 원하는 데이터를 담아서 보내는 순서를 알고나서 단순채팅 뿐아니라 시간정보외 필요한 정보들을 DB에서 조회하거나 생성하며 다양하게 활용을 해볼 수 있었습니다.
Issue #1
문제: 채팅방이 달라도 유저1과 유저2가 메세지를 주고 받을 수 있는 현상
원인: 프론트에서 WebSocket에 연결을 페이지 접속시에 연결을했고, consumer에서 send 하는 group의 이름에 room_id가 아닌 user_id를 받아서 적용시킴
해결: url에 해당 room_id를 받아서 보내고, WebSocket 연결도 해당 채팅방에 접속했을때만 연결이 되게 코드수정
Issue #2
문제: Issue#1 에서 수정후에 채팅방을 옮기고나서 기존에 열렸던 WebSocket이 disconnect가 안되는 현상
원인: JS 코드 함수내에서 WebSocket을 연결해서 close() 메소드가 WebSocket을 인식을 못함.
해결: 연결한 WebSocket을 따로 전역변수에 저장하고나서 close메소드를 실행
코드
// 채팅방 열기 // 선택된 채팅방 웹소켓 주소 저장 var chatSocket = '' var contractSocket = '' async function openChatRoom(roomId) { // 이미 접속한 채팅, 거래 웹소켓이 있다면 종료 if (chatSocket != '' && contractSocket != '') { chatSocket.close() contractSocket.close() chatSocket = '' contractSocket = '' } // 선택한 채팅방 스타일 효과 $('.lend-room').attr('style', 'background-color: rgb(255, 239, 194)') $('.borrow-room').attr('style', 'background-color: rgb(191, 255, 194)') const selectedChatRoom = document.getElementById(`chat-room-${roomId}`) selectedChatRoom.style.boxShadow = '5px 5px 5px yellowgreen' // 선택한 채팅방 알림 효과 끄기 new Alert().offAlertEffect(roomId) // 채팅 웹소켓 new Websocket().chatWebsocket(roomId) // 채팅룸 데이터 API const roomData = await chatRoomApi(roomId) // 거래 웹소켓 new Websocket().contractWebsocket(roomId, roomData) // 채팅방 내용 생성 new CreateElement().chatRoomElements(roomId, roomData) }
Issue #3
문제: Docker를 활용해서 NGINX와 WEB 컨테이너만 생성해서 배포시 WebSocket 연결 불가능
원인: 배포시에 daphne를 활용해서 따로 컨테이너를 생성해야 한다는걸 모르고 있었음
해결: asgiserver 컨테이너를 생성하여 WebSocket 전용 port를 daphne를 사용해서 열어줌
docker-compose.prod.yml
version: "3.9" services: nginx: build: ./nginx volumes: - static_volume:/usr/src/app/static - media_volume:/usr/src/app/media - ./data/certbot/conf:/etc/letsencrypt - ./data/certbot/www:/var/www/certbot ports: - 80:80 - 443:443 depends_on: - web restart: always certbot: image: certbot/certbot volumes: - ./data/certbot/conf:/etc/letsencrypt - ./data/certbot/www:/var/www/certbot web: build: . command: gunicorn egodaeyeo.wsgi:application --bind 0.0.0.0:8000 ports: - 8000:8000 working_dir: /usr/src/app/ env_file: - ./.env.prod volumes: - ./:/usr/src/app/ - static_volume:/usr/src/app/static - media_volume:/usr/src/app/media expose: - 8000 restart: always asgiserver: build: . command: daphne -u /tmp/daphne.sock egodaeyeo.asgi:application --bind 0.0.0.0 -p 8080 ports: - 8080:8080 working_dir: /usr/src/app/ volumes: - ./:/usr/src/app/ env_file: - ./.env.prod expose: - 8080 restart: always volumes: static_volume: media_volume:
'개발일지 > Project' 카테고리의 다른 글
[Django, JavaScript] 백엔드 페이지네이션 적용, 프론트 무한 스크롤 (0) | 2022.07.25 |
---|---|
[Django, JavaScript] 프론트엔드, 백엔드 분리 카카오 로그인 구현 (0) | 2022.07.20 |
[Django] DRF, Final Project S.A (0) | 2022.07.14 |
[Django] DRF 활용한 딥러닝 이미지처리 Project (0) | 2022.06.29 |
[Django] 추천시스템 프로젝트 코드복기 2 (0) | 2022.06.17 |