next-seo란?

이언덕·2025년 10월 4일
post-thumbnail

1. 📖 next-seo란?

SEO(Search Engine Optimization)에 대해서는 이전 글에서 정리한 SEO 기본 개념을 참고하면 된다.
여기서는 SEO 중에서도 가장 기초가 되는 메타 태그 관리 문제를 어떻게 더 간단히 풀 수 있을지를 다룬다.


웹사이트를 만들면 꼭 따라오는 단어가 하나 있다. 바로 SEO(Search Engine Optimization, 검색 엔진 최적화)다.
쉽게 말해, 내 웹사이트가 구글이나 네이버 같은 검색엔진에서 잘 보이게 만드는 기술이다.
SEO에 대해 깊이 들어가면 복잡하지만, 가장 기본은 메타 태그(meta tag) 라는 걸 잘 관리하는 데서 시작한다.

예를 들어 이런 태그들이다:

  • <title>: 브라우저 탭에 보이는 제목
  • <meta name="description">: 검색결과에 표시되는 요약 설명
  • <meta property="og:image">: 카톡이나 트위터에 링크를 붙였을 때 나오는 썸네일 이미지


    Next.js에서는 이런 태그를 next/head라는 컴포넌트로 직접 작성할 수 있다.
    하지만 페이지가 많아지면 이런 코드가 여기저기 중복되고, 실수로 빼먹기도 쉽다.


    👉 여기서 등장하는 게 바로 next-seo라는 라이브러리다.
  • Next.js 프로젝트에서 SEO 태그를 한 곳에서 관리할 수 있게 도와준다.
  • 사이트 공통 설정(DefaultSeo)페이지별 개별 설정(NextSeo) 을 깔끔하게 분리할 수 있다.
  • Open Graph(OG)나 Twitter Card 같은 소셜 공유용 태그도 자동으로 붙여준다.

즉, next-seo는 “SEO 태그를 손쉽게 관리해주는 도우미”라고 생각하면 된다.
Next.js에서 규모 있는 프로젝트를 하려면 거의 필수에 가깝다.



2. 🤔 왜 next-seo를 사용해야 할까?

Next.js에서는 기본적으로 next/head를 이용해 SEO 태그를 직접 작성할 수 있다.
예를 들어 블로그 글 페이지라면 이렇게 쓸 수 있다:

import Head from "next/head";

export default function BlogPage() {
  return (
    <>
      <Head>
        <title>내 블로그 글 제목</title>
        <meta name="description" content="이 글은 Next.js SEO에 관한 글입니다." />
        <meta property="og:title" content="내 블로그 글 제목" />
        <meta property="og:description" content="이 글은 Next.js SEO에 관한 글입니다." />
        <meta property="og:image" content="/og-image.png" />
      </Head>
      <main>...</main>
    </>
  );
}

겉보기에는 큰 문제가 없어 보인다. 하지만 프로젝트 규모가 커지면 이런 방식은 곧 한계에 부딪힌다.



next/head만 쓸 때 문제점

  • 페이지마다 중복되는 태그를 계속 써야 한다 (title, description, OG 등)
  • 기본값이 바뀌면 모든 파일을 수정해야 한다
  • 태그를 빠뜨리거나 오타를 내면 검색 노출이 떨어진다
  • 소셜 미리보기(카톡/트위터 카드)까지 챙기려면 태그 관리가 점점 복잡해진다

이런 이유로 규모가 커지면 유지보수가 힘들어진다.

next-seo를 쓰면 좋은 점

  • 한 곳에서 전역 기본값 관리: 사이트 전체에 공통으로 적용되는 제목, 설명, 썸네일 이미지를 설정할 수 있다.
  • 페이지마다 필요한 부분만 덮어쓰기: 블로그 글, 제품 상세 페이지처럼 개별 SEO 정보가 필요한 곳에서만 추가 설정 가능.
  • OG/Twitter 카드 자동화: 소셜 미리보기 태그를 한 번에 관리할 수 있다.
  • JSON-LD 구조화 데이터 지원: 검색엔진이 페이지 성격(FAQ, 블로그 글, 제품 정보 등)을 더 잘 이해하도록 도와준다.


3. ⚙️ next-seo 주요기능

3-1) 전역 SEO 기본값 관리 (DefaultSeo)

무엇을 하는 기능인가?

  • 사이트 전체에 공통적으로 적용될 SEO 정보(제목, 설명, 대표 이미지 등)를 한 번에 설정한다.
  • 예를 들어 “사이트명”, “서비스 설명”, “대표 OG 이미지” 같은 값은 모든 페이지에 반복적으로 쓰이는데, 이를 전역 기본값으로 묶어두면 새 페이지를 만들 때 따로 적지 않아도 자동으로 반영된다.
  • DefaultSeo는 말 그대로 “SEO의 기본 세트”를 사이트 전역에서 관리하는 역할을 한다.


    왜 중요한가?
  • 공통 정보를 한 곳에서 관리하므로 브랜드명·대표 이미지 변경 시 한 번 수정으로 전체 반영할 수 있다.
  • 모든 페이지가 최소한의 SEO 정보를 자동으로 가져가기 때문에 빠뜨리는 실수를 막고, 일관성을 유지할 수 있다.


    코드 예시
