스코프란
Eloquent의 스코프는, 쿼리에 대한 제약을 정리하여 재이용할 수 있는 구조입니다. 스코프에는 2종류가 있습니다.
| 종류 | 적용 타이밍 | 용도 |
|---|
| 글로벌 스코프 | 항상 자동으로 적용 | 소프트 삭제, 멀티 테넌트, 공개 필터 |
| 로컬 스코프 | 명시적으로 호출했을 때만 적용 | ”인기 게시물”, “액티브 사용자” 등의 공통 필터 |
로컬 스코프
로컬 스코프는, 모델의 메서드에 #[Scope] 어트리뷰트를 부여하여 정의합니다.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
/**
* 公開済みの投稿に絞り込む
*/
#[Scope]
protected function published(Builder $query): void
{
$query->where('status', 'published');
}
/**
* 人気の投稿に絞り込む
*/
#[Scope]
protected function popular(Builder $query): void
{
$query->where('views', '>', 1000);
}
}
#[Scope] 어트리뷰트는 Illuminate\Database\Eloquent\Attributes 네임스페이스에 있습니다. PHP 8.0 이후의 네이티브 구문입니다.
사용법
정의한 스코프는, 메서드로서 호출할 수 있습니다. 체인도 가능합니다.
use App\Models\Post;
// 公開済み投稿のみ取得
$posts = Post::published()->get();
// 公開済みかつ人気の投稿を最新順で取得
$posts = Post::published()->popular()->latest()->get();
파라미터 전달
스코프 메서드의 제2인수 이후에 추가 파라미터를 정의할 수 있습니다.
#[Scope]
protected function ofStatus(Builder $query, string $status): void
{
$query->where('status', $status);
}
호출 시에 그대로 인수를 전달합니다.
$posts = Post::ofStatus('draft')->get();
$posts = Post::ofStatus('published')->get();
orWhere와의 조합
스코프를 orWhere로 연결할 때, 논리 그룹이 필요할 때가 있습니다.
// クロージャを使う方法(確実だが冗長)
$users = User::popular()->orWhere(function (Builder $query) {
$query->active();
})->get();
// 高階メソッドを使うとシンプルに書ける
$users = User::popular()->orWhere->active()->get();
글로벌 스코프
글로벌 스코프는 Illuminate\Database\Eloquent\Scope 인터페이스를 구현한 클래스입니다. 이 인터페이스는 apply 메서드 하나만을 요구합니다.
// フレームワーク本体のインターフェース定義
// src/Illuminate/Database/Eloquent/Scope.php
interface Scope
{
public function apply(Builder $builder, Model $model);
}
apply 메서드 안에서 쿼리 빌더에 제약을 추가합니다.
글로벌 스코프 클래스의 작성
make:scope 커맨드로 뼈대를 생성합니다.
php artisan make:scope ActiveScope
app/Models/Scopes/ActiveScope.php가 생성됩니다.
<?php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class ActiveScope implements Scope
{
/**
* スコープをクエリビルダーに適用する
*/
public function apply(Builder $builder, Model $model): void
{
$builder->where('is_active', true);
}
}
모델에의 적용
#[ScopedBy] 어트리뷰트로 등록(권장)
Laravel 13에서는 #[ScopedBy] 어트리뷰트를 사용하는 것이 가장 심플합니다.<?php
namespace App\Models;
use App\Models\Scopes\ActiveScope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Model;
#[ScopedBy([ActiveScope::class])]
class User extends Model
{
//
}
여러 스코프를 배열로 지정할 수 있습니다.#[ScopedBy([ActiveScope::class, TenantScope::class])]
class User extends Model
{
//
}
booted() 메서드로 수동 등록
booted 메서드를 오버라이드하여 addGlobalScope를 호출하는 방법도 있습니다.<?php
namespace App\Models;
use App\Models\Scopes\ActiveScope;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
protected static function booted(): void
{
static::addGlobalScope(new ActiveScope);
}
}
글로벌 스코프를 추가하면, User::all() 등 전 쿼리에 자동으로 WHERE is_active = 1이 붙습니다.
무명 클로저에 의한 글로벌 스코프
클래스를 별도 파일로 만들 정도는 아닌 단순한 스코프는, 클로저로 정의할 수 있습니다.
protected static function booted(): void
{
static::addGlobalScope('active', function (Builder $builder) {
$builder->where('is_active', true);
});
}
클로저로 정의한 스코프를 나중에 제외하려면, 클래스명이 아니라 스코프명(문자열)을 사용해야 합니다.
글로벌 스코프의 제외
특정 쿼리에서는 스코프를 무효로 하고 싶은 장면이 있습니다.
use App\Models\Scopes\ActiveScope;
// 特定のスコープを除外
User::withoutGlobalScope(ActiveScope::class)->get();
// クロージャで定義したスコープを除外
User::withoutGlobalScope('active')->get();
// すべてのグローバルスコープを除外
User::withoutGlobalScopes()->get();
// 複数のスコープを除外
User::withoutGlobalScopes([ActiveScope::class, TenantScope::class])->get();
// 指定したスコープ以外をすべて除外
User::withoutGlobalScopesExcept([TenantScope::class])->get();
프레임워크 내부: SoftDeletingScope
Laravel 표준의 SoftDeletes 트레이트가 글로벌 스코프를 어떻게 활용하고 있는지를 보면, 구현 패턴을 알 수 있습니다.
SoftDeletingScope는 Scope 인터페이스를 구현하고 있습니다.
// src/Illuminate/Database/Eloquent/SoftDeletingScope.php
class SoftDeletingScope implements Scope
{
protected $extensions = [
'Restore', 'RestoreOrCreate', 'CreateOrRestore',
'WithTrashed', 'WithoutTrashed', 'OnlyTrashed',
];
/**
* スコープをクエリビルダーに適用する
* deleted_at が NULL のレコードだけを取得するよう制約を追加
*/
public function apply(Builder $builder, Model $model)
{
$builder->whereNull($model->getQualifiedDeletedAtColumn());
}
/**
* クエリビルダーにマクロを拡張する
* withTrashed() / onlyTrashed() などが使えるようになる
*/
public function extend(Builder $builder)
{
foreach ($this->extensions as $extension) {
$this->{"add{$extension}"}($builder);
}
// delete() 操作をオーバーライドして deleted_at を更新する処理にする
$builder->onDelete(function (Builder $builder) {
$column = $this->getDeletedAtColumn($builder);
return $builder->update([
$column => $builder->getModel()->freshTimestampString(),
]);
});
}
}
withTrashed()는 실제로는 withoutGlobalScope($this)를 호출하고 있습니다. 즉 SoftDeletingScope 자신을 제외함으로써, 삭제된 레코드도 취득할 수 있게 하고 있습니다.protected function addWithTrashed(Builder $builder)
{
$builder->macro('withTrashed', function (Builder $builder, $withTrashed = true) {
if (! $withTrashed) {
return $builder->withoutTrashed();
}
return $builder->withoutGlobalScope($this);
});
}
onlyTrashed()도 마찬가지로, 스코프 자신을 제외한 뒤 whereNotNull('deleted_at')을 추가하고 있습니다.protected function addOnlyTrashed(Builder $builder)
{
$builder->macro('onlyTrashed', function (Builder $builder) {
$model = $builder->getModel();
$builder->withoutGlobalScope($this)->whereNotNull(
$model->getQualifiedDeletedAtColumn()
);
return $builder;
});
}
Scope 인터페이스에 extend 메서드는 정의되어 있지 않지만, Eloquent의 빌더는 스코프가 extend 메서드를 가지고 있으면 자동적으로 호출합니다. 커스텀 매크로를 추가하고 싶은 경우에 활용할 수 있습니다.
실전적인 유스케이스
멀티 테넌트: 테넌트 ID에 의한 자동 좁혀 넣기
SaaS 애플리케이션에서는, 전 쿼리에 테넌트 ID의 필터를 자동 적용하는 것이 중요합니다.
<?php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
if ($tenantId = auth()->user()?->tenant_id) {
$builder->where('tenant_id', $tenantId);
}
}
}
use App\Models\Scopes\TenantScope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
#[ScopedBy([TenantScope::class])]
class Post extends Model
{
//
}
이것으로 Post::all()을 호출하는 것만으로, 인증 중인 사용자의 테넌트 데이터만이 반환됩니다.
공개/비공개 필터
관리 화면에서는 비공개 게시물도 표시하고 싶지만, 프론트엔드에서는 공개된 것만 표시하고 싶은 경우입니다.
<?php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class PublishedScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where('status', 'published')
->where('published_at', '<=', now());
}
}
관리 화면에서는 withoutGlobalScope로 스코프를 제외합니다.
// フロントエンド: 公開済みのみ(PublishedScope が自動適用)
$posts = Post::latest()->get();
// 管理画面: 全投稿を表示
$posts = Post::withoutGlobalScope(PublishedScope::class)->latest()->get();
select가 아닌 addSelect를 사용한다
글로벌 스코프 내에서 컬럼을 추가할 때는 select가 아니라 addSelect를 사용해 주세요. select를 사용하면, 호출자의 쿼리가 select하고 있는 컬럼을 덮어써 버립니다.// 悪い例: 呼び出し元の select を上書きする
public function apply(Builder $builder, Model $model): void
{
$builder->select('id', 'tenant_id', 'name');
}
// 良い例: 既存の select に追記する
public function apply(Builder $builder, Model $model): void
{
$builder->addSelect('tenant_id');
}
다음 단계
Eloquent 커스텀 캐스트
속성의 변환 로직을 커스텀 캐스트로서 구현하고, Value Object 패턴을 활용하는 방법을 배웁니다.