akizora.dev
Back to Garden
SEOJavaScriptNext.jsGoogle Search ConsoleArchitecture

JavaScript SEO: 초기 HTML과 GSC 렌더링 결과로 색인 문제 진단하기

GSC 렌더링 결과와 초기 HTML을 대조하는 JavaScript SEO 진단 프레임워크

검색 결과에서 페이지가 누락되는 구조적 원인

웹 브라우저에서는 제목과 본문이 정상적으로 렌더링되지만, 정작 검색엔진 결과에는 페이지가 노출되지 않거나 의도한 키워드 순위에서 배제되는 현상이 빈번하게 발생합니다. 이때 단순히 "SEO 최적화가 부족하다"고 판단하기 전에 다음 세 가지 핵심 질문을 바탕으로 문제를 진단해야 합니다.

  1. Googlebot이 해당 URL을 발견(Discovery)하고 정상적으로 크롤링했는가?
  2. 크롤링 시점에 핵심 콘텐츠(h1, 본문, 메타데이터)가 HTML에 온전히 포함되어 있었는가?
  3. 렌더링 이후 색인(Indexing)을 방해하는 기술적/품질적 신호가 존재하는가?
다이어그램을 렌더링하고 있어요...

검증 전 핵심 원칙: 브라우저 화면과 검색엔진 수집 문서의 분리

JavaScript 기반 Single Page Application(SPA) 환경에서는 사용자 브라우저의 최종 렌더링 화면과 검색엔진 크롤러가 수신하는 초기 문서를 동일시해서는 안 됩니다. 브라우저 개발자 도구의 Elements 탭에 보이는 UI는 브라우저가 JavaScript 번들을 내려받아 실행하고 비동기 API 요청을 완료한 최종 산출물에 불과하기 때문입니다.

만약 Next.js 페이지가 초기 렌더링 시 빈 컨테이너나 로딩 중... 텍스트만 전달하고 useEffect 훅 내부에서 데이터를 채운다면, Googlebot이 초기 요청에서 수신하는 문서는 완전히 비어 있는 상태가 됩니다.

Googlebot이 JavaScript를 실행(Rendering Queue)할 수 있는 것은 사실이나, 즉각적인 실행이 보장되지는 않습니다. 스크립트 실행 지연, API 타임아웃, 방화벽 및 CORS 차단, 인증 의존성이 개입할 경우 핵심 콘텐츠가 누락된 채 크롤링이 종료될 수 있습니다.

따라서 기술적 SEO의 첫 번째 원칙은 "브라우저의 최종 뷰가 아니라, 서버가 응답한 초기 HTML과 GSC 실제 렌더링 결과 모두에 핵심 콘텐츠가 존재하는가" 를 검증하는 것입니다.


1차 증거 수집: 페이지 소스(Raw HTML) 검증

가장 먼저 검색엔진이 수신하는 초기 문서를 확인해야 합니다. 개발자 도구의 DOM 트리가 아닌, 브라우저의 페이지 소스 보기(view-source:) 또는 터미널의 curl 명령어를 통해 서버의 원본 응답을 확인합니다.

curl -L https://example.com/products/item-123

페이지 소스 내에서 h1, 대표 본문, 상품명, 가격, 내부 링크가 텍스트 형태로 온전히 포함되어 있는지 검색합니다. 만약 빈 루트 요소(<div id="__next"></div>)만 존재한다면, 해당 페이지는 클라이언트 렌더링 성공 여부에 전적으로 의존하고 있는 구조입니다.


2차 증거 수집: Google Search Console(GSC) URL 검사

초기 HTML을 확인한 후에는 Google Search Console의 URL 검사 도구를 통해 Google이 실제로 해당 문서를 어떻게 처리했는지 파악합니다.

  1. 상태 코드 점검: 발견됨 - 현재 색인되지 않음인지, 크롤링됨 - 현재 색인되지 않음인지 상태를 명확히 구분합니다.
  2. 실제 URL 테스트(Test Live URL) 실행: 현재 배포본을 기준으로 Googlebot이 페이지를 렌더링한 스크린샷과 렌더링된 HTML을 확인합니다.
  3. 리소스 로딩 탭 검토: 스크립트 실행 도중 401/403/500 오류가 발생했거나, robots.txt에 의해 CSS/JS 리소스 접근이 차단되었는지 점검합니다.

3대 진단 시나리오별 원인 분석 및 해결 방안

진단 시나리오 A: 초기 HTML에는 없으나 GSC 렌더링 결과에는 존재하는 경우

Googlebot이 후속 렌더링 큐를 통해 JavaScript를 실행하여 콘텐츠를 가져왔음을 의미합니다. 당장 색인이 불가능한 것은 아니지만, 크롤링부터 색인까지의 주기가 장기화되며 API 장애 발생 시 빈 문서로 색인될 위험이 상존합니다.

  • 해결 방안: 가격, 제품 설명, 카테고리 소개, 게시글 본문처럼 검색에 노출되어야 하는 모든 공개 정보는 Next.js의 Server Component(RSC), SSG, 또는 SSR 을 통해 초기 HTML 응답에 반드시 포함하도록 아키텍처를 전환합니다.

진단 시나리오 B: 초기 HTML과 GSC 렌더링 결과 모두에 콘텐츠가 누락된 경우

