Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions app/blog/post/[id]/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import {
getBlogPostMeta,
getBlogPostMetaBySlug,
getBlogPosts,
getPageContent,
} from "@/lib/notion/blog";
import { pageToMarkdown } from "@/lib/notion/client";
import type { BlogPost } from "@/lib/notion/types";
import { UUID_RE } from "@/lib/site";

Expand All @@ -16,8 +16,10 @@ export const resolvePost = cache(async (param: string) =>
UUID_RE.test(param) ? getBlogPostMeta(param) : getBlogPostMetaBySlug(param)
);

// 실패를 excerpt로 대체하지 않는다 — 빌드가 본문 없는 글을 조용히 배포하던 원인.
// ISR 재생성 중 실패면 Next가 직전 페이지를 계속 서빙한다
export const resolveContent = cache(async (post: BlogPost) =>
getPageContent(post.id, post.excerpt)
pageToMarkdown(post.id)
);

export const getPosts = cache(getBlogPosts);
60 changes: 34 additions & 26 deletions lib/notion/blog.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
import { notion, n2m, queryDatabase } from "./client";
import {
NotionHttpError,
notion,
queryDatabase,
withNotionRetry,
} from "./client";
import { getColorGradient } from "./colors";
import type { BlogPost } from "./types";

/** Notion이 "그런 페이지 없다"고 답한 경우 — 일시적 실패와 구분해야 한다 */
function isMissingPage(error: unknown): boolean {
const e = error as { code?: string; status?: number };
return e?.code === "object_not_found" || e?.status === 404;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function mapPageToBlogPost(page: any): BlogPost {
const props = page.properties;
Expand All @@ -24,20 +35,6 @@ function mapPageToBlogPost(page: any): BlogPost {
};
}

// 페이지의 블록 내용을 마크다운으로 변환 (실패 시 fallback 사용)
export async function getPageContent(
pageId: string,
fallback = ""
): Promise<string> {
try {
const mdblocks = await n2m.pageToMarkdown(pageId);
return n2m.toMarkdownString(mdblocks).parent;
} catch (error) {
console.error(`Failed to fetch blocks for page ${pageId}:`, error);
return fallback;
}
}

// 목록용 조회 — 본문(content)은 상세 페이지에서만 필요하므로
// 글마다 블록을 변환하는 N+1 호출을 하지 않는다
export async function getBlogPosts(): Promise<BlogPost[]> {
Expand Down Expand Up @@ -67,18 +64,24 @@ export async function getBlogPosts(): Promise<BlogPost[]> {

// 본문 없이 메타데이터만 — OG 이미지 생성 등 가벼운 소비처용
export async function getBlogPostMeta(id: string): Promise<BlogPost | null> {
let page;
try {
const page = await notion.pages.retrieve({ page_id: id });
const post = mapPageToBlogPost(page);

// 미공개 글은 노출하지 않는다
if (!post.releasable) return null;

return post;
page = await withNotionRetry(`pages.retrieve(${id})`, () =>
notion.pages.retrieve({ page_id: id })
);
} catch (error) {
console.error("Failed to fetch blog post:", error);
return null;
// 없는 페이지만 null — 일시적 실패까지 '글 없음'으로 만들면
// layout이 멀쩡한 글을 404로 굳혀 버린다
if (isMissingPage(error)) return null;
throw error;
}

const post = mapPageToBlogPost(page);

// 미공개 글은 노출하지 않는다
if (!post.releasable) return null;

return post;
}

// 슬러그로 조회 (본문 제외) — Notion DB에 Slug 속성이 없으면 쿼리가 400이므로 null 처리
Expand All @@ -98,7 +101,12 @@ export async function getBlogPostMetaBySlug(
const page = response.results[0];
return page ? mapPageToBlogPost(page) : null;
} catch (error) {
console.error("Failed to fetch post by slug:", error);
return null;
// DB에 Slug 속성이 없으면 400 — 이때만 '슬러그로 찾을 수 없음'으로 본다.
// 429·5xx까지 null로 삼키면 일시적 장애가 404로 굳는다
if (error instanceof NotionHttpError && error.status === 400) {
console.error("Slug 속성으로 조회할 수 없습니다:", error.body);
return null;
}
throw error;
}
}
164 changes: 145 additions & 19 deletions lib/notion/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,133 @@ import { NotionToMarkdown } from "notion-to-md";
// 키 없는 로컬 환경에서 fallback UI조차 뜨지 못하고 페이지 전체가 500이 된다
const apiKey = process.env.NOTION_API_KEY;

const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));

// ── 요청 간격 조절 ────────────────────────────────────────────────
// Notion 공개 API는 통합당 초당 평균 3요청이다. 빌드는 글 수만큼 페이지를
// 동시에 만들고 글 하나가 블록 요청을 여러 번 보내므로, 조절하지 않으면
// 수백 개가 한꺼번에 나가 429를 맞는다. 요청을 한 줄로 세워 간격을 둔다.
const MIN_REQUEST_GAP_MS = Number(process.env.NOTION_MIN_REQUEST_GAP_MS ?? 350);

let gate: Promise<void> = Promise.resolve();

function takeTurn(): Promise<void> {
const mine = gate.then(() => sleep(MIN_REQUEST_GAP_MS));
// 대기열이 끊기지 않도록 실패해도 다음 차례는 진행시킨다
gate = mine.catch(() => {});
return mine;
}

