Skip to content

sakamoto-jun/dev-archive

Repository files navigation

개발 아카이브 블로그

Notion을 CMS로 활용하여 글을 작성하면 블로그에 자동으로 반영되는 구조로 제작했습니다.

🔗 www.sakamotoarchive.dev


프로젝트 목표

  • Notion DB를 CMS로 사용하는 개인 기술 블로그 구축
  • 글 작성 → 자동 노출되는 워크플로우 실현
  • ISR(Incremental Static Regeneration) 기반으로 정적 사이트의 성능과 동적 콘텐츠 갱신을 동시에 확보

기술 스택

분류 기술
Framework Next.js (App Router)
Language TypeScript
Styling TailwindCSS, @tailwindcss/typography
Notion 연동 @notionhq/client, notion-to-md
상태 관리 Context API (테마)
아이콘 lucide-react
배포 Vercel (Cloudflare 도메인 연결)

주요 기능

  • Notion DB 연동 — Published 프로퍼티 체크된 글만 자동 노출
  • 태그 멀티셀렉트 필터 + 검색 (클라이언트 사이드)
  • 다크/라이트 테마 토글 (localStorage 유지)
  • ISR — Notion Webhook 수신 시 캐시 자동 갱신
  • SEO — 페이지별 메타태그, OG 태그, sitemap.xml, robots.txt

트러블슈팅

1. 전역 테마 하이드레이션 미스매치

문제
다크모드 상태를 localStorage에서 읽어 적용하는 구조였는데, React 하이드레이션 이전에 DOM이 먼저 그려지면서 테마가 잠깐 반전되는 플래시 현상(FOUC)이 발생했다.

해결
layout.tsx<head>에 즉시실행함수(IIFE) 스크립트를 인라인으로 직접 삽입했다.
하이드레이션 전에 스크립트가 실행되어 localStorage 값을 읽고 <html>dark 클래스를 즉시 부여하는 방식으로 플래시 현상을 제거했다.

// layout.tsx

const themeInitScript = `
  (function() {
    var s = localStorage.getItem('theme');
    var d = window.matchMedia('(prefers-color-scheme: dark)').matches;
    document.documentElement.classList.toggle('dark', s === 'dark' || (s === null && d));
  })();
`;

2. 코드 블록 렌더링 개선 (shiki + rehype-pretty-code 전환)