// src/seo/next-seo.config.ts
// 👉 사이트 전역 기본 SEO 세팅을 모아두는 설정 파일
import type { DefaultSeoProps } from "next-seo";

const SEO_CONFIG: DefaultSeoProps = {
  titleTemplate: "%s | PlanMate",
  defaultTitle: "PlanMate - 나만의 맞춤형 플래너",
  description: "일간·주간·월간·투두·습관·메모를 자유롭게 조합하는 커스텀 플래너",
  canonical: "https://planmate.com",
  openGraph: {
    type: "website",
    locale: "ko_KR",
    url: "https://planmate.com",
    siteName: "PlanMate",
    images: [
      {
        url: "https://planmate.com/og/og-default.png", // OG 기본 이미지
        width: 1200,
        height: 630,
        alt: "PlanMate 대표 이미지",
      },
    ],
  },
  twitter: {
    cardType: "summary_large_image",
  },
};

export default SEO_CONFIG;
// app/layout.tsx
// 👉 App Router의 최상위 레이아웃에서 불러와 전역에 적용
import "./globals.css";
import { DefaultSeo } from "next-seo";
import SEO_CONFIG from "@/seo/next-seo.config";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body>
        {/* 전역 SEO 기본값을 사이트 전체에 적용 */}
        <DefaultSeo {...SEO_CONFIG} />
        {children}
      </body>
    </html>
  );
}


3-2) 페이지별 SEO 오버라이드 (NextSeo)

무엇을 하는 기능인가?

  • 블로그 글이나 제품 상세 페이지처럼 각 페이지마다 다른 SEO 정보가 필요한 경우, 전역 기본값을 덮어쓰는 기능이다.
  • 예를 들어 블로그 글 상세 페이지라면, 글의 제목과 요약이 <title><meta description>에 들어가야 한다. 이때 NextSeo를 사용하면 해당 페이지에서만 전역 기본값을 덮어써서 고유한 정보를 제공할 수 있다.
  • NextSeo는 전역 설정을 토대로, 페이지별로 고유한 제목·설명·이미지·URL을 부여하는 역할을 한다.


    왜 중요한가?
  • 검색엔진은 각 페이지가 고유한 메타 정보를 가질 때 더 높은 점수를 준다.
  • 모든 페이지가 똑같은 제목·설명을 갖고 있으면 검색엔진 입장에서는 중복 콘텐츠로 인식돼 노출이 불리해진다.


    코드 예시
// app/blog/[slug]/page.tsx
// 👉 블로그 상세 페이지에서 글마다 다른 SEO 태그 적용
import { NextSeo } from "next-seo";

export default function BlogDetailPage({ params }: { params: { slug: string } }) {
  const url = `https://planmate.com/blog/${params.slug}`;

  return (
    <>
      <NextSeo
        title="Zustand vs Redux - 무엇을 선택할까?"
        description="프론트엔드 상태관리 라이브러리 비교, 선택 기준 정리"
        canonical={url}
        openGraph={{
          type: "article",
          url,
          title: "Zustand vs Redux - 무엇을 선택할까?",
          description: "프론트엔드 상태관리 라이브러리 비교, 선택 기준 정리",
          images: [
            {
              url: "https://planmate.com/og/zustand-vs-redux.png",
              width: 1200,
              height: 630,
              alt: "상태 관리 비교",
            },
          ],
          article: {
            publishedTime: "2025-10-01T09:00:00+09:00",
            modifiedTime: "2025-10-02T10:00:00+09:00",
            authors: ["https://planmate.com/about"],
          },
        }}
      />
      <main>{/* 블로그 본문 내용 */}</main>
    </>
  );
}


3-3) 소셜 미리보기 자동화 (Open Graph / Twitter Card)

무엇을 하는 기능인가?

  • 링크를 카카오톡, 페이스북, 트위터, 슬랙 등에 공유했을 때 표시되는 미리보기 카드(제목, 설명, 이미지) 를 자동으로 만들어준다.
  • Open Graph(OG)와 Twitter Card 표준을 지원해, 어디서 공유하든 일관된 미리보기를 보여줄 수 있다.
  • og:title, og:description, og:image 같은 태그를 반복 작성하지 않아도 된다.


    왜 중요한가?
  • 공유 카드가 깔끔하면 클릭률(CTR)이 올라가고 유입이 늘어난다.
  • 브랜드 신뢰도를 높이고, SNS에서 콘텐츠가 눈에 잘 띄도록 해준다.


    코드 예시
// app/promo/fall-2025/page.tsx
// 👉 프로모션 랜딩 페이지에서 공유용 OG/Twitter 카드 지정
import { NextSeo } from "next-seo";

export default function PromoFall2025Page() {
  return (
    <>
      <NextSeo
        title="PlanMate 가을 프로모션"
        description="지금 가입하면 3개월 무료! 커스텀 플래너를 경험해보자."
        openGraph={{
          title: "PlanMate 가을 프로모션",
          description: "지금 가입하면 3개월 무료! 커스텀 플래너를 경험해보자.",
          images: [
            {
              url: "https://planmate.com/og/promo-fall-2025.png",
              width: 1200,
              height: 630,
              alt: "가을 프로모션 배너",
            },
          ],
        }}
        twitter={{ cardType: "summary_large_image" }}
      />
      <main>{/* 프로모션 콘텐츠 */}</main>
    </>
  );
}


