블로그 개발기 6 (검색 편)
구글에 내 블로그가 안 나오던 이유 - 색인이 막힌 게 아니라 발견될 길이 없었다
블로그를 DB 기반으로 갈아엎고, 조용한 버그들을 잡고, 브라우저에서 글을 쓰게 만들었다. 발행한 글은 27편이 됐다.
그런데 문득 궁금해졌다. 구글에 내 글이 나오기는 할까?
검색해봤다. 안 나온다. 제목을 통째로 붙여넣어도 안 나온다.
"1년 넘게 쓴 글이 어디에도 없다."
기분이 묘했다. 아무도 안 읽는 것과 아무도 못 찾는 것은 다른 문제다. 후자라면 그건 내가 만든 문제다. 그래서 파보기로 했다.
색인이 막힌 게 아니라 발견될 길이 없었다
처음엔 뭔가가 크롤러를 막고 있다고 생각했다. noindex가 어딘가 박혀 있거나, 호스팅이 로봇을 거르고 있거나.
배포본을 직접 찔러봤다. 결과는 예상과 달랐다.
| 확인한 것 | 결과 |
|---|---|
/robots.txt | 404 |
/sitemap.xml | 404 |
응답 헤더의 X-Robots-Tag | 없음 |
| 포스트 페이지 HTML | 제목·본문·설명 정상 |
포스트 페이지는 멀쩡했다. 서버가 제목도 본문도 다 그려서 내려주고 있었다. 막힌 게 아니었다.
문제는 그 다음이었다. 크롤러가 URL을 알아내는 길은 셋뿐이다. 사이트맵, 다른 페이지에서 걸린 링크, 그리고 직접 제출. 사이트맵은 404였고, 직접 제출한 적은 없었다.
그럼 링크는? 홈을 받아서 <a> 태그를 세어봤다.
curl -s https://muring-blog.vercel.app/ | grep -o '<a ' | wc -l
# 1
1개. 그마저도 푸터의 개인정보 처리방침 링크였다. 34,635바이트짜리 HTML 안에 글로 가는 링크가 하나도 없었다.
색인이 막힌 게 아니라, 애초에 내 글이 있다는 걸 알릴 방법이 없었던 것이다.
홈이 크롤러에게는 빈 페이지였다
브라우저로 열면 카드가 잘 보인다. 그런데 HTML에는 없다. 이 간극이 이해가 안 됐다.
서버 쪽 코드는 이랬다.
export default async function Home() {
const [posts, tags] = await Promise.all([getPublishedPosts(), getAllTags()]);
return (
<div className="home">
{/* ... */}
<Suspense fallback={<div style={{ minHeight: "80vh" }} />}>
<PostGrid posts={posts} tags={tags} />
</Suspense>
</div>
);
}
서버가 글을 다 가져온다. 그리고 PostGrid에 넘긴다. 그런데 HTML에는 없다.
"다 가져와놓고 왜 안 나오지?"
받은 HTML을 다시 뒤졌다. min-height:80vh가 있었다. fallback만 나가고 있었다.
원인은 PostGrid 안에 있었다.
"use client";
export default function PostGrid({ posts, tags }) {
const searchParams = useSearchParams();
const selectedTag = searchParams.get("tag");
// ...
}
태그 필터를 ?tag= 로 받으려고 useSearchParams를 쓰고 있었다. 정적으로 미리 그려두는 페이지에서 이 훅을 쓰면, Next는 그 훅을 쓰는 가지를 통째로 정적 셸에서 빼버린다. 쿼리스트링은 요청이 와야 알 수 있으니 당연한 동작이다.
그래서 미리 만들어진 HTML에는 Suspense fallback인 빈 div만 남았다. 카드는 브라우저에서 자바스크립트가 돌아야 채워진다. 사람 눈에는 순식간이라 아무 문제가 없었다. 크롤러 입장에서는 빈 페이지였다.
구글이 자바스크립트를 실행해주긴 한다. 다만 그건 나중에 따로 도는 대기열이고, 신뢰도가 없는 신규 사이트가 그 순번을 언제 받을지는 알 수 없다.
정적 프리렌더와 크롤 가능한 목록은 동시에 가질 수 없었다
고치는 방법 자체는 간단했다. 태그를 클라이언트에서 읽지 말고 서버에서 읽어 내려주면 된다.
export default async function Home({ searchParams }: Props) {
const [{ tag }, posts, tags] = await Promise.all([
searchParams, getPublishedPosts(), getAllTags(),
]);
const selectedTag = typeof tag === "string" ? tag : null;
return <PostGrid posts={posts} tags={tags} selectedTag={selectedTag} />;
}
문제는 대가가 있다는 것이었다. 서버에서 searchParams를 읽는 순간 이 페이지는 미리 만들어둘 수 없게 된다. 빌드 결과가 ○(정적)에서 ƒ(요청 시 렌더)로 바뀐다.
"홈의 CDN 캐시를 버리는 게 맞나?"
한참 고민했다. 결론은 버리기로 했다. 목록 데이터는 이미 1시간짜리 캐시가 받치고 있어서 DB를 매번 타지는 않는다. 실제로 잃는 건 페이지 통째의 CDN 캐시 하나다. 그리고 useSearchParams를 쓰는 한, 정적 셸과 크롤 가능한 목록을 동시에 가질 방법이 없었다.
읽히지 않는 캐시된 페이지보다, 조금 느려도 읽히는 페이지가 낫다.
사이트맵을 낸다
링크가 생겼어도 홈에는 첫 6개만 걸린다. 나머지 글은 사이트맵으로 알려야 한다.
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
let posts = [];
try {
posts = await getSitemapEntries();
} catch {
posts = []; // DB 가 정지해도 배포까지 깨뜨리지는 않는다
}
// ...
}
try/catch는 방어적으로 보이지만 이유가 있다. 무료 티어 DB는 정지될 수 있고, 그때 사이트맵 하나 때문에 빌드 전체가 깨지면 곤란하다. 포스트 경로를 미리 만들 때도 같은 이유로 빈 배열로 물러나게 해뒀다.
수정일을 그대로 쓸 수 있었던 건 우연이 아니다
사이트맵에는 각 URL의 마지막 수정일을 적는다. 크롤러가 "안 바뀐 글은 다시 안 읽는" 판단에 쓰는 값이다.
DB의 updated_at을 그대로 쓸 수 있었다. 조회수와 댓글 수를 올릴 때 ORM 대신 raw SQL을 쓰고 있었기 때문이다.
이건 예전에 다른 이유로 내린 결정이었다. ORM으로 조회수를 올리면 수정일이 함께 갱신돼서, 관리 목록의 수정일이 "고친 날"이 아니라 "마지막으로 읽힌 날"이 돼버린다. 그게 싫어서 raw SQL로 바꿔놨었다.
그 덕분에 지금 이 값이 진짜 "고친 날"이다. 만약 되돌려놨다면, 크롤러는 아무도 고치지 않은 글을 누가 읽을 때마다 다시 읽으러 왔을 것이다.
메타데이터에서 두 번 밟았다
canonical과 OG 태그를 붙이는 건 단순 작업이라고 생각했다. 두 번 틀렸다.
canonical을 최상위에 두면 모든 페이지가 스스로를 중복이라고 말한다
처음엔 루트 레이아웃에 이렇게 적었다.
export const metadata = {
alternates: { canonical: "/" },
};
한 줄로 끝난 줄 알았다. 그런데 Next의 메타데이터는 얕게 상속된다. 하위 페이지가 alternates를 따로 정의하지 않으면 이 값을 그대로 물려받는다.
즉 소개 페이지도, 개인정보 처리방침도 "내 정본 주소는 홈이다"라고 선언하게 된다. 스스로를 홈의 중복 페이지로 만드는 셈이다. 색인에서 빠뜨려달라고 부탁하는 것과 같다.
canonical은 페이지마다 따로 적어야 했다.
태그 하나 얹었더니 세 개가 사라졌다
같은 상속 규칙에 한 번 더 걸렸다. 레이아웃에 사이트 공통 OG 정보를 넣어두고, 홈에는 주소만 얹었다.
// 레이아웃
openGraph: { siteName: "Mublog", locale: "ko_KR", type: "website" }
// 홈
openGraph: { url: "/" }
배포본을 확인하다가 홈의 og:site_name과 og:locale이 없어진 걸 발견했다. 얕은 병합은 객체를 합쳐주는 게 아니라 통째로 갈아끼운다. 홈이 openGraph를 하나라도 정의하는 순간 레이아웃 것은 사라진다.
"빌드도 통과하고 화면도 멀쩡한데 태그만 조용히 사라졌다."
또 이 부류다. 지난 글에서 실컷 데였던 그 종류. 공통 필드를 따로 빼서 매번 펼쳐 넣도록 고쳤다.
og:image로 SVG는 쓸 수 없다
공유 카드 이미지도 붙이기로 했다. 로고가 SVG로 있으니 그걸 쓰면 되겠다 싶었는데, 안 된다. 카카오톡·슬랙·X 대부분의 미리보기 수집기가 SVG를 거른다.
그래서 로고를 1200×630 PNG로 굽는 스크립트를 만들어 결과물을 커밋했다. 정사각형 로고를 그대로 주면 수집기마다 다르게 잘라내기 때문에, 카드 비율에 맞춰 앉혀야 했다.
여기서 하나 걸렸다. 워드마크를 블로그 본문 폰트로 넣으려고 했는데 안 됐다. 이미지를 굽는 렌더러는 @font-face를 아예 보지 않고, 시스템에 설치된 폰트만 쓴다. 브라우저에 내려주려고 가지고 있는 웹폰트는 여기서 쓸 수 없다는 뜻이다.
재밌는 건 확인하는 과정이었다. @font-face로 폰트를 지정한 것과, 아예 존재하지 않는 폰트 이름을 준 것의 결과가 픽셀 단위로 똑같았다. 조용히 기본 폰트로 대체되고 있었던 것이다.
지금은 시스템 폰트로 구웠다. 글자가 전부 로마자라 티가 안 나지만, 한글을 넣으려면 폰트를 시스템에 설치하는 것부터 해야 한다.
그리고 <html lang>이 en으로 남아 있었다. 본문이 전부 한국어인데. 이건 그냥 초기 템플릿을 안 고친 것이었다.
등록하고 나서
Search Console에 등록하려다 한 번 더 막혔다.
기본으로 권하는 "도메인" 속성은 DNS에 레코드를 넣어야 확인된다. 그런데 내 주소는 호스팅이 주는 서브도메인이라 DNS를 만질 수 없다. "URL 접두어" 속성으로 등록하고, HTML 태그를 심는 방식으로 소유권을 확인했다.
알게 된 김에 하나 더. 이런 공용 호스팅 도메인은 서브도메인마다 주인이 다르다고 등록돼 있어서, 구글도 각각을 별개 사이트로 본다. 옆집 평판이 옮아오지 않는 대신 물려받는 것도 없다. 신뢰도를 0부터 쌓아야 한다는 뜻이다.
사이트맵을 제출했다. 검색된 페이지 30개로 확정됐다.
다만 이 숫자는 "구글이 목록을 읽었다"는 뜻이지 "30개가 색인됐다"는 뜻이 아니다. 색인은 이제부터 시작이고, 몇 주가 걸린다. 이 글을 쓰는 지금도 검색하면 아직 아무것도 안 나온다.
결과
배포본에 직접 요청해서 확인한 값이다.
| 전 | 후 | |
|---|---|---|
/robots.txt | 404 | 사이트맵 위치 포함 |
/sitemap.xml | 404 | 30개 URL |
| 홈 HTML의 글 링크 | 0개 | 6개 |
| canonical | 없음 | 페이지마다 |
| og 태그 (홈) | 0개 | 7개 |
<html lang> | en | ko |
숫자로 보면 별것 아닌 작업이다. 파일 몇 개 추가하고 훅 하나 걷어낸 게 전부다.
그런데 이걸 안 했으면 앞으로 쓸 글도 전부 같은 처지였을 것이다.
느낀점
- 안 되는 것과 안 보이는 것을 구분하는 데 시간을 써야 했다. 처음엔 뭔가가 막고 있다고 단정하고 원인을 찾았는데, 실제로는 막힌 게 없었고 알릴 길이 없었을 뿐이었다. 진단을 틀리면 엉뚱한 걸 고치게 된다.
- 사람 눈으로 하는 확인은 절반만 확인이다. 홈은 브라우저에서 완벽하게 잘 보였다. HTML을 직접 받아서 세어보기 전까지는 아무 문제가 없어 보였다.
- 또 조용히 깨지는 것들이었다. 상속 규칙에 걸려 사라진 태그도, 영어로 남아 있던
lang도 빌드와 lint를 전부 통과했다. 이 블로그에서 세 번째로 같은 교훈을 얻는 중이다. - 과거의 결정이 나중에 값을 한다. 조회수를 raw SQL로 올려두지 않았으면 사이트맵의 수정일을 쓸 수 없었다. 그때는 관리 화면 때문에 내린 결정이었는데 여기서 다시 만났다.
이번 작업은 결과를 바로 볼 수 없다는 게 제일 낯설었다. 버그는 고치면 그 자리에서 확인되는데, 이건 고쳐놓고 몇 주를 기다려야 한다. 잘 고쳤는지도 그때 가서야 안다.
그래서 이번엔 "됐다"가 아니라 "할 수 있는 건 다 했다"에서 멈추기로 했다. 여기까지 읽어주신 분들께 감사 인사 올리고 여기서 마치겠다(넙죽).
댓글