seo-hnoo.me 를 만들며
글을 쓰겠다고 마음먹은 김에, 이 블로그 자체를 어떻게 만들었는지부터 한 번 정리해두고 싶었다. 이 도구 자체에 대한 이야기를 해두고 싶다.
이 블로그는 직접 코딩한 게 없다. Next.js 프로젝트 하나를 깔고, 디자인 결정 몇 가지 docs 세팅해 두고, 나머지는 통째로 맡겼다. 그렇다고 "AI 가 뚝딱 만들어줬어요"를 말하려는 글은 아니고, 그렇게 맡기기 위해 몇 겹의 구조를 어떻게 짰는지가 하고 싶은 이야기다. 지난번 Tabswirl 글에서 말했던 "한 겹 더 랩핑"의 연장선이기도 하다.

왜 직접 만들었나
티스토리도 있고, 벨로그도 있고, 노션 사이트도 있고, 미디엄도 있다. 사실 어디서 쓰든 글 자체가 좋으면 별 차이가 없다는 것도 안다. 그런데 그러면 왜 직접 만들었냐 하면, 솔직히 단순하다.
- 내 도메인에, 내가 정한 모양으로 글이 박혀 있는 그림이 좋았다. 이건 블로그를 시작하는 이유에서 말한 자산화 동기랑 직결된다. 플랫폼이 어떤 형태를 원하든, 정책이 바뀌든, 광고가 끼어들든 영향을 안 받는 형태로 글이 쌓였으면 했다. 무엇보다 원하는 대로 만들 수가 있다.
- 어차피 만드는 데 그렇게 큰 비용이 안 든다. 옛날이면 며칠 잡고 만들어야 했을 일이, 지금은 Claude 한테 시키면 반나절이다. 이미 tabswirl 글에서 한 번 경험했듯이, "이거 어차피 금방 만든다" 라는 감각이 임계치를 넘은 상태였다.
- 그리고 코드 자체가 깃헙에 노출되어 있으면 그 또한 작은 포트폴리오가 된다. 어떻게 만들었는지가 글로도 쓸 거리가 되고(지금 이 글이다), 코드로도 보인다.
그래서 그냥 만들기로 했다. 디자인 결정에 한 시간 이상 쓰지 않는다는 룰만 정해뒀다.
큰 그림: 두 개의 레포
블로그 만들기에서 처음 한 결정은 디자인도 스택도 아니고 레포를 두 개로 쪼개는 것이었다.
blog-app (Public, GitHub) ← Next.js 코드
└─ content/ (git submodule) ← blog-content 레포가 여기 마운트됨
blog-content (Private, GitHub) ← .md 글 + 이미지쪼갠 이유는 비대칭이다.
- 코드는 공개해도 된다. 오히려 공개되어 있는 게 좋다. 깃헙 프로필의 한 자산이 된다.
- 글은 공개되면 안 된다. 정확히 말하면 발행된 글 만 공개되어야 하고, 드래프트, 쓰다 만 글, 지워버린 글, 커밋 메시지에 적힌 부끄러운 메모, 이런 작성 과정 전체는 사적인 영역에 남아야 한다. 글이라는 거 자체가 발산 과정에서 만든 부산물들이 훨씬 많다.
처음엔 그냥 한 레포에 두고 .gitignore 로 drafts 만 무시할까도 했는데, 글이 좀 쌓이면 결국 작성 히스토리가 다 깃헙에 남는다는 게 마음에 안 들었다. 그래서 그냥 콘텐츠 레포를 따로 private 으로 파고, blog-app 에서 git submodule 로 끌어다 빌드하는 구조로 갔다.
서브모듈은 다루기 좀 귀찮은 것 정도지, 이런 용도에는 거의 완벽하게 맞는다. blog-app 입장에서는 content 디렉토리가 그냥 거기 있는 것처럼 보이고, Vercel 도 빌드할 때 알아서 submodule 을 fetch 해서 합쳐준다 (단, private repo 라서 PAT 하나 끼워줘야 하긴 했다).
그래서 스택은 blog-app 깃헙에 들어가보면 알 수 있을 것이고, "필요해질 때까지 안 넣는다" 쪽에 가깝다. 댓글 시스템, 다크 모드, 태그 페이지, 검색, view counter, related posts 이런 거 다 일부러 안 넣었다. 글이 20개 이상 쌓이고 정말 필요해지면 그때 넣기로.
한 겹 더: BUILD.md
하네스 하듯이 한 겹을 더 깔았다. blog-content 레포 안에 docs/BUILD.md 라는 문서를 만들고, 거기에 이 블로그를 처음부터 끝까지 빌드하기 위한 모든 결정과 코드를 다 박아뒀다. 사이트 정체성, 도메인, 레포 구조, 디자인 원칙, 라우트, 페이지별 코드, Vercel 설정, 자주 발생하는 함정, 일부러 만들지 않을 기능 목록까지.
이 문서는 사람이 읽는 가이드라기보단, Claude Code 가 한 세션 안에 다 실행할 수 있는 실행 스펙에 가깝다. 그래서 그냥 클로드에게 이 문서를 던져주고 "이거 그대로 따라가서 만들어줘"라고 했고, 정말로 한 세션 안에 다 만들어졌다. (물론 중간에 막힌 게 없진 않다. Tailwind v4 가 v3 와 설정 방식이 다르다는 거나, Vercel 의 GitHub App 인증이 submodule fetch 까지는 안 가져가서 PAT 를 따로 넣어줘야 한다는 거나. 그런 건 클로드가 알아서 막아내며 진행했다.)

