# Eloquent ORM: Laravel를 위한 패턴과 최적화 > 고급 패턴과 최적화 기법으로 Eloquent ORM을 마스터합니다. Laravel 애플리케이션을 위한 eager loading, query scope, accessor, mutator 및 성능. - Published: 2026-02-10 - Updated: 2026-04-28 - Author: SharpSkill - Tags: laravel, eloquent, php, orm, database - Reading time: 18 min --- Eloquent ORM은 데이터베이스와의 상호작용을 유려하고 표현력 있는 작업으로 변환합니다. 우아한 문법을 넘어서, 고급 패턴과 최적화 기법을 익히는 것이 운영 환경에서의 Laravel 애플리케이션 성능을 결정합니다. > **성능이 우선입니다** > > N+1 문제는 Eloquent 애플리케이션 느림의 주된 원인입니다. 최적화되지 않은 모든 관계는 레코드당 추가 SQL 쿼리를 발생시킵니다. ## eager loading으로 N+1 문제 해결하기 N+1 문제는 컬렉션에 대한 각 반복이 관계를 로드하기 위한 추가 쿼리를 발생시킬 때 나타납니다. 100개의 기사와 그 작성자의 경우, 최적화된 단일 쿼리 대신 101개의 쿼리가 됩니다. Eager loading은 모든 관계를 최대 한두 개의 쿼리로 가져와 응답 시간을 크게 줄입니다. ```php // app/Http/Controllers/ArticleController.php // Demonstration of N+1 problem and its solution namespace App\Http\Controllers; use App\Models\Article; use Illuminate\Http\Request; class ArticleController extends Controller { // ❌ N+1 problem: 1 articles query + N author queries public function indexWithProblem() { $articles = Article::all(); // 1 query foreach ($articles as $article) { echo $article->author->name; // N additional queries } } // ✅ Eager loading: 2 queries maximum public function indexOptimized() { $articles = Article::with('author')->get(); // 2 queries total foreach ($articles as $article) { echo $article->author->name; // No additional queries } } // ✅ Nested eager loading for multiple relationships public function indexWithNestedRelations() { // Loads articles → authors → profiles + articles → comments → users $articles = Article::with([ 'author.profile', 'comments.user' ])->get(); return view('articles.index', compact('articles')); } } ``` `with()`를 통한 eager loading은 필요한 데이터를 미리 예측하여 로드합니다. 큰 컬렉션에서 성능 차이가 극적이 됩니다. ## 조건부 및 제약된 eager loading 방대한 관계는 때때로 부분 로드가 필요합니다. eager loading에 대한 제약은 가져오는 데이터를 제한하면서 N+1을 피합니다. ```php // app/Http/Controllers/UserController.php // Eager loading with constraints to optimize queries namespace App\Http\Controllers; use App\Models\User; use Illuminate\Database\Eloquent\Builder; class UserController extends Controller { public function showWithRecentOrders(int $id) { // Load only the 5 most recent paid orders $user = User::with(['orders' => function (Builder $query) { $query->where('status', 'paid') ->orderByDesc('created_at') ->limit(5); }])->findOrFail($id); return view('users.show', compact('user')); } public function indexActiveWithStats() { // Conditional eager loading with withCount $users = User::query() ->where('active', true) ->with(['profile', 'subscription']) ->withCount(['orders', 'reviews']) // Adds orders_count and reviews_count ->withSum('orders', 'total') // Adds orders_sum_total ->get(); return view('users.index', compact('users')); } public function showWithConditionalRelation(int $id) { // Load relationship only if user is premium $user = User::findOrFail($id); $user->loadMissing( $user->isPremium() ? ['premiumFeatures', 'analytics'] : [] ); return view('users.show', compact('user')); } } ``` `withCount()` 및 `withSum()` 메서드는 전체 컬렉션을 로드하지 않고 집계를 추가하여 대시보드 통계에 이상적입니다. ## 재사용 가능한 쿼리를 위한 query scope Query scope는 필터링 로직을 모델 내부에 캡슐화합니다. 로컬 scope는 유연성을 제공하고, 글로벌 scope는 모든 쿼리에 자동으로 적용됩니다. > **명명 규칙** > > 로컬 scope는 모델에서 `scope` 접두사를 사용하지만 호출할 때는 접두사 없이 사용합니다: `scopeActive()`는 `User::active()`가 됩니다. ```php // app/Models/Article.php // Local and global scopes to encapsulate business logic namespace App\Models; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Attributes\ScopedBy; #[ScopedBy([PublishedScope::class])] // Global scope via PHP 8 attribute class Article extends Model { // Simple local scope: Article::published() public function scopePublished(Builder $query): Builder { return $query->whereNotNull('published_at') ->where('published_at', '<=', now()); } // Local scope with parameter: Article::byCategory('tech') public function scopeByCategory(Builder $query, string $category): Builder { return $query->where('category', $category); } // Local scope with optional parameter public function scopePopular(Builder $query, int $minViews = 1000): Builder { return $query->where('views_count', '>=', $minViews) ->orderByDesc('views_count'); } // Dynamic scope for flexible search public function scopeSearch(Builder $query, ?string $term): Builder { if (empty($term)) { return $query; } return $query->where(function (Builder $q) use ($term) { $q->where('title', 'like', "%{$term}%") ->orWhere('content', 'like', "%{$term}%") ->orWhereHas('tags', fn($t) => $t->where('name', 'like', "%{$term}%")); }); } } ``` ```php // app/Models/Scopes/PublishedScope.php // Reusable global scope across models 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 { // Automatically applied to all Article queries $builder->whereNotNull('published_at') ->where('published_at', '<=', now()); } } ``` ```php // Using scopes in a controller $articles = Article::query() ->byCategory('technology') ->popular(500) ->search($request->input('q')) ->with('author') ->paginate(20); // Disable a global scope temporarily $allArticles = Article::withoutGlobalScope(PublishedScope::class)->get(); ``` Scope의 체이닝은 비즈니스 로직을 모델에 집중시키면서 가독성과 유지보수성이 좋은 쿼리를 만들어냅니다. ## Attribute로 accessor 및 mutator 사용하기 Laravel 9+는 `Attribute` 클래스를 통해 accessor와 mutator를 위한 통합 문법을 도입했습니다. 이 현대적인 접근 방식은 `get*Attribute` 및 `set*Attribute` 메서드를 대체합니다. ```php // app/Models/User.php // Modern accessors and mutators with the Attribute class namespace App\Models; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; class User extends Model { protected $fillable = ['first_name', 'last_name', 'email', 'password']; // Accessor: generates a virtual attribute from other fields protected function fullName(): Attribute { return Attribute::make( get: fn () => "{$this->first_name} {$this->last_name}", ); } // Mutator: transforms value before saving protected function password(): Attribute { return Attribute::make( set: fn (string $value) => Hash::make($value), ); } // Combined accessor + mutator protected function email(): Attribute { return Attribute::make( get: fn (string $value) => Str::lower($value), set: fn (string $value) => Str::lower(trim($value)), ); } // Cached accessor to avoid recalculations protected function initials(): Attribute { return Attribute::make( get: fn () => Str::upper( Str::substr($this->first_name, 0, 1) . Str::substr($this->last_name, 0, 1) ), )->shouldCache(); // Caches result during request } // Computed attribute based on a relationship protected function ordersTotal(): Attribute { return Attribute::make( get: fn () => $this->orders->sum('total'), ); } } ``` ```php // Transparent usage of accessors and mutators $user = new User(); $user->first_name = 'John'; $user->last_name = 'Doe'; $user->email = ' JOHN@EXAMPLE.COM '; // Automatically normalized $user->password = 'secret123'; // Automatically hashed $user->save(); echo $user->full_name; // "John Doe" echo $user->initials; // "JD" echo $user->email; // "john@example.com" ``` `shouldCache()` 메서드는 동일한 모델에서 여러 번 재계산을 피함으로써 비용이 높은 accessor를 최적화합니다. ## 복잡한 타입을 위한 커스텀 cast Cast는 PHP와 데이터베이스 간 값을 자동으로 변환합니다. 커스텀 cast는 비즈니스 타입에 대한 직렬화 로직을 캡슐화합니다. ```php // app/Casts/MoneyCast.php // Custom cast for handling monetary amounts namespace App\Casts; use App\ValueObjects\Money; use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use Illuminate\Database\Eloquent\Model; use InvalidArgumentException; class MoneyCast implements CastsAttributes { public function __construct( protected string $currency = 'USD' ) {} // DB → PHP conversion: cents to Money object public function get(Model $model, string $key, mixed $value, array $attributes): ?Money { if ($value === null) { return null; } return new Money( amount: (int) $value, currency: $this->currency ); } // PHP → DB conversion: Money object to cents public function set(Model $model, string $key, mixed $value, array $attributes): ?int { if ($value === null) { return null; } if ($value instanceof Money) { return $value->getAmountInCents(); } if (is_numeric($value)) { return (int) ($value * 100); } throw new InvalidArgumentException('Value must be Money instance or numeric'); } } ``` ```php // app/ValueObjects/Money.php // Immutable Value Object representing amounts namespace App\ValueObjects; use JsonSerializable; final readonly class Money implements JsonSerializable { public function __construct( private int $amount, // Stored in cents private string $currency ) {} public function getAmountInCents(): int { return $this->amount; } public function getAmountInUnits(): float { return $this->amount / 100; } public function format(): string { return number_format($this->getAmountInUnits(), 2) . ' ' . $this->currency; } public function add(Money $other): self { return new self($this->amount + $other->amount, $this->currency); } public function jsonSerialize(): array { return [ 'amount' => $this->getAmountInUnits(), 'currency' => $this->currency, ]; } } ``` ```php // app/Models/Order.php // Using the custom cast namespace App\Models; use App\Casts\MoneyCast; use Illuminate\Database\Eloquent\Model; class Order extends Model { protected function casts(): array { return [ 'total' => MoneyCast::class, // USD by default 'shipping_cost' => MoneyCast::class . ':USD', 'tax_amount' => MoneyCast::class . ':USD', 'paid_at' => 'datetime', 'metadata' => 'array', ]; } } ``` ```php // Natural usage with the cast $order = Order::find(1); echo $order->total->format(); // "149.99 USD" echo $order->total->getAmountInCents(); // 14999 $order->total = 199.99; // Automatically converted $order->save(); // Stored as 19999 in DB ``` Value Object와 커스텀 cast를 결합하면 우아한 API를 유지하면서 비즈니스 데이터의 무결성이 보장됩니다. ## 대규모 쿼리 최적화 수백만 건의 레코드에 대한 작업은 메모리 고갈을 피하기 위해 특정 기법이 필요합니다. 청킹과 커서는 데이터를 배치로 처리합니다. > **메모리 주의** > > `Model::all()`은 모든 레코드를 메모리에 로드합니다. 10만 행 테이블에서는 수 기가바이트의 RAM을 소비하고 애플리케이션을 충돌시킬 수 있습니다. ```php // app/Console/Commands/ProcessUsersCommand.php // Batch processing techniques for large tables namespace App\Console\Commands; use App\Models\User; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; class ProcessUsersCommand extends Command { protected $signature = 'users:process'; public function handle(): int { // ✅ Chunk: processes in batches of 1000, reloads from DB User::query() ->where('needs_processing', true) ->chunk(1000, function ($users) { foreach ($users as $user) { $user->processAccount(); } }); // ✅ Chunk with updates: avoids infinite loop during modifications User::query() ->where('status', 'pending') ->chunkById(1000, function ($users) { foreach ($users as $user) { $user->update(['status' => 'processed']); } }); // ✅ Lazy collection: single record in memory at a time foreach (User::lazy(1000) as $user) { $user->sendNewsletter(); } // ✅ Cursor: for read-only operations, minimal memory foreach (User::cursor() as $user) { $this->info("Processing: {$user->email}"); } // ✅ Mass update without Eloquent: maximum performance User::query() ->where('last_login_at', '<', now()->subYear()) ->update(['status' => 'inactive']); // ✅ Optimized mass deletion User::query() ->where('deleted_at', '<', now()->subMonths(6)) ->forceDelete(); return self::SUCCESS; } } ``` `chunk()`, `lazy()`, `cursor()` 사이의 선택은 사용 사례에 달려 있습니다. 수정에는 `chunk()`, 중간 작업에는 `lazy()`, 최소 메모리로 단순 읽기에는 `cursor()`를 사용합니다. ## 고급 다형적 관계 다형적 관계는 하나의 모델이 단일 관계를 통해 여러 다른 모델 타입에 속할 수 있게 합니다. 이 유연성은 댓글, 태그, 첨부 파일에 이상적입니다. ```php // app/Models/Comment.php // Model with inverse polymorphic relationship namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo; class Comment extends Model { protected $fillable = ['body', 'user_id']; // A comment can belong to Article, Video, or any other model public function commentable(): MorphTo { return $this->morphTo(); } public function user() { return $this->belongsTo(User::class); } } ``` ```php // app/Models/Article.php // Parent model with polymorphic relationship namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphMany; class Article extends Model { public function comments(): MorphMany { return $this->morphMany(Comment::class, 'commentable'); } } ``` ```php // app/Models/Video.php // Another parent model using the same relationship namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphMany; class Video extends Model { public function comments(): MorphMany { return $this->morphMany(Comment::class, 'commentable'); } } ``` ```php // Migration for polymorphic comments table // database/migrations/2026_01_15_create_comments_table.php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { Schema::create('comments', function (Blueprint $table) { $table->id(); $table->text('body'); $table->foreignId('user_id')->constrained(); $table->morphs('commentable'); // Creates commentable_type and commentable_id $table->timestamps(); // Composite index for polymorphic queries $table->index(['commentable_type', 'commentable_id']); }); } }; ``` ```php // Using polymorphic relationships $article = Article::find(1); $article->comments()->create([ 'body' => 'Excellent article!', 'user_id' => auth()->id(), ]); $video = Video::find(1); $video->comments()->create([ 'body' => 'Very instructive video', 'user_id' => auth()->id(), ]); // Retrieve parent from comment $comment = Comment::with('commentable')->find(1); echo get_class($comment->commentable); // App\Models\Article or App\Models\Video ``` 다형적 관계는 테이블 중복을 피하고 횡단 기능을 위한 로직을 집중시킵니다. ## 재사용 가능한 로직을 위한 trait와 observer Trait는 모델 간에 재사용 가능한 동작을 캡슐화합니다. Observer는 라이프사이클 이벤트의 훅을 집중시킵니다. ```php // app/Models/Concerns/HasSlug.php // Trait for automatic slug generation namespace App\Models\Concerns; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str; trait HasSlug { public static function bootHasSlug(): void { static::creating(function (Model $model) { if (empty($model->slug)) { $model->slug = $model->generateUniqueSlug(); } }); } protected function generateUniqueSlug(): string { $slug = Str::slug($this->getSlugSource()); $originalSlug = $slug; $counter = 1; // Check uniqueness and add suffix if needed while (static::where('slug', $slug)->exists()) { $slug = "{$originalSlug}-{$counter}"; $counter++; } return $slug; } // Can be overridden in the model protected function getSlugSource(): string { return $this->title ?? $this->name; } } ``` ```php // app/Models/Concerns/HasUuid.php // Trait for using UUIDs as primary key namespace App\Models\Concerns; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str; trait HasUuid { public static function bootHasUuid(): void { static::creating(function (Model $model) { if (empty($model->{$model->getKeyName()})) { $model->{$model->getKeyName()} = (string) Str::uuid(); } }); } public function getIncrementing(): bool { return false; } public function getKeyType(): string { return 'string'; } } ``` ```php // app/Observers/ArticleObserver.php // Observer to centralize hooks on Article model namespace App\Observers; use App\Models\Article; use App\Jobs\NotifySubscribersJob; use App\Services\SearchIndexService; use Illuminate\Support\Facades\Cache; class ArticleObserver { public function __construct( private SearchIndexService $searchIndex ) {} public function created(Article $article): void { // Invalidate recent articles cache Cache::tags(['articles', 'recent'])->flush(); // Index for search $this->searchIndex->index($article); } public function updated(Article $article): void { // Update search index $this->searchIndex->update($article); // Notify subscribers if article was just published if ($article->wasChanged('published_at') && $article->published_at !== null) { NotifySubscribersJob::dispatch($article); } Cache::tags(['articles'])->flush(); } public function deleted(Article $article): void { $this->searchIndex->remove($article); Cache::tags(['articles'])->flush(); } // Prevent deletion if article has comments public function deleting(Article $article): bool { if ($article->comments()->exists()) { return false; // Cancel deletion } return true; } } ``` ```php // app/Models/Article.php // Model using traits and observer namespace App\Models; use App\Models\Concerns\HasSlug; use App\Models\Concerns\HasUuid; use App\Observers\ArticleObserver; use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Model; #[ObservedBy(ArticleObserver::class)] class Article extends Model { use HasSlug, HasUuid; protected $fillable = ['title', 'content', 'published_at']; } ``` Trait의 `boot*` 메서드는 모델 초기화 시 자동으로 실행되어 투명한 통합을 가능하게 합니다. ## 결론 Eloquent ORM을 마스터하는 것은 기본 메커니즘을 이해하고 적절한 패턴을 적용하는 데 달려 있습니다. 소개된 기법들은 순진한 쿼리를 성능이 좋고 유지보수가 쉬운 코드로 변환합니다. **Eloquent 최적화 체크리스트:** ✅ 표시되는 관계에 대해 체계적으로 `with()` 사용 ✅ 카운트를 위해 컬렉션을 로드하는 대신 `withCount()` 적용 ✅ 필터링 로직을 query scope에 캡슐화 ✅ 뷰에서의 반복 계산보다 accessor 선호 ✅ 비즈니스 Value Object를 위한 커스텀 cast 구현 ✅ 큰 테이블 작업에는 `chunk()` 또는 `lazy()` 사용 ✅ 사이드 이펙트를 observer에 집중 ✅ 공통 동작을 trait로 추출 `php artisan telescope` 또는 `laravel-debugbar` 도구는 생성된 SQL 쿼리를 시각화하고 누락된 최적화를 식별할 수 있게 해줍니다. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ko/blog/laravel/eloquent-orm-patterns-optimizations