Google가 내 전체 사이트를 무시하게 만든 Next.js SEO 버그 (그리고 내가 그것을 찾은 방법)

발행: (2026년 3월 7일 PM 12:51 GMT+9)
16 분 소요
원문: Dev.to

I’m ready to translate the article for you, but I need the full text you’d like translated. Could you please paste the content of the article (excluding the source line you already provided) here? Once I have the text, I’ll translate it into Korean while preserving the original formatting, markdown, and technical terms.

일부 배경

저는 개발자는 아닙니다. 무언가를 만들고 새로운 도구를 시도하는 것을 좋아합니다. SEO는 언제 “나중에 알아볼게” 라고 생각하던 분야였죠. 유명한 마지막 말이죠.

저는 SEO를 아주 높은 수준에서 이해하고 있지만, 마케팅 퍼포먼스 분야에서 일하면서 잘 색인된 웹사이트와 올바른 키워드의 중요성을 잘 알고 있습니다. 저는 그것이 대부분—검색어를 조사하고 그에 맞는 좋은 콘텐츠를 만드는 것—이라고 생각했습니다.

이번에는 직접 알아내야 했습니다!

말했듯이, 저는 새로운 도구와 기술을 배우기 위해 무언가를 직접 만들곤 합니다.

앱 이름은 MonkeyTravel 입니다. 이 앱은 AI를 활용해 개인 맞춤형 여행 일정—하루하루의 활동, 레스토랑, 호텔, 예산 분배까지—을 생성합니다. 영어, 스페인어, 이탈리아어를 지원합니다. 저는 친구들과 그룹 여행을 계획할 때 항상 혼란스러웠기 때문에 더 똑똑한 무언가가 필요해서 만들었습니다. 보통 그렇듯이, 처음엔 저를 위해 만들었지만 이번에는 대규모 Projects Graveyard에 묻히고 싶지 않았습니다.

앱 자체는 훌륭하게 작동했습니다. 찾아본 사람들은 모두 만족했습니다.

문제는? 아무도 찾을 수 없었다는 점입니다.

저는 직접 해결책을 찾아야 했습니다! 그리고 이것은 시작에 불과합니다.

Phase 1: “왜 Google이 내 사이트를 표시하지 않을까?”

Google Search Console을 처음 확인했을 때 뭔가가 보이길 기대했어요. 사이트는 이미 몇 주째 라이브 상태였거든요. 그런데 결과는 한 줄짜리 그래프뿐.

노출 0회. 클릭 0회. 색인된 페이지 0개.

첫 번째 직감은 Google을 탓하는 것이었어요. “시간이 걸릴 거야,” 라고 스스로에게 말했죠. 일주일을 더 기다렸지만 여전히 0이었습니다.

그때서야 실제 설정을 살펴보게 되었어요:

  • ❌ 오래된 사이트맵
  • ❌ canonical 태그 없음
  • ❌ hreflang 태그 없음 (3개 언어가 있음에도)
  • ❌ 구조화 데이터 부족
  • create-next-app에서 생성된 기본 robots.txt
  • ❌ 절반 이상의 페이지에 메타 설명 없음

쉽게 말해, 멋진 집을 지었지만 문에 번호를 붙이는 걸 잊은 셈이죠. 이제 내 잘못된 선택을 떠나 SEO에 집중해 봅시다. 😄

Phase 2: 기본 구축 (지루하지만 필요함)

주말 동안 기본적인 작업들을 추가했습니다. 혁신적인 것은 없고, 모든 사이트에 필요한 기본 사항들만 구현했습니다.

Sitemap

Next.js에서는 app/sitemap.ts 파일을 통해 쉽게 만들 수 있습니다. 여기서는 모든 정적 페이지, 블로그 포스트, 그리고 세 개의 로케일에 걸친 목적지 페이지 URL을 생성합니다. Supabase에서 가져오는 동적 콘텐츠도 포함됩니다.

// Simplified version of my sitemap
export default async function sitemap(): Promise {
  const baseUrl = "https://monkeytravel.app";
  const locales = ["en", "es", "it"];

  // Blog posts × 3 languages
  const blogSlugs = getAllSlugs();
  const blogPages = blogSlugs.flatMap(slug =>
    locales.map(locale => ({
      url:
        locale === "en"
          ? `${baseUrl}/blog/${slug}`
          : `${baseUrl}/${locale}/blog/${slug}`,
      changeFrequency: "monthly" as const,
      priority: 0.7,
    }))
  );

  return [...staticPages, ...blogPages, ...destinationPages];
}

Canonical + Hreflang

다국어 사이트에서는 정규화된 URL(canonical)과 대체 언어 URL 세트를 모두 제공해야 합니다. 각 페이지에서 generateMetadata()를 사용했습니다:

