Notion API 어떻게 불러와야 할까?

notion image
현재 이 사이트의 글은 Notion API를 통해 불러오고 있으며, 프로젝트는 Next.js 환경에서 동작하고 있습니다. Notion API 특성상 브라우저에서 직접 호출할 수 없기 때문에, 초반에는 서버에서 받아온 데이터를 Jotai에 저장해 전역 상태처럼 사용하는 방식으로 개발을 진행했습니다.
그런데 Notion을 “서버에서 내려주는 원본 데이터”라고 바라보니, 이 데이터를 Jotai로 들고 있는 게 맞는지, 아니면 TanStack Query를 사용해 서버 상태로 관리하는 것이 더 적절한지에 대한 고민이 생기기 시작했습니다.
그래서 이번 글에서는 Next.js에서 Notion 데이터를 어떤 기준으로 관리할지를 정리해보려고 합니다. 특히
  • 정적 사이트 생성(SSG)으로 빌드 타임에 데이터를 가져오는 방식
  • 서버 사이드 렌더링(SSR)으로 요청 시점에 데이터를 가져오는 방식,
이 둘 위에서 Jotai와 TanStack Query를 각각 어떻게 활용할 수 있을지를 중심으로 살펴보겠습니다.

SSG 데이터는 어떻게 관리될까?

App Router 기준으로 보면:
  • 레이아웃 / 페이지는 기본적으로 Server Component입니다. 여기에서 fetch나 DB 호출을 하면, 말 그대로 “서버에서 데이터를 받아서 렌더링”하게 됩니다.
  • 이때 이 렌더링이 SSR로 동작할지, SSG로 동작할지는 다음과 같은 조건에 따라 달라집니다.
    • cache: "no-store", dynamic = "force-dynamic" 같은 설정이 있는지
    • 쿠키·헤더 등 동적인 요소를 사용하는지
특별한 동적 요소가 없고, no-store 같은 설정도 없다면:
  1. Next.js는 해당 라우트를 정적 렌더링(Static Rendering) 대상이라고 판단하고
  1. 빌드 시점 또는 최초 요청 시점에 한 번 렌더링해서 Full Route Cache에 HTML과 RSC Payload를 저장해 두고
  1. 이후 동일한 라우트 요청에 대해서는 캐시된 결과를 재사용하는 방식으로 응답합니다.
이 흐름을 이해했으니, 이제 잠깐 Full Route Cache가 어떻게 동작하는지와,
서버 컴포넌트의 비동기 동작을 살펴본 뒤 이야기를 이어가 보겠습니다.

Full Route Cache?

Full Route Cache는 말 그대로 정적으로 렌더링된 라우트 전체를 서버에 캐싱해 두는 저장소라고 이해하면 됩니다.
에 따르면, Next.js의 캐시 메커니즘은 크게 네 가지로 나눌 수 있습니다.
  • Request Memoization: 하나의 요청 안에서 동일한 fetch가 여러 번 호출되더라도 실제 네트워크 요청은 한 번만 보내도록 메모이제이션
  • Data Cache: 서버에서 수행한 fetch 결과를 여러 요청/배포 사이에서도 재사용하기 위한 캐시
  • Full Route Cache: 렌더링된 HTML + React Server Component(RSC) payload를 서버에 저장
  • Router Cache: 클라이언트 측에서 RSC payload를 메모리에 올려두고 라우터 전환 시 재사용
이 중에서 Full Route Cache는:
정적으로 렌더링 가능한 라우트에 대해,
HTML과 RSC payload를 서버에 캐시해 두고 이후 요청에서는 이 결과를 재사용함으로써
렌더링 비용을 줄이고 성능을 높이기 위한 캐시입니다.
즉, 한 번 정적으로 렌더링된 라우트에 대해서는 지속적으로 유지되는 서버 캐시라고 볼 수 있습니다.

언제 만들어질까?

Full Route Cache는 크게 두 경우에 만들어집니다.
  1. 정적 라우트 (Static Route) – 빌드 타임
  1. 동적 세그먼트(Dynamic Segment) + generateStaticParams 사용 시

