라라벨에서 멀티 테넌시하는 두 가지 방법.
프레임워크를 사용할 때는 그 프레임워크가 의도한 대로 작업하고 싶어요. 기분이 좋아지는 느낌이죠? 문제가 생기면 프레임워크가 매우 우아한 방식으로 그 문제를 해결했을 가능성이 높습니다. 적어도 프레임워크가 좋다면요. 그리고 라라벨을 사용하면요, 최고 중의 최고를 사용하고 있어요. 그러니까 분명 멋진 분이시겠죠.
그렇게 멋진 분(사람, 고양이, 어떻게든 자신을 정의하는 대로)이세요, 멋진 SaaS 아이디어를 가지고 앱을 만드려고 하시나 본가요? 그게 SaaS라면 사용자들이 필요합니다. 사용자가 필요하다면, 이들을 서로 구분해야겠죠. Jane이 Janet의 데이터를 보는 건 원하지 않으시겠죠? 물론 그렇지요. 아무도 그런 걸 보고 싶어하지 않습니다. 이게 멀티 테넌시라는 개념이 들어오는 곳입니다.
여러분이 사용하는 멀티 테넌시는, 하나의 데이터베이스를 공유하는 여러 사용자가 있는 앱을 의미합니다. 특히 이번 서버리스 샤넌이 복잡한 시대에서 이는 다른 것을 의미할 수 있습니다. 그러나 우리의 목적에 맞게, 이는 단일 데이터베이스, 여러 사용자를 가리킵니다. 이는 작고 큰 SaaS 앱에서 일반적인 설정입니다. 복잡한 서버리스 설정을 관리하는 복잡성을 줄이지만, 일부 보안 문제를 도입할 수도 있습니다. 정확히 어떻게 Jane이 Janet을 감시하는 걸 막을 수 있을까요?
이 작업을 수행하는 여러 가지 방법이 있습니다. 고양이를 애정스럽게 쓰다듬는 여러 방법이 있어요. 실제로 멀티 테넌시를 재미있게 만들기 위해 Laravel의 마법을 활용하는 몇 가지 방법에 대해 이야기하려고 합니다 (이것이 Laravel이 재미있게 만드는 데 좋은 점입니다)! 이러한 접근 방법은 다음과 같습니다:
- "tenant" 미들웨어 사용하기
- 전역 쿼리 스코프 사용하기
하지만 그에 앞서 스코프된 URL에 대해 이야기해야 합니다.
스코프된 URL
알겠어요, scoped urls에 대해 알려드릴게요. 이는 Laravel의 라우팅 범주에 속하며, 이것을 깊게 다루진 않겠지만, 공식 문서는 정말 대단해요. 간단히 이야기해보면 라우트 매개변수에 대해요.
라라벨은 라우트를 생성할 때 url의 일부를 매개변수화하는 것을 허용하며, 이는 매우 일반적으로 모델과 대응될 것입니다. 우리의 특정 케이스에서는 테넌트(팀)을 생각해보세요:
Route::get('/app/{tenant}', function(Tenant $tenant){
return "안녕하세요 {$tenant.name}"
});
암시적 바인딩을 사용하면, Laravel이 테넌트 매개변수를 가져와 해당 테넌트 모델을 찾아서 이름을 표시할 겁니다. 이것만으로도 정말 멋져요. 그러나 아마도 사람들이 id를 사용하여 테넌트를 조회하는 것을 원하지 않을 것이므로, Laravel은 바인딩 키를 지정할 수 있게 해줘요:
Route::get('app/{tenant:slug}', function(Tenant $tenant){...});
알겠어요. 이제 매개 변수화에 대해 이해했으니(그리고 실제 단어라는 것을 배웠으니) 범위 지정된 URL로 계속 진행해 봅시다. 단일 매개 변수를 사용하는 URL도 멋지지만, 두 개를 사용하는 URL은 더 멋집니다:
Route::get('app/{tenant:slug}/{project:slug}', function (
Tenant $tenant,
Project $project)
{
return "프로젝트 {$project->name}에 오신 것을 환영합니다."
})
네, 암시적 바인딩을 사용하고 경로의 매개 변수 순서를 준수하여라면 Laravel은 사용자에게 보여줄 적합한 Tenant 및 Project를 가져올 것입니다. 그러나 이것이 전부가 아니에요! 우리가 바인딩 키(slug)를 지정했고, 특히 두 번째 매개 변수에 대한 특별한 설정을 한 것때문에 Laravel은 "하위" 매개 변수로 처리할 것이며, 테넌트의 직속 하위인 프로젝트만 반환할 것입니다. 따라서 "Cats Inc."라는 이름의 회사에 "Cat Food"라는 프로젝트가 있는 경우, /app/cats-inc/cat-food는 우리에게 해당 프로젝트를 제공할 것이지만, /app/dogs-inc/cat-food는 그렇지 않을 것입니다(같은 이름을 가진 프로젝트를 가진 Dogs Inc.가 존재하는 경우를 제외합니다. 그러나 그럴 경우에도 여전히 각 팀에 맞는 올바른 프로젝트를 보고 있을 것입니다). 매개 변수를 계속해서 연결할 수 있고, 관계가 모델에서 정의되어 있고 타입 힌트를 적절히 지정한다면 Laravel은 모두 가져오는 방법을 알고 있습니다.
Route::get('/app/{tenant:slug}/{project:slug}/{project-item:slug}/{item-comment:slug}...', function( Tenant $tenant, Project $project, ProjectItem $projectItem, ItemComment $itemComment )) { return $itemComment; }
만약 고양이 주식회사의 고양이 프로젝트를 위한 슬라이드쇼 전달가능한 항목에 대한 아이템 코멘트가 존재한다면, 사용자는 그것을 볼 수 있어요. 정말 단순해요.
코드 재사용을 피하기 위해 라우트 그룹화를 사용할 수 있어요:
Route::prefix('/app/{tenant:slug}')->group(function(){ Route::get('/{project:slug}', function(Tenant, Project)...); Route::get('/{member:name}', function (Tenant, Member)...; })
이제 프로젝트를 테넌트에 범위로 지정하고 회원을 테넌트에 범위로 지정하는 라우트를 생성했어요. 그것도 한 번에 처리했답니다. 라라벨 진짜 멋지죠? 저는 그렇게 생각해요.
테넌트 미들웨어
좋아요, 여기 똑똑한 독자들(여러분)은 아마 여기서 실제로 보안 문제에 대한 언급이 없다는 것에 주목했을 겁니다. 맞아요, 테넌트에 속한 프로젝트만 보여줄 수 있고 프로젝트 이름과 테넌트 이름을 추측해서 알아야만 볼 수 있는데, 이것은 보안이라기보다는 조금은 난해한 것일 뿐이에요. 한 종류의 보안이긴 하지만 아주 좋은 건 아니에요. 아무 것도 잠겨 있지 않죠. 여기서 "can" 미들웨어와 모델 정책이 등장해요.
모델 정책은 모델에 대한 액세스를 제어하는 방법입니다. 세부 내용에 대해 자세히 설명하지는 않겠지만, 기본적으로 특정 사용자가 특정 작업을 할 수 있는지를 결정하는 논리를 포함하는 함수를 정의하는 것입니다. 그래서 우리의 테넌트에 대해 특정 테넌트에 속한 사용자만 테넌트를 볼 수 있다면, 이렇게 할 수 있어요:
//App\Policies\TenantPolicy
class TenantPolicy
{
public function view(User $user, Tenant $tenant): bool
{
return $user->tenant->id === $tenant->id;
}
}
이는 사용자가 하나의 테넌트에만 속할 것으로 가정하고, 모델 간의 관계가 모델 클래스에 설정되어 있다고 가정합니다. 이는 전형적인 설정이며, 테넌트를 팀이나 회사와 같은 것으로 생각한다면 매우 이치에 맞는 설계입니다.
이제 정책을 설정했으니, 라우트에서 "can" 미들웨어를 사용하여 호출할 수 있고, 접두사에 사용한 것과 동일한 그룹에 적용할 수 있습니다:
Route::middleware('can:view,tenant')->prefix('/app/{tenant:slug}')->group(function(){
Route::get('/{project:slug}', function(Tenant, Project)...);
Route::get('/{member:name}', function (Tenant, Member)...);
});
마법 같지 않나요? 우리는 Laravel에게 기본 "can" 미들웨어를 사용하도록 지시하는 것뿐인데요. 이 미들웨어는 모델에 대한 정책을 호출할 수 있는데, 우리는 "view" 메서드 호출과 매개 변수인 "tenant"를 제공합니다. Laravel은 테넌트 매개 변수를 자동으로 Tenant 클래스에 바인딩하고 테넌트 정책을 호출하도록 명명 규칙을 알고 있습니다. 정책은 전달된 URL의 테넌트가 사용자가 속한 것과 같은지 알려줄 것입니다. 맞다면 좋아요! 그렇지 않으면 사용자는 403 "금지됨" 오류를 받게 될 거예요.
이 접근법을 사용하면 모든 하위 리소스의 보안에 신경쓰게 됩니다. 사용자가 현재 테넌트를 볼 수 없다면 오류가 발생합니다. 그리고 테넌트의 하위 항목이 아니라면 리소스를 찾을 수 없습니다. 와우, 우리 해냈어요!
이 접근법의 강점은 그 간단함에 있습니다. 사실상 우리는 단일 장애 지점을 갖고 있는데, 이는 모델이나 미래 모델에 의존하지 않고 요청당 한 번만 확인하면 되기 때문에 좋은 방법입니다. 우리는 초기 필수 게이트인 테넌트 주변에 자원을 보호하기 위한 울타리를 세웠습니다. 테넌트에 속하니? 아니요? 그럼 슬라이드쇼에 대한 댓글을 볼 수 없네요. 여기서 벗어나 주세요, Jane! 그리고 간단함 때문에, 다른 모델들은 테넌트와의 관계를 이해할 필요가 없습니다. 오직 "자연스러운" 직계 하위 항목에 대해서만 이해하면 됩니다.
이 접근법에는 몇 가지 단점이 있습니다. 코드 예제에서 주목하셨을 것이지만, 결과를 반환하는 모든 함수에서 테넌트를 typehint해야 합니다. 이렇게 하면 Laravel이 테넌트 매개 변수를 자동으로 테넌트 모델에 바인딩하는 방법을 알 수 있습니다. 우리 예제에서는 그렇게 나쁘지 않지만, 모델들의 컨트롤러를 사용하는 경우 URL을 따라 두 번째, 세 번째, 심지어 더 깊은 매개 변수까지 내려가야 하는 경우에는 번거로울 수 있습니다. 우리가 과장된 듯한 예를 들면, 깊게 중첩된 댓글을 위한 경로의 경우, 해당 댓글을 위한 컨트롤러에서 이렇게 인수 목록이 있는 함수가 있을 것입니다:
public function show(
테넌트 $테넌트,
프로젝트 $프로젝트,
프로젝트항목 $프로젝트항목,
항목코멘트,
$항목코멘트
){...}
좀 그렇네요. 항목 코멘트 컨트롤러는 이렇게 많은 매개변수에 신경 쓰지 않아도 됩니다. 또한, 종종 라우트에서 추가 매개변수를 사용하지 않습니다. 이런 부분은 여전히 "라라벨스러운" 느낌을 유지하면서 처리할 수 있습니다. 예를 들어, 라우트 파일에서 컨트롤러 메소드를 호출하기 전에, 그룹을위한 의존성을 타입힌트 할 수 있는 익명 함수를 사용한 다음 필요한 모델만 전달하여 컨트롤러 메소드를 반환합니다. 이 방법은 새로운 라우트를 추가하고 기존 라우트를 수정할 때 잘 작동합니다. 또한, 더 단순한 경우 기존 컨트롤러를 수정할 필요가 없기 때문에 기존 라우트 파일에 타입힌트 로직이 유지되고, 이것이 더 잘 맞는다고 생각합니다.
다른 단점은 라우트가 복잡해질 수 있다는 것입니다. 이제 url에서 테넌트가 필요하기 때문에 추가적인 매개변수가 도입되었습니다. 즉, url 범위 체인을 따라 이동하기 시작하면 문제가 됩니다. 루트 도우미 함수를 호출하고 4개의 매개변수를 제공해야 하는 경우, 테넌트로 깊게 중첩된 모델로 올라가는 것은 재미있는 경험이 아닙니다. 이것은 라우트 스코핑을 사용하는 다중 테넌시의 고유한 문제는 아니지만 문제를 악화시킵니다. 이러한 접근 방식을 사용할 것이라면, 적절한 매개변수를 라우트 도우미 함수에 삽입하는 방법을 알고 있는 사용자 정의 라우트 도우미 서비스를 추천 드립니다.
이러한 강점과 약점을 쉽게 이해하려면 단연코 목록을 만들어봅시다.
Multi-Tenancy with Tenant Middleware
장점:
- 코드 설정이 최소화됩니다.
- 리소스에 대한 단일 진입 지점 (테넌트)
- Laravel의 내장 기능을 사용하여 리소스에 대한 액세스 규칙을 쉽게 강제할 수 있습니다.
단점:
- 컨트롤러나 라우트 함수에서 경로 매개변수를 힌트로 입력해야 하는 것은 귀찮고 효율적이지 않아요.
- 여러 매개변수가 있는 복잡한 경로로 빠르게 복잡해지며, 이 방식은 처음부터 복잡함을 추가해요.
이제 다음으로 다중 테넌시를 구현하는 또 다른 방법으로 넘어가 봐요.
쿼리 스코프
Laravel에서의 쿼리 스코프는 모델이 데이터베이스에서 검색되는 방식을 제어할 수 있게 해줘요. 모델 클래스에 설정된 글로벌 스코프는 해당 모델에 대한 모든 쿼리에 적용돼요. 쿼리 스코프를 생성하는 자세한 방법은 문서를 참고해 주세요. 하지만 테넌트에 맞는 간단한 스코프를 에뮬레이트해 볼게요:
// App/Models/Scopes
클래스 TenantScope는 Scope를 구현합니다.
{
public function apply(Builder $builder, Model $model): void
{
$tenantId = ...
$builder->where('tenant_id', '=', $tenantId);
}
}
모델 클래스에 이를 적용하는 방법은 여러 가지가 있습니다. "booted" 메서드를 살펴보겠습니다.
class Project extends Model
{
/**
* 모델의 "booted" 메서드입니다.
*/
protected static function booted(): void
{
static::addGlobalScope(new TenantScope);
}
}
이제 프로젝트가 쿼리될 때 마다 우리의 스코프가 쿼리에 추가됩니다. 멋지죠? 그런데 스코프에 원하는 테넌트 ID를 설정하지 않았다는 것을 알아차렸을 수도 있습니다. 스코프에서 적절한 ID에 어떻게 접근할까요? ID는 무엇이어야 할까요? 이전 솔루션에서는 라우트 메서드의 미들웨어에서 사용자를 제공했습니다. 여기에는 그것이 없지만 글로벌 "auth" 도우미를 통해 간단히 액세스할 수도 있습니다:
$tenantId = auth()->user()->tenant_id;
이제 프로젝트 쿼리가 실행될 때마다 사용자의 테넌트 ID로 쿼리가 제한됩니다. 사용자는 자신의 테넌트 ID와 일치하는 프로젝트만 볼 수 있습니다. 멋져요! 이 방법을 사용하면 실제로 더 이상 URL에 테넌트를 포함시킬 필요가 없습니다:
Route::get('/{project}', function(Project $project){...});
더 간단한 라우트가 되었기 때문에, 이 방법은 이전 방법에서 제기된 문제점에 대응합니다. 이전 솔루션과 같이 URL을 범위로 지정하면, 이는 여전히 부모 리소스가 소유하지 않은 자식 리소스에 액세스하는 것을 방지합니다. 멋지죠! 모델 정책 및 권한 부여 미들웨어가 필요 없어졌습니다.
아마도 모델 클래스에 scope를 직접 적용해야 한다는 점을 알아차렸을 것입니다. 우리가 사용하려는 모든 부모 모델에 대해 이것이 사실이며, 이는 번거로울 수 있습니다. 다행히도 스코핑 부착을 트레잇이나 별도의 클래스로 추출할 수 있습니다:
namespace App\Models;
class TenantModel extends Model
{
protected static function booted(): void
{
static::addGlobalScope(new TenantScope);
}
}
테넌트에 대해 스코프가 지정된 클래스를 확장하고 싶은 모든 클래스를 TenantModel 클래스로 확장할 수 있습니다.
이전과 마찬가지로 웹 친화적인 장단점 목록:
글로벌 쿼리 스코프
장점
- 사용자의 테넌트에 대한 모델 쿼리는 자동으로 스코프 지정됩니다
- URL에 테넌트를 포함할 필요가 없습니다
- 추가 미들웨어가 필요하지 않습니다
단점
- 모델에 직접 scope를 추가해야 합니다.
- 사용자가 테넌트 리소스를 볼 수 있는 경우와 볼 수 없는 경우가 덜 명확합니다.
결론
저는 이 두 가지 접근 방식을 모두 사용해봤고, 둘 다 라라벨의 매직을 효과적으로 활용하여 다중 테넌시를 구현하는 데 즐거웠어요! 선택할 때 상황을 고려하시기 바랍니다. 이미 URL에 테넌트를 포함하고 있다면 테넌트 미들웨어를 사용하여 범위가 지정된 URL로 보안 요구 사항을 충족시킬 수 있습니다. URL에 테넌트를 포함하고 싶지 않다면 전역 쿼리 스코핑을 고려해보세요.
물론 두 가지를 혼합해서 사용해도 괜찮습니다. URL에 테넌트를 포함하고 여전히 자식 리소스에 범위를 지정하여 추가적인 예방 조치를 취하고 싶을 수도 있습니다. 모든 URL에 테넌트를 포함하고, 미들웨어 확인을 수행한 다음 매개변수를 “잊고” 나머지 리소스에 대해 전역 쿼리 스코핑을 사용할 수 있습니다. 여러분에게 가장 적합한 방법을 선택하고 멋진 웹 앱을 만들 수 있는 라라벨이 있다는 것에 감사드립니다!