
해당 포스팅은 유투버 Dennis Ivy의 『Django Channels & WebSockets Oversimplified』를 시청하여 작성 되었습니다.
동영상 바로가기
동영상 속 깃헙 바로가기
Channels 패키지는 ASGI(Asynchronous Server Gateway Interface) 애플리케이션 서버 위에서 작동하며 HTTP 프로토콜 +α(WebSockets, Chat Protocols, IoT Protocols) 를 가능하게 해준다.
클라이언트 - 서버간 양방향 오픈 통신을 구현하기 위해 Channels + WebSockets이 사용된다.
서버 셋업 및 양방향 통신을 구현하기 위해 다음 네 가지 단계가 필수적이다;
Step 1️⃣: 서버측 ASGI 설정
Step 2️⃣: 서버측 Consumers(채널에서 장고 뷰 역할을 하는 것) 생성
Step 3️⃣: 서버측 Routing(Consumers를 위한 url routing) 설정.
Step 4️⃣: 클라이언트 측에서 양방향 통신을 위한 hand-shaking을 시작. 즉, HTML 파일에서 WebSockets(자바스크립트 빌트인 API)을 설정해줌.
우선 가장 기초적인 형태의 장고 뷰를 만들어 화면을 확인한다. views.py에서 lobby 함수를 만들었고, html 파일을 만들어 렌더링 하게 해줬다. 개발서버를 실행하면 아래와 같은 상태에서 실습을을 시작한다;

pip install channels 이후 프로젝트 settings.py > INSTALLED_APPS에 'channels'를 추가해 준다.
채널 서버로 HTTP 요청이 왔을 때 어떻게 처리해주어야 하는지를 정의하기 위해 다음 두 가지 파일에서 설정을 추가해 준다;
asgi.py
...(생략)
from channels.routing import ProtocolTypeRouter
...(생략)
application = ProtocolTypeRouter({
'http': get_asgi_application() # 추후 변경 예정
})
settings.py
...(생략)
INSTALLED_APPS = [..., 'channels', ...]
ASGI_APPLICATION = 'Rest.asgi.application'
MIDDLEWARE = [...]
...(생략)
이후 개발서버를 실행시켜 터미널 메세지에서 ASGI 서버로 장고가 실행됨을 확인한다.

이상하다. Starting development server at http://127.0.0.1:8000/는 장고의 기본 개발서버(WSGI)가 실행되었다는 메세지이다..
공식문서를 찾아보았다.
pip install daphne 이후 daphne 패키지를 settings.py > INSTALLED_APPS에 추가시켜줘야 한다는 내용이 있다.
따라해준 후 다시 서버 실행;

