비어 있는 Next.js 사이트맵: APP_URL 하나에 두 가지 뜻을 실은 스타터 템플릿
형제 앱 하나의 운영 사이트맵을 열었더니 비어 있었습니다. <urlset> 태그만 있고 주소가 하나도 없었습니다. 코드에는 공개 경로 목록을 읽어 사이트맵을 만드는 sitemap.ts가 있었습니다. 목록도 비어 있지 않았습니다.
원인을 따라가 보니 이 앱 하나의 실수가 아니었습니다. 제가 새 앱을 만들 때 쓰는 공통 스타터 템플릿에 같은 구멍이 있었고, 그 템플릿으로 만든 앱은 모두 같은 구멍을 안고 태어날 수 있었습니다.
첫 번째 원인, 한 변수에 두 가지 뜻
사이트맵의 주소는 APP_URL 환경변수를 앞에 붙여 만들고 있었습니다. 운영 환경에는 이 값이 없었습니다. 그래서 주소를 만들 수 없는 항목이 빠지고 빈 사이트맵이 나왔습니다.
왜 값이 없었는지가 흥미로웠습니다. 스타터 템플릿의 설명은 APP_URL을 "비동기 작업 큐가 앱을 다시 부를 때 쓰는 주소"로 소개하고 있었습니다. 큐를 쓰지 않는 앱을 배포하는 사람은 이 값을 채울 이유가 없다고 읽습니다. 실제로 배포 설정에서도 빠져 있었습니다. 그런데 스타터의 robots.ts와 sitemap.ts는 같은 변수를 공개 주소로 쓰고 있었습니다.
한 변수에 두 가지 뜻이 실려 있었습니다. 큐의 콜백 주소와 검색엔진에 알려 줄 공개 주소입니다. 둘은 같은 값일 때가 많지만 같은 개념은 아닙니다. 그리고 설명에는 한쪽 뜻만 적혀 있었습니다.
두 번째 원인, 값을 넣었는데도 비어 있던 사이트맵
배포 설정에 APP_URL을 넣고 다시 배포했습니다. 사이트맵은 여전히 비어 있었습니다.
Next.js 앱 라우터에서 robots.ts와 sitemap.ts는 동적인 요소가 없으면 빌드할 때 한 번 만들어져 정적 파일로 굳습니다. 이 앱의 배포는 환경변수를 실행할 때만 넣고 빌드할 때는 넣지 않았습니다. 빌드 시점의 APP_URL은 여전히 비어 있었고, 그때 만들어진 빈 사이트맵이 그대로 나가고 있었습니다. 실행 환경에 값을 넣는 것으로는 이미 굳은 파일을 바꿀 수 없습니다.
고친 방법은 두 파일을 요청 때 만들도록 바꾸는 것이었습니다.
export const dynamic = "force-dynamic";사이트맵은 자주 요청되는 파일이 아니라 요청 때 만드는 비용은 무시할 만합니다. 빌드 환경과 실행 환경의 값을 맞추는 것도 방법이지만, 두 환경이 어긋날 수 있다는 사실 자체를 없애는 쪽을 골랐습니다. 사이트맵이 언제 다시 만들어지는지에 대한 다른 함정은 Next.js 사이트맵과 메타데이터 라우트 캐싱에 따로 적었습니다.
템플릿의 구멍은 복제된다
두 원인 모두 이 앱이 만든 것이 아니었습니다. 스타터 템플릿의 robots.ts와 sitemap.ts가 같은 변수를 같은 방식으로 읽고 있었습니다. 템플릿으로 앱을 하나 만들 때마다 이 구멍도 하나씩 복사됩니다. 운이 좋은 앱은 큐를 써서 APP_URL을 채웠고 빌드 때도 값이 들어가서 문제가 드러나지 않았을 뿐입니다.
저는 이것이 템플릿이 가진 가장 불편한 성질이라고 봅니다. 템플릿은 좋은 것을 복제하는 도구이지만, 나쁜 것도 똑같은 효율로 복제합니다. 그리고 복제된 구멍은 앱마다 다른 모양으로 드러나서, 같은 원인이라는 것을 알아보기 어렵습니다.
공개 주소를 따로 두기
템플릿에서 할 일은 두 가지로 정했습니다. 첫째, 공개 주소를 SITE_URL이라는 별도 변수로 분리합니다. metadataBase, canonical, robots, 사이트맵은 모두 이 값을 기준으로 합니다. 큐의 콜백 주소는 APP_URL에 그대로 둡니다. 이름이 뜻을 하나만 말하면 "이 값은 우리 앱에 필요 없다"는 오해가 생기지 않습니다.
둘째, 배포 워크플로가 SITE_URL을 반드시 채우게 하고, 비어 있으면 배포를 멈추게 합니다. 사이트맵이 비어 있는 것은 에러가 아니라서 아무도 알려 주지 않습니다. 멈추게 하지 않으면 다음 앱도 조용히 빈 사이트맵으로 운영됩니다.
이미 만들어진 앱은 템플릿을 고쳐도 바뀌지 않습니다. 그래서 공개 호스트를 돌며 사이트맵이 비어 있지 않은지를 주기적으로 확인하는 점검을 함께 두기로 했습니다. 템플릿은 앞으로 태어날 앱을 지키고, 점검은 이미 태어난 앱을 지킵니다.
함께 읽기
- Next.js 체감 속도: 낙관적 업데이트, 스트리밍, 스켈레톤 비교제조 공정을 관리하는 화면을 만들고 있습니다. 공정 카드마다 「피복 끝 → 가황」 같은 버튼이 있고, 누르면 다음 단계로 넘어갑니다. 써 보다가 걸리는 게 있었습니다. 전에 만든 다른 관리 화면은 누르는 순간 버튼이 바뀌는데, 이 화면은 통신이 끝나야 버튼이 돌아왔습니다.
- Next.js 병렬 라우트와 인터셉트 라우트: 모달 패턴과 default.js사진 목록에서 사진을 누르면 모달로 크게 보여 주고, 같은 주소를 새 탭에서 열면 전체 페이지로 보여 주는 화면이 있습니다. 주소는 /photo/123 하나인데 보이는 모양이 둘입니다.
- Next.js 데이터 가져오기 순서: 순차와 병렬, 그리고 Suspense 위치서버 컴포넌트에서 데이터를 읽는 코드는 이렇게 생깁니다.
- Next.js 다국어 경로 설계: 쿠키와 하위 경로공개하지 않고 내부에서만 여는 제품 카탈로그 데모에 언어 전환을 붙였습니다. 한국어, 영어, 중국어, 일본어, 독일어, 프랑스어 여섯 개입니다. 고른 언어는 쿠키에 저장하고, 쿠키가 없으면 브라우저의 Accept-Language 헤더를 봅니다. 주소는 나누지 않았습니다. /products/<기종> 하나로 여섯 언어를 모…
- Next.js 라우트 핸들러와 서버 함수 구분앱 라우터에서 서버로 무언가를 보내는 방법이 둘입니다. app/api/.../route.ts 에 라우트 핸들러를 만들거나, "use server" 를 붙인 서버 함수를 부르는 것입니다. 폼 하나를 붙일 때마다 어느 쪽을 쓸지 정해야 하는데, 「둘 다 서버에서 도니 아무거나」로 두면 나중에 갈립니다.