3-4) JSON-LD 구조화 데이터 지원

무엇을 하는 기능인가?

  • 검색엔진이 페이지 성격을 정확히 이해할 수 있도록 JSON-LD 구조화 데이터를 붙인다.
  • 블로그 글은 Article, FAQ는 FAQPage, 상품은 Product처럼 각 페이지 목적에 맞는 스키마를 선언할 수 있다.
  • next-seoArticleJsonLd, FAQPageJsonLd 같은 컴포넌트를 제공한다.


    왜 중요한가?
  • 조건을 만족하면 구글 검색결과에서 리치 스니펫(FAQ 토글, 별점, 가격 등) 으로 노출되어 클릭률 상승 효과를 기대할 수 있다.


    코드 예시
// app/blog/next-seo-guide/page.tsx
// 👉 블로그 글 + FAQ 섹션에 구조화 데이터 추가
import { NextSeo, ArticleJsonLd, FAQPageJsonLd } from "next-seo";

export default function NextSeoGuideArticle() {
  const url = "https://planmate.com/blog/next-seo-guide";

  return (
    <>
      <NextSeo title="Next.js에서 next-seo 완전 가이드" description="메타/OG/JSON-LD 관리법" canonical={url} />
      <ArticleJsonLd
        url={url}
        title="Next.js에서 next-seo 완전 가이드"
        images={["https://planmate.com/og/next-seo-guide.png"]}
        datePublished="2025-10-01"
        dateModified="2025-10-02"
        authorName="이현석"
        publisherName="PlanMate"
        description="메타 태그와 OG, JSON-LD를 next-seo로 관리하는 방법"
      />
      <FAQPageJsonLd
        mainEntity={[
          {
            questionName: "OG 이미지 권장 크기는?",
            acceptedAnswerText: "1200x630 픽셀을 권장합니다. 용량은 300KB 이하가 적당합니다.",
          },
        ]}
      />
      <main>{/* 글 본문 */}</main>
    </>
  );
}


3-5) 검색 노출 제어 (noindex, nofollow)

무엇을 하는 기능인가?

  • 특정 페이지를 검색엔진이 색인하지 않거나, 내부 링크를 따라가지 않도록 설정한다.
  • noindex는 페이지 자체가 검색결과에 나타나지 않게 하고, nofollow는 페이지 안의 링크를 따라가지 못하게 한다.
  • 마이페이지, 결제 완료 페이지, 관리자 화면 등 외부 노출이 불필요한 페이지에서 활용된다.


    왜 중요한가?
  • 검색에 노출되면 안 되는 페이지가 실수로 올라가면 브랜딩·보안에 악영향을 끼친다.
  • 필요한 페이지만 검색에 반영되도록 관리해 SEO 효율을 높일 수 있다.


    코드 예시
// app/internal/labs/page.tsx
// 👉 내부 테스트용 페이지를 검색 색인에서 제외
import { NextSeo } from "next-seo";

export default function InternalLabs() {
  return (
    <>
      <NextSeo
        title="Labs (Internal)"
        noindex
        nofollow
        robotsProps={{
          nosnippet: true,
          noimageindex: true,
        }}
      />
      <main>{/* 내부용 실험 페이지 */}</main>
    </>
  );
}


3-6) 정규 URL & 다국어 지원 (canonical, alternate)

무엇을 하는 기능인가?

  • 같은 내용이 여러 주소에서 보일 때, 대표 URL(canonical) 을 지정해 중복 문제를 해결한다.
  • 다국어 페이지에서는 alternate 속성을 통해 언어별 버전을 서로 연결해줄 수 있다. (ko, en, x-default)
  • 예: /ko/post/1, /en/post/1 같은 페이지를 올바르게 인식시키는 데 사용한다.


    왜 중요한가?
  • 중복 페이지가 많으면 검색엔진이 점수를 분산시켜 검색 순위가 떨어질 수 있다.
  • 다국어 사이트에서 사용자가 자신의 언어 버전을 더 쉽게 찾을 수 있게 도와준다.


    코드 예시
// app/[locale]/blog/[slug]/page.tsx
// 👉 다국어 블로그 글 페이지에서 canonical + hreflang 지정
import { NextSeo } from "next-seo";

export default function I18nBlogPage({ params }: { params: { locale: "ko" | "en"; slug: string } }) {
  const base = "https://planmate.com";
  const url = `${base}/${params.locale}/blog/${params.slug}`;

  return (
    <>
      <NextSeo
        canonical={url}
        additionalLinkTags={[
          { rel: "alternate", href: `${base}/ko/blog/${params.slug}`, hrefLang: "ko" },
          { rel: "alternate", href: `${base}/en/blog/${params.slug}`, hrefLang: "en" },
          { rel: "alternate", href: `${base}/blog/${params.slug}`, hrefLang: "x-default" },
        ]}
      />
      <main>{/* 다국어 페이지 본문 */}</main>
    </>
  );
}


4. 🚀 프로젝트에 적용하기

