재하의 개발 블로그
회고 10 분 소요

블로그 개발일지 #4 — Supabase로 콘텐츠 관리 아키텍처 전환

claude-code ai astro supabase architecture

3편에서 OG 이미지, 공유 버튼, Mermaid 플러그인까지 블로그를 다듬었다. 이번에는 콘텐츠 관리 방식 자체를 바꾸는 대규모 마이그레이션을 진행했다. 로컬 MDX 파일 기반에서 Supabase 데이터베이스 기반으로 — 왜 바꿨고, 어떻게 바꿨고, 무엇을 배웠는지 기록한다.

결론부터: 왜 Supabase로 옮겼나

기존 아키텍처는 빌드 타임에 로컬 MDX 파일을 읽어 정적 페이지를 생성했다. 글을 작성하려면 다음 과정이 필요했다.

  1. .mdx 파일을 직접 편집
  2. git commit & push
  3. Vercel에서 빌드 & 배포 (2-3분 소요)

이 방식의 문제는 콘텐츠 수정의 피드백 루프가 너무 길다는 것이다. 오타 하나 고치려고 빌드를 다시 돌려야 했다. 또한 MCP 서버가 로컬 파일 시스템에 직접 쓰는 구조라, Claude Code에서 글을 작성하면 git 커밋까지 수동으로 해야 했다.

Supabase로 옮기면서 다음이 가능해졌다.

  • 빌드 없이 콘텐츠 수정 — Supabase 레코드만 업데이트하면 끝
  • MCP 서버로 즉시 발행 — Claude Code에서 대화하듯 블로그 글을 작성하고 바로 발행
  • API를 통한 콘텐츠 관리 — 외부 도구나 모바일 앱에서도 글을 작성 가능한 확장성

물론 트레이드오프도 있다. 모든 페이지를 빌드 타임에 생성하던 방식에서 빌드 타임에 Supabase에서 fetch하는 방식으로 바뀌었으므로, 빌드 시 네트워크 의존성이 생겼다. 하지만 이 비용은 콘텐츠 관리 편의성 대비 충분히 감수할 만했다.

아키텍처 변경 — Before & After

Before: 로컬 MDX 파일

