티스토리 뷰

반응형

고객에게 B2B SaaS 제품을 만드는데, 어떻게 환경을 구축할지에 대한 고민 끝에 고객사마다 독립된 환경을 제공하자의 컨셉

B2B SaaS에서 테넌트를 격리하는 방식은 크게 세 가지입니다.

  • URL Path 방식service.com/acme/dashboard
  • 서브도메인 방식acme.service.com
  • 완전 별도 도메인ai.acme.com

Path 방식은 구현이 쉽지만 URL 구조가 복잡해지고, 테넌트 간 격리가 약합니다. 별도 도메인은 고객사마다 DNS와 SSL을 관리해야 합니다.
서브도메인 방식은 와일드카드 SSL 하나로 모든 테넌트를 커버하면서, 테넌트 간 격리도 명확합니다.
신규 고객사 온보딩 시 DNS 변경 없이 즉시 활성화할 수 있다는 것도 큰 장점입니다.


Next.js 16 + 모노레포 구조

왜 모노레포인가

서비스 앱(apps/service)과 슈퍼어드민 앱(apps/admin) 두 개의 Next.js 앱을 운영합니다. 여기에 공통 패키지들이 붙습니다. 이걸 각각 별도 레포로 관리하면 타입 불일치, 중복 로직, API 스펙 드리프트가 생깁니다. 모노레포는 이 문제를 구조적으로 막습니다.

services/
├── apps/
│   ├── service/          # 서비스 앱 (acme.service.com)
│   └── admin/             # 슈퍼어드민 앱 (admin.service.com)
└── packages/
    ├── ui/                # 아토믹 UI 컴포넌트 (@repo/ui)
    ├── api/               # API 클라이언트 + 공통 타입 (@repo/api)
    ├── i18n/              # 다국어 설정 공유 (@repo/i18n)
    ├── eslint-config/     # ESLint 설정 공유
    ├── typescript-config/ # tsconfig 베이스 공유
    └── vitest-config/     # 테스트 설정 공유

Turborepo로 빌드 캐싱과 태스크 오케스트레이션을 관리합니다. packages/가 변경되지 않으면 apps/의 빌드를 캐시에서 바로 가져옵니다.

packages/api — 타입과 클라이언트를 한 곳에서

API 클라이언트를 패키지로 분리한 핵심 이유는 타입 공유입니다. 백엔드 API 스펙이 바뀌면 @repo/api의 타입만 수정하면 두 앱이 동시에 타입 오류를 잡습니다. 앱마다 fetch 로직을 따로 작성할 필요가 없습니다.

createApiClienttenantId를 받아서 모든 요청 헤더에 X-Tenant-ID를 자동으로 포함시킵니다. 개별 API 호출 시 테넌트를 신경 쓸 필요가 없습니다.

// tenantId가 모든 요청에 자동 포함
const api = await getApiClient()
const data = await api.get('/stats')  // X-Tenant-ID 헤더 자동 첨부

packages/ui — 아토믹 컴포넌트 분리

Button, Input 같은 아토믹 컴포넌트는 @repo/ui에 둡니다. 비즈니스 로직이 없고, 앱에서 가져다 조합하는 방식입니다. 두 앱이 같은 디자인 시스템을 쓰면서 컴포넌트가 분산되지 않습니다.

도메인 컴포넌트는 앱 내부 app/[domain]/components/에 두고, @repo/ui를 가져와서 비즈니스 로직과 합칩니다.

packages/i18n — 다국어 설정 공유

next-intl 라우팅 설정과 지원 언어 목록을 @repo/i18n에서 관리합니다. 두 앱이 같은 locale 목록을 바라봅니다. 언어를 추가할 때 패키지 한 곳만 수정하면 됩니다.

Next.js 16 컨벤션 — proxy.ts

Next.js 16부터 미들웨어 로직을 middleware.ts 외부로 분리할 수 있습니다. proxy.ts에서 로직을 작성하고 진입점에서 re-export하는 방식을 택했습니다. 미들웨어 로직을 독립적으로 테스트하기 쉽고, 파일 이름이 역할을 명확히 드러냅니다.

React.cache — 요청당 메모이제이션

getTenant()getApiClient()React.cache를 적용했습니다. 같은 요청 사이클 안에서 여러 Server Component가 동일 함수를 호출해도 실제 실행은 한 번입니다. Next.js의 fetch 캐싱과 별개로, 헤더 읽기나 객체 생성 같은 작업에도 적용할 수 있습니다.


어떻게 구현했나

전체 흐름

브라우저: acme.service.com/ko/dashboard
    ↓