1) 정적 라우트 – 빌드 타임

정적으로 렌더링이 가능한 라우트는 빌드 시점에 미리 렌더링되고, 그 결과가 그대로 Full Route Cache에 저장됩니다.
  • Static Rendering
    • 빌드 타임이나 재검증 이후에 라우트를 한 번 렌더링하고,
    • 그 결과를 캐시해 두었다가 이후 요청에서 재사용합니다.
  • 정적 라우트는 이 과정에서 Full Route Cache에 완전히 캐시되며,
  • Next.js는 매 요청마다 새로 렌더링하는 대신, 캐시된 라우트 결과를 그대로 서빙합니다.

2) 동적 세그먼트 + generateStaticParams

/blog/[slug] 같은 동적 세그먼트에서 generateStaticParams를 사용하면, 다음과 같이 동작합니다.
  • generateStaticParams가 반환한 slug 들에 대해서는 빌드 시점에 미리 렌더링되고, 그 결과가 Full Route Cache에 저장됩니다.
  • 빌드 시점에는 몰랐던 나머지 slug에 대해서는 처음 요청이 들어오는 시점에 서버에서 렌더링한 뒤, 그 결과를 Full Route Cache에 추가로 캐시합니다.
이렇게 보면, Full Route Cache는
  • 빌드 타임 + 최초 요청 시점을 통해 점점 채워지는 정적 라우트 캐시”라고 정리할 수 있습니다.

서버 컴포넌트의 비동기 동작

Next.js에서 빌드를 진행할 때, fetch의 결과가 동적이 아니라면 정적으로 저장된다고 이야기했습니다.
그렇다면 Next.js는 정말 fetch 특별하게 처리해줄까요?
에 따르면, Server Component에서는 다음과 같이 명시되어 있습니다.
Server Component에서는 fetch, ORM/DB, 파일 읽기 등
어떤 비동기 I/O든 모두 사용할 수 있다.
즉, 빌드 시점에 Next.js가 하는 일은:
Server Component 트리 전체를 한 번 실행하고,
그 결과(RSC 트리)를 캐싱 가능한 형태로 저장해 두는 것
이라고 볼 수 있습니다.
이때 내부에서
  • fetch를 쓰든,
  • notion-client를 쓰든,
  • setTimeout을 쓰든,
  • findDetailBySlug 같은 유틸 함수를 여러 번 호출하든,
“빌드 기준으로는 “한 번 실행되는 로직”일 뿐입니다.
정적으로 렌더링 가능한 라우트라면 이 렌더 결과가 한 번 만들어지고 나면,
그 이후 요청에서는 아예 렌더 과정을 다시 거치지 않고 캐시된 결과를 그대로 반환합니다.

fetch와 일반 비동기의 차이

여기서 중요한 포인트는, Next.js가 fetch를 특별 대우한다는 점입니다.
에서는 fetch를 확장해서 다음과 같이 동작한다고 설명합니다.
Next.js는 fetch를 확장해서
  • 빌드 타임(또는 첫 요청 시점)에 한 번 렌더링하고
  • 그 결과인 HTML + RSC Payload를 Full Route Cache에 저장한 뒤
  • 이후 요청에서는 이 캐시를 재사용한다.
반면, 우리가 직접 만든 비동기 함수는:
  • Next.js의 Data Cache 대상이 아니고
  • Request Memoization도 fetch에만 적용되기 때문에
그냥 “부른 만큼 실행되는 함수”에 가깝습니다.
정리하면, 같은 렌더 트리 안에서:
  • fetch('https://api.example.com/x')를 여러 컴포넌트에서 호출하면 → 실제 네트워크 요청은 한 번만 나가고, 결과가 공유됩니다. (Request Memoization)
  • 반대로 findDetailBySlug처럼 직접 만든 비동기 함수를 여러 곳에서 호출하면 → 호출한 횟수만큼 그대로 실행됩니다. (별도로 처리하지 않는 한)
