BinaryHub 개발

BinaryHub 개발기 ⑨ 공식 블로그 한 카테고리만 위키로 — 권한 매트릭스의 실험

4
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: "이 카테고리 글은 운영자만 수정할 수 있습니다." };
    }
  }
 
  // ... 저장 로직
}

세 가지 안전망이 겹친다.

  1. 새 카테고리가 허용 목록에 있는지 — 부원은 tetris 만
  2. 원래 카테고리가 권한 범위인지 — 부원이 다른 카테고리 글을 tetris 로 바꾸는 식의 우회 방지
  3. 새 카테고리가 허용 범위인지 — 부원이 카테고리를 옮겨서 권한 밖으로 못 만듦

이 셋이 다 통과해야 저장된다.

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 의 어뷰징 방지 이야기다.

관련 글

태그가 겹치는 다른 글