7. 사용자 간 1:1 쪽지 기능(DM) 구현

[STEP 7] 사용자 간 1:1 쪽지 기능 (DM) 구현하기

이번 단계에서는 이메일 스타일의 쪽지(Direct Message) 시스템을 구현해 보겠습니다.

단순한 텍스트 전송을 넘어, 제목(Title)이 있고 상대방이 읽었는지 확인할 수 있는 수신 확인(Read Check) 기능을 포함한 Direct Message 기능을 구현해보도록 하겠습니다.

1. 모델 수정 (accounts/models.py)

쪽지 데이터를 저장할 모델을 정의합니다. 누가(Sender), 누구에게(Receiver), 무엇을(Title, Content) 보냈는지 저장하며, read_at 필드를 통해 수신 확인 기능을 구현합니다.

accounts/models.py 하단에 Message 클래스를 추가합니다.

from django.db import models
from django.conf import settings # User 모델 참조

class Message(models.Model):
    # 보낸 사람, 받는 사람 (User 모델과 1:N 관계)
    sender = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="sent_messages", on_delete=models.CASCADE, verbose_name="보낸 사람")
    receiver = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="received_messages", on_delete=models.CASCADE, verbose_name="받는 사람")
    
    # 제목 및 내용
    title = models.CharField(max_length=200, verbose_name="제목")
    content = models.TextField(verbose_name="내용")
    
    # 시간 기록
    created_at = models.DateTimeField(auto_now_add=True, verbose_name="보낸 시간")
    read_at = models.DateTimeField(null=True, blank=True, verbose_name="읽은 시간") # 읽었을 때만 시간이 기록됨

    def __str__(self):
        return f"To {self.receiver}: {self.title}"
    
    class Meta:
        ordering = ['-created_at'] # 최신 쪽지가 맨 위에 오도록 정렬

2. DB 반영 (마이그레이션)

새로운 모델을 DB에 반영합니다.

[Bash]

python manage.py makemigrations

⚠️ 주의: 기존 데이터 처리 알림 만약 "It is impossible to add a non-nullable field 'title'..." 메시지가 뜬다면, 기존 데이터에 제목을 뭘로 채울지 묻는 것입니다.

  1. Select an option: 1 입력 (일회성 기본값 제공)
  2. >>> '제목 없음' 입력 (작은따옴표 필수!)
[Bash]
python manage.py migrate

3. 폼 작성 (accounts/forms.py)

쪽지 작성 시 사용할 폼을 만듭니다.

accounts/forms.py를 수정합니다.

# [import 추가] Message 모델 추가
from .models import User, Message 

# ... 기존 UserChangeForm ...

# [추가] 쪽지 작성 폼
class MessageForm(forms.ModelForm):
    class Meta:
        model = Message
        fields = ['title', 'content'] # 제목과 내용만 입력받음
        widgets = {
            'title': forms.TextInput(attrs={
                'class': 'form-control',
                'placeholder': '제목을 입력하세요.'
            }),
            'content': forms.Textarea(attrs={
                'class': 'form-control',
                'rows': 5,
                'placeholder': '내용을 입력하세요.'
            })
        }

4. URL 설정 (accounts/urls.py)

쪽지함(목록), 보내기, 상세 보기 3가지 URL을 등록합니다.

accounts/urls.py에 추가합니다.

urlpatterns = [
    # ... 기존 url들 ...
    
    # 1. 쪽지함 (받은/보낸 통합)
    path('messages/', views.message_list, name='message_list'),
    
    # 2. 쪽지 보내기 (receiver_pk: 받는 사람 ID)
    path('messages/send/<int:receiver_pk>/', views.message_send, name='message_send'),
    
    # 3. 쪽지 상세 보기 (message_pk: 쪽지 ID)
    path('messages/<int:message_pk>/', views.message_detail, name='message_detail'),
]

5. 로직 작성 (accounts/views.py)

쪽지 기능의 핵심 로직입니다.

특히 message_list에서 안 읽은 개수 계산message_detail에서 읽음 처리 로직을 눈여겨보세요.

accounts/views.py 하단에 추가합니다.

