Notion API 블로그 성능 최적화
- Notion API

배경
Notion API를 활용해 개인 블로그를 만드는 중, 사용자 경험에 영향을 주는 몇 가지 성능 이슈를 발견했다. 특히 게시글 상세 페이지에서 목차가 본문보다 늦게 렌더링되는 현상이 눈에 띄었고, 이를 계기로 전체적인 로직 분석과 개선 작업을 진행하게 되었다.
블로그의 모든 컨텐츠는 Notion을 CMS로 활용하고 있어, Notion API를 통해 게시글 데이터를 가져오는 구조다. 초기에는 빠른 구현과 적은 양의 데이터로 테스트하다 보니 API 호출 최적화나 에러 처리 같은 부분을 간과했고, 이것이 성능 저하의 주요 원인이었다.
문제 인식과 분석
가장 먼저 눈에 띈 문제는 목차 부분의 렌더링 지연이었다. 사용자가 게시글에 접속하면 본문은 빠르게 보이는데, 목차 영역만 계속 로딩 상태로 남아있었다. 이상하다고 생각해 코드를 분석해보니, 페이지 블록 데이터를 여러 번 중복으로 조회하고 있었다.
page.tsx에서 getInitialPageBlocks를 호출해 페이지의 내용의 초반부 블록들을 가져오고, 동시에 목차를 불러오기 위해 getFullTableOfContents를 호출해 전체 블록을 다시 가져왔다. 또한, 게시글 내용에서 남은 전체 블록 데이터를 불러오고 있었다. 같은 데이터를 2-3번 조회하는 비효율적인 구조였다.
추가로 코드를 살펴보니 다른 문제들도 발견되었다. Table 블록의 자식 블록들을 순차적으로 조회하고 있어서, Table이 여러 개면 그만큼 시간이 배로 늘어났다. 관련 게시글 추천 기능은 100개의 포스트를 전부 가져와서 점수를 계산한 뒤 3개만 선택하는 방식이었다. 에러 처리도 전체 API 호출의 33%만 적용되어 있어, 오류 발생 시 사용자에게 적절한 피드백을 줄 수 없었다.
중복 API 호출 제거
첫 번째로 해결한 문제는 페이지 블록 중복 조회다. 초기 설계에서는 빠른 초기 렌더링 을 위해 전체 게시글 내용 중, 윗 부분을 먼저 보여주고 나머지는 나중에 로드하려는 의도였다. 하지만 실제로는 목차를 조회하는 getFullTableOfContents에서 이미 전체 블록을 가져오고 있었다. 그렇다면 사실상 페이지 내용을 굳이 나누어서 받아올 필요도 없었고, 한 번의 데이터 요청으로도 충분히 두 곳에서 모두 활용할 수 있을 것이라 생각했다.
해결 방법은 간단했다. getPageBlocks 한 번만 호출해서 전체 블록을 가져오고, 그 데이터로 목차를 추출하여 TableOfContents 컴포넌트에 props로 전달했다. 페이지 내용을 가져오는 부분에서도 API 호출을 제거하고 props로 내려주었다. 이렇게 수정하니 API 호출이 3회에서 1회로 줄어들었고, 목차가 본문과 동시에 렌더링되었다. 이미 전체 블록을 한 번에 가져오므로 불필요한 부분들을 제거할 수 있었다.
Table 블록 병렬 처리
두 번째로 개선한 부분은 Table 블록 조회 방식이다. 기존 코드는 for 루프를 돌면서 각 Table 블록의 자식 블록을 순차적으로 await하고 있었다. 게시글에 Table이 3개 있고 각각 응답 시간이 1초라면, 총 3초가 걸리는 구조였다.
이를 Promise.all을 활용한 병렬 처리로 변경했다. Table 블록들을 먼저 필터링한 후, 각 블록의 자식 블록을 가져오는 Promise를 배열로 만들어 Promise.all에 전달했다. 모든 요청이 동시에 실행되므로, 3개의 Table이 있어도 1초면 완료된다.
// Table 블록만 필터링
const tableBlocks = allBlocks.filter((block) => block.type === 'table' && block.has_children)
if (tableBlocks.length > 0) {
// 각 Table의 자식 블록을 가져오는 Promise 배열 생성
const childBlocksPromises = tableBlocks.map((block) =>
fetchChildBlocks(block.id).then((children) => ({ blockId: block.id, children }))
)
// 모든 요청을 병렬로 실행
const childBlocksResults = await Promise.all(childBlocksPromises)
// 결과를 원래 블록 배열에 할당
childBlocksResults.forEach(({ blockId, children }) => {
const block = allBlocks.find((b) => b.id === blockId)
if (block) block.children = children
})
}관련 글 추천 최적화
세 번째는 관련 게시글 추천 로직이다. 기존에는 getPostsFromNotion(100)으로 100개의 포스트를 가져온 뒤, 각 포스트와 현재 글의 태그를 비교해 점수를 계산하고, 점수 순으로 정렬해서 상위 3개를 선택했다. 100개를 가져오는 이유는 충분한 후보군을 확보하기 위함이었지만, 실제로는 3개만 사용하므로 엄청난 낭비였다. 초기에는 게시글이 적었기에 큰 영향이 없을 것이라 생각했지만, 점점 많아질 게시글들을 고려하면 해당 로직은 개선이 필요하다고 판단했다.
Notion API의 필터 기능을 활용하면 서버 측에서 태그 기반 필터링을 할 수 있다는 점을 알게 되었다. 현재 글의 태그를 기반으로 OR 조건 필터를 구성하고, page_size를 4개(현재 글 제외하고 3개 표시)로 제한했다. 최신순 정렬도 API 레벨에서 처리하도록 했다.
// 현재 글의 태그들을 OR 조건 필터로 변환
const tagFilters = currentTags.map((tag) => ({
property: 'tag',
multi_select: { contains: tag },
}))
// Notion API 요청 본문 구성
const requestBody = {
page_size: limit + 1, // 현재 글 제외를 위해 1개 더 요청
sorts: [{ property: 'date', direction: 'descending' }], // 최신순 정렬
filter: { or: tagFilters }, // 태그 OR 조건 필터
}이렇게 변경하니 100개 조회가 4개 조회로 줄어들었고(96% 감소), 클라이언트 사이드의 점수 계산 로직도 완전히 제거할 수 있었다. 코드가 훨씬 간결해지고 의도도 명확해졌다.
에러 처리
네 번째로 집중한 부분은 에러 처리다. 분석 결과 전체 API 호출 중 33%만 try-catch로 감싸져 있었고, 에러가 발생해도 사용자에게 적절한 피드백을 주지 못했다. Notion API는 외부 서비스이므로, 초당 API 제한도 있었고, 네트워크 이슈나 Rate Limit, 일시적인 장애 등 다양한 실패 상황을 고려해야 했다.
먼저 Next.js의 에러 바운더리를 활용해 각 레벨별 에러 페이지를 구현했다. 글로벌 error.tsx, 게시글 상세 페이지용 posts/[postId]/error.tsx, 그리고 404 상황을 위한 not-found.tsx를 추가했다. 각 페이지는 단순히 에러를 표시할 뿐만 아니라, 다시 시도 버튼과 홈으로 돌아가기 링크를 제공해 사용자가 다음 행동을 취할 수 있도록 했다.
무한 스크롤에도 에러 처리를 추가했다. use-infinite-scroll 훅에 error state와 retry 함수를 추가하고, 컴포넌트에서는 에러 발생 시 메시지와 재시도 버튼을 표시하도록 했다. 댓글과 방명록 폼의 에러 메시지도 개선해서, 404 에러인지 네트워크 에러인지 구분하여 사용자에게 명확한 피드백을 제공했다.
단순히 에러 커버리지를 개선하고자 하기보다는 사용자가 에러 상황에서도 무엇이 잘못되었는지 이해하고, 적절한 조치를 취할 수 있도록 하고자 했다.
API 안정성 강화를 위한 Timeout과 Retry
다섯 번째로 구현한 것은 API 타임아웃과 재시도 로직이다. 기존에는 fetch 요청에 타임아웃이 없어서, API 서버가 응답하지 않으면 사용자가 무한정 기다려야 했다. 또한 일시적인 네트워크 오류나 Notion API의 Rate Limit에도 대응할 수 없었다.
개인적으로 학교에서 개발할 때 네트워크가 불안정한 경우가 많았고 느려질 때가 종종 있었다. 그렇기 때문에 이런 부분에 민감하게 고려할 수밖에 없었던 것 같다.
api-client.ts에 fetchWithTimeout 함수를 구현했다. AbortController를 사용해 30초 후 자동으로 요청을 취소하도록 했다. 재시도 로직도 추가해서, 실패 시 최대 2번까지 재시도하되 지수 백오프 방식으로 대기 시간을 늘렸다.
const fetchWithTimeout = async (url, options, timeout = 30000) => {
// AbortController로 요청 취소 제어
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
// signal을 통해 타임아웃 시 자동 취소
const response = await fetch(url, { ...options, signal: controller.signal })
clearTimeout(timeoutId)
return response
} catch (error) {
clearTimeout(timeoutId)
// AbortError인 경우 사용자 친화적 메시지로 변환
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('요청 시간이 초과되었습니다')
}
throw error
}
}재시도 로직에는 몇 가지 규칙을 적용했다. 4xx 에러는 클라이언트 측 문제이므로 재시도하지 않지만, 429(Rate Limit)는 예외로 재시도한다. 5xx 에러는 서버 측 일시적 문제일 수 있으므로 재시도한다. 429 에러의 경우 대기 시간을 2배로 늘려서 Rate Limit을 대응하도록 했다.
이 설정을 모든 Notion API 호출에 적용했다. 네트워크가 불안정하거나 Notion API에 일시적 장애가 발생해도, 자동으로 재시도하여 성공할 가능성을 높였다. 그래도 실패하면 30초 후 타임아웃되어 사용자가 무한정 기다리는 상황을 방지한다.
폰트 최적화
마지막으로 진행한 것은 폰트 최적화다. 초기에는 Pretendard Variable 폰트를 사용했는데, 파일 크기가 2.1MB였다. Variable 폰트는 모든 굵기를 하나의 파일에 담고 있어 편리하지만, 실제로 사용하는 굵기가 제한적이라면 개별 굵기 파일을 사용하는 것이 더 효율적이라 생각했다.
코드베이스를 분석한 결과, 실제로는 Regular(400), Medium(500), Bold(700) 세 가지 굵기만 사용하고 있었고, 대부분 Regular와 Medium만 사용했다. 개별 굵기 파일은 각각 약 750-770KB로, 3개를 모두 다운로드해도 2-2.5MB 정도였다.
하지만 실제 사용자 경험에서는 개별 파일이 더 유리하다. Preload를 통해 가장 많이 사용하는 Regular, Medium만 먼저 로드하면 초기 로드는 약 1.6MB로, 사용자는 훨씬 빠르게 콘텐츠를 볼 수 있다. Bold는 font-display: swap으로 나중에 로드되므로, 초기 렌더링을 차단하지 않는다.
마무리하며
최적화 작업을 통해 블로그의 성능이 개선되었다. API 호출 횟수는 67% 감소했고, Table 블록은 병렬 처리로 N배 빠르게 로드된다. 관련 글 조회는 96% 감소했고, 에러 커버리지는 33%에서 80% 이상으로 증가했다. 무엇보다 사용자 경험이 확연히 개선되었다. 목차가 본문과 동시에 나타나고, 에러 상황에서도 명확한 피드백을 받을 수 있다.
개발 중에는 '일단 동작하게 만들고 나중에 추가하자'고 생각하기 쉽지만, 에러 처리는 사용자 경험의 핵심이라고 생각한다. 특히 외부 API에 의존하는 서비스일수록 더욱 신경 써야겠다고 느껴졌다. 예상하지 못한 에러가 발생할 수도 있고, 그렇기에 공식 문서를 더 꼼꼼하게 살펴보는 습관을 들여야겠다.
폰트 최적화를 고려하며 Variable 폰트와 개별 파일 중 선택할 때, 각각의 장단점을 분석하고 실제 사용 패턴을 고려해 결정했다. 만약 다양한 굵기를 사용하고 있었거나 다른 상황이었다면 분명 선택이 달라졌을 것이다. 과거 경험에 의존하기보다는 현재 상황에 더 적합한 방향을 선택하는 것이 중요하다고 느꼈다.
단순히 코드 관점에서 문제를 바라보기보다 실제 사용자들이 어떻게 느낄까를 고민하며 시도한 과정들이었다. 이전에는 최적화를 매번 미루고 배제하는 경우가 대다수였지만, 이번 과정을 통해 꼬리를 무는 생각들을 거치며 그 과정이 재밌었다. 앞으로도 더 개선할 부분은 없는지 찾아보고, 더 부드러운 블로그가 되기 위해 계속 도전할 것이다.