서버가 ASGI/Daphne를 통해 잘 실행되고 있음을 확인했다.
일단 Daphne는 ASGI 애플리케이션을 실행하는 ASGI 서버로,
WSGI 애플리케이션을 실행할 수 있는 대표적인 uWSGI인 Gunicorn과 같은 역할을 하는 ASGI 서버이다.
유툽 튜토리얼에서는 channels==3.x.x 버전을 사용하는데,
이때까지만 해도 Daphne가 Channels에 의존성으로 포함되어 있었다고 한다.
즉, Channels만 설치해도 Daphne가 자동으로 딸려 왔던 것.
하지만 channels==4.x.x 부터 의도적으로 Channels와 Daphne가 분리 되었다. 이유는 다음과 같다;
1. 경량화: Channels는 라우팅/프로토콜 처리 프레임워크만 제공.
2. ASGI 서버 선택지 증가: 최근에는 uvicorn(Fast API에서 사용)이나 gunicorn 등도 ASGI 서버로 많이 선택되는 추세이기 때문에 프로젝트가 Daphne를 사용할 필요가 없어졌다. 따라서 개발자가 원하는 ASGI 서버를 직접 선택할 수 있도록 했다.
소켓 연결을 위해 다음 스크립트를 html에 추가해 준다;
<script type="text/javascript">
let url = `ws://${window.location.host}/ws/socket-server/`; // hand-shake 엔드포인트 (http 대신 웹소켓 사용!)
const chatSocket = new WebSocket(url); // 웹소켓 오브젝트 생성
chatSocket.onmessage = function(e){ // 서버 메세지를 리슨하기 위한 onmessage 이벤트 함수 정의
let data = JSON.parse(e.data);
console.log('데이터: ', data)
}
</script>
위 코드는 웹소켓 사용을 위한 클라이언트측 초기 설정이다.
현재 페이지 도메인에 맞춰 웹 소켓 연결을 생성하고, 서버에서 메세지를 수신하면 JSON으로 파싱하여 그 결과를 콘솔에 출력한다.
상세 설명은 다음과 같다;
let url = ... 부분에서는 웹소켓 연결을 위한 URL을 설정한다.
ws://은 WebSocket Protocol을 나타내며, 나중에 보안 연결을 위해서는 wss://가 필요하다.
window.location.host는 현재 웹사이트의 도메인과 포트를 가져온다.
아마 개발 서버에서는 localhost:8000을, 운영서버에서는 saeminister.store를 가져올 것이다.
결과적으로 변수 url에는 ws://localhost:8000/ws/socket-server/가 담길 것이다.
const chatSocket = ..부분에서는 웹소켓 객체를 생성한다.
해당 시점에서 브라우저는 서버와 웹소켓 연결을 시도하게 된다.
chatSocket.onmessage = ..부분은 서버로부터 메세지를 수신했을 때 실행될 콜백 함수이다.
JSON 문자열 형식인 서버의 메세지(e.data)를 자바스크립트 객체로 변환한 뒤,
그것을 콘솔에 출력하여 내용을 확인한다.
콜백 함수란?
콜백 함수란 어떤 일이 일어난 후에 실행되도록 전달하는 함수이다.
예를 들어 폼을 제출하는 fetch() 함수 안에서 사전에 따로 만들어둔 유효성 검사 함수들을 호출하는 경우, 이 유효성 검사 함수들이 콜백함수일 수 있다.
웹소켓 설정의 경우, onmessage는 메세지 수신시 자동으로 호출할 함수를 등록하는 속성이며, 이후에 나오는 function(e)가 콜백 함수가 된다.
이제 클라이언트측에서 소켓을 연결할 준비는 되었지만
클라이언트의 요청을 보낼 곳이나 수신자가 없다.
+) 라우팅은 왜 필요한거야? 라우팅은 어떤 수신자에게 요청이 가야 하는지 지정해 주는 역할을 한다.
실제로 개발자 도구를 열어보면 다음과 같은 오류가 뜨는 것을 볼 수 있다. 이는 클라이언트의 비동기 요청을 받을 endpoint가 서버측에 마련되어 있지 않기 때문이다.

이를 위해 서버측에서 routers와 consumers 설정이 필요하다.
Consumer는 웹소켓 요청을 처리하는 Django 서버의 핸들러이다.
기존의 HTTP 요청에서 장고 뷰 역할과 유사하다. 즉, 웹 소켓 요청은 HTTP와 다른 방식이기 때문에 장고의 기본 구조로 처리할 수 없다. 따라서 장고에서는 웹 소켓 처리를 위해 ASGI(Asynchronous Server Gateway Interface) 방식이 필요하다.
# 채널을 위한 장고 뷰와 같은 것: 양방향 통신 스트림에서 클라 요청에 대한 응답 + 서버쪽에서 클라이언트로 요청 송신 기능
import json
from channels.generic.websocket import WebsocketConsumer
# 컨수머 생성
class ChatConsumer(WebsocketConsumer):
# 실시간으로 클라이언트의 요청에 응답 + 해당 컨수머와 연결되어 있는 다른 클라이언트들에게 브로드캐스팅
# 특정 컨수머와 연결하는건 어떻게하는거임?
# 클라이언트의 연결 요청 처리
def connect(self):
self.accept() # 클라이언트의 연결 요청 수락
self.send(text_data=json.dumps({ # 연결이 수립되었음을 서버쪽에서 클라이언트로 송신
'type': 'connection_established',
'message': '양방향 통신 연결됨!'
}))
# 클라이언트로부터 수신된 메세지 처리
# def receive(self, text_data=None):
# return
# 클라이언트가 연결 해제시 처리
# def disconnect(self, close_code):
# return
Router는 웹소켓 경로(URL)을 어떤 Consumer가 처리할지 지정해 주는 역할을 한다.
HTTP 요청을 처리하는 장고 앱의 urls.py와 유사하다고 생각하면 된다. (단, rounting.py는 웹 소켓 요청을 처리함)

