Dotorian

처음에는 글을 파일로 쓰는 방식이 가장 단순했다. content/posts에 Markdown 파일을 추가하고 push하면 Vercel이 배포한다. 문제는 글을 고치는 순간이었다. 짧은 문장 하나를 바꾸려고 로컬 개발 환경을 열고 커밋하는 흐름은 점점 무거워졌다.

그래서 별도 CMS로 데이터를 옮기지 않고 /admin을 만들었다. 핵심 원칙은 그대로 유지했다.

글은 여전히 레포 안의 Markdown 파일이고, 저장은 여전히 GitHub 커밋이며, 발행은 Vercel 배포다.

저장 버튼은 GitHub 커밋을 만든다

관리자에서 새 글을 쓰거나 기존 글을 저장하면 서버 액션이 먼저 같은 Markdown 파서로 frontmatter를 검증한다. 제목·날짜·카테고리가 규칙에 맞지 않으면 커밋 자체를 만들지 않는다.

검증을 통과한 내용은 GitHub Contents API로 content/posts/<slug>.md에 저장된다. main에 새 커밋이 생기면 Vercel이 이를 감지해 재배포한다. 관리자 UI가 따로 데이터베이스를 관리하지 않아도 되는 이유다.

이미지와 다운로드 파일도 같은 방식이다. 이미지는 public/uploads/<slug>/, 첨부 파일은 public/downloads/에 커밋한다. 글 본문에는 배포 뒤에도 그대로 쓸 수 있는 경로만 넣는다.

로그인 권한과 쓰기 권한을 분리했다

로그인은 다람쥐 달리기 점수판에도 쓰이기 때문에 모든 GitHub 계정에 열어뒀다. 하지만 “GitHub로 로그인했다”는 사실만으로 글을 쓰게 하지는 않는다. 관리자 권한은 서버에서 ADMIN_GITHUB_ID의 불변 숫자 ID를 우선 비교하고, 이 값이 없을 때만 ADMIN_GITHUB_LOGIN을 비교한다.

실제 커밋에는 다른 자격 증명을 쓴다. 서버에만 있는 fine-grained Personal Access Token에는 이 레포의 Contents 읽기·쓰기 권한만 준다. OAuth 토큰을 브라우저 세션에 넣지 않으므로, 로그인 처리와 저장 권한이 섞이지 않는다.

동시에 고친 글을 덮어쓰지 않게 했다

편집 화면을 열 때 해당 파일의 Git SHA도 함께 가져온다. 저장 시 이 SHA를 GitHub에 전달한다. 그 사이 다른 곳에서 같은 글이 수정됐다면 GitHub는 충돌로 거절하고, 관리자는 새로고침을 안내한다.

작은 개인 블로그에서도 이 장치는 필요하다. 관리자와 로컬 편집을 병행할 때, 나중에 저장한 쪽이 조용히 앞의 작업을 지워버리면 복구 비용이 더 크기 때문이다.

WYSIWYG가 원본을 지우면 안 됐다

편집기는 브라우저의 contenteditable과 Selection API를 쓴다. 별도 에디터 의존성을 넣기보다, 편집 화면의 블록을 다시 Markdown으로 직렬화한다. 제목, 목록, 링크, 코드 블록, 표, 이미지 figure와 설명문을 원래 형식으로 되돌린다.

사진은 너비를 조절할 수 있고, 저장되는 HTML에는 figure, img, figcaption이 함께 들어간다. 읽는 화면과 편집 화면의 결과가 달라지지 않도록 미리보기도 같은 Markdown 렌더 파이프라인을 사용한다.

이 관리자는 CMS를 새로 만든 이야기가 아니다. 파일 기반 블로그의 투명함을 유지한 채, 글쓰기의 마찰만 줄인 이야기에 가깝다.

  • #nextjs
  • #github
  • #auth
  • #cms