Testlify의 마케팅 사이트는 11,000개가 넘는 페이지를 갖고 있다. 기존 WordPress 구성은 모든 요청마다 서버 사이드 렌더링 오버헤드와 플러그인 병목을 발생시켜 페이지 로드 속도와 검색 엔진 순위에 영향을 미쳤다. 나는 사이트를 Next.js 16으로 마이그레이션하는 아키텍처 결정을 주도했고, 현재도 롤아웃이 진행 중이다.
아키텍처 결정
- WordPress 대신 캐시 우선: 페이지는 첫 요청에서 한 번 렌더링되고 무기한 캐시되며, 매 요청마다 데이터베이스를 조회하거나 11,000개 이상 페이지에 대해 몇 시간짜리 일괄 재빌드를 요구하지 않는다. Payload CMS 웹훅이 콘텐츠 변경이 실제로 영향을 미치는 페이지만 무효화한다.
- 헤드리스 CMS 통합: 콘텐츠는 Postgres 기반의 셀프 호스팅 헤드리스 CMS인 Payload CMS를 통해 관리되며, 캐시 무효화 훅이 각 변경을 자동으로 감지하는 동안 마케팅 팀이 페이지를 편집하고 게시할 수 있다.
- 대규모 마이그레이션: 11,000개 이상 페이지의 단계적 마이그레이션을 계획하고 실행했으며, 프로덕션의 실제 사이트맵과 대조해 페이지 단위로 검증하여 모든 카테고리에서 URL 구조, 캐노니컬 태그, 기존 SEO 권위를 보존했다.
왜 중요했는가
11,000개 이상의 페이지를 가진 사이트를 WordPress에서 캐시 우선 아키텍처로 옮기면 페이지 로드 시간이 줄고, 기술적 SEO 성능이 개선되며, 마케팅 팀이 엔지니어링 배포에 의존하지 않고 업데이트를 게시할 수 있다.
여전히 진행 중인 오너십
나는 WordPress에서 벗어나는 아키텍처 결정을 주도했다. 이것은 혼자 만든 것이 아니다: 2026년 7월 이후 약 15-20명의 기여자로부터 2,500건 이상의 커밋이 있었고, 이번 달까지도 활발히 활동 중이며, 마지막 상태 확인 시점에 프로덕션으로의 DNS 전환만이 남은 유일한 단계였다.
이것이 전형적인 Jamstack이 아닌 이유
11,000개 이상의 페이지에 대한 뻔한 접근법은 빌드 타임 정적 생성이다. 여기에는 맞지 않는다: 모든 로케일과 콘텐츠 타입에 걸친 전체 재빌드는 몇 시간이 걸릴 것이고, 콘텐츠 하나를 편집했다고 사이트 전체를 재빌드해서 배포해야 해서는 안 된다. 대신, 모든 동적 라우트(category, author, hr-tools, hr-guide, blog, test-library 등)는 동일한 규칙을 따른다: 빌드는 라우트 타입당 정확히 하나의 샘플 페이지만 사전 렌더링하는데, Cache Components가 빌드하려면 최소 하나의 파라미터가 필요하고 빌드 자체는 데이터베이스 쿼리를 전혀 실행하지 않아야 하기 때문이다. 해당 라우트 아래의 다른 모든 페이지는 첫 실제 요청 시 Next.js 16의 Cache Components 아래에서 렌더링된 다음, Payload CMS의 afterChange 훅이 해당 특정 페이지에 대해 서명된 재검증 엔드포인트를 호출할 때까지 무기한 캐시된 상태로 유지된다(cacheLife는 Next 자체의 INFINITE_CACHE 상수로 설정되어 있다). 콘텐츠 변경은 전체 재빌드 주기가 아니라 몇 초 만에 전파되며, 캐시는 방문자 입장에서는 여전히 CDN 엣지 캐시처럼 동작한다. 재검증 엔드포인트 자체는 HMAC으로 서명되고 상수 시간 비교로 검증되며, 한 번에 하나의 태그만 무효화하도록 의도적으로 제한되어 있다: 별도로 수동 트리거되는 관리자 경로만이 컬렉션 전체 태그를 퍼지할 수 있어서, 유출된 웹훅 시크릿은 최악의 경우 페이지 하나만 무효화할 수 있을 뿐, 사이트 전체 캐시를 한 번에 스탬피드시킬 수는 없다.
프로덕션에서 발견되어 근본에서 수정된 두 개의 프레임워크 버그
빌드 중 두 가지 별개의 Next.js 16 문제가 나타났고, 둘 다 이제 next.config.ts에 직접 문서화되어 다음 담당자가 힘들게 다시 발견하지 않도록 했다. 첫째, cacheComponents가 기본적으로 enablePrerenderSourceMaps를 조용히 활성화하는데, 이것이 정적 페이지 생성 중 프로덕션 빌드를 메모리 부족으로 죽이고 있었다. Next 자체의 소스까지 추적한 뒤 명시적인 오버라이드로 수정했다. 둘째, 캐시 수명에 Infinity를 사용하면 Next의 빌드 워커로 가는 도중 JSON으로 직렬화되며 null이 되어버리는데, 이는 에러를 내는 대신 해당 라우트를 조용히 캐시되지 않는 상태로 강등시켜, 발견되기 전까지 모든 로케일에서 프리렌더링을 깨뜨렸다. 원시 Infinity 대신 Next 자체의 INFINITE_CACHE 상수를 사용해 수정했다.
스트리밍 엣지 케이스를 위한 SEO 수정
Cache Components의 스트리밍 아래에서, Next의 자동 notFound()는 페이지 셸이 이미 클라이언트로 스트리밍되기 시작한 뒤에는 404 HTTP 상태를 커밋할 수 없어서, 그 셸만 읽는 크롤러는 이를 절대 보지 못한다. 이 수정은 첫 번째 플러시에 수동으로 noindex 지시문을 주입해, 셸에서 멈춘 크롤러도 페이지의 나머지 부분이 리졸브되기 전에 올바른 신호를 받도록 한다.
데이터 모델과 규모
- 11,000개 이상의 페이지는 하나의 평평한 콘텐츠 소스가 아니라,
next-intl을 통해 지원되는 모든 로케일과 교차하는 여러 콘텐츠 타입(category, author, hr-tools, hr-guide, leadership, test-library)에서 나온다. - 모든 프로덕션 WordPress URL은 전용 감사 문서(
docs/audits/url-parity-with-production.md)에서 이 프로젝트의 출력과 대조 검증된다: WordPress가 오늘 서빙하는 URL을 이 프로젝트가 404로 처리한다면, 그것은 허용 가능한 격차가 아니라 잃어버린 트래픽이다. - 단위 테스트(Vitest)가 사이트맵 생성, 카탈로그 시드 폴백, 관련 콘텐츠 로테이션을 커버하며, Playwright가 엔드투엔드 흐름을 커버한다.
Build vs. Buy
SaaS 헤드리스 CMS 대신 Postgres 위의 셀프 호스팅 Payload CMS: 마케팅 사이트를 위해 좌석당 SaaS 비용을 지불하는 대신 콘텐츠 소유권과 데이터베이스를 사내에 유지한다. 직접 만든 CDN/ISR 계층 대신 Next.js 자체의 Cache Components와 태그 기반 무효화: 캐싱 동작은 이 팀이 직접 구축하고 운영해야 했을 인프라가 아니라 프레임워크에서 나온다.
Stack
Next.js 16, React, TypeScript, Postgres 위의 Payload CMS, 로컬라이제이션을 위한 next-intl, 단위 테스트를 위한 Vitest, 엔드투엔드 테스트를 위한 Playwright, Vercel에 배포.