alternates: {
  canonical:
    locale === "en"
      ? `${BASE_URL}/${slug}`
      : `${BASE_URL}/${locale}/${slug}`,
  languages: {
    en: `${BASE_URL}/${slug}`,
    es: `${BASE_URL}/es/${slug}`,
    it: `${BASE_URL}/it/${slug}`,
    "x-default": `${BASE_URL}/${slug}`,
  },
},

Structured Data

Organization, WebSite, SoftwareApplication, Article, TouristDestination에 대한 JSON‑LD 스키마를 작성했습니다. 이를 위한 작은 유틸리티를 만들었습니다:

export function jsonLdScriptProps(data: object) {
  return {
    type: "application/ld+json",
    dangerouslySetInnerHTML: {
      __html: JSON.stringify(data),
    },
  };
}

Result

Sitemap을 제출한 뒤, Google은 48시간 이내에 모든 URL을 발견했습니다. 하지만 “발견” ≠ “인덱싱”. 대부분의 페이지는 “Discovered — currently not indexed” 대기열에 머물렀습니다.

일주일 후: 12 페이지가 인덱싱됨. 진전은 있었지만 매우 느린 속도였습니다.

실제 문제는? Google Search Console이 페이지가 거부된 이유에 대해 별다른 피드백을 제공하지 않는다는 점입니다.

Phase 3: Google가 실제로 원하는 콘텐츠

내 사이트가 로그인 벽 뒤에 있는 앱에 가깝다는 것을 깨달았다. Google이 색인할 공개 콘텐츠가 거의 없었기 때문에 콘텐츠를 대대적으로 늘렸다.

  • 50개의 블로그 포스트는 실제 여행 주제, 일정 가이드, 목적지 비교, 저예산 여행 팁, 계절별 추천을 다룬다.
    × 3개 언어 = 150개의 블로그 페이지.

  • 20개의 목적지 랜딩 페이지(파리, 도쿄, 발리, 바르셀로나 등)에는 기후 데이터, AI 일정 미리보기, 블로그 포스트와의 교차 링크가 포함된다.
    × 3개 언어 = 60페이지.

  • 5개의 SEO 랜딩 페이지는 특정 검색 의도를 타깃으로 한다: /free-ai-trip-planner, /group-trip-planner, /budget-trip-planner 등.

놀라운 점: 내부 링크가 콘텐츠 자체보다 더 중요했다. 여러 다른 페이지에서 교차 링크된 페이지는 고립된 페이지보다 훨씬 빠르게 색인되었다. 나는 다음을 추가했다:

  • 모든 랜딩 페이지에 “From the Blog” 섹션
  • 모든 목적지 페이지에 “Related destinations” 섹션
  • 블로그 ↔ 목적지 링크(양방향)
  • 블로그 인덱스에 지역 필터(유럽, 아시아, 아메리카, 아프리카)

2주 후: 78페이지가 색인됨. 곡선이 가속화되고 있었다.

Phase 4: 거의 모든 것을 망칠 뻔한 버그

그때 구글 검색 콘…

(이야기는 계속됩니다…)

사용자 선택 캐노니컬 없이 중복

구글이 내 홈페이지를 거부하고 있었습니다. monkeytravel.app 대신 www.monkeytravel.app을 캐노니컬로 선택하고 있었는데, 나는 다음을 설정했음에도 불구하고:

  • www → non‑www 로의 301 리디렉션 (미들웨어 Vercel 설정 모두에 적용)
  • HTML에 올바른 캐노니컬 태그
  • 사이트맵의 모든 URL이 non‑www 사용

모든 것을 다시 확인했습니다. 리디렉션은 정상 작동했고, HTML에는 올바른 태그가 있었으며, curl 로도 확인했습니다:

$ curl -s https://monkeytravel.app/ | grep canonical

그럼에도 구글이 “User‑declared canonical: None” 라고 표시하는 이유는 무엇일까요?

발견

몇 시간을 고민한 끝에 깨달았습니다. 핵심은 내가 검증한 방식이었습니다.

  • curl 은 전체 응답이 끝날 때까지 기다립니다.
  • Googlebot 은 그렇지 않 않습니다.

**Next.js 15.2+**에서는 generateMetadata() 가 메타데이터를 비동기적으로 스트리밍합니다. <link rel="canonical"> 태그는 초기 HTML 페이로드에 포함되지 않고, 본문이 렌더링된 뒤 스트림을 통해 삽입됩니다. Googlebot 이 초기 응답을 파싱할 때는 캐노니컬 태그가 아직 존재하지 않 (또는 적어도 AI 답변과 문서를 파고들면서 내가 그렇게 결론지었습니다).

스트리밍이 완료되기 의 원시 초기 HTML을 확인해 보니 <link rel="canonical"> 가 전혀 없었습니다.

해결책: 하나의 설정 옵션

