Chat 앱

고급 (Advanced)예상 4시간

동작하는 전체 코드: examples/chat/ — make test가 함께 돌리고, 릴리스 게이트(CI)가 태그마다 다시 돌리는 예제다.

이 튜토리얼에서는 실시간 채팅 앱을 만들며 Streams API와 Presence API를 학습합니다.

학습 목표#

  • Streams API로 대규모 리스트 효율적 처리
  • Presence API로 온라인 사용자 추적
  • 타이핑 표시 구현
  • leaving() 라이프사이클 훅 활용
  • push_js()로 클라이언트 사이드 명령 전송

완성 앱 미리보기#

  • 실시간 메시지 전송/수신
  • 온라인 사용자 목록
  • 타이핑 표시 ("User is typing...")
  • 새 메시지 시 자동 스크롤
  • 메모리 효율적 메시지 리스트

Part 1: 기본 설정#

모델 정의#

chat/models.py:

from django.db import models


class Room(models.Model):
    """채팅방"""
    name = models.CharField(max_length=100)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.name


class Message(models.Model):
    """채팅 메시지"""
    room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name='messages')
    sender = models.CharField(max_length=100)
    text = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ['created_at']

    def __str__(self):
        return f"{self.sender}: {self.text[:50]}"

기본 구조#

앱 구조:

chat/
├── models.py
├── live.py
├── views.py
├── urls.py
└── templates/chat/
    ├── room.html           # 페이지 템플릿
    ├── room_component.html # 채팅방 컴포넌트
    ├── message_item.html   # 메시지 아이템
    └── online_users.html   # 온라인 사용자 목록

Part 2: Streams API - 메시지 리스트#

왜 Streams를 사용하나요?#

일반 리스트 렌더링:

  • 전체 리스트를 상태에 저장
  • 변경 시 전체 HTML 다시 렌더링
  • 메모리 사용량 증가

Streams:

  • 아이템 단위로 독립적 렌더링
  • 변경된 아이템만 전송
  • 메모리 효율적

메시지 리스트 컴포넌트#

chat/live.py:

from wireview import Component
from .models import Room, Message


class XMessageList(Component):
    """메시지 리스트 컴포넌트 - Streams API 사용"""

    class Meta:
        template_name = 'chat/message_list.html'

    room_id: int

    async def joined(self):
        """컴포넌트 연결 시 메시지 로드"""
        messages = await self._load_messages()
        # stream()으로 초기화 - 아이템별로 렌더링
        await self.stream("messages", messages, template="chat/message_item.html")

    async def _load_messages(self, limit: int = 50):
        """최근 메시지 로드"""
        messages = Message.objects.filter(
            room_id=self.room_id
        ).order_by('-created_at')[:limit]
        # 역순으로 반환 (오래된 것부터)
        return list(reversed([m async for m in messages]))

메시지 리스트 템플릿#

chat/templates/chat/message_list.html:

{% load wireview %}
<div {% tag_header %} class="message-list">
  <ul wire-stream="messages"></ul>
</div>

핵심: wire-stream="messages" 속성이 stream 컨테이너를 지정합니다. 항목은 컴포넌트 상태가 아니므로 템플릿이 반복문으로 그리지 않는다. joined()의 stream()이 이 컨테이너를 채운다.

메시지 아이템 템플릿#

chat/templates/chat/message_item.html:

<li id="messages-{{ item.pk }}" class="message">
  <span class="sender">{{ item.sender }}</span>
  <span class="text">{{ item.text }}</span>
  <span class="time">{{ item.created_at|time:"H:i" }}</span>
</li>

핵심: id="messages-{{ item.pk }}" - stream 이름과 pk로 DOM ID 생성

항목 템플릿이 받는 컨텍스트는 item(그 항목)과 this(스트림을 보낸 컴포넌트) 둘뿐이다. template=을 생략하면 {컴포넌트 템플릿}_item.html(여기서는 chat/message_list_item.html)을 찾는다. 이 튜토리얼은 뒤의 XChatRoom도 같은 항목 템플릿을 쓰므로 매번 template=으로 지정한다.

메시지 추가#

