Published on

Django로 1:1 비공개 Q&A 게시판 만들기 — 권한 분리와 PostComment 패턴

Authors
  • avatar
    Name
    Hyo814
    Twitter

Django로 1:1 비공개 Q&A 게시판 만들기 — 권한 분리와 PostComment 패턴

공개 게시판은 쉽지만 "작성자 본인 + 관리자만" 읽을 수 있는 비공개 Q&A 는 권한 처리가 은근히 까다롭습니다. 열람/작성/답변/수정/삭제 각각이 서로 다른 권한 조건을 가져서, 하나만 빠뜨려도 즉시 보안 문제가 됩니다. 이번에 만든 1:1 Q&A 게시판을 권한 위주로 정리합니다.


1. 요구사항 — 권한 매트릭스

먼저 누가 무엇을 할 수 있는지 표로 그렸습니다.

액션비로그인일반 사용자 (작성자 본인)일반 사용자 (타인)관리자(staff)
목록 조회✅ (본인 글만)✅ (전체)
상세 조회
문의 작성
문의 수정/삭제✅ (답변 전까지)
답변 작성/수정/삭제

핵심 포인트: 일반 사용자가 "본인 글만" 볼 수 있고, 답변은 오직 staff만 건드릴 수 있습니다.


2. 모델 — 문의 + 답변은 하나의 PostComment 모델로

문의(Inquiry)와 답변(Answer)을 별도 모델로 두는 대신, PostComment 라는 일반화된 모델 하나에 "어떤 글의 댓글인지"를 외래키로 연결했습니다. 이 패턴은 공지사항 댓글에도 재사용됩니다.

# common/models.py
class Post(models.Model):
    """문의 본문"""
    CATEGORY_CHOICES = [
        ('question', '질문'),
        ('bug', '오류 신고'),
        ('etc', '기타'),
    ]
    title = models.CharField(max_length=200)
    content = models.TextField()
    category = models.CharField(max_length=20, choices=CATEGORY_CHOICES)
    author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
    created_at = models.DateTimeField(auto_now_add=True)
    is_private = models.BooleanField(default=True)   # 항상 비공개


class PostComment(models.Model):
    """답변 — staff만 작성"""
    post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='comments')
    author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
    content = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

3. 열람 권한 — QuerySet 레벨에서 차단

뷰에서 "본인인지" 체크하기 전에, QuerySet부터 필터링합니다. 그래야 URL을 직접 쳐서 타인 글에 접근하는 것도 막힙니다.

# common/views/inquiry.py
class InquiryListView(LoginRequiredMixin, ListView):
    template_name = 'common/inquiry/inquiry_list.html.j2'

    def get_queryset(self):
        user = self.request.user
        qs = Post.objects.filter(is_private=True).select_related('author')
        if user.is_staff:
            return qs.order_by('-created_at')
        # 일반 사용자는 본인 글만
        return qs.filter(author=user).order_by('-created_at')


class InquiryDetailView(LoginRequiredMixin, DetailView):
    model = Post
    template_name = 'common/inquiry/inquiry.html.j2'

    def get_object(self, queryset=None):
        obj = super().get_object(queryset)
        user = self.request.user
        # staff가 아니고 본인 글도 아니면 404
        if not user.is_staff and obj.author != user:
            raise Http404('권한이 없습니다.')
        return obj

왜 404인가PermissionDenied로 403을 주면 "그 글이 존재한다"는 정보가 새어 나갑니다. 404로 감추는 쪽이 안전합니다.


4. 답변 작성/수정/삭제 — staff 전용 뷰

답변 관련 뷰는 UserPassesTestMixin으로 staff 여부만 확인합니다.

from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin

class StaffRequiredMixin(UserPassesTestMixin):
    def test_func(self):
        return self.request.user.is_staff


class CommentCreateView(LoginRequiredMixin, StaffRequiredMixin, CreateView):
    model = PostComment
    fields = ['content']

    def form_valid(self, form):
        form.instance.post_id = self.kwargs['post_id']
        form.instance.author = self.request.user
        return super().form_valid(form)


class CommentUpdateView(LoginRequiredMixin, StaffRequiredMixin, UpdateView):
    model = PostComment
    fields = ['content']

    def get_object(self, queryset=None):
        obj = super().get_object(queryset)
        # 본인이 쓴 답변만 수정 가능 (다른 staff도 못 건드리게)
        if obj.author != self.request.user:
            raise Http404()
        return obj

5. 문의 수정/삭제 — "답변이 달리기 전까지만" 허용

사용자가 본인 문의를 수정하는 것은 좋지만, 답변이 달린 뒤에 수정되면 답변 맥락이 깨집니다.

