BinaryHub 개발기 ⑨ 공식 블로그 한 카테고리만 위키로 — 권한 매트릭스의 실험
BinaryHub 개발기. 전체를 다 풀지 않는 것이 위키가 살아남는 길이다.
왜 일부만 풀었나
부원과 함께 테트리스 관련 용어·전략·셋업 을 정리하고 싶어졌다. T-spin, PC, DT cannon, SDPC, DAS·ARR 같은 항목들을 한 곳에 모으는 위키. 그런데 공식 블로그(BBlog) 전체에 위키 권한을 열기엔 부담이 컸다.
- 운영자(나)의 회고·연구노트는 톤·서사가 글의 본질이다. 부원이 손대면 그 가치가 흩어진다
- R&E 같은 글은 사실상 개인 기록 — 익명화 처리도 들어가 있어서 무심코 수정될 위험
- 동아리 5명짜리 규모에서 모든 글에 권한 매트릭스를 두는 건 과한 비용
그래서 결론은 — 공식 블로그의 한 카테고리만 위키로 풀자. 운영자는 여전히 모든 카테고리, 부원은 그 한 곳만 쓰고 다듬을 수 있다.
데이터 모델은 그대로
이 결정의 가장 좋은 점은 데이터 모델을 안 건드려도 된다 는 것이다.
- 글은 여전히
content/posts/*.md - 카테고리는 frontmatter 의
category한 줄 - 부원의 개인 블로그는 별개의
content/club/[member]/posts/디렉토리에 그대로
위키 카테고리는 category: tetris 하나가 더 있을 뿐이고, 그 안의 글은 운영자 글과 같은 디렉토리에 섞여 있다. 빌드·sitemap·RSS·OG image 가 다 똑같이 동작한다.
카테고리 정의 한 줄
// lib/types.ts
export const CATEGORIES = [
// ... 다른 카테고리들
{
slug: "tetris",
label: "테트리스 위키",
description:
"T-spin·PC·DT·SDPC·DAS·SRS 같은 테트리스 용어·전략·셋업·메타를 정리하는 위키형 카테고리. 동아리 부원 누구나 항목을 추가하거나 다듬을 수 있다.",
},
] as const;이전 글에서 SEO 정리하면서 카테고리 description 을 도입해 뒀더니, 새 카테고리 하나만 추가해도 카테고리 페이지·태그 페이지·sitemap·OG 메타가 다 자동으로 따라온다.
권한 매트릭스
핵심 결정은 누가, 어떤 카테고리에, 무엇을 할 수 있는가 다.
| tetris 카테고리 | 그 외 카테고리 | |
|---|---|---|
| 운영자 (admin) | 작성·수정·삭제 | 작성·수정·삭제 |
| 부원 (일반) | 작성·수정·삭제 | ✗ |
| 비로그인 | ✗ | ✗ |
운영자 식별은 단순하게.
// lib/club.ts
export const ADMIN_SLUGS: ReadonlySet<string> = new Set(["member1"]);
export function isAdminSlug(slug: string | null | undefined): boolean {
return !!slug && ADMIN_SLUGS.has(slug);
}부원 인증은 이미 NextAuth + Google OAuth 가 깔려 있어서 session.user.memberSlug 가 들어온다. admin 인지 여부는 그 slug 가 ADMIN_SLUGS 에 있느냐 한 줄로 결정.
server action 의 카테고리 분기
진짜 안전망은 UI 가 아니라 server action 안의 검증 이다. UI 가 카테고리 select 를 안 보여줘도, 누군가가 form 을 직접 조립해서 다른 카테고리로 POST 하면 처리해야 한다.
// lib/blog-edit-actions.ts
function categoryAllowed(category: string, isAdmin: boolean): boolean {
if (!CATEGORIES.some((c) => c.slug === category)) return false;
if (isAdmin) return true;
return category === TETRIS;
}
export async function saveBlogPost(isNew: boolean, formData: FormData) {
const session = await auth();
const memberSlug = session?.user?.memberSlug;
if (!memberSlug) return { error: "로그인이 필요합니다." };
const admin = isAdminSlug(memberSlug);
const payload = parsePayload(formData);
if (!categoryAllowed(payload.category, admin)) {
return { error: "허용되지 않은 카테고리입니다." };
}
// 기존 글 수정이면 원래 카테고리도 검사 — 부원이 tetris 외 글 못 만지게
if (!isNew) {
const { data } = matter(await fs.readFile(filePath, "utf-8"));
if (!admin && data.category && data.category !== TETRIS) {
return { error: "이 카테고리 글은 운영자만 수정할 수 있습니다." };
}
}
// ... 저장 로직
}세 가지 안전망이 겹친다.
- 새 카테고리가 허용 목록에 있는지 — 부원은 tetris 만
- 원래 카테고리가 권한 범위인지 — 부원이 다른 카테고리 글을 tetris 로 바꾸는 식의 우회 방지
- 새 카테고리가 허용 범위인지 — 부원이 카테고리를 옮겨서 권한 밖으로 못 만듦
이 셋이 다 통과해야 저장된다.
UI 분기 — PostEditor 의 canChooseCategory
같은 PostEditor 컴포넌트를 운영자/부원 양쪽에 쓰되, 카테고리 select 가 보이느냐만 prop 으로 갈린다.
// components/PostEditor.tsx
type Props = {
isNew: boolean;
saveAction: SaveAction;
/** true 면 카테고리 select 노출 (운영자 전용). false 면 hidden 으로 고정. */
canChooseCategory?: boolean;
initial: { /* ... */ };
};
// 폼 안
{canChooseCategory ? (
<select name="category" value={category} onChange={...}>
{CATEGORIES.map((c) => (
<option key={c.slug} value={c.slug}>{c.label}</option>
))}
</select>
) : (
/* 부원은 카테고리 변경 불가 — initial.category(= tetris) 값을 hidden 으로 고정 */
<input type="hidden" name="category" value={category} />
)}new/edit 페이지가 session 보고 admin 여부 결정해서 prop 으로 넘겨준다.
// app/blog/edit/new/page.tsx
const session = await auth();
const memberSlug = session?.user?.memberSlug;
if (!memberSlug) redirect("/sign-in?callbackUrl=/blog/edit/new");
const admin = isAdminSlug(memberSlug);
return (
<PostEditor
isNew
saveAction={saveBlogPost.bind(null, true)}
canChooseCategory={admin}
initial={{
// ...
category: admin ? "dev" : "tetris",
}}
/>
);목록 페이지(/blog/edit) 도 같은 패턴 — admin 이면 전체 글, 부원이면 tetris 글만 보인다. 부원 본인이 다른 글의 /blog/edit/<slug> 를 직접 입력해도 notFound() 로 끊긴다.
위키 메타 — author 와 lastEditedBy
위키스러우려면 누가 처음 썼고 누가 다듬었는지 가 글에 박혀야 한다. frontmatter 에 두 줄을 추가한다.
author: member3
lastEditedBy: member1
editedAt: 2026-06-17T14:23:00.000Z
lastEditedReason: "DT 정의 보완"저장 로직.
// 기존 글이면 원래 author 보존, 마지막 수정자만 갱신
const author = originalAuthor ?? memberSlug;
const fm = {
// ...
author,
};
if (!isNew) {
fm.lastEditedBy = memberSlug;
fm.editedAt = new Date().toISOString();
if (payload.reason) fm.lastEditedReason = payload.reason;
}본문 헤더에서:
2026-06-17 · 약 4분 · 마지막 수정 2026-06-17 (DT 정의 보완) · 편집
운영자 글에는 author 가 없으니까(.md 로 직접 작성) 헤더에 추가 메타는 안 뜬다. 위키 카테고리 글에만 자연스럽게 author·수정자·사유가 깔린다.
변경 이력은 이미 깔려 있다
이전 글에서 깔아 둔 git log 기반 history 가 그대로 위키 카테고리 글에도 적용된다. 본문 끝 <details> 에 commit 별로 시점·author·사유(commit subject 또는 lastEditedReason)가 흐른다. web 편집기로 수정해도, .md 직접 commit 해도 같은 history 에 줄 하나가 추가된다.
= 권한만 새로 분기하고, 이력·UX·SEO·OG image 같은 인프라는 그대로 재사용된다. 위키 카테고리가 다른 카테고리와 똑같이 굴러간다.
한 가지 더 — 화이트리스트로 운영자 글 보호
server action 의 categoryAllowed 가 분기를 잡지만, 한 가지 더 안전망을 둔다. 부원이 /blog/edit/binaryhub-04-studyace 같은 URL 을 직접 쳐도 404 가 떠야 한다.
// app/blog/edit/[slug]/page.tsx
const post = getPost(slug);
if (!post) notFound();
// 부원이 tetris 외 글에 접근하면 404
if (!admin && post.category !== "tetris") notFound();UI(notFound) + server action(권한 거절) + categoryAllowed(저장 거절) 의 삼중 안전망. 한 군데 빠뜨려도 다른 두 군데가 잡는다.
결과 — 같은 블로그, 다른 결의 한 카테고리
테트리스 카테고리는 다른 카테고리와 똑같이 보인다. 카테고리 페이지, 태그, OG image, RSS 까지 다 같다. 그런데 들어가 보면 글마다 "작성 / 마지막 수정 / 편집" 메타가 뜨고, 부원이 로그인 후 "+ 새 글" 로 항목을 추가하고 있다.
며칠 운영해 보니 부원이 글 두 편을 이미 올렸다 — tetrio playstyle, supercharged. 첫 위키 시드.
다음
위키 시스템을 깔면서 한 가지 깨달았다 — 내 사이트 어디가 클라이언트를 너무 믿고 있는가 가 보이기 시작했다는 것. 다음 글은 시각이 옮겨 간 곳, StudyAce 의 어뷰징 방지 이야기다.