class XMessageList(Component):
    # ... 기존 코드 ...

    async def add_message(self, sender: str, text: str):
        """새 메시지 추가"""
        message = await Message.objects.acreate(
            room_id=self.room_id,
            sender=sender,
            text=text.strip()
        )

        # stream_insert로 아이템 추가 (append)
        await self.stream_insert("messages", message, at=-1, template="chat/message_item.html")

        # 새 메시지로 스크롤
        await self.scroll_into_view(f"messages-{message.pk}", behavior="smooth")

Stream 메서드 정리#

메서드 용도 at 파라미터
stream(name, items, template=None) 초기화/리셋 -
stream_insert(name, item, at=-1, template=None) 삽입 -1: 끝, 0: 처음, n: 인덱스
stream_delete(name, dom_id) 삭제 -

Part 3: Presence API - 온라인 사용자#

Presence 개념#

  • PresenceMixin: 자신의 상태를 브로드캐스트 (Producer)
  • PresenceTrackerMixin: 다른 사용자 상태 추적 (Consumer)

채팅방 컴포넌트 (Producer)#

from wireview import Component, PresenceMixin, JS
from .models import Room, Message


class XChatRoom(PresenceMixin, Component):
    """채팅방 메인 컴포넌트"""

    class Meta:
        template_name = 'chat/room_component.html'

    room_id: int
    room_name: str
    username: str

    # Presence 설정
    def _presence_topic(self) -> str:
        return f"chat.room.{self.room_id}"

    def _presence_user_id(self) -> str:
        return self.username

    def _presence_username(self) -> str:
        return self.username

    async def joined(self):
        """입장 알림"""
        await self.presence_join()

    async def leaving(self):
        """퇴장 알림 - 연결 종료 시 자동 호출"""
        await self.presence_leave()

    async def send_message(self, text: str):
        """메시지 전송"""
        if not text.strip():
            return

        await Message.objects.acreate(
            room_id=self.room_id,
            sender=self.username,
            text=text.strip()
        )

        # 입력 필드 초기화
        await self.push_js(JS().set_value("input[name=text]", ""))

    async def on_typing(self):
        """타이핑 표시 - 자동으로 3초 후 해제"""
        await self.presence_set_typing(True)

온라인 사용자 컴포넌트 (Consumer)#

from wireview import PresenceTrackerMixin


class XOnlineUsers(PresenceTrackerMixin, Component):
    """온라인 사용자 목록 컴포넌트"""

    class Meta:
        template_name = 'chat/online_users.html'

    room_id: int
    username: str  # 현재 사용자 (자기 자신 식별용)

    def _presence_topic(self) -> str:
        return f"chat.room.{self.room_id}"

    def _presence_my_user_id(self) -> str:
        return self.username

    def get_subscriptions(self) -> set[str]:
        # Presence 채널 구독
        return {self._presence_channel()}

    async def joined(self):
        # 자기 자신을 추적 목록에 추가하고 다른 사용자에게 sync 요청
        await self.presence_track_self(username=self.username)

온라인 사용자 템플릿#

chat/templates/chat/online_users.html:

{% load wireview %}
<div {% tag_header %} class="online-users">
  <h3>Online ({{ this.presence_online_count }})</h3>

  <ul>
    {% for user in this.presence_users %}
      <li class="{% if user.is_typing %}typing{% endif %}">
        <span class="username">{{ user.username }}</span>
        {% if user.is_typing %}
          <span class="typing-indicator">typing...</span>
        {% endif %}
      </li>
    {% empty %}
      <li class="empty">No one online</li>
    {% endfor %}
  </ul>
</div>

Presence 속성/메서드 정리#

PresenceMixin (Producer):

메서드 용도
presence_join() 입장 알림
presence_leave() 퇴장 알림
presence_set_typing(bool) 타이핑 상태 설정

PresenceTrackerMixin (Consumer):

속성/메서드 용도
presence_users 모든 사용자 리스트
presence_online_count 온라인 사용자 수
presence_typing_users 타이핑 중인 사용자
presence_track_self(username) 자기 자신 등록

Part 4: 완성된 코드#

전체 live.py#

from wireview import Component, PresenceMixin, PresenceTrackerMixin, JS
from .models import Room, Message