4-0) 📦 next-seo 설치

# npm
npm install next-seo

# yarn
yarn add next-seo

# pnpm
pnpm add next-seo
  • 타입 정의는 패키지에 포함되어 있어 @types/* 별도 설치 불필요하다.
  • App Router 사용 시 JSON-LD 컴포넌트에 useAppDir={true} 옵션을 주는 걸 권장한다.

4-1) 컴포넌트 & 페이지 없을 때: 최소 세팅만으로 배포 환경 점검

목표: 화면(UI) 없이도 전역 SEO 기본값을 깔고, 더미 라우트 1–2개로 OG/Twitter 카드·인덱싱이 올바르게 동작하는지 바로 확인.

(A) 베이스 URL 유틸 + 환경변수

# .env.local (로컬)
NEXT_PUBLIC_SITE_URL=http://localhost:3000

# (예: Vercel Production) 환경변수
NEXT_PUBLIC_SITE_URL=https://planmate.com
// src/seo/baseUrl.ts
// 👉 절대 URL 생성 유틸. canonical/OG에 사용
export const getBaseUrl = () => {
  const fromEnv = process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "");
  return fromEnv ?? "http://localhost:3000";
};

export const withBase = (path = "/") => {
  const base = getBaseUrl();
  if (!path.startsWith("/")) return path; // 이미 절대 주소면 그대로
  return `${base}${path}`;
};

💡 왜 “환경변수 + 유틸”로 나누나?

  • canonical, og:image 등은 절대 URL을 요구/권장 → 소셜 크롤러에서 깨짐 방지.
  • 로컬/프리뷰/운영 도메인이 달라서 하드코딩하면 실수(랭킹 신호 분산, 카드 깨짐).
  • SSR/SSG에선 window.location 사용 불가 → 서버에서도 안전하게 절대경로 생성 필요.
  • 도메인 변경/서브도메인 분리 시 한 곳(env+유틸)만 바꾸면 전체 반영.
  • 슬래시 중복/누락 같은 자잘한 버그를 유틸에서 흡수 → 메타 태그 품질 안정.

(B) 전역 기본값: DefaultSeo 주입

// src/seo/next-seo.config.ts
// 👉 사이트 공통 SEO 기본값
import type { DefaultSeoProps } from "next-seo";
import { getBaseUrl, withBase } from "./baseUrl";

const base = getBaseUrl();

const SEO_CONFIG: DefaultSeoProps = {
  titleTemplate: "%s | MyPlanMate",
  defaultTitle: "MyPlanMate - 나만의 맞춤형 플래너",
  description:
    "일간·주간·월간·투두·습관·메모를 자유롭게 조합하는 커스텀 플래너",
  canonical: base,
  openGraph: {
    type: "website",
    locale: "ko_KR",
    url: base,
    siteName: "MyPlanMate",
    images: [
      {
        url: withBase("/og/og-default.png"), // 권장: 절대 URL
        width: 1200,
        height: 630,
        alt: "MyPlanMate 대표 이미지",
      },
    ],
  },
  twitter: { cardType: "summary_large_image" },
};

export default SEO_CONFIG;
// app/layout.tsx
// 👉 최상위 레이아웃에서 전역 기본값 1회 주입
// app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";
import { Providers } from "./providers";
import { DefaultSeo } from "next-seo";
import SEO_CONFIG from "@/seo/next-seo.config";

// ✅ 중복 방지를 위해 title/description 제거하고 최소한만 유지
export const metadata: Metadata = {
  // 절대 URL이 필요한 일부 Next.js 내부 태그용(선택)
  metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"),
  // 여기엔 icons/manifest/themeColor 등만 두고,
//  title: "Custom Daily Planner",        // ❌ 제거 (next-seo가 관리)
//  description: "나만의 맞춤형 플래너...", // ❌ 제거 (next-seo가 관리)
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body>
        {/* ✅ 전역 SEO 기본값을 사이트 전체에 적용 */}
        <DefaultSeo {...SEO_CONFIG} />

        {/*
          Providers로 감싸는 이유:
          - React Query의 QueryClientProvider를 전역 적용
          - 모든 하위 컴포넌트가 동일한 client & cache 공유
          - useQuery, useMutation 훅이 어디서든 정상 동작
        */}
        {/* 
          ✅ 추후 확장 예시:
          <AuthProvider>
            <ThemeProvider>
              <Providers>{children}</Providers>
            </ThemeProvider>
          </AuthProvider>
        */}
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

.



(C) 더미 라우트로 바로 검증

OG/Twitter 카드 테스트

// app/lab/og-preview/page.tsx
// 👉 공유 카드(OG/Twitter) 미리보기용 더미 페이지
import { NextSeo } from "next-seo";
import { withBase } from "@/seo/baseUrl";

export default function OgPreviewPage() {
  const title = "PlanMate 프로모션 Seed";
  const desc = "공유 카드(OG/Twitter) 테스트용 페이지";
  const image = withBase("/og/promo-seed.png");

  return (
    <>
      <NextSeo
        title={title}
        description={desc}
        openGraph={{
          title,
          description: desc,
          images: [{ url: image, width: 1200, height: 630, alt: title }],
        }}
        twitter={{ cardType: "summary_large_image" }}
      />
      <main className="p-8">OG/Twitter 카드 테스트 페이지</main>
    </>
  );
}

