본문으로 건너뛰기

데이터 격리

📝 초안 (Draft)

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

PostgreSQL RLS(Row-Level Security)를 활용한 테넌트 데이터 격리 아키텍처를 설명합니다.

개요

데이터 격리는 멀티테넌트 SaaS에서 가장 중요한 보안 요소입니다. 한 테넌트의 사용자가 다른 테넌트의 데이터에 접근할 수 없도록 보장합니다.

┌─────────────────────────────────────────────────────────────────┐
│ PostgreSQL 데이터베이스 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ posts 테이블 │
│ ┌──────┬───────────┬─────────────────────────────────────┐ │
│ │ id │ tenant_id │ content │ │
│ ├──────┼───────────┼─────────────────────────────────────┤ │
│ │ 1 │ 🔵 1 │ Tenant A의 게시글 │ │
│ │ 2 │ 🔴 2 │ Tenant B의 게시글 │ │
│ │ 3 │ 🔵 1 │ Tenant A의 다른 게시글 │ │
│ │ 4 │ 🟢 3 │ Tenant C의 게시글 │ │
│ └──────┴───────────┴─────────────────────────────────────┘ │
│ │
│ RLS 정책: WHERE tenant_id = current_tenant() │
│ │
│ 🔵 Tenant A 사용자 → 행 1, 3만 보임 │
│ 🔴 Tenant B 사용자 → 행 2만 보임 │
│ 🟢 Tenant C 사용자 → 행 4만 보임 │
│ │
└─────────────────────────────────────────────────────────────────┘

격리 방식 비교

방식격리 수준복잡도비용채택
별도 데이터베이스물리적높음높음-
별도 스키마논리적중간중간-
RLS (행 수준)논리적낮음낮음

RLS 방식을 선택한 이유

  1. 단순한 운영: 하나의 DB만 관리
  2. 비용 효율: 커넥션 풀 공유
  3. 간편한 배포: 마이그레이션 한 번에 적용
  4. 유연한 확장: 테넌트 수 무제한

격리 계층

Multi-SaaS Kit은 2중 격리를 적용합니다.

1. 애플리케이션 레벨 (Laravel)

Eloquent ORM에서 자동으로 tenant_id 필터링.

// BelongsToTenant Trait가 적용된 모델
class Post extends Model
{
use BelongsToTenant;
}

// 자동으로 tenant_id 조건 추가
Post::all();
// SELECT * FROM posts WHERE tenant_id = ?

2. 데이터베이스 레벨 (PostgreSQL RLS)

PostgreSQL의 RLS 정책으로 추가 보호.

-- RLS 정책 예시
CREATE POLICY tenant_isolation ON posts
USING (tenant_id = current_setting('app.tenant_id')::integer);

Laravel에서의 격리 구현

TenantScope 동작

BelongsToTenant Trait이 적용된 모델은 자동으로 테넌트 필터링됩니다.

namespace App\Core\Base\Tenant\Scopes;

class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$user = auth()->user();

// Platform/SaaS Admin (Level 0-1)은 우회 가능
if ($user && $user->level <= 1) {
// 테넌트 컨텍스트가 있으면 해당 테넌트로 필터링
if (app()->has('current.tenant')) {
$builder->where('tenant_id', app('current.tenant')->getId());
}
return;
}

// 일반 사용자는 자신의 테넌트만
if ($user && $user->tenant_id) {
$builder->where('tenant_id', $user->tenant_id);
}
}
}

생성 시 자동 할당

// BelongsToTenant Trait
static::creating(function (Model $model) {
// Platform/SaaS Admin이 직접 지정한 경우 허용
if (auth()->check() && auth()->user()->level <= 1 && $model->tenant_id) {
return;
}

// 일반 사용자는 현재 컨텍스트의 tenant_id 강제 적용
if (app()->has('current.tenant')) {
$model->tenant_id = app('current.tenant')->getId();
} elseif (auth()->check()) {
$model->tenant_id = auth()->user()->tenant_id;
}
});

격리 우회 레벨

config 설정

// config/core.php
return [
'tenant' => [
'bypass_levels' => [0, 1], // 우회 가능한 레벨
],
];

레벨별 동작

Level격리설명
0-1우회 가능모든 테넌트 데이터 접근
2-6적용자신의 테넌트만 접근
// 우회 가능 여부 확인
if ($user->canBypassTenantIsolation()) {
// 모든 테넌트 데이터 접근
}

격리 정책 예제

기본 정책 (자동 적용)

// 모든 쿼리에 tenant_id 조건 자동 추가
$posts = Post::all();
// SELECT * FROM posts WHERE tenant_id = 현재_테넌트_ID

조건부 격리

특정 조건에서만 격리를 적용합니다.

// 공개 게시글은 모든 테넌트가 볼 수 있음
public function scopePublicOrOwned(Builder $query): Builder
{
return $query->where(function ($q) {
$q->where('is_public', true)
->orWhere('tenant_id', auth()->user()->tenant_id);
});
}

// 사용
$posts = Post::publicOrOwned()->get();

예외 정책

테넌트 격리에서 제외할 테이블을 정의합니다.

// 공유 데이터 테이블 (격리 제외)
class Country extends Model
{
// BelongsToTenant 미사용 → 전역 데이터
}

class Currency extends Model
{
// BelongsToTenant 미사용 → 전역 데이터
}

명시적 격리 제어

격리 해제 (관리자용)

// 전체 테넌트 데이터 조회
$allPosts = Post::withoutTenantScope()->get();

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

특정 테넌트 지정

