소셜 오브 복숭아.
← 목록으로
FastAPI·NextJS·SSR·SEO·마이그레이션·nginx

Vite SPA에서 Next.js SSR로 전면 전환

Ethan· 2026. 9. 3.· 6분 읽기· 조회 5· 댓글 0

오늘의 목표와 맥락

프론트엔드를 Vite + React SPA에서 Next.js 16(App Router) SSR로 전면 전환했다. 하루 안에 범위 산정 → 구현 → 운영 배포까지 완료했다.

기존 구조는 SPA의 SEO 한계를 백엔드 프리렌더 계층으로 메꾸고 있었다. FastAPI가 빌드 산출물 index.html을 읽어 SEO 마커 블록을 글별 OG/JSON-LD/본문으로 치환하는 방식(약 700줄 + 테스트 3본)인데, 화면과 프리렌더가 서로 다른 곳에서 만들어지다 보니 "마운트 직후 링크가 사라지지 않게 프론트도 같은 링크를 유지해야 한다" 같은 암묵적 동기화 규칙이 계속 늘었다. SSR 전환의 목표는 SEO HTML 생성 책임을 실제 화면 컴포넌트와 같은 곳으로 모으는 것이다.

사내 공통 아키텍처가 Vite + React 19로 스택을 고정하고 있어, 이탈 사유를 CLAUDE.md 고유 결정에 기록하고 시작했다. 진행 방식은 PR 분할 없이 단일 브랜치(feat/nextjs-ssr-migration), 렌더링은 SSG/ISR 없이 순수 SSR(새 글이 배포·재생성을 기다리지 않도록)로 확정했다. 상세 계획은 docs/nextjs-ssr-migration-scope.md 참조.

구현한 내용

Phase 0~5를 커밋 11개로 나눠 진행했다.

  • 스캐폴드: Vite 설정·index.html을 걷어내고 Next.js 16 + Tailwind 4(PostCSS) 구조로 교체. 구 소스는 legacy-src/로 옮겨 참조하며 포팅하고 완료 후 삭제했다. 테마 FOUC 방지 스크립트·GA4·파비콘·RSS 링크 등 index.html의 공통 head는 루트 레이아웃으로 승계했다.
  • 공개 페이지 SSR: 홈·태그·태그별 목록·게시글 상세·소개를 서버 컴포넌트 + generateMetadata로 재작성. 서버가 백엔드 공개 API를 no-store로 조회해 본문·내부 링크·OG·JSON-LD를 첫 HTML에 싣는다. SEO 규칙은 구 프리렌더 테스트의 기대값을 명세 삼아 src/lib/seo.ts 한 파일로 이식했다(og:title에 사이트명 미포함, description 160자 클램프, KST +09:00 표기 등).
  • 관리자·인증 이식: /login·/auth/callback·/admin 8화면은 클라이언트 컴포넌트로 옮기고 라우팅만 App Router로 치환했다. axios + React Query + Zustand 계층은 그대로 재사용.
  • 백엔드 정리: 프리렌더 라우터·서비스·테스트를 삭제하고, sitemap/rss가 쓰던 경로·설명 헬퍼만 seo_service.py로 추출해 남겼다. sitemap/robots/rss는 계속 FastAPI가 SSOT다.
  • 배포 전환: nginx location /를 정적 dist 서빙 → Next 서버(127.0.0.1:3000) 프록시로 바꾸고 systemd 유닛 social_of_peach_web을 추가했다.

설계와 판단

데이터는 서버에서, 상호작용은 클라이언트에서. 공개 페이지는 서버 컴포넌트가 데이터를 조회해 props로 내려주고, 스크롤 진행률·목차·댓글 같은 상호작용만 클라이언트 컴포넌트로 남겼다. 클라이언트 컴포넌트도 첫 HTML은 서버에서 렌더되므로 크롤러 관점의 완전성이 유지되면서 기존 컴포넌트를 최대한 재사용할 수 있었다.

프리렌더 테스트를 이식 명세로 사용. OG/JSON-LD 필드·포맷은 새로 설계하지 않고 test_prerender*.py가 검증하던 출력(headline 110자 클램프, keywords, mainEntityOfPage, 마이크로초 제거 등)을 TS로 그대로 옮겼다. SEO 회귀 위험을 명세 대조 문제로 바꾼 판단이다.

전환으로 없앤 우회 코드: react-helmet과 서버판 canonical 중복을 걷어내는 data-ssr-seo 정리 로직, soft-404 방지용 200+noindex(이제 진짜 HTTP 404), 페이지네이션 버튼(크롤러가 따라갈 수 있는 링크로 교체).

배포는 기존 워크플로 유지. deploy.sh에 멱등 전환 단계를 넣어, 구 서버에서도 평소처럼 git pull + bash deploy.sh 한 번이면 frontend/.env 생성 → web 유닛 등록 → nginx location / 교체(nginx -t 실패 시 자동 롤백)까지 처리되게 했다. 빌드 중 서비스 공백은 구 dist.new 방식을 승계해 NEXT_DIST_DIR=.next.new 스테이징 빌드 후 mv 스왑으로 줄였다.

문제와 해결

  • RSS 자동발견 링크 누락: Next의 Metadata.alternates는 top-level 병합이라, 페이지가 canonical을 선언하는 순간 루트 레이아웃의 application/rss+xml 항목이 통째로 사라졌다. 메타 빌더에서 canonical과 함께 선언하도록 수정. SEO 분석 단계에서 curl 대조로 잡았다.
  • hydration mismatch 경고: 원인은 우리 코드가 아니라 브라우저 확장이 주입한 bis_skin_checked 속성이었다. 시크릿 창에서 재현되지 않음을 확인하고 종료. 다만 실제 위험이었던 localStorage 기반 인증 UI는 마운트 게이트로 서버/클라이언트 첫 렌더를 일치시켰다.
  • 로컬 GitHub 로그인 실패: OAuth 앱에 등록된 콜백(localhost:8000)과 로컬 백엔드 포트(8001)가 달라 콜백이 죽은 포트로 떨어졌다. 백엔드를 8000으로 옮겨 해결 — 전환과 무관한 로컬 포트 불일치였다.

검증한 내용

  • 백엔드 pytest 101개 전부 통과, 프론트 next build + ESLint 클린.
  • 로컬에서 크롤러 관점(curl) 대조: 전 공개 페이지의 title/canonical/OG/JSON-LD/robots, 404 상태 코드, /login·/admin noindex, sitemap <loc>과 SSR canonical 일치.
  • 실사용 흐름: GitHub 로그인 → 관리자 글 작성 → 작성 직후 빌드 없이 홈·상세·사이트맵에 즉시 반영(SSR 선택의 핵심 이점) 확인.
  • 운영 배포 후 실도메인 검증: 한글 slug 글의 canonical 퍼센트 인코딩(로컬에 한글 slug 데이터가 없어 마지막까지 미검증이던 항목), article:published_time +09:00, SEO 루트 라우트 200, 파비콘 7일 캐시.
이 글 공유하기

관련 글

댓글

아직 댓글이 없습니다.

    댓글을 작성하려면 GitHub로 로그인하세요.