
블로그를 하나 더 만들고 싶었다. 그런데 프로젝트를 또 하나 파자니 관리 비용이 두 배가 되는 게 뻔했다.
museck.com을 만들면서 PayloadCMS + Next.js 조합에 꽤 익숙해졌다. 같은 스택으로 애드센스 수익화용 니치 블로그를 여러 개 운영하고 싶었는데, 사이트마다 별도 레포를 만들면 패키지 업데이트, CI/CD 파이프라인, 인프라 설정을 전부 n배로 관리해야 한다. 그래서 찾은 해법이 @payloadcms/plugin-multi-tenant이다. 단일 인스턴스에서 여러 서브도메인을 운영하는 멀티테넌트 아키텍처를 구축했다.
큰 그림은 이렇다. 하나의 Next.js 앱이 여러 서브도메인(trend.example.com, cert.example.com 등)의 요청을 받고, middleware에서 도메인을 파싱해 어떤 테넌트인지 판별한다. PayloadCMS의 multi-tenant 플러그인이 Posts, Media 같은 컬렉션에 tenant 필드를 자동으로 주입해서, 콘텐츠가 테넌트별로 격리된다.
핵심은 middleware다. 들어오는 요청의 host 헤더를 읽어서 x-tenant-domain이라는 커스텀 헤더에 넣어 서버 컴포넌트로 전달한다. admin 경로는 테넌트 해석 없이 그냥 통과시킨다.
// src/middleware.ts
export function middleware(request: NextRequest) {
const host = request.headers.get('host') || ''
if (request.nextUrl.pathname.startsWith('/admin')) return NextResponse.next()
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-tenant-domain', host)
return NextResponse.next({ request: { headers: requestHeaders } })
}여기서 한참 삽질했던 부분이 있다. 처음에는 response.headers.set()으로 응답 헤더에 테넌트 정보를 넣었다. 그런데 서버 컴포넌트의 headers() 함수는 요청 헤더만 읽을 수 있다. 응답 헤더에 아무리 넣어봐야 서버 컴포넌트에서는 보이지 않는다. NextResponse.next({ request: { headers } }) 패턴으로 요청 헤더를 수정해야 한다.
middleware가 심어둔 헤더를 서버 컴포넌트에서 읽어 테넌트를 조회하는 유틸리티 함수를 만들었다.
// src/lib/tenant.ts
export async function getCurrentTenant() {
const headersList = await headers()
const domain = headersList.get('x-tenant-domain')
const { docs } = await payload.find({
collection: 'tenants',
where: { domain: { equals: domain } },
overrideAccess: true,
})
return docs[0] || null
}overrideAccess: true가 중요하다. 프론트엔드에서 PayloadCMS Local API를 호출할 때 이걸 빼먹으면 access control에 걸려서 빈 배열이 반환된다. 에러도 안 나고 조용히 빈 결과만 오기 때문에 디버깅하기가 까다롭다.
멀티테넌트에서 가장 고민이 많았던 건 컬렉션 설계다. 단순히 Posts에 tenant 필드를 추가하는 것만으로는 부족하다. 서브도메인마다 카테고리 구조, 내비게이션 메뉴, 사이트 설정이 전부 다르기 때문이다.
이 구조 덕분에 새 서브도메인을 추가할 때 코드 수정 없이 PayloadCMS 관리자 패널에서 테넌트를 만들고 SiteConfig를 설정하면 끝난다.
니치 블로그에서는 콘텐츠 유형이 다양하다. 일반 블로그 포스트 외에도 가이드, 비교글, 리뷰, 용어 사전, 트러블슈팅 같은 특화된 포맷이 필요하다. 각 콘텐츠 타입마다 최적화된 레이아웃 컴포넌트를 만들었다.
// 콘텐츠 타입에 따른 상세 컴포넌트 분기
const contentComponents = {
guide: GuideDetail, // 단계별 가이드 (HowTo 스키마)
comparison: ComparisonDetail, // 비교표 포함
review: ReviewDetail, // 별점, 장단점
glossary: GlossaryDetail, // 용어 정의 목록
troubleshooting: TroubleshootingDetail, // 문제-해결 쌍
}Guide 타입은 HowTo 구조화 데이터(JSON-LD)를 자동 생성하고, Comparison 타입은 비교표를 렌더링한다. 콘텐츠 타입별로 SEO에 유리한 구조화 데이터가 다르기 때문에 이 분리가 중요하다.
최종적으로 Layout에서 테넌트와 SiteConfig를 조회해 사이트 전역에 적용한다.
// src/app/(frontend)/layout.tsx
export default async function FrontendLayout({ children }) {
const tenant = await getCurrentTenant()
const siteConfig = tenant ? await getSiteConfig(tenant.id) : null
const adsenseClientId = siteConfig?.adsense?.enabled
? siteConfig.adsense.clientId
: null
// AdSense 스크립트 조건부 삽입, 사이트 제목/설명 등 적용
}이 구조를 완성하고 나니, 새 니치 블로그를 추가하는 데 걸리는 시간이 극적으로 줄었다. DNS에 서브도메인 추가하고, PayloadCMS 관리자 패널에서 테넌트와 SiteConfig를 만들면 끝이다. 코드를 한 줄도 안 건드린다.
museck-public과 동일한 패키지 버전(PayloadCMS 3.77 + Next.js 16 canary)을 유지하는 것도 중요한 교훈이었다. PayloadCMS와 Next.js canary의 호환성이 특정 버전 조합에서만 보장되기 때문에, 검증된 버전을 그대로 가져오는 게 삽질을 줄이는 가장 확실한 방법이다.
결국 멀티테넌트 아키텍처의 핵심 가치는 "한 번 잘 만들어두면 n개 사이트를 운영 비용 거의 없이 추가할 수 있다"는 것이다. 1인 개발자에게 이건 상당한 레버리지다.
Q. middleware에서 response.headers가 아닌 request.headers에 테넌트 정보를 설정하는 이유는?
Next.js App Router에서 서버 컴포넌트의 headers() 함수는 요청 헤더만 읽을 수 있다. response.headers.set()으로 설정하면 서버 컴포넌트에서 접근할 수 없기 때문에, NextResponse.next()의 request.headers 옵션을 통해 요청 헤더를 수정해야 한다.
Q. 테넌트별 설정(AdSense, SEO 인증 등)은 어떻게 관리하나?
SiteConfigs 컬렉션을 만들어 테넌트별로 AdSense 클라이언트 ID, Google/Naver 사이트 인증 코드, 사이트명, 설명 등을 관리한다. Layout에서 현재 테넌트의 SiteConfig를 조회하여 동적으로 적용한다.
Q. PayloadCMS multi-tenant 플러그인은 어떤 원리로 동작하나?
플러그인이 지정된 컬렉션(Posts, Media 등)에 tenant 관계 필드를 자동으로 추가한다. 관리자 UI에서는 현재 사용자의 테넌트에 해당하는 문서만 표시되고, 프론트엔드에서는 도메인 기반으로 테넌트를 해석하여 해당 콘텐츠만 조회한다.