코드는 다음과 같다;
from django.urls import re_path # 이건 왜하는거? Due to the limitations that we have in url router (?)
from . import consumers
websocket_urlpattersn = [
# 프론트에서 최초의 소켓 연결을 위해 설정한 end-point를
# ChatConsumer로의 라우팅 설정
# 즉, 소켓 연결을 통해 클라이언트가 요청 송신시 ChatConsumer(채널?)로 가도록(?)
re_path(r'ws/socket-server/', consumers.ChatConsumer.as_asgi())
]
이때 re_path()는 정규표현식 기반의 URL 맵핑을 위해 사용된다.
장고에서는 보통 path()를 사용한다.
하지만 비동기통신을 위해 설치한 Channels 패키지에서는 re_path()를 웹소켓 라우팅의 표준처럼 사용하여 더 유연한 URL 패턴을 수용 가능하도록 한다.
장고 공식문서에서 해당 내용을 찾아볼 수 있다.
+) 정확하게 re_path()를 왜 사용하는지는 아직 잘 모르겠다.
r은 'raw_string'을 의미하는 파이썬의 문자열 접두사 이다.
정규표현식에서는 \를 자주 사용하게 되는데, 일반 문자열에서는 이게 이스케이프 문자로 처리된다. 이때 문자열 앞에 r를 붙여주게 되면 이스케이프 문자를 무시하고 문자 그대로 해석한다.

import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
# AuthMiddleware는 장고 표준 인증을 위해 SessionMiddleware를 필요로 하며, SessionMiddleware는 CookieMiddleware를 필요로함.
# 편의를 위해 세가지 미들웨어들이 전부 AuthMiddlewareStack에 포함되어 있음.
import myapp.routing
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "Rest.settings")
application = ProtocolTypeRouter({
'http': get_asgi_application(),
'websocket': AuthMiddlewareStack( # AuthMiddlewareStack로 URLRouter 감싸기
URLRouter(
myapp.routing.websocket_urlpattersn # URLRouter에 websocket_urlpattersn 목록 전달
)
)
})
세션 미들웨어 설정으로 인해 데이터베이스에 변경사항이 발생하였음으로 python manage.py migrate를 해준 뒤 서버를 실행시켜 준다.
+) migrate랑 makemigrations 차이?
+) 장고 세션은 DB에 어떻게 저장 되는가?

와우! 웹소켓을 통한 클라이언트 - 서버간 양방향 연결이 설정된 것이 보인다!
해당 데이터는 서버 쪽에서 클라이언트로 보내진 메세지라는 점에서
클라이언트의 요청에 일방적으로 응답만 하는 일방향 HTTP 통신만 하던 나에게 (?)
아주 기념비적인 일이다! 🥳
기존의 html 파일에서 채팅을 입력할 폼을 만들어준 뒤
웹소켓을 통해 채팅 내용을 서버로 전송하는 기능을 만들어준다;
<form id="form">
<input type="text" name="message">
</form>
<script type="text/javascript">
// 소켓 연결 설정 ==================================================
let url = `ws://${window.location.host}/ws/socket-server/`; // hand-shake 엔드포인트 (http 대신 웹소켓 사용!)
const chatSocket = new WebSocket(url); // 웹소켓 오브젝트 생성
chatSocket.onmessage = function(e){ // 서버 메세지를 리슨하기 위한 onmessage 이벤트 함수 정의
let data = JSON.parse(e.data);
console.log('데이터: ', data)
}
// 클라이언트측에서 메세지 송신 ==================================================
let form = document.getElementById('form');
form.addEventListener('submit', (e) => {
e.preventDefault(); // 디폴트 동작이 뭐길래? 이건 왜 하는거?
let message = e.target.message.value;
chatSocket.send(JSON.stringify({ // chatSocket(위에서 만든 웹소켓 오브젝트) 내에 포함되어 있는 send() 기능을 통해 stringify된 유저 인풋값을 서버로 전송
'message': message
}));
form.reset(); // 폼 리셋
})
</script>
// 디폴트 동작이 뭐길래? 이건 왜 하는거? submit 기본동작이 서버로 HTTP 요청 보내는거. WebSocket.send()로 요청 보내야함
기존의 ChatConsumer 클래스 하위에 receive() 함수를 정의해 준다;
# 클라이언트로부터 발신된 메세지 수신 기능
def receive(self, text_data=None):
text_data_json = json.loads(text_data)
message = text_data_json['message']
print('Message: ', message) # 일단 터미널에 출력해보기
이후 프론트에서 채팅을 제출하면 서버 콘솔에 정상적으로 찍히는 것을 테스트 했다.