색인 차단(noindex/nofollow) 테스트

// app/lab/noindex/page.tsx
// 👉 내부/실험 페이지는 검색에서 가린다
import { NextSeo } from "next-seo";

export default function NoIndexPage() {
  return (
    <>
      <NextSeo
        title="Labs (Internal)"
        noindex
        nofollow
        robotsProps={{ nosnippet: true, noimageindex: true }}
      />
      <main className="p-8">내부 테스트 페이지(검색 제외)</main>
    </>
  );
}

.



(D) 이번 단계 요약

  • 베이스 URL 유틸 + 환경변수로 왜 분리를 했나?
    DefaultSeo 자체에 “필수”는 아니지만, canonical/og:image를 항상 절대 URL로 안전하게 넣고(로컬·프리뷰·운영 환경 대응) 실수 줄이려는 권장 패턴이라 분리하였다.

  • 이번에 실제로 주입한 기능은?
    전역 DefaultSeo 한 가지. (app/layout.tsx에서 <DefaultSeo {...SEO_CONFIG} />)
    사이트 전체 기본 타이틀/설명/OG/Twitter 카드 규칙을 “전역”으로 적용함.

  • 테스트로 무엇을 했나?
    더미 라우트 2종은 검증용일 뿐, 기능 추가가 아니다.
    • app/lab/og-preview/page.tsxOG/Twitter 카드가 제대로 표시되는지 확인
    • app/lab/noindex/page.tsxnoindex/nofollow가 정상 동작하는지 확인

  • 나중에 페이지가 생성되면 무엇을 추가하나?
    3-1 페이지별 SEO 오버라이드 (NextSeo)
    페이지 고유값을 전역값 위에 덮어씀.
    핵심 필드: title, description, canonical(절대 URL), openGraph.images[0].url(절대 URL)
    상황별 보완: openGraph.url, openGraph.type(website/article/product), article.publishedTime/modifiedTime/authors


    3-2 소셜 미리보기 자동화 (Open Graph / Twitter Card)
    공유 썸네일·제목·설명 품질 관리.
    권장: twitter.cardType = "summary_large_image", 필요 시 twitter.site/creator 설정.


    3-3 JSON-LD 구조화 데이터
    페이지 목적에 맞는 스키마를 선택적으로 추가.
    예: ArticleJsonLd(글), FAQPageJsonLd(FAQ), ProductJsonLd(상품), OrganizationJsonLd(브랜드), BreadcrumbJsonLd(빵부스러기).


    3-4 검색 노출 제어 (noindex, nofollow)
    내부/저가치/민감 페이지에만 제한적으로 적용. 필요 시 robotsProps(예: nosnippet, noimageindex).


    3-5 정규 URL & 다국어 (canonical, alternate)
    중복 경로는 대표 주소로 canonical 고정.
    다국어는 alternate(hreflang)로 언어 버전 연결 + 각 언어 페이지는 self-canonical.



(E) 화면에서 무엇을 확인하나 (디버그 코드 없이)

1) 전역 DefaultSeo 적용 확인 (아무 페이지나)

  • 브라우저 탭 제목
    • 전역 titleTemplate이 적용되어 {페이지제목} | MyPlanMate 형태로 보인다.
    • NextSeo를 쓰지 않은 페이지(예: 홈)가 있다면, 탭 제목이 전역 defaultTitle 그대로여야 한다.
  • (개발자도구 Elements > <head> 확인)
    • meta[name="description"]가 전역 description 값과 일치
    • link[rel="canonical"]절대 URL(환경변수 도메인)로 지정
    • meta[property="og:image"]절대 URL인지
      주의: 전역값은 기본값일 뿐. 이후 페이지에서 NextSeo로 덮어쓰면 페이지값이 우선한다.

2) app/lab/og-preview (OG/Twitter 카드 검증)

  • 브라우저 탭 제목
    • PlanMate 프로모션 Seed | MyPlanMate처럼 페이지 제목 + 전역 템플릿이 보인다.

  • (개발자도구 <head> 확인)
    • meta[property="og:title"] / meta[property="og:description"]가 페이지에서 설정한 값으로 들어갔는지
    • meta[property="og:image"]절대 URL이며 1개 이상 존재하는지
    • meta[name="twitter:card"]summary_large_image인지

  • (선택) 외부 화면에서 미리보기 확인
    • 카카오톡/슬랙/트위터에 페이지 URL을 붙여 카드 썸네일과 텍스트가 기대대로 보이는지

3) app/lab/noindex (noindex/nofollow 검증)

  • 브라우저 탭 제목
    • Labs (Internal) | MyPlanMate처럼 보이는지

  • (개발자도구 <head> 확인)
    • meta[name="robots"]contentnoindex, nofollow가 포함되어 있는지
    • 추가로 설정했다면 nosnippet, noimageindex도 함께 들어가 있는지

