DEVELOPMENT NOTE3

프런트엔드에서 posts checkout을 제거한 과정

상세와 검색, 분류, Sitemap을 백엔드로 옮기고 로컬 생성 계층을 제거한 과정

#architecture#nextjs#backend-api#mdx#migration#content-pipeline
아카이브로 돌아가기

목록만 백엔드를 사용하고 있었다

/blog 목록과 pagination은 백엔드 API를 읽었지만 상세 본문은 생성된 body JSON을 사용했다. Category와 Tag는 local manifest를 filter했고 검색과 Sitemap도 build 결과에 의존했다. 공개 자산을 별도 snapshot으로 분리한 뒤에도 프런트엔드에서 houkago.posts checkout을 지울 수 없었던 이유다.

Generator부터 삭제하면 checkout 의존성이 사라지는 것처럼 보이지만 실제 consumer가 먼저 깨진다. 순서를 다음처럼 정했다.

  1. 생성 파일을 읽는 consumer를 찾는다.
  2. 필요한 백엔드 contract만 확장한다.
  3. Server-only client에서 응답을 검증하고 프런트엔드 모델로 바꾼다.
  4. Route와 UI의 입력을 새 loader로 교체한다.
  5. Build-time 콘텐츠 열거를 제거한다.
  6. Consumer가 모두 사라진 뒤 generator를 삭제한다.

Local fallback은 두지 않았다. 백엔드에서 비공개나 삭제 상태가 된 글이 오래된 manifest에는 남을 수 있기 때문이다. 정상적인 상세 404와 페이지 범위 초과만 notFound()로 연결하고, network와 5xx, 잘못된 JSON, contract 오류는 그대로 드러냈다.

상세 본문과 자산 경로를 함께 옮겼다

상세 API에는 기존 공개 메타데이터와 함께 rawBody, assetBaseUrl을 추가했다. rawBody는 MySQL 조회 모델의 Markdown·MDX 원문이고, assetBaseUrl은 설정된 공개 origin과 /assets/posts/{slug}/로 백엔드가 계산한다. HTML 렌더링은 백엔드로 옮기지 않았다.

text
GET /api/posts/{slug}
  -> runtime contract 검증
  -> FrontendPostDetail 변환
  -> rawBody + assetBaseUrl
  -> 기존 MDX serializer
  -> 상세 화면

Page와 generateMetadata()는 같은 memoized server-only loader를 사용한다. React cache는 요청 안에서 함수를 재사용할 뿐 영구 콘텐츠 cache가 아니다. 공개 상세가 없을 때만 loader가 null을 반환하고 route가 404가 된다.

기존 next-mdx-remote와 GFM, 수식, syntax highlighting, 제한된 custom component는 유지했다. Markdown AST의 image와 link를 순회해 ./assets/...assetBaseUrl 아래로 바꿨다. Absolute URL과 root-relative URL, anchor는 그대로 뒀다. Base URL은 HTTP(S)와 canonical path인지 확인하고 .., encoded slash, backslash와 control character가 있는 자산 경로는 거부했다.

운영 설정이 localhost를 노출했다

전환 전 운영 gate에서 assetBaseUrl이 HTTPS origin 대신 http://localhost:8080을 반환했다. 새 image에는 올바른 contract가 있었지만 운영 host의 Compose descriptor가 이전 revision이라 HOUKAGO_PUBLIC_ASSET_ORIGIN을 container에 전달하지 않았다.

text
새 백엔드 image + 이전 Compose
  -> public origin mapping 누락
  -> local 기본값 노출

이미지와 MySQL은 바꾸지 않았다. 기존 Compose와 실행 환경 파일을 root 전용으로 백업하고 clean checkout을 origin/main으로 FF-only 갱신했다. App과 sync가 같은 digest를 보고 public origin은 app에만 전달되는지 확인한 뒤 current digest로 app만 다시 만들었다. MySQL ID와 restart count, health는 유지됐다.

복구 뒤 trailing slash가 있는 HTTPS assetBaseUrl, 공개 자산의 200, draft와 unknown 상세의 404를 확인했다. 이 문제는 애플리케이션 이미지와 호스트 실행 설정이 서로 다른 생명주기를 가진다는 사실을 보여 줬다.

분류와 정렬의 권한도 백엔드로 옮겼다

Category와 Tag는 기존 public page API에 optional filter를 추가했다. Category는 exact filter와 백엔드의 content, totalElements, totalPages를 사용한다. Category Highlights는 네 category마다 page 0, size 3을 요청하고 빈 결과는 화면에서 제외한다.

