From 9208a504eb787f014445d60e8e6234af84320734 Mon Sep 17 00:00:00 2001 From: JunSeoChoi Date: Mon, 14 Sep 2026 18:14:55 +0900 Subject: [PATCH] =?UTF-8?q?Fix:=20Notion=20=ED=98=B8=EC=B6=9C=EC=97=90=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EA=B0=84=EA=B2=A9=C2=B7=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=20=EC=B6=94=EA=B0=80,=20=EC=8B=A4=ED=8C=A8=EB=A5=BC?= =?UTF-8?q?=20'=EA=B8=80=20=EC=97=86=EC=9D=8C'=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=98=A4=EC=9D=B8=ED=95=98=EC=A7=80=20=EC=95=8A=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 빌드가 Notion API 429로 실패하거나(실제로 #54 프로덕션 배포가 이걸로 실패), 조용히 본문 없는 글을 배포할 수 있는 구조였다. 실패가 세 갈래로 번졌다. 1) getPageContent가 예외를 삼키고 excerpt로 대체 — 본문이 사라진 채 빌드가 성공해 그대로 배포된다 2) queryDatabase가 429에 그대로 실패 — 빌드 전체가 중단된다 3) getBlogPostMeta/BySlug가 모든 예외를 null로 — layout이 이를 '글 없음'으로 읽어 멀쩡한 글이 404로 굳는다 - client.ts: 요청을 한 줄로 세워 최소 간격(기본 350ms)을 둔다. SDK에 커스텀 fetch를 주입해 notion-to-md의 블록 요청까지 같은 게이트를 지나게 했다 — 호출부만 감싸면 실제 트래픽 대부분이 새어 나간다 - client.ts: 429·5xx에 지수 백오프 재시도(최대 8회), Notion이 알려주는 retry_after를 우선 존중. 4xx는 즉시 전파 - pageToMarkdown을 client.ts로 옮겨 블로그·프로젝트가 함께 쓴다 - blog.ts: object_not_found/404만 null, 나머지는 전파. 슬러그 조회는 Slug 속성 부재(400)일 때만 null - projects.ts도 같은 경로를 타게 정리 검증: 재시도 정책 6케이스(429 복구·retry_after 존중·5xx·4xx 즉시 전파· 한도 소진 시 전파·SDK 에러 형태) 통과. 연속 빌드 3회 모두 성공(이전엔 3회차에 실패), 2회차는 48번 재시도로 자력 복구. 생성된 글 42개 전부 30KB 이상으로 본문 유실 없음. 404/308/200 상태 코드 회귀 없음. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VMaC7hvbuyHaPbCatSjvqK --- app/blog/post/[id]/resolve.ts | 6 +- lib/notion/blog.ts | 60 +++++++------ lib/notion/client.ts | 164 ++++++++++++++++++++++++++++++---- lib/notion/projects.ts | 10 +-- 4 files changed, 185 insertions(+), 55 deletions(-) diff --git a/app/blog/post/[id]/resolve.ts b/app/blog/post/[id]/resolve.ts index 65a7daa..5917786 100644 --- a/app/blog/post/[id]/resolve.ts +++ b/app/blog/post/[id]/resolve.ts @@ -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"; @@ -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); diff --git a/lib/notion/blog.ts b/lib/notion/blog.ts index a752dda..1ac80df 100644 --- a/lib/notion/blog.ts +++ b/lib/notion/blog.ts @@ -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; @@ -24,20 +35,6 @@ function mapPageToBlogPost(page: any): BlogPost { }; } -// 페이지의 블록 내용을 마크다운으로 변환 (실패 시 fallback 사용) -export async function getPageContent( - pageId: string, - fallback = "" -): Promise { - 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 { @@ -67,18 +64,24 @@ export async function getBlogPosts(): Promise { // 본문 없이 메타데이터만 — OG 이미지 생성 등 가벼운 소비처용 export async function getBlogPostMeta(id: string): Promise { + 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 처리 @@ -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; } } diff --git a/lib/notion/client.ts b/lib/notion/client.ts index 4f9fc6e..9ae90e5 100644 --- a/lib/notion/client.ts +++ b/lib/notion/client.ts @@ -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((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 = Promise.resolve(); + +function takeTurn(): Promise { + const mine = gate.then(() => sleep(MIN_REQUEST_GAP_MS)); + // 대기열이 끊기지 않도록 실패해도 다음 차례는 진행시킨다 + gate = mine.catch(() => {}); + return mine; +} + +// SDK의 모든 요청도 같은 게이트를 지나게 한다 — notion-to-md는 페이지 하나에 +// 블록 요청을 여러 번 보내므로 호출부만 감싸서는 조절되지 않는다 +const throttledFetch = async ( + input: Parameters[0], + init?: Parameters[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( + label: string, + fn: () => Promise +): Promise { + 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 { + 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, @@ -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(); + }); } diff --git a/lib/notion/projects.ts b/lib/notion/projects.ts index a6b2e1c..4926b19 100644 --- a/lib/notion/projects.ts +++ b/lib/notion/projects.ts @@ -1,4 +1,4 @@ -import { queryDatabase, n2m } from "./client"; +import { queryDatabase, pageToMarkdown } from "./client"; import { getColorGradient } from "./colors"; import type { Project } from "./types"; @@ -15,13 +15,7 @@ export async function getProjects(): Promise { 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,