배포와 인프라7 분 읽기

구글은 제 앱의 넉 달 전 문구를 색인하고 있었습니다

브라우저에서는 새 문구가 보이는데 검색 결과와 미리보기에는 옛날 문구가 떴습니다. 원인은 하드코딩된 폴백 한 줄과 그와 갈라진 i18n 데이터였습니다.

#gotchas#reality-check#static-sites#debugging#first-principles
Left panel: server-rendered HTML contains a hardcoded old headline that a crawler and view-source see. Right panel: the browser swaps in the new headline from a locale object after hydration.
서버가 내보낸 HTML과 브라우저가 보여주는 화면이 서로 다른 문구를 담고 있었습니다.

랜딩 문구를 바꾼 지 몇 달 뒤, 검색 결과에 뜨는 제목이 여전히 옛날 문구였습니다. 메신저에 링크를 붙였을 때 나오는 미리보기 카드도 옛날 문구였습니다. 그런데 브라우저로 그 페이지를 열면 새 문구가 멀쩡히 보입니다.

당신 사이트의 검색 미리보기가 실제 화면과 다르다면, 무엇을 먼저 확인하시겠습니까? 저는 사이트맵과 canonical부터 봤습니다. 둘 다 깨끗했습니다.

이상한 조합

  • 브라우저: 새 문구.
  • curl로 받은 HTML: 옛날 문구.
  • Google Search Console의 색인된 페이지 미리보기: 옛날 문구.
  • OG 미리보기: 옛날 문구.

브라우저만 새 문구를 보고 있었습니다. 나머지는 전부 자바스크립트를 실행하지 않고 HTML만 읽는 쪽입니다. 여기서 답이 좁혀집니다. 새 문구는 JS가 그린 것이고, HTML에 박혀 나가는 건 옛날 문구다.

HTML에 박혀 있던 것

랜딩 컴포넌트가 이런 모양이었습니다.

// 서버 컴포넌트
export default function Landing() {
  return <h1>당신의 이름, 다시 짓다</h1>   // 넉 달 전 카피가 그대로
}
// 클라이언트 컴포넌트
"use client"
export function Headline() {
  const t = useCopy(locale)              // 로케일 데이터에서 최신 카피
  return <h1>{t.landing.headline}</h1>
}

문구를 업데이트할 때 로케일 데이터(copy/ko.ts)만 고쳤습니다. 서버 컴포넌트의 하드코딩된 <h1>은 그대로 뒀습니다. 그래서:

  • 초기 HTML(서버가 내보내는 것): 하드코딩된 옛날 <h1>.
  • 하이드레이션 후: 클라이언트가 로케일 데이터의 새 <h1>로 교체.

사람 눈에는 교체가 순식간이라 안 보입니다. 크롤러·curl·OG 스크래퍼는 교체 전 HTML을 가져가고, 구글은 렌더링을 나중으로 미루는 큐가 있어서 그 옛날 문구가 색인에 눌러앉습니다.

같은 패턴이 라이브 앱 여러 개에 들어 있었습니다. 전부 "폴백은 하드코딩, 진짜 값은 데이터 객체" 구조였고, 문구 수정이 한쪽만 건드렸습니다.

여기서 당신이라면?

두 갈래입니다.

  1. 서버 컴포넌트의 하드코딩 <h1>을 최신 문구로 손으로 맞춘다.
  2. 서버·클라이언트·OG가 같은 한 곳에서 문구를 읽게 만든다.

1번은 지금은 맞지만 다음 수정 때 또 갈라집니다. 사람이 두 곳을 동기화하는 구조는 반드시 언젠가 어긋납니다. 저는 2번으로 갔습니다.

고친 구조

// 서버 컴포넌트 — 로케일 데이터를 서버에서 읽어 그대로 렌더
import { getCopy } from "@/copy"
 
export default async function Landing({ params }: { params: { locale: string } }) {
  const t = getCopy(params.locale)
  return <h1>{t.landing.headline}</h1>   // HTML에 최신 문구가 박혀 나간다
}

generateMetadata도 같은 getCopy를 씁니다. 이제 문구는 copy/<locale>.ts 한 곳에만 있고, HTML·메타태그·클라이언트가 전부 거기서 나옵니다. 하드코딩 <h1>은 삭제했습니다. 폴백이 꼭 필요하면 폴백도 getCopy의 기본 로케일을 import 해서 씁니다 — 복붙한 문자열이 아니라.

자가진단 3개

  • curl -s https://당신앱/ | grep '<h1' 결과가 브라우저에서 보이는 제목과 같습니까?
  • 마케팅 문구가 코드에 몇 군데 들어 있습니까? (grep -rn "그 문구 일부" src/) 두 군데 이상이면 이미 갈라질 준비가 된 겁니다.
  • 문구를 한 곳에서 고쳤을 때 SSR HTML·OG 태그·클라이언트 화면이 전부 바뀝니까, 아니면 한 곳만 바뀝니까?

솔직한 부분

이건 버그라기보다 구조가 거짓말을 하기 쉽게 돼 있던 것입니다. 하드코딩 폴백은 "혹시 데이터 로딩 실패하면"이라는 선의로 넣었는데, 실제로 한 일은 크롤러에게 옛날 문구를 먹이는 거였습니다. 폴백과 진짜 값이 다른 파일에 살면, 둘은 시간이 지나면 반드시 갈라집니다.

규칙 하나: 같은 문자열이 코드에 두 번 있으면, 그중 하나는 이미 틀렸다고 가정하고 하나로 합친다.

리포가 스토어보다 한 세대 뒤였습니다·Cloudflare 캐시가 옛날 페이지를 붙잡고 있었습니다와 같은 계열입니다. 셋 다 "내가 보는 것"과 "밖에서 보는 것"이 갈라졌고, 갈라진 지점은 매번 내가 두 곳을 동기화한다고 믿었던 자리였습니다.

지금 딱 하나만 해보세요. 당신 랜딩 페이지를 curl로 받아서 <h1>을 눈으로 확인하시기 바랍니다.

유튜브

구글은 제 앱의 넉 달 전 문구를 색인하고 있었습니다

관련 글