4개 언어 Next.js 16 사이트를 사람과 AI 크롤러를 위해 구축하기: hreflang, llms.txt, JSON-LD 및
요약
본 글은 Next.js 16을 사용하여 네 가지 언어(터키어, 영어, 스페인어, 아랍어)를 지원하는 대규모 다국어 웹사이트 구축 방법을 다룹니다. 특히 hreflang 구현 시 번역된 슬러그를 처리하기 위한 매핑 전략과 i18n 라이브러리 없이 폴더 기반의 로케일 라우팅 방식을 설명합니다.
핵심 포인트
- Next.js 16 App Router와 TypeScript, Tailwind CSS v4 스택 사용.
- i18n 라이브러리 대신 명시적인 폴더 구조로 다국어 라우팅 구현.
- 번역된 슬러그를 처리하기 위해 hreflang에 수동 매핑(map) 전략 필요.
- Googlebot 등 AI 크롤러가 평가하는 웹 SEO 최적화 방안 제시.
저는 이스탄불에 위치한 소규모 웹 디자인 및 SEO 스튜디오인 ModernWebSEO를 운영하고 있습니다. 저희 사이트는 네 가지 언어(터키어가 메인 시장이며 루트 경로에 위치), 영어(/en), 스페인어(/es), 아랍어(/ar, 오른쪽에서 왼쪽으로 쓰기)로 되어 있습니다. 사이트맵에는 700개의 URL이 포함되어 있습니다.
SEO 스튜디오의 웹사이트는 사람들에 의해, 그리고 점점 더 기계들(Googlebot, Bingbot, ChatGPT, Claude, Perplexity 및 Gemini 뒤에 있는 크롤러 및 페처)에 의해 세부 사항을 기준으로 평가받습니다. 이 글에서는 레포지토리의 코드를 통해 사이트가 각 계층을 어떻게 처리하는지 설명합니다. 스택은 Next.js 16 (App Router), TypeScript, Tailwind CSS v4이며 Vercel에 배포되었습니다.
또한 코드가 타협점이거나 단순히 잘못된 부분도 지적할 것입니다. 그 부분이 보통 유용한 내용입니다.
1. i18n 라이브러리 없이 폴더별 로케일 라우팅
next-intl이나 [locale] 동적 세그먼트는 없습니다. 각 로케일은 일반적인 폴더로 구성됩니다:
app/
page.tsx -> /
hizmetler/
...
왜 폴더를 사용할까요? URL이 단순히 접두사만 붙는 것이 아니라 번역되기 때문입니다. /hizmetler/e-ticaret은 영어에서는 /en/services/e-commerce가 되고 스페인어에서는 /es/servicios/tienda-online이 됩니다. 각 로케일의 라우트 트리를 수동으로 작성하는 것이 명시적이었고, 각 언어 페이지는 문자열을 교체하는 하나의 템플릿 대신 자체 레이아웃과 콘텐츠를 가질 수 있습니다.
비용은 <html lang> 태그에서 나타납니다. 루트 레이아웃은 모든 페이지에 대해 `<html lang=
2. 슬러그가 번역된 경우의 hreflang
번역된 슬러그는 hreflang을 접두사 교체만으로 생성할 수 없다는 것을 의미합니다. 모든 페이지에는 매핑(map)이 필요합니다. 서비스의 경우, 이 매핑은 라우트 옆에 위치합니다:
// app/en/services/[slug]/page.tsx (발췌)
const HREFLANG_BY_SLUG: Record<ServiceSlug, Record<string, string>> = {
'e-commerce': {
...
블로그 게시물은 별도의 슬러그 맵 모듈(blogSlugMapTrToEn, blogSlugMapEnToTr 등)을 사용하며, generateMetadata가 이를 이용해 대체(alternates)를 구축합니다. x-default는 주요 시장인 터키어 버전을 가리킵니다:
// app/en/blog/[slug]/page.tsx (발췌)
alternates: {
canonical: `/en/blog/${slug}`,
...
각 로케일은 자체 참조적인 canonical을 갖습니다. 영어 페이지가 터키어 버전을 canonical로 설정하는 일은 결코 없습니다. 그렇게 하면 Google이 해당 번역본을 제외하라고 인식할 수 있습니다.
동일한 대체(alternates) 정보는 Next의 MetadataRoute.Sitemap이 지원하는 alternates.languages 필드를 사용하여 app/sitemap.ts에도 포함됩니다:
// app/sitemap.ts (발췌)
const staticRouteMap = [
{ tr: 'metodoloji', en: 'en/methodology', es: 'es/metodologia', ar: 'ar/methodology' },
...
주의할 점: hreflang의 출처가 두 군데에서 불일치할 수 있습니다. 이 글을 작성하면서 페이지 메타데이터를 사이트맵과 비교했습니다. 스페인어 방법론 페이지는 네 가지 언어를 모두 나열합니다. 반면, 영어 방법론 페이지는 tr와 en만 나열하고, 사이트맵에는 네 가지 언어가 모두 나열되어 있습니다. hreflang은 상호적(reciprocal)이어야 하므로, 이러한 불일치는 Search Console에서 누락된 역링크로 보고됩니다. 만약 두 곳에 대체 언어를 선언한다면, 이들을 비교하는 작은 검사 기능을 작성하거나 하나의 맵으로부터 둘 다 생성해야 합니다.
관련하여 정리할 부분이 있습니다: 과거 잘못된 언어 접두사(예: /blog/...의 영어 게시물)로 발행되었던 포스트들은 next.config.ts에서 해당 로케일 경로로 영구 리디렉션되도록 설정되어, 이전 URL들이 중복으로 인덱스에 남아있지 않게 했습니다.
3. Content Signals를 포함한 robots.txt를 라우트 핸들러로 사용하기
robots.txt는 정적 파일이 아니라 app/robots.txt/route.ts의 라우트 핸들러(route handler)입니다. 따라서 규칙들은 배열로부터 구축됩니다:
// app/robots.txt/route.ts (발췌)
const CONTENT_SIGNAL = 'search=yes, ai-input=yes, ai-train=yes'
const commonDisallow = ['/api/', '/admin/', '/private/']
...
이 명명된 목록에는 검색 크롤러와 AI 크롤러가 포함됩니다: GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot, PerplexityBot, Perplexity-User, Google-Extended, Applebot-Extended, CCBot, Amazonbot, Meta-ExternalAgent 등 기타 크롤러들이 있습니다. Content Signals 라인은 해당 콘텐츠가 사용될 수 있는 용도를 명시합니다. 동일한 값은 HTTP 헤더(next.config.ts의 headers에서 전송됨)와 `<meta name=
솔직히 말하자면: Bard, Gemini 및 Copilot과 같은 목록의 일부 항목은 실제 크롤러가 전송하는 사용자 에이전트 토큰이 아닙니다. 이들은 무해하지만 노이즈입니다. Google의 AI 사용에 대한 진정한 제어는 Google-Extended입니다. 만약 이런 목록을 복사한다면, 각 이름을 해당 공급업체의 문서를 통해 확인하세요.
4. 요청하는 에이전트를 위한 llms.txt 및 Markdown
public/llms.txt는 수동으로 관리되는 Markdown 파일입니다. 이 파일은 비즈니스가 무엇인지 설명하는 짧은 인용구로 시작하며, 서비스, 가격 책정, 도구, 91개 직업 가이드 페이지, 언어별 블로그 게시물에 대한 섹션들로 구성됩니다. 더 긴 llms-full.txt가 그 옆에 위치합니다.
다국어 사이트를 운영할 때 제가 권장하는 습관이 있습니다: 연결된 페이지가 어떤 언어로 되어 있는지 명시하세요. 몇몇 서비스 페이지는 터키어에서만 존재하므로, 해당 줄 끝에 "(서비스 페이지는 터키어입니다.)"와 같이 표시합니다. 그러면 영어 사용자를 위해 사이트를 요약하는 모델이 거기에 없는 영어 페이지를 약속하지 않도록 알게 됩니다.
검색은 루트 레이아웃의 <head>에서 처리됩니다:
<meta name="content-signal" content="search=yes, ai-input=yes, ai-train=yes" />
<link rel="alternate" type="text/markdown" href="/llms.txt" title="LLM Context" />
그리고 next.config.ts는 클라이언트가 해당 경로를 조회할 때 /.well-known/llms.txt를 /llms.txt로 리디렉션합니다.
Accept: text/markdown을 전송하는 에이전트(또는 .md 경로를 요청하는 경우)는 HTML 대신 Markdown을 받습니다. 미들웨어는 이러한 요청을 API 라우트로 재작성합니다:
// middleware.ts (발췌)
const accept = request.headers.get('accept') || ''
const isMarkdownRequest =
...
/api/markdown은 경로를 해결합니다: 루트는 llms.txt를 반환하고, 블로그 URL은 해당 게시물의 Markdown 본문과 작은 프론트 매터(제목, 작성자, 날짜, 정규화된 URL) 및 FAQ가 추가되어 반환되며, 직업 및 사례 연구 페이지도 동일하게 처리됩니다. 소스 콘텐츠가 이미 TypeScript 데이터 파일에 존재하기 때문에, Markdown은 두 번째 복사본이 아닌 HTML과 같은 데이터를 사용합니다.
두 브랜치 모두에 Vary: Accept가 설정되어 있어 캐시가 Markdown 버전을 브라우저로 전달하는 것을 방지합니다. 배포 후에는 curl -I를 사용하여 실제 헤더를 확인하세요. 프레임워크나 CDN은 때때로 Vary를 재작성할 수 있습니다.
5. @id로 연결된 단일 JSON-LD 그래프
사이트 전체의 구조화된 데이터는 @graph가 포함된 단일 <script type="application/ld+json">입니다. 노드들은 자신을 반복하는 대신 @id를 통해 서로 참조합니다:
// components/seo/structured-data.tsx (축약됨)
const graph = [
{
...
sameAs는 하나의 설정 파일(lib/config.ts)에 존재하며 Wikidata 항목을 포함하므로, 모든 프로필 링크가 한 번만 유지됩니다. 이는 개체 인식(entity recognition)에 중요합니다. Google과 AI 도구들은 이 링크들을 통해 "ModernWebSEO"를 여러 플랫폼에서 연결합니다.
페이지 레벨 스키마는 자체 언어를 설정합니다. 영어 GEO 서비스 페이지는 Service 노드에 inLanguage: 'en-US'를 선언하고 영어로 FAQPage를 추가합니다. 해당 페이지의 언어에 맞는 스키마가, 그 언어로 된 페이지에 존재하는 것이 규칙입니다.
여기서 수정할 두 가지 사항이 있습니다:
ar-AR는 "아랍어, 아르헨티나"로 읽히는데, 이는AR이 아르헨티나의 국가 코드이기 때문입니다. 순수한ar(또는ar-SA와 같은 실제 목표 국가)가 올바릅니다.- 전역 WebPage 및 Article 노드는
dateModified: new Date().toISOString()를 사용하므로, 빌드할 때마다 날짜가 변경됩니다. 이는 사이트맵에서 제가 따르는 규칙과 모순됩니다 (다음 섹션). 수정된 날짜는 콘텐츠가 변경될 때만 바뀌어야 합니다.
6. lastmod에 대해 거짓말하지 않고 매번 빌드 후 IndexNow 사용하기
IndexNow를 사용하면 Bing, Yandex 및 다른 참여 검색 엔진들에게 URL이 변경되었다고 알려줄 수 있습니다. Google은 이를 사용하지 않습니다. 사이트는 npm의 postbuild 훅에서 이 작업을 수행합니다:
"scripts": {
"build": "next build",
"postbuild": "tsx scripts/submit-indexnow.ts",
...
이 스크립트는 사이트맵(모든 네 가지 로케일을 포함)을 가져와, lastmod가 지난 24시간 이내인 URL만 유지하고 이를 배치로 게시합니다:
핵심 파일은 public/ 디렉터리에 키 이름으로 지정되어 있습니다. 실패가 발생하면 로그를 남기고 다음 단계로 진행하며, 스크립트는 검색 엔진 핑(ping)이 배포를 중단시켜서는 안 되기 때문에 의도적으로 process.exit(1)을 호출하지 않습니다.
24시간 필터는 lastmod가 정확할 때만 작동합니다. 이것이 사이트맵 파일에서 가장 중요한 줄입니다:
// app/sitemap.ts (발췌)
/**
* 섹션 콘텐츠 날짜 (터키어에서 번역된 주석).
...
블로그 게시물은 자체적인 updatedAt 또는 publishedAt을 사용합니다. 목록 페이지는 가장 최신 게시물의 날짜를 사용합니다. 제가 섹션의 내용을 변경할 때, 수동으로 그 날짜를 변경합니다. 이는 수동적이며, 그것이 핵심입니다: 이 날짜가 의미를 갖기 때문입니다.
주의사항: postbuild는 이전 사이트맵을 확인합니다. postbuild는 새로운 배포가 아직 빌드되는 동안 실행되며, 스크립트는 라이브 도메인에서 사이트맵을 가져옵니다. 이것은 이전 배포의 사이트맵입니다. 이번 배포에서 추가된 페이지는 아직 포함되어 있지 않습니다. 중요한 변경 사항의 경우, 저는 배포가 완료된 후 npm run indexnow:manual을 실행합니다.
훔쳐갈 수 있는 체크리스트
- 번역된 슬러그에는 명시적인 로케일(locale) 맵이 필요합니다. 동일한 맵에서 페이지 대체본(page alternates)과 사이트맵 대체본을 생성하거나, CI 환경에서 차이를 확인해야 합니다.
- 로케일별 자체 참조 카노니컬(canonical). 번역본을 원본으로 카노니컬화하지 마십시오.
- 정적 유지를 위해
[lang]세그먼트를 건너뛰는 경우, 래퍼에lang과dir속성을 지정하고 무엇을 포기했는지 알아야 합니다. - robots.txt: 원하는 AI 크롤러의 이름을 명시하고, 모든 이름이 존재하는지 확인하십시오.
- llms.txt: 사이트가 어떤 내용인지 첫 줄에 설명하고, 단일 언어에서만 존재하는 페이지를 표시하십시오.
- Markdown 협상(negotiation):
Vary: Accept를 사용한 후 실제 헤더를 확인하십시오. - JSON-LD: 하나의 그래프,
@id참조, 페이지별inLanguage, 실제 수정 날짜를 사용하십시오. - IndexNow: 먼저 정확한
lastmod를 사용하고, 그 다음 자동화하십시오.
이 모든 것은 저희가 클라이언트 사이트에서 사용하는 47단계 방법론의 기술적 및 지리(GEO) 기둥과 연결됩니다. AI 측면의 배경 정보가 필요하다면, 저는 llms.txt란 무엇이며 어떻게 작성하는지에 대해 글을 썼고, 사이트에는 AI 크롤러 규칙이 포함된 무료 robots.txt 생성기가 있습니다. GEO 서비스 페이지에서는 저희가 이를 클라이언트를 위해 어떻게 적용하는지 다루고 있으며, ModernWebSEO 허브는 다른 모든 것을 연결합니다.
실제 파일도 확인할 수 있습니다: llms.txt 및 robots.txt.
hreflang 맵이나 Markdown 경로에 대한 질문은 댓글로 남겨주세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기