Googlebot이 렌더링 과정에서 콘텐츠를 불러오지 못한 명백한 기술적 결함입니다.

  • 해결 방안:
    1. API 엔드포인트가 특정 인증 쿠키나 세션을 전제로 하고 있는지 확인합니다.
    2. IP 차단 솔루션이나 WAF(방화벽)가 Googlebot의 크롤러 IP/User-Agent를 차단하고 있지 않은지 점검합니다.
    3. robots.txt에서 빌드 산출물(_next/static) 접근을 막고 있지 않은지 확인합니다.
    4. 클라이언트 전용 전역 객체(window, localStorage) 참조로 인한 Hydration 오류를 해결합니다.

진단 시나리오 C: 렌더링 결과는 정상이지만 색인이 거부된 경우

렌더링 파이프라인의 문제가 아니라, 기술적 메타 신호 또는 콘텐츠 품질의 문제입니다.

  • 해결 방안:
    1. 페이지 내 noindex 메타 태그가 의도치 않게 주입되었는지 확인합니다.
    2. canonical 태그가 현재 URL과 일치하는 대표 URL을 정확히 가리키고 있는지 점검합니다.
    3. 중복 콘텐츠나 파라미터 변형 URL이 다량 생성되어 크롤링 예산이 낭비되고 있지 않은지 정리합니다.

Next.js 실무 구현 가이드

1. 공개 콘텐츠는 Server Component로 제공

// app/products/[slug]/page.tsx
export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const product = await fetchProductBySlug(slug);
 
  if (!product) {
    notFound();
  }
 
  return (
    <article>
      <h1>{product.title}</h1>
      <p>{product.description}</p>
      {/* 사용자 상호작용이 필요한 영역만 Client Component로 격리 */}
      <AddToCartButton productId={product.id} />
    </article>
  );
}

2. 동적 메타데이터와 Canonical의 엄격한 정의

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
  const { slug } = await params;
  const product = await fetchProductBySlug(slug);
 
  if (!product) return {};
 
  return {
    title: product.title,
    description: product.summary,
    alternates: {
      canonical: `/products/${slug}`,
    },
    openGraph: {
      title: product.title,
      description: product.summary,
      images: [product.thumbnailUrl],
    },
  };
}

3. sitemap.tsrobots.ts의 정밀한 동기화

사이트맵은 검색엔진이 공개 URL을 탐색하는 나침반입니다. 실제 배포 도메인과 200 OK 상태를 반환하는 유효한 URL만 선별적으로 포함해야 합니다.

// app/sitemap.ts
import { MetadataRoute } from "next";
 
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getAllPublishedPosts();
 
  return posts.map((post) => ({
    url: `https://example.com/blog/${post.slug}`,
    lastModified: new Date(post.updatedAt),
    changeFrequency: "weekly",
    priority: 0.8,
  }));
}
// app/robots.ts
import { MetadataRoute } from "next";
 
export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: "*",
      allow: "/",
      disallow: ["/admin/", "/api/private/"],
    },
    sitemap: "https://example.com/sitemap.xml",
  };
}

4. 동적 라우트의 404 처리와 Revalidation 시점 제어

존재하지 않거나 삭제된 상품/게시글에 대해 일반 200 OK 페이지를 반환하면 Google은 이를 Soft 404 로 간주하여 사이트 전체의 평가를 낮춥니다. 데이터가 없는 경우 즉시 notFound()를 호출하여 엄격한 404 상태 코드를 반환해야 합니다.

콘텐츠가 수정되었을 때는 On-demand Revalidation(revalidatePath, revalidateTag)을 통해 최신 렌더링 결과와 사이트맵의 lastModified를 즉각 동기화합니다.


수정 후 재검증 및 운영 체크리스트

코드를 배포한 직후 다음 절차를 거쳐 최종 상태를 검증합니다.

  1. 페이지 소스 검증: 배포 URL에서 view-source:h1, 본문, canonical 태그가 정확히 출력되는지 확인합니다.
  2. Rich Results Test: 구조화 데이터(JSON-LD)가 표준 스키마에 부합하는지 테스트합니다.
  3. GSC 실제 URL 테스트: Live URL 검사를 실행하여 스크린샷과 렌더링된 HTML이 사용자 화면과 완전히 일치하는지 대조합니다.

운영 체크리스트

  • 초기 Raw HTML 응답에 핵심 텍스트와 내부 링크가 완전히 포함되어 있는가?
  • canonical, robots.txt, sitemap.xml, HTTP 상태 코드가 서로 모순되지 않는가?
  • GSC 실제 렌더링 결과에서 리소스 차단이나 스크립트 런타임 오류가 없는가?
  • 비유효한 동적 라우트 요청 시 정확히 HTTP 404 상태 코드를 반환하는가?

결론: 데이터와 증거 기반의 SEO 운영

JavaScript SEO는 감이나 추측으로 접근할 수 없는 엄밀한 공학적 영역입니다. 브라우저의 최종 렌더링 화면에 의존하지 않고, 초기 HTML 응답(Raw HTML)GSC 실제 렌더링 결과를 비교 분석할 때 비로소 문제의 근본 원인을 정확히 식별하고 해결할 수 있습니다.

견고한 서버 렌더링 아키텍처와 명확한 메타 신호를 확립하는 것이 장기적인 검색 신뢰성을 확보하는 출발점입니다.