// SDK의 모든 요청도 같은 게이트를 지나게 한다 — notion-to-md는 페이지 하나에
// 블록 요청을 여러 번 보내므로 호출부만 감싸서는 조절되지 않는다
const throttledFetch = async (
input: Parameters<typeof fetch>[0],
init?: Parameters<typeof fetch>[1]
) => {
await takeTurn();
return fetch(input, init);
};

export const notion = new Client({
auth: apiKey,
fetch: throttledFetch,
});

export const n2m = new NotionToMarkdown({ notionClient: notion });

// ── 실패 처리 ─────────────────────────────────────────────────────

/** fetch 기반 호출의 실패 — 호출부가 상태 코드로 분기할 수 있게 status를 보존한다 */
export class NotionHttpError extends Error {
status: number;
body: string;
retryAfterSeconds?: number;

constructor(status: number, body: string, retryAfterSeconds?: number) {
super(`Notion API error: ${status}\n${body}`);
this.name = "NotionHttpError";
this.status = status;
this.body = body;
this.retryAfterSeconds = retryAfterSeconds;
}
}

const MAX_ATTEMPTS = 8;
const MAX_BACKOFF_MS = 30_000;

/** 응답 본문·헤더에 담긴 Notion의 권장 대기 시간(초) */
function parseRetryAfter(
body: string,
header?: string | null
): number | undefined {
const fromHeader = header ? Number(header) : NaN;
if (Number.isFinite(fromHeader)) return fromHeader;

try {
const parsed = JSON.parse(body);
const seconds = Number(parsed?.additional_data?.retry_after);
return Number.isFinite(seconds) ? seconds : undefined;
} catch {
return undefined;
}
}

/** 다시 시도할 가치가 있는 실패인지 — 한도 초과(429)와 서버측 오류(5xx)만 */
function retryDelayMs(error: unknown, attempt: number): number | null {
const e = error as {
status?: number;
body?: string;
retryAfterSeconds?: number;
};
const status = e?.status;
const retryable =
status === 429 || (typeof status === "number" && status >= 500);
if (!retryable) return null;

const advised =
e.retryAfterSeconds ??
(typeof e.body === "string" ? parseRetryAfter(e.body) : undefined);
if (advised !== undefined) return Math.min(advised * 1000, MAX_BACKOFF_MS);

// 지수 백오프 + 지터 — 빌드의 여러 워커가 같은 시각에 몰려 다시 429를 맞지 않게
const base = Math.min(500 * 2 ** (attempt - 1), MAX_BACKOFF_MS);
return base + Math.floor(Math.random() * 250);
}

/**
* 간격을 둬도 워커가 여러 개면 한도에 닿을 수 있으므로 재시도를 정상 경로로 둔다.
* 한도를 넘겨도 실패하면 호출부로 던진다 — 조용히 빈 값을 돌려주면
* 본문 없는 글이 그대로 배포된다.
*/
export async function withNotionRetry<T>(
label: string,
fn: () => Promise<T>
): Promise<T> {
for (let attempt = 1; ; attempt++) {
try {
return await fn();
} catch (error) {
const wait = retryDelayMs(error, attempt);
if (wait === null || attempt >= MAX_ATTEMPTS) throw error;

console.warn(
`[notion] ${label} 실패 — ${wait}ms 후 재시도 (${attempt}/${MAX_ATTEMPTS - 1})`
);
await sleep(wait);
}
}
}

// ── 조회 ──────────────────────────────────────────────────────────

/** 페이지 블록을 마크다운으로 — 블로그 본문과 프로젝트 설명이 함께 쓴다 */
export async function pageToMarkdown(pageId: string): Promise<string> {
return withNotionRetry(`pageToMarkdown(${pageId})`, async () => {
const mdblocks = await n2m.pageToMarkdown(pageId);
return n2m.toMarkdownString(mdblocks).parent;
});
}

export async function queryDatabase(
database_id: string,
filter?: unknown,
Expand All @@ -20,25 +141,30 @@ export async function queryDatabase(
throw new Error("NOTION_API_KEY environment variable is not set");
}

const response = await fetch(
`https://api.notion.com/v1/databases/${database_id}/query`,
{
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Notion-Version": "2022-06-28",
"Content-Type": "application/json",
},
body: JSON.stringify({ filter, sorts }),
}
);

if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Notion API error: ${response.status} ${response.statusText}\n${errorText}`
return withNotionRetry(`queryDatabase(${database_id})`, async () => {
await takeTurn();
const response = await fetch(
`https://api.notion.com/v1/databases/${database_id}/query`,
{
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Notion-Version": "2022-06-28",
"Content-Type": "application/json",
},
body: JSON.stringify({ filter, sorts }),
}
);
}

return response.json();
if (!response.ok) {
const errorText = await response.text();
throw new NotionHttpError(
response.status,
errorText,
parseRetryAfter(errorText, response.headers.get("retry-after"))
);
}

return response.json();
});
}
10 changes: 2 additions & 8 deletions lib/notion/projects.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { queryDatabase, n2m } from "./client";
import { queryDatabase, pageToMarkdown } from "./client";
import { getColorGradient } from "./colors";
import type { Project } from "./types";

Expand All @@ -15,13 +15,7 @@ export async function getProjects(): Promise<Project[]> {
const props = page.properties;

// 페이지의 블록 내용을 마크다운으로 변환 (모달에서 사용)
let content = "";
try {
const mdblocks = await n2m.pageToMarkdown(page.id);
content = n2m.toMarkdownString(mdblocks).parent;
} catch (error) {
console.error(`Failed to fetch blocks for project ${page.id}:`, error);
}
const content = await pageToMarkdown(page.id);

return {
id: page.id,
Expand Down
Loading