// next.config.ts
const nextConfig: NextConfig = {
  // 크롤러에 대해 스트리밍을 비활성화하여 메타데이터를 포함한 전체 HTML을
  // 동기적으로 전송합니다.
  htmlLimitedBots: /Googlebot|Google-InspectionTool|Bingbot|Yandex/i,
  trailingSlash: false,
};

export default nextConfig;

htmlLimitedBots 은 Next.js 에게 “크롤러가 방문하면 스트리밍을 끄고, 메타데이터가 포함된 전체 HTML을 동기적으로 보내라” 라는 지시를 내립니다. 이 정규식 하나만으로 문제가 해결되었습니다.

또한 루트 레이아웃의 캐노니컬을 "/" 에서 "./" 로 변경하여 모든 페이지가 자기 자신을 가리키는 캐노니컬을 갖게 했습니다. 이렇게 하면 모든 페이지가 홈페이지를 가리키는 것이 아니라 각각의 페이지가 자체 캐노니컬을 갖게 됩니다(미묘하지만 중요한 차이점).

배포 후 재인덱싱을 요청하니 결과가 빠르게 나타났습니다:

  • 몇 일 안에 176 페이지가 인덱싱됨.
  • 아직 230+ 페이지 전체는 아니지만, 추세는 명확합니다.

숫자

메트릭0주차1주차2주차3주차
색인된 페이지01278176
사이트맵의 전체 페이지 수0~50~225~230
블로그 게시물(언어당)011550
구조화 데이터 스키마0355

내가 틀린 점 (당신은 안 틀리게)

  1. htmlLimitedBots를 첫날부터 설정하지 않음 – SEO에 신경 쓰는 모든 Next.js 프로젝트는 이 설정이 필요합니다. 메타데이터 스트리밍은 눈에 보이지 않으며, 수동으로 확인할 때는 괜찮아 보이지만 크롤러는 다른 모습을 보게 됩니다.
  2. SEO를 “나중에” 해결할 문제로 취급 – 사이트맵과 정규화 태그를 미루면 매번 잠재적인 크롤링을 일주일씩 낭비합니다. 구글의 대기열은 당신이 조급해도 빨라지지 않습니다.
  3. 내부 링크의 중요성을 과소평가 – 서로 연결된 페이지는 고립된 페이지보다 3‑4배 빠르게 색인됩니다. 가능한 한 관련 콘텐츠를 링크하세요.
  4. 다국어 지원을 위한 hreflang 누락 – hreflang 없이 세 가지 언어 버전을 제공하면 구글이 이를 번역이 아닌 중복 콘텐츠로 인식합니다.

AI Tricks That Helped

  • AI‑assisted blog content – AI로 구조를 초안하고, 이후 편집 및 현지화했습니다. 50개의 게시물 × 3개 언어에 대해 수개월의 수작업을 절감했습니다.
  • Automated cross‑linking – 주제를 분석하고 내부 링크를 제안하는 스크립트를 작성했으며, 수동 매핑보다 훨씬 효율적이었습니다.
  • Prompt engineering for i18n – 영어 기사 번역 대신, AI에게 대상 독자를 위해 작성하도록 요청했습니다(예: “이탈리아 독자를 위한 파리 소개 작성”). 보다 자연스러운 콘텐츠를 얻을 수 있었습니다.

과거의 나에게 하고 싶은 말

하루 1에, 기능 하나도 작성하기 전에 이것부터 시작하세요:

  • next.config.tshtmlLimitedBots
  • 사이트맵 생성
  • 모든 페이지에 정규화 태그
  • 사이트를 Google Search Console에 제출

다른 모든 것—블로그 포스트, 구조화 데이터, 내부 링크—도 중요하지만, 이 네 가지가 기본입니다. 이를 빼면 다른 것은 아무 의미가 없습니다.

업데이트: 아직 129개의 페이지가 Google 대기열에 있습니다. 진행 상황을 보면 몇 주 안에 (희망적으로) 색인될 것입니다. 그때부터가 진짜 게임입니다: 경쟁 키워드에 실제로 순위에 오르는 것이죠. 그건 재미있을 겁니다.

MonkeyTravel는 무료로 사용할 수 있습니다 — 목적지를 입력하면 몇 초 만에 맞춤형 AI 일정이 제공됩니다. Next.js, Supabase로 구축하고 Vercel에 호스팅했습니다. 피드백은 언제든 환영합니다! 함께 새로운 것을 배워봅시다.

0 조회
Back to Blog

관련 글

더 보기 »

JavaScript의 비밀스러운 삶: Observer

Timothy는 의자에 몸을 기대고 노트북 팬의 갑작스럽고 거친 윙윙거림을 들었다. 그는 방금 대규모 …에 대한 lazy‑loading 기능을 구현한 막 끝냈다.