class XChatRoom(PresenceMixin, Component):
    """채팅방 메인 컴포넌트"""

    class Meta:
        template_name = 'chat/room_component.html'

    room_id: int
    room_name: str
    username: str

    def _presence_topic(self) -> str:
        return f"chat.room.{self.room_id}"

    def _presence_user_id(self) -> str:
        return self.username

    def _presence_username(self) -> str:
        return self.username

    async def joined(self):
        # 메시지 로드
        messages = await self._load_messages()
        await self.stream("messages", messages, template="chat/message_item.html")

        # 입장 알림
        await self.presence_join()

    async def leaving(self):
        await self.presence_leave()

    async def _load_messages(self, limit: int = 50):
        messages = Message.objects.filter(
            room_id=self.room_id
        ).order_by('-created_at')[:limit]
        return list(reversed([m async for m in messages]))

    async def send_message(self, text: str):
        if not text.strip():
            return

        message = await Message.objects.acreate(
            room_id=self.room_id,
            sender=self.username,
            text=text.strip()
        )

        await self.stream_insert("messages", message, at=-1, template="chat/message_item.html")
        await self.scroll_into_view(f"messages-{message.pk}", behavior="smooth")
        await self.push_js(JS().set_value("input[name=text]", ""))

    async def on_typing(self):
        await self.presence_set_typing(True)


class XOnlineUsers(PresenceTrackerMixin, Component):
    """온라인 사용자 목록"""

    class Meta:
        template_name = 'chat/online_users.html'

    room_id: int
    username: str

    def _presence_topic(self) -> str:
        return f"chat.room.{self.room_id}"

    def _presence_my_user_id(self) -> str:
        return self.username

    def get_subscriptions(self) -> set[str]:
        return {self._presence_channel()}

    async def joined(self):
        await self.presence_track_self(username=self.username)

room_component.html#

{% load wireview %}
<div {% tag_header %} class="chat-room">
  <header class="chat-header">
    <h2>{{ room_name }}</h2>
  </header>

  <div class="chat-body">
    <div class="messages-container">
      <ul wire-stream="messages" class="message-list"></ul>
    </div>

    <aside class="sidebar">
      {% component 'XOnlineUsers' id="online-"|concat:room_id room_id=room_id username=username %}
    </aside>
  </div>

  <footer class="chat-footer">
    <form class="message-form">
      <input
        type="text"
        name="text"
        placeholder="Type a message..."
        autocomplete="off"
        {% on "input.debounce.100" "on_typing" %}
        {% on "keypress.enter.prevent" "send_message" %}
      >
      <button type="button" {% on "click" "send_message" %}>Send</button>
    </form>
  </footer>
</div>

message_item.html#

<li id="messages-{{ item.pk }}" class="message {% if item.sender == this.username %}own{% endif %}">
  <div class="message-content">
    <span class="sender">{{ item.sender }}</span>
    <p class="text">{{ item.text }}</p>
    <span class="time">{{ item.created_at|time:"H:i" }}</span>
  </div>
</li>

컴포넌트 필드는 this로 읽는다. 스트림 항목의 컨텍스트에 username은 없다.

online_users.html#

{% load wireview %}
<div {% tag_header %} class="online-users">
  <h3>
    <span class="status-dot"></span>
    Online ({{ this.presence_online_count }})
  </h3>

  <ul class="user-list">
    {% for user in this.presence_users %}
      <li class="user {% if user.is_typing %}is-typing{% endif %}">
        <span class="avatar">{{ user.username|slice:":1"|upper }}</span>
        <span class="name">{{ user.username }}</span>
        {% if user.is_typing %}
          <span class="typing-dots">
            <span></span><span></span><span></span>
          </span>
        {% endif %}
      </li>
    {% endfor %}
  </ul>
</div>

CSS 예제#

.chat-room {
  display: flex;
  flex-direction: column;
  height: 100vh;
}

.chat-body {
  display: flex;
  flex: 1;
  overflow: hidden;
}

.messages-container {
  flex: 1;
  overflow-y: auto;
  padding: 1rem;
}

.message-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.message {
  margin-bottom: 1rem;
  padding: 0.5rem 1rem;
  background: #f0f0f0;
  border-radius: 8px;
  max-width: 70%;
}

