발행일

게시판 종류를 코드가 아니라 데이터로 뒀다 — 그리고 넉 달 전 내 결론을 안 지켰다

게시판 종류를 코드가 아니라 데이터로 뒀다

시스템에 공지사항 게시판은 있었습니다. 여기에 둘을 더 붙여야 했어요.

  • FAQ — 누구나 보는 공개 목록, 아코디언
  • 1:1 문의(Q&A) — 로그인해야 쓰고, 본인과 관리자만 열람

권한 설계·PostComment 모델·QuerySet 레벨 차단은 넉 달 전에 따로 정리해뒀습니다. 이 글은 그 위에서 실제로 붙이며 만난 것 — 게시판을 무엇으로 구별할 것인가, 그리고 새 게시판이 기존 화면의 전제를 깬 것을 씁니다.

게시판을 어떻게 구별할 것인가

방법장점포기하는 것판단
① 모델 3개 (Notice·Faq·Inquiry)각자 필드가 자유롭고 권한 로직이 안 섞임첨부·CKEditor 본문·상태·정렬이 세 벌. 첨부 삭제 시그널만 세 번. 넷째 게시판이면 또 한 벌기각
Post 하나 + type choices 필드재사용. 필드 하나면 끝. 상수로 비교하니 이름이 안 흔들림게시판 추가에 마이그레이션이 딸림. 운영에서 늘리려면 배포가 필요기각
Post 하나 + Category FK게시판 추가가 데이터 한 행. Category 모델과 관리 화면이 이미 있었음게시판을 이름 문자열로 찾게 됨채택
Post 멀티테이블 상속공통 필드 공유 + 각자 필드조회마다 조인. 이 프로젝트에 전례가 없어 이것만 다른 패턴이 됨기각

③으로 갔습니다. Category 모델과 그 관리 화면이 이미 있었다는 게 제일 큰 이유였어요. 새 개념을 만들지 않고 있는 자리에 얹는 쪽입니다.

대가는 이겁니다.

INQUIRY_CATEGORY_NAME = "문의"

...
queryset = (
    Post.objects.filter(category__name__exact=INQUIRY_CATEGORY_NAME)
    .select_related("user")
    .prefetch_related("post_comments")
)

게시판을 한글 문자열로 찾습니다. 관리자가 관리 화면에서 분류 이름을 "1:1 문의"로 고치는 순간 Q&A 게시판이 빈 목록이 돼요. 지금은 초기화 커맨드(init_post_category)가 세 분류를 고정 생성하고, 코드가 그 이름을 믿는 구조입니다.

정직하게 말하면 이게 이 작업의 제일 약한 자리입니다. ②의 장점("상수로 비교하니 이름이 안 흔들림")을 버린 대가를 지금 치르고 있어요. Categoryslug 같은 불변 키를 넣었어야 했는데, 이미 쓰이고 있는 모델이라 필드 추가 + 기존 행 백필이 딸려 와서 미뤘습니다. 비교표에서 "포기하는 것"으로 적었던 항목이 그대로 부채가 된 사례입니다.

넉 달 전 내 결론을 안 지켰다

넉 달 전 글의 마지막 절에 이렇게 적어뒀습니다.

타인 글 접근은 404가 403보다 안전합니다. 존재 여부를 감출 수 있습니다.

그리고 같은 글에서 스스로 이렇게 지적했습니다.

get_queryset을 목록 뷰와 같은 필터로 오버라이드하면 타인 글은 애초에 조회가 안 돼 404가 공짜로 나오고, 체크 코드 자체가 사라져요.

이번에 실제로 낸 코드입니다.

class InquiryDetailView(LoginRequiredMixin, DetailView):
    def get_object(self, queryset=None):
        obj = super().get_object(queryset)
        if obj.category.name != INQUIRY_CATEGORY_NAME:
            raise PermissionDenied
        if obj.user != self.request.user and not _is_staff(self.request.user):
            raise PermissionDenied
        return obj

403입니다. 지적한 그 패턴 그대로, get_queryset 오버라이드가 아니라 get_object에서 뷰 레벨 체크를 합니다. e2e 스펙도 403을 단언하도록 썼어요.

왜 그랬는지 되짚어보면, 쓸 때는 이렇게 생각했습니다. "로그인한 사용자에게는 '볼 수 없는 글'과 '없는 글'을 구별해주는 게 친절하다." 404를 내면 사용자는 자기가 URL을 잘못 친 줄 알고 계속 찾습니다.

문제는 이게 글을 쓸 때 이미 검토했던 논점이라는 겁니다. 넉 달 전에는 "존재 여부 은닉"을 더 무겁게 봤고, 이번에는 안 봤어요. 판단이 바뀐 게 아니라 그때의 판단을 잊고 새로 정한 것에 가깝습니다.

지금 다시 놓고 보면 이렇습니다.

상황403404
로그인 사용자가 남의 문의 URL을 받았을 때"권한 없음"을 알려 헤매지 않음없는 글로 알고 계속 찾음
문의 pk를 훑어 누가 몇 건 썼는지 알아내려 할 때403/404 차이로 존재 여부가 새어나감전부 404라 셀 수 없음
관리자가 디버깅할 때원인이 명확"지워졌나?"를 먼저 의심

비공개 게시판에서는 두 번째 줄이 첫 번째 줄보다 무겁습니다. pk가 연번이라 훑으면 문의가 몇 건인지, 어느 구간에 몰려 있는지가 드러나요. 넉 달 전 결론이 맞았습니다.

지금 고치지 않은 이유는, get_queryset 오버라이드로 옮기면 category.name 검사와 소유자 검사를 쿼리 조건으로 합쳐야 하고, 그러면 관리자 분기(_is_staff)까지 쿼리셋에 들어갑니다. 30분이면 되는 일인데 이번 배포 범위 밖이라 안 건드렸어요. 다음 손댈 때 첫 항목입니다.

