DEVELOPMENT NOTE3

정적 블로그에 백엔드를 둔 이유

검색과 본문 로딩을 먼저 고친 뒤에도 콘텐츠 조회 책임을 분리해야 했던 이유

#architecture#nextjs#performance#content-pipeline#backend
아카이브로 돌아가기

느린 글 한 편에서 시작했다

2026년 5월, 블로그에서 목록 뒤쪽에 있는 글을 열면 유난히 늦게 나타나는 것처럼 느껴졌다. 최악의 경우 체감 시간은 2~3초 정도였다. 정밀한 측정값은 아니었지만 글을 읽는 경로를 조사하기에는 충분한 신호였다.

처음에는 배열 탐색을 의심했다.

ts
export function getPostBySlug(slug: string) {
  return getRenderablePosts().find((post) => post.slug === slug);
}

목록 뒤쪽의 글일수록 .find()가 더 오래 순회하니 체감과 코드가 얼핏 맞아 보였다. Slug map이나 데이터베이스 인덱스를 붙이면 끝날 문제라고 생각했다.

하지만 요청 전체를 따라가 보니 한 건을 찾기 전에 더 큰 비용이 발생하고 있었다. 글 하나를 열 때마다 모든 글의 본문이 검색 준비에 참여하고 있었다.

문제는 .find()가 아니었다

당시 글은 별도 Git 저장소의 Markdown과 MDX로 관리했다. 프런트엔드는 빌드 전에 원본을 읽어 목록과 검색, 본문 렌더링에 사용할 데이터를 만들었다.

text
houkago.posts
  -> generate-posts.mjs
  -> 생성 데이터
  -> Next.js 빌드와 렌더링
  -> Vercel

글이 적을 때는 잘 맞는 구조였다. 별도 API나 데이터베이스 없이도 목록, 카테고리, 태그, 상세 페이지와 SEO를 구성할 수 있었다. Git commit은 글의 이력이 됐고, checkout만 있으면 같은 페이지를 다시 만들 수 있었다.

문제는 전역 검색을 추가하면서 시작됐다. 어느 페이지에서든 검색창을 열 수 있도록 검색 데이터를 RootLayout에서 준비했다. 이 과정에서 모든 공개 글의 body를 읽어 검색용 텍스트를 만들고, 결과를 클라이언트 컴포넌트인 Header에 전달했다.

ts
const searchPosts = getRenderablePosts().map((post) => ({
  slug: post.slug,
  title: post.title,
  searchText: extractSearchText(post.body),
}));

따라서 글 하나를 여는 요청도 다음 경로를 지났다.

text
페이지 요청
  -> RootLayout
  -> 모든 본문에서 검색 텍스트 추출
  -> 전체 검색 데이터를 Header에 전달
  -> 요청한 글 탐색
  -> 선택한 본문만 MDX로 변환

현재 글 수에서 배열을 한 번 순회하는 비용만으로 2~3초 체감을 설명하기는 어려웠다. 반면 검색하지 않는 요청도 모든 본문을 정규식으로 처리하고 그 결과를 클라이언트로 보내고 있었다.

posts-manifest.json도 목록과 SEO에 필요한 메타데이터뿐 아니라 모든 본문을 함께 담고 있었다. 목록, 검색과 본문이 서로 다른 시점에 필요하다는 사실이 데이터 구조에는 반영되지 않았다. .find()는 남겨도 됐지만 우선 고칠 문제는 아니었다.

프런트엔드 비용부터 줄였다

백엔드 API로 바로 옮기기 전에 프런트엔드가 만든 비용부터 세 단계로 나눠 제거했다.

검색 텍스트를 미리 만들었다

첫 번째 변경은 3c2dd1c perf: prebuild blog search index였다.

요청 때마다 실행하던 extractSearchText()scripts/generate-posts.mjs로 옮겼다. Markdown을 읽는 posts:sync 단계에서 검색용 텍스트를 한 번 만들고 .generated/search-index.json에 저장했다.

반복 계산은 사라졌지만 검색 데이터는 여전히 RootLayout이 읽어 Header에 전달했다. 계산 시점만 옮겼을 뿐 검색하지 않는 방문자도 초기 전송 비용을 부담했다.

검색창을 열 때 가져왔다

두 번째 변경은 337611a perf: lazy load blog search index였다.

RootLayoutHeader에서 검색 데이터 prop을 제거하고 /api/search-index를 추가했다. 검색창이 닫혀 있을 때는 아무 데이터도 읽지 않고, 사용자가 처음 열었을 때만 검색 인덱스를 가져왔다.

데스크톱과 모바일 검색창이 같은 데이터를 중복 요청하지 않도록 완료된 결과와 진행 중인 Promise를 공유하는 작은 캐시도 뒀다. 검색을 처음 열 때 네트워크 요청이 한 번 생기지만, 자주 사용하지 않는 기능의 비용을 모든 페이지 요청에 미리 부과하지 않게 됐다.

본문을 manifest에서 분리했다

세 번째 변경은 f6d0edc perf: split post bodies from manifest였다.