proxy.ts (Next.js Middleware)
  1. host에서 subdomain 추출 → "acme"
  2. subdomain 없으면 → 랜딩 페이지(i18n만 처리)
  3. 공개 경로(/login, /sign-up) → 인증 건너뜀
  4. JWT 쿠키 없으면 → /login 리다이렉트
  5. JWT 디코드 → tenantSlug와 subdomain 비교
  6. 불일치 → 403
  7. 헤더 주입: x-tenant-id, x-tenant-slug
    ↓
app/[locale]/layout.tsx (Server Component)
  - headers()로 x-tenant-id, x-tenant-slug 읽기
  - <TenantProvider>로 트리 감싸기
    ↓
Server Component → getTenant() / getApiClient()
Client Component → useTenant()

proxy.ts — 미들웨어에서 테넌트 주입

미들웨어는 모든 요청의 진입점입니다. 서브도메인을 파싱하고, JWT를 검증하고, 테넌트 정보를 헤더에 심습니다. URL 구조를 바꾸지 않고 헤더만 추가하기 때문에 하위 레이어는 테넌트 식별 로직을 몰라도 됩니다.

newHeaders.set('x-tenant-id', tenantId ?? '')
newHeaders.set('x-tenant-slug', tenantSlug)
return handleI18nRouting(new NextRequest(request, { headers: newHeaders }))

미들웨어에서 i18n 라우팅도 함께 처리합니다. next-intlcreateMiddleware를 마지막에 태워서 /ko/, /en/ 같은 locale prefix를 붙입니다. 테넌트 처리와 i18n이 한 곳에서 관리됩니다.

getTenant() — 서버에서 테넌트 읽기

export const getTenant = cache(async () => {
  const h = await headers()
  return {
    id: h.get('x-tenant-id') ?? '',
    slug: h.get('x-tenant-slug') ?? '',
  }
})

React.cache로 감쌉니다. 같은 요청 사이클 안에서 여러 Server Component가 호출해도 헤더 읽기는 한 번만 일어납니다.

getApiClient() — API 클라이언트에 테넌트 자동 포함

export const getApiClient = cache(async () => {
  const h = await headers()
  return createApiClient({
    baseURL: process.env.NEXT_PUBLIC_API_BASE_URL ?? '',
    tenantId: h.get('x-tenant-id') ?? '',
    getAuthToken: () => h.get('x-auth-token'),
  })
})

API 클라이언트를 매번 수동으로 테넌트 ID를 넘기지 않아도 됩니다. createApiClient가 모든 요청 헤더에 X-Tenant-ID를 자동으로 포함시킵니다.

TenantProvider + useTenant() — 클라이언트에 테넌트 공급

Server Component인 layout.tsx가 서버에서 읽은 테넌트 정보를 React Context에 넘깁니다. Client Component는 useTenant()로 꺼내 씁니다.

// Server Component (layout.tsx)
const tenant = await getTenant()
return <TenantProvider tenant={tenant}>{children}</TenantProvider>

// Client Component
const { id, slug } = useTenant()

서버에서 한 번 읽고, 클라이언트 트리 전체에 공급하는 구조입니다. 클라이언트에서 별도로 API 호출해서 테넌트 정보를 가져올 필요가 없습니다.


기술 선택 이유

Next.js App Router

Server Component와 Client Component를 명확히 분리할 수 있습니다. 테넌트 식별처럼 서버에서만 필요한 로직은 Server Component에 두고, 상태 관리가 필요한 UI는 Client Component에 둡니다. Middleware 지원도 내장돼 있어 proxy.ts 구현이 깔끔합니다.

서브도메인 기반 테넌트 식별

와일드카드 SSL 하나로 전체 테넌트를 커버합니다. NEXT_PUBLIC_BASE_DOMAIN 환경변수 하나로 로컬/스테이징/프로덕션을 전환할 수 있습니다.

JWT + HttpOnly Cookie

JWT를 HttpOnly Cookie에 담으면 JavaScript에서 직접 읽을 수 없습니다. XSS 공격에서 토큰이 탈취되는 경로를 차단합니다. JWT 페이로드에 tenantIdtenantSlug를 포함시켜서 미들웨어에서 추가 DB 조회 없이 테넌트를 확인할 수 있습니다.

jose의 decodeJwt

현재는 서명 검증 없이 디코드만 합니다. 백엔드에서 JWT secret을 공유받으면 jwtVerify로 교체할 수 있도록 jose 라이브러리를 선택했습니다. API 호환이 같아서 한 줄 교체로 전환됩니다.

next-intl

i18n 처리를 미들웨어 레벨에서 함께 처리합니다. 테넌트 처리 후 i18n 라우팅을 태우는 구조라 두 관심사가 충돌하지 않습니다. 라우팅 설정을 @repo/i18n으로 분리해서 두 앱이 동일한 locale 목록을 바라봅니다.