이제 웹 소켓을 통해 정상적으로 채팅 내용이 서버로 들어오는 것을 확인 했으니,
다수의 유저를 만들어 실제로 같은 채널에 있는 유저들이 서로의 채팅 내용을 공유할 수 있는지(즉, 서버가 브로드캐스팅을 잘 해주는지) 확인을 해보자.
위에서 정의한 receice() 함수에 self.send() 부분을 추가해 준다;
# 클라이언트로부터 발신된 메세지 수신 기능
def receive(self, text_data=None):
text_data_json = json.loads(text_data)
message = text_data_json['message']
print('Message: ', message) # 일단 터미널에 출력해보기
self.send(text_data=json.dumps({ # 채널에서 수신한 메세지를 브로드캐스팅
'type': 'chat',
'message': message
}))
다른 유저의 채팅 내용을 노출학 div 태그를 만들어 주고,
서버 메세지를 리슨하는 onmessage 이벤트 발생시 if() 구문을 아래와 같이 추가해 준다;
(서버로부터 다른 유저의 채팅 메세지를 수신하면 클라이언트의 채팅창에 div 태그를 생성하여 보여줌)
...(폼 태그)...
<div id="messages"></div>
<script type="text/javascript">
// 소켓 연결 설정 ==================================================
let url = `ws://${window.location.host}/ws/socket-server/`; // hand-shake 엔드포인트 (http 대신 웹소켓 사용!)
const chatSocket = new WebSocket(url); // 웹소켓 오브젝트 생성
chatSocket.onmessage = function(e) { // 서버 메세지를 리슨하기 위한 onmessage 이벤트 함수 정의
let data = JSON.parse(e.data);
console.log('데이터: ', data)
if (data.type == 'chat') {
let message = document.getElementById('messages');
message.insertAdjacentHTML('beforeend', `<div>
<p>${data.message}</p>
</div>`)
}
}
...(생략)...
</script>
이후 두개의 브라우저를 띄워두고 채팅을 입력하면 서로의 채팅 내용이 보여야 한다.