text
.generated/posts-manifest.json
  -> 목록, 카테고리, 태그, 경로와 SEO 메타데이터
 
.generated/search-index.json
  -> 검색 데이터
 
.generated/post-bodies/{slug}.json
  -> 글 한 편의 본문

Manifest에서는 body를 제거하고 bodyPath만 남겼다. 상세 페이지는 slug에 맞는 메타데이터를 찾은 뒤 본문 파일 하나만 읽어 MDX로 변환했다.

파일을 나누면서 경로 검증도 추가했다. bodyPath.generated 밖으로 나가지 않는지, 본문 파일 안의 slug가 요청한 slug와 같은지 확인했다. 데이터 분리가 잘못된 파일을 읽는 새로운 문제로 이어지지 않도록 한 것이다.

실제로 달라진 것

2026년 5월 당시 검증에서는 265개의 본문 파일이 생성됐다. 본문을 제거한 manifest는 126,803자, 134,308바이트였고 빌드는 144개 경로를 만들며 성공했다. 이전 manifest는 본문을 포함해 약 787,403자였다.

당시 Vercel의 기존 배포와 개선 배포를 비교한 결과도 다음과 같았다.

text
자체 전송량
  약 13.4 MB -> 약 3.1 MB
 
자체 메인 스레드 시간
  약 378.9 ms -> 약 177.3 ms

서로 다른 배포를 한 시점에 비교한 관찰값이므로 일반적인 성능 보장은 아니다. /blog의 LCP는 양쪽 모두 약 0.3초로 이미 낮았다. 모든 지표가 크게 빨라졌다기보다, 초기 요청에서 불필요한 계산과 전송을 제거한 결과로 해석했다.

이후 요청 경로는 다음처럼 바뀌었다.

  • 페이지 요청마다 모든 본문에서 검색 텍스트를 만들지 않는다.
  • 검색창을 열기 전에는 검색 인덱스를 읽거나 전달하지 않는다.
  • 목록과 SEO 경로는 모든 본문을 포함한 manifest를 읽지 않는다.
  • 상세 페이지는 요청한 slug의 본문 파일 하나만 읽는다.
  • .find()는 남아 있지만 현재 규모의 주 병목으로 취급하지 않는다.

다음 날에는 클라이언트에서만 삽입되던 MDX 본문과 이미지 렌더링도 별도 변경으로 안정화했다. 검색과 본문 데이터 분리, 렌더링 문제를 하나의 원인으로 묶지는 않았다.

남은 문제는 속도가 아니었다

처음 관찰한 성능 문제는 프런트엔드 안에서 상당 부분 해결했다. 대신 생성 파일과 빌드 로직이 늘었고, 검색을 처음 열 때는 별도 요청이 필요해졌다. 상세 페이지가 안전한 본문 경로만 읽는지도 계속 검증해야 했다.

그보다 더 큰 문제는 콘텐츠와 프런트엔드의 생명주기가 계속 묶여 있다는 점이었다.

  • 글 하나를 수정해도 프런트엔드가 원본을 다시 읽고 빌드한 뒤 배포해야 했다.
  • 공개 여부와 메타데이터 검증, 목록·검색·상세 입력 생성이 프런트엔드 빌드에 집중돼 있었다.
  • 생성 파일은 빌드 결과물일 뿐, 콘텐츠를 독립적으로 조회하고 갱신할 서비스 경계는 아니었다.
  • 조회수, 반응과 추천처럼 Git으로 관리하기 어려운 상태를 둘 곳도 없었다.

검색 인덱스를 더 줄이거나 .find()를 map으로 바꿔도 이 문제들은 사라지지 않는다.

백엔드는 다음 책임을 맡았다

백엔드를 도입한 이유는 느린 JavaScript 한 줄을 SQL로 바꾸기 위해서가 아니었다. 실제 백엔드 모델과 구현도 성능 개선 직후가 아니라 6~7월에 별도의 구조 문제로 구체화했다.

프런트엔드는 어떤 데이터를 언제 읽고 전달할지 책임진다. 백엔드는 원본 변경과 서비스 조회 상태의 생명주기를 나누고, 공개 조회와 검증을 한곳에서 적용하며, 프런트엔드 빌드 없이 콘텐츠를 갱신할 경계를 맡는다. 앞으로 동적인 상태를 추가할 자리도 이 경계에 둘 수 있다.

물론 백엔드를 추가하면 동기화, 오래된 상태, 스키마, 배포와 운영이라는 새로운 비용이 생긴다. 개인 블로그의 트래픽만 생각하면 기존 생성 파이프라인을 유지하는 선택도 충분했다. 이 프로젝트에서는 콘텐츠 버전과 프런트엔드 배포를 분리하고, 공개 조회 경계를 실제로 운영해 보는 가치가 그 비용보다 컸다.

정적 블로그가 느리다는 이유만으로 백엔드를 만든 것은 아니다. 먼저 프런트엔드가 스스로 만든 비용을 줄였고, 그 뒤에도 남아 있던 콘텐츠 원본과 서비스 조회의 책임을 나누기 위해 백엔드를 도입했다.