이때 사용할 수 있는 도구가 바로 **React의 cache()**입니다.

React cache가 하는 일

에 따르면, cache()는 다음과 같이 정의됩니다.
  • cache(fn)Server Component에서만 사용해야 하고,
  • 같은 인자(argument)로 여러 번 호출하면 중복 작업을 건너뛰고 결과를 재사용하며,
  • 요청(Request) 간에는 캐시가 유지되지 않는다 → 요청이 새로 시작되면 캐시는 초기화됩니다.
즉, cache()는:
하나의 서버 렌더링(= 하나의 요청 또는 하나의 빌드 패스) 동안
같은 비동기 함수를 여러 번 호출해도
실제로는 한 번만 실행되도록 만들어주는 “요청 단위 캐시”
라고 볼 수 있습니다.
이게 특히 유용한 상황은 다음과 같습니다.
  • 같은 데이터를 여러 군데에서 쓰는 경우
    • 예: 같은 (slug, category)
      • layout에서 호출
      • page에서 호출
      • generateMetadata에서 SEO용으로 호출
    • cache()가 없으면 → findDetailBySlug가 최대 3번 실행
    • cache()가 있으면 → 첫 호출만 실제 실행, 나머지는 캐시된 결과 반환
  • fetch가 아니라서 Request Memoization 대상이 아닌 함수인 경우
    • getList 내부에서 fetch를 직접 쓰고 있다면 fetch 자체는 어느 정도 중복 요청이 되더라도,
      • 그 앞뒤에 있는 decodeSlug, find, getDetail 같은 추가 연산은 그대로 여러 번 실행됩니다.
    • notion-client, Prisma 같은 DB 클라이언트 호출은 fetch가 아니기 때문에 → Next.js의 Request Memoization / Data Cache 대상이 아닙니다.
이럴 때 cache()로 감싸주면,
한 번의 렌더 과정 안에서 반복되는 데이터 로딩/계산을 한 번으로 줄일 수 있습니다.

“한 라우트 렌더”가 의미하는 범위

React cache() 관점에서 말하는 “한 번의 렌더”는 대략 다음과 같이 이해할 수 있습니다.
  • 하나의 요청(또는 하나의 빌드 시 렌더 사이클) 동안
  • 같은 cache(fn) + 같은 인자 조합에 대해
  • 결과를 한 번만 계산하고 재사용하는 것
Next.js App Router에 이 개념을 대입해 보면, 어떤 URL을 렌더링할 때:
  • layout.tsx
  • page.tsx
  • generateMetadata
  • 기타 Server Component들
이들이 같은 요청 / 같은 빌드 패스 안에서 실행된다면,
cache()로 감싼 비동기 함수는 한 번만 실제 실행되고, 나머지는 그 결과를 재사용하게 됩니다.

“요청이 새로 시작되면 cache는 어떻게 될까?”

여기서 자연스럽게 이런 질문이 나옵니다.
“Layout, page, metadata 모두 같은 비동기 호출을 하면 캐시를 재사용할 텐데,
나중에 page만 다시 호출되면 이전 캐시는 안 쓰는 거 아닌가?”
이걸 두 가지 케이스로 나눌 수 있습니다.
  1. 같은 요청 안에서 Layout/Metadata는 이미 렌더를 마친 뒤, 이어서 Page가 렌더되는 경우 → 여전히 같은 요청/렌더 사이클이므로 cache() 결과를 그대로 재사용합니다.
  1. 완전히 다른 요청인 경우 예: 새로고침, 다른 유저의 요청, ISR 재검증 후 재렌더 등 → 이는 새로운 요청/새 렌더 사이클로 취급되며 → cache() 내부 캐시는 초기화된 상태로 다시 시작합니다.
그래서 한 줄로 정리하면:
cache()는 “요청/빌드 한 번”이라는 범위 안에서만 유효한 메모이제이션이고,
그 범위를 벗어나면(새 요청/새 빌드) 다시 계산됩니다.

