멀티테넌시
이 문서는 검토 중입니다. 내용이 변경될 수 있습니다.
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 방식의 장점
- 단순한 인프라: 하나의 DB만 관리
- 효율적 리소스: 커넥션 풀 공유
- 간편한 마이그레이션: 모든 테넌트에 동시 적용
- 유연한 확장: 테넌트 수 제한 없음
테넌트 식별
테넌트는 다양한 방식으로 식별할 수 있습니다.
지원하는 식별 방식
| 방식 | 예시 | 설정 |
|---|---|---|
| 서브도메인 | company-a.app.com | 도메인 설정 필요 |
| 경로 기반 | app.com/tenant/company-a | 기본 제공 |
| 헤더 기반 | X-Tenant-ID: 123 | API 용 |
| 도메인 | 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"