- 발행일
새 모델 없이 Post + 카테고리로 FAQ 게시판 만들기
새 모델 없이 Post + 카테고리로 FAQ 게시판 만들기
FAQ(자주 묻는 질문) 게시판을 추가했습니다. 처음엔 "FAQ 모델을 새로 만들까?" 였는데, 결국 기존 Post 모델에 FAQ 카테고리를 얹는 방향으로 갔어요. 게시판이 늘 때마다 모델을 늘리지 않는 게 이 프로젝트의 결이기도 했습니다. 그 결정과 구현을 정리합니다.
참고로 1:1 비공개 Q&A는 별도 글에서 다뤘는데, 그건 사용자별 비공개 문의였고 이번 FAQ는 공개·읽기 중심 이라 성격이 완전히 다릅니다.
1. 결정 — 새 모델 vs 기존 모델 + 카테고리
FAQ가 필요로 하는 건 결국 제목 + 본문 + 노출 상태 + 분류입니다. 그건 공지사항이 쓰는 Post와 똑같았어요. 새 모델을 파면 마이그레이션·관리자 화면·폼을 다 새로 만들어야 하는데, FAQ는 그만한 고유 필드가 없었습니다.
| 방식 | 장점 | 단점 |
|---|---|---|
새 Faq 모델 | 도메인 명확 | 마이그레이션·CRUD·폼 전부 신설, 공지와 중복 |
Post + FAQ 카테고리 | 기존 CRUD·폼 재사용 | 쿼리에 카테고리 조건 필수 |
후자로 갔어요. 대신 카테고리 조건을 빼먹으면 공지·FAQ가 섞이는 리스크가 있어서, 그걸 막는 게 구현의 핵심이 됐습니다.
2. 카테고리를 안전하게 얻기
FAQ 카테고리가 없는 환경(초기화 커맨드 미실행)에서도 깨지지 않도록, 없으면 만들어 반환 하는 헬퍼를 뒀어요.
FAQ_CATEGORY_NAME = "FAQ"
def _get_faq_category():
"""FAQ 분류를 반환한다. init_post_category 가 실행되지 않았어도 안전하게 생성한다."""
category, _created = Category.objects.get_or_create(
name=FAQ_CATEGORY_NAME,
defaults={"description": "자주 묻는 질문 게시판"},
)
return category
"카테고리가 있다고 가정" 하는 대신 get_or_create로 두니, 새 환경에서도 첫 진입이 터지지 않았습니다.
3. 사용자 화면 — 공개·검색·큐레이션 순서
사용자용 목록 뷰는 카테고리 = FAQ 이면서 공개 상태 인 글만 보여줍니다. 검색은 제목/내용 부분일치로요.
class FaqListView(ListView):
model = Post
template_name = "common/faq/faq_list.html.j2"
def get_queryset(self):
queryset = super().get_queryset()
if self.search_keyword:
queryset = queryset.filter(
Q(category__name__exact="FAQ")
& Q(state__exact=Post.State.PUBLIC)
& (Q(subject__icontains=self.search_keyword)
| Q(content__icontains=self.search_keyword))
).distinct()
else:
queryset = queryset.filter(
Q(category__name__exact="FAQ") & Q(state__exact=Post.State.PUBLIC)
).distinct()
# 등록(큐레이션) 순서대로 노출 — faq.json 의 우선순위 순서를 그대로 따른다.
return queryset.order_by("pk")
여기서 작은 결정이 정렬이었어요. FAQ는 최신순이 아니라 "중요한 질문이 위" 가 맞습니다. 그래서 최신순(-create_date) 대신 등록 순서(pk)로 노출했어요. 적재 데이터(faq.json)에 우선순위 순서대로 넣어두면, 그 순서가 곧 화면 순서가 됩니다. 큐레이션을 데이터 순서로 표현 한 셈이에요.
같은 김에 공지사항 목록 검색도 공지사항 카테고리로 한정 했습니다. 한 Post 모델을 공유하니, 각 화면이 자기 카테고리만 보도록 조건을 박아두는 게 중요했어요.
4. 관리자 CRUD와 읽기 전용 본문
관리자 쪽은 기존 Post용 CreateView/UpdateView/DeleteView를 그대로 재사용했습니다. FAQ 목록에 FAQ 추가 버튼을 얹고, 검토 탭에 FAQ를 추가한 정도예요. (아래 FaqListView는 사용자용과 이름은 같지만 web_admin/views/system/faq.py의 별도 클래스예요.)
class FaqListView(ListView):
extra_context = _make_context({
"extra_search_buttons": [{
"title": _("FAQ 추가"), "class": "primary",
"onclick": {"type": "url", "url": "web-admin:system:add_faq"},
}],
"detail_url": "web-admin:system:faq_detail",
"tab_list": _get_tab_list(),
})
model = Post
상세 페이지에서는 CKEditor 본문을 읽기 전용 으로 처리했어요. FAQ·공지 상세는 보는 화면이지 편집 화면이 아니니, 에디터가 입력 가능한 상태로 뜨면 혼란스럽습니다.
데이터는 load_faq 커맨드로 faq.json을 적재하게 했습니다. 카테고리 초기화(init_post_category)와 함께 load.sh/load.bat에 묶어, 새 환경에서 한 번에 시드 되도록 했어요.
5. 검증
모델을 공유하는 구조라, 검증의 초점은 기능 자체보다 "섞이지 않는가" 였습니다.
| 항목 | 결과 |
|---|---|
| FAQ 목록 | FAQ 카테고리 + 공개 상태 글만 노출 |
| 공지 목록 | FAQ 글이 섞이지 않음 (공지 검색도 카테고리 한정) |
| 비공개 글 | FAQ 목록·검색 어디에도 안 나옴 |
| 검색 | 제목·본문 부분일치, 카테고리 조건 유지 |
| 노출 순서 | faq.json 등록 순서 그대로 |
| 새 환경 첫 진입 | init_post_category 미실행 상태에서도 안 터짐 (get_or_create) |
| 시드 | load.sh/load.bat 한 번으로 카테고리 + FAQ 적재 완료 |
두 번째·세 번째 줄이 이 구조의 값을 치르는 지점입니다. 모델을 나눴으면 애초에 확인할 필요가 없는 항목이에요. 모델을 아낀 대가를 검증으로 갚는 셈입니다.
6. 한계
① 카테고리 이름이 문자열로 흩어져 있습니다.
FAQ_CATEGORY_NAME = "FAQ" 상수를 만들어놓고, 정작 뷰의 쿼리에서는 리터럴을 씁니다.
FAQ_CATEGORY_NAME = "FAQ" # 헬퍼는 상수를 쓰는데
...
Q(category__name__exact="FAQ") # 뷰는 리터럴
카테고리 이름을 바꾸면 에러 없이 목록이 빈 채로 뜹니다. 조용히 깨지는 종류라 더 나빠요. 상수로 통일하는 게 최소한이고, 더 나아가면 이름이 아니라 카테고리 pk나 별도 식별자로 참조해야 합니다.
② 카테고리 조건을 강제하는 구조가 없습니다.
1절에서 "카테고리 조건을 빼먹으면 섞인다" 는 리스크를 인정하고 시작했는데, 그걸 막는 건 결국 개발자가 매번 조건을 쓰는 것뿐입니다. 화면이 하나 더 늘 때 빼먹으면 그대로 섞여요.
커스텀 매니저(Post.faq.all())로 조건을 모델 쪽에 박아두면 빼먹을 수가 없습니다. 지금 화면이 둘뿐이라 넘어갔지만, 세 번째가 생기는 시점에는 옮기는 게 맞습니다.
③ 큐레이션 순서를 운영자가 못 바꿉니다.
노출 순서가 pk라, 순서를 조정하려면 faq.json을 고쳐 다시 적재해야 합니다. 중간에 질문 하나를 끼워 넣을 수도 없어요. "적재 순서 = 노출 순서"는 초기 세팅에는 편했지만, 운영이 시작되면 order 필드가 필요해집니다.
7. 교훈
- 게시판이 늘 때마다 모델을 늘리지 않아도 됩니다. 고유 필드가 없다면 기존 모델 + 카테고리가 마이그레이션·CRUD·폼을 통째로 아껴줘요.
- 모델을 공유하면 "카테고리 조건"이 안전장치입니다. 각 화면이 자기 카테고리만 보도록 쿼리에 못 박아야, 공지·FAQ가 섞이지 않아요. 공지 검색까지 카테고리로 한정한 게 그래서였습니다.
- 정렬은 도메인 의미를 담습니다. FAQ는 최신순이 아니라 큐레이션 순서(중요도). 적재 순서를 곧 노출 순서로 쓰면 운영이 단순해져요.
- 시드 커맨드를 같이 만들면 환경 재현이 쉬워집니다. 카테고리 생성을
get_or_create로 방어하고, 적재 커맨드를 load 스크립트에 묶어두면 새 환경에서 안 터집니다.
새 모델을 안 만들고도 FAQ가 깔끔하게 떨어졌어요. "이 데이터가 정말 새 모델을 필요로 하는가, 아니면 분류 하나면 되는가" 를 먼저 물은 게 이번 작업의 출발점이었습니다.