블로그 ISR 적용 트러블 슈팅기(1)

2025-09-19

front

현재 블로그는 Next.js로 구성되어 있다. Next.js는 SSG가 기본으로 적용된다. 그러나, 나는 노션에 글을 작성하면 이게 실시간으로 블로그에 반영이 되길 바랬다.
이를 구현하기 위해선 Next.js의 ISR(Guides: ISR)을 이해해야 한다. 이 부분은 현재 포스트에서 자세히 다루지 않겠다.
 

왜 ISR이 제대로 이루어지지 않지?

나는 데이터베이스에 글을 추가하면 노션 웹 훅이 Vercel로 validation 요청을 보내고, 이 요청을 확인하여 revalidate를 호출하여 ISR을 진행하도록 구성했다.
notion image
notion image
그러나 이 기능이 제대로 작동되지 않았다. ISR을 진행하여 page 데이터를 받아오는데 Tiemout이 발생했다.
notion image
그러나 notion-to-md 라이브러리를 사용하면 정상적으로 ISR이 이루어지는데, react-notion-x를 사용하면 되지 않았다. 따라서 react-notion-x에 문제가 있다고 판단했고 그 속을 파보기로 결정했다.

react-notion-x

react-notion-x를 이용하여 notion 페이지 정보를 가져오기 위해 notionX의 클라이언트를 생성하고 getPage메서드로 특정 페이지의 정보를 가져오도록 코드를 작성했다.
const notionX = new NotionAPI(); ... export const getPostRecordMap = async (pageId: string) => { const recordMap = await notionX.getPage(pageId); return recordMap; };
getPage가 어떤식으로 구성이 되어있나 확인해보자. 내부적으로 노션의 비공식 API를 호출하여 데이터를 받아오는 형태이다.
export class NotionAPI { public async getPage(pageId: string): Promise<notion.ExtendedRecordMap> { const page = await this.getPageRaw(pageId) ... } public async getPageRaw(pageId: string) { ... return this.fetch<notion.PageChunk>({ endpoint: 'loadPageChunk', body, kyOptions }) } public async fetch<T>(...) { ... const res = await ky.post(url, { mode: 'no-cors', ...this._kyOptions, ...kyOptions, json: body, headers }) return ret.json(); } }
내가 주목한 부분은 바로 이곳이다. npmnpmnpm: ky를 이용하여 post 요청을 보내고 있다. 현재 요청을 보냈지만 응답이 오지 않는 문제의 핵심 부분이라 생각했다.
public async fetch<T>(...) { ... const res = await ky.post(url, { mode: 'no-cors', ...this._kyOptions, ...kyOptions, json: body, headers }) return ret.json(); }
mode: ‘no-cors’ 이 부분은 무얼 의미할까? 의미로 봐선 CORS를 적용하지 않는다는 의미인 것 같은데… 우선 CORS에 대해 간략히 짚고 넘어가자면, CORS는 Cross Origin Resource Sharing의 약자로 SOP 위에서 동작하는 보안 매커니즘이다. 서로다른 출처에 대해서 요청을 주고 받는 것을 관리하는 정책이라고 보면된다.
 
MDN Web DocsMDN Web DocsRequest: mode property - Web APIs | MDN에서 해당 mode: ‘no-cors’는 CORS를 비활성화 하며, 요청에 대한 응답은 확인할 수 없는 ‘불투명한 응답’으로 오게된다.
 
지금 상황이 딱 이런 상황이다. 요청은 보냈지만 그 처리가 제대로 이루어지지 않아 무한정 기다리는.. 이로 인해 Timeout이 발생하고 있다.
 

어떻게 해결할 수 있나?

요청이 제대로 오지 않는 문제에 대한 해결 방법 자체는 간단하다. NotionAPI 클라이언트 객체를 생성할때 ky options에 mode를 추가해주어 기존의 ‘no-cors’를 덮어씌우면 된다.
const notionX = new NotionAPI({ kyOptions: { mode: "cors", }, });
그래도 나는 아직도 의문이든다. CORS는 브라우저와 서버 통신간에 발생하는 메커니즘으로서 서버 간 통신에는 적용이 되지 않는다고 알고 있다.
어째서 ISR과정에서 일어나는 서버(Vercel) - 서버(Notion API) 통신에서 ‘no-cors’가 적용이 되고 있는걸까? 아니 실제로 ‘no-cors’가 적용되고 있는게 맞는걸까? 다른 원인이 있는건 아닐까?
 

서버 간 통신에서 정말 ‘no-cors’가 무시되나?