문제
기존에는 react-markdown + rehype-highlight로 클라이언트에서 마크업 변환과 코드 하이라이팅을 처리했다.
주석(//, /* */)이 제대로 하이라이팅되지 않는 경우가 있었고, 마크업 변환 자체가 클라이언트에서 실행되다 보니 포스트 상세 페이지 진입 시 렌더링이 눈에 띄게 느렸다.

해결
rehype-pretty-code + shiki로 교체하고, 마크업 변환 과정 전체를 서버 컴포넌트(PostContent)에서 처리하도록 변경했다.
빌드 또는 요청 시 서버에서 HTML로 완전히 변환된 결과를 클라이언트에 내려주기 때문에 클라이언트 측 렌더링 부담이 없어졌고, shiki의 정확한 토크나이징 덕분에 주석 처리도 올바르게 동작한다.

// PostContent.tsx (서버 컴포넌트)

const markup = await unified()
  .use(remarkParse)
  .use(remarkGfm)
  .use(remarkRehype)
  .use(rehypePrettyCode, {
    theme: { light: 'github-light', dark: 'github-dark-dimmed' },
    keepBackground: false,
  })
  .use(rehypeStringify)
  .process(content);

3. CSR → ISR 전환 (서버/클라이언트 컴포넌트 분리)

문제
초기에는 React Query를 사용한 CSR 방식으로 클라이언트에서 데이터를 fetch 했다.
검색엔진 크롤링이 불가하고, 매 요청마다 Notion API를 호출하는 성능 문제가 있었다.

해결
Next.js App Router의 서버 컴포넌트 구조로 전환했다.

  • page.tsx (서버 컴포넌트) — getPosts()를 직접 import해서 서버에서 데이터를 fetch 하고 props 로 전달
  • PostListClient.tsx (클라이언트 컴포넌트) — 검색/필터 등 인터랙션만 담당
  • api/revalidate — Notion Webhook에서 POST 요청이 들어오면 revalidatePath를 실행해 ISR 캐시를 갱신

이 구조로 초기 렌더링 성능과 SEO를 확보하면서, Notion에 글을 작성하면 Webhook → 캐시 갱신 → 자동 반영되는 워크플로우를 구현했다.


4. Notion 빈 블록 렌더링 누락

문제
Notion에서 문단 사이 구분감을 위한 띄움(빈 블록)을 넣어도 블로그에서는 간격이 생기지 않았다.
notion-to-md가 rich_text 배열이 비어있는 paragraph 블록을 빈 문자열로 변환하고, 이후 마크다운 파이프라인에서 의미없는 공백으로 처리되어 렌더에서 사라졌기 때문이다.

해결
setCustomTransformer로 paragraph 블록 변환을 가로채서, rich_text가 비어있으면 &nbsp;를 반환하도록 처리했다.
rehype-raw가 파이프라인에 포함되어 있어 HTML 엔티티가 그대로 렌더링되면서 빈 블록이 의도한 여백으로 표시된다.

// lib/notion.ts

n2m.setCustomTransformer('paragraph', async (block) => {
  const { paragraph } = block;
  if (!paragraph.rich_text.length) {
    return '\n&nbsp;\n';
  }
  return false; // 기본 변환 사용
});

5. 포스트 상세 페이지 SEO (generateStaticParams)

문제
동적 라우트(/posts/[id])는 기본적으로 요청 시 서버 렌더링되어 초기 응답이 느리고, 검색엔진이 각 페이지를 제대로 수집하지 못할 수 있었다.

해결
generateStaticParams로 빌드 시 모든 포스트 ID를 미리 가져와 정적 페이지를 사전 생성했다.
각 포스트 페이지는 generateMetadata로 고유한 title, description, OG 태그를 동적으로 생성한다.

// posts/[id]/page.tsx

export async function generateStaticParams() {
  const posts = await getPosts();
  return posts.map((post) => ({ id: post.id }));
}

업데이트

Notion Callout 커스텀 렌더링

기존에 notion-to-md는 callout 블록을 > blockquote 마크다운으로 변환했다.
이 경우 이모지 아이콘, 배경색 등 Notion callout의 시각적 특성이 전혀 반영되지 않아 일반 인용문과 구분이 불가능했다.

setCustomTransformer로 callout 블록 변환을 직접 가로채서 커스텀 HTML로 출력하도록 교체했다.
callout의 icon.emojicolor 속성을 읽어 CSS 클래스를 부여하고, rich_text 배열은 별도 유틸(richTextToHtml)로 인라인 서식(볼드, 이탤릭, 코드, 링크 등)까지 보존해서 HTML로 변환하였다.
파이프라인에 rehype-raw가 포함되어 있어서 raw HTML이 그대로 DOM에 반영된다.

// lib/notion.ts

n2m.setCustomTransformer('callout', async (block) => {
  const { callout } = block;
  const icon = callout.icon?.emoji ?? '';
  const color: string = callout.color ?? 'default';
  const colorClass = color.includes('yellow')
    ? 'callout-yellow'
    : 'callout-blue';
  const text = richTextToHtml(callout.rich_text ?? []);

  return `\n<div class="callout ${colorClass}"><span class="callout-icon">${icon}</span><div class="callout-body">${text}</div></div>\n`;
});
// PostContent.tsx — rehype-raw로 raw HTML 파싱 활성화

const markup = await unified()
  .use(remarkParse)
  .use(remarkGfm)
  .use(remarkRehype, { allowDangerousHtml: true })
  .use(rehypeRaw)
  .use(rehypePrettyCode, { ... })
  .use(rehypeStringify)
  .process(content);

Releases

No releases published

Packages

 
 
 

Contributors