# [필수 import]
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.auth.decorators import login_required
from django.contrib.auth import get_user_model
from django.utils import timezone # 시간 기록용
from .models import Message
from .forms import MessageForm

# 1. 쪽지함 (목록)
@login_required
def message_list(request):
    # 받은 쪽지 & 보낸 쪽지 조회
    received_messages = Message.objects.filter(receiver=request.user)
    sent_messages = Message.objects.filter(sender=request.user)
    
    # [핵심] 안 읽은 쪽지 개수 계산 (read_at이 비어있는 것)
    unread_count = received_messages.filter(read_at__isnull=True).count()
    
    context = {
        'received_messages': received_messages,
        'sent_messages': sent_messages,
        'unread_count': unread_count, # 뱃지 표시용
    }
    return render(request, 'accounts/message_list.html', context)

# 2. 쪽지 보내기
@login_required
def message_send(request, receiver_pk):
    User = get_user_model()
    receiver = get_object_or_404(User, pk=receiver_pk)
    
    if request.method == 'POST':
        form = MessageForm(request.POST)
        if form.is_valid():
            message = form.save(commit=False)
            message.sender = request.user   # 보낸 사람: 나
            message.receiver = receiver     # 받는 사람: 지정된 유저
            message.save()
            return redirect('accounts:message_list')
    else:
        form = MessageForm()
        
    return render(request, 'accounts/message_send.html', {'form': form, 'receiver': receiver})

# 3. 쪽지 상세 보기 & 읽음 처리
@login_required
def message_detail(request, message_pk):
    message = get_object_or_404(Message, pk=message_pk)
    
    # [권한 체크] 당사자(보낸 사람, 받은 사람)가 아니면 접근 불가
    if request.user != message.sender and request.user != message.receiver:
        return redirect('accounts:message_list')

    # [읽음 처리] 받는 사람이 처음 열어본 경우에만 현재 시간 기록
    if request.user == message.receiver and not message.read_at:
        message.read_at = timezone.now()
        message.save()

    return render(request, 'accounts/message_detail.html', {'message': message})

6. 화면 작성 1 - 쪽지함 (accounts/message_list.html)

Bootstrap Tabs를 이용해 받은 편지함과 보낸 편지함을 구분합니다.

받은 편지함 탭에는 안 읽은 메시지가 있을 때 빨간 뱃지(N)를 띄워줍니다.

accounts/templates/accounts/message_list.html 생성 후 아래 코드를 작성합니다.

{% extends 'base.html' %}

{% block content %}
<div class="container mt-4">
    <h3 class="mb-4"><i class="bi bi-envelope"></i> 내 쪽지함</h3>

    <ul class="nav nav-tabs" id="messageTab" role="tablist">
        <li class="nav-item" role="presentation">
            <button class="nav-link active" id="received-tab" data-bs-toggle="tab" data-bs-target="#received" type="button" role="tab" aria-controls="received" aria-selected="true">
                받은 쪽지 
                {% if unread_count > 0 %}
                    <span class="badge bg-danger rounded-pill">{{ unread_count }}</span>
                {% endif %}
            </button>
        </li>
        <li class="nav-item" role="presentation">
            <button class="nav-link" id="sent-tab" data-bs-toggle="tab" data-bs-target="#sent" type="button" role="tab" aria-controls="sent" aria-selected="false">
                보낸 쪽지 
                </button>
        </li>
    </ul>

    <div class="tab-content p-3 border border-top-0 bg-white" id="messageTabContent">
        
        <div class="tab-pane fade show active" id="received" role="tabpanel" aria-labelledby="received-tab">
            <div class="list-group list-group-flush">
                {% for msg in received_messages %}
                <a href="{% url 'accounts:message_detail' msg.pk %}" class="list-group-item list-group-item-action">
                    <div class="d-flex justify-content-between">
                        <strong>
                            {% if not msg.read_at %}
                            <span class="badge bg-danger me-1">N</span> {% endif %}
                            {{ msg.title }}
                        </strong>
                        <small class="text-muted">{{ msg.created_at|date:"Y-m-d" }}</small>
                    </div>
                    <div class="small text-muted mt-1">
                        From: 
                        {% if msg.sender.avatar %}
                        <img src="{{ msg.sender.avatar.url }}" width="20" height="20" class="rounded-circle me-1">
                        {% endif %}
                        {{ msg.sender.nickname|default:msg.sender.username }}
                    </div>
                </a>
                {% empty %}
                <p class="text-center py-4 text-muted">받은 쪽지가 없습니다.</p>
                {% endfor %}
            </div>
        </div>

        <div class="tab-pane fade" id="sent" role="tabpanel" aria-labelledby="sent-tab">
             <div class="list-group list-group-flush">
                {% for msg in sent_messages %}
                <a href="{% url 'accounts:message_detail' msg.pk %}" class="list-group-item list-group-item-action">
                    <div class="d-flex justify-content-between">
                        <strong>{{ msg.title }}</strong>
                        <small class="text-muted">
                            {% if msg.read_at %}
                            <span class="text-success"><i class="bi bi-check-all"></i> 읽음</span>
                            {% else %}
                            <span class="text-secondary">읽지 않음</span>
                            {% endif %}
                        </small>
                    </div>
                    <div class="small text-muted mt-1">
                        To: 
                        {% if msg.receiver.avatar %}
                        <img src="{{ msg.receiver.avatar.url }}" width="20" height="20" class="rounded-circle me-1">
                        {% endif %}
                        {{ msg.receiver.nickname|default:msg.receiver.username }}
                    </div>
                </a>
                {% empty %}
                <p class="text-center py-4 text-muted">보낸 쪽지가 없습니다.</p>
                {% endfor %}
            </div>
        </div>
    </div>
