← 블로그 홈

Django Channels 완전 정복: WebSocket과 실시간 통신 구현 가이드

실시간 채팅, 라이브 알림, 협업 도구 등을 개발하면서 Django의 기본 기능만으로는 한계를 느끼셨나요? Django Channels는 Django 애플리케이션에 WebSocket, HTTP/2, 그리고 다른 프로토콜을 지원하여 실시간 기능을 구현할 수 있게 해주는 강력한...

실시간 채팅, 라이브 알림, 협업 도구 등을 개발하면서 Django의 기본 기능만으로는 한계를 느끼셨나요? Django Channels는 Django 애플리케이션에 WebSocket, HTTP/2, 그리고 다른 프로토콜을 지원하여 실시간 기능을 구현할 수 있게 해주는 강력한 라이브러리입니다. 이 글에서는 Django Channels의 설치부터 실제 채팅 애플리케이션 구현까지 모든 과정을 상세히 다루겠습니다.

🚀 Django Channels란?

Django Channels의 핵심 개념

Django는 기본적으로 WSGI (Web Server Gateway Interface)를 사용하는 동기식 웹 프레임워크입니다. 하지만 실시간 기능을 위해서는 ASGI (Asynchronous Server Gateway Interface)가 필요합니다.

# 기존 Django (WSGI) - 동기식
def my_view(request):
    # 요청 처리
    return HttpResponse("Hello World")

# Django Channels (ASGI) - 비동기식
async def my_consumer(scope, receive, send):
    # 비동기 요청 처리
    await send({
        'type': 'websocket.accept'
    })

Django Channels의 주요 특징:

  • 비동기 처리: WebSocket, HTTP/2 등 지원
  • 채널 계층: 서버 간 메시지 전달
  • Consumer: WebSocket 연결 처리
  • Routing: URL 패턴과 Consumer 매핑

📦 설치 및 기본 설정

1. Django Channels 설치

# 가상환경 생성 및 활성화
python -m venv venv
source venv/bin/activate  # Linux/Mac
# venv\Scripts\activate   # Windows

# Django와 Channels 설치
pip install django
pip install channels
pip install channels-redis  # Redis 채널 레이어용

2. Django 프로젝트 생성

# 새 프로젝트 생성
django-admin startproject chatproject
cd chatproject

# 앱 생성
python manage.py startapp chat

3. settings.py 설정

# chatproject/settings.py
import os

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'channels',  # Channels 추가
    'chat',      # 생성한 앱 추가
]

# ASGI 애플리케이션 설정
ASGI_APPLICATION = 'chatproject.asgi.application'

# 채널 레이어 설정 (개발용 - 인메모리)
CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels.layers.InMemoryChannelLayer'
    }
}

# 프로덕션용 Redis 설정 (나중에 사용)
# CHANNEL_LAYERS = {
#     'default': {
#         'BACKEND': 'channels_redis.core.RedisChannelLayer',
#         'CONFIG': {
#             "hosts": [('127.0.0.1', 6379)],
#         },
#     },
# }

4. ASGI 설정 파일 생성

# chatproject/asgi.py
import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
import chat.routing

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'chatproject.settings')

application = ProtocolTypeRouter({
    "http": get_asgi_application(),
    "websocket": AuthMiddlewareStack(
        URLRouter(
            chat.routing.websocket_urlpatterns
        )
    ),
})

🛠️ Consumer 작성

Consumer는 WebSocket 연결을 처리하는 핵심 컴포넌트입니다.

1. 기본 Consumer 구현

# chat/consumers.py
import json
from channels.generic.websocket import AsyncWebsocketConsumer

class ChatConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        """WebSocket 연결 시 호출"""
        self.room_name = self.scope['url_route']['kwargs']['room_name']
        self.room_group_name = f'chat_{self.room_name}'

        # 그룹에 참가
        await self.channel_layer.group_add(
            self.room_group_name,
            self.channel_name
        )

        # WebSocket 연결 수락
        await self.accept()
        
        print(f"User connected to room: {self.room_name}")

    async def disconnect(self, close_code):
        """WebSocket 연결 해제 시 호출"""
        # 그룹에서 나가기
        await self.channel_layer.group_discard(
            self.room_group_name,
            self.channel_name
        )
        
        print(f"User disconnected from room: {self.room_name}")

    async def receive(self, text_data):
        """클라이언트로부터 메시지 수신"""
        try:
            text_data_json = json.loads(text_data)
            message = text_data_json['message']
            username = text_data_json.get('username', 'Anonymous')

            # 그룹의 모든 사용자에게 메시지 전송
            await self.channel_layer.group_send(
                self.room_group_name,
                {
                    'type': 'chat_message',
                    'message': message,
                    'username': username
                }
            )
        except json.JSONDecodeError:
            # 잘못된 JSON 형식의 메시지 처리
            await self.send(text_data=json.dumps({
                'error': 'Invalid message format'
            }))

    async def chat_message(self, event):
        """그룹으로부터 메시지를 받아 WebSocket으로 전송"""
        message = event['message']
        username = event['username']

        # WebSocket으로 메시지 전송
        await self.send(text_data=json.dumps({
            'message': message,
            'username': username,
            'timestamp': self.get_timestamp()
        }))
    
    def get_timestamp(self):
        """현재 시간 반환"""
        from datetime import datetime
        return datetime.now().strftime('%Y-%m-%d %H:%M:%S')

2. 고급 Consumer 기능

# chat/consumers.py (고급 기능 추가)
import json
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async
from django.contrib.auth.models import User
from .models import ChatRoom, Message

class AdvancedChatConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        self.room_name = self.scope['url_route']['kwargs']['room_name']
        self.room_group_name = f'chat_{self.room_name}'
        self.user = self.scope['user']

        # 인증된 사용자만 접근 허용
        if self.user.is_anonymous:
            await self.close()
            return

        # 채팅방 존재 확인 및 생성
        self.room = await self.get_or_create_room(self.room_name)
        
        # 그룹에 참가
        await self.channel_layer.group_add(
            self.room_group_name,
            self.channel_name
        )

        await self.accept()
        
        # 사용자 접속 알림
        await self.channel_layer.group_send(
            self.room_group_name,
            {
                'type': 'user_join',
                'username': self.user.username
            }
        )

    async def disconnect(self, close_code):
        # 사용자 퇴장 알림
        if hasattr(self, 'room_group_name') and hasattr(self, 'user'):
            await self.channel_layer.group_send(
                self.room_group_name,
                {
                    'type': 'user_leave',
                    'username': self.user.username
                }
            )
            
            await self.channel_layer.group_discard(
                self.room_group_name,
                self.channel_name
            )

    async def receive(self, text_data):
        try:
            data = json.loads(text_data)
            message_type = data.get('type', 'message')
            
            if message_type == 'message':
                await self.handle_message(data)
            elif message_type == 'typing':
                await self.handle_typing(data)
        except json.JSONDecodeError:
            await self.send_error('Invalid JSON format')

    async def handle_message(self, data):
        """메시지 처리"""
        message_text = data.get('message', '').strip()
        if not message_text:
            return

        # 데이터베이스에 메시지 저장
        message_obj = await self.save_message(message_text)
        
        # 그룹에 메시지 전송
        await self.channel_layer.group_send(
            self.room_group_name,
            {
                'type': 'chat_message',
                'message': message_text,
                'username': self.user.username,
                'message_id': message_obj.id,
                'timestamp': message_obj.created_at.isoformat()
            }
        )

    async def handle_typing(self, data):
        """타이핑 상태 처리"""
        is_typing = data.get('is_typing', False)
        
        await self.channel_layer.group_send(
            self.room_group_name,
            {
                'type': 'typing_status',
                'username': self.user.username,
                'is_typing': is_typing
            }
        )

    # 데이터베이스 작업을 위한 동기 함수들
    @database_sync_to_async
    def get_or_create_room(self, room_name):
        from .models import ChatRoom
        room, created = ChatRoom.objects.get_or_create(name=room_name)
        return room

    @database_sync_to_async
    def save_message(self, message_text):
        from .models import Message
        return Message.objects.create(
            room=self.room,
            user=self.user,
            content=message_text
        )

    # 이벤트 핸들러들
    async def chat_message(self, event):
        await self.send(text_data=json.dumps({
            'type': 'message',
            'message': event['message'],
            'username': event['username'],
            'message_id': event['message_id'],
            'timestamp': event['timestamp']
        }))

    async def user_join(self, event):
        await self.send(text_data=json.dumps({
            'type': 'user_join',
            'username': event['username']
        }))

    async def user_leave(self, event):
        await self.send(text_data=json.dumps({
            'type': 'user_leave',
            'username': event['username']
        }))

    async def typing_status(self, event):
        # 자신의 타이핑 상태는 전송하지 않음
        if event['username'] != self.user.username:
            await self.send(text_data=json.dumps({
                'type': 'typing',
                'username': event['username'],
                'is_typing': event['is_typing']
            }))

    async def send_error(self, message):
        await self.send(text_data=json.dumps({
            'type': 'error',
            'message': message
        }))

