와일드카드 서브도메인 Next.js 미들웨어를 활용한 멀티테넌트 SaaS
서브도메인 기반 멀티테넌시의 필요성
분석해보겠습니다. SaaS 앱에서 멀티테넌트 라우팅을 처리할 때 일반적으로 세 가지 옵션이 있습니다.
| 패턴 | 예시 | 격리 수준 | SEO | 사용자 경험 |
|---|---|---|---|---|
| 경로 기반 | app.com/tenant-a/dashboard |
낮음 | 공유 도메인 권한 | 공유 플랫폼처럼 느껴짐 |
| 서브도메인 기반 | tenant-a.app.com |
중간 | 서브도메인 권한 | 전용 앱처럼 느껴짐 |
| 커스텀 도메인 | app.tenant-a.com |
높음 | 전체 도메인 권한 | 완벽한 화이트 라벨링 |
왜 서브도메인을 자주 선택할까요? 이것이 바로 골디락스 상황입니다—95%의 경우에 완벽합니다. 테넌트가 자신만의 브랜드 URL(acme.yourapp.com)을 얻고, 복잡도는 관리 가능합니다. 게다가 나중에 커스텀 도메인으로 업그레이드하고 싶을 때도 막히지 않습니다. 테넌트에게는 개인화된 느낌을 주고 기술 스택이 룹 골드버그 기계로 변하는 것을 방지합니다.
하지만 이게 핵심입니다: 서브도메인으로 시작하고 커스텀 도메인을 프리미엄 기능으로 제공하세요. Next.js 미들웨어를 사용하면 이 모든 것이 하나의 깔끔한 파이프라인을 통해 흐를 수 있습니다. 정말 효율적이죠?

아키텍처 개요
모든 요청이 마치 컨베이어 벨트에 올려져 앱으로 들어온다고 상상해보세요. 먼저 만나는 것은? Next.js 미들웨어입니다. 이 신뢰할 수 있는 코드 조각이 서브도메인을 추출하고, 어느 테넌트에 속하는지 파악한 후, 내부 경로를 다시 작성하거나 앱이 사용할 수 있는 헤더로 이 요청에 플래그를 지정합니다. 쉽죠!
요청: acme.yourapp.com/dashboard
↓
미들웨어: 호스트명 추출 → 테넌트 해결 → 헤더 주입
↓
다시 작성: /dashboard → /[tenant]/dashboard (내부 다시 작성)
↓
페이지: 파라미터 또는 헤더에서 테넌트를 읽고, 테넌트별 데이터 가져오기
실제로 일어나는 것은 마법의 트릭입니다—미들웨어 다시 작성은 사용자에게 보이지 않습니다. 사용자의 브라우저는 여전히 자랑스럽게 acme.yourapp.com/dashboard를 표시하는 동안, 뒤에서는 Next.js가 실제로 /acme/dashboard로 라우팅합니다.
디렉토리 구조
프로젝트의 모습을 살펴보겠습니다.
├── middleware.ts
├── app/
│ ├── [tenant]/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── dashboard/
│ │ │ └── page.tsx
│ │ └── settings/
│ │ └── page.tsx
│ └── api/
│ └── tenant/
│ └── route.ts
├── lib/
│ ├── tenant.ts
│ └── middleware-utils.ts
와일드카드 DNS 설정
코드를 한 줄도 작성하기 전에 DNS를 관리해야 합니다.
Vercel
이 부분은 간단합니다: 프로젝트 설정으로 이동하여 와일드카드 도메인을 추가하세요.
*.yourapp.com
yourapp.com
그리고 안심하세요. Vercel이 SSL 인증서로 힘든 작업을 해줍니다. 2025년 기준, 취미 요금제가 제외되므로 최소한 Pro 요금제($20/월 팀원당)가 필요합니다.
Cloudflare
Cloudflare는 와일드카드 라우팅을 잘 지원합니다. 다음과 같이 A 레코드를 설정하세요.
유형: A
이름: *
콘텐츠: <your-server-ip>
프록시: 예 (주황색 클라우드)
Vercel을 사용 중이라면 CNAME 레코드로 바꾸세요.
유형: CNAME
이름: *
콘텐츠: cname.vercel-dns.com
프록시: DNS만 (회색 클라우드)
회색인 이유는? Vercel이 SSL을 처리하고, Cloudflare 프록시는 거기서 잘 작동하지 않습니다. 중립이 최고의 친구입니다.
자체 호스팅 (Nginx)
server {
listen 80;
server_name *.yourapp.com;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
SSL의 경우 Let's Encrypt에서 와일드카드 인증서가 필요합니다. 해당 DNS-01 챌린지와 Certbot 플러그인은 생각보다 덜 복잡합니다.
미들웨어 구축하기
이제 코드를 원하시나요? 여기 프로덕션에서 테스트된 미들웨어가 있습니다. 각 세미콜론의 영광은 생략하겠지만 믿으세요, 정말 훌륭합니다.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
// 테넌트 서브도메인으로 취급되지 않아야 할 도메인
const RESERVED_SUBDOMAINS = new Set([
'www',
'api',
'admin',
'app',
'mail',
'blog',
'docs',
'status',
]);
const ROOT_DOMAIN = process.env.NEXT_PUBLIC_ROOT_DOMAIN || 'yourapp.com';
export const config = {
matcher: [
/*
* 다음을 제외한 모든 경로 일치:
* - _next/static (정적 파일)
* - _next/image (이미지 최적화)
* - favicon.ico
* - public 폴더 파일
*/
'/((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js)$).*)',
],
};
export default async function middleware(req: NextRequest) {
const url = req.nextUrl;
const hostname = req.headers.get('host') || '';
// 로컬 개발을 위해 포트 제거
const currentHost = hostname.replace(/:\d+$/, '');
// 루트 도메인 또는 www인지 확인
if (
currentHost === ROOT_DOMAIN ||
currentHost === `www.${ROOT_DOMAIN}` ||
currentHost === 'localhost'
) {
// 마케팅 사이트 / 랜딩 페이지
return NextResponse.next();
}
// 서브도메인 추출
let tenant: string | null = null;
if (currentHost.endsWith(`.${ROOT_DOMAIN}`)) {
const subdomain = currentHost.replace(`.${ROOT_DOMAIN}`, '');
if (RESERVED_SUBDOMAINS.has(subdomain)) {
return NextResponse.next();
}
tenant = subdomain;
} else {
// 커스텀 도메인일 수 있음
tenant = await resolveCustomDomain(currentHost);
}
if (!tenant) {
// 알 수 없는 도메인 — 메인 사이트로 리다이렉트
return NextResponse.redirect(new URL(`https://${ROOT_DOMAIN}`));
}
// 테넌트별 경로로 다시 작성
const tenantUrl = new URL(`/${tenant}${url.pathname}`, req.url);
tenantUrl.search = url.search;
const response = NextResponse.rewrite(tenantUrl);
// 다운스트림에서 사용할 테넌트 정보를 헤더로 주입
response.headers.set('x-tenant-slug', tenant);
response.headers.set('x-tenant-domain', currentHost);
return response;
}
async function resolveCustomDomain(domain: string): Promise<string | null> {
try {
// 프로덕션에서는 적극적으로 캐시
const res = await fetch(
`${process.env.INTERNAL_API_URL}/api/domains/resolve?domain=${domain}`,
{
headers: { Authorization: `Bearer ${process.env.INTERNAL_API_KEY}` },
next: { revalidate: 300 },
}
);
if (!res.ok) return null;
const data = await res.json();
return data.tenantSlug || null;
} catch {
return null;
}
}
매처 패턴이 전부입니다
속도를 원하시나요? 그 config.matcher 정규식이 최고의 친구입니다. 없으면 모든 요청이 미들웨어를 함께 끌고 다닙니다—정적 자산, 이미지, 모두입니다. 그리고 그것은 200ms 이상의 추가 레이턴시로 가는 편도표입니다. 아무도 원하지 않습니다. 이를 보세요: /((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js)$).*). 이것만으로 P95 레이턴시를 40% 감소시켰습니다! 맞습니다, 정말 큰 문제입니다.
리다이렉트 대신 리라이트를 사용하는 이유
리라이트는 사용자가 브라우저에서 보는 것을 엉망으로 만들지 않습니다—순항하는 것이죠. 리다이렉트? 사용자가 당신을 신뢰하고 올바른 장소로 도착하기를 바라는 것과 같습니다—상자에 또 다른 URL입니다. rewrite()를 사용하여 드라마 없는 사용자 경험을 유지하세요.

테넌트 해결 전략
위의 미들웨어가 서브도메인에서 테넌트를 전문가처럼 뽑아냅니다. 하지만 테넌트 존재 여부는? 확인해야 합니다. 전략에 관해서는 다양한 방법이 있습니다.
전략 1: 미들웨어에서 데이터베이스 조회
문제는 다음과 같습니다: Next.js 미들웨어는 엣지 런타임에서 실행되므로 많은 친근한 Node.js API에 작별을 고해야 합니다.
// Prisma Accelerate 또는 @prisma/client/edge와 함께 작동
import { PrismaClient } from '@prisma/client/edge';
const prisma = new PrismaClient();
async function resolveTenant(slug: string) {
return prisma.tenant.findUnique({
where: { slug },
select: { id: true, slug: true, plan: true },
});
}
전략 2: KV 스토어 조회
이것이 제 최고의 선택입니다. Vercel KV, Upstash Redis, Cloudflare KV와 같은 KV 스토어에 슬러그를 저장하세요. 엣지 조회가 1-5ms 단위의 무시할 수 있는 지연을 의미합니다.
import { kv } from '@vercel/kv';
async function resolveTenant(slug: string) {
const tenant = await kv.get(`tenant:${slug}`);
return tenant as TenantConfig | null;
}
전략 3: 정적 허용 목록
작은 규모의 운영? 정적 허용 목록은 당신의 친구가 될 수 있습니다—빌드 시간 JSON 파일이 상황을 타이트하고 네트워크 없이 유지합니다.
import tenants from './tenants.json';
const tenantMap = new Map(tenants.map(t => [t.slug, t]));
function resolveTenant(slug: string) {
return tenantMap.get(slug) || null;
}
ISR 또는 웹훅으로 다시 빌드하여 새 테넌트를 동적으로 처리하세요.
멀티테넌시를 위한 데이터베이스 설계
여기가 진지한 부분입니다. 선택할 수 있는 두 가지 주요 경로가 있습니다.
테넌트 ID가 있는 공유 데이터베이스
tenantId 열을 세일 중인 것처럼 추가하세요.
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_projects_tenant ON projects(tenant_id);
그리고 맞습니다, PostgreSQL RLS는 당신의 보험입니다.
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.current_tenant')::uuid);
컨텍스트를 설정하면 생활이 쉬워집니다.
await db.execute(sql`SELECT set_config('app.current_tenant', ${tenantId}, true)`);
테넌트별 데이터베이스
수준 높은 격리와 규정 준수 요구 사항이 장갑처럼 맞기를 원하세요? 테넌트당 데이터베이스를 사용하세요. Neon의 브랜칭과 PlanetScale의 쿨한 데이터베이스당 가격 책정은 2025년에도 이것을 가능하게 합니다.
커스텀 도메인 지원
여기가 배포와 허영이 만나는 곳입니다. 테넌트는 CNAME 레코드가 있는 커스텀 도메인이 당신의 방향을 가리켜야 합니다. 그리고 SSL? 클릭한 모든 링크가 그 HTTPS 광택으로 반짝여야 합니다.
Vercel 커스텀 도메인 API
Vercel의 API는 가능한 한 고통 없게 만듭니다.
async function addCustomDomain(domain: string, tenantId: string) {
const response = await fetch(
`https://api.vercel.com/v10/projects/${process.env.VERCEL_PROJECT_ID}/domains`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.VERCEL_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: domain }),
}
);
if (response.ok) {
// 도메인에서 테넌트 매핑 저장
await db.customDomain.create({
data: { domain, tenantId, verified: false },
});
}
return response.json();
}
Vercel은 DNS가 정렬되면 SSL을 처리합니다. 해당 Platforms Starter Kit을 확인해보세요—이것은 모델 구현입니다.
Cloudflare for SaaS
이것은 멋진 커스텀 DNS 관리자에게 이상적입니다. Cloudflare의 "SSL for SaaS"는 SSL 프로비저닝과 프록싱을 포함합니다.
성능 및 캐싱
요청을 처리할 때 모든 나노초가 중요합니다. 트릭은 테넌트 해결을 가속화하는 것입니다.
테넌트 해결 캐싱
테넌트 조회를 캐싱하여 날을 저장할(그리고 서버를 저장할) 것입니다.
import { LRUCache } from 'lru-cache';
const tenantCache = new LRUCache<string, TenantConfig>({
max: 10000,
ttl: 1000 * 60 * 5, // 5분
});
async function resolveTenantCached(slug: string): Promise<TenantConfig | null> {
const cached = tenantCache.get(slug);
if (cached) return cached;
const tenant = await resolveTenantFromDB(slug);
if (tenant) {
tenantCache.set(slug, tenant);
}
return tenant;
}
기억하세요, 인메모리 캐시는 엣지 런타임용이 아닙니다. Upstash Redis와 같은 것으로 가세요—이것이 그들이 최고인 것입니다.
ISR 및 멀티테넌시
ISR은 멀티테넌시를 좋아합니다. 왜냐하면 스마트하기 때문입니다. 각 테넌트는 /dashboard에서 /acme/dashboard로 다시 작성하여 고유하게 캐시된 버전을 얻습니다. 추가 설정 없이, 그냥 Next.js 마법의 영광을 누리세요.
배포 구성
결정, 결정, 결정입니다. 배포 옵션을 비교해봅시다.
| 기능 | Vercel Pro | Cloudflare Pages | 자체 호스팅 (Docker) |
|---|---|---|---|
| 와일드카드 도메인 | ✅ | ✅ | ✅ (수동) |
| 커스텀 도메인 API | ✅ | ✅ | ❌ (수동) |
| 엣지 미들웨어 | ✅ | ✅ | ❌ (Node만) |
| 자동 SSL | ✅ | ✅ | ⚠️ Let's Encrypt |
| 가격 | $20/좌석/월 + 사용량 | $5/월 + 사용량 | $50-200/월 서버 |
| 최대 커스텀 도메인 | 50+ | 무제한 | 무제한 |
반짝이고 빠른 스타트업 출시를 위해 Vercel Pro가 당신의 표입니다. 하지만 이 숫자들이 오르면—더 많은 사용자, 더 많은 요청—Cloudflare Pages 또는 자체 호스팅 옵션이 유연성과 비용 효율성을 제공합니다.
우리는 각 방법으로 멀티테넌트 원더랜드를 탐색했으므로, 다음 단계를 고민하고 있다면 우리의 Next.js 개발 역량이 당신의 가이드가 될 수 있습니다.
보안 고려 사항
멀티테넌시는 매우 멋있습니다. 그때까지죠. 모든 테넌트는 안전한 데이터를 기대합니다—누수 없음, 미끄러짐 없음.
테넌트 격리 체크리스트
- 테넌트 ID로 필터링: URL만 신뢰하지 마세요. 백엔드 확인이 중요합니다.
- PostgreSQL RLS: 24/7 데이터베이스 보안 세부사항을 갖는 것과 같습니다.
- 서브도메인 살균: 절대 미끄러지지 마세요—
[a-z0-9-]만 허용하세요. 서브도메인 탈취를 피하세요. - 테넌트당 속도 제한: 동적 헤더로 API 스로틀이 저장할 수 있습니다.
- 로그, 감사, 검토: 각 쓰기 작업이 "알겠습니다"라고 말해야 합니다. 자신감이 신뢰를 불어넣습니다.
// 미들웨어에서 테넌트 슬러그 유효성 검사
const VALID_SLUG = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
if (!VALID_SLUG.test(subdomain)) {
return NextResponse.redirect(new URL(`https://${ROOT_DOMAIN}`));
}
CORS 및 멀티테넌시
API가 많은 서브도메인을 제공하면, CORS는 성질을 내릴 필요가 없습니다.
// app/api/[...route]/route.ts
export async function GET(req: NextRequest) {
const origin = req.headers.get('origin') || '';
const isValidOrigin =
origin.endsWith(`.${ROOT_DOMAIN}`) ||
await isCustomDomain(origin.replace('https://', ''));
const headers = new Headers();
if (isValidOrigin) {
headers.set('Access-Control-Allow-Origin', origin);
headers.set('Access-Control-Allow-Credentials', 'true');
}
// ... 나머지 핸들러
}
로컬에서 멀티테넌트 미들웨어 테스트
로컬 개발이 서브도메인을 지원하지 않으면 두통이 샘솟습니다. 다시 꿈꾸는 방법은 다음과 같습니다.
옵션 1: `/etc/hosts` 편집
# /etc/hosts
127.0.0.1 yourapp.local
127.0.0.1 acme.yourapp.local
127.0.0.1 globex.yourapp.local
그런 다음 개발 중에 .yourapp.local을 인식하도록 미들웨어를 업데이트하세요.
const ROOT_DOMAIN = process.env.NODE_ENV === 'development'
? 'yourapp.local:3000'
: 'yourapp.com';
옵션 2: nip.io 또는 sslip.io 사용
이 서비스들은 마법의 IP 매핑을 끌어냅니다.
acme.127.0.0.1.nip.io → 127.0.0.1
globex.127.0.0.1.nip.io → 127.0.0.1
간단하고 호스트 편집이 필요하지 않습니다.
옵션 3: 커스텀 서브도메인을 포함한 로컬 터널
빠른 터널을 위해 ngrok (또는 이와 유사한 것)을 사용하세요.
ngrok http 3000 --hostname=*.your-dev-domain.ngrok.io
테스트는 실제 시나리오보다 어려워야 하지 않습니다.
통합 테스트 작성
import { describe, it, expect } from 'vitest';
import { middleware } from './middleware';
import { NextRequest } from 'next/server';
describe('tenant middleware', () => {
it('rewrites subdomain requests to tenant path', async () => {
const req = new NextRequest('https://acme.yourapp.com/dashboard');
const res = await middleware(req);
expect(res.headers.get('x-middleware-rewrite')).toContain('/acme/dashboard');
expect(res.headers.get('x-tenant-slug')).toBe('acme');
});
it('passes through root domain requests', async () => {
const req = new NextRequest('https://yourapp.com/');
const res = await middleware(req);
expect(res.headers.get('x-middleware-rewrite')).toBeNull();
});
it('rejects invalid subdomain characters', async () => {
const req = new NextRequest('https://acme--evil.yourapp.com/');
const res = await middleware(req);
expect(res.status).toBe(307); // 리다이렉트
});
});
더 큰 그림을 생각하고 있나요? Next.js 또는 Astro 사이의 선택이든, 우리의 헤드리스 CMS 계층 인사이트 여정에 올라타는 것을 고려하세요.
기억하세요, 탐색이란 묻고, 적응하고, 성장하는 것입니다. 브레인스토밍 세션을 원하거나 최고 수준의 파트너십을 목표로 한다면 우리의 가격에 대해 더 자세히 알아보려면 연락주세요.