</div>

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% endblock %}

 


7. 화면 작성 2 - 쪽지 쓰기 (accounts/message_send.html)

제목과 내용을 입력받는 폼입니다. form.as_p 대신 수동으로 필드를 배치합니다.

accounts/templates/accounts/message_send.html 생성 후 아래 코드를 작성합니다.

{% extends 'base.html' %}

{% block content %}
<div class="row justify-content-center">
    <div class="col-md-6">
        <div class="card mt-4">
            <div class="card-header bg-primary text-white">
                <i class="bi bi-send"></i> 쪽지 보내기
            </div>
            <div class="card-body">
                <p>
                    받는 사람: 
                    <strong>
                        {% if receiver.avatar %}
                        <img src="{{ receiver.avatar.url }}" width="24" height="24" class="rounded-circle me-1">
                        {% endif %}
                        {{ receiver.nickname|default:receiver.username }}
                    </strong>
                </p>
                
                <form method="post">
                    {% csrf_token %}
                    
                    <div class="mb-3">
                        <label for="{{ form.title.id_for_label }}" class="form-label">제목</label>
                        {{ form.title }}
                    </div>

                    <div class="mb-3">
                        <label for="{{ form.content.id_for_label }}" class="form-label">내용</label>
                        {{ form.content }}
                    </div>
                    
                    <div class="d-grid gap-2">
                        <button type="submit" class="btn btn-primary">전송하기</button>
                        <a href="javascript:history.back()" class="btn btn-secondary">취소</a>
                    </div>
                </form>
            </div>
        </div>
    </div>
</div>
{% endblock %}

8. 화면 작성 3 - 쪽지 상세 (accounts/message_detail.html)

쪽지의 내용을 확인하고, 받은 쪽지라면 바로 답장할 수 있는 버튼을 제공합니다.

accounts/templates/accounts/message_detail.html 생성 후 아래 코드를 작성합니다.

{% extends 'base.html' %}

