랜딩 문구를 바꾼 지 몇 달 뒤, 검색 결과에 뜨는 제목이 여전히 옛날 문구였습니다. 메신저에 링크를 붙였을 때 나오는 미리보기 카드도 옛날 문구였습니다. 그런데 브라우저로 그 페이지를 열면 새 문구가 멀쩡히 보입니다.
당신 사이트의 검색 미리보기가 실제 화면과 다르다면, 무엇을 먼저 확인하시겠습니까? 저는 사이트맵과 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을 가져가고, 구글은 렌더링을 나중으로 미루는 큐가 있어서 그 옛날 문구가 색인에 눌러앉습니다.
같은 패턴이 라이브 앱 여러 개에 들어 있었습니다. 전부 "폴백은 하드코딩, 진짜 값은 데이터 객체" 구조였고, 문구 수정이 한쪽만 건드렸습니다.
여기서 당신이라면?
두 갈래입니다.
- 서버 컴포넌트의 하드코딩
<h1>을 최신 문구로 손으로 맞춘다. - 서버·클라이언트·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>을 눈으로 확인하시기 바랍니다.