logo
홈블로그소개
4,186

Built with Next.js, Bun, Tailwind CSS and Shadcn/UI

·개인정보처리방침
Next.jsReactWeb

Next.js 실무 SEO 최적화: 검색엔진이 이해하는 사이트 만들기

Toma
2026년 7월 1일
약 18분
목차
🧭 먼저: 검색엔진은 어떻게 동작하는가
🩺 진단: 무엇이 문제였나
🏗️ 1. 모든 메타를 한 곳에서 주입하는 토대 만들기
📝 2. 페이지마다 고유한 제목·설명 (중복 색인 탈출)
정적·목록 페이지 — 직접 작성
상세 페이지 — 콘텐츠로 자동 생성
🏷️ 3. 검색엔진에 "이게 뭔지" 알려주기 (JSON-LD)
🎯 4. 대표 주소 하나로 통일 (canonical)
🗺️ 5. 동적 sitemap — 봇에게 전체 지도 건네기
🤖 6. robots + lang + 소셜 메타 마무리
🧩 실무에서 배운 것 (요약)
이전 포스트Google ADK : Loop Agent · 평가 · API 서버 · SSE · Vertex AI 배포
다음 포스트멀티레포에서 Turborepo 모노레포로: 개념부터 실전 이슈까지

목차

🧭 먼저: 검색엔진은 어떻게 동작하는가
🩺 진단: 무엇이 문제였나
🏗️ 1. 모든 메타를 한 곳에서 주입하는 토대 만들기
📝 2. 페이지마다 고유한 제목·설명 (중복 색인 탈출)
정적·목록 페이지 — 직접 작성
상세 페이지 — 콘텐츠로 자동 생성
🏷️ 3. 검색엔진에 "이게 뭔지" 알려주기 (JSON-LD)
🎯 4. 대표 주소 하나로 통일 (canonical)
🗺️ 5. 동적 sitemap — 봇에게 전체 지도 건네기
🤖 6. robots + lang + 소셜 메타 마무리
🧩 실무에서 배운 것 (요약)

특정 플랫폼의 SEO를 처음부터 끝까지 뜯어고쳤다. "SEO 좀 해줘"라는 막연한 요청을 검색엔진이 실제로 사이트를 처리하는 순서에 맞춰 하나씩 풀어낸 기록이다. Next.js(Pages Router) 기준이지만 원리는 어떤 스택에도 적용된다.

🎯 이 글이 다루는 것 — 검색엔진 동작 원리(크롤링·색인·서빙), 페이지별 메타 태그 설계, JSON-LD 구조화 데이터, canonical 중복 처리, 동적 sitemap, robots. 그리고 각 작업을 "현재 상황 → 왜 문제 → 어떻게 → 효과" 순으로 풀어낸 실무 경험.


🧭 먼저: 검색엔진은 어떻게 동작하는가

작업을 시작하기 전에 이 그림부터 머리에 넣어야 한다. SEO 작업의 90%는 이 4단계 중 어딘가를 손보는 일이다.

plain
크롤링(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 없음)검색 점수가 여러 주소로 분산 → 어느 주소도 상위에 못 감
4sitemap에 개별 콘텐츠 주소가 0개봇이 상세 페이지를 늦게 발견하거나 누락 → 색인 누락
5HTML 언어가 영어(lang="en")한국어 사이트인데 영어로 표기 → 한국어 검색 노출에 불리

이제 하나씩, 왜 그렇게 고쳤는지와 함께 본다.


🏗️ 1. 모든 메타를 한 곳에서 주입하는 토대 만들기

다른 모든 작업이 이 토대 위에 올라간다. 문제는 단순했다 — 페이지마다 다른 메타를 주입할 공통 수단이 없었다. 모든 페이지가 똑같은 <Head>를 공유하고 있었다.

해법은 공통 레이아웃(RootLayout)이 페이지별 메타를 props로 받아 <Head>에 뿌리게 만드는 것.

typescript
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로 메타를 다시 렌더링할 때 중복 삽입이 아니라 덮어쓰기가 된다. 이게 없으면 레이아웃 기본값과 페이지 값이 둘 다 들어가 버린다.

✅ 설계 포인트 — 각 페이지는 핵심 키워드만 넘긴다. 브랜드명 | 브랜드명 은 레이아웃이 자동으로 붙이므로 페이지 코드에 브랜드명이 반복되지 않는다.


