타다 테크 블로그 리뉴얼하기
이 아티클은 Claude가 작성했습니다.
시작하며
타다 테크 블로그를 새로 만들기로 했을 때 첫 번째 고민은 "어디에 글을 쓸까"가 아니라 "어떻게 운영할까"였다. 팀원들이 글을 쓰는 공간으로는 Notion이 이미 자리 잡혀 있었다. 익숙한 에디터, 풍부한 블록 타입, 간편한 협업. 그런데 그 글을 독자에게 보여줄 블로그는 별도로 존재해야 했다.
"Notion에서 쓰고 버튼 하나로 블로그에 올라가면 좋겠다."
이 한 문장이 이 프로젝트의 출발점이었다.
전체 아키텍처: 런타임 없는 정적 배포
처음에는 Notion API를 런타임에 직접 호출하는 방식도 검토했다. 하지만 그 방향은 몇 가지 문제가 있었다.
- Notion API의 응답 속도는 페이지 로딩 성능에 직접 영향을 미친다
- Notion 이미지 URL은 만료 시간(1시간)이 있어서
og:image등에 쓰기 어렵다 - 외부 API 의존성이 있으면 Notion 장애가 블로그 장애로 이어진다
그래서 선택한 방향은 "Notion은 글쓰기 도구, GitHub은 콘텐츠 저장소" 였다.
flowchart TD
A["글 작성 (Notion)"] --> B["배포하기 버튼"]
B --> C["POST /api/publish"]
C --> D["Notion API로 콘텐츠 읽기"]
D --> E["이미지 다운로드, 로컬 경로 교체"]
E --> F["GitHub Git Tree API로 단일 커밋"]
F --> G["GitHub Repository"]
G --> H["Vercel 자동 빌드 트리거"]
H --> I["정적 블로그 (SSG)"]배포된 블로그는 런타임에 Notion을 전혀 호출하지 않는다. 모든 콘텐츠는 content/posts/[slug]/ 디렉토리에 파일로 존재하고, Next.js가 빌드 타임에 읽어서 정적 HTML을 생성한다.
프론트엔드
스택 선택
Next.js 16 App Router + React 19 + TypeScript + Tailwind CSS v4
App Router를 선택한 이유는 단순했다. generateStaticParams로 모든 아티클 페이지를 빌드 타임에 정적 생성하고, 서버 컴포넌트와 클라이언트 컴포넌트를 명확히 나눌 수 있는 구조가 블로그에 잘 맞았다.
Tailwind v4는 설정 파일 없이 CSS 파일의 @theme 블록에서 직접 디자인 토큰을 정의하는 방식이라, 기존의 tailwind.config.js보다 훨씬 직관적이었다.
/* globals.css */
@theme {
--color-primary: #283873;
--color-foreground: #121018;
--color-muted: #6a5e8d;
--font-sans: "Pretendard", -apple-system, sans-serif;
--leading-ko: 1.6;
}이렇게 정의한 토큰은 text-primary, leading-ko 같은 Tailwind 유틸리티 클래스로 바로 사용할 수 있다.
디자인 시스템
폰트는 next/font 대신 <head> 태그의 CDN <link>로 로드했다. Pretendard는 Korean-optimized 웹폰트로, CDN 캐시 활용률이 높고 설정이 간단하다. next/font의 최적화 이점보다 단순함을 택했다.
컴포넌트는 역할별로 두 레이어로 나눴다.
ui/— 도메인 무관한 범용 프리미티브 (Button, Badge, Tag, Callout, CodeBlock)blog/— 블로그 전용 컴포넌트 (FeaturedCarousel, ArticleGrid, NotionBlocks, AuthorBio 등)
인터랙션이 필요한 컴포넌트(캐러셀, 카테고리 필터, 댓글 섹션)만 'use client'를 붙이고, 나머지는 서버 컴포넌트로 유지해 렌더링 전략을 최적화했다.
페이지 구조와 SSG
페이지는 세 가지다.
/— 최신 아티클 캐러셀 + 카테고리 필터 그리드/articles/[slug]— 아티클 상세 (SSG)/design-system— 내부용 디자인 토큰 참조 페이지
아티클 상세 페이지는 generateStaticParams로 빌드 타임에 모두 생성된다.
export function generateStaticParams() {
return getAllSlugs().map((slug) => ({ slug }));
}
export const dynamicParams = false; // 알려지지 않은 slug는 404getAllSlugs()는 content/posts/ 디렉토리를 동기적으로 읽어서 slug 목록을 반환한다. 런타임에 Notion을 호출하는 코드는 전혀 없다.
Notion 블록 렌더링
Notion 콘텐츠는 NotionBlock[] 타입의 배열로 저장된다. 각 블록은 type 필드로 판별하는 유니온 타입이며, NotionBlocks 컴포넌트가 이를 순회하며 타입에 맞는 컴포넌트를 렌더링한다. 코드블록은 VS Code 다크 테마 스타일로, callout은 아이콘과 함께 강조 박스로 표시된다.
한 가지 처리해야 했던 점은 bulleted/numbered list의 연속 블록 병합이었다. Notion API는 각 리스트 아이템을 개별 블록으로 반환하지만, HTML에서는 <ul>, <ol> 하나로 묶어야 한다. 연속된 같은 타입의 블록을 그룹으로 묶는 전처리 로직을 추가했다.
목차 (Table of Contents)
아티클 상세 페이지에는 heading_2, heading_3 블록에서 목차를 자동 추출하는 TableOfContents 컴포넌트가 있다. 빌드 타임에 블록 배열을 필터링해 헤딩 목록을 만들고, 클라이언트에서 IntersectionObserver로 현재 읽고 있는 섹션을 하이라이트한다.
const headings = blocks
.filter((b) => b.type === "heading_2" || b.type === "heading_3")
.map((b) => ({
id: b.id,
level: b.type === "heading_2" ? 2 : 3,
text: b[b.type].rich_text.map((t) => t.plain_text).join(""),
}));캐러셀과 애니메이션
메인 페이지의 최신 아티클 캐러셀(FeaturedCarousel)은 5초 자동 슬라이드를 기본으로, 수동 조작 시 타이머를 리셋한다.
개발 중 흥미로운 문제가 하나 있었다. 방향별 슬라이드 애니메이션을 동적 클래스명으로 구현하려 했는데, 배포 후 애니메이션이 완전히 사라지는 현상이 생겼다.
원인은 Tailwind v4의 JIT 스캐너였다. Tailwind는 소스 파일을 정적으로 분석해 사용된 클래스 목록을 추출한다. 템플릿 리터럴로 동적으로 조합한 클래스명은 스캔되지 않아 CSS 자체가 생성되지 않는다.
// ❌ 동작 안 함 — Tailwind가 클래스명을 감지 못함
className={`animate-carousel-in-${direction}`}
// ✅ 동작 함 — 두 클래스명 모두 소스에 리터럴로 존재
const animClass =
direction === "rtl" ? "animate-carousel-in-rtl" : "animate-carousel-in-ltr";또 다른 애니메이션 버그는 공유 버튼 토스트에서 발생했다. left-1/2 -translate-x-1/2로 가운데 정렬된 토스트가 애니메이션 중에 왼쪽으로 치우치는 현상이었다. 원인은 CSS 속성 충돌이었다. Tailwind v4의 -translate-x-1/2는 CSS translate 속성(individual transform)을 사용하는데, keyframe 애니메이션에도 translateX(-50%)를 넣으면 두 속성이 각각 독립적으로 적용되어 X축 오프셋이 -100%가 된다.
/* ❌ translate:-50% + keyframe translateX(-50%) = -100% 오프셋 발생 */
@keyframes toast-in {
from { transform: translateX(-50%) translateY(10px); }
to { transform: translateX(-50%) translateY(0); }
}
/* ✅ 위치는 Tailwind 클래스에 맡기고, 애니메이션은 translateY만 */
@keyframes toast-in {
from { transform: translateY(10px); }
to { transform: translateY(0); }
}OG 메타데이터
링크 공유 시 썸네일과 제목이 표시되도록 generateMetadata에 OpenGraph와 Twitter Card 메타데이터를 추가했다.
export async function generateMetadata({ params }) {
const post = getPostBySlug(slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
type: "article",
images: [{ url: post.imageUrl, width: 1200, height: 630 }],
publishedTime: post.date,
authors: [post.author.name],
},
twitter: { card: "summary_large_image" },
};
}metadataBase는 루트 layout.tsx에서 환경변수로 설정해 로컬 이미지 경로가 절대 URL로 변환되도록 했다.
서버 사이드 (API)
Notion 데이터베이스 설계
Notion에는 세 개의 데이터베이스가 있다.
Posts DB — 글 관리
| 필드 | 타입 | 설명 |
|---|---|---|
| Title | title | 글 제목 |
| Status | select | Draft / Approved / Published |
| Slug | rich_text | URL 경로 |
| Author | relation | Authors DB 연결 |
| Category | multi_select | 카테고리 |
| Tags | multi_select | 태그 |
| Excerpt | rich_text | 요약문 |
| Date | date | 발행일 |
| ReadTime | number | 읽기 소요 시간 (분) |
| CoverImage | url | 커버 이미지 URL |
| Approvals | people | 승인자 목록 |
Authors DB — 작성자 관리
| 필드 | 타입 | 설명 |
|---|---|---|
| Name | title | 이름 |
| Role | select | 직군 |
| Bio | rich_text | 소개글 |
| Avatar | url | 프로필 이미지 |
| URL | url | 개인 링크 (옵션) |
Comments DB — 댓글 관리
| 필드 | 타입 | 설명 |
|---|---|---|
| Content | title | 댓글 내용 |
| UserName | rich_text | 작성자 이름 |
| Date | date | 작성일 |
| Slug | rich_text | 해당 아티클 slug |
POST /api/analyze — AI 자동 메타데이터 분석
글 초안을 작성한 뒤 Notion 버튼을 누르면 Gemini AI가 본문을 분석해 메타데이터를 자동으로 채워준다.
- Notion API로 페이지 블록 읽기
- 블록을 평문 텍스트로 변환
- Gemini API에 분석 요청 (Slug, Category, Tags, Excerpt, ReadTime, 이미지 키워드)
- Unsplash API로 커버 이미지 자동 검색 (선택)
- Notion 페이지 속성 업데이트
- 결과를 Notion 결과 블록에 기록
Gemini 프롬프트는 JSON만 반환하도록 엄격하게 설계했다. 그럼에도 가끔 응답에 마크다운 코드펜스가 붙어 오는 경우가 있어 파싱 전에 방어 코드를 추가했다.
responseText = responseText
.replace(/^```(?:json)?\n?/, "")
.replace(/\n?```$/, "");POST /api/publish — GitHub에 단일 커밋으로 배포
승인자가 Notion에서 "배포하기" 버튼을 누르면 배포가 시작된다.
1. 인증 확인
Notion 웹훅은 요청 본문을 커스터마이징할 수 없다. 커스텀 헤더(x-publish-secret)를 우선 확인하고, 없으면 본문의 secret 필드를 확인한다.
2. 필수 필드 검증
8개 필수 필드를 한꺼번에 검사해 누락된 필드를 모두 알려준다. 하나씩 막히는 것보다 한 번에 모든 문제를 파악할 수 있어 작성자 입장에서 훨씬 편하다.
const missingFields: string[] = [];
if (!slug) missingFields.push("Slug");
if (!hasAuthor) missingFields.push("Author");
if (!category) missingFields.push("Category");
if (!postDate) missingFields.push("Date");
// ...
if (missingFields.length > 0) {
await updateResultBlock(pageId, "🚀",
`결과: ❌ 실패 / ${date} / 필수 데이터 누락: ${missingFields.join(", ")}`
);
}3. 이미지 처리
Notion 내부 이미지(S3 서명 URL)는 만료되기 때문에 배포 시점에 다운로드해서 저장한다. 커버 이미지는 public/posts/images/{slug}/cover.{ext}로, 본문 내 이미지는 블록 ID를 파일명으로 저장한다. 블록 데이터 내의 URL도 로컬 경로로 교체한다.
4. GitHub Git Tree API로 단일 커밋
여러 파일(meta.json, blocks.json, 이미지들)을 하나의 커밋으로 GitHub에 올린다. 파일별로 commit을 만드는 대신 Git Tree API를 직접 사용해 원자적으로 처리한다.
// 1. 브랜치 최신 커밋 SHA 조회
// 2. 각 파일의 blob 생성 (병렬)
const blobs = await Promise.all(
files.map(async (file) => {
const { data: blob } = await octokit.git.createBlob({
content: file.content.toString("base64"),
encoding: "base64",
});
return { path: file.path, sha: blob.sha };
})
);
// 3. 새 tree 생성 (base_tree로 기존 파일 보존)
const { data: newTree } = await octokit.git.createTree({
base_tree: baseTreeSha,
tree: blobs.map((b) => ({ path: b.path, mode: "100644", type: "blob", sha: b.sha })),
});
// 4. 새 commit 생성 → 5. 브랜치 ref 업데이트5. Notion 상태 업데이트
커밋이 성공하면 Notion 페이지 Status를 "Published"로 바꾸고, 결과 블록에 성공 메시지와 커밋 SHA를 기록한다.
GET / POST /api/comments/[slug] — Notion 기반 댓글
댓글은 별도의 데이터베이스를 두지 않고 Notion Comments DB를 그대로 활용한다. 런타임에 Notion API를 호출하는 몇 안 되는 부분 중 하나다.
초기에는 Posts DB로의 Relation 필드로 글을 연결하려 했는데, Posts DB 자체가 런타임에 사용되지 않으면서 Relation이 의미가 없어졌다. 그래서 slug를 rich_text 필드로 저장하고 문자열 매칭으로 조회하는 방식으로 변경했다.
await notion.databases.query({
database_id: DB_ID,
filter: {
property: "Slug",
rich_text: { equals: slug },
},
});트러블슈팅: Cloud Run에서 Unsplash 이미지가 안 보이는 문제
GCP Cloud Run에 배포하고 나서 Unsplash 이미지가 전혀 표시되지 않는 문제가 있었다. URL을 보면 /_next/image?url=https%3A%2F%2Fimages.unsplash.com%2F... 형태로, Next.js Image Optimization을 거치고 있었다.
원인을 파고들어 보니 구조적인 문제였다. Next.js <Image> 컴포넌트는 외부 이미지를 /_next/image 엔드포인트가 서버 사이드에서 프록시해 최적화한다. 이 서버 요청이 Cloud Run에서 나가면 User-Agent가 브라우저가 아닌 서버 프로세스로 표시된다. Unsplash CDN(Imgix)은 이를 감지하고 요청을 차단한다.
next.config.ts에 remotePatterns를 등록하는 것만으로는 해결되지 않는다. 이미지가 Next.js 서버를 통해 프록시되는 구조 자체가 문제이기 때문이다.
해결책은 외부 URL에 대해 Next.js 최적화를 건너뛰는 것이다.
// 외부 URL이면 true (http:// 또는 https://)
export function isExternalUrl(url: string): boolean {
return url.startsWith("http://") || url.startsWith("https://");
}
// 컴포넌트에서
<Image
src={imageUrl}
fill
unoptimized={isExternalUrl(imageUrl)} // 외부 URL → 브라우저가 직접 요청
/>unoptimized={true}이면 /_next/image 프록시를 거치지 않고 브라우저가 Unsplash에 직접 요청한다. 로컬 이미지(/posts/images/...)는 여전히 Next.js 최적화가 적용된다.
마무리
처음부터 완벽한 설계로 시작하지 않았다. 목데이터로 UI를 만들고, Notion 연동을 붙이고, 배포 환경에서 발생하는 문제를 하나씩 해결해 나가는 과정이었다.
가장 흥미로웠던 점은 "Notion을 CMS로 쓴다"는 아이디어가 단순한 것 같으면서도 실제로 구현하면 생각보다 많은 엣지 케이스가 있었다는 것이다. Notion 이미지 URL 만료 문제, Notion 웹훅의 페이로드 제약, 리스트 블록 병합, AI 응답 파싱 방어 처리까지. 각각의 문제를 해결할 때마다 시스템이 조금씩 더 견고해졌다.
결과적으로는 팀원이 Notion에서 글을 쓰고 버튼 두 번(AI 분석 → 배포)으로 블로그에 글이 올라가는 파이프라인이 완성됐다.