4) 절대 URL 일관성 (공통)

  • (개발자도구 <head> 확인)
    • link[rel="canonical"]href환경변수에 설정한 도메인으로 시작하는지
    • meta[property="og:image"]상대 경로(/og/...)가 아닌 절대 URL인지
      위 4가지만 눈으로 확인하면, 4-1 단계의 목표(전역 기본값 적용 + OG/인덱싱 동작 확인)가 충족된다.


🧯 트러블슈팅: App Router에서 <DefaultSeo> 쓰다 터질 때

상황 요약
<DefaultSeo> 추가해 둔 상태였고, App Router(app/)에서 렌더링 에러가 발생했다.
원인은 간단히 말해 <DefaultSeo>가 내부에서 next/head를 쓰기 때문이고, App Router에서는 next/head비호환이다.



1) 증상(대표 에러)

  • Invalid hook call / useContext(...) is null
  • 에러 스택에 Head / next/head 표시
  • 페이지 진입 시 500



2) 원인

  • App Router의 <head>는 Metadata API가 전담한다. Pages Router 시절처럼 next/head로 직접 조작하지 않는다.

  • next-seo<DefaultSeo>는 내부적으로 next/head 를 사용한다.
    App Router(서버 컴포넌트·스트리밍 렌더링)에는 next/head가 기대하는 Head 컨텍스트가 존재하지 않아 훅 호출이 깨지고, 그 결과 Invalid hook call/useContext 관련 오류가 난다.

  • 결론적으로 “App Router + <DefaultSeo>(= next/head) = 호환 불가” 이며, <NextSeo>도 동일한 이유로 오류가 난다.



3) 해결 방법

1. <DefaultSeo> 전부 제거

  • app/layout.tsx에서 import { DefaultSeo } from "next-seo" 및 JSX <DefaultSeo …/> 삭제.
  • 전역 검색으로 DefaultSeo/NextSeo/next/head 잔여물 없는지 확인.

2. 전역 메타를 Metadata API로 이관

  • app/layout.tsxexport const metadata 추가(아래 예시 제공).
  • metadataBaseNEXT_PUBLIC_SITE_URL로 설정해 canonical/OG의 절대 URL을 자동 보장.
  • 전역 값(타이틀 템플릿, 설명, OG/Twitter 기본값) 선언.

3. 공통 상수 분리

  • src/seo/constants.ts 생성: SITE_URL, SITE_NAME, DEFAULT_TITLE, TITLE_TEMPLATE, DEFAULT_DESCRIPTION, OG_DEFAULT_IMAGE, LOCALE한 곳에서 관리.
  • layout.tsx는 이 상수만 import해서 사용(변경 지점 단일화).

4. 테스트 라우트는 Metadata API만 사용

  • /app/lab/og-preview/page.tsx: export const metadatatitle/description/og:image.
  • /app/lab/noindex/page.tsx: export const metadata.robotsindex:false, follow:false.
  • NextSeo/DefaultSeo는 테스트 라우트에서도 금지.

5. 환경 변수 점검

  • 각 환경에 NEXT_PUBLIC_SITE_URL 정확히 설정(마지막 슬래시 제거).
  • 도메인 변경 시 constants.ts/환경변수만 바꾸면 전역 반영.

6. OG 이미지/경로 확인

  • OG_DEFAULT_IMAGE 및 각 페이지 og:image실존 파일이며 공개 접근 가능(403/404 방지).
  • 권장: 1200×630, 용량 ≈300KB 이하, 절대 URL(또는 metadataBase로 절대화).

7. 중복/충돌 요소 정리

  • next-seo.config.ts삭제(또는 보관) — 더 이상 사용하지 않음.
  • 커스텀 <head> 같은 중복 렌더링 로직 제거.


    결론(해결방법 요약):
    JSON-LD만 next-seo로 넣고, 나머지 메타는 Next.js Metadata API로 처리하는 패턴이 현재 베스트 프랙티스다.



4) 적용 코드와 “왜 이렇게 고쳤는지”

4-1) 공통 상수

// src/seo/constants.ts
export const SITE_NAME = "MyPlanMate";
export const SITE_URL =
  process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") ?? "http://localhost:3000";

export const DEFAULT_TITLE = `${SITE_NAME} - 나만의 맞춤형 플래너`;
export const TITLE_TEMPLATE = `%s | ${SITE_NAME}`;
export const DEFAULT_DESCRIPTION =
  "일간·주간·월간·투두·습관·메모를 자유롭게 조합하는 커스텀 플래너";

export const OG_DEFAULT_IMAGE = "/og/og-default.png"; // metadataBase로 절대 URL로 변환
export const LOCALE = "ko_KR";

무엇을 고쳤나

  • 삭제: src/seo/next-seo.config.ts (더 이상 사용하지 않음)
  • 추가: src/seo/constants.ts (도메인/브랜드/OG 기본 이미지 등 전역 상수의 단일 출처)


    왜? 환경별 도메인/브랜드 텍스트/이미지 경로를 한 군데에서 관리하면 전역/페이지 일관성이 생기고, 변경도 한 곳만 고치면 끝.



4-2) 전역 메타: app/layout.tsx

// app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";
import { Providers } from "./providers";
import {
  SITE_NAME, SITE_URL, DEFAULT_TITLE, TITLE_TEMPLATE,
  DEFAULT_DESCRIPTION, OG_DEFAULT_IMAGE, LOCALE,
} from "@/seo/constants";