// 특정 테넌트의 데이터만 조회
$posts = Post::forTenant(123)->get();

// 컬렉션에서 필터링
$tenantPosts = $posts->where('tenant_id', 123);

데이터 누출 방지 체크리스트

코드 작성 시 확인사항

항목확인
테넌트 데이터 모델에 BelongsToTenant 적용
마이그레이션에 tenant_id 컬럼 추가
tenant_id에 외래 키 제약 설정
직접 쿼리 시 tenant_id 조건 포함
API 응답에 다른 테넌트 데이터 미포함 확인
파일 업로드 시 테넌트별 경로 분리

위험한 패턴

// ❌ 위험: 직접 쿼리에서 tenant_id 누락
DB::table('posts')->where('status', 'published')->get();

// ✅ 안전: Eloquent 사용 (자동 필터링)
Post::where('status', 'published')->get();

// ✅ 안전: 직접 쿼리 시 명시적 필터링
DB::table('posts')
->where('tenant_id', auth()->user()->tenant_id)
->where('status', 'published')
->get();

관계에서의 주의점

// ❌ 위험: 관계 통해 다른 테넌트 접근 가능
$user = User::find(1); // 다른 테넌트 사용자
$posts = $user->posts; // 해당 사용자의 모든 게시글

// ✅ 안전: Post 모델에 BelongsToTenant 적용
// TenantScope가 자동으로 현재 테넌트로 필터링

테스트 방법

단위 테스트

namespace Tests\Unit\Core\Tenant;

use Tests\TestCase;
use App\Models\Post;
use App\Models\User;
use App\Models\Tenant;

class TenantIsolationTest extends TestCase
{
public function test_user_can_only_see_own_tenant_data(): void
{
// 두 테넌트 생성
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();

// 각 테넌트에 게시글 생성
$postA = Post::factory()->create(['tenant_id' => $tenantA->id]);
$postB = Post::factory()->create(['tenant_id' => $tenantB->id]);

// Tenant A 사용자로 로그인
$userA = User::factory()->create(['tenant_id' => $tenantA->id]);
$this->actingAs($userA);

// Tenant A 게시글만 조회됨
$posts = Post::all();

$this->assertCount(1, $posts);
$this->assertEquals($postA->id, $posts->first()->id);
}

public function test_platform_admin_can_see_all_tenants(): void
{
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();

Post::factory()->create(['tenant_id' => $tenantA->id]);
Post::factory()->create(['tenant_id' => $tenantB->id]);

// Platform Admin (Level 0)
$admin = User::factory()->create(['level' => 0, 'tenant_id' => null]);
$this->actingAs($admin);

// 모든 테넌트 데이터 조회 가능
$posts = Post::all();

$this->assertCount(2, $posts);
}
}

Feature 테스트

namespace Tests\Feature\Core\Security;

use Tests\TestCase;
use App\Models\Post;
use App\Models\User;
use App\Models\Tenant;

class CrossTenantAccessTest extends TestCase
{
public function test_cannot_access_other_tenant_data_via_api(): void
{
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();

$postB = Post::factory()->create(['tenant_id' => $tenantB->id]);

$userA = User::factory()->create(['tenant_id' => $tenantA->id]);

// Tenant A 사용자가 Tenant B의 게시글 접근 시도
$response = $this->actingAs($userA)
->getJson("/api/posts/{$postB->id}");

$response->assertStatus(404); // 접근 불가
}

public function test_cannot_update_other_tenant_data(): void
{
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();

$postB = Post::factory()->create(['tenant_id' => $tenantB->id]);

$userA = User::factory()->create(['tenant_id' => $tenantA->id]);

$response = $this->actingAs($userA)
->putJson("/api/posts/{$postB->id}", ['title' => 'Hacked']);

$response->assertStatus(404);

// 데이터 변경 안됨 확인
$this->assertDatabaseMissing('posts', [
'id' => $postB->id,
'title' => 'Hacked',
]);
}
}

성능 고려사항

인덱스 최적화

// 마이그레이션에서 인덱스 추가
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained();
$table->string('status');
$table->timestamps();

// 복합 인덱스 (테넌트 + 자주 사용하는 컬럼)
$table->index(['tenant_id', 'status']);
$table->index(['tenant_id', 'created_at']);
});

쿼리 분석

-- EXPLAIN으로 쿼리 분석
EXPLAIN ANALYZE
SELECT * FROM posts
WHERE tenant_id = 1 AND status = 'published';

-- 인덱스 사용 확인
-- Index Scan using posts_tenant_id_status_index

대용량 데이터 처리

// 청크 처리로 메모리 효율화
Post::where('status', 'draft')
->chunk(1000, function ($posts) {
foreach ($posts as $post) {
// 처리
}
});

// 또는 커서 사용
foreach (Post::where('status', 'draft')->cursor() as $post) {
// 처리
}

파일 스토리지 격리

테넌트별 경로 분리

// 업로드 시 테넌트별 경로 사용
$path = "tenants/{$tenant->id}/uploads/{$filename}";
Storage::put($path, $file);

// 또는 헬퍼 함수 사용
function tenantPath(string $path): string
{
$tenantId = auth()->user()->tenant_id;
return "tenants/{$tenantId}/{$path}";
}

Storage::put(tenantPath("documents/{$filename}"), $file);

접근 제어

// 파일 다운로드 시 테넌트 확인
public function download(Document $document)
{
// Policy에서 테넌트 확인
$this->authorize('download', $document);

return Storage::download($document->path);
}

관련 문서