Next.js 사이트맵은 최신 100편만 알고 있었습니다 — 페이지네이션과 ISR 재검증

정기창·

Next.js로 운영하는 블로그의 사이트맵을 열어 봤습니다. 발행한 글보다 훨씬 적은, 최신 100편만 들어 있었습니다. 에러도 경고도 없었습니다.

같은 점검에서 ISR 재검증 쪽 문제가 하나 더 나왔습니다. 글을 고쳐도 공개 페이지와 사이트맵이 제때 바뀌지 않았는데, 저장할 때마다 불려야 할 온디맨드 재검증이 여러 경로에서 조용히 빠져 있었습니다. 하나는 페이지네이션, 하나는 캐시 무효화의 문제였지만, 둘 다 에러 없이 틀려 있었다는 점은 같았습니다.

이 블로그는 Next.js 16 App Router와 NestJS 백엔드로 되어 있습니다. 같은 점검에서 나온 Soft 404나 robots, 구조화 데이터 이야기는 다른 글에서 다룹니다.

Next.js 사이트맵은 최신 100편만 알고 있었습니다

사이트맵은 sitemap.ts가 목록 함수로 백엔드에서 글을 받아 만듭니다. 카테고리와 태그 페이지도 같은 목록 함수를 쓰고 있어서, 세 곳이 모두 최신 100편만 알고 있었습니다. 발행한 글의 3분의 2 가까이가 사이트맵에서도, 카테고리·태그 목록에서도 빠져 있었습니다.

원인은 단순했습니다. 목록 API를 한 페이지 100편으로 딱 한 번만 불렀습니다. API는 총 페이지 수를 함께 돌려주는 페이지네이션을 이미 제공하고 있었는데, 첫 페이지만 쓰고 있었습니다. 글이 100편보다 적을 때는 첫 페이지가 곧 전부였으니 드러날 일이 없었고, 100편을 넘긴 뒤부터 조용히 잘리기 시작했습니다.

돌이켜 생각해보면 limit은 한 페이지의 크기일 뿐인데, 한 번만 부르는 순간 그 숫자가 전체의 상한이 됩니다. 잘린 목록도 목록이라서 겉보기에는 이상한 데가 없었습니다. 사이트맵은 Search Console에 제출하는 방법까지 따로 정리해 둘 만큼 신경 쓰던 파일인데, 정작 그 안이 잘려 있었습니다.

페이지네이션을 끝 페이지까지 순회하고, slug로 중복을 거릅니다

목록 함수를 총 페이지 수를 따라 마지막 페이지까지 순회하도록 바꿨습니다. 다만 하나를 더 챙겨야 했습니다. 순회하는 사이에 새 글이 발행되면 오프셋이 밀리면서 같은 글이 두 페이지에 걸쳐 나올 수 있어서, slug로 중복을 걸러냅니다. 끝나지 않는 루프가 되지 않도록 페이지 수에는 상한을 뒀습니다.

const MAX_PAGES = 50; // 무한 루프를 막는 상한 (예시 값)

async function fetchAllPosts(): Promise<Post[]> {
  const bySlug = new Map<string, Post>();

  for (let page = 1; page <= MAX_PAGES; page++) {
    const { posts, totalPages } = await fetchPostPage({ page, limit: 100 });
    // 순회 중 새 글이 발행되면 오프셋이 밀려 같은 글이 두 페이지에 걸칠 수 있다
    for (const post of posts) bySlug.set(post.slug, post);
    if (page >= totalPages) break;
  }
  return [...bySlug.values()];
}

곁가지로, 같은 글이 두 slug로 발행돼 있던 중복 콘텐츠도 하나 있어서 더 많이 참조되던 쪽으로 영구 리디렉션했습니다. Next.js 설정의 영구 리디렉션은 308을 쓰고, 검색엔진은 이를 301과 같은 영구 이동으로 취급합니다.

페이지네이션을 고친 뒤, 빌드 로그가 아니라 지금 나가는 응답으로 셉니다

고친 뒤에는 운영 사이트맵의 URL 수와 글 수, 중복 수를 직접 세어 발행 글 수와 대조했습니다. 사이트맵의 글 수는 발행 글 수와 같았고, 중복은 0이었습니다. 기준은 빌드 당시 로그에 찍힌 수치가 아니라 지금 운영에서 나가는 응답으로 잡았습니다. 검색엔진이 받아 가는 것도 그 응답이기 때문입니다.

ISR 온디맨드 재검증은 여러 경로에서 조용히 빠져 있었습니다

두 번째 문제는 캐시였습니다. 이 블로그는 공개 페이지를 ISR로 만들고, 평소에는 1시간 주기로 다시 만듭니다. 여기에 더해 백엔드가 글을 저장·발행·삭제하면 Next.js 쪽 재검증 API를 호출하고, 그 API가 revalidatePath로 해당 글과 홈의 캐시를 무효화합니다. 이 구조를 고른 배경은 Next.js ISR을 선택한 이유에 적어 두었습니다.

이 구조에서는 온디맨드 재검증이 실패해도 주기가 지나면 결국 새 내용이 보입니다. 그래서 고장은 에러가 아니라 '조금 늦다'는 정도로만 보였습니다. 안전망이 고장까지 가려 주고 있었던 것입니다. 점검해 보니 빠진 곳은 한두 군데가 아니었습니다.

