특정 플랫폼의 SEO를 처음부터 끝까지 뜯어고쳤다. "SEO 좀 해줘"라는 막연한 요청을 검색엔진이 실제로 사이트를 처리하는 순서에 맞춰 하나씩 풀어낸 기록이다. Next.js(Pages Router) 기준이지만 원리는 어떤 스택에도 적용된다.
🎯 이 글이 다루는 것 — 검색엔진 동작 원리(크롤링·색인·서빙), 페이지별 메타 태그 설계, JSON-LD 구조화 데이터, canonical 중복 처리, 동적 sitemap, robots. 그리고 각 작업을 "현재 상황 → 왜 문제 → 어떻게 → 효과" 순으로 풀어낸 실무 경험.
작업을 시작하기 전에 이 그림부터 머리에 넣어야 한다. SEO 작업의 90%는 이 4단계 중 어딘가를 손보는 일이다.
크롤링(Crawling) → 렌더링(Rendering) → 색인(Indexing) → 서빙·랭킹(Serving)
봇이 URL 발견 JS 실행해 콘텐츠 DB에 분석·저장 검색어에 맞춰 순위| 단계 | 하는 일 | 여기서 삐끗하면 |
|---|---|---|
| 크롤링 | 봇(Googlebot, 네이버 Yeti)이 URL을 발견하고 방문 | sitemap에 URL이 없으면 상세 페이지를 늦게 발견하거나 누락 |
| 렌더링 | Chrome으로 페이지를 그리고 JS 실행 | CSR만 있으면 렌더링 큐 지연 → 콘텐츠 인식이 늦음 (그래서 SSR 권장) |
| 색인 | 텍스트·태그(title, alt) 분석 후 대표(canonical) 페이지 선택 | 모든 페이지 title이 같으면 "중복"으로 묶여 일부만 색인 |
| 서빙 | 검색어에 맞는 페이지를 품질·관련성 순으로 반환 | title에 키워드가 없으면 관련성 점수가 낮아 순위에서 밀림 |
💡 핵심: 색인되지 않은 페이지는 검색 결과에 절대 나오지 않는다. 그래서 SEO의 출발점은 "봇이 우리 페이지를 빠짐없이 발견하고, 중복으로 오해하지 않고, 유형과 주제를 정확히 파악하게 하는 것"이다. 예쁜 문구는 그다음 문제다.
작업 전 플랫폼의 상태를 진단하니 5가지 큰 문제가 있었다.
| # | 현재 상황 | 왜 문제인가 |
|---|---|---|
| 1 | 모든 페이지의 제목·설명이 동일 | 검색엔진이 "중복 페이지"로 오해 → 일부만 색인, 검색어 매칭 약화 |
| 2 | 페이지 유형을 알려주는 구조화 데이터가 전무 | 봇이 "글자 덩어리"로만 인식 → 분류 부정확, 리치 결과 자격 없음 |
| 3 | 같은 페이지가 여러 주소로 존재 (canonical 없음) | 검색 점수가 여러 주소로 분산 → 어느 주소도 상위에 못 감 |
| 4 | sitemap에 개별 콘텐츠 주소가 0개 | 봇이 상세 페이지를 늦게 발견하거나 누락 → 색인 누락 |
| 5 | HTML 언어가 영어(lang="en") | 한국어 사이트인데 영어로 표기 → 한국어 검색 노출에 불리 |
이제 하나씩, 왜 그렇게 고쳤는지와 함께 본다.
다른 모든 작업이 이 토대 위에 올라간다. 문제는 단순했다 — 페이지마다 다른 메타를 주입할 공통 수단이 없었다. 모든 페이지가 똑같은 <Head>를 공유하고 있었다.
해법은 공통 레이아웃(RootLayout)이 페이지별 메타를 props로 받아 <Head>에 뿌리게 만드는 것.
type TProps = {
children: JSX.Element;
ogTitleProps?: string;
ogDescriptionProps?: string;
ogUrlProps?: string;
ogTypeProps?: string;
jsonLd?: Record<string, unknown>;
};
const RootLayout = ({ children, ogTitleProps, ogDescriptionProps, ogUrlProps, ogTypeProps, jsonLd }: TProps) => {
// 핵심 문구만 넘기면 브랜드명은 자동으로 붙는다
const ogTitle = ogTitleProps ?? "브랜드명 .....";
const ogDescription = ogDescriptionProps ?? "....";
const ogUrl = ogUrlProps ?? "https://naver.com";
const ogType = ogTypeProps ?? "website";
return (
<>
<Head>
<title>{ogTitle} | 플랫폼</title>
<meta name="description" content={ogDescription} key="description" />
<meta property="og:type" content={ogType} key="og:type" />
<meta property="og:title" content={ogTitle} />
<meta property="og:url" content={ogUrl} key="og:url" />
<link rel="canonical" href={ogUrl} key="canonical" />
{jsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
)}
</Head>
{/* ... */}
</>
);
};💡 Pages Router의
key프롭이 핵심이다.<meta ... key="description" />처럼 key를 주면 개별 페이지가 같은 key로 메타를 다시 렌더링할 때 중복 삽입이 아니라 덮어쓰기가 된다. 이게 없으면 레이아웃 기본값과 페이지 값이 둘 다 들어가 버린다.
✅ 설계 포인트 — 각 페이지는 핵심 키워드만 넘긴다. 브랜드명
| 브랜드명은 레이아웃이 자동으로 붙이므로 페이지 코드에 브랜드명이 반복되지 않는다.
토대가 생겼으니 이제 페이지별 문구를 넣는다. 여기서 title과 description의 SEO 영향이 다르다는 걸 짚고 가야 한다.
| 태그 | 영향 | 작성 원칙 |
|---|---|---|
title | 직접 + 간접. 랭킹 신호로 직접 사용 + 검색결과 파란 링크로 CTR에 영향 | 핵심 키워드를 앞에 배치 |
description | 간접만. 랭킹 신호 아님. 회색 스니펫으로 CTR에만 간접 작용 | 자연스러운 문장으로 클릭 유도 (키워드 나열 금지) |
콘텐츠 데이터가 없는 홈·소개·목록 페이지는 문구를 손으로 쓴다.
// 목록 페이지
<RootLayout
ogTitleProps="~~ 목록"
ogDescriptionProps="~~~ 한눈에 확인하세요."
/>상세 페이지는 수백 개다. 손으로 못 쓴다. 콘텐츠 데이터에서 규칙 기반으로 자동 생성한다. 도메인별 util로 분리했다.
/** 콘텐츠 관련 응답을 가지고 사람이 읽기 좋은 SEO 문장으로 조합한다. 비어있는 필드는 자동으로 건너뛴다. */
export const buildItemSeoTitle = (itemDetail: TItemDetailRes) => {
const { name, region, category, subType } = itemDetail;
const regionLabel = [region.area, region.district[0]].filter(Boolean).join(" ");
const categoryLabel = CATEGORY_LABELS[category] ?? "";
const typeLabel = [subType, categoryLabel].filter(Boolean).join(" ");
// 형태: "이름 | 지역 라벨"
return [name, [regionLabel, typeLabel].filter(Boolean).join(" ")].filter(Boolean).join(" | ");
};💡
filter(Boolean).join(" ")패턴이 반복되는 이유 — 실무 데이터는 항상 빈 필드가 있다. 지역 정보가 없는 항목, 이름이 안 적힌 항목.filter(Boolean)으로 빈 값을 먼저 걸러내면 공백이 두 칸 들어가거나"| .."처럼 구분자만 덩그러니 남는 사고를 막는다. 자동 생성의 품질은 결측치 처리에서 갈린다.
description도 같은 방식으로 문장을 조립한다:
export const buildItemSeoDescription = (itemDetail: TItemDetailRes) => {
const { name, region, subType, authors, makers } = itemDetail;
const regionLabel = /* ... */;
const typeLabel = /* ... */;
const lead = regionLabel
? `${regionLabel}에 위치한 ${typeLabel} 항목 '${name}'입니다.`
: `${typeLabel} 항목 '${name}'입니다.`;
const credits = [
authors.length ? `작성 ${authors.join(", ")}` : "",
makers.length ? `제작 ${makers.join(", ")}` : "",
].filter(Boolean).join(", ");
};사이트 전체에 구조화 데이터가 0건이었다. 봇 입장에선 어떤 유형의 상세 페이지인지, 회사 소개인지, 제품인지 알 길이 없었다. 해법은 schema.org 타입을 JSON-LD로 심는 것.
| 페이지 | schema.org 타입 |
|---|---|
| 홈 | Organization • WebSite |
| 콘텐츠 상세 (글 유형) | Article |
| 단체·조직 상세 | Organization |
| 상품 상세 | Product |
앞서 만든 jsonLd 프롭에 객체만 넘기면 레이아웃이 <script type="application/ld+json">으로 직렬화해 준다.
// 콘텐츠 상세 페이지 (getServerSideProps 안)
const itemTitle = buildItemSeoTitle(itemDetail);
const itemDescription = buildItemSeoDescription(itemDetail);
return {
props: {
rootLayoutProps: {
ogTitleProps: itemTitle,
ogDescriptionProps: itemDescription,
ogTypeProps: "article",
jsonLd: {
"@context": "https://schema.org",
"@type": "Article",
headline: itemTitle,
author: { "@type": "Organization", name: "플랫폼" },
publisher: { "@type": "Organization", name: "플랫폼" },
},
},
},
};⚠️ 모든 페이지에 무작정 넣지 않았다. 일부 유형의 상세 페이지는 본문 텍스트가 빈약해서 구조화 데이터를 억지로 넣으면 오히려 "빈 껍데기" 신호가 될 수 있다. 콘텐츠 품질이 받쳐줄 때만 적용하는 게 맞다.
같은 콘텐츠가 여러 URL로 흩어져 있으면 검색 점수도 흩어진다. ?sort=, ?page=, ?category=, 그리고 www 유무까지. 여기서 미묘한 판단이 필요했다.
💬 정렬·페이지네이션은 보여주는 내용이 같으므로 대표 주소로 접어야 하지만 카테고리는 보여주는 목록이 실제로 다르므로 각자 살려야 한다. "canonical = 무조건 하나로 합치기"가 아니다.
// 카테고리별 SEO 메타. 기본 카테고리는 대표 URL로 접고, 비기본만 self-canonical.
const CATEGORY_SEO: Record<string, { title: string; description: string }> = {
CATEGORY_B: {
title: "카테고리B 목록",
description: "카테고리B 사례를 한눈에 확인하세요. ...",
},
};
export const buildCategorySeo = (category: string | undefined) => {
const base = `${process.env.NEXT_PUBLIC_SITE_URL}/items`;
const meta = category ? CATEGORY_SEO[category] : undefined;
if (!meta) {
// 기본(CATEGORY_A) 및 화이트리스트 밖 값 → 대표 URL로 접음
return {
title: "전체 목록",
description: "전체 사례를 한눈에 확인하세요.",
ogUrlProps: base,
};
}
// 알려진 카테고리 → self-canonical
return { title: meta.title, description: meta.description, ogUrlProps: `${base}?category=${category}` };
};화이트리스트 방식을 쓴 것도 의도적이다. ?category=에 아무 값이나 들어와 무한히 색인되는 걸 막고, 우리가 노리는 카테고리만 골라 검색 채널로 살린다.
기존 public/sitemap.xml은 목록 페이지 15개뿐, 개별 상세 주소는 0개였다. 게다가 수동 정적 파일이라 콘텐츠가 추가돼도 반영이 안 됐다.
pages/sitemap.xml.ts 동적 라우트로 바꿨다. BE가 내려주는 콘텐츠 ID+수정일로 매 요청 시 sitemap을 생성한다.
import { ROUTES } from "@/routes/routes";
// ROUTES 상수를 재사용해야 실제 라우트와 100% 일치한다.
const DETAIL_PATH = {
typeA: ({ id }) => ROUTES.typeA.detail(id),
typeB: ({ id }) => ROUTES.typeB.detail(id),
typeC: ({ id }) => ROUTES.typeC.detail(id),
};
const urls: { url: string; lastmod?: string }[] = [];
// ... 콘텐츠마다:
urls.push({
url: /* ... */,
// lastModified가 없으면 lastmod 생략 (부정확한 날짜를 넣지 않음)
...(entry.lastModified && { lastmod: entry.lastModified }),
});
export const getServerSideProps: GetServerSideProps = async ({ res }) => {
// 1시간 캐싱
res.setHeader("Cache-Control", "public, max-age=3600, s-maxage=3600");
// ...
};✅ 설계에서 챙긴 3가지 —
①ROUTES상수 재사용: sitemap URL을 문자열로 하드코딩하면 라우트 구조가 바뀔 때 sitemap만 조용히 틀어진다. 실제 라우트 함수를 그대로 쓰면 항상 일치한다.
② **lastmod**는 있을 때만: 부정확한 날짜를 넣느니 생략한다. 있으면 봇이 "변경분만 재크롤링"한다.
③ 1시간 캐싱: 매 봇 방문마다 DB를 때리지 않도록.
나머지 작업들. _document.tsx에서 처리한다.
// _document.tsx
<Html lang="ko"> {/* en → ko: 한국어 사이트임을 명시 */}
<Head>
<meta name="robots" content="index, follow, max-image-preview:large" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@your_handle" />
{/* OG 이미지: 1200×630, og:image:alt, og:locale=ko_KR ... */}
</Head>
</Html>lang="en" → lang="ko": 한 줄 수정이지만 한국어 검색 노출·스크린리더 발음에 직결.Disallow: /로 막아 개발 URL이 검색에 뜨는 사고를 방지.| 교훈 | 왜 중요한가 |
|---|---|
| 메타 주입은 공통 토대부터 | 페이지마다 <Head> 복붙하면 canonical·OG가 어긋나기 시작함. props로 한 곳에서. |
| 자동 생성의 품질은 결측치 처리 | filter(Boolean) 없이는 실데이터에서 공백·구분자 사고가 남 |
| canonical은 합치기 ≠ 정답 | 내용이 같으면 접고, 다르면 살린다. 카테고리는 검색 채널로. |
| shallow 라우팅 = 메타 유령 버그 | SSR이 재실행 안 되면 CSR에서 <Head>로 메타를 직접 갱신해야 함 |
| sitemap은 상수 재사용으로 | URL 하드코딩하면 라우트 변경 시 sitemap만 조용히 틀어짐 |
ℹ️ 마지막으로, 검색 노출·순위 변화는 색인 주기상 통상 2~4주 이상 걸린다. 배포 직후 효과를 기대하기보다 Google Search Console 지표를 주기적으로 관찰하는 게 맞다. 그리고 단일 빅키워드는 경쟁이 극심하니, 여러 속성을 조합한 롱테일 키워드(예: "지역 + 카테고리 + 유형")로 세분화하는 편이 현실적이다.
📚 참고 출처
· How Google Search Works — 크롤링·색인·서빙 3단계
· JavaScript SEO Basics — 렌더링 큐 지연, SSR 권장
· Influencing Title Links — title이 랭킹·CTR에 미치는 영향
· 네이버 서치어드바이저 가이드