무색
기술블로그
에세이
연구
소개

무색

소프트웨어로 비즈니스의 가능성을 만듭니다. 웹·앱 개발, 음성 AI, 자동화 콘텐츠 제작까지 — 기술이 필요한 곳에 무색이 있습니다.

연락처

contact@museck.com

사업자 정보

상호: 무색

대표: 배성재

사업자등록번호: 577-58-00836

인천광역시 연수구 인천타워대로 323, 에이동 8층 801-802호 AB-132 (송도동, 송도 센트로드)

© 2026 무색. All rights reserved.
개인정보처리방침·이용약관·연락처
INCHEON, KR
멀티테넌트 SEO 전략 — bauhaus 키 비주얼
💰 애드센스 블로그 네트워크
2026. 2. 27.

멀티테넌트 블로그의 SEO 전략: 동적 sitemap, JSON-LD, canonical URL

멀티테넌트 아키텍처를 만들었다. 그런데 SEO는? 서브도메인마다 sitemap이 따로 있어야 하고, 구조화 데이터도 달라야 하고, canonical URL도 각각 설정해야 한다.

이전 글에서 PayloadCMS multi-tenant 플러그인으로 단일 인스턴스 멀티사이트 구조를 만들었다. 이번에는 그 위에 SEO 레이어를 얹는 작업이다. 단일 앱에서 여러 서브도메인의 SEO 메타데이터를 동적으로 생성하는 건 생각보다 손이 많이 갔다. 특히 Next.js App Router의 메타데이터 API와 PayloadCMS의 조합에서 겪은 삽질이 꽤 있었다.

테넌트별 SEO 구조 개요

멀티테넌트 블로그에서 SEO가 복잡해지는 이유는 간단하다. 검색 엔진은 도메인 단위로 사이트를 인식한다. trend.example.com과 cert.example.com은 서로 다른 사이트이므로, 각각의 sitemap, robots.txt, 메타데이터가 필요하다.

동적 sitemap 생성

Next.js App Router에서는 sitemap.ts 파일로 동적 사이트맵을 생성할 수 있다. 핵심은 현재 테넌트를 해석하고, 그 테넌트에 속한 콘텐츠만 포함시키는 것이다.

// src/app/sitemap.ts
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const tenant = await getCurrentTenant()
  const baseUrl = tenant?.domain ? `https://${tenant.domain}` : ''
  if (!baseUrl) return []

  const { docs: posts } = await payload.find({
    collection: 'posts',
    where: { tenant: { equals: tenant.id } },
    overrideAccess: true,
    limit: 1000,
  })

  return [
    { url: baseUrl, lastModified: new Date() },
    ...posts.map(post => ({
      url: `${baseUrl}/${post.slug}`,
      lastModified: new Date(post.updatedAt),
    })),
  ]
}

테넌트가 없는 요청(예: IP 직접 접속)은 빈 배열을 반환해서 잘못된 URL이 sitemap에 포함되는 걸 방지한다.

JSON-LD 구조화 데이터

구조화 데이터는 검색 결과에 리치 스니펫을 노출시키는 핵심 도구다. 콘텐츠 타입별로 적합한 스키마가 다르기 때문에 컴포넌트 하나에서 분기 처리했다.

  • BlogPosting — 모든 포스트의 기본 스키마. headline, datePublished, author 등
  • FAQPage — FAQ가 있는 포스트에 추가. 검색 결과에서 아코디언 형태로 노출
  • HowTo — guide 타입 콘텐츠에 적용. 단계별 절차가 검색 결과에 표시
  • BreadcrumbList — 사이트 계층 구조 표시. 홈 > 카테고리 > 포스트
// src/components/JsonLd.tsx
export function JsonLd({ post, baseUrl }: Props) {
  const schemas = [
    // 기본: BlogPosting
    {
      '@type': 'BlogPosting',
      headline: post.title,
      datePublished: post.publishedAt,
      url: `${baseUrl}/${post.slug}`,
    },
    // FAQ가 있으면 FAQPage 추가
    ...(post.faq?.length ? [{
      '@type': 'FAQPage',
      mainEntity: post.faq.map(f => ({
        '@type': 'Question',
        name: f.question,
        acceptedAnswer: {
          '@type': 'Answer', text: f.answer
        },
      })),
    }] : []),
  ]
  // ...
}

canonical URL과 robots.txt

모든 페이지에 canonical URL을 명시적으로 설정했다. 멀티테넌트 환경에서는 동일 콘텐츠가 여러 경로로 접근될 가능성이 있어서, 검색 엔진에 정본 URL을 확실히 알려줘야 한다.