하지만 그런 일은 일어나지 않았다.
왜냐하면 위 두개의 크롬 브라우저는 독립적인 웹 소켓 연결을 하고 있고,
따라서 서로의 존재를 모르기 때문이다!🤔
뭔지 알것 같은데 뭔소린지 모르겠음
웹소켓은 브라우저 하나하나가 서버에 개별적으로 연결을 맺는 구조이다.
위의 예시에서 크롬창 A와 크롬창 B에서 각각 채팅을 보낼 때,
크롬창 A -> 서버
크롬창 B -> 서버
이렇게 2개의 독립적인 연결이 생긴다.
이때 크롬창 A로부터 서버가 메세지를 수신한 후, 다시 어디로 보내줘야 할지 정해주지 않으면
연결된 소켓을 통해 크롬창A에게 다시 메세지를 보내고 끝난다. (현재 상황)
크롬창 A로부터 서버가 메세지를 수신한 시점에, 서버는 크롬창 B가 연결이 되어 있는지 혹은 URL도 누구인지도 알지 못하는 것이다.
이때 Channels Layer가 필요하다.
Channels Layer는
서버가 여러 웹소켓 연결들(=브라우저들)을 같은 '그룹'으로 묶어
메세지를 공유할 수 있도록 만들어주는 도구이다.
Channels Layer를 사용하면
서버가 크롬창 A로부터 메세지 수신시
크롬창 A와 크롬창 B가 같은 그룹에 있는 것을 확인하고
메세지를 크롬창 B에게도 전송해 준다.
Channels: 하나하나의 웹 소켓 연결 (예를들어 2030 모임 채팅방에 10명이 있다면, 10개의 채널이 존재함.)
Groups: 여러 채널들의 묶음, 집합, 그룹. (예를 들어 2030 모임 채팅방이 있다면, 이건 10개의 채널을 갖는 하나의 그룹.)
일단 간단한 개념만 알고 코드를 작성한 후 설명을 이어가 보자.
settings.py에서 ASGI_APPLICATION 설정 아래에 다음 내용을 추가;
# 테스트용(개발서버용) only
# 운영용으로는 InMemoryDatabase로 Redis를 사용해야함
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels.layers.InMemoryChannelLayer'
}
}
위에서 Channels Layer는 '서버가 여러 웹소켓 연결들을 같은 "그룹"으로 묶어 메세지를 공유할 수 있도록 만들어주는 도구'라고 설명했다.
그리고 그룹의 정보는 In-Memory-Database에 저장된다.
import json
from channels.generic.websocket import WebsocketConsumer
from asgiref.sync import async_to_sync
class ChatConsumer(WebsocketConsumer):
def connect(self):
self.room_group_name = 'test' # 채팅방 이름
async_to_sync(self.channel_layer.group_add)( #
self.room_group_name,
self.channel_name
)
self.accept() # 클라이언트의 연결 요청 수락
def receive(self, text_data=None):
text_data_json = json.loads(text_data)
message = text_data_json['message']
async_to_sync(self.channel_layer.group_send)(
self.room_group_name,
{
'type': 'chat_message',
'message': message
}
)
def chat_message(self, event):
message = event['message']
self.send(text_data=json.dumps({
'type': 'chat',
'message': message
}))
위 코드를 하나씩 떼어서 설명해 보도록 하자;
1️⃣ connect(self)는 클라이언트가 웹소켓 연결을 최초로 시도할 시 호출되는 함수이다.
def connect(self):
self.room_group_name = 'test'
async_to_sync(self.channel_layer.group_add)(
self.room_group_name,
self.channel_name
)
self.accept() # 웹소켓 연결 수락
위에서는 'test'를 채팅방 이름으로 설정하고 있으며,
이후 웹 소켓 연결을 그룹에 추가하고 있다.
코드를 하나씩 뜯어 설명하자면 우선 async_to_sync(self.channel_layer.group_add)(...)는 '비동기 함수'를 '동기 함수'로 변환한 후 (...) 안의 내용을 바로 실행하는 문법이다.
그렇다면 왜 비동기 함수를 동기 함수로 변환해야 하는가?

