본문으로 건너뛰기

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미들웨어로 테넌트 컨텍스트 설정
TenantRouteServiceURL에서 테넌트 식별

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();

관련 문서