서버 간 통신에서 정말 CORS가 적용되지 않는지 먼저 확인할 필요가 있었다. 2개의 서로다른 출처를 갖는 서버를 로컬에 띄우고, 요청을 보내보았다.
import express from "express"; const app = express(); const port = 4000; app.post("/test-cors", (req, res) => { console.log("✅ /test-cors endpoint received a request."); // ⚠️ 중요: CORS 관련 헤더를 전혀 보내지 않음! // res.setHeader('Access-Control-Allow-Origin', '*'); // 이 코드가 없음 res.json({ message: "This is a secret message from the server!", success: true, }); }); app.listen(port, () => { console.log(`🚀 Test server listening at http://localhost:${port}`); });
서버1
import express from "express"; const app = express(); const port = 3000; app.get("/call-other-server", async (req, res) => { console.log( "🚀 클라이언트 서버(3000)에서 API 서버(4000)로 요청 보내는 중..." ); try { // 🔥 핵심: 서버에서 다른 서버로 요청 (CORS 헤더 없어도 OK!) const response = await fetch("http://localhost:4000/test-cors", { method: "POST", headers: { "Content-Type": "application/json", }, }); const data = await response.json(); console.log("✅✅✅ 서버 간 통신 성공! 받은 데이터:", data); res.json({ message: "서버 간 통신 성공!", fromOtherServer: data, proof: "CORS 헤더가 없어도 서버 간 통신은 문제없음!", }); } catch (error) { console.error("❌ 서버 간 통신 실패:", error); res.status(500).json({ error: error.message }); } }); app.listen(port, () => { console.log(`🌐 클라이언트 서버가 http://localhost:${port}에서 실행 중`); console.log(`📡 테스트: curl http://localhost:${port}/call-other-server`); });
서버2
 
서버 간 통신에 별도의 CORS 헤더를 설정해주지 않았음에도 불구하고 서버 간 통신이 잘 이루어진 것을 확인할 수 있었다. 고로 ‘서버 간 통신에는 CORS가 적용되지 않으며, mode: ‘no-cors’도 무효화된다.’가 증명됐다.
notion image
notion image
 

ISR이 안되는 ‘진짜’ 이유?

그렇다면 왜 우리의 상황은 mode: ‘no-cors’가 적용되고 있는 것과 같을까? 뭐가 문제인지 확인해보기 위해 fetch api와 ky로 Notion API를 호출하는 앱을 ISR로 빌드해보았다.
두 방식 모두 mode: ‘no-cors’로 설정하여 요청을 보내도록 했다.
if (fetchType === "ky") { response = await ky.post(url, { mode: "no-cors", headers, json: body, }); } else if (fetchType === "fetch") { response = await fetch(url, { mode: "no-cors", method: "POST", headers, body: JSON.stringify(body), }); }
 
결과는 놀라웠다.
 
fetch를 이용한 요청에 대해서는 ISR이 잘 이루어졌고, ky를 이용한 요청에 대해서는 기존과 동일한 문제가 발생했다. 즉, ky가 문제였다는 것이다?!!!!
=== ISR 페이지 생성 시작: fetch === 생성 시간: 2025-09-17T17:10:51.189Z 환경: Server (ISR) 🌐 Native Fetch API로 테스트 FETCH 환경: Server (ISR) -------- FETCH 요청 URL: https://www.notion.so/api/v3/loadPageChunk -------- FETCH 응답 타입: basic FETCH 응답 상태: 200 FETCH 응답 OK: true FETCH JSON 파싱 시도... FETCH JSON 파싱 성공 ✅ ISR 페이지 생성 성공: 285ms === ISR 페이지 생성 완료: fetch ===
응답을 확인하였다!
=== ISR 페이지 생성 시작: ky === 생성 시간: 2025-09-17T15:07:54.134Z 환경: Server (ISR) KY 라이브러리로 테스트 KY 환경: Server (ISR) -------- KY 요청 URL: https://www.notion.so/api/v3/loadPageChunk -------- ❌ ISR 페이지 생성 실패 (5001ms): 5초 타임아웃: KY 요청 실패 === ISR 페이지 생성 완료: ky ===
응답을 확인할 수 없어 무작정 기다리다 Timeout 발생
 
그렇다면 어째서 ky를 이용한 요청이 제대로 처리가 되지 않는걸까?
문제의 본질은 WHATWG Fetch 표준(패치 표준)과 연결되며, 또한 Ky의 구현체와도 관련이 있다.
이 내용은 다음 글에서 더 자세히 다룬다.