Skip to main content

멀티테넌시

📝 초안 (Draft)

이 문서는 검토 중입니다. 내용이 변경될 수 있습니다.

Multi-SaaS Kit의 멀티테넌시 아키텍처와 테넌트 관리 방법을 설명합니다.

개요

**멀티테넌시(Multi-tenancy)**란 하나의 애플리케이션 인스턴스에서 여러 고객(테넌트)을 서비스하는 아키텍처입니다. 각 테넌트의 데이터는 완전히 격리되어 다른 테넌트가 접근할 수 없습니다.

┌─────────────────────────────────────────────────────────────────┐
│ Multi-SaaS Kit 플랫폼 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
│ │ Tenant A │ │ Tenant B │ │ Tenant C │ │
│ │ (회사 A) │ │ (회사 B) │ │ (회사 C) │ │
│ ├───────────────┤ ├───────────────┤ ├───────────────┤ │
│ │ 사용자 │ │ 사용자 │ │ 사용자 │ │
│ │ 데이터 │ │ 데이터 │ │ 데이터 │ │
│ │ 설정 │ │ 설정 │ │ 설정 │ │
│ └───────────────┘ └───────────────┘ └───────────────┘ │
│ 🔒 🔒 🔒 │
│ 격리됨 격리됨 격리됨 │
└─────────────────────────────────────────────────────────────────┘

테넌트란?

테넌트는 플랫폼의 고객 단위입니다. 일반적으로 회사, 조직, 또는 구독자를 나타냅니다.

용어설명예시
테넌트서비스를 이용하는 고객 단위회사 A, 학교 B
테넌트 관리자테넌트를 관리하는 사용자 (Level 2)회사 A의 관리자
테넌트 멤버테넌트에 소속된 일반 사용자회사 A의 직원

격리 방식

Multi-SaaS Kit은 단일 데이터베이스 + RLS(Row-Level Security) 방식을 사용합니다.

격리 방식 비교

방식설명장점단점
별도 데이터베이스테넌트마다 DB 분리완전한 격리관리 복잡, 비용 높음
별도 스키마테넌트마다 스키마 분리격리 보장마이그레이션 복잡
RLS (채택)단일 DB + 행 수준 보안효율적, 간단정책 설계 중요

RLS 방식의 장점

  1. 단순한 인프라: 하나의 DB만 관리
  2. 효율적 리소스: 커넥션 풀 공유
  3. 간편한 마이그레이션: 모든 테넌트에 동시 적용
  4. 유연한 확장: 테넌트 수 제한 없음

테넌트 식별

테넌트는 다양한 방식으로 식별할 수 있습니다.

지원하는 식별 방식

방식예시설정
서브도메인company-a.app.com도메인 설정 필요
경로 기반app.com/tenant/company-a기본 제공
헤더 기반X-Tenant-ID: 123API 용
도메인company-a.com커스텀 도메인

테넌트 컨텍스트

현재 테넌트는 자동으로 감지되어 컨텍스트에 저장됩니다.

// 현재 테넌트 가져오기
$tenant = app('current.tenant');

// 테넌트 ID 확인
$tenantId = $tenant->getId();

// 테넌트 이름
$tenantName = $tenant->name;

사용자 기반 자동 감지

로그인한 사용자의 tenant_id를 통해 자동으로 테넌트가 설정됩니다.

// 사용자의 테넌트 ID
$user->tenant_id;

// 사용자가 속한 테넌트
$user->tenant;

테넌트 생성 및 관리

테넌트 생성 (Level 0-1)

Platform Admin 또는 SaaS Admin만 테넌트를 생성할 수 있습니다.

use App\Models\Tenant;

$tenant = Tenant::create([
'name' => '회사 A',
'slug' => 'company-a',
'settings' => [
'theme' => 'light',
'timezone' => 'Asia/Seoul',
],
]);

테넌트 관리자 생성

테넌트 생성 시 해당 테넌트의 관리자(Level 2)를 함께 생성합니다.

use App\Models\User;
use App\Core\Base\Permission\Enums\UserLevel;

$tenantAdmin = User::create([
'name' => '홍길동',
'email' => 'admin@company-a.com',
'password' => bcrypt('secure-password'),
'level' => UserLevel::TENANT_ADMIN->value, // Level 2
'tenant_id' => $tenant->id,
]);

Filament에서 테넌트 관리

SaaS Admin 패널에서 테넌트를 관리합니다.

http://localhost:8100/saas/tenants
기능설명
테넌트 목록모든 테넌트 조회
테넌트 생성새 테넌트 추가
테넌트 수정설정 변경
사용자 관리테넌트 내 사용자

테넌트 데이터 모델

BelongsToTenant Trait

테넌트에 소속되는 모델에 적용합니다.

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use App\Core\Base\Tenant\Traits\BelongsToTenant;
use App\Core\Base\Tenant\Contracts\TenantableInterface;

