
글의 주제를 설명하려고 만든 일러스트예요. 실제 제품 화면이나 정확한 구성도는 아니에요.
회사와 서비스 이름, 내부 식별자는 가명으로 바꿨어요.
사이트맵에 지점과 상품, 분류 검색, 공지와 콘텐츠 상세 URL을 넣어야 했어요. 주소를 모으려면 여러 API를 조회해야 하는데, 크롤러가 올 때마다 같은 목록을 처음부터 만들고 싶지는 않았어요. 그렇다고 빌드할 때만 만들면 새 상품이 다음 배포까지 사이트맵에 나오지 않아요.
그래서 결과를 캐시하고 일정 시간이 지나면 다시 만드는 쪽을 골랐어요. Next.js 16의 Cache Components를 켜고 'use cache'와 cacheLife를 사용했어요. 그런데 그 안에서 공개 API를 호출하자, 인증이 필요 없는 조회까지 공용 인터셉터가 쿠키를 읽고 있었어요.
사이트맵에 캐시 경계 두기
import type { MetadataRoute } from 'next';
import { cacheLife, cacheTag } from 'next/cache';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
'use cache';
cacheLife('days');
cacheTag('public-index');
const [locationIds, items, announcementIds, categoryPaths, filterPaths] = await Promise.all([
getAllLocationIds(),
getAllItems(),
getAllAnnouncementIds(),
getAllCategoryPaths(),
loadFilterPaths(),
]);
// 항목 변환 코드는 생략
return [
...toLocationEntries(locationIds),
...toItemEntries(items),
...toAnnouncementEntries(announcementIds),
...toCategoryEntries(categoryPaths),
...toFilterEntries(filterPaths),
];
}지점과 상품 등은 서로의 조회 결과를 기다릴 필요가 없어서 Promise.all로 함께 불러와요. cacheLife에는 days 프로필을 지정했어요.
이 프로필은 정해진 시각에 사이트맵을 만드는 예약 작업은 아니에요. stale 5분 동안 클라이언트 라우터 캐시가 서버 확인 없이 결과를 재사용하고, revalidate 1일이 지난 뒤 요청이 오면 서버가 백그라운드에서 다시 만들어요. expire는 1주예요. 새 결과를 만드는 계기는 어디까지나 요청이에요.
sitemap.ts는 요청 시점 API나 동적 설정을 쓰지 않으면 원래 캐시되는 특수 라우트 핸들러(Route Handler)예요. 여기서는 Cache Components 안에서 캐시할 함수를 명시하고 days 프로필을 고르려고 'use cache'를 썼어요.
여러 요청이 같은 결과를 쓰려면 캐시를 저장하는 곳도 같아야 해요. 자체 호스팅의 기본 런타임 캐시는 프로세스별 메모리이고, 서버리스에서는 요청 사이에 남지 않을 수 있어요. 인스턴스를 나눠 실행한다면 플랫폼의 영속 캐시나 공유 cacheHandler를 함께 봐야 해요.
예제의 public-index는 실제 사이트맵 태그를 바꾼 이름이에요. 다만 코드에는 이 태그를 넘기는 revalidateTag나 updateTag 호출이 없어요. 태그를 붙였다는 이유만으로 상품 변경 직후 사이트맵이 무효화되지는 않고, 지금은 시간에 따른 재검증을 사용해요.
공개 조회인데 인터셉터가 쿠키를 읽었어요
기존 Axios 인터셉터는 요청이 공개인지와 관계없이 액세스 토큰을 찾았어요. 서버에서는 cookies()를 읽으니 요청별 값에 의존하게 됐고, 사이트맵의 공개 캐시 안에서도 그 동작이 따라왔어요. 이 경로에서 토큰을 읽지 않게 바꿔야 했어요.
쿠키만 뺀다고 어떤 응답이든 같은 캐시에 넣을 수 있는 건 아니에요. 테넌트나 Host, 로케일, 미리보기 헤더로 결과가 달라진다면 그 값도 인자로 받아 캐시 키에 들어가야 해요. 공개 조회에 비공개 데이터가 섞이는 문제도 토큰 읽기와 별도로 확인할 부분이에요.
처음에는 서버에서 토큰을 아예 읽지 않게 했어요. 이후 회원별 혜택 적용가를 서버에서 미리 불러오는 기능이 생기면서 그 규칙을 다시 나눴어요. 공개 조회는 쿠키를 읽지 않는 게 기본이고, 인증이 필요한 SSR 조회만 includeServerAuth: true를 넘기게 했어요. 그 호출은 공개 캐시 바깥에 둬요.
declare module 'axios' {
interface AxiosRequestConfig {
includeServerAuth?: boolean;
}
}
async function readAuthToken(includeServerAuth: boolean) {
if (typeof window === 'undefined') {
if (!includeServerAuth) return null;
const { cookies } = await import('next/headers');
return (await cookies()).get('auth_token')?.value ?? null;
}
return readCookieValue('auth_token');
}호출부의 기본값은 false예요.
// 사이트맵: 공개 응답이므로 서버 쿠키를 읽지 않는다.
fetchPublicSummary();
// SSR 프리페치: 혜택 상태·적용가가 회원별 응답이므로 쿠키 사용을 명시한다.
fetchPublicSummary({
includeServerAuth: true,
});이렇게 하면 같은 API라도 호출한 곳에서 인증 여부가 보여요. 다만 불리언을 잘못 넘길 수는 있어요. 서버 전용 공개 클라이언트와 인증 클라이언트, 브라우저 클라이언트를 모듈로 나누는 일이 남아 있어요. 별도 fetch 래퍼에 cache: 'no-store'를 넣어도 Axios 호출은 바뀌지 않아요. 그 옵션은 Next가 확장한 fetch에만 적용돼요.
브라우저 쪽에는 또 다른 우회가 남았어요. 쿠키를 읽는 Server Function을 부르기 전에 0ms 비동기 지연을 두고 렌더링 중 디스패치를 다음 매크로태스크로 미뤄요. 이 지연이 액션 큐를 건너뛰거나 병렬 실행을 보장해 주지는 않아요. 토큰을 브라우저 호출에 전달하는 방식을 BFF와 함께 다시 다룰 부분이에요.
정확하지 않은 사이트맵 메타데이터
현재 코드는 생성 시각 하나를 모든 항목의 lastModified로 쓰고, 페이지 종류에 따라 priority도 부여해요.
const lastModified = new Date();
const itemEntries = items.map(({ locationId, itemId }) => ({
url: `${PUBLIC_ORIGIN}/locations/${locationId}/items/${itemId}`,
lastModified,
priority: ENTRY_PRIORITY.Detail,
}));다시 생성했을 뿐인데 모든 페이지가 방금 수정된 것처럼 표시되는 셈이에요. 상품 목록 DTO에는 수정 시각 자체가 없고, 지점과 공지, 추천 콘텐츠의 수정 필드는 이름과 의미가 달라요. 현재 헬퍼는 그 값도 버리고 ID와 경로만 남겨요. 이 상태에서는 실제 수정 시각을 확인할 수 있는 항목만 lastModified를 넣고 나머지는 생략하는 편이 맞아요.
priority와 changefreq도 Google이 사용하지 않는 값이에요. 페이지 종류별 우선순위를 붙여도 Google의 크롤링 우선순위를 지정하는 효과는 없어요.
전체 조회와 부분 실패 처리도 고칠 부분이 남았어요
현재 지점과 상품 조회는 먼저 페이지 크기 1로 전체 개수를 얻은 뒤, 그 값을 페이지 크기로 보내 전체 목록을 한 번에 받아요. 각 헬퍼는 오류가 나면 []를 반환해요.
async function getAllLocationIds() {
try {
const { data: countSummary } = await fetchLocationList({
pageNumber: 1,
pageSize: 1,
});
if (!countSummary.totalCount) return [];
const { data: pageResult } = await fetchLocationList({
pageNumber: 1,
pageSize: countSummary.totalCount,
});
return pageResult.items.map(({ id }) => id);
} catch {
return [];
}
}전체 개수를 pageSize로 보내면 API의 최대 크기를 넘길 수 있어요. 데이터가 늘수록 응답도 커지고요. 실패를 빈 배열로 바꾸는 쪽은 더 조심해야 해요. Promise.all 입장에서는 모든 조회가 성공했으니, 일부 URL이 빠진 결과가 정상 사이트맵으로 캐시돼요. 페이지를 나눠 읽고 중요한 소스의 실패를 생성 실패로 전달하는 처리는 아직 적용하지 못했어요.
페이지 단위로 바꿔도 오프셋 조회 중 데이터가 변하면 중복, 누락이 생겨요. 커서나 스냅샷 API가 없다면 안정 정렬과 ID 중복 제거, 반복 횟수와 최대 페이지 수를 제한하는 방어 로직이 필요해요. URL이 50,000개 또는 비압축 50MB에 가까워지면 사이트맵 인덱스와 여러 사이트맵으로 나눠야 해요.
실패 시 오류를 던지도록 바꿔도 기존 결과가 항상 유지되지는 않아요. stale 상태의 항목을 백그라운드에서 재검증할 때는 이전 값을 제공할 수 있지만, 콜드 스타트, 캐시 미스, 만료 뒤에는 사이트맵 응답 자체가 실패할 수 있어요.
XML로 바꾸는 부분에도 중복 처리가 생길 수 있어요. 지금 헬퍼는 쿼리 문자열을 미리 이스케이프해요. Next.js가 마지막 직렬화에서 다시 이스케이프한다면 URL이 이중으로 바뀔 수 있어서, 생성된 XML을 직접 파싱해 한 번만 적용되는지 확인해야 해요.


