당신은 Next.js 14 App Router 기반 프로젝트의 Google SEO를 Google 공식 권장사항 기준으로 완전히 구현합니다.
아래 항목을 빠짐없이 전부 구현해주세요.
스택 가정: Next.js 14 App Router, TypeScript, TailwindCSS, Supabase
환경변수: NEXT_PUBLIC_SITE_URL=https://실제도메인.com
1. 기술 SEO - 크롤링/색인 기반
1-1. sitemap (app/sitemap.ts)
- 정적 페이지(홈, 랭킹 등) + 동적 페이지(게시글, 프로필) 전부 포함
- lastModified 반드시 포함
- changeFrequency, priority 설정
- 게시글에 이미지가 있으면 image sitemap 포함
(Google이 이미지 검색에서 발견할 수 있는 유일한 방법)
예시:
import { MetadataRoute } from 'next';
import { supabaseAdmin } from '@/lib/supabase';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
const { data: posts } = await supabaseAdmin
.from('posts')
.select('id, updated_at, content')
.eq('is_deleted', false);
const postEntries = (posts || []).map(post => {
const imgMatch = post.content?.match(/]+))/);
return {
url: ${SITE_URL}/post/${post.id},
lastModified: new Date(post.updated_at),
changeFrequency: 'weekly' as const,
priority: 0.8,
...(imgMatch?.[1] && { images: [imgMatch[1]] }),
};
});
return [
{ url: SITE_URL, lastModified: new Date(), changeFrequency: 'daily', priority: 1.0 },
{ url: ${SITE_URL}/ranking, lastModified: new Date(), changeFrequency: 'daily', priority: 0.7 },
...postEntries,
];
}
1-2. robots (app/robots.ts)
- /admin, /api 크롤링 차단
- sitemap 경로 명시
- 사용자 비공개 페이지 차단
예시:
import { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
return {
rules: [
{
userAgent: '',
allow: '/',
disallow: ['/admin/', '/api/', '/mypage//settings'],
},
],
sitemap: ${SITE_URL}/sitemap.xml,
};
}
1-3. public/robots.txt (정적 fallback용)
User-agent: *
Allow: /
Disallow: /admin/
Disallow: /api/
Sitemap: https://실제도메인.com/sitemap.xml
1-4. canonical URL
- 모든 동적 페이지 generateMetadata에 반드시 포함
alternates: { canonical:${SITE_URL}/경로} - 중복 콘텐츠 방지 (같은 내용이 여러 URL에서 접근 가능한 경우)
1-5. ads.txt (AdSense 사용 시, public/ads.txt)
google.com, pub-XXXXXXXXXXXXXXXX, DIRECT, f08c47fec0942fa0
2. 메타데이터 (generateMetadata)
규칙
- 모든 동적 페이지에 generateMetadata 함수 구현 (서버 컴포넌트에서만 가능)
- generateMetadata와 page 함수가 같은 데이터를 쓰면 select 필드 통일해서 DB 쿼리 재사용
- supabaseAdmin(서버 전용) 사용 — 클라이언트 Supabase 사용 금지
필수 포함 항목
- title (페이지별 고유 제목)
- description (150자 이내, 핵심 내용 요약)
- keywords (관련 검색어 배열)
- authors
- openGraph:
type: 게시글은 'article', 프로필은 'profile', 나머지는 'website'
locale: 'ko_KR'
url: 전체 URL
siteName: 사이트명
images: [{ url, width, height, alt }] - twitter:
card: 'summary_large_image'
title, description, images - alternates.canonical
layout.tsx 전역 메타데이터 필수 항목
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL), // 상대 경로 이미지 URL 자동 변환
title: { default: '사이트명', template: '%s | 사이트명' },
description: ...,
keywords: [...],
robots: {
index: true, follow: true,
googleBot: { index: true, follow: true, 'max-image-preview': 'large', 'max-snippet': -1 },
},
verification: { google: process.env.NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION },
openGraph: { ... },
twitter: { ... },
icons: { icon: [...], apple: [...] },
alternates: { canonical: SITE_URL },
};
3. 구조화 데이터 (JSON-LD, Schema.org)
핵심 규칙
- next/script의 Script 컴포넌트 사용 금지 → 반드시 [removed] 태그 사용
(next/script는 서버 컴포넌트에서 동작 안 함) - 반드시 서버 컴포넌트(page.tsx)에서 렌더링
- 하드코딩 도메인 금지 → process.env.NEXT_PUBLIC_SITE_URL 사용
- 'use client' 페이지: 서버 page.tsx + 클라이언트 XxxClient.tsx로 분리
components/StructuredData.tsx 구현 목록
[전체 페이지 - layout.tsx에 삽입]
// Organization: 사이트 정체성, 지식 패널 노출 가능
export function OrganizationSchema() {
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
const schema = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: '사이트명',
url: SITE_URL,
logo: ${SITE_URL}/logo.webp,
description: '사이트 설명',
sameAs: ['소셜미디어URL1', '소셜미디어URL2'], // SNS 있으면 추가
};
return [removed];
}
// WebSite + SearchAction: Google 검색창에 사이트 내 검색 기능 노출
export function WebSiteSchema() {
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
const schema = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: '사이트명',
url: SITE_URL,
potentialAction: {
'@type': 'SearchAction',
target: ${SITE_URL}/search?q={search_term_string},
'query-input': 'required name=search_term_string',
},
};
return [removed];
}
[게시글 페이지 - app/post/[id]/page.tsx에 삽입]
// Article: 뉴스/블로그 리치 결과 (큰 이미지, 날짜, 작성자 표시)
export function ArticleSchema({ title, description, author, authorId, datePublished, dateModified, url, image, tags = [] }) {
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
const schema = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: title, // 110자 이내 권장
description,
author: {
'@type': 'Person',
name: author,
...(authorId && { url: ${SITE_URL}/mypage/${authorId} }),
},
datePublished,
dateModified: dateModified || datePublished,
url,
...(image && { image }), // 이미지 없으면 필드 자체 생략 (기본값 하드코딩 금지)
publisher: {
'@type': 'Organization',
name: '사이트명',
logo: ${SITE_URL}/logo.webp,
},
keywords: tags.join(', '),
inLanguage: 'ko',
};
return [removed];
}
// 게시글 본문에서 첫 이미지 추출 방법 (page.tsx에서 사용)
const imgMatch = post.content?.match(/]+))/);
const firstImage = imgMatch?.[1] || undefined;
// BreadcrumbList: 검색 결과에 경로 표시 (홈 > 페이지명)
export function BreadcrumbSchema({ items }) {
const schema = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: item.url,
})),
};
return [removed];
}
[프로필 페이지 - app/mypage/[id]/page.tsx에 삽입]
// ProfilePage: 프로필 리치 결과
export function ProfileSchema({ username, description, url, image, level, rank }) {
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
const schema = {
'@context': 'https://schema.org',
'@type': 'ProfilePage',
mainEntity: {
'@type': 'Person',
name: username,
description,
url,
...(image && { image }),
jobTitle: rank || '멤버',
worksFor: { '@type': 'Organization', name: '사이트명', url: SITE_URL },
...(level && { knowsAbout: 레벨 ${level} }),
},
};
return [removed];
}
[FAQ 페이지 - 해당 페이지에 삽입]
// FAQPage: FAQ 리치 결과 (검색 결과에서 질문/답변 펼쳐서 표시)
export function FAQSchema({ questions }) {
const schema = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: questions.map(q => ({
'@type': 'Question',
name: q.question,
acceptedAnswer: { '@type': 'Answer', text: q.answer },
})),
};
return [removed];
}
[커뮤니티/포럼 사이트 추가 권장]
// DiscussionForumPosting: 포럼/커뮤니티 게시글 전용 리치 결과
// Article 대신 사용하거나 병행 가능
export function DiscussionForumPostingSchema({ headline, text, url, author, authorId, datePublished }) {
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
const schema = {
'@context': 'https://schema.org',
'@type': 'DiscussionForumPosting',
headline,
text,
url,
datePublished,
author: {
'@type': 'Person',
name: author,
...(authorId && { url: ${SITE_URL}/mypage/${authorId} }),
},
};
return [removed];
}
서버/클라이언트 분리 패턴 (use client 페이지에 스키마 추가 방법)
// app/mypage/[id]/page.tsx — 서버 컴포넌트 (use client 없음)
export default async function MyPagePage({ params }) {
const { data: user } = await supabaseAdmin.from('users').select(...).eq('id', params.id).single();
return (
{user && }
{user && }
// useParams()로 URL 직접 읽는 클라이언트 컴포넌트
);
}
4. 이미지 SEO
모든
태그 필수 규칙
- alt 속성: 이미지 내용을 구체적으로 설명 (빈 문자열 금지)
좋은 예: alt="2024년 나스닥 차트 분석"
나쁜 예: alt="이미지", alt="" - 데코레이티브(장식용) 이미지: alt="" aria-hidden="true"
- onError 핸들러: 깨진 이미지 fallback 처리
예: onError={(e) => { e.currentTarget.src = '/default-avatar.png'; e.currentTarget.onerror = null; }}
(e.currentTarget.onerror = null → 무한 루프 방지)
next/image 사용 규칙
- 내부 이미지: next/image 사용 (자동 WebP 변환, lazy loading, 크기 최적화)
- 외부 이미지 도메인: next.config.js의 images.domains 또는 remotePatterns에 등록
- sizes 속성으로 반응형 크기 명시
- priority 속성: LCP(Largest Contentful Paint) 대상 이미지에만 사용
이미지 파일 자체 최적화
- 포맷: WebP 또는 AVIF 권장 (JPG/PNG 대비 30~50% 용량 절감)
- 크기: 실제 표시 크기의 최대 2배 이하
- 압축: browser-image-compression 라이브러리 활용
maxSizeMB: 1, maxWidthOrHeight: 1920, fileType: 'image/webp'
이미지 sitemap
- 게시글 본문 이미지를 sitemap에 포함시켜야 Google 이미지 검색 노출 가능
(1번 sitemap.ts 예시 참고)
5. 링크 SEO
외부 링크 rel 속성
- 사용자 생성 콘텐츠(댓글, 게시글 본문) 내 외부 링크: rel="noopener noreferrer ugc"
- 일반 외부 링크 (편집자 작성): rel="noopener noreferrer"
- 유료 광고/제휴 링크: rel="noopener noreferrer sponsored"
- href="#" 사용 금지 → 실제 의미 있는 URL로 대체
내부 링크
- 관련 페이지 간 내부 링크 적극 활용 (크롤링 경로 확장)
- 앵커 텍스트를 구체적으로 작성 ("여기를 클릭" 금지, "나스닥 분석 글 보기" 권장)
- 깨진 링크(404) 정기적으로 점검
접근성 (SEO 영향)
- 아이콘만 있는 링크/버튼: aria-label 필수
예: - 로고 링크: aria-label="사이트명 홈으로 이동"
- 스크린리더가 읽어야 하는 요소에 aria-hidden="true" 금지
- 순수 장식 요소에만 aria-hidden="true" 적용
6. 페이지 타이틀 & 설명
타이틀 작성 규칙
- 페이지마다 고유한 타이틀
- 60자 이내 (초과 시 검색 결과에서 잘림)
- 핵심 키워드를 앞쪽에 배치
- 브랜드명은 뒤에 붙임: "나스닥 분석 | 사이트명"
- Next.js: title: { template: '%s | 사이트명' } 패턴 사용
설명(description) 작성 규칙
- 페이지마다 고유한 설명
- 150자 이내 (초과 시 잘림)
- 사용자가 클릭하고 싶게 내용 요약
- 핵심 키워드 자연스럽게 포함
- 동일 설명 여러 페이지에 재사용 금지
URL 구조
- 영문 소문자, 숫자, 하이픈(-)만 사용 권장
- 의미 있는 단어 포함: /post/[id] 보다 /post/[slug] 권장 (가능하면)
- 불필요한 파라미터 최소화
7. 콘텐츠 품질 (Google E-E-A-T)
Google이 평가하는 기준
- Experience (경험): 실제 경험 기반 콘텐츠
- Expertise (전문성): 주제 관련 전문 지식
- Authoritativeness (권위성): 신뢰받는 출처
- Trustworthiness (신뢰성): 정확하고 투명한 정보
구현 방법
- 작성자 정보 명시 (이름, 프로필 링크) → ArticleSchema의 author 필드
- 게시 날짜 / 수정 날짜 명시 → datePublished, dateModified
- 관련 내부/외부 링크 연결
- 이미지에 관련 텍스트 배치 (이미지 주변 설명 텍스트)
8. 성능 (Core Web Vitals) - Google 랭킹 직접 반영
LCP (Largest Contentful Paint) - 2.5초 이하 목표
- 히어로 이미지/배너: 사용
- 폰트: font-display: swap 설정, preconnect 추가
- 중요 CSS 인라인 처리
FID/INP (Interaction to Next Paint) - 200ms 이하 목표
- 무거운 연산을 useCallback/useMemo로 메모이제이션
- 이벤트 핸들러 최적화
CLS (Cumulative Layout Shift) - 0.1 이하 목표
- 이미지/영상에 width, height 반드시 명시 (레이아웃 안정화)
- 동적 콘텐츠 삽입 시 공간 미리 확보
- 웹폰트 로딩 중 fallback 폰트 크기 맞추기
일반 성능
- 불필요한 JavaScript 제거 (bundle analyzer로 점검)
- 이미지 lazy loading (next/image 기본 적용)
- API 응답 캐싱 (Cache-Control 헤더 설정)
9. 모바일 최적화
- viewport 메타태그 필수
export const viewport: Viewport = { width: 'device-width', initialScale: 1 } - 터치 타겟 크기: 최소 48x48px
- 폰트 크기: 최소 16px (모바일에서 확대 없이 읽을 수 있도록)
- 가로 스크롤 금지: overflow-x: hidden
- Google은 모바일 우선 색인(Mobile-First Indexing) 적용 중
10. 국제화 / 한국어 사이트
- 필수
- hreflang 태그: 다국어 지원 시
- OpenGraph locale: 'ko_KR'
- Schema.org inLanguage: 'ko'
- 날짜 표시: toLocaleString('ko-KR')
11. 검증 및 모니터링
구현 후 필수 검증
- npm run build → 빌드 오류 0건 확인
- https://search.google.com/test/rich-results → 구조화 데이터 검증
- https://validator.schema.org → 전체 Schema.org 유효성 검사
- https://pagespeed.web.dev → Core Web Vitals 점수 확인
- Google Search Console → URL 검사 → 색인 요청
Google Search Console 설정
- 사이트 소유권 확인: NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION 환경변수
metadata.verification.google = process.env.NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION - sitemap 제출: Search Console → Sitemaps → sitemap.xml URL 등록
- Core Web Vitals 리포트 정기 확인
환경변수 체크리스트
NEXT_PUBLIC_SITE_URL=https://실제도메인.com
NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION=구글서치콘솔인증코드
(AdSense 사용 시) 광고 클라이언트 ID
12. 절대 하면 안 되는 것 (Google 스팸 정책)
- 키워드 스터핑: 같은 키워드 반복 남용 금지
- 숨겨진 텍스트: display:none 또는 배경색과 같은 색상 텍스트로 키워드 삽입 금지
- 클로킹: 사용자와 크롤러에게 다른 콘텐츠 제공 금지
- 자동 생성 저품질 콘텐츠 대량 생산 금지
- 구매한 링크로 PageRank 조작 금지
- 구조화 데이터 허위 정보 삽입 금지 (실제 없는 별점, 리뷰 등)




