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

- Name
- Hyo814
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 패턴은 한 번 잘 만들어두면 게시판마다 재활용됩니다.
비공개 게시판은 "공개 게시판의 필터 하나 추가" 가 아니라 권한 중심으로 재설계해야 합니다. 특히 상세/수정/답변 각각을 독립적으로 권한 체크하는 습관이 중요합니다.