🔗 Routing 설정

1. WebSocket URL 패턴

# chat/routing.py
from django.urls import re_path
from . import consumers

websocket_urlpatterns = [
    re_path(r'ws/chat/(?P<room_name>\w+)/$', consumers.ChatConsumer.as_asgi()),
    # 고급 Consumer 사용 시
    # re_path(r'ws/chat/(?P<room_name>\w+)/$', consumers.AdvancedChatConsumer.as_asgi()),
]

2. HTTP URL 패턴

# chat/urls.py
from django.urls import path
from . import views

app_name = 'chat'

urlpatterns = [
    path('', views.index, name='index'),
    path('<str:room_name>/', views.room, name='room'),
]
# chatproject/urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('chat/', include('chat.urls')),
]

📊 모델 설계

# chat/models.py
from django.db import models
from django.contrib.auth.models import User

class ChatRoom(models.Model):
    name = models.CharField(max_length=100, unique=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    
    def __str__(self):
        return self.name
    
    class Meta:
        ordering = ['-updated_at']

class Message(models.Model):
    room = models.ForeignKey(ChatRoom, on_delete=models.CASCADE, related_name='messages')
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    content = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)
    
    def __str__(self):
        return f'{self.user.username}: {self.content[:50]}'
    
    class Meta:
        ordering = ['created_at']

class OnlineUser(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE)
    room = models.ForeignKey(ChatRoom, on_delete=models.CASCADE)
    connected_at = models.DateTimeField(auto_now_add=True)
    
    class Meta:
        unique_together = ['user', 'room']

🖼️ 뷰와 템플릿 작성

1. 뷰 함수

# chat/views.py
from django.shortcuts import render, get_object_or_404
from django.contrib.auth.decorators import login_required
from .models import ChatRoom, Message

def index(request):
    """채팅방 목록"""
    rooms = ChatRoom.objects.all()
    return render(request, 'chat/index.html', {'rooms': rooms})

@login_required
def room(request, room_name):
    """특정 채팅방"""
    room_obj, created = ChatRoom.objects.get_or_create(name=room_name)
    
    # 최근 메시지 50개 가져오기
    messages = Message.objects.filter(room=room_obj).select_related('user').order_by('-created_at')[:50]
    messages = reversed(messages)
    
    context = {
        'room_name': room_name,
        'messages': messages,
        'user': request.user
    }
    return render(request, 'chat/room.html', context)

2. 템플릿 작성