export const metadata: Metadata = {
  metadataBase: new URL(SITE_URL), // ✅ 상대 경로를 절대 URL로 변환
  title: { default: DEFAULT_TITLE, template: TITLE_TEMPLATE }, // ✅ 전역 타이틀 규칙
  description: DEFAULT_DESCRIPTION,
  openGraph: {
    type: "website",
    url: "/", // ✅ metadataBase 기준으로 절대 URL 처리
    siteName: SITE_NAME,
    title: DEFAULT_TITLE,
    description: DEFAULT_DESCRIPTION,
    images: [{ url: OG_DEFAULT_IMAGE, width: 1200, height: 630, alt: `${SITE_NAME} 대표 이미지` }],
    locale: LOCALE,
  },
  twitter: { card: "summary_large_image" },
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

무엇을 고쳤나

  • 제거: import { DefaultSeo } from "next-seo" / <DefaultSeo {...} />
  • 제거: import SEO_CONFIG from "@/seo/next-seo.config" (파일 자체도 삭제)
  • 추가: export const metadata로 전역 title.template/description/openGraph/twitter 선언
  • 적용: metadataBase = SITE_URLcanonical/og:image절대 URL 자동 보장


    왜?
  • metadataBase"/og/og-default.png"/"/" 같은 상대 경로를 자동 절대화.
  • title.template로 전역 타이틀 패턴을 만들고, 나중에 페이지가 생기면 그 제목만 채우면 됨.
  • 전역 openGraph/twitter 기본값을 깔아두면 누락/중복을 방지.



4-3) 테스트 라우트(메타만 선언)

// app/lab/og-preview/page.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "PlanMate 프로모션 Seed",
  description: "공유 카드(OG/Twitter) 테스트용 페이지",
  openGraph: {
    title: "PlanMate 프로모션 Seed",
    description: "공유 카드(OG/Twitter) 테스트용 페이지",
    images: [{ url: "/og/promo-seed.png", width: 1200, height: 630, alt: "PlanMate 프로모션 Seed" }],
  },
  twitter: { card: "summary_large_image" },
};

export default function Page() {
  return <main className="p-8">OG/Twitter 카드 테스트 페이지</main>;
}
// app/lab/noindex/page.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Labs (Internal)",
  robots: {
    index: false, follow: false,
    googleBot: { index: false, follow: false, noimageindex: true, nosnippet: true },
  },
};

export default function Page() {
  return <main className="p-8">내부 테스트 페이지(검색 제외)</main>;
}

무엇을 고쳤나

  • 제거: 두 파일에서 import { NextSeo } from "next-seo"<NextSeo …/>
  • 추가: 각 파일 최상단에 export const metadata로 필요한 메타만 선언
  • 유지: 페이지 본문 UI는 그대로 (검증용 문구만 노출)


    왜?
  • <NextSeo> 없이도 페이지별 메타를 안전하게 주입할 수 있다.
  • robots는 의도치 않은 노출을 방지하는 안전장치(실험용 라우트에 추천).



화면 테스트

app/layout.tsx (전역 메타)

무슨 파일?

  • 사이트 전체에 적용되는 전역 메타데이터(타이틀 템플릿, 설명, OG/Twitter 기본값 등)를 선언하는 최상위 레이아웃.

브라우저 화면에서 확인할 것

  • 탭 제목 패턴 동작
    • 페이지에 별도 제목이 없으면: MyPlanMate - 나만의 맞춤형 플래너 가 탭에 보이는지.
    • 페이지에 제목이 있으면: {페이지제목} | MyPlanMate 형태로 보이는지. (예: 위 og-preview, noindex)
  • 페이지 렌더 정상 여부
    • 전역 메타로 바꾼 뒤에도 각 페이지 본문이 정상 노출되는지(라우팅 오류 없이).

전역 description, OG/Twitter 값 등은 화면에 직접 보이지 않음. 이 단계에서는 탭 제목 패턴이 올바르게 동작하는지만 보면 충분하다.

app/lab/og-preview/page.tsx

무슨 파일?

  • 페이지별 메타(특히 OG/Twitter 카드)가 정상 주입되는지 간단히 확인하는 공유 미리보기 전용 더미 페이지.

브라우저 화면에서 확인할 것

  • 브라우저 탭 제목PlanMate 프로모션 Seed | MyPlanMate 형태로 보이는지.
  • 본문에 “OG/Twitter 카드 테스트 페이지” 문구가 렌더되는지(라우팅/렌더 정상 확인).

메타 태그 자체는 화면에 안 보이므로, 화면에서 볼 수 있는 건 “탭 제목 패턴 + 본문 문구 노출”이 핵심이다.



app/lab/noindex/page.tsx

무슨 파일?

  • 검색 색인 제외 설정(noindex/nofollow)이 들어간 내부/실험용 더미 페이지.

브라우저 화면에서 확인할 것

  • 브라우저 탭 제목Labs (Internal) | MyPlanMate 형태로 보이는지.
  • 본문에 “내부 테스트 페이지(검색 제외)” 문구가 렌더되는지(의도한 페이지에 진입했는지 확인).