SSG 빌드에서 cache()로 얻는 이점

/blog/hello 페이지를 SSG로 빌드한다고 가정해 보겠습니다.

1) cache()를 쓰지 않을 때

SSG 빌드 시:
  • generateMetadata에서 findDetailBySlug('hello')
  • layout에서 findDetailBySlug('hello')
  • page에서 findDetailBySlug('hello')
를 호출하면:
  • 빌드 시점에 노션/DB/파일 조회 로직이 최대 3번까지 실행될 수 있습니다.
만약 이 안에서 fetch를 직접 호출하고 있고,
URL과 옵션이 완전히 같다면 fetch 자체는 Request Memoization 덕분에
네트워크 요청은 한 번만 나갈 수도 있습니다.
하지만 그 앞뒤에 있는:
  • decodeSlug
  • find
  • getDetail
같은 추가 연산은 그대로 3번씩 돌게 됩니다.

2) cache()로 감쌌을 때

와 같이 감싸 두고, 위의 세 군데에서 같은 인자 조합으로 호출하면:
  • /blog/hello를 렌더하는 한 번의 빌드 패스 전체에서
    • 첫 호출만 실제로 실행
    • 나머지 두 번은 cache()가 돌려준 결과를 그대로 재사용
하게 됩니다.
즉, 정리하면:
“SSG로 빌드할 때 같은 비동기 함수를 3번 호출해서 페이지를 만들고,
cache()를 쓰면 실제 실행은 한 번만 하기 때문에 충분히 이득이 될 수 있다.”
특히 아래와 같은 패턴에서 효과가 큽니다.
  • generateMetadata + page + layout 등 여러 곳에서 같은 데이터가 필요할 때
  • fetch가 아니라 notion-client, Prisma, 파일 I/O처럼 직접 비동기 호출을 쓰는 경우
  • getDetail 자체가 꽤 무거운 연산일 때

Notion API 호출의 경우는?

그동안 정리한 내용을 바탕으로, 이제 Notion API로 불러온 데이터가 실제 라우트에서 어떻게 쓰이고 있는지를 한 번에 정리해볼 수 있습니다.
지금 사이트에서 Notion 데이터를 사용하는 전체 플로우를 정리하면 대략 이렇게 볼 수 있습니다.
  1. Root Layout에서 공통 데이터 조회
      • blog, about, project에 해당하는 데이터를 Root Layout에서 한 번 불러옵니다.
  1. 공통 하단 바에서 데이터 사용
      • 이렇게 불러온 데이터는 모든 페이지에 공통으로 노출되는 하단 바에서 재사용됩니다.
  1. [category] 페이지 – 카테고리별 리스트 조회
      • /blog, /about, /project처럼 [category] 경로로 진입하면 해당 카테고리에 맞는 리스트 데이터를 별도로 다시 조회합니다.
  1. [category]/[slug] 페이지 – 상세 페이지 조회
      • /blog/[slug]처럼 상세 페이지로 진입하면 해당 slug에 대한 상세 데이터를 추가로 조회합니다.
정리하면,
  • 리스트 조회
  • 상세 페이지 조회
이 두 가지 작업이 모두 서버에서 Notion API를 호출해 렌더링하는 흐름(SSR 또는 서버 렌더 기반)으로 동작하고 있습니다.
이제 남은 질문은 하나입니다.
“이렇게 서버에서 계속 호출하고 있는 Notion 데이터를
어떤 기준으로, 어떤 도구로 관리하는 게 좋을까?”
제가 보기에는, 이 문제에 크게 세 가지 도구를 사용해볼 수 있을 것 같습니다.

불러온 값을 props로 전달

Root Layout에서 불러온 데이터를,
그 데이터를 그대로 사용하기만 하는 컴포넌트에 props로 내려주는 방식입니다.
불러온 값을 별도로 수정하지 않고,
단순히 화면에 “읽기 전용으로만” 보여주면 된다면 이 방법만으로도 충분합니다.
  • 실시간 업데이트가 필요 없고
  • 재검증(revalidation)도 크게 중요하지 않고
  • 굳이 전역 상태로 들고 있을 이유도 없다면