빌드 타임에 src/content/blog/ko/*.mdx 파일을 Astro의 glob() 로더로 읽어 정적 HTML을 생성했다. 콘텐츠 작성은 .mdx 파일 편집 → git commit → Vercel 빌드의 3단계였다. MCP 서버는 로컬 파일 시스템에 직접 CRUD 작업을 수행했다.

After: Supabase 데이터베이스

빌드 타임에 Supabase DB에서 데이터를 fetch하는 커스텀 로더(supabaseBlogLoader)를 사용한다. 콘텐츠 작성은 MCP 서버 또는 REST API로 Supabase에 직접 쓰기만 하면 끝이다. MCP 서버는 블로그 API를 호출하여 Supabase에 데이터를 쓴다.

핵심 차이는 **콘텐츠의 단일 소스(Single Source of Truth)**가 로컬 파일 시스템에서 Supabase 데이터베이스로 옮겨갔다는 것이다.

구현 과정

1. Supabase 테이블 설계

posts 테이블을 다음 스키마로 생성했다.

컬럼타입설명
iduuidPrimary key
slugtextURL용 고유 식별자 (locale 내 unique)
localetext언어 코드 (ko/en)
titletext글 제목
descriptiontext요약 (최대 300자)
bodytext본문 (Markdown)
categorytext카테고리 (til/article/tutorial/infra)
tagstext[]태그 배열
draftboolean초안 여부
published_datetimestamp발행일
updated_datetimestamp수정일 (nullable)
seriestext시리즈명 (nullable)
series_orderinteger시리즈 내 순서 (nullable)
cover_imagetext커버 이미지 URL (nullable)
archived_attimestamp소프트 삭제 시각 (nullable)

(locale, slug) 조합에 unique constraint를 걸어 같은 언어 내에서 slug 중복을 방지했다.

2. 커스텀 Astro 로더 구현

Astro 5의 Content Collections는 loader 인터페이스를 통해 어디서든 콘텐츠를 가져올 수 있다. Supabase에서 데이터를 fetch하는 커스텀 로더를 만들었다. 로더는 Supabase Client로 posts 테이블을 조회하여 포스트 목록을 가져오고, 각 포스트의 Markdown 본문을 HTML로 변환한 후 Astro의 Content Layer에 주입한다.

핵심 동작은 세 단계다.

  1. Supabase Client로 archived되지 않은 모든 포스트 조회
  2. 각 포스트의 Markdown 본문을 renderMarkdown()으로 HTML 변환
  3. store.set()으로 Astro Content Layer에 데이터 주입

id는 ${locale}/${slug} 형태로 구성하여 getEntry("blog", "ko/my-post") 같은 조회를 지원한다.

3. Markdown 렌더링 — Shiki, GFM Alerts, Mermaid

MDX에서 Markdown으로 전환하면서 커스텀 JSX 컴포넌트를 사용할 수 없게 되었다. 대신 표준 Markdown 문법으로 동일한 기능을 제공하도록 변경했다.

GFM Alerts (Callout 대체)

GitHub Flavored Markdown의 Alert 문법을 사용한다. remark-github-alerts 플러그인으로 이 문법을 HTML로 변환한다.

Mermaid 다이어그램

표준 Markdown 코드 블록을 사용한다. 커스텀 remark-mermaid.ts 플러그인으로 lang이 "mermaid"인 코드 블록을 <pre class="mermaid">로 변환하고, 클라이언트에서 mermaid.min.js로 렌더링한다.

Shiki 코드 하이라이팅

rehype-shiki 플러그인으로 코드 블록에 syntax highlighting을 적용한다. renderMarkdown() 함수는 unified 파이프라인에서 remarkParse → remarkGfm → remarkGithubAlerts → remarkMermaid → remarkRehype → rehypeShiki → rehypeStringify 순서로 플러그인을 적용한다.

변환된 HTML은 BlogPostLayout에서 set:html로 삽입된다.

MDX vs Markdown MDX는 JSX 컴포넌트를 쓸 수 있어 확장성이 높지만, 빌드 타임이 길고 표준 Markdown 도구와 호환되지 않는다. Markdown + remark/rehype 플러그인 조합은 표준을 유지하면서도 필요한 기능을 모두 지원한다.

4. MCP 서버 역할 변화

기존 MCP 서버는 로컬 파일 시스템에 .mdx 파일을 직접 생성/수정/삭제했다. Supabase로 마이그레이션 후에는 블로그 API를 호출하는 방식으로 변경되었다.

이전에는 파일 경로를 조합하여 파일을 생성했다면, 현재는 블로그 API 엔드포인트에 HTTP 요청을 보낸다. API는 인증을 검증하고 Supabase에 데이터를 저장한다. MCP 도구 목록도 변경되었다.

도구역할
create-post새 포스트 생성 (draft=true 기본)
edit-post-metadata제목, 태그, 카테고리 등 메타데이터 수정
publish-postdraft를 false로 변경하고 published_date 설정
delete-post소프트 삭제 (archived_at 설정)
get-post특정 포스트 조회
list-posts포스트 목록 조회 (필터링 지원)
list-tags전체 태그 목록
list-categories전체 카테고리 목록

5. 마이그레이션 스크립트

기존 MDX 파일을 Supabase로 옮기는 스크립트를 작성했다. src/content/blog 디렉토리의 모든 .mdx 파일을 순회하며 gray-matter로 frontmatter와 본문을 파싱하고, Supabase Client로 레코드를 생성했다. 파일 경로에서 locale을 추출하고, 파일명에서 slug를 가져왔다.

마이그레이션 후 모든 .mdx 파일을 삭제하고 src/content/blog/ 디렉토리를 제거했다.

트러블슈팅

1. 빌드 시 Supabase 연결 실패

초기 배포에서 Vercel 빌드가 Supabase 호출 타임아웃으로 실패했다. 원인은 환경 변수가 설정되지 않은 것이었다. Vercel 대시보드에서 SUPABASE_URL과 SUPABASE_ANON_KEY를 추가하고 재배포하여 해결했다.

2. Markdown 렌더링 시 Mermaid 스크립트 중복 로딩

remark-mermaid 플러그인이 script 태그를 HTML에 주입하는데, 여러 Mermaid 다이어그램이 있으면 스크립트가 중복 삽입되었다. 이를 방지하기 위해 BaseHead.astro에서 mermaid 스크립트를 전역으로 로딩하고, 플러그인은 <pre class="mermaid"> 만 생성하도록 수정했다.

3. 기존 URL 호환성

MDX 파일 기반일 때는 getCollection("blog")이 자동으로 ko/, en/ 하위 경로를 인식했다. 커스텀 로더에서는 id를 ${locale}/${slug} 형태로 직접 구성해야 했다. 이를 놓쳐서 일부 페이지에서 404가 발생했고, 로더의 id 생성 로직을 수정하여 해결했다.

성과 및 회고

잘 된 것

  • 콘텐츠 수정이 즉각 반영됨 — Supabase 레코드만 업데이트하면 끝 (빌드 불필요)
  • MCP 서버로 글 작성 경험 개선 — Claude Code에서 대화하며 초안 작성 → 바로 발행
  • 확장 가능한 아키텍처 — API를 통해 외부 도구에서도 콘텐츠 관리 가능
  • 표준 Markdown 사용 — GitHub, VS Code 등 다른 도구와 호환성 확보

아쉬운 점

  • 빌드 시 네트워크 의존성 — Supabase가 다운되면 빌드가 실패함
  • 초기 마이그레이션 공수 — 커스텀 로더, Markdown 렌더링, API 레이어 전체를 새로 작성
  • Markdown 제약 — JSX 컴포넌트를 쓸 수 없어 복잡한 인터랙티브 요소는 구현하기 어려움

배운 것

Astro의 Content Layer API는 강력하다

loader 인터페이스 하나로 콘텐츠 소스를 완전히 바꿀 수 있다. 파일 시스템, 데이터베이스, 외부 API — 무엇이든 가능하다. 이 유연성 덕분에 로컬 파일 기반에서 Supabase 기반으로 전환할 때 페이지 컴포넌트는 한 줄도 수정하지 않았다. getCollection("blog")의 구현체만 바뀌었을 뿐이다.

도구의 역할을 명확히 하라

MDX 기반일 때는 MCP 서버가 "파일 시스템 래퍼"였다. Supabase 기반으로 바뀌면서 MCP 서버는 "API 클라이언트"가 되었다. 같은 도구이지만 책임이 달라졌다. 이 역할 변화를 명확히 인식하고 코드를 재구성하니, 각 계층의 책임이 더 분명해졌다.

마이그레이션은 단계적으로

처음에는 MDX와 Supabase를 동시에 지원하는 하이브리드 구조를 고려했다. 하지만 복잡도가 너무 높아 포기하고, 한 번에 완전히 전환하는 방식을 택했다. 대신 마이그레이션 스크립트로 데이터를 옮긴 후, 로컬에서 충분히 테스트하고 배포했다. All-in 전환이지만 단계적 검증이 핵심이었다.

블로그 개발일지 #3 — OG 이미지, 공유 버튼, 그리고 remark 플러그인

블로그 개발일지 #2 — 세 가지 버그와 정적 사이트의 함정

idjaeha/my-blog — 이 블로그의 소스 코드