{% block content %}
<div class="row justify-content-center">
    <div class="col-md-8">
        <div class="card mt-4">
            <div class="card-header d-flex justify-content-between align-items-center">
                <h5 class="mb-0">{{ message.title }}</h5>
                <small class="text-muted">{{ message.created_at|date:"Y-m-d H:i" }}</small>
            </div>
            <div class="card-body">
                <div class="mb-3 p-3 bg-light rounded d-flex justify-content-between align-items-center">
                    <div>
                        <span class="text-secondary me-2">보낸 사람:</span>
                        {% if message.sender.avatar %}
                            <img src="{{ message.sender.avatar.url }}" width="24" height="24" class="rounded-circle">
                        {% endif %}
                        <strong>{{ message.sender.nickname|default:message.sender.username }}</strong>
                    </div>
                    
                    {% if user == message.sender %}
                        <div class="small">
                            {% if message.read_at %}
                                <span class="text-success"><i class="bi bi-check-all"></i> {{ message.read_at|date:"m/d H:i" }} 읽음</span>
                            {% else %}
                                <span class="text-muted">읽지 않음</span>
                            {% endif %}
                        </div>
                    {% endif %}
                </div>

                <div class="content mb-4" style="min-height: 150px;">
                    {{ message.content|linebreaks }}
                </div>

                <div class="d-flex justify-content-end gap-2">
                    <a href="{% url 'accounts:message_list' %}" class="btn btn-secondary">목록</a>
                    
                    {% if user == message.receiver %}
                        <a href="{% url 'accounts:message_send' message.sender.pk %}" class="btn btn-primary">답장</a>
                    {% endif %}
                </div>
            </div>
        </div>
    </div>
</div>
{% endblock %}

9. 네비게이션 및 게시판 연결

마지막으로 이 훌륭한 기능을 사용자가 쉽게 찾을 수 있도록 연결합니다.

1. 상단바 수정 (templates/base.html)

 - 프로필 클릭 시 마이페이지 | 쪽지함 드롭다운 메뉴 추가

<li class="nav-item dropdown me-3">
    <a class="nav-link dropdown-toggle d-flex align-items-center gap-2" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
        <strong>{{ user.nickname|default:user.username }}</strong>
    </a>
    <ul class="dropdown-menu dropdown-menu-end">
        <li><a class="dropdown-item" href="{% url 'accounts:profile' %}"><i class="bi bi-person-gear"></i> 내 정보</a></li>
        <li><a class="dropdown-item" href="{% url 'accounts:message_list' %}"><i class="bi bi-envelope"></i> 쪽지함</a></li>
    </ul>
</li>

 

2. 게시판 상세 페이지 (board_detail.html)

 - 작성자 클릭 시 '쪽지 보내기' 메뉴 추가

<span class="dropdown">
    <a href="#" class="text-decoration-none text-dark fw-bold dropdown-toggle" data-bs-toggle="dropdown">
        {{ post.author.nickname|default:post.author.username }}
    </a>
    <ul class="dropdown-menu">
        <li><a class="dropdown-item" href="{% url 'accounts:message_send' post.author.pk %}">쪽지 보내기</a></li>
    </ul>
</span>

 

10. 테스트 (기능 점검)

테스트를 위해 Chrome 시크릿 모드나 다른 브라우저를 켜서, 두 개의 아이디(보내는 사람/받는 사람)로
동시에 로그인해 두면 편합니다.)
  1. 쪽지 보내기 (게시판 연동):
    • 게시글 상세 페이지에서 작성자 닉네임을 클릭합니다.
    • 드롭다운 메뉴에서 **[쪽지 보내기]**를 누릅니다.
    • 제목과 내용을 입력하고 전송합니다.
  2. 수신 확인 (받는 사람 입장):
    • 받는 사람 아이디로 로그인합니다.
    • 상단 메뉴바의 프로필을 클릭하고 **[쪽지함]**으로 이동합니다.
    • [받은 쪽지] 탭에 **빨간색 뱃지(N)**가 떠 있는지, 안 읽은 쪽지 개수가 맞는지 확인합니다.
    • 쪽지를 클릭해서 상세 내용을 읽습니다. (이 순간 '읽음' 처리가 됩니다.)
  3. 읽음 확인 (보낸 사람 입장):
    • 다시 보낸 사람 아이디로 돌아옵니다.
    • 쪽지함의 [보낸 쪽지] 탭으로 이동합니다.
    • 방금 보낸 쪽지의 상태가 **"읽지 않음"**에서 **"읽음(체크 표시)"**으로 바뀌었는지 확인합니다.
  4. 답장 기능:
    • 받은 쪽지 상세 페이지에서 [답장] 버튼을 눌러 바로 답장을 보내봅니다.