데이터 격리
📝 초안 (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 방식을 선택 한 이유
- 단순한 운영: 하나의 DB만 관리
- 비용 효율: 커넥션 풀 공유
- 간편한 배포: 마이그레이션 한 번에 적용
- 유연한 확장: 테넌트 수 무제한
격리 계층
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();