Tenant 모듈
📝 초안 (Draft)
이 문서는 검토 중입니다. 내용이 변경될 수 있습니다.
멀티테넌시 데이터 격리를 자동으로 처리하는 Core 모듈입니다.
개요
Tenant 모듈은 테넌트 단위로 데이터를 자동 격리하는 기능을 제공합니다. BelongsToTenant Trait을 적용한 모델은 쿼리 시 자동으로 현재 테넌트의 데이터만 조회합니다.
┌─────────────────────────────────────────────────────────────┐
│ 데이터베이스 │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Tenant A │ │ Tenant B │ │ Tenant C │ │
│ │ tenant_id=1│ │ tenant_id=2│ │ tenant_id=3│ │
│ └────────────┘ └────────────┘ └────────────┘ │
└─────────────────────────────────────────────────────────────┘
↑
TenantScope 자동 필터링
↑
┌─────────────────────────────────────────────────────────────┐
│ User (tenant_id=2)가 요청 │
│ │
│ Product::all() │
│ → SELECT * FROM products WHERE tenant_id = 2 │
│ → Tenant B의 데이터만 반환 │
└─────────────────────────────────────────────────────────────┘
핵심 컴포넌트
| 컴포넌트 | 역할 |
|---|---|
TenantScope | 전역 스코프로 쿼리에 자동 tenant_id 조건 추가 |
BelongsToTenant | 모델에 적용하는 Trait (스코프 + 자동 설정) |
SetTenantContext | 미들웨어로 테넌트 컨텍스트 설정 |
TenantRouteService | URL에서 테넌트 식별 |
BelongsToTenant Trait
테넌트 격리가 필요한 모델에 적용합니다.
기본 사용법
<?php
namespace App\Models;
use App\Core\Base\Tenant\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
use BelongsToTenant;
protected $fillable = [
'name',
'price',
// tenant_id는 자동 설정되므로 fillable 불필요
];
}
Trait이 제공하는 기능
// 1. 생성 시 tenant_id 자동 설정
$product = Product::create(['name' => 'Item']);
// tenant_id가 현재 사용자의 tenant_id로 자동 설정됨
// 2. 조회 시 자동 필터링
Product::all();
// WHERE tenant_id = {현재 사용자 tenant_id}
// 3. 특정 테넌트 데이터 조회
Product::forTenant($tenantId)->get();
// 4. 테넌트 스코프 일시 해제 (관리자용)
Product::withoutTenantScope()->get();
// 5. 모든 테넌트 데이터 조회 (관리자용)
Product::allTenants()->get();
마이그레이션
테넌트 격리 테이블에는 tenant_id 컬럼이 필요합니다.
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->decimal('price', 10, 2);
$table->timestamps();
// 테넌트별 성능 최적화 인덱스
$table->index(['tenant_id', 'created_at']);
});
TenantScope 동작 방식
TenantScope는 사용자 레벨에 따라 다르게 동작합니다.
// TenantScope.php의 핵심 로직
public function apply(Builder $builder, Model $model): void
{
// 1. 테넌트 기능 비활성화 시 스킵
if (!config('core.tenant.enabled', true)) {
return;
}
$user = auth()->user();
if ($user) {
// 2. Platform/SaaS Admin은 테넌트 격리 우회
$bypassLevels = config('core.tenant.bypass_levels', [0, 1]);
if (in_array($user->level ?? 6, $bypassLevels, true)) {
// 테넌트 전환 컨텍스트가 있으면 해당 테넌트로 필터링
if (app()->has('current.tenant')) {
$tenant = app('current.tenant');
$builder->where('tenant_id', $tenant->getId());
}
// 없으면 모든 테넌트 데이터 접근 가능
return;
}
// 3. 일반 사용자는 자신의 테넌트 데이터만
if ($user->tenant_id) {
$builder->where('tenant_id', $user->tenant_id);
}
}
}
레벨별 동작
| 레벨 | 동작 | 예시 |
|---|---|---|
| Level 0-1 (Platform/SaaS Admin) | 모든 테넌트 접근 가능, 전환 가능 | 전체 대시보드 |
| Level 2 (Tenant Admin) | 자신의 테넌트만 접근 | 테넌트 관리 |
| Level 3-6 | 자신의 테넌트만 접근 | 일반 사용 |
테넌트 컨텍스트 설정
미들웨어 적용
// routes/web.php
Route::middleware(['auth', 'tenant.context'])->group(function () {
Route::resource('products', ProductController::class);
});
// routes/api.php
Route::middleware(['auth:sanctum', 'tenant.context'])->group(function () {
Route::apiResource('products', ProductController::class);
});
수동 컨텍스트 설정 (관리자용)
// Platform Admin이 특정 테넌트로 전환
app()->instance('current.tenant', $tenant);
// 이후 쿼리는 해당 테넌트로 필터링
Product::all(); // 해당 테넌트 데이터만
테넌트 격리 우회
관리자가 여러 테넌트 데이터를 조회해야 할 때 사용합니다.
withoutTenantScope()
// 특정 쿼리에서만 스코프 해제
$allProducts = Product::withoutTenantScope()
->where('price', '>', 1000)
->get();
// 일반 쿼리는 여전히 스코프 적용
$myProducts = Product::all();
allTenants()
// 모든 테넌트 데이터 조회 (별칭)
$stats = Product::allTenants()
->selectRaw('tenant_id, COUNT(*) as count')
->groupBy('tenant_id')
->get();
forTenant()
// 특정 테넌트 데이터만 조회
$tenantProducts = Product::forTenant($tenantId)->get();
// 여러 테넌트
$products = Product::whereIn('tenant_id', [$tenantA, $tenantB])->get();
설정 (config/core.php)
'tenant' => [
// 테넌트 기능 활성화
'enabled' => env('CORE_TENANT_ENABLED', true),
// PostgreSQL RLS 활성화 (선택)
'rls_enabled' => env('CORE_RLS_ENABLED', false),
// 테넌트 컬럼명
'column' => 'tenant_id',
// 격리 우회 가능 레벨 (Platform/SaaS Admin)
'bypass_levels' => [0, 1],
],
테넌트 전환 (Impersonate와 연동)
Platform/SaaS Admin이 특정 테넌트의 관점으로 시스템을 볼 수 있습니다.
// TenantSwitchController.php
public function switch(Tenant $tenant)
{
// 권한 확인 (Level 0-1만 가능)
if (!auth()->user()->canBypassTenantIsolation()) {
abort(403);
}
// 컨텍스트 설정
app()->instance('current.tenant', $tenant);
session(['switched_tenant_id' => $tenant->id]);
return redirect()->back()
->with('info', "{$tenant->name} 테넌트로 전환되었습니다.");
}
public function exitSwitch()
{
app()->forgetInstance('current.tenant');
session()->forget('switched_tenant_id');
return redirect()->back()
->with('info', '테넌트 전환이 해제되었습니다.');
}
PostgreSQL RLS (Row Level Security)
데이터베이스 레벨에서 추가 보안을 제공합니다.
RLS 활성화 마이그레이션
// 2024_01_01_000001_enable_rls_products.php
public function up(): void
{
if (config('core.tenant.rls_enabled')) {
DB::statement('ALTER TABLE products ENABLE ROW LEVEL SECURITY');
DB::statement('ALTER TABLE products FORCE ROW LEVEL SECURITY');
// 정책 생성
DB::statement("
CREATE POLICY tenant_isolation_policy ON products
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id')::bigint)
");
}
}
public function down(): void
{
if (config('core.tenant.rls_enabled')) {
DB::statement('DROP POLICY IF EXISTS tenant_isolation_policy ON products');
DB::statement('ALTER TABLE products DISABLE ROW LEVEL SECURITY');
}
}
RLS 컨텍스트 설정
// 미들웨어에서 설정
DB::statement("SET app.current_tenant_id = '{$tenantId}'");
RLS 주의사항
RLS는 Application Level 격리(TenantScope)와 함께 사용하는 이중 보안입니다. RLS만 사용하면 Eloquent 이벤트 등에서 문제가 발생할 수 있습니다.
테스트 작성
테넌트 격리 테스트
use App\Models\Product;
use App\Models\Tenant;
use App\Models\User;
test('사용자는 자신의 테넌트 데이터만 볼 수 있다', function () {
// Given: 두 테넌트와 각 테넌트의 상품
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$userA = User::factory()->create(['tenant_id' => $tenantA->id, 'level' => 6]);
$userB = User::factory()->create(['tenant_id' => $tenantB->id, 'level' => 6]);
$productA = Product::factory()->create(['tenant_id' => $tenantA->id]);
$productB = Product::factory()->create(['tenant_id' => $tenantB->id]);
// When: 사용자 A로 로그인하여 상품 조회
$this->actingAs($userA);
$products = Product::all();
// Then: 테넌트 A의 상품만 보여야 함
expect($products)->toHaveCount(1)
->and($products->first()->id)->toBe($productA->id);
});
test('Platform Admin은 모든 테넌트 데이터를 볼 수 있다', function () {
// Given: 여러 테넌트의 상품
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
Product::factory()->create(['tenant_id' => $tenantA->id]);
Product::factory()->create(['tenant_id' => $tenantB->id]);
$admin = User::factory()->create(['level' => 0]); // Platform Admin
// When: Platform Admin으로 조회
$this->actingAs($admin);
$products = Product::all();
// Then: 모든 상품이 보여야 함
expect($products)->toHaveCount(2);
});
test('생성 시 tenant_id가 자동 설정된다', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id, 'level' => 6]);
$this->actingAs($user);
// tenant_id 명시하지 않고 생성
$product = Product::create(['name' => 'Test Product', 'price' => 100]);
expect($product->tenant_id)->toBe($tenant->id);
});
테넌트 전환 테스트
test('Platform Admin이 테넌트를 전환할 수 있다', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
Product::factory()->create(['tenant_id' => $tenantA->id]);
Product::factory()->create(['tenant_id' => $tenantB->id]);
$admin = User::factory()->create(['level' => 0]);
$this->actingAs($admin);
// 테넌트 A로 전환
app()->instance('current.tenant', $tenantA);
$products = Product::all();
// 테넌트 A의 데이터만 보임
expect($products)->toHaveCount(1)
->and($products->first()->tenant_id)->toBe($tenantA->id);
});
흔한 실수와 해결
1. tenant_id 누락
// ❌ 잘못된 예: fillable에 tenant_id 추가하고 수동 설정
protected $fillable = ['name', 'tenant_id'];
Product::create(['name' => 'Test', 'tenant_id' => 1]);
// ✅ 올바른 예: BelongsToTenant가 자동 설정
protected $fillable = ['name'];
Product::create(['name' => 'Test']);
// tenant_id는 현재 사용자의 tenant_id로 자동 설정
2. 전역 쿼리에서 격리 누락
// ❌ 잘못된 예: 관계 쿼리에서 격리 적용 안 됨
class Category extends Model
{
// BelongsToTenant 누락!
public function products()
{
return $this->hasMany(Product::class);
}
}
// ✅ 올바른 예: 양쪽 모델에 BelongsToTenant 적용
class Category extends Model
{
use BelongsToTenant;
public function products()
{
return $this->hasMany(Product::class);
}
}
3. 콘솔/큐에서 테넌트 컨텍스트 누락
// ❌ 잘못된 예: 큐 작업에서 테넌트 컨텍스트 없음
class ProcessOrder implements ShouldQueue
{
public function handle()
{
Product::all(); // 어떤 테넌트?
}
}
// ✅ 올바른 예: 테넌트 ID 전달 및 설정
class ProcessOrder implements ShouldQueue
{
public function __construct(
public Order $order
) {}
public function handle()
{
// 테넌트 컨텍스트 설정
app()->instance('current.tenant', $this->order->tenant);
$products = Product::all(); // 해당 테넌트 데이터
}
}
모범 사례
1. 테넌트 격리 대상 명확화
// 테넌트 격리 필요
class Product extends Model { use BelongsToTenant; }
class Order extends Model { use BelongsToTenant; }
class Customer extends Model { use BelongsToTenant; }
// 테넌트 격리 불필요 (공유 데이터)
class Country extends Model { /* BelongsToTenant 없음 */ }
class Currency extends Model { /* BelongsToTenant 없음 */ }
class SystemSetting extends Model { /* BelongsToTenant 없음 */ }
2. 테넌트 컬럼 인덱스
// 성능 최적화를 위한 복합 인덱스
Schema::table('products', function (Blueprint $table) {
$table->index(['tenant_id', 'status']);
$table->index(['tenant_id', 'category_id']);
$table->index(['tenant_id', 'created_at']);
});
3. Eager Loading에서 테넌트 고려
// 테넌트 격리가 자동 적용됨
$orders = Order::with(['products', 'customer'])->get();
// 명시적 테넌트 조건 (명확성)
$orders = Order::with([
'products' => fn($q) => $q->forTenant($tenantId),
])->get();
관련 문서
- 멀티테넌시 - 개념 이해
- 데이터 격리 - 격리 전략
- Permission 모듈 - 레벨별 접근 제어
- 테스트 가이드 - 테넌트 테스트 작성