Eloquent ORM: Laravel를 위한 패턴과 최적화
고급 패턴과 최적화 기법으로 Eloquent ORM을 마스터합니다. Laravel 애플리케이션을 위한 eager loading, query scope, accessor, mutator 및 성능.

Eloquent ORM은 데이터베이스와의 상호작용을 유려하고 표현력 있는 작업으로 변환합니다. 우아한 문법을 넘어서, 고급 패턴과 최적화 기법을 익히는 것이 운영 환경에서의 Laravel 애플리케이션 성능을 결정합니다.
N+1 문제는 Eloquent 애플리케이션 느림의 주된 원인입니다. 최적화되지 않은 모든 관계는 레코드당 추가 SQL 쿼리를 발생시킵니다.
eager loading으로 N+1 문제 해결하기
N+1 문제는 컬렉션에 대한 각 반복이 관계를 로드하기 위한 추가 쿼리를 발생시킬 때 나타납니다. 100개의 기사와 그 작성자의 경우, 최적화된 단일 쿼리 대신 101개의 쿼리가 됩니다.
Eager loading은 모든 관계를 최대 한두 개의 쿼리로 가져와 응답 시간을 크게 줄입니다.
// 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을 피합니다.
// 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()가 됩니다.
// 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}%"));
});
}
}// 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());
}
}// 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의 체이닝은 비즈니스 로직을 모델에 집중시키면서 가독성과 유지보수성이 좋은 쿼리를 만들어냅니다.
Laravel 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
Attribute로 accessor 및 mutator 사용하기
Laravel 9+는 Attribute 클래스를 통해 accessor와 mutator를 위한 통합 문법을 도입했습니다. 이 현대적인 접근 방식은 get*Attribute 및 set*Attribute 메서드를 대체합니다.
// 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'),
);
}
}// 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는 비즈니스 타입에 대한 직렬화 로직을 캡슐화합니다.
// 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');
}
}// 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,
];
}
}// 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',
];
}
}// 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 DBValue Object와 커스텀 cast를 결합하면 우아한 API를 유지하면서 비즈니스 데이터의 무결성이 보장됩니다.
대규모 쿼리 최적화
수백만 건의 레코드에 대한 작업은 메모리 고갈을 피하기 위해 특정 기법이 필요합니다. 청킹과 커서는 데이터를 배치로 처리합니다.
Model::all()은 모든 레코드를 메모리에 로드합니다. 10만 행 테이블에서는 수 기가바이트의 RAM을 소비하고 애플리케이션을 충돌시킬 수 있습니다.
// 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()를 사용합니다.
고급 다형적 관계
다형적 관계는 하나의 모델이 단일 관계를 통해 여러 다른 모델 타입에 속할 수 있게 합니다. 이 유연성은 댓글, 태그, 첨부 파일에 이상적입니다.
// 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);
}
}// 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');
}
}// 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');
}
}// 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']);
});
}
};// 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다형적 관계는 테이블 중복을 피하고 횡단 기능을 위한 로직을 집중시킵니다.
Laravel 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
재사용 가능한 로직을 위한 trait와 observer
Trait는 모델 간에 재사용 가능한 동작을 캡슐화합니다. Observer는 라이프사이클 이벤트의 훅을 집중시킵니다.
// 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;
}
}// 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';
}
}// 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;
}
}// 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 쿼리를 시각화하고 누락된 최적화를 식별할 수 있게 해줍니다.
연습을 시작하세요!
면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.
태그
공유
관련 기사

Laravel과 PHP 면접 질문: 2026년 핵심 25선
Laravel과 PHP 면접에서 가장 자주 출제되는 25가지 질문을 상세히 다룹니다. Eloquent ORM, 미들웨어, 큐, 테스트, 아키텍처 패턴에 대한 상세한 답변과 코드 예제를 제공합니다.

Laravel 11: 처음부터 완전한 애플리케이션 구축하기
Laravel 11로 인증, REST API, Eloquent ORM, 배포까지 완전한 애플리케이션을 구축하는 종합 가이드입니다. 실용적인 코드 예제와 함께 설명합니다.

Laravel Middleware 완벽 가이드: 인증, 속도 제한, 커스텀 미들웨어 구축
Laravel 미들웨어의 파이프라인 구조부터 인증, 속도 제한(Rate Limiting), 커스텀 미들웨어 작성, 프로덕션 패턴까지 실무 코드 예제와 함께 체계적으로 정리합니다.