BinaryHub 개발기 ⑧ 블로그에 위키 UX 일부 입히기 — git history, Recent Changes, 풍부한 에디터
BinaryHub 개발기. 위키처럼 보이게 만드는 건 권한이 아니라 발자국이다.
위키 시스템 ≠ 권한 시스템
처음에 "전체 블로그에 위키 시스템을 일부 적용하자" 라는 얘기가 나왔을 때, 나는 그게 부원 누구나 모든 글을 고칠 수 있게 하자 라는 뜻인 줄 알았다. 그래서 권한 매트릭스를 머릿속에 그리고 있었다. 그런데 정작 원했던 건 다른 것이었다.
위키 시스템이라는 게 부원 편집 가능이 아니라, 위키의 히스토리, 최근 기록이라든지 나무위키의 에디터 같은걸 말하는거야
핵심은 UX. 글이 언제 누구에 의해 어떻게 자라났는지를 페이지에서 볼 수 있고, 최근에 무엇이 움직였는지를 한 화면에서 훑을 수 있고, 글을 쓸 때는 도구바·미리보기·변경 사유 같은 위키 풍 도구가 옆에 있는 것. 권한은 그대로 둬도 된다.
이 차이를 잡고 나니 작업이 셋으로 깔끔하게 나뉘었다.
- 모든 글에 변경 이력 — git log 기반
- Recent Changes 페이지 — 모든 글 최신 수정 순
- 에디터 풍부화 — 도구바, 단축키 도움말, 변경 사유
① 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+BCtrl+I같은 건 RichEditor 가 이미 처리 중이라 마크다운 모드의 도구바 는 클릭 전용. 추후 CodeMirror keymap 으로 확장 가능.
다음
위키 UX 가 깔리니까, 부원이 같이 채우는 카테고리 한 곳을 본격적으로 굴리고 싶어졌다. 그게 다음 글의 주제다 — 테트리스 카테고리를 동아리 부원과 함께 운영하는 권한 매트릭스 이야기.