<!-- chat/templates/chat/base.html -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>{% raw %}{% block title %}Django Channels Chat{% endblock %}{% endraw %}</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            margin: 0;
            padding: 20px;
            background-color: #f5f5f5;
        }
        .container {
            max-width: 800px;
            margin: 0 auto;
            background: white;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
            overflow: hidden;
        }
        .header {
            background: #007bff;
            color: white;
            padding: 20px;
            text-align: center;
        }
        .content {
            padding: 20px;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="header">
            <h1>{% raw %}{% block header %}Django Channels Chat{% endblock %}{% endraw %}</h1>
        </div>
        <div class="content">
            {% raw %}{% block content %}
            {% endblock %}{% endraw %}
        </div>
    </div>
</body>
</html>
<!-- chat/templates/chat/room.html -->
{% raw %}{% extends 'chat/base.html' %}

{% block title %}Room: {{ room_name }}{% endblock %}

{% block header %}
    채팅방: {{ room_name }}
    <small style="display: block; margin-top: 10px;">
        사용자: {{ user.username }}
    </small>
{% endblock %}

{% block content %}{% endraw %}
<div id="chat-container">
    <div id="chat-log" style="height: 400px; overflow-y: auto; border: 1px solid #ddd; padding: 10px; margin-bottom: 10px; background: #f9f9f9;">
        {% raw %}{% for message in messages %}
            <div class="message" style="margin-bottom: 10px;">
                <strong>{{ message.user.username }}</strong>
                <span style="color: #666; font-size: 0.8em;">{{ message.created_at|date:"H:i:s" }}</span><br>
                {{ message.content }}
            </div>
        {% endfor %}{% endraw %}
    </div>
    
    <div id="typing-indicator" style="color: #666; font-style: italic; height: 20px; margin-bottom: 10px;"></div>
    
    <div style="display: flex; gap: 10px;">
        <input id="chat-message-input" type="text" 
               placeholder="메시지를 입력하세요..." 
               style="flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 4px;">
        <button id="chat-message-submit" 
                style="padding: 10px 20px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer;">
            전송
        </button>
    </div>
</div>

<script>
    const roomName = JSON.parse(document.getElementById('room-name').textContent);
    const userName = JSON.parse(document.getElementById('user-name').textContent);
    
    // WebSocket 연결
    const chatSocket = new WebSocket(
        'ws://' + window.location.host + '/ws/chat/' + roomName + '/'
    );

    const chatLog = document.querySelector('#chat-log');
    const messageInputDom = document.querySelector('#chat-message-input');
    const submitButton = document.querySelector('#chat-message-submit');
    const typingIndicator = document.querySelector('#typing-indicator');

    let typingTimer;
    let isTyping = false;

    // WebSocket 메시지 수신
    chatSocket.onmessage = function(e) {
        const data = JSON.parse(e.data);
        
        switch(data.type) {
            case 'message':
                addMessage(data.username, data.message, data.timestamp);
                break;
            case 'user_join':
                showNotification(data.username + '님이 입장했습니다.');
                break;
            case 'user_leave':
                showNotification(data.username + '님이 퇴장했습니다.');
                break;
            case 'typing':
                handleTypingStatus(data.username, data.is_typing);
                break;
            case 'error':
                showError(data.message);
                break;
        }
    };

    chatSocket.onclose = function(e) {
        console.error('Chat socket closed unexpectedly');
        showError('연결이 끊어졌습니다. 페이지를 새로고침해주세요.');
    };

    // 메시지 전송
    function sendMessage() {
        const message = messageInputDom.value.trim();
        if (message) {
            chatSocket.send(JSON.stringify({
                'type': 'message',
                'message': message
            }));
            messageInputDom.value = '';
            
            // 타이핑 상태 종료
            if (isTyping) {
                sendTypingStatus(false);
            }
        }
    }

    // 타이핑 상태 전송
    function sendTypingStatus(typing) {
        chatSocket.send(JSON.stringify({
            'type': 'typing',
            'is_typing': typing
        }));
        isTyping = typing;
    }

    // 메시지 추가
    function addMessage(username, message, timestamp) {
        const messageDiv = document.createElement('div');
        messageDiv.className = 'message';
        messageDiv.style.marginBottom = '10px';
        
        const time = new Date(timestamp).toLocaleTimeString('ko-KR');
        messageDiv.innerHTML = `
            <strong>${username}</strong>
            <span style="color: #666; font-size: 0.8em;">${time}</span><br>
            ${message}
        `;
        
        chatLog.appendChild(messageDiv);
        chatLog.scrollTop = chatLog.scrollHeight;
    }

    // 알림 표시
    function showNotification(message) {
        const notifDiv = document.createElement('div');
        notifDiv.style.cssText = 'color: #666; font-style: italic; text-align: center; margin: 10px 0;';
        notifDiv.textContent = message;
        chatLog.appendChild(notifDiv);
        chatLog.scrollTop = chatLog.scrollHeight;
    }

    // 에러 표시
    function showError(message) {
        const errorDiv = document.createElement('div');
        errorDiv.style.cssText = 'color: red; font-weight: bold; text-align: center; margin: 10px 0;';
        errorDiv.textContent = '에러: ' + message;
        chatLog.appendChild(errorDiv);
        chatLog.scrollTop = chatLog.scrollHeight;
    }

    // 타이핑 상태 처리
    function handleTypingStatus(username, typing) {
        if (typing) {
            typingIndicator.textContent = username + '님이 입력 중...';
        } else {
            typingIndicator.textContent = '';
        }
    }

    // 이벤트 리스너
    submitButton.onclick = sendMessage;

    messageInputDom.onkeyup = function(e) {
        if (e.keyCode === 13) {  // Enter
            sendMessage();
        } else {
            // 타이핑 상태 관리
            if (!isTyping) {
                sendTypingStatus(true);
            }
            
            clearTimeout(typingTimer);
            typingTimer = setTimeout(() => {
                if (isTyping) {
                    sendTypingStatus(false);
                }
            }, 1000);
        }
    };

    messageInputDom.focus();
</script>

{% raw %}{{ room_name|json_script:"room-name" }}
{{ user.username|json_script:"user-name" }}
{% endblock %}{% endraw %}

🔄 Redis를 이용한 프로덕션 설정

1. Redis 설치 및 설정

# Ubuntu/Debian
sudo apt-get install redis-server

# macOS
brew install redis

# Redis 서비스 시작
redis-server

# 연결 테스트
redis-cli ping
# PONG 응답이 나와야 함

2. 프로덕션 settings.py

# chatproject/settings.py
import os

# Redis 채널 레이어 설정
CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels_redis.core.RedisChannelLayer',
        'CONFIG': {
            "hosts": [os.environ.get('REDIS_URL', 'redis://localhost:6379')],
            "capacity": 1500,  # 채널당 최대 메시지 수
            "expiry": 60,      # 메시지 만료 시간 (초)
        },
    },
}