.message.own {
  background: #007bff;
  color: white;
  margin-left: auto;
}

.sidebar {
  width: 200px;
  border-left: 1px solid #ddd;
  padding: 1rem;
}

.chat-footer {
  padding: 1rem;
  border-top: 1px solid #ddd;
}

.message-form {
  display: flex;
  gap: 0.5rem;
}

.message-form input {
  flex: 1;
  padding: 0.5rem;
}

/* 타이핑 애니메이션 */
.typing-dots span {
  animation: typing 1s infinite;
  display: inline-block;
  width: 4px;
  height: 4px;
  background: #666;
  border-radius: 50%;
  margin: 0 1px;
}

.typing-dots span:nth-child(2) { animation-delay: 0.2s; }
.typing-dots span:nth-child(3) { animation-delay: 0.4s; }

@keyframes typing {
  0%, 100% { opacity: 0.3; }
  50% { opacity: 1; }
}

Part 5: 고급 기능#

메시지 실시간 수신 (다른 사용자)#

모델 구독을 추가해 다른 사용자의 메시지도 실시간 수신한다. 자동 브로드캐스트는 Message가 저장될 때 chat.message(모델 전체) 말고도 외래 키가 가리키는 쪽의 채널 chat.room.{room_pk}.messages ({관계 모델}.{pk}.{related_name})에 알린다. 이 채널을 쓰려면 설정에서 related를 켠다:

WIREVIEW = {
    "AUTO_BROADCAST": AutoBroadcast(model=True, model_pk=True, related=True, senders={("chat", "Message")}),
}
from wireview import ModelAction


class XChatRoom(PresenceMixin, Component):
    # ...

    def get_subscriptions(self) -> set[str]:
        # 이 방의 메시지만 받는다
        return super().get_subscriptions() | {f"chat.room.{self.room_id}.messages"}

    async def mutation(self, channel: str, action: ModelAction, instance):
        """다른 사용자의 메시지 수신 (내 메시지는 send_message가 이미 넣었다)"""
        if action == ModelAction.CREATED and instance.sender != self.username:
            await self.stream_insert("messages", instance, at=-1, template="chat/message_item.html")
            await self.scroll_into_view(f"messages-{instance.pk}", behavior="smooth")

메시지 로드 더 보기#

처음 불러온 메시지 중 가장 오래된 것이 기준점이다. joined()에서 기록해 두지 않으면 첫 "더 보기"가 이미 보이는 최신 메시지를 다시 불러온다.

class XChatRoom(PresenceMixin, Component):
    # ...
    oldest_message_id: int | None = None
    has_more: bool = True

    async def joined(self):
        messages = await self._load_messages()
        await self.stream("messages", messages, template="chat/message_item.html")
        if messages:
            self.oldest_message_id = messages[0].id
        self.has_more = len(messages) == 50
        await self.presence_join()

    async def load_more(self):
        """이전 메시지 로드"""
        if not self.has_more or self.oldest_message_id is None:
            return

        qs = Message.objects.filter(room_id=self.room_id, id__lt=self.oldest_message_id)
        # QuerySet은 await할 수 없다. async for로 모은다. 최신 것부터 20개
        messages = [m async for m in qs.order_by('-id')[:20]]

        if len(messages) < 20:
            self.has_more = False

        # 최신 것부터 하나씩 맨 앞에 붙이면 가장 오래된 것이 맨 위에 온다
        for message in messages:
            await self.stream_insert("messages", message, at=0, template="chat/message_item.html")
        if messages:
            self.oldest_message_id = messages[-1].id

연습 문제#

  1. 메시지 삭제: 자신의 메시지를 삭제하는 기능 추가
  2. 이미지 전송: 이미지 URL을 감지해 미리보기 표시
  3. 읽음 표시: 메시지 읽음 상태 추적
  4. 다이렉트 메시지: 1:1 채팅 기능

다음 단계#

Chat 앱을 통해 Streams API와 Presence API의 핵심 개념을 학습했습니다.

다음 튜토리얼에서는 AsyncResult를 사용한 비동기 데이터 로딩과 복합 컴포지션을 배워봅니다.