참고: noindex 효과는 검색엔진 측 처리라 화면만으로는 차이를 체감하기 어렵다. 화면에서는 의도된 페이지에 왔는지만 확인하면 충분하다.



앞으로 할 것 ✅

왜: App Router → Metadata API로 간다

  • App Router(현재 프로젝트): DefaultSeo/NextSeo 쓰지 않는다. 이 둘은 내부에서 next/head를 쓰는데, App Router에선 비호환이다. → 메타는 전부 Metadata API로.
  • next-seo는 “JSON-LD 컴포넌트”만 사용한다. (ArticleJsonLd, FAQPageJsonLd 등, useAppDir={true} 꼭)

언제 next-seo의 DefaultSeo/NextSeo를 쓰나?

  • Pages Router(pages/ 디렉토리): ✅ 사용 가능. 예전 방식이라 next/head 기반 컴포넌트가 정상 동작.
  • App Router(app/ 디렉토리): ❌ 사용 불가/비추천. 대신 Metadata APItitle/description/canonical/OG/Twitter/robots를 선언.

App Router에서의 권장 패턴

  • 메타(타이틀/디스크립션/OG/Twitter/캐노니컬/로봇스)Next.js Metadata API
  • 구조화 데이터(JSON-LD)next-seo의 *JsonLd만 (예: ArticleJsonLd), useAppDir={true} 필수


    App Router(app/)에서는 <head> 제어를 Metadata API가 맡았다.
    next/head를 내부에서 쓰는 <DefaultSeo>/<NextSeo>는 호환되지 않아 메타는 Metadata로 처리하기로 했다.
    next-seoJSON-LD 전용으로만 쓰는 패턴을 채택했다.



지금까지 Metadata에 설정한 것(전역: app/layout.tsx)

  • title 템플릿: "%s | MyPlanMate"
  • 기본 title/description: 사이트 공통 제목/설명
  • metadataBase: NEXT_PUBLIC_SITE_URL(상대 경로를 절대 URL로 자동 변환)
  • Open Graph 기본값: type: "website", url: "/", siteName, title, description, images[0](대표 OG 이미지), locale
  • Twitter 카드 기본값: card: "summary_large_image"
  • (구조) 전역 상수는 src/seo/constants.ts에서 관리

결과: 아무 페이지에서도 탭 제목 패턴과 기본 메타가 안정적으로 적용된다.



페이지가 생기면 Metadata에 무엇을 써야 하나(오버라이드 체크리스트)

app/**/page.tsx마다 해당 페이지의 값만 덮어쓰면 된다.

필수(최소)

  • title: 페이지 고유 제목
  • description: 검색/공유 요약
  • alternates.canonical: 대표 URL(상대 경로 OK, metadataBase가 절대화)

권장(공유 품질)

  • openGraph.title, openGraph.description
  • openGraph.images[0].url(공유 썸네일, 1200×630 권장)
  • twitter.card = "summary_large_image"

상황별 확장

  • 글/뉴스 등: openGraph.type = "article", publishedTime, modifiedTime, authors
  • 인덱싱 제어: robots.index = false, robots.follow = false (내부/저가치 페이지)
  • 다국어: alternates.languages(hreflang), 각 언어 페이지는 self-canonical
  • 동적 라우트: generateMetadata()로 데이터 기반 값 주입

예시(간단)

// app/about/page.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "소개",
  description: "MyPlanMate 소개 페이지",
  alternates: { canonical: "/about" },
  openGraph: {
    title: "소개",
    description: "MyPlanMate 소개 페이지",
    images: [{ url: "/og/about.png", width: 1200, height: 630, alt: "소개" }],
  },
  twitter: { card: "summary_large_image" },
};

.



JSON-LD는 언제 next-seo로 쓰나(리치 스니펫용)

메타 태그는 Metadata, 스키마(구조화 데이터)는 next-seo로 분담한다.

사용 시점(페이지 목적이 명확할 때만 추가)

  • 글/블로그: ArticleJsonLd
  • FAQ 섹션: FAQPageJsonLd
  • 상품/가격: ProductJsonLd
  • 브랜드/조직 소개: OrganizationJsonLd
  • 빵부스러기: BreadcrumbJsonLd

규칙

  • App Router에서는 useAppDir={true} 옵션을 반드시 넣기
  • JSON-LD 내용은 화면에 실제로 보이는 정보와 일치(미스매치 금지)
  • URL/이미지 경로는 절대 URL(또는 metadataBase로 보장)
  • 남발 금지: 해당 페이지에 “정말 필요한” 스키마만

예시

// app/blog/awesome-post/page.tsx
import { ArticleJsonLd } from "next-seo";

export default function Page() {
  return (
    <>
      {/* 본문... */}
      <ArticleJsonLd
        useAppDir={true}
        url="https://myplanmate.com/blog/awesome-post"
        title="완벽 가이드"
        images={["https://myplanmate.com/og/awesome-post.png"]}
        datePublished="2025-10-01"
        dateModified="2025-10-02"
        authorName="팀 MyPlanMate"
        publisherName="MyPlanMate"
        description="메타/OG/JSON-LD를 올바르게 쓰는 방법"
      />
    </>
  );
}

0개의 댓글