← 목록으로
BinaryHub 개발

BinaryHub 개발기 ⑧ 블로그에 위키 UX 일부 입히기 — git history, Recent Changes, 풍부한 에디터

4
BinaryHub 개발기 ⑧ 블로그에 위키 UX 일부 입히기 — git history, Recent Changes, 풍부한 에디터

BinaryHub 개발기. 위키처럼 보이게 만드는 건 권한이 아니라 발자국이다.

위키 시스템 ≠ 권한 시스템

처음에 "전체 블로그에 위키 시스템을 일부 적용하자" 라는 얘기가 나왔을 때, 나는 그게 부원 누구나 모든 글을 고칠 수 있게 하자 라는 뜻인 줄 알았다. 그래서 권한 매트릭스를 머릿속에 그리고 있었다. 그런데 정작 원했던 건 다른 것이었다.

위키 시스템이라는 게 부원 편집 가능이 아니라, 위키의 히스토리, 최근 기록이라든지 나무위키의 에디터 같은걸 말하는거야

핵심은 UX. 글이 언제 누구에 의해 어떻게 자라났는지를 페이지에서 볼 수 있고, 최근에 무엇이 움직였는지를 한 화면에서 훑을 수 있고, 글을 쓸 때는 도구바·미리보기·변경 사유 같은 위키 풍 도구가 옆에 있는 것. 권한은 그대로 둬도 된다.

이 차이를 잡고 나니 작업이 셋으로 깔끔하게 나뉘었다.

  1. 모든 글에 변경 이력 — git log 기반
  2. Recent Changes 페이지 — 모든 글 최신 수정 순
  3. 에디터 풍부화 — 도구바, 단축키 도움말, 변경 사유

① git log 가 곧 변경 이력이다

블로그 글은 다 .md 파일이고, 모든 파일이 git 안에 있다. 그러면 한 글의 history 는 git log 한 줄로 다 나온다.

// lib/git-history.ts
import { execSync } from "node:child_process";
 
export function getFileGitHistory(filePath: string): GitChange[] {
  const out = execSync(
    `git log --follow --no-merges --format="%h|%aI|%an|%ae|%s" -- "${filePath}"`,
    { cwd: REPO_ROOT, encoding: "utf-8" }
  );
  return out
    .trim()
    .split("\n")
    .filter(Boolean)
    .map((line) => {
      const [hash, at, author, email, ...subjectParts] = line.split("|");
      return { hash, at, author, email, subject: subjectParts.join("|") };
    });
}
  • --follow — 파일이 rename 돼도 따라간다
  • --no-merges — 머지 커밋 제외
  • 빌드 한 번 안에서 module-level Map 으로 cache. 같은 파일을 여러 페이지가 조회해도 한 번만 호출

글 본문 헤더에는 commit 이 2개 이상이면 "마지막 수정 YYYY-MM-DD" 가 한 줄 들어가고, 본문 끝에는 <details> 로 전체 이력이 접혀 있다. native HTML 만 써서 클라이언트 컴포넌트 0개.

{hasMultipleChanges && (
  <details className="mt-12 border-t pt-6">
    <summary>변경 이력 ({gitHistory.length}건)</summary>
    <ol>
      {[...gitHistory].reverse().map((h, idx) => (
        <li key={`${h.hash}-${idx}`}>
          <time dateTime={h.at}>{...}</time>
          <span>{h.author} {idx === gitHistory.length - 1 ? "최초 작성" : "수정"}</span>
          {h.subject && <span> — {h.subject}</span>}
        </li>
      ))}
    </ol>
  </details>
)}

코드 에디터에서 .md 직접 고치고 commit 해도, web 편집기에서 부원이 고쳐도, 둘 다 같은 git log 한 줄이 추가된다. 두 흐름이 자연스럽게 한 history 로 합쳐진다.

② Recent Changes — 같은 데이터, 다른 시선

페이지 별로 history 가 있다면, 전체 글을 마지막 수정 시점 순 으로 정렬한 페이지도 자연스럽게 따라온다. 새로 올라온 글, 보강된 글, 오타가 잡힌 글이 위에서 아래로 흘러간다.

// app/blog/recent/page.tsx
const rows = getAllPosts()
  .map((p) => {
    const last = getLastChange(`content/posts/${p.slug}.md`);
    if (!last) return null;
    return { slug: p.slug, title: p.title, category: p.category,
             at: last.at, author: last.author, subject: last.subject };
  })
  .filter(Boolean)
  .sort((a, b) => (a.at > b.at ? -1 : 1));

시간 표시는 절대값과 상대값 둘 다. <time title> 에 절대 시간(2026-06-17 14:23), 화면에는 상대 시간("3시간 전"). 한 화면에서 둘 다 잡힌다.

