기술 블로그를 처음 개발할 때 Notion을 선택한 이유는 명확했다. 애초에 포스트 Draft를 Notion에 작성하기 때문에 별도의 마이그레이션 작업 없이 블로그 데이터를 가져오면 좋겠다 싶었다. 익숙한 편집 환경에서 빠르게 글을 작성할 수 있고, Notion API를 활용하면 별도의 CMS 구축 없이도 블로그를 운영할 수 있고, 무엇보다 무료. Notion 리스트 내 페이지에 글을 작성하기만 하면 그대로 블로그 포스트로 연동된다는 개념이 내게는 직관적으로 다가왔다.
🧶 A to Z를 개발하며: 이래야만 하나의 연속
처음 노션 API를 연동하면서 가장 큰 고충은 블록 데이터를 마크다운으로 변환하는 작업이었다. 애초에 Notion API는 CMS 서비스가 아니기 때문에 반환값이 당연히 불친절했다. 페이지를 마크다운이 아닌 블록 단위로 반환하는데, 각 블록 타입(paragraph, heading, code, list 등)마다 다른 구조를 가지고 있어서 이를 처리하는 파서를 직접 구현해야 했다.
특히 nested 블록이나 rich text 안의 bold, italic, link 등의 annotation을 처리하는 로직은 복잡하기도 했고, 말하자면 '노가다'였다. 불과 3년 전이지만 이런 귀찮은 일들을 LLM에게 맡기는 시대도 아니었기 때문에(새삼 격세지감) 한땀한땀 수공예 코딩을 했다.
// 노션 블록을 마크다운으로 변환하는 복잡한 로직 예시
function convertBlockToMarkdown(block: any): string {
switch (block.type) {
case 'paragraph':
return convertRichText(block.paragraph.rich_text) + '\n\n'
case 'heading_1':
return `# ${convertRichText(block.heading_1.rich_text)}\n\n`
case 'code':
return `\`\`\`${block.code.language}\n${block.code.rich_text[0].plain_text}\n\`\`\`\n\n`
case 'bulleted_list_item':
// nested list 처리가 복잡함
return `- ${convertRichText(block.bulleted_list_item.rich_text)}\n`
// 수십 개의 블록 타입을 각각 처리해야 함...
}
}
function convertRichText(richTexts: any[]): string {
return richTexts.map(text => {
let result = text.plain_text
if (text.annotations.bold)
result = `**${result}**`
if (text.annotations.italic)
result = `*${result}*`
if (text.annotations.code)
result = `\`${result}\``
if (text.href)
result = `[${result}](${text.href})`
return result }).join('')
}
더 큰 문제는 이미지였다. 노션 API가 반환하는 이미지 URL은 만료 시간이 있어서 1시간 정도 지나면 접근할 수 없게 된다. 빌드 시점에 이미지 URL을 가져와도, 사용자가 나중에 페이지를 방문하면 이미지가 깨지는 문제가 발생했다. 이를 해결하기 위해 굳이 1시간마다 캐시를 날리거나 이미지를 별도로 다운로드해서 S3에 업로드하는 로직을 추가로 구현해야 했고, 배보다 배꼽이 더 커지는 상황이 왕왕 발생했다.
⚡️ 성능 문제와 안정성 이슈
Notion API의 응답 속도도 문제였다. 단일 페이지를 가져오는 데 평균 500ms~1초가 소요됐고, 블록이 많은 페이지의 경우 2초 이상 걸리기도 했다. ISR을 사용하더라도 첫 방문자는 긴 로딩 시간을 경험할 수밖에 없었다. 또한 Notion 서버의 간헐적인 불안정성으로 API 요청이 실패하는 경우도 있었다. Rate limit도 제약사항이었는데, 분당 3회 요청 제한 때문에 여러 페이지를 빌드할 때 throttling을 직접 구현해야 했다.
📿 Sanity: Headless CMS로의 전환
어쨌든 모든 기능을 구현한 후 몇 년은 잘 썼다. 초반이 좀 귀찮았을 뿐이지 유지보수는 어렵지 않았기 때문이다. 시간이 지나 블로그를 어떻게 개발했는지도 까먹었을 즈음, 웹서핑을 하며 습관적으로 사용자 경험이 좋은 웹사이트들의 개발자도구를 열어보다 요즘 들어 Contentful
, Strapi
같은 Headless CMS로 구현된 블로그나 포트폴리오가 많다는 사실을 인지하게 됐다. 공식문서들을 살펴보니 꽤 흥미롭고 편해보였다. Notion 데이터를 다루기 위한 로직들로 점철된 내 기술 블로그도 슬슬 100% 컨텐츠를 위해 만들어진 서비스로 이전해봐야겠다는 호기심이 일었다.
마이그레이션을 결심하고 여러 Headless CMS를 비교했다. Contentful은 가장 유명하지만 무료 플랜은 25개 콘텐츠 모델로 제한되어 있고, 스페이스도 하나만 사용할 수 있다. Strapi는 오픈소스로 자체 호스팅이 가능하지만, 서버 관리 부담과 인프라 비용이 발생한다. Ghost는 블로깅에 특화되어 있지만 커스터마이징이 제한적이었다.
반면 Sanity는 무료 플랜에서도 무제한 콘텐츠 타입과 로케일을 제공하며, 최대 20명의 사용자와 2개의 데이터셋을 사용할 수 있다. 개인 블로그 운영에 충분한 리소스를 제공하면서도, 완전히 커스터마이징 가능한 오픈소스 편집 환경인 Sanity Studio를 통해 자유롭게 편집 경험을 구성할 수 있어 선택하게 되었다.
✍️ Sanity Studio: 섬세한 콘텐츠 편집 환경
Sanity Studio는 Sanity가 제공하는 오픈소스 CMS 인터페이스다. React 기반으로 만들어져 있으며, 내 프로젝트에 직접 설치하고 커스터마이징할 수 있다. npm create sanity@latest
명령어로 간단하게 설정할 수 있고, 스키마를 정의하면 자동으로 적절한 UI가 생성된다.
// Studio 커스터마이징 예시
import { defineConfig } from 'sanity'
import { deskTool } from 'sanity/desk'
import { visionTool } from '@sanity/vision'
import schemas from './schemas'
export default defineConfig({
name: 'default',
title: 'My Blog',
projectId: 'your-project-id',
dataset: 'production',
plugins: [ deskTool(), visionTool(),
// GROQ 쿼리 테스트 도구
],
schema: { types: schemas, },
})
Studio는 Sanity에서 제공하는 배포 CLI를 통해 sanity.io/@id
로 곧바로 배포하는 것이 권장되지만, localhost에서 실행하거나 Vercel 같은 플랫폼에도 물론 배포할 수 있다. 별도의 복잡한 변환 로직 없이, Studio에서 작성한 내용이 그대로 구조화된 데이터로 저장되고 API를 통해 즉시 사용할 수 있다는 점이 가장 큰 장점이다.
Next.js와의 착붙 상성
Sanity는 Next.js와 특히 잘 맞는다. 공식적으로 next-sanity
패키지를 제공하며, Next.js의 새로운 기능들과 seamless하게 통합된다. 특히 App Router와 Server Components를 사용할 때 Sanity 클라이언트를 서버에서 직접 호출할 수 있어 클라이언트 번들 크기를 줄일 수 있다.
// app/blog/[slug]/page.tsx
import { client } from '@/lib/sanity'
export async function generateStaticParams() {
const posts = await client.fetch(`*[_type == "post"]{ "slug": slug.current }`);
return posts;
}
async function getPost(slug: string) {
return await client.fetch( `*[_type == "post" && slug.current == $slug][0]`, { slug } );
}
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
return <article>{/* ... */}</article>;
}
🤖 웹훅으로 완성하는 배포와 캐싱 자동화
Sanity의 웹훅 기능은 서버에서 데이터를 가져오는 Next.js 애플리케이션에서 가장 번거로운 부분을 상당부분 해결해준다. 대시보드를 통해 Studio에서 글을 발행, 수정, 삭제할 때마다 자동으로 Next.js의 캐시를 무효화하도록 설정할 수 있다. Sanity Dashboard에서 웹훅 URL을 등록하고 트리거할 이벤트(create, update, delete)를 선택하면 끝이다. 이런 기능이 없어 글 작성, 수정, 삭제 이벤트에 일일이 DIY로 revalidation 로직을 달아주어야 했던 것을 생각하면 장족의 발전이다.
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
const WEBHOOK_SECRET = process.env.SANITY_WEBHOOK_SECRET
export async function POST(req: NextRequest) {
// 보안을 위한 secret 검증
const secret = req.headers.get('sanity-webhook-secret')
if (secret !== WEBHOOK_SECRET) {
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 })
}
const body = await req.json()
const { _type, slug } = body
if (_type === 'post') {
// 특정 포스트와 목록 페이지 재검증
revalidateTag('posts')
if (slug?.current) {
revalidatePath(`/blog/${slug.current}`)
}
revalidatePath('/blog')
}
return NextResponse.json({ revalidated: true, now: Date.now() })
}
이제 Sanity Studio에서 글을 수정하고 Publish 버튼을 누르면 몇 초 안에 변경사항이 프로덕션에 반영된다. 별도의 배포나 수동 캐시 삭제가 필요 없다.
🧤 마이그레이션 결과
마이그레이션 후 체감되는 개선은 명확했다. 페이지 로딩 시간은 물론 Sanity의 이미지 CDN 덕분에 이미지 로딩도 훨씬 빨라진데다 불필요한 캐싱 무효화에 걸리는 로드도 제거되었다. 복잡한 마크다운 변환 로직과 이미지 URL 만료 문제에서 해방되어 유지보수 부담도 크게 줄었다. 추후 GROQ를 활용해 관련 포스트 추천, 태그별 필터링, 인기 글 표시 등 다양한 기능도 쉽게 구현할 수 있을 걸로 기대된다.
🧑💻 프론트엔드 개발자의 기술 블로그
많은 개발자들이 마크다운 파일을 프로젝트에 직접 포함하거나 Gatsby 같은 정적 사이트 생성기를 사용해 블로그를 만든다. 개발자에게는 익숙하고 편한 방식이다. 마크다운은 버전 관리가 쉽고 배포가 간단하지만, 협업하는 디자이너나 마케터, 기획자에게는 완전히 낯선 도구이기도 하다. 우리가 실제로 맞닥뜨리는 유저들은 Git을 몰라도 되고, 마크다운 문법을 배울 필요도 없다. 그저 글을 쓰고 이미지를 넣고 발행 버튼을 누르면 기다릴 필요 없이 콘텐츠가 배포되는 환경을 기대한다.
처음에 Notion을 연동한 것도 프론트엔드 개발자로서 '개발자에게 편한 방식'보다 '사용자가 당연하다고 생각하는 방식'을 먼저 고려하고 싶은 생각이 컸기 때문이다. Obsidian 같이 다양한 기능을 제공하고 입맛에 맞게 커스터마이징이 가능한 메모앱에 익숙한 개발자들에게 Notion은 허접하고 느리고 기능이 부족하게 느껴질 수 있는 툴이지만, 협업하는 동료들 모두가 다룰 수 있고 익숙한 툴이라는 점은 모든 약점을 상쇄할만큼 중요하고 강력하다. 실무에서 콘텐츠를 다루는 대부분의 프로젝트는 개발자가 혼자 운영하지 않는다. Headless CMS를 다루는 경험은 단순히 콘텐츠 관리를 넘어 비개발자 동료들의 워크플로우를 이해하고 그들이 효율적으로 일할 수 있는 시스템을 설계하는 법을 배우는 과정이기도 했다(특히 Studio에서 썸네일 alt text를 AI로 바로 작성해주는 부분은 너무 섬세해서 감동했다).
또 Headless CMS를 사용하면 콘텐츠와 프레젠테이션의 분리, RESTful API나 GraphQL 같은 데이터 fetching 패턴, 실시간 동기화, 권한 관리 등 실무에서 필수적인 개념들을 자연스럽게 익히게 되는데, 나중에 더 복잡한 프로젝트에서 백엔드 팀과 협업할 때도 도움이 될 걸로 생각한다. 마크다운 기반 블로그는 개인 포트폴리오로는 충분하지만, 프론트엔드 개발자로서는 작은 블로그라도 실제 사용자를 위한 도구를 다루는 경험을 여러 번, 여러 레이어에서 접하는 게 사용자 경험에 대한 다양한 인사이트를 얻을 수 있는 빠른 방법인 듯하다.