class Post extends Model implements TenantableInterface
{
use BelongsToTenant;

protected $fillable = ['title', 'content', 'tenant_id'];
}

자동 동작

BelongsToTenant trait을 사용하면:

동작설명
자동 필터링쿼리 시 현재 테넌트 데이터만 조회
자동 할당생성 시 현재 테넌트 ID 자동 설정
관계 정의$model->tenant 관계 자동 제공
// 자동 필터링: 현재 테넌트의 포스트만 조회
$posts = Post::all(); // tenant_id = 현재 테넌트

// 자동 할당: tenant_id 자동 설정
$post = Post::create([
'title' => '새 글',
'content' => '내용',
// tenant_id는 자동 설정됨
]);

// 테넌트 관계
$tenant = $post->tenant;

테넌트 격리 우회

관리자 권한

Level 0-1 (Platform/SaaS Admin)은 테넌트 격리를 우회할 수 있습니다.

// 현재 사용자가 격리 우회 가능한지 확인
if ($user->canBypassTenantIsolation()) {
// 모든 테넌트 데이터 접근 가능
}

명시적 우회

특정 쿼리에서 테넌트 스코프를 제거할 수 있습니다.

// 모든 테넌트의 포스트 조회 (관리자용)
$allPosts = Post::withoutTenantScope()->get();

// 또는
$allPosts = Post::allTenants()->get();

// 특정 테넌트로 필터링
$posts = Post::forTenant($tenantId)->get();
보안 주의

withoutTenantScope()는 관리자 기능에서만 사용하세요. 일반 사용자에게 노출되면 데이터 유출이 발생합니다.

테넌트 컨텍스트 전환

관리자의 테넌트 전환

Platform/SaaS Admin은 특정 테넌트로 컨텍스트를 전환할 수 있습니다.

// 테넌트 컨텍스트 설정
app()->instance('current.tenant', $tenant);

// 이후 쿼리는 해당 테넌트로 필터링
$posts = Post::all(); // 전환된 테넌트의 데이터

Impersonate와 함께 사용

사용자 전환(Impersonate) 시 해당 사용자의 테넌트로 자동 전환됩니다.

// Level 2 사용자로 전환
$impersonateService->impersonate($tenantAdmin);

// 자동으로 해당 테넌트 컨텍스트로 전환
$posts = Post::all(); // 전환된 사용자의 테넌트 데이터

테넌트 설정

테넌트별 설정 저장

테넌트마다 다른 설정을 적용할 수 있습니다.

// 테넌트 설정 저장
$tenant->update([
'settings' => [
'theme' => 'dark',
'timezone' => 'Asia/Seoul',
'language' => 'ko',
'features' => [
'chat' => true,
'analytics' => false,
],
],
]);

// 설정 조회
$theme = $tenant->settings['theme'];
$hasChat = $tenant->settings['features']['chat'] ?? false;

config에서 테넌트 설정 사용

// config/core.php
return [
'tenant' => [
'enabled' => true,
'bypass_levels' => [0, 1], // 격리 우회 가능한 레벨
'default_settings' => [
'theme' => 'light',
'timezone' => 'UTC',
],
],
];

데이터베이스 마이그레이션

tenant_id 컬럼 추가

테넌트에 속하는 테이블에는 tenant_id 컬럼이 필요합니다.

Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->onDelete('cascade');
$table->string('title');
$table->text('content');
$table->timestamps();

// 테넌트 내에서 고유 인덱스 (선택)
$table->unique(['tenant_id', 'slug']);
});

인덱스 권장사항

// tenant_id 기반 인덱스 추가 (성능)
$table->index('tenant_id');

// 복합 인덱스 (자주 사용하는 쿼리)
$table->index(['tenant_id', 'created_at']);
$table->index(['tenant_id', 'status']);

모범 사례

1. 모든 테넌트 데이터에 tenant_id

테넌트에 속하는 모든 데이터는 tenant_id를 포함해야 합니다.

// ✅ 좋은 예: tenant_id 포함
class Invoice extends Model
{
use BelongsToTenant;
}

// ❌ 나쁜 예: tenant_id 없음
class Invoice extends Model
{
// 다른 테넌트 데이터 접근 가능 (보안 위험)
}

2. 관계에서 테넌트 확인

관계를 통한 접근에서도 테넌트를 확인하세요.

// ✅ 좋은 예: 관계도 테넌트 스코프 적용
public function posts()
{
return $this->hasMany(Post::class);
// Post에 BelongsToTenant가 있으면 자동 필터링
}

// ❌ 나쁜 예: 직접 쿼리 시 테넌트 확인 누락
$posts = Post::where('user_id', $userId)->get();
// 다른 테넌트의 데이터도 포함될 수 있음

3. API에서 테넌트 헤더 사용

API 호출 시 테넌트를 명시적으로 지정합니다.

# API 요청 시 테넌트 헤더
curl -X GET https://api.example.com/posts \
-H "Authorization: Bearer {token}" \
-H "X-Tenant-ID: 123"

관련 문서