// 각 페이지의 generateMetadata에서
export async function generateMetadata({ params }): Promise<Metadata> {
  const tenant = await getCurrentTenant()
  const baseUrl = `https://${tenant.domain}`
  return {
    alternates: {
      canonical: `${baseUrl}/${params.slug}`,
    },
  }
}

robots.ts도 마찬가지로 테넌트 도메인 기반으로 sitemap URL을 포함시킨다. 이래야 Google Search Console에서 서브도메인별로 sitemap을 인식한다.

overrideAccess 삽질기

이 작업에서 가장 시간을 많이 잡아먹은 건 SEO 코드가 아니라 PayloadCMS의 overrideAccess 이슈였다. 프론트엔드에서 payload.find()를 호출할 때 overrideAccess: true를 빼먹으면 access control에 걸려서 빈 배열이 반환된다. 에러 없이 조용히 빈 결과만 오니까, "왜 sitemap이 비어있지?"를 한참 디버깅했다. 프론트엔드 코드 전체를 훑어서 overrideAccess: true를 일괄 추가하는 것으로 해결했다.

결과와 배운 것

이 작업 후 각 서브도메인에서 Google Search Console에 sitemap을 제출하고 정상적으로 인덱싱이 시작되었다. JSON-LD 구조화 데이터도 Google의 리치 결과 테스트를 통과했다.

멀티테넌트 SEO에서 가장 중요한 원칙은 "모든 SEO 메타데이터는 도메인 단위로 격리되어야 한다"는 것이다. Next.js App Router의 메타데이터 API가 이를 함수 기반으로 처리할 수 있게 해주기 때문에, middleware에서 테넌트를 해석하는 레이어만 잘 만들면 나머지는 자연스럽게 따라온다.

그리고 PayloadCMS Local API의 overrideAccess는 프론트엔드 코드를 작성할 때 체크리스트의 첫 번째 항목으로 넣어두는 게 좋다. 빠뜨리면 디버깅 시간만 날린다.

자주 묻는 질문

Q. 멀티테넌트에서 sitemap을 왜 테넌트별로 분리해야 하나?

Google은 sitemap의 URL이 해당 sitemap이 호스팅된 도메인과 일치해야 한다고 요구한다. trend.example.com의 sitemap에 cert.example.com URL이 포함되면 무시된다. 따라서 요청 도메인에 따라 해당 테넌트의 콘텐츠만 포함하는 동적 sitemap을 생성해야 한다.

Q. JSON-LD 구조화 데이터는 SEO에 얼마나 영향을 주나?

직접적인 랭킹 팩터는 아니지만, 검색 결과에 리치 스니펫(FAQ 아코디언, HowTo 단계, 별점 등)을 노출시켜 CTR을 높인다. 특히 FAQPage 스키마는 검색 결과에서 차지하는 면적이 커져 클릭률 향상에 효과적이다.

Q. canonical URL을 왜 명시적으로 설정해야 하나?

멀티테넌트 환경에서 동일 콘텐츠가 여러 URL로 접근될 가능성이 있다. www 유무, 트레일링 슬래시 차이 등으로 중복 인덱싱되면 SEO에 불리하다. canonical URL을 명시적으로 설정해 검색 엔진에 정본 URL을 알려줘야 한다.

자주 묻는 질문

멀티테넌트에서 sitemap을 왜 테넌트별로 분리해야 하나?
Google은 sitemap의 URL이 해당 sitemap이 호스팅된 도메인과 일치해야 한다고 요구한다. trend.example.com의 sitemap에 cert.example.com URL이 포함되면 무시된다. 따라서 요청 도메인에 따라 해당 테넌트의 콘텐츠만 포함하는 동적 sitemap을 생성해야 한다.
JSON-LD 구조화 데이터는 SEO에 얼마나 영향을 주나?
직접적인 랭킹 팩터는 아니지만, 검색 결과에 리치 스니펫(FAQ 아코디언, HowTo 단계, 별점 등)을 노출시켜 CTR을 높인다. 특히 FAQPage 스키마는 검색 결과에서 차지하는 면적이 커져 클릭률 향상에 효과적이다.
canonical URL을 왜 명시적으로 설정해야 하나?
멀티테넌트 환경에서 동일 콘텐츠가 여러 URL로 접근될 가능성이 있다. www 유무, 트레일링 슬래시 차이 등으로 중복 인덱싱되면 SEO에 불리하다. canonical URL을 명시적으로 설정해 검색 엔진에 정본 URL을 알려줘야 한다.
💰 애드센스 블로그 네트워크(2/3)
Prev

단일 Next.js + PayloadCMS로 멀티 서브도메인 블로그 운영하기

Next

기존 프로젝트의 CI/CD를 새 프로젝트에 복제하기: Gitea Actions + ArgoCD