새 게시판이 기존 화면의 전제를 깼다

공지사항 목록 검색은 원래 분류를 안 가렸습니다. Post 전체에서 제목을 훑었어요. 게시판이 공지 하나뿐일 때는 아무 문제가 없었습니다.

비공개 게시판이 같은 테이블에 들어온 순간 이건 정보 노출입니다. 공지 게시판에서 검색하면 남의 문의 제목이 섞여 나올 수 있어요.

검색을 공지사항 분류로 한정해서 막았습니다. 이건 새 기능의 버그가 아니라 기존 코드의 전제가 무너진 것이라, 새 기능 리뷰만 보면 안 걸립니다. Post를 읽는 모든 자리를 훑어야 나와요.

같은 종류로 하나 더 있었습니다. FAQ·공지 상세가 CKEditor5Field를 그대로 렌더해서 읽기 화면에 편집기가 떴습니다. 툴바가 나오고 커서가 들어가요. 저장은 안 되지만 사용자는 편집할 수 있다고 믿습니다. 읽기 전용으로 전환했습니다.

화면이 이미 아는 건 묻지 않는다

관리자 FAQ 등록 폼에서 분류 선택을 숨겼습니다. FAQ 관리 화면에서 만드는 글은 항상 FAQ 분류니까요.

고를 수 있게 두면 실수로 공지로 저장할 수 있고, 그러면 그 글은 FAQ 목록에서 사라집니다. 관리자는 "방금 쓴 글이 어디 갔지"가 되고요. 입력을 없애서 오류 자체를 없앤 것이 고치는 것보다 쌌습니다.

FAQ 초기 데이터 40여 건은 faq.json + load_faq 커맨드로 넣습니다. 손으로 넣게 할 수도 있었지만 원본이 문서로 존재하고 재적재가 필요해서요. 이 시스템은 용어도 엑셀에서 적재합니다 — "원본이 DB 밖에 있으면 변환보다 재적재" 가 이미 굳은 방식이라 FAQ도 맞췄습니다. load.sh·load.bat에 연결해 환경 초기화에 딸려 들어갑니다.

e2e가 스스로 만들고 스스로 지운다

Playwright 시나리오 14건을 붙였습니다. 문제는 문의 게시판에 시드 데이터를 둘 수 없다는 것이었어요. 본인 글만 보이는 게시판이라 고정 시드를 만들면 그 계정으로만 테스트가 됩니다.

스펙이 직접 만들고 직접 지웁니다.

  • 문의 CRUD — 등록 → 조회 → 수정 → 삭제를 한 시나리오 안에서 왕복
  • 소유권 — 계정 A로 문의를 만들고, 계정 B로 그 URL에 접근해 403 확인, A로 돌아와 삭제

두 번째 때문에 계정이 둘 필요해서 auth.setup.jsenv.js를 손봤습니다. pk 하드코딩을 걷어낼 때는 "밀리지 않는 것을 잡자"였는데, 여기서는 아예 고정 데이터에 기대지 않는 쪽으로 갔어요. 그쪽 글에서 배운 것 — 정리(cleanup)가 죽지 않게 하는 것이 여기서도 그대로 적용됩니다.

시나리오: 메뉴 진입 / FAQ 아코디언·전체 펼치기 / FAQ 검색(제목+내용) / 비로그인 Q&A 차단 / 본인 문의만 조회·타인 403 / 문의 CRUD / 관리자 답변 CRUD / FAQ 관리 CRUD.

검증

  • Playwright 시나리오 14건 통과.
  • 공지 검색에 문의 글이 안 섞이는 것을 확인. 이건 새 기능 스펙이 아니라 기존 화면 스펙에 넣었습니다 — 다음에 검색을 손대는 사람이 보는 자리가 거기라서요.
  • 답변 카드 배경이 팔레트에 없는 gray-50이었던 것을 gray-100으로 교정. 이 프로젝트는 팔레트 밖 색을 금지하고 있어서 잡혔습니다.
  • 마이그레이션 1건(0005_postcomment), 게시판 분류 3종 초기화 커맨드.

남은 것 · 한계

  • 403을 404로 바꾸는 것. 위에 쓴 대로 넉 달 전 제 결론이 맞았고, 지금 코드가 그걸 안 지킵니다. get_queryset 오버라이드로 옮기면서 관리자 분기를 쿼리 조건으로 표현하는 게 다음 첫 항목입니다.
  • 게시판을 한글 이름으로 찾습니다. 분류 이름을 관리 화면에서 바꾸면 게시판이 빕니다. Category에 불변 키를 넣기 전까지는 "아무도 안 건드리길 바라는" 상태예요.
  • 답변 알림이 없습니다. 관리자가 답을 달아도 문의자는 다시 들어와 봐야 압니다. 메일 발송 경로가 이 시스템에 없어 미뤘습니다.
  • 첨부는 문의에만 있고 답변에는 없습니다. PostAttachmentPost FK 하나라, 답변 첨부가 필요해지면 구조를 손봐야 합니다.
  • FAQ 검색은 icontains 두 필드입니다. 건수가 적어 지금은 충분하지만 자리만 표시해뒀어요.
  • 답변 XSS는 TextField + 템플릿 이스케이프에 기대고 있습니다. 넉 달 전 글에서 | safe 하나로 stored XSS가 열려 있던 걸 스스로 지적했는데, 나중에 답변에 에디터를 붙이면 그 전제가 다시 깨집니다.

관련 글: Django로 1:1 비공개 Q&A 게시판 만들기 · pk를 박아둔 e2e 스펙 11개가 한꺼번에 빨개졌다