Tag는 JSON array의 exact membership으로 조회한다. Blank나 잘못된 route segment는 요청 전에 거부하고, unknown tag와 범위를 벗어난 page는 기존 UI처럼 404로 유지했다. 프런트엔드에서 전체 결과를 다시 filter나 slice하지 않았다.

Build 때 category와 tag, page를 열거하던 generateStaticParams()도 제거했다. 전후 build의 static route는 123개에서 10개로 줄었다. 기능을 삭제한 것이 아니라 콘텐츠 수에 묶인 사전 생성을 없앤 결과다.

같은 날짜의 순서도 달랐다. Local generator는 date DESC, slug ASC, 백엔드는 postDate DESC, id DESC였다. 프런트엔드에서 다시 정렬하면 pagination 경계와 권한이 갈리므로 백엔드 순서를 채택하고 page별 slug 배열과 순서를 비교했다.

검색은 전체 index 대신 작은 응답을 받았다

기존 검색은 258개 글의 687,611바이트 index를 처음 사용할 때 내려받아 browser에서 처리했다. 백엔드 page query에 q를 추가해 title과 description, rawBody의 정규화된 literal substring을 찾게 했다. 별도 검색 engine이나 ranking은 만들지 않았다.

text
Browser
  -> same-origin /api/search?q=...
  -> Next.js Route Handler
  -> Spring /api/posts?q=...&page=0&size=20
  -> 검증된 summary

Dialog는 입력 뒤 300ms를 기다리고 이전 요청을 취소한다. Route Handler와 browser fetch는 no-store이며 browser에는 rawBody를 보내지 않는다. 백엔드 totalElements와 최신 20개 summary만 전달한다. 전체 파일은 사라졌지만 검색은 network와 백엔드 latency에 의존하고 literal substring 기능만 제공한다.

Sitemap도 요청할 때 API를 사용했다

정적 Sitemap이 남으면 build artifact가 별도의 공개 글 목록을 소유한다. App Router metadata route가 public list API를 size 50으로 순회하게 바꿨다. 첫 page의 totalPages, totalElements가 이후 page와 같은지 확인하고 중복 slug와 잘못된 날짜도 contract 오류로 처리한다. lastModifiedupdated가 있으면 사용하고 없으면 postDate를 사용한다.

당시 운영 smoke에서는 정적 URL 3개와 공개 글 URL 258개, 총 261개를 확인했다. 특정 revision의 관찰값이다. 백엔드가 실패해도 이전 XML로 돌아가지 않았다.

마지막 consumer 뒤에 generator를 삭제했다

상세와 분류, 검색, Sitemap을 모두 옮긴 뒤 manifest와 body JSON, search index, 복사된 asset을 읽는 consumer가 없는지 다시 검색했다. 그때 다음 항목을 제거했다.

  • 641줄의 scripts/generate-posts.mjs
  • Manifest reader와 local content type
  • posts:sync, predev hook
  • Generated body와 asset 관련 ignore rule
  • POSTS_REPO_PATH, POSTS_INCLUDE_DRAFTS를 전제로 한 일반 workflow

해당 commit은 765줄을 삭제하고 12줄을 바꿨다. 숫자 자체보다 consumer가 0이 된 뒤 producer를 지웠다는 순서가 중요했다. houkago.posts는 계속 원본이며, 프런트엔드만 직접 parser와 public copy의 consumer에서 백엔드 조회 모델의 consumer로 바뀌었다.

Fresh clone으로 숨은 의존성을 확인했다

Sibling repository와 이전 생성 파일이 없는 houkago.blog fresh clone에서 검증했다.

text
houkago.posts 없음
POSTS_REPO_PATH 없음
POSTS_INCLUDE_DRAFTS 없음
generated content 없음

이 환경에서 npm ci, 22 suites의 220 tests, lint와 build를 통과했다. 백엔드 URL을 도달할 수 없는 주소로 둔 build도 성공해 build-time 응답 의존성이 없음을 확인했다. 같은 결과물의 runtime에서는 실제 백엔드로 목록과 분류, 상세, 검색, Sitemap을 smoke했고 생성 파일이 다시 생기지 않는지도 확인했다.

빌드가 백엔드 없이 성공한다는 사실은 실행 시 요청까지 항상 성공한다는 뜻은 아니다. 전환 뒤 캐시되지 않은 경로와 MDX 직렬화, 검색은 백엔드와 네트워크에 의존하며 오류도 로컬 대체 경로 뒤에 숨지 않는다.

Application과 build의 checkout 의존성은 사라졌지만 당시 posts push가 프런트엔드 배포를 시작하는 workflow는 아직 남아 있었다. 코드 의존성을 제거한 것과 두 배포 생명주기를 분리하는 것은 별도의 작업이었다.