굳이 Jotai나 TanStack Query를 도입하지 않고 SSR/SSG + props만으로도 깔끔하게 유지할 수 있습니다.

Jotai를 사용

Jotai는 공식적으로 “리렌더 최소화 + 유연한 클라이언트 상태 관리”를 목표로 하는 상태 관리 라이브러리입니다.
다음과 같은 상황에서 특히 잘 어울립니다.
  • props drilling 없이 여러 단계에 걸쳐 값을 내려보내고 싶을 때
  • 여러 Client Component에서 같은 값을 가볍게 공유하고 싶을 때
  • 주로 UI 상태(모달 열림 여부, 탭 선택, 필터 상태 등)를 관리할 때
이런 경우라면,
“서버에서 내려온 원본 데이터”를 전부 Jotai에 넣기보다는
그 데이터 위에서 파생되는 UI 상태만 Jotai atom으로 관리하는 편이 더 의미 있습니다.

Tanstack Query를 사용

TanStack Query는 공식적으로 자신을 “Server State 라이브러리”라고 정의하고 있습니다.
즉, 서버에서 가져온 데이터의 수명 주기를 관리하는 데 특화된 도구입니다.
예를 들어 다음과 같은 요구가 있을 때 잘 어울립니다.
  • Notion뿐 아니라 여러 API 기반 서버 데이터가 있고,
    • 캐싱 전략,
    • refetch 타이밍,
    • stale-while-revalidate,
    • mutation 이후 invalidate
같은 것들을 일관된 방식으로 관리하고 싶을 때
  • Notion 데이터도
    • 클라이언트에서 “새로고침” 버튼을 눌렀을 때 다시 불러오고 싶거나
    • 다른 API 데이터와 함께 동기화된 캐시 전략을 가져가고 싶을 때
이런 상황이라면, Notion 응답도 TanStack Query의 server state로 두고 관리하는 편이 더 자연스럽습니다.
정리하자면,
  • 그냥 읽어서 보여주기만 하는 정적/반정적 데이터라면 → props 전달
  • 여러 Client Component가 공유하는 UI 상태라면 → Jotai
  • 재검증·캐싱·동기화가 중요한 서버 데이터라면 → TanStack Query
이렇게 역할을 나눠서 생각해볼 수 있습니다.

최종 선택은?

지금까지 내용을 바탕으로 현재 전제 조건을 다시 정리해보면 다음과 같습니다.
  1. 데이터 출처
      • Notion 기반 블로그 데이터 (리스트/상세 등)
  1. 신선도 관리 방식
      • 클라이언트에서 refetch() 호출 없음
      • ISR만으로 데이터 갱신 (빌드/첫 요청/재검증 타이밍에 서버에서 다시 렌더)
      • 페이지 안에 “새로고침 버튼”, “실시간 업데이트” 같은 기능 없음
  1. 용도
      • 오직 뷰를 위한 읽기 전용 데이터
      • 클라이언트에서 이 데이터를 수정하지 않음
  1. 스코프
      • 헤더, 사이드바, 페이지 내용 등 여러 위치에서 전역적으로 재사용되는 데이터
이 조건을 놓고 보면:
  • “서버 상태 라이브러리”인 TanStack Query가 강점으로 내세우는
    • 캐싱 전략,
    • 백그라운드 refetch,
    • stale 관리,
    • mutation 이후 invalidate 같은 기능이 거의 필요하지 않습니다.
  • 반대로, 지금 핵심 요구는
    • “전역에서 읽기 전용으로 공유하고 싶다”
    • “UI에서 이 값을 편하게 꺼내 쓰고 싶다”
    • 에 가깝기 때문에, 클라이언트 전역 상태 계층(Jotai) 쪽이 더 잘 맞는 상황입니다.