# 로깅 설정
LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'handlers': {
        'file': {
            'level': 'INFO',
            'class': 'logging.FileHandler',
            'filename': 'channels.log',
        },
    },
    'loggers': {
        'channels': {
            'handlers': ['file'],
            'level': 'INFO',
            'propagate': True,
        },
    },
}

3. Docker를 이용한 Redis 설정

# docker-compose.yml
version: '3.8'

services:
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    command: redis-server --appendonly yes

  web:
    build: .
    ports:
      - "8000:8000"
    depends_on:
      - redis
    environment:
      - REDIS_URL=redis://redis:6379

volumes:
  redis_data:

🚀 배포 및 성능 최적화

1. Daphne를 이용한 프로덕션 서버

# Daphne 설치
pip install daphne

# 프로덕션 서버 실행
daphne -p 8000 chatproject.asgi:application

# 백그라운드 실행
nohup daphne -p 8000 chatproject.asgi:application &

2. Nginx 설정

# /etc/nginx/sites-available/chatproject
server {
    listen 80;
    server_name yourdomain.com;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location /ws/ {
        proxy_pass http://127.0.0.1:8000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

3. 성능 최적화 팁

# chat/consumers.py - 최적화된 Consumer
import json
import asyncio
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async
from django.core.cache import cache

class OptimizedChatConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        self.room_name = self.scope['url_route']['kwargs']['room_name']
        self.room_group_name = f'chat_{self.room_name}'
        self.user = self.scope['user']

        if self.user.is_anonymous:
            await self.close()
            return

        # 연결된 사용자 수 증가
        await self.increment_user_count()
        
        await self.channel_layer.group_add(
            self.room_group_name,
            self.channel_name
        )

        await self.accept()

    async def disconnect(self, close_code):
        if hasattr(self, 'room_group_name'):
            # 연결된 사용자 수 감소
            await self.decrement_user_count()
            
            await self.channel_layer.group_discard(
                self.room_group_name,
                self.channel_name
            )

    async def receive(self, text_data):
        try:
            data = json.loads(text_data)
            
            # 메시지 길이 제한
            if len(data.get('message', '')) > 1000:
                await self.send_error('메시지가 너무 깁니다.')
                return

            # 스팸 방지 - 사용자당 초당 메시지 제한
            if not await self.check_rate_limit():
                await self.send_error('메시지 전송 속도가 너무 빠릅니다.')
                return

            await self.handle_message(data)
            
        except json.JSONDecodeError:
            await self.send_error('잘못된 메시지 형식입니다.')

    async def check_rate_limit(self):
        """초당 메시지 제한 확인"""
        cache_key = f'rate_limit_{self.user.id}'
        current_count = cache.get(cache_key, 0)
        
        if current_count >= 5:  # 초당 5개 메시지 제한
            return False
        
        cache.set(cache_key, current_count + 1, timeout=1)
        return True

    async def increment_user_count(self):
        """방의 사용자 수 증가"""
        cache_key = f'room_users_{self.room_name}'
        current_count = cache.get(cache_key, 0)
        cache.set(cache_key, current_count + 1, timeout=3600)

    async def decrement_user_count(self):
        """방의 사용자 수 감소"""
        cache_key = f'room_users_{self.room_name}'
        current_count = cache.get(cache_key, 1)
        cache.set(cache_key, max(0, current_count - 1), timeout=3600)

    # ... 나머지 메서드들

🐛 디버깅 및 테스트

1. 테스트 작성

# chat/tests.py
from channels.testing import WebsocketCommunicator
from channels.db import database_sync_to_async
from django.test import TransactionTestCase
from django.contrib.auth.models import User
from .consumers import ChatConsumer

class ChatConsumerTest(TransactionTestCase):
    def setUp(self):
        self.user = User.objects.create_user(
            username='testuser',
            password='testpass'
        )

    async def test_chat_consumer_connection(self):
        """WebSocket 연결 테스트"""
        communicator = WebsocketCommunicator(
            ChatConsumer.as_asgi(),
            "/ws/chat/test/"
        )
        communicator.scope['user'] = self.user
        
        connected, subprotocol = await communicator.connect()
        self.assertTrue(connected)
        
        await communicator.disconnect()

    async def test_chat_message_sending(self):
        """메시지 전송 테스트"""
        communicator = WebsocketCommunicator(
            ChatConsumer.as_asgi(),
            "/ws/chat/test/"
        )
        communicator.scope['user'] = self.user
        
        connected, subprotocol = await communicator.connect()
        self.assertTrue(connected)

        # 메시지 전송
        await communicator.send_json_to({
            'type': 'message',
            'message': 'Hello World!'
        })

        # 메시지 수신 확인
        response = await communicator.receive_json_from()
        self.assertEqual(response['message'], 'Hello World!')
        self.assertEqual(response['username'], 'testuser')

        await communicator.disconnect()

2. 디버깅 팁

# 로깅 설정으로 디버깅
import logging

logger = logging.getLogger(__name__)

class ChatConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        logger.info(f"User {self.scope['user']} connecting to {self.room_name}")
        # ... 연결 로직

    async def receive(self, text_data):
        logger.debug(f"Received message: {text_data}")
        # ... 메시지 처리 로직

    async def disconnect(self, close_code):
        logger.info(f"User {self.scope['user']} disconnected with code {close_code}")
        # ... 연결 해제 로직

🔒 보안 고려사항

1. 인증 및 권한 확인

# chat/consumers.py
from channels.auth import login_required
from channels.generic.websocket import AsyncWebsocketConsumer

class SecureChatConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        # 인증된 사용자만 허용
        if self.scope['user'].is_anonymous:
            await self.close(code=4001)
            return

        # 방 접근 권한 확인
        if not await self.has_room_permission():
            await self.close(code=4003)
            return

        # ... 연결 로직

    @database_sync_to_async
    def has_room_permission(self):
        """방 접근 권한 확인"""
        # 비즈니스 로직에 따라 구현
        return True

2. 메시지 검증 및 필터링

import re
from django.utils.html import escape

class SecureChatConsumer(AsyncWebsocketConsumer):
    BANNED_WORDS = ['spam', 'abuse']  # 금지어 목록
    
    def sanitize_message(self, message):
        """메시지 검증 및 정제"""
        # HTML 이스케이프
        message = escape(message)
        
        # 길이 제한
        if len(message) > 500:
            return None
        
        # 금지어 필터링
        for word in self.BANNED_WORDS:
            if word.lower() in message.lower():
                return None
        
        return message.strip()

📈 모니터링 및 분석

1. 메트릭 수집

# chat/middleware.py
import time
from django.core.cache import cache

class WebSocketMetricsMiddleware:
    def __init__(self, inner):
        self.inner = inner

    async def __call__(self, scope, receive, send):
        if scope["type"] == "websocket":
            start_time = time.time()
            
            # 연결 수 증가
            cache.set('websocket_connections', 
                     cache.get('websocket_connections', 0) + 1, 
                     timeout=3600)
            
            try:
                return await self.inner(scope, receive, send)
            finally:
                # 연결 시간 기록
                duration = time.time() - start_time
                cache.set('avg_connection_duration',
                         (cache.get('avg_connection_duration', 0) + duration) / 2,
                         timeout=3600)
                
                # 연결 수 감소
                cache.set('websocket_connections',
                         max(0, cache.get('websocket_connections', 1) - 1),
                         timeout=3600)
        else:
            return await self.inner(scope, receive, send)

2. 관리자 대시보드

# chat/admin.py
from django.contrib import admin
from django.urls import path
from django.shortcuts import render
from django.core.cache import cache
from .models import ChatRoom, Message

@admin.register(ChatRoom)
class ChatRoomAdmin(admin.ModelAdmin):
    list_display = ['name', 'created_at', 'message_count', 'active_users']
    readonly_fields = ['created_at', 'updated_at']

    def get_urls(self):
        urls = super().get_urls()
        custom_urls = [
            path('metrics/', self.admin_site.admin_view(self.metrics_view), 
                 name='chat_metrics'),
        ]
        return custom_urls + urls

    def metrics_view(self, request):
        context = {
            'websocket_connections': cache.get('websocket_connections', 0),
            'avg_connection_duration': cache.get('avg_connection_duration', 0),
            'total_rooms': ChatRoom.objects.count(),
            'total_messages': Message.objects.count(),
        }
        return render(request, 'admin/chat_metrics.html', context)

    def message_count(self, obj):
        return obj.messages.count()
    
    def active_users(self, obj):
        return cache.get(f'room_users_{obj.name}', 0)

🎯 결론

Django Channels는 Django 애플리케이션에 실시간 기능을 추가할 수 있는 강력한 도구입니다. 이 가이드에서 다룬 내용들을 정리하면:

핵심 포인트

  1. ASGI 기반: WebSocket과 HTTP/2 지원
  2. Consumer: WebSocket 연결 처리의 핵심
  3. 채널 레이어: 서버 간 메시지 전달
  4. Redis: 프로덕션 환경에서의 필수 구성요소

다음 단계

  • 확장 기능: 파일 전송, 화상/음성 채팅
  • 모바일 앱: WebSocket을 이용한 모바일 연동
  • 마이크로서비스: 분산 환경에서의 Channels 활용

Django Channels를 통해 현대적인 실시간 웹 애플리케이션을 구축해보세요. 사용자 경험을 크게 향상시킬 수 있는 강력한 도구가 될 것입니다.

참고 자료

이 글을 공유해보세요!