헤더 nav 에 한 줄 추가:

<Link href="/blog/recent">최근 변경</Link>

이게 들어가니까 블로그가 한 번 더 "살아 있다" 는 인상을 준다. 정적 사이트인데도 페이지가 어떻게 자라고 있는지 보인다.

③ 에디터 풍부화 — 운영자도 쓸 거니까

원래 web 편집기는 부원 글 작성용으로만 짰다. 운영자(나) 는 코드 에디터로 .md 직접 고쳤다. 그런데 위키 UX 를 입히면서 자연스럽게 나도 web 편집기에서 쓰자 가 됐다. 그러면 에디터가 좀 더 풍부해야 한다.

추가한 것들:

선택 기반 도구바. 마크다운 모드의 CodeMirror 위에 작은 도구바. 선택한 텍스트를 wrap 하거나 cursor 위치에 snippet 을 끼워 넣는다.

function applyMarkdown(op: "h2" | "bold" | "quote" | "codeblock" | "link" | "image" | "table") {
  const view = cmRef.current?.view;
  if (!view) return;
  const { from, to } = view.state.selection.main;
  const selected = view.state.sliceDoc(from, to);
 
  // op 별로 before/after/placeholder 결정
  // ...
 
  view.dispatch({
    changes: { from, to, insert: `${before}${placeholder}${after}` },
    selection: {
      anchor: from + before.length,
      head: from + before.length + placeholder.length,
    },
  });
  view.focus();
}

@uiw/react-codemirror 의 ref 가 view: EditorView 를 그대로 노출하니까, CodeMirror v6 의 dispatch 를 직접 부른다. 텍스트가 선택돼 있으면 그 부분만 wrap, 없으면 placeholder 가 들어가고 그 부분이 다시 선택된 상태로 남는다. 다음 문자 입력이 곧장 그 자리에 들어간다.

H2 / H3 / 굵게 / 기울임 / 인용 / 목록 / 인라인 코드 / 코드 블록 / 링크 / 이미지 / 표. 표는 multi-line snippet 이라 selection 무시하고 그냥 끼워 넣는다.

변경 사유 입력. 수정 모드에서만 나타나는 한 줄짜리 input.

{!isNew && (
  <label>
    변경 사유 <span>(선택 — 변경 이력에 남음)</span>
    <input name="reason" placeholder="예: 오타 수정, DT 정의 보완, 표 추가" maxLength={120} />
  </label>
)}

서버에서 받아 frontmatter.lastEditedReason 에 박고, 본문 헤더 메타에 "마지막 수정 YYYY-MM-DD (오타 수정)" 처럼 노출된다. 위키스럽게.

단축키·문법 도움말. 폼 하단의 토글 버튼. 열면 Ctrl+S, 마크다운 빠른 문법, 이미지 D&D 안내가 나온다. 처음 쓰는 부원도 한 번 펼쳐 보면 길을 잡는다.

권한은 손대지 않았다

이 모든 변경에서 누가 무엇을 편집할 수 있는가 는 그대로다. 운영자는 모든 글, 부원은 테트리스 카테고리만. 권한 매트릭스는 이미 그 전에 잡아 뒀고(다음 글에서 다룬다), 위키 UX 작업은 그 위에 얹은 한 겹이다.

이게 의외로 큰 차이를 만든다. 위키처럼 보이는 블로그는 권한이 위키여서가 아니라 발자국 이 위키여서다. 누가 언제 무엇을 고쳤는지가 모든 페이지에 깔리면, 보는 사람 입장에서 사이트가 살아 있는 것처럼 느껴진다.

작은 디테일들

  • post 페이지에 auth() 가 들어가면 그 페이지가 dynamic 으로 빠진다. SSG 점수가 깎인다. 그래서 "편집" 링크는 모두에게 보여 주고, 클릭 후 sign-in flow 가 권한을 처리한다. 결과적으로 카테고리·post 페이지가 다 (SSG) 로 돌아왔다.
  • git log 호출은 빌드 시점이라 dev 모드에선 매 요청마다 비싸다. module-level cache 가 그 비용을 한 번으로 묶는다.
  • 도구바의 단축키 Ctrl+B Ctrl+I 같은 건 RichEditor 가 이미 처리 중이라 마크다운 모드의 도구바 는 클릭 전용. 추후 CodeMirror keymap 으로 확장 가능.

다음

위키 UX 가 깔리니까, 부원이 같이 채우는 카테고리 한 곳을 본격적으로 굴리고 싶어졌다. 그게 다음 글의 주제다 — 테트리스 카테고리를 동아리 부원과 함께 운영하는 권한 매트릭스 이야기.

관련 글

태그가 겹치는 다른 글