TanStack Query 공식 문서에서도 스스로를 “server-state library”라고 정의하고 있고,
이에 비해 Redux / MobX / Zustand 같은 것들은 client-state library라고 선을 긋습니다.
Jotai는 이 client-state 쪽에 속하는 라이브러리입니다.

그대로 Jotai를 선택할 이유

1. 서버 레이어에서는 이미 “SSR/SSG/ISR + Full Route Cache”로 해결

  • Notion 데이터는 Server Component에서 한 번 fetch한 뒤
    • SSG/ISR로 생성된 정적 HTML + RSC Payload에 포함되고,
    • Full Route Cache에 캐시된 상태로 서빙됩니다.
  • 말 그대로 “신선도 관리”는 ISR 레이어에서 이미 끝난 상태라서,
    • 브라우저에서 굳이 useQuery로 다시 refetch할 이유가 없습니다.
정리하면:
데이터의 신선도 / 캐싱 / 리패칭은
Next.js(SSR/SSG/ISR) 레이어에서 이미 해결되어 있고
클라이언트에서는 그냥 읽기 전용 전역 상태만 있으면 된다.
이런 구조에서는 TanStack Query의 주 역할(서버 상태 동기화, 캐시, refetch)을
굳이 한 번 더 도입할 이유가 거의 없습니다.

2. 클라이언트 요구사항은 “여러 군데에서 쉽게 꺼내 쓰고 싶다”

클라이언트 관점에서의 요구는 사실 아주 단순합니다.
  • 여러 위치에서 같은 blogList / blogDetail을 재사용하고 싶고
  • 매번 props로 길게 내려보내고 싶지는 않다 → 이게 요구사항의 대부분입니다.
이 지점에서 Jotai의 특성이 잘 맞습니다.
  • atom 하나를 정의해 두면 → 어디서든 useAtom으로 접근 가능
  • 각 atom 단위로만 리렌더가 일어나서 → Context 기반 전역 상태보다 불필요한 리렌더가 적음
지금 블로그 데이터의 특성을 다시 보면:
  • 읽기 전용이다. (클라이언트에서 수정하지 않음)
  • 전역적으로 재사용된다.
  • 신선도는 ISR로 서버에서 보장된다.
이 세 가지를 모두 만족하기 때문에,
“한 번 받아온 readonly 스냅샷을, 클라이언트 쪽에서 편하게 전역으로 들고 있는 용도”
로는 Jotai atom이 가장 자연스러운 선택이라고 볼 수 있습니다.

총총

결론만 놓고 보면, 처음에 고민했던 것들에 비해 조금 싱겁게 끝난 느낌도 있습니다.
“Jotai vs TanStack Query, 뭐가 더 맞을까?”에서 시작했는데,
막상 정리해 보니 현재 상황에서는 그냥 Jotai를 그대로 쓰는 게 가장 자연스럽다는 결론이니까요.
하지만 그 과정에서 얻은 건 생각보다 꽤 많았습니다.
  • SSG와 SSR이 어떤 기준으로 나뉘는지
  • SSG 페이지가 어떤 식으로 생성되고, Full Route Cache에 어떻게 쌓이는지
  • fetch와 일반 비동기 함수, 그리고 React cache()서버 렌더링 안에서 각자 어떤 역할을 하는지
이런 것들을 한 번에 정리해 볼 수 있는 기회였습니다.
특히,
“그냥 SSR로 데이터를 호출한다고 해서 항상 ‘실시간’은 아니다”
라는 점을 다시 한 번 체감했습니다.
빌드/첫 요청/ISR 재검증 시점에 정적으로 굳어지는 SSG/ISR 특성을 이해하고 나니,
  • “이건 정말 요청마다 신선해야 하는 데이터인지
  • “아니면 조금 늦게 갱신돼도 괜찮은 컨텐츠인지
  • “그렇다면 이걸 SSR/SSG/ISR 중 어디에 둘지,”
  • “클라이언트 상태로까지 끌고 와야 할지
같은 것들을 이전보다 더 구체적으로 고민하게 되었습니다.