Django Chanels에는 두 종류의 Consumer가 있는데, 그중 내가 상속받은 WebsocketConsumer는 동기형이다. 때문에 내부 함수들은 모두 동기 함수를 사용해야 한다.
(그게 싫으면 AsyncWebsocketConsumer를 상속 받은 후 await과 async를 사용하여 대체할 수도 있다.)
이때 async_to_sync 함수가 비동기 함수를 동기 함수로 변환해 주는 기능을 담당한다.
다음으로 self.channel_layer.group_add는 속성처럼 보이지만 실제로는 비동기 함수 (async def)로 정의된 매서드 이다.
self.channel_layer는 Django Channels에서 제공하는 Channel Layer 인터페이스 객체이며, Redis 같은 백엔드를 통해 채널 간 통신을 가능하게 한다.group_add는 해당 인터페이스에 정의된 비동기 함수이다.async def group_add(group_name, channel_name): ...따라서 async_to_sync(self.channel_layer.group_add) 는 비동기 함수인 group_add를 다음과 같이 동기함수로 변환함을 의미한다;
sync_func = async_to_sync(self.channel_layer.group_add)
이후 group_add() 함수의 인자로 그룹명과 채널명을 넘겨주면 다음 함수가 실행된다;
sync_func(self.room_group_name, self.channel_name)
이때 self.channel_name는 웹소켓 연결 하나하나를 구분하는 고유 ID이다.
Django Channels는 각각의 웹소켓 연결을 'channels'라는 이름으로 추적한다. 실제로 print(self.channel_name)를 해보면 해당 연결이 서버에 등록될 때 사용된 고유 ID를 확인할 수 있다.
그리고 하나의 웹소켓 연결을 그룹에 추가할 때에는 group_add('그룹명', '연결 채널명')과 같이 어떤 연결을 어떤 그룹에 넣을지 정의해 준다.
즉, connect(self) 함수는 새로운 클라이언트가 웹소켓 연결시 호출되어
해당 연결을 'test' 그룹에 추가해준 뒤
self.accept()를 통해 웹 소켓 연결을 수락하는 기능을 한다.
2️⃣ receive(self, text_data=None)는 클라이언트가 서버로 메세지 송신시 호출되는 함수이다.
def receive(self, text_data=None):
text_data_json = json.loads(text_data)
message = text_data_json['message']
async_to_sync(self.channel_layer.group_send)(
self.room_group_name,
{
'type': 'chat_message',
'message': message
}
)
클라이언트가 보낸 메세지를 text_data라는 인자에 담아 JSON으로 파싱한 후 메세지 실제 내용만 message라는 변수에 담는다.
이후에 나오는 group_send() 함수는 Channels Layer를 통해 메세지 송신자의 그룹의 모든 연결에 이벤트를 브로드캐스팅한다.
이때 중요한 것은 서버가 '텍스트 메세지'를 전송하는 것이 아니라 '이벤트'를 전송한다는 것이다.
group_send(grou_name, event)에서 볼 수 있듯이 group_send 함수의 두번째 인자는 evnet 혹은 event dictionary, message dict로 불린다.
해당 딕셔너리에서는 필수값으로 'type'이라는 키를 지정해 줘야 하는데, 이 'type'은 전송되는 이벤트를 어떤 함수로 처리해야 하는지를 지정하는 '이벤트 라벨'과도 같다.
'type'을 지정해 주지 않으면 이벤트를 수신하는 측은 해당 이벤트를 어떻게 처리해야 할지 모르기 때문에 에러가 발생한다.
3️⃣ chat_message(self, event)는 서버가 이벤트를 받아 그룹에 포함된 모든 클라이언트들에게 이벤트 메세지를 전송해 줄 때 호출되는 함수이다.
def chat_message(self, event):
message = event['message']
self.send(text_data=json.dumps({
'type': 'chat',
'message': message
}))
receive() 함수에서 'type'을 'chat_message'로 정의 하여 group_send() 함수를 호출 했었다.
이때 Channels는 이벤트를 받는 모든 Consumer 안에서 chat_message() 함수를 찾아 자동으로 호출하게 된다.
chat_message() 함수가 호출되면 웹소켓으로 데이터를 전송해야하기 때문에 JSON 문자열로 변환한 후 현재 연결된 모든 클라이언트들에게 1:1로 메세지를 전송해 주는 것이다.
전체적인 흐름을 정리해 보자면,
크롬창 A가 최초 접속시 (클라이언트 측에서) 웹소켓 연결 시도. 서버측에서 connect() 함수가 이를 받아 그룹에 포함 시키고 웹 소켓 연결 시작.
크롬창 A에서 클라이언트가 서버로 메세지 보냄
서버에서 receive() 함수 실행. group_send()로 같은 그룹에 있는 모든 클라이언트 들에게 이벤트 브로드 캐스팅
그룹에 있는 모든 클라이언트들의 chat_message() 함수가 실행됨. 그 과정에서 서버가 클라이언트에게 웹 소켓을 통해 크롬창 A의 메세지를 1:1 전송.

타라-⭐️
채팅이 잘 연결되는 것을 볼 수 있다!
다음번에는 실제로 포레포레에 이걸 적용해본 뒤,
도커에서 Redis 서버를 띄워 실전사용을 위한 연습을 해보려고 한다!
끝!