📝 2. 페이지마다 고유한 제목·설명 (중복 색인 탈출)

토대가 생겼으니 이제 페이지별 문구를 넣는다. 여기서 title과 description의 SEO 영향이 다르다는 걸 짚고 가야 한다.

태그영향작성 원칙
title직접 + 간접. 랭킹 신호로 직접 사용 + 검색결과 파란 링크로 CTR에 영향핵심 키워드를 앞에 배치
description간접만. 랭킹 신호 아님. 회색 스니펫으로 CTR에만 간접 작용자연스러운 문장으로 클릭 유도 (키워드 나열 금지)

정적·목록 페이지 — 직접 작성

콘텐츠 데이터가 없는 홈·소개·목록 페이지는 문구를 손으로 쓴다.

typescript
// 목록 페이지
<RootLayout
  ogTitleProps="~~ 목록"
  ogDescriptionProps="~~~ 한눈에 확인하세요."
/>

상세 페이지 — 콘텐츠로 자동 생성

상세 페이지는 수백 개다. 손으로 못 쓴다. 콘텐츠 데이터에서 규칙 기반으로 자동 생성한다. 도메인별 util로 분리했다.

typescript
/** 콘텐츠 관련 응답을 가지고 사람이 읽기 좋은 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도 같은 방식으로 문장을 조립한다:

typescript
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(", ");
};

🏷️ 3. 검색엔진에 "이게 뭔지" 알려주기 (JSON-LD)

사이트 전체에 구조화 데이터가 0건이었다. 봇 입장에선 어떤 유형의 상세 페이지인지, 회사 소개인지, 제품인지 알 길이 없었다. 해법은 schema.org 타입을 JSON-LD로 심는 것.

페이지schema.org 타입
홈Organization • WebSite
콘텐츠 상세 (글 유형)Article
단체·조직 상세Organization
상품 상세Product

앞서 만든 jsonLd 프롭에 객체만 넘기면 레이아웃이 <script type="application/ld+json">으로 직렬화해 준다.

typescript
// 콘텐츠 상세 페이지 (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: "플랫폼" },
      },
    },
  },
};

⚠️ 모든 페이지에 무작정 넣지 않았다. 일부 유형의 상세 페이지는 본문 텍스트가 빈약해서 구조화 데이터를 억지로 넣으면 오히려 "빈 껍데기" 신호가 될 수 있다. 콘텐츠 품질이 받쳐줄 때만 적용하는 게 맞다.


🎯 4. 대표 주소 하나로 통일 (canonical)

같은 콘텐츠가 여러 URL로 흩어져 있으면 검색 점수도 흩어진다. ?sort=, ?page=, ?category=, 그리고 www 유무까지. 여기서 미묘한 판단이 필요했다.

💬 정렬·페이지네이션은 보여주는 내용이 같으므로 대표 주소로 접어야 하지만 카테고리는 보여주는 목록이 실제로 다르므로 각자 살려야 한다. "canonical = 무조건 하나로 합치기"가 아니다.

typescript
// 카테고리별 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=에 아무 값이나 들어와 무한히 색인되는 걸 막고, 우리가 노리는 카테고리만 골라 검색 채널로 살린다.


🗺️ 5. 동적 sitemap — 봇에게 전체 지도 건네기

기존 public/sitemap.xml은 목록 페이지 15개뿐, 개별 상세 주소는 0개였다. 게다가 수동 정적 파일이라 콘텐츠가 추가돼도 반영이 안 됐다.

pages/sitemap.xml.ts 동적 라우트로 바꿨다. BE가 내려주는 콘텐츠 ID+수정일로 매 요청 시 sitemap을 생성한다.

typescript
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를 때리지 않도록.


🤖 6. robots + lang + 소셜 메타 마무리

나머지 작업들. _document.tsx에서 처리한다.

typescript
// _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": 한 줄 수정이지만 한국어 검색 노출·스크린리더 발음에 직결.
  • robots 메타: 색인·팔로우·큰 이미지 미리보기 허용을 명시.
  • Twitter Card / Open Graph: 공유 시 큰 이미지 카드로 클릭 유도.
  • robots.txt: 관리자·로그인·마이페이지 등 비공개 경로 차단 + sitemap 위치 안내. 운영 환경에서만 색인 허용하고 개발 서버는 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에 미치는 영향
· 네이버 서치어드바이저 가이드