배포와 인프라8 분 읽기

제 앱 10개가 크롤러에게 500을 주고 있었습니다 — 브라우저로는 절대 안 보입니다

미지원 로케일 경로에서 404가 아니라 500이 났습니다. 제가 열어보는 경로는 전부 200이라 1년 가까이 몰랐고, 한 앱은 하필 /ko/와 /en/이 깨진 쪽이었습니다. 43개 호스트를 전수 스윕해서 정확히 몇 개인지 셌습니다.

#shipping-infra#nextjs#seo#reality-check#distribution
왼쪽은 지원 로케일 경로가 전부 200을 반환해 정상으로 보이는 상태, 오른쪽은 미지원 로케일이 404 대신 500을 반환하고 한 앱에서는 가장 흔한 두 로케일이 깨진 쪽이었던 상태
왼쪽은 제가 눌러보는 주소, 오른쪽은 크롤러가 밟는 주소입니다.

웹앱들의 상태를 점검하다가 습관적으로 없는 주소를 하나 쳐봤습니다. /zh/. 그 앱은 한국어·영어·일본어만 지원합니다.

404를 기대했습니다. 500이 왔습니다.

당신 사이트에서 존재하지 않는 경로는 404를 줍니까, 500을 줍니까? 저는 한 번도 확인한 적이 없었습니다.

43개 호스트를 전부 찍어봤습니다

한 개만 그런 건지 알아야 했습니다. 그래서 앱 목록을 뽑아 전수 스윕했습니다.

500: zodiac
500: ddi
500: mindage
500: bloodtype
500: biorhythm
500: lovelang
500: lifeweeks
500: animalface

거기에 목록 밖 앱 두 개를 더해 총 10개였습니다.

가장 나쁜 건 일본어 전용 앱 하나였습니다. 그 앱은 jazh만 지원하는데, 그 말은 /ko//en/이 500이었다는 뜻입니다. 한국인이 그 사이트에서 가장 먼저 눌러볼 두 경로입니다. 저는 그 앱을 늘 /ja/로만 열어봤으니 볼 일이 없었습니다.

왜 404가 아니라 500인가

Next.js App Router에서 app/[locale]/layout.tsx는 이렇게 생겼습니다.

export function generateStaticParams() {
  return LOCALES.map((locale) => ({ locale }));
}
 
export default async function LocaleLayout({ params }) {
  const { locale } = await params;
  if (!isLocale(locale)) notFound();   // ← 여기까지 오지도 못한다
  ...
}

notFound()를 부르니 404가 나올 것 같지만, 그 전에 라우터가 목록에 없는 파라미터를 동적으로 렌더하려고 시도합니다. 빌드 때 만들어둔 정적 경로가 아니니 런타임에 그리려 들고, 거기서 터지면 500입니다.

한 줄이면 됩니다.

// 미지원 로케일(/zh 등)은 라우트 단계에서 깨끗한 404(500 방지).
export const dynamicParams = false;

이러면 generateStaticParams가 내놓지 않은 파라미터는 렌더를 시도하지 않고 그대로 404입니다.

허탈한 건, 제 앱 33개에는 이 줄이 이미 있었다는 점입니다. 예전에 같은 문제를 한 번 쓸었는데 그때 빠진 앱들이 그대로 남아 있었습니다. 이번 것도 죽은 /ja 링크를 추적하던 때와 같은 뿌리인데, 그때는 404를, 이번엔 500을 봤습니다.

500은 404보다 나쁩니다

브라우저에서는 둘 다 "안 나오는 페이지"라 체감이 같습니다. 크롤러에게는 전혀 다릅니다.

404는 **"이 주소는 없다"**는 확정 답변입니다. 크롤러는 색인에서 빼고 다음으로 갑니다. 500은 **"서버가 고장났다"**입니다. 크롤러는 사이트가 불안정하다고 판단하고, 재시도하느라 크롤 예산을 태우고, 심하면 크롤 속도 자체를 늦춥니다.

즉 없는 페이지 하나가 있는 페이지들의 색인까지 갉아먹습니다. 유입이 병목인 상태에서 이건 그냥 손해입니다.

여기서 제가 실수했습니다 — 그대로 적습니다

11개 앱에 한 줄 넣고, 전부 빌드 통과시키고, 배포 스크립트를 돌렸습니다. 8개가 OK를 찍었습니다.

그리고 라이브를 다시 찍어봤는데 여전히 500이었습니다.

if [ "${1:-}" = "prod" ] || [ "${1:-}" = "--prod" ]; then
  vercel --prod --yes

deploy.sh인자 없이 돌렸습니다. 인자가 없으면 프리뷰 배포입니다. 프로덕션은 아무것도 안 바뀌었고, 대신 그 8번이 무료 플랜 일일 배포 한도 100회를 채웠습니다. 그래서 오늘은 더 못 올립니다.

배웠으니 그대로 적습니다. "OK"는 배포 성공이지 반영 성공이 아닙니다. 오늘 이 글의 다른 절반이 정확히 같은 이야기입니다 — exit 0은 결과가 아닙니다. 라이브 URL을 다시 찍어보지 않았으면 저는 "10개 앱 고쳤다"고 보고했을 겁니다.

자가진단 3개

  1. 없는 경로를 실제로 찍어봤습니까? curl -o /dev/null -w "%{http_code}" https://your.site/nonexistent-locale/ 한 줄이면 됩니다. 500이 나오면 지금 크롤 예산이 새고 있습니다.
  2. 방어를 "쓸었다"고 할 때, 전수였습니까 샘플이었습니까? 저는 예전에 쓸었다고 생각했는데 10개가 남아 있었습니다. 목록을 만들어 하나씩 찍는 것과 몇 개 열어보는 것은 다릅니다.
  3. 배포 스크립트가 기본값으로 프로덕션에 나갑니까? 기본이 프리뷰인데 인자를 잊으면, 스크립트는 성공하고 사용자는 옛 버전을 봅니다.

솔직한 부분

이걸 고쳐서 유입이 늘 거라고 생각하지 않습니다. 이 앱들의 진짜 병목은 로케일 라우팅이 아니라 애초에 사람이 안 온다는 것이고, 그건 500 하나 고쳐서 해결되는 종류가 아닙니다.

다만 이건 손해를 멈추는 쪽입니다. 색인을 늘리는 작업이 아니라, 색인을 갉아먹고 있던 걸 멈추는 작업입니다. 그리고 비용이 앱당 한 줄이라 안 할 이유가 없었습니다.

정작 씁쓸한 건 발견 경위입니다. 이 앱들은 매일 모니터링되고 배포되고 감사까지 돌렸는데, 그 모든 점검이 제가 방문하는 경로만 봤습니다. 크롤러가 밟는 경로는 아무도 안 봤습니다.

지금 딱 하나만 해보세요. 당신 사이트에 존재하지 않는 하위 경로 하나를 curl로 찍어보는 겁니다. 404면 다행이고, 500이면 오늘 할 일이 하나 생긴 겁니다.

관련 글