빠져 있던 것 그래서 생긴 일
웹 주소를 담은 환경변수가 운영에 없음 기본값 localhost로 호출 → 조용히 실패하고 로그에만 남음
예약 발행 경로가 다른 이름의 환경변수를 읽음 같은 일을 하는 경로끼리 설정이 어긋남
제안 승인·되돌리기 경로에 따로 있던 재검증 복사본 복사본마다 조건이 조금씩 다름
slug를 바꿔도 이전 주소는 재검증하지 않음 이전 주소의 캐시가 그대로 남음
재검증이 임베딩 갱신 같은 무관한 작업 뒤에 있음 그 작업이 실패하면 재검증을 건너뜀
재검증 대상에 사이트맵이 없음 글을 고쳐도 사이트맵이 제때 바뀌지 않음
재검증 요청에 타임아웃이 없음 요청이 늘어져도 끊을 기준이 없음

하나씩 보면 사소했지만, 모아 놓고 보니 두 가지가 눈에 걸렸습니다. 하나는 기본값입니다. 웹 주소가 비어 있어도 localhost로 대신 호출하니 코드는 멈추지 않았고, 멈추지 않으니 실패는 로그 속에만 쌓였습니다. 문제를 일찍, 크게 드러내라는 fail-fast 원칙과는 정반대로 움직이고 있었습니다.

다른 하나는 복사본입니다. 『실용주의 프로그래머』의 DRY 원칙은 코드를 반복하지 말라는 말이라기보다, 시스템 안의 모든 지식이 권위 있는 표현을 단 하나만 가져야 한다는 이야기입니다. '글이 바뀌면 어떤 캐시를 지워야 하는가'라는 지식이 여러 경로에 흩어져 있었고, 흩어진 것들은 조용히 서로 달라져 있었습니다.

재검증을 한 함수로 모으고, 저장 직후로 옮겼습니다

재검증을 공통 함수 하나로 모았습니다. 이전 slug와 새 slug를 함께 재검증하되 둘이 같으면 한 번만 호출하고, 대상에는 글과 홈에 더해 사이트맵을 넣었습니다.

요청에는 타임아웃을 두고, 주소 끝의 슬래시도 정규화했습니다. 생성·수정·삭제·예약 발행·제안 승인·되돌리기까지 모든 경로가 이제 이 함수 하나를 씁니다. 빠져 있던 환경변수는 운영에 채워 넣었습니다.

호출하는 자리도 옮겼습니다. 저장이 성공한 직후, 임베딩 갱신 같은 무관한 후속 작업보다 먼저 부릅니다. 뒤의 작업이 실패하더라도 재검증은 이미 끝나 있게 하려는 것입니다.

저장 직후에 확인해야 했던 이유 — ISR의 주기적 재생성이라는 교란 변수

확인은 관리자 화면에서 글 하나를 실제로 저장해 보는 것으로 했습니다. 백엔드 로그에서 글·홈·사이트맵 재검증이 성공한 것을 봤고, 공개 HTML의 수정 시각(dateModified)과 사이트맵의 lastmod가 방금 저장한 시각과 같아진 것을 확인했습니다. 저장 전에는 캐시 HIT 상태였던 응답이 저장 뒤에는 MISS로 바뀌었습니다. 그사이 재검증을 손으로 호출하지는 않았습니다.

여기에는 함정이 하나 있습니다. ISR 주기가 지난 뒤에 확인하면, 온디맨드 재검증이 전혀 동작하지 않았더라도 주기적 재생성만으로 최신 내용이 보입니다. 보려는 원인과 같은 결과를 내는 다른 원인이 끼어 있는 것, 실험 설계에서 말하는 교란 변수가 이런 것입니다.

그래서 확인은 저장 직후, 주기 안에서 해야 합니다. 그 안에서 손으로 부른 재검증까지 없다면, 새 내용이 보일 이유는 저장이 일으킨 재검증 하나뿐입니다.

이 함정은 머릿속 가정이 아니었습니다. 작업을 마치고 몇 시간이 지나 다시 확인했을 때, 실제로 이 구분이 불가능했습니다. 재검증이 고장 나 있었더라도 똑같이 통과했을 확인이었습니다. 실패할 수 없는 확인은 아무것도 증명하지 못한다는 생각이 들었습니다.

에러가 없다는 것은 잘 동작한다는 뜻이 아니었습니다

두 문제 모두 에러 없이 틀려 있었습니다. 하나는 세어 봐야, 다른 하나는 저장 직후에 봐야 드러났습니다. 에러가 나기를 기다리는 방식으로는 둘 다 찾지 못했을 것입니다.

그래서 남긴 결론은 두 가지입니다. 페이지네이션이 있는 목록 API에서 limit은 조용한 상한이 되니, 전체가 필요한 곳은 끝까지 순회하고 결과 개수를 원본 개수와 대조합니다. 그리고 같은 역할을 하는 함수의 복사본은 설정 불일치를 만드니, 캐시 무효화는 한 함수로 모아 저장 직후에 두고 무관한 작업의 실패에 묶지 않습니다.

사이트맵이 모든 글을 담게 됐다고 해서 검색 결과가 달라졌다고 말하기는 이릅니다. 빠져 있던 글들이 실제로 색인되는지는 아직 알 수 없습니다. Search Console에서 '접수됐다'와 '완료됐다'를 구분해 읽는 이야기는 다른 글에서 다룹니다.

Next.jsISR사이트맵페이지네이션캐시SEO