- 발행일
게시판 종류를 코드가 아니라 데이터로 뒀다 — 그리고 넉 달 전 내 결론을 안 지켰다
게시판 종류를 코드가 아니라 데이터로 뒀다
시스템에 공지사항 게시판은 있었습니다. 여기에 둘을 더 붙여야 했어요.
- 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)가 세 분류를 고정 생성하고, 코드가 그 이름을 믿는 구조입니다.
정직하게 말하면 이게 이 작업의 제일 약한 자리입니다. ②의 장점("상수로 비교하니 이름이 안 흔들림")을 버린 대가를 지금 치르고 있어요. Category에 slug 같은 불변 키를 넣었어야 했는데, 이미 쓰이고 있는 모델이라 필드 추가 + 기존 행 백필이 딸려 와서 미뤘습니다. 비교표에서 "포기하는 것"으로 적었던 항목이 그대로 부채가 된 사례입니다.
넉 달 전 내 결론을 안 지켰다
넉 달 전 글의 마지막 절에 이렇게 적어뒀습니다.
타인 글 접근은 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을 잘못 친 줄 알고 계속 찾습니다.
문제는 이게 글을 쓸 때 이미 검토했던 논점이라는 겁니다. 넉 달 전에는 "존재 여부 은닉"을 더 무겁게 봤고, 이번에는 안 봤어요. 판단이 바뀐 게 아니라 그때의 판단을 잊고 새로 정한 것에 가깝습니다.
지금 다시 놓고 보면 이렇습니다.
| 상황 | 403 | 404 |
|---|---|---|
| 로그인 사용자가 남의 문의 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.js와 env.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에 불변 키를 넣기 전까지는 "아무도 안 건드리길 바라는" 상태예요. - 답변 알림이 없습니다. 관리자가 답을 달아도 문의자는 다시 들어와 봐야 압니다. 메일 발송 경로가 이 시스템에 없어 미뤘습니다.
- 첨부는 문의에만 있고 답변에는 없습니다.
PostAttachment가PostFK 하나라, 답변 첨부가 필요해지면 구조를 손봐야 합니다. - FAQ 검색은
icontains두 필드입니다. 건수가 적어 지금은 충분하지만 자리만 표시해뒀어요. - 답변 XSS는
TextField+ 템플릿 이스케이프에 기대고 있습니다. 넉 달 전 글에서| safe하나로 stored XSS가 열려 있던 걸 스스로 지적했는데, 나중에 답변에 에디터를 붙이면 그 전제가 다시 깨집니다.
관련 글: Django로 1:1 비공개 Q&A 게시판 만들기 · pk를 박아둔 e2e 스펙 11개가 한꺼번에 빨개졌다