안녕하세요.
지난 포스트 에서 저희는 Channels를 이용할 밑준비를 마쳤습니다.
오늘은 다음 코드를 작성하기 전에, 저희가 설계할 실시간 채팅의 구조와 각각의 요소들이 어떤 역할을 하는지 한번 알아보겠습니다.
아래의 그림을 한번 볼까요?
(들어가기 전, 이 포스트는 비전공자가 독학으로 공부하여 작성하는 포스트입니다. 현업 개발자 분들은 보시다가 '아 코딩 그렇게 하는거 아닌데' 하실 수 있습니다. 모든 지적과 질문은 감사히 받겠습니다.)
1. 그래서 Channel Layer랑 이 함수들이 뭘 하는데?
실시간 채팅의 구조도입니다.
Channel Layer
Channel Layer 는 여러개의 Consumer(클라이언트)가 서로간에 통신(채팅)할 수 있게 해주는 역할을 하고, 다른 Django의 요소(View 등)와도 이어질 수 있게하는 역할을 합니다.
Channel Layer는 Channel과 Room 으로 구성됩니다.
위의 그림에서는 Client 1과 2가 각각의 Channel 이름을 가지고 'Room 1'안에서 서로 채팅을 할 수 있고, Client 3, 4는 'Room 2'에서 채팅을 할 수 있게 됩니다.
Daphne ASGI
Django는 Python으로 작동하며, 클라이언트 또는 웹서버와 상호작용 하기 위해선 중간 다리역할을 해주는 소프트웨어가 필요합니다. 쉽게 말해 외국어(Python) 통역사가 필요한거죠.
WSGI는 동기(Sync) 함수 처리만을 지원합니다. 하지만 우리가 사용하는 웹소켓은 비동기(Async) 방식입니다.
ASGI는 WSGI와 달리 비동기 처리를 지원 하며, HTTP 프로토콜이 아닌 WebSocket 프로토콜 또한 지원합니다. Daphne는 그런 ASGI를 지원하는 소프트웨어입니다.
ChatConsumer 클래스의 함수들
connect 함수는 클라이언트가 웹소켓 연결을 요청하면 Room 이름을 입력받아 클라이언트를 Room에서 통신할 수 있게 해줍니다.
async def connect ( self ):
self .room_id = self .scope[ "url_route" ][ "kwargs" ][ "room_id" ]
self .room_group_name = f "chat_ { self .room_id } "
# Room 에 참가
await self .channel_layer.group_add(
self .room_group_name, self .channel_name
)
await self .accept()
위의 코드에서는 WebSocket 연결 요청을 한 URL에 있는 'room_id'를 가져와서 'room_group_name' 으로 사용하고 있습니다. 아래의 'routing.py'를 참조해주세요.
# routing.py
websocket_urlpatterns = [
re_path( r "ws/chat/( ?P<room_id> \w + )/$" , consumers.ChatConsumer.as_asgi()),
] # <room_id>를 connect함수에서 self.scope를 이용해 가져옵니다.
그 다음, channel_layer.group_add() 메서드를 통해 Channel Layer에 'room_group.name'과 'channel_name'을 추가시켜 Room안에 클라이언트 채널을 집어넣습니다.
마지막으로 self.accept()를 통해서 연결을 수락합니다. ____________________________________________________________
receive 함수는 이미 연결되어 있는 웹소켓에서 데이터가 들어오면 이를 해당 Room에 전달하는 역할을 합니다.
async def receive ( self , text_data ):
text_data_json = json.loads(text_data)
message = text_data_json[ "message" ]
# --------- ChatConsumer Room에 메시지 전송
await self .channel_layer.group_send(
self .room_group_name, {
# type과 이를 처리하는 함수 이름은 동일해야 함.
"type" : "chat_message" ,
"header" : 'new_message' ,
"message" : message,
}
)
여기서 'text_data'는 채팅 내용을 포함한 클라이언트로 받은 JSON 데이터이며, message 변수에는 채팅 내용이 담기게 됩니다.
전달 받은 메시지를 마지막에는 Channel Layer안의 'room_group_name'에게 전달합니다.
여기서 'type': 'chat_message' 라고 적혀있는게 보이시나요? 마지막에 room_group_name을 전달함과 동시에, 어떤 함수가 클라이언트에게 처리된 데이터를 다시 보낼건지 적어둔게 'type'입니다.
즉, '채팅이 들어오면 채팅방에 참가한 사람들에게 'chat_message' 함수가 시키는 대로 채팅 내용을 전달해줘라' 라고 적어둔 코드인거죠 .
이 type은 꼭 'chat_message'라고 적으실 필요는 없습니다. 원하시는 이름이 있다면 바꾸셔도 됩니다. 단, 이를 처리해주는 함수 이름은 같아야겠죠? 가령 type을 'chatting'이라고 바꾼다면 밑의 함수 이름도 아래와 같이 바뀌어야 합니다.
# 그룹으로부터 메시지 수신
async def chatting ( self , event ):
____________________________________________________________
chat_message 함수는 그룹으로부터 수신한 채팅 내용을 그룹에 있는 클라이언트들에게 최종적으로 전송해주는 역할을 합니다.
# 그룹으로부터 메시지 수신
async def chat_message ( self , event ):
message = event[ "message" ]
# 웹소켓으로 JSON 메시지 전송
await self .send( text_data = json.dumps({
"header" : 'new_message' ,
"message" : message,
}))
self.send() 메서드를 통해 클라이언트에게 최종적으로 message 변수에 담긴 내용을 전달하게 됩니다.
2. 실제로 채팅을 해봅시다
Django 백엔드 측에서의 처리를 어느정도 이해했으니 이제 프론트엔드에서 어떻게 처리하고 통신하는지 한번 알아봅시다.
좀 더 쉬운 설명을 위해 지난 포스트에서 소개드린 코드에서 좀 더 간소화된 템플릿과 자바스크립트 코드를 들고 왔습니다.
참조를 위해 템플릿과 자바스크립트, CSS는 파일을 올려드리도록 하겠습니다.
닉네임과 채팅방 이름을 치고 들어가는 구조입니다.
저희가 알아볼 건, 프론트엔드 JS 코드입니다.
let chatSocket;
const connect_btn = document. getElementById ( 'connect' );
const disconnect_btn = document. getElementById ( 'disconnect' );
const DM_room_name = document. getElementById ( 'DM-nickname' );
connect_btn. addEventListener ( 'click' , function () {
if (chatSocket) {
console. log ( '이미 채팅방에 참가 중입니다.' );
}
else {
let chatroom_input = document. getElementById ( 'chatroom-input' );
get_DM_room (chatroom_input. value );
}
});
DM.js
function get_DM_room (room_name) {
// 웹소켓 URL을 서버에 요청
chatSocket = new WebSocket (
'ws://' +
window. location . host + '/ws/chat/' + room_name + '/'
);
// 웹소켓이 열릴때의 처리
chatSocket. onopen = function () {
console. log ( `' ${ room_name } ' 채팅방에 접속하였습니다.` );
DM_room_name. innerText = room_name;
}
// 메시지가 도착하는 경우의 처리
chatSocket. onmessage = function (e) {
// 서버로부터 수신한 JSON 데이터를 Parsing
const data = JSON . parse (e. data );
const message_div = document. createElement ( 'div' );
message_div. setAttribute ( 'class' , 'DM-to' );
const ballon_to_div = document. createElement ( 'div' );
ballon_to_div. setAttribute ( 'class' , 'balloon-to' );
ballon_to_div. innerText = data. message ;
message_div. appendChild (ballon_to_div);
document. getElementById ( 'DM-window' ). appendChild (message_div);
// 채팅 입력 시, 자동으로 아래로
document. getElementById ( 'DM-window' ). scrollTop = document. getElementById ( 'DM-window' ). scrollHeight ;
}
// 웹소켓이 닫힐 때의 처리
chatSocket. onclose = function (e) {
console. log ( '채팅방에서 나왔습니다.' );
DM_room_name. innerText = '' ;
}
// 엔터 누르면 전송 버튼이 클릭되게 설정
document. querySelector ( '#sending-textarea' ). onkeydown = function (e) {
if (e. keyCode == 13 && ! e. shiftKey ) { // enter, return
document. querySelector ( '#submit-message' ). click ();
}
};
document. querySelector ( '#sending-textarea' ). onkeyup = function (e) {
if (e. keyCode == 13 && ! e. shiftKey ) { // enter, return
document. querySelector ( '#submit-message' ). click ();
}
};
// 전송 버튼 이벤트
document. querySelector ( '#submit-message' ). onclick = function (e) {
const messageInputDom = document. querySelector ( '#sending-textarea' );
const message = messageInputDom. value ;
let nickname = document. getElementById ( 'nickname-input' ). value ;
if (message != '' && message != " \n " ) {
// WebSocket에 JSON 데이터 전송
chatSocket. send ( JSON . stringify ({
'message' : message,
'nickname' : nickname,
'room_id' : room_name,
}));
}
messageInputDom. value = '' ;
};
}
DM.js
코드의 일부를 발췌했습니다. '입장' 버튼을 누르면 채팅방 이름을 입력값으로 받는 'get_DM_room' 함수를 실행하게 되는 구조입니다. 한 부분씩 살펴봅시다.
// 웹소켓 URL을 서버에 요청
chatSocket = new WebSocket (
'ws://' +
window. location . host + '/ws/chat/' + room_name + '/'
);
DM.js
'chatSocket'은 이미 선언된 전역 변수이고, 여기에 new WebSocket을 통해 새로운 웹소켓 연결을 할당합니다.
// 웹소켓이 열릴때의 처리
chatSocket. onopen = function () {
console. log ( `' ${ room_name } ' 채팅방에 접속하였습니다.` );
DM_room_name. innerText = room_name;
}
DM.js
onopen 에서는 WebSocket 연결이 성공했을 때의 이벤트를 처리해주시면 됩니다. 저는 채팅방 윗부분에 현재 채팅방 이름이 나오게 바꿔봤습니다.
입장 버튼을 누르면 현재 채팅방 이름이 채팅방 상단에 나옵니다.
// 메시지가 도착하는 경우의 처리
chatSocket. onmessage = function (e) {
// 수신한 JSON 데이터를 Parsing
const data = JSON . parse (e. data );
const message_div = document. createElement ( 'div' );
message_div. setAttribute ( 'class' , 'DM-to' );
const ballon_to_div = document. createElement ( 'div' );
ballon_to_div. setAttribute ( 'class' , 'balloon-to' );
ballon_to_div. innerText = data. message ;
message_div. appendChild (ballon_to_div);
document. getElementById ( 'DM-window' ). appendChild (message_div);
// 채팅 입력 시, 자동으로 아래로
document. getElementById ( 'DM-window' ). scrollTop = document. getElementById ( 'DM-window' ). scrollHeight ;
}
DM.js
onmessage에서는 웹소켓으로부터 데이터를 수신한 경우에 프론트 측에서의 처리 내용을 적어주면 됩니다. 즉, 새로운 채팅 메시지가 오면 말풍선을 채팅창에 띄워주는 코드를 여기 기재해둡니다.
'JSON.parse(e.data) '를 통해 수신한 JSON을 파싱해서 data.message와 같은 식으로 JSON 데이터를 이용할 수 있게 합니다 . 아까의 백엔드 코드를 다시 보면 백엔드에서 'message': message 와 같은 형식으로 넘겨준 메시지를 프론트엔드에서는 ballon_to_div에 innerText를 수정 하여 표시하게 됩니다.
# 웹소켓으로 JSON 메시지 전송
await self .send( text_data = json.dumps({
"header" : 'new_message' ,
"message" : message,
}))
consumers.py
// 전송 버튼 이벤트
document. querySelector ( '#submit-message' ). onclick = function (e) {
const messageInputDom = document. querySelector ( '#sending-textarea' );
const message = messageInputDom. value ;
let nickname = document. getElementById ( 'nickname-input' ). value ;
if (message != '' && message != " \n " ) {
// WebSocket에 JSON 데이터 전송
chatSocket. send ( JSON . stringify ({
'message' : message,
'nickname' : nickname,
'room_id' : room_name,
}));
}
messageInputDom. value = '' ;
};
DM.js
전송 버튼을 누르고 아무 내용이 없는게 아니라면 chatSocket에 할당된 WebSocket을 통해 채팅 내용을 전송하고, 메시지 창을 비웁니다. 아직 닉네임이 쓰이진 않지만 추후 사용되니 일단 같이 보내놓읍시다.
한번 작동 모습을 볼까요? 정말로 1대1 채팅이 되는지 확인하기 위해 127.0.0.1:8000에 runserver를 실행하지 않고, 제 공유기 로컬 아이피인 192.168.0.2:8000에다가 테스트 서버를 열고 한번 PC와 핸드폰으로 접속해서 채팅을 동시에 쳐보겠습니다.
만약 따라하시는데 Disallowed Host 에러가 뜬다면, settings.py에서 ALLOWED_HOSTS = [] 부분에 '192.168.0.2'와 같이 아이피 주소를 입력해주세요.
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = [ '192.168.0.2' ,]
settings.py
[실제 테스트 모습]
잘 되는군요! 아직 상대방이 보낸 메시지('하이하이')가 제가 보낸것 처럼 나오긴 하지만 어쨌든 상대방과 실시간 채팅을 하게 되었습니다.
이번 포스팅은 여기까지 하고, 다음 포스팅에서는 좀 더 구체적인 기능들을 한번 구현해보겠습니다.
긴 글 읽어주셔서 감사합니다.