class InquiryUpdateView(LoginRequiredMixin, UpdateView):
    model = Post

    def get_object(self, queryset=None):
        obj = super().get_object(queryset)
        user = self.request.user

        # 1. 본인 또는 staff만
        if obj.author != user and not user.is_staff:
            raise Http404()

        # 2. 답변이 달렸으면 일반 사용자는 수정 불가 (staff는 가능)
        if obj.comments.exists() and not user.is_staff:
            raise PermissionDenied('답변이 등록된 문의는 수정할 수 없습니다.')

        return obj

6. 첨부파일 — 공지사항 패턴 재사용

첨부파일 업로드 UI는 공지사항(notice_set)에서 쓰던 jQuery formset + 아코디언 패턴을 그대로 가져왔습니다.

{# templates/common/inquiry/inquiry_form.html.j2 #}
<div class="attachment-accordion">
    {{ attachment_formset.management_form }}
    {% for form in attachment_formset %}
        <div class="attachment-row">
            {{ form.file }}
            {{ form.DELETE }}
        </div>
    {% endfor %}
    <button type="button" class="btn-add-attachment">+ 첨부 추가</button>
</div>

<script>
    $('.btn-add-attachment').on('click', function () {
        const total = $('#id_attachments-TOTAL_FORMS');
        const idx = parseInt(total.val());
        const $last = $('.attachment-row').last();
        const $clone = $last.clone();
        // 이름/id 치환
        $clone.find(':input').each(function () {
            this.name = this.name.replace(/-\d+-/, `-${idx}-`);
            this.id = this.id.replace(/-\d+-/, `-${idx}-`);
            $(this).val('');
        });
        $last.after($clone);
        total.val(idx + 1);
    });
</script>

재사용의 이득 — 이미 검증된 UI라 별도 QA 없이 배포 가능. 공지사항 팀이 버그 고치면 Q&A도 같이 개선되는 효과.


7. 템플릿에서 권한 분기

상세 페이지에서 "답변 작성" 버튼은 staff에게만, "수정/삭제"는 작성자 본인에게만 보이도록 합니다.

{# templates/common/inquiry/inquiry.html.j2 #}
<section class="inquiry-detail">
    <h2>{{ post.title }}</h2>
    <p>{{ post.content | safe }}</p>

    {% if post.author == request.user and not post.comments.exists %}
        <a href="{{ url('common:inquiry_edit', post.id) }}" class="btn">수정</a>
        <button class="btn-delete" data-id="{{ post.id }}">삭제</button>
    {% endif %}
</section>

<section class="answers">
    {% for comment in post.comments.all %}
        <div class="answer">
            <p>{{ comment.content | safe }}</p>
            {% if request.user.is_staff and comment.author == request.user %}
                <a href="{{ url('common:comment_edit', comment.id) }}">수정</a>
            {% endif %}
        </div>
    {% endfor %}

    {% if request.user.is_staff %}
        <form method="post" action="{{ url('common:comment_create', post.id) }}">
            {{ csrf_input }}
            <textarea name="content" required></textarea>
            <button type="submit">답변 작성</button>
        </form>
    {% endif %}
</section>

8. 네비게이션 — require_auth로 비로그인 노출 차단

메뉴 자체를 비로그인 사용자에게 숨기는 것도 잊지 마세요.

{# templates/utils/common.html.j2 #}
{% if request.user.is_authenticated %}
    <a href="{{ url('common:inquiry_list') }}">Q&A</a>
{% endif %}

그리고 nav 설정에서도 require_auth: true를 명시해두면 설정과 템플릿이 이중 방어막이 됩니다.


정리

  • 권한 매트릭스를 먼저 표로 그리세요. 뷰를 짜기 전에 4×5 정도의 표로 정리하면 놓치는 케이스가 확 줄어듭니다.
  • QuerySet 레벨 필터링이 뷰 레벨 체크보다 안전합니다. URL 추측 공격을 차단합니다.
  • 타인 글 접근은 404가 403보다 안전합니다. 존재 여부를 감출 수 있습니다.
  • "답변 달린 뒤 수정 불가" 같은 조건부 제약은 get_object에서 PermissionDenied로 명시적으로 차단하세요.
  • Post + PostComment 일반화 모델은 공지사항, Q&A, FAQ 등 유사한 게시판에 모두 재사용 가능합니다.
  • 첨부파일 formset 패턴은 한 번 잘 만들어두면 게시판마다 재활용됩니다.

비공개 게시판은 "공개 게시판의 필터 하나 추가" 가 아니라 권한 중심으로 재설계해야 합니다. 특히 상세/수정/답변 각각을 독립적으로 권한 체크하는 습관이 중요합니다.