이게 Tabswirl 글에서 말한 "한 겹 더" 의 또 다른 적용이다. 그때는 PRD 와 WORK_LOG 로 한 겹을 깔아서 코딩-테스트 루프를 클로드에게 위임했었다. 이번에는 BUILD.md 로 한 겹을 깔아서, "이 사이트는 이런 사이트다" 라는 정체성부터 마지막 배포 체크리스트까지 통째로 위임한 거다.
랩핑이 한 겹 늘어날 때마다, 내가 사람으로서 하는 일은 줄어들고, 대신 "이게 잘 굴러갈 수 있게 설계하는 일"이 늘어난다. 좀 신기한 건, 그 설계 자체도 점점 더 클로드와 같이 하게 된다는 거다. BUILD.md 자체를 클로드와 같이 잡았고, 내가 한 건 거기서 진짜로 내 취향이나 의사결정이 들어가야 하는 부분만 넣는 일이었다.
발행 워크플로우
만든 것 자체보다 더 신경 쓴 게 워크플로우다. 글을 쓰는 순간의 마찰을 어떻게 0 에 수렴시킬 것인가.
지금의 흐름은 이렇다.
- Obsidian 에서 blog-content vault 를 연다.
drafts/에 새.md파일을 만들고 그냥 아무렇게나 쓴다.- 다 쓰면
/draft <slug>슬래시 커맨드로 클로드에게 던진다. frontmatter 채우기, 한국어 맞춤법·띄어쓰기 교정, 레이아웃 다듬기, 이미지 경로 정리, 로컬 프리뷰까지 알아서 해준다. - 발행할 때는
/publish <slug>. 드래프트를posts/로 옮기고, 커밋, 푸시, blog-app 의 submodule pointer 까지 bump 해서 Vercel 빌드를 트리거하는 흐름이 자동으로 돌아간다.
/draft, /publish 둘 다 Skill 로 만들어서, 사용자 입장에서는 그냥 한 줄짜리 명령이다. 안에서는 frontmatter 체크, 정자법 교정, 이미지 동기화, 깃 워크플로우, Vercel deploy 트리거가 다 정해진 순서대로 돈다. 한 번 만들어두면 그 뒤로는 마찰이 거의 없다.
재밌는 건 이 스킬 자체가 계속 다듬어진다는 거다. 지금 이 글을 쓰면서도, 내가 초안을 보고 한 번 갈아엎은 흔적을 클로드가 다시 읽어서 "이 사람은 이런 식으로 쓰는구나" 를 메모리에 적어두고 있다. 다음 초안부터는 그 패턴이 반영된 채로 나올 거고, 그렇게 한 겹씩 내 문체까지 하네스에 들어가면서 결국 드래프팅까지 위임 가능한 형태로 자라는 중이다.
이런 자동화가 없으면 어떻게 되냐면, 매번 발행할 때마다 "드래프트를 posts 로 옮기고, frontmatter 의 draft: true 를 false 로 바꾸고, blog-content 에서 커밋하고 푸시하고, blog-app 으로 와서 submodule update 하고, 또 커밋하고 푸시하고…" 를 머리에 외우고 있어야 한다. 그 마찰이 한 5~10분쯤 되는데, 글 쓰고 나서 그걸 또 하는 건 발행 자체를 미루게 만드는 가장 큰 요인이다. (이거 엉망으로 하자 글에서 말했던 "잘 하려다 시작 못 하는 그 메커니즘"이랑 같은 결이다.)
디테일 두 가지
전체 그림은 위가 다고, 여기부터는 만들면서 재밌었던 디테일 두 가지.
Obsidian 으로 쓴 마크다운이 그대로 사이트에 뜨게
Obsidian 에서 영상 링크를 그냥 줄 하나에 paste 해서 쓰는 습관이 있는데, 그게 사이트에서도 그대로 동영상 embed 로 떴으면 했다. 그래서 작은 remark 플러그인 하나를 짰다. 단락 전체가 하나의 YouTube URL 일 때만 그걸 <YouTube /> 컴포넌트로 바꿔주는 식이다. 일반 본문 안에 inline 으로 박힌 링크는 안 건드린다.
function urlFromParagraph(node: MdNode): string | null {
if (node.type !== "paragraph" || node.children?.length !== 1) return null;
const child = node.children[0];
if (child.type === "text" && typeof child.value === "string") {
return child.value;
}
if (child.type === "link" && typeof child.url === "string") {
return child.url;
}
return null;
}비슷하게 이미지도, Obsidian 에서  으로 그냥 쓰면, 빌드 때 images/ 가 자동으로 public/posts/ 밑에 동기화되고, 캡션이 figcaption 으로 렌더되도록 했다. 즉 글을 쓸 때 머릿속에는 Obsidian 의 마크다운 한 가지 문법만 있으면 되고, 거기서 사이트로 가는 사이의 모든 변환은 코드가 알아서 한다.
드래프트가 절대 leak 되지 않게
드래프트 미리보기는 정말 잘 작동하지만, 그게 실수로 production 으로 새는 건 절대 막고 싶었다. 그래서 게이트를 두 겹으로 깔았다.
const DRAFT_PREVIEW =
process.env.NODE_ENV === "development" && !process.env.VERCEL;NODE_ENV 만 보면 충분할 것 같지만, Vercel 의 일부 환경에서는 dev 처럼 굴 때가 있어서 VERCEL 환경변수까지 같이 확인한다. 두 조건이 모두 만족하지 않으면 drafts/ 폴더는 빌드 시 존재하지 않는 것과 똑같이 취급된다. 폴더 자체가 submodule 안에 들어 있어도, posts 와 같이 안 빠지고 같이 떠나가는 일은 없다.
끝나지 않을 작업
이 글이 발행되는 시점에는 위에서 말한 모든 게 다 작동하고 있겠지만, 절대로 "다 끝났다"가 안 된다. 쓰다보면 어떻게 해줘, 스킬을 업데이트하거나 아예 코드가 고쳐져 있을 것이다. 근데 그렇게 해서 하네스 다듬는 거고 내 블로그 에이전트가 만들어지는 것 같다.
지금 이 글도 그런 자체참조의 한 장면이다. 이 글을 쓰면서 또 무언가 한 군데 손볼 일이 생길 거고, 거기에 대해 또 짧게 한 줄 쓸 거리가 생길지도 모른다. 엉망으로 하자에서 말한 것처럼, 첫 번째의 유일한 목적은 두 번째를 시작하기 위함이라는 말을, 도구 자체에도 똑같이 적용해보고 있다.
다음번에는 같은 방식으로 만들고 있는 다른 프로덕트 이야기를 가져와볼까 한다.