백엔드에서 고려할 부분

프론트엔드는 테넌트를 식별하고 전달하는 역할만 합니다. 실제 데이터 격리는 백엔드의 몫입니다.

DB 격리 전략

초기에는 Shared DB + tenant_id 컬럼 방식을 권장합니다. 모든 테이블에 tenant_id를 두고, PostgreSQL Row-Level Security(RLS)로 쿼리 레벨에서 자동 필터링합니다. 애플리케이션 레이어에서 실수로 tenant_id 조건을 빠뜨려도 DB에서 차단됩니다.

CREATE POLICY tenant_isolation ON documents
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

트래픽이 늘고 엔터프라이즈 고객이 생기면 Schema-per-tenant로 마이그레이션할 수 있습니다.

JWT 발급과 검증

현재 프론트엔드는 JWT를 디코드만 합니다(서명 미검증). 백엔드가 JWT secret을 공유하면 jwtVerify로 교체해서 위변조를 차단해야 합니다. JWT 페이로드 스펙:

{
  "sub": "user_123",
  "tenantId": "tenant_abc",
  "tenantSlug": "acme",
  "role": "admin"
}

테넌트 프로비저닝

신규 고객사 온보딩은 슈퍼어드민(admin.service.com)에서 테넌트 레코드를 생성하면 즉시 활성화됩니다. 와일드카드 DNS 덕분에 DNS 변경이나 SSL 발급이 필요 없습니다. 백엔드에서 POST /tenants API 하나로 처리할 수 있습니다.

각 API 엔드포인트에서 독립 검증

미들웨어가 테넌트를 확인하더라도, 각 Server Action과 Route Handler에서도 독립적으로 테넌트를 검증하는 것을 권장합니다. 미들웨어 우회 가능성을 막기 위해서입니다.


SaaS와 온프레미스, 같은 코드로 가능한 이유

코드베이스를 두 개로 나누지 않습니다. 환경변수와 배포 방식만 다르게 가져갑니다.

SaaS

DEPLOYMENT_MODE=saas
MULTI_TENANT=true
NEXT_PUBLIC_BASE_DOMAIN=service.com

멀티테넌트 로직이 풀로 동작합니다. *.service.com으로 들어오는 모든 서브도메인 요청을 처리합니다.

온프레미스

DEPLOYMENT_MODE=onprem
MULTI_TENANT=false
NEXT_PUBLIC_BASE_DOMAIN=service.bigcorp.internal

MULTI_TENANT=false일 때 미들웨어는 서브도메인 파싱을 건너뛰고 단일 테넌트로 동작합니다. 고객사 도메인 하나에 단독으로 설치됩니다.

온프레미스 고객사는 Docker Compose나 Helm으로 직접 설치하고, 버전 업그레이드도 직접 관리합니다. 인프라 의존성을 최소화해서 AWS가 아닌 환경(GCP, Azure, 자체 서버)에서도 동작합니다.


추후 보완할 부분

JWT 서명 검증

현재 decodeJwt(서명 미검증)를 씁니다. 백엔드에서 JWT secret을 공유받으면 jwtVerify로 교체해야 합니다. 토큰 위변조 공격을 막기 위해 필수입니다.

플랜 기반 기능 게이팅

현재 TenantProvider{ id, slug }만 제공합니다. 플랜(Free/Pro/Enterprise)에 따라 기능을 켜고 끄는 로직이 아직 없습니다. 백엔드 API에서 planfeatures 목록을 함께 내려주고, TenantProvider에 포함시켜 컴포넌트 레벨에서 게이팅할 수 있도록 확장이 필요합니다.

Refresh Token 처리

현재 Access Token만 다룹니다. 토큰 만료 시 자동 갱신 로직(Refresh Token rotation)이 없습니다. 세션이 끊기면 로그인 페이지로 떨어지는데, 사용자 경험상 자동 갱신이 필요합니다.

테넌트 불일치 UX

현재 JWT의 tenantSlug와 서브도메인이 다르면 403 JSON을 그대로 반환합니다. 에러 페이지나 올바른 테넌트 URL로 안내하는 UX가 필요합니다.

온프레미스 Air-gapped 환경

인터넷이 차단된 환경에서의 동작을 검증해야 합니다. 외부 CDN 의존, 폰트 로드, 외부 API 호출 등을 점검하고 필요하면 self-hosted 옵션을 제공해야 합니다.

Server Action 레벨 테넌트 검증

현재 미들웨어에서만 테넌트를 확인합니다. 각 Server Action에서 독립적으로 getTenant()를 호출해 테넌트를 재검증하는 패턴을 표준화해야 합니다. 미들웨어를 우회하는 직접 API 호출에 대한 방어선입니다.

반응형
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/08   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31
글 보관함