Eloquent: 관계

업데이트됨

번역일: 2026년 8월 4일

이 페이지는 원문이 업데이트되어 번역이 갱신되었습니다.

원문 수정
2026년 8월 4일
번역 갱신
2026년 8월 4일

Eloquent: 관계

소개

데이터베이스의 테이블들은 서로 연관되어 있는 경우가 많습니다. 예를 들어, 블로그 게시글에는 여러 댓글이 달릴 수 있고, 주문은 특정 사용자에게 속합니다. Eloquent는 이런 관계를 쉽고 직관적으로 다룰 수 있도록 다양한 관계 유형을 지원합니다.

관계 정의하기

Eloquent 관계는 Eloquent 모델 클래스의 메서드로 정의합니다. 관계 자체도 강력한 쿼리 빌더 역할을 하므로, 메서드로 정의하면 메서드 체이닝과 쿼리 기능을 모두 활용할 수 있습니다. 예를 들면 다음처럼 관계에 추가 제약 조건을 연결할 수 있습니다.

$user->posts()->where('active', 1)->get();

관계를 본격적으로 살펴보기 전에, 먼저 각 유형별 정의 방법을 알아봅시다.

일대일 / Has One

일대일 관계는 가장 기본적인 관계입니다. 예를 들어, User 모델이 하나의 Phone 모델과 연결되는 경우입니다. 이 관계를 정의하려면 User 모델에 phone 메서드를 추가하고, 그 안에서 hasOne 메서드를 호출하여 반환합니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasOne; class User extends Model { /** * 사용자와 연결된 전화번호 정보를 반환합니다. */ public function phone(): HasOne { return $this->hasOne(Phone::class); } }

hasOne의 첫 번째 인수는 연관 모델 클래스입니다. 관계를 정의하고 나면 Eloquent의 동적 프로퍼티를 통해 연관 레코드에 접근할 수 있습니다. 동적 프로퍼티를 사용하면 관계 메서드를 마치 모델의 일반 프로퍼티처럼 사용할 수 있습니다.

$phone = User::find(1)->phone;

Eloquent는 부모 모델명을 기준으로 외래 키를 자동으로 결정합니다. 위 예시에서는 Phone 모델에 user_id 외래 키가 있다고 가정합니다. 이 기본값을 바꾸고 싶다면 hasOne의 두 번째 인수로 외래 키 이름을 전달하면 됩니다.

return $this->hasOne(Phone::class, 'foreign_key');

또한 Eloquent는 외래 키 값이 부모의 기본 키(id)와 일치한다고 가정합니다. 즉, user_id 컬럼의 값이 사용자의 id와 같은 Phone 레코드를 찾습니다. id가 아닌 다른 컬럼을 기준으로 삼고 싶다면 세 번째 인수로 로컬 키를 지정할 수 있습니다.

return $this->hasOne(Phone::class, 'foreign_key', 'local_key');

역방향 관계 정의하기

User 모델에서 Phone 모델에 접근하는 방법을 알았으니, 이제 Phone 모델에서 해당 사용자에게 접근하는 역방향 관계를 정의해봅시다. hasOne의 반대인 belongsTo 메서드를 사용합니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class Phone extends Model { /** * 이 전화번호를 소유한 사용자를 반환합니다. */ public function user(): BelongsTo { return $this->belongsTo(User::class); } }

belongsTo를 호출하면 Eloquent는 Phone 모델의 user_id 컬럼을 외래 키로 사용하여 일치하는 User를 찾습니다. 외래 키 이름은 관계 메서드명에 _id를 붙인 규칙을 따릅니다. 즉, user 메서드이므로 user_id를 외래 키로 사용합니다.

기본 규칙과 다른 외래 키를 사용한다면, 두 번째 인수로 지정할 수 있습니다.

public function user(): BelongsTo { return $this->belongsTo(User::class, 'foreign_key'); }

부모 모델의 기본 키가 id가 아닌 경우, 또는 다른 컬럼으로 연결하고 싶다면 세 번째 인수로 부모의 키 컬럼을 지정합니다.

public function user(): BelongsTo { return $this->belongsTo(User::class, 'foreign_key', 'owner_key'); }

일대다 / Has Many

일대다 관계는 하나의 모델이 여러 개의 자식 모델을 가질 때 사용합니다. 예를 들어, 블로그 게시글(Post)은 여러 개의 댓글(Comment)을 가질 수 있습니다. hasOne과 마찬가지로 hasMany 메서드를 사용하여 정의합니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; class Post extends Model { /** * 게시글에 달린 댓글 목록을 반환합니다. */ public function comments(): HasMany { return $this->hasMany(Comment::class); } }

Eloquent는 Comment 모델의 외래 키를 자동으로 결정합니다. 관례에 따라, 부모 모델명을 스네이크 케이스로 변환하고 _id를 붙인 컬럼명(post_id)을 사용합니다. 필요하다면 두 번째, 세 번째 인수로 외래 키와 로컬 키를 직접 지정할 수 있습니다.

관계를 정의한 후에는 comments 동적 프로퍼티로 댓글 컬렉션에 접근할 수 있습니다.

use App\Models\Post; $comments = Post::find(1)->comments; foreach ($comments as $comment) { // ... }

모든 관계는 쿼리 빌더이기도 하므로, comments 메서드를 호출하고 추가 조건을 체이닝할 수 있습니다.

$comment = Post::find(1)->comments() ->where('title', '공지사항') ->first();

hasOne과 마찬가지로, hasMany에도 두 번째·세 번째 인수로 외래 키와 로컬 키를 지정할 수 있습니다.

return $this->hasMany(Comment::class, 'foreign_key'); return $this->hasMany(Comment::class, 'foreign_key', 'local_key');

일대다 (역방향) / Belongs To

댓글에서 부모 게시글에 접근하려면 belongsTo 관계를 정의합니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class Comment extends Model { /** * 이 댓글이 속한 게시글을 반환합니다. */ public function post(): BelongsTo { return $this->belongsTo(Post::class); } }

이제 post 동적 프로퍼티로 댓글의 부모 게시글에 접근할 수 있습니다.

use App\Models\Comment; $comment = Comment::find(1); return $comment->post->title;

위 예시에서 Eloquent는 Comment 모델의 post_id 컬럼과 일치하는 Post를 찾습니다.

Eloquent는 관계 메서드명에서 외래 키를 추론합니다. post 메서드이므로 post_id를 사용합니다. 다른 외래 키를 사용한다면 두 번째 인수로 지정하세요.

public function post(): BelongsTo { return $this->belongsTo(Post::class, 'foreign_key'); }

부모 모델이 id가 아닌 다른 컬럼을 기본 키로 쓴다면, 세 번째 인수로 해당 컬럼명을 지정합니다.

public function post(): BelongsTo { return $this->belongsTo(Post::class, 'foreign_key', 'owner_key'); }

기본 모델

belongsTo, hasOne, hasOneThrough, morphOne 관계에서는 연관 모델이 null일 때 반환할 기본 모델을 지정할 수 있습니다. 이 패턴을 Null 오브젝트 패턴이라 하며, 코드에서 null 체크를 줄이는 데 유용합니다. 아래 예시에서는 게시글에 사용자가 없을 경우 빈 User 모델이 반환됩니다.

public function user(): BelongsTo { return $this->belongsTo(User::class)->withDefault(); }

기본 모델에 특정 속성값을 채우려면 배열이나 클로저를 withDefault에 전달합니다.

public function user(): BelongsTo { return $this->belongsTo(User::class)->withDefault([ 'name' => '이름 없음', ]); } public function user(): BelongsTo { return $this->belongsTo(User::class)->withDefault(function (User $user, Post $post) { $user->name = '이름 없음'; }); }

Belongs To 관계 쿼리하기

"belongs to" 관계의 자식 모델을 쿼리할 때는 직접 where 절을 작성하는 방법도 있습니다.

use App\Models\Post; $posts = Post::where('user_id', $user->id)->get();

하지만 whereBelongsTo 메서드를 사용하면 더 간결하게 표현할 수 있습니다. 이 메서드는 모델의 외래 키와 관계를 자동으로 판단합니다.

$posts = Post::whereBelongsTo($user)->get();

whereBelongsTo컬렉션을 전달하면 컬렉션 내 모든 모델에 속하는 레코드를 조회합니다.

$users = User::where('vip', true)->get(); $posts = Post::whereBelongsTo($users)->get();

기본적으로 Eloquent는 모델의 클래스명을 기반으로 관계를 자동 결정합니다. 관계명을 직접 지정하고 싶다면 두 번째 인수로 전달하면 됩니다.

$posts = Post::whereBelongsTo($user, 'author')->get();

Has One of Many

모델이 여러 연관 모델을 가질 때, 그 중 "가장 최근" 또는 "가장 오래된" 하나의 모델만 가져오고 싶은 경우가 있습니다. 예를 들어, User 모델은 여러 Order를 가질 수 있는데, 그 중 가장 최근 주문 하나만 조회하고 싶을 때 hasOneofMany 메서드를 조합하여 사용합니다.

/** * 사용자의 가장 최근 주문을 반환합니다. */ public function latestOrder(): HasOne { return $this->hasOne(Order::class)->latestOfMany(); }

마찬가지로, 가장 오래된 연관 모델을 가져오는 메서드도 있습니다.

/** * 사용자의 첫 번째 주문을 반환합니다. */ public function oldestOrder(): HasOne { return $this->hasOne(Order::class)->oldestOfMany(); }

latestOfManyoldestOfMany는 기본적으로 모델의 기본 키를 기준으로 정렬합니다. 기본 키가 정렬 가능한 경우에만 올바른 결과를 반환합니다. 다른 컬럼을 기준으로 정렬하고 싶다면 ofMany 메서드에 집계 함수와 컬럼명을 전달하세요.

/** * 사용자의 가장 큰 금액의 주문을 반환합니다. */ public function largestOrder(): HasOne { return $this->hasOne(Order::class)->ofMany('price', 'max'); }

WARNING

PostgreSQL은 UUID 컬럼에 MAX 함수를 지원하지 않습니다. 따라서 PostgreSQL의 UUID 컬럼과 ofMany를 함께 사용할 수 없습니다.

고급 Has One of Many 관계

더 복잡한 "has one of many" 관계도 구성할 수 있습니다. 예를 들어, Product 모델은 새로운 가격이 게시된 후에도 시스템에 오래된 Price 레코드가 남아있을 수 있습니다. 전체적으로는 hasMany 관계이지만, 현재 유효한 가격(즉, 게시일이 오늘 이전인 것 중 가장 최근 것)만 조회하고 싶을 때 ofMany를 활용합니다.

/** * 상품의 현재 가격을 반환합니다. */ public function currentPricing(): HasOne { return $this->hasOne(Price::class)->ofMany([ 'published_at' => 'max', 'id' => 'max', ], function (Builder $query) { $query->where('published_at', '<=', now()); }); }

Has One Through

"has one through" 관계는 하나의 중간 모델을 거쳐 다른 모델과 연결되는 일대일 관계입니다. 예를 들어, 자동차 정비소 애플리케이션에서 Mechanic(정비사) 모델은 Car(자동차) 모델을 통해 Owner(차주) 모델과 연결될 수 있습니다.

mechanics 테이블:
    id - integer
    name - string

cars 테이블:
    id - integer
    model - string
    mechanic_id - integer

owners 테이블:
    id - integer
    name - string
    car_id - integer

이 구조에서 Mechanic 모델은 Car를 통해 Owner에 접근할 수 있습니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasOneThrough; class Mechanic extends Model { /** * 자동차 소유자를 반환합니다. */ public function carOwner(): HasOneThrough { return $this->hasOneThrough(Owner::class, Car::class); } }

hasOneThrough의 첫 번째 인수는 최종적으로 접근하려는 모델, 두 번째 인수는 중간 모델입니다.

관계에서 사용할 키도 직접 지정할 수 있습니다.

class Mechanic extends Model { /** * 자동차 소유자를 반환합니다. */ public function carOwner(): HasOneThrough { return $this->hasOneThrough( Owner::class, // 최종 모델 Car::class, // 중간 모델 'mechanic_id', // cars 테이블의 외래 키 'car_id', // owners 테이블의 외래 키 'id', // mechanics 테이블의 로컬 키 'id' // cars 테이블의 로컬 키 ); } }

관계 구조를 시각적으로 이해하면 도움이 됩니다.

Has Many Through

"has many through" 관계는 중간 모델을 거쳐 여러 개의 연관 모델에 접근하는 방법입니다. 예를 들어, 배포 플랫폼처럼 ProjectEnvironmentDeployment 구조를 생각해봅시다.

projects 테이블:
    id - integer
    name - string

environments 테이블:
    id - integer
    project_id - integer
    name - string

deployments 테이블:
    id - integer
    environment_id - integer
    commit_hash - string

deployments 테이블에는 project_id 컬럼이 없지만, hasManyThrough 관계를 사용하면 Project에서 Environment를 통해 모든 배포 내역에 접근할 수 있습니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasManyThrough; class Project extends Model { /** * 프로젝트의 모든 배포 내역을 반환합니다. */ public function deployments(): HasManyThrough { return $this->hasManyThrough(Deployment::class, Environment::class); } }

첫 번째 인수는 최종적으로 접근할 모델, 두 번째 인수는 중간 모델입니다.

키 이름을 직접 지정하려면 추가 인수를 전달합니다.

class Project extends Model { public function deployments(): HasManyThrough { return $this->hasManyThrough( Deployment::class, // 최종 모델 Environment::class, // 중간 모델 'project_id', // environments 테이블의 외래 키 'environment_id', // deployments 테이블의 외래 키 'id', // projects 테이블의 로컬 키 'id' // environments 테이블의 로컬 키 ); } }

NOTE

중간 모델이 소프트 삭제를 사용하는 경우, 해당 모델의 소프트 삭제된 레코드는 기본적으로 조회 결과에서 제외됩니다. 소프트 삭제된 레코드도 포함하려면 withTrashedIntermediate 메서드를 사용하세요.

return $this->hasManyThrough(Deployment::class, Environment::class) ->withTrashedIntermediate();

스코프가 적용된 관계

관계 정의에 추가적인 쿼리 제약 조건을 붙이고 싶을 때가 있습니다. 예를 들어, 게시글과 연결된 댓글 중 검증된 사용자가 남긴 것만 가져오고 싶다면 아래와 같이 정의할 수 있습니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use App\Models\Comment; class Post extends Model { public function verifiedComments(): HasMany { return $this->hasMany(Comment::class)->whereHas('user', function ($query) { $query->where('verified', true); }); } }

이처럼 관계 메서드 안에서 쿼리 빌더 메서드를 자유롭게 체이닝할 수 있습니다. 단, 스코프가 적용된 관계는 Eager 로딩 시 쿼리를 제약하는 방식과 상충될 수 있으니 주의하세요.

다대다 관계

다대다 관계는 hasOne이나 hasMany보다 조금 더 복잡합니다. 대표적인 예로, 한 사용자가 여러 역할을 가질 수 있고, 동일한 역할이 여러 사용자에게 공유되는 경우입니다. 예를 들어 사용자에게 "작성자"와 "편집자" 역할을 동시에 부여할 수 있고, "작성자" 역할은 여러 사용자가 가질 수 있습니다.

테이블 구조

이 관계를 정의하려면 users, roles, role_user 세 개의 테이블이 필요합니다. role_user 테이블은 연결된 두 모델명을 알파벳순으로 조합하여 이름을 짓는 것이 관례입니다.

users 테이블:
    id - integer
    name - string

roles 테이블:
    id - integer
    name - string

role_user 테이블:
    user_id - integer
    role_id - integer

모델 구조

다대다 관계는 belongsToMany 메서드를 사용합니다. 모든 Eloquent 모델의 기반인 Illuminate\Database\Eloquent\Model 클래스가 이 메서드를 제공합니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; class User extends Model { /** * 사용자가 가진 역할 목록을 반환합니다. */ public function roles(): BelongsToMany { return $this->belongsToMany(Role::class); } }

이제 roles 동적 프로퍼티로 역할 컬렉션에 접근할 수 있습니다.

use App\Models\User; $user = User::find(1); foreach ($user->roles as $role) { // ... }

모든 관계는 쿼리 빌더이므로 roles 메서드에 추가 조건을 체이닝할 수도 있습니다.

$roles = User::find(1)->roles()->orderBy('name')->get();

중간 테이블 이름은 두 모델명의 알파벳 순 조합으로 자동 결정됩니다. 직접 지정하고 싶다면 두 번째 인수로 전달합니다.

return $this->belongsToMany(Role::class, 'role_user');

외래 키 이름도 세 번째와 네 번째 인수로 직접 지정할 수 있습니다. 세 번째 인수는 현재 모델(관계를 정의하는 모델)의 외래 키, 네 번째 인수는 연결 대상 모델의 외래 키입니다.

return $this->belongsToMany(Role::class, 'role_user', 'user_id', 'role_id');

역방향 관계 정의하기

다대다 역방향 관계를 정의할 때도 belongsToMany를 사용합니다. 아래 예시처럼 Role 모델에서도 users 관계를 정의합니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; class Role extends Model { /** * 이 역할을 가진 사용자 목록을 반환합니다. */ public function users(): BelongsToMany { return $this->belongsToMany(User::class); } }

User 모델과 동일하게 belongsToMany를 사용합니다. 테이블명과 키 이름을 재사용하므로 중복 설정할 필요가 없습니다.

중간 테이블 컬럼 조회

다대다 관계에서는 중간 테이블에 접근해야 할 때가 있습니다. Eloquent는 이를 위해 편리한 방법을 제공합니다. 예를 들어 User 모델이 여러 Role과 연결되어 있고, 관계를 조회하면 모델의 pivot 속성으로 중간 테이블에 접근할 수 있습니다.

use App\Models\User; $user = User::find(1); foreach ($user->roles as $role) { echo $role->pivot->created_at; }

pivot 속성은 중간 테이블을 나타내는 모델로, 일반 Eloquent 모델과 마찬가지로 사용할 수 있습니다.

기본적으로 pivot 모델에는 두 모델의 키만 포함됩니다. 중간 테이블에 추가 컬럼이 있다면 관계 정의 시 withPivot 메서드로 명시해야 합니다.

return $this->belongsToMany(Role::class)->withPivot('active', 'created_by');

중간 테이블의 created_at, updated_at 타임스탬프도 자동으로 관리하려면 withTimestamps 메서드를 사용합니다.

return $this->belongsToMany(Role::class)->withTimestamps();

WARNING

withTimestamps를 사용하는 중간 테이블에는 created_atupdated_at 컬럼이 모두 있어야 합니다.

`pivot` 속성 이름 변경하기

pivot이라는 이름이 도메인에 어울리지 않는다면 as 메서드로 이름을 바꿀 수 있습니다. 예를 들어 사용자와 팟캐스트의 구독 관계라면 subscription이 더 자연스럽습니다.

return $this->belongsToMany(Podcast::class) ->as('subscription') ->withTimestamps();

이렇게 설정하면 pivot 대신 subscription으로 중간 테이블에 접근합니다.

$users = User::with('podcasts')->get(); foreach ($users->flatMap->podcasts as $podcast) { echo $podcast->subscription->created_at; }

중간 테이블 컬럼으로 쿼리 필터링

wherePivot, wherePivotIn, wherePivotNotIn, wherePivotBetween, wherePivotNotBetween, wherePivotNull, wherePivotNotNull 등의 메서드를 사용하여 관계 쿼리 결과를 중간 테이블 컬럼 기준으로 필터링할 수 있습니다.

return $this->belongsToMany(Role::class) ->wherePivot('approved', 1); return $this->belongsToMany(Role::class) ->wherePivotIn('priority', [1, 2]); return $this->belongsToMany(Role::class) ->wherePivotNotIn('priority', [1, 2]); return $this->belongsToMany(Podcast::class) ->as('subscriptions') ->wherePivotBetween('created_at', ['2020-01-01 00:00:00', '2020-12-31 00:00:00']); return $this->belongsToMany(Podcast::class) ->as('subscriptions') ->wherePivotNotBetween('created_at', ['2020-01-01 00:00:00', '2020-12-31 00:00:00']); return $this->belongsToMany(Podcast::class) ->as('subscriptions') ->wherePivotNull('expired_at'); return $this->belongsToMany(Podcast::class) ->as('subscriptions') ->wherePivotNotNull('expired_at');

중간 테이블 컬럼으로 쿼리 정렬

orderByPivot 메서드로 중간 테이블 컬럼을 기준으로 결과를 정렬할 수 있습니다. 아래 예시는 사용자의 최신 배지를 먼저 가져오는 코드입니다.

return $this->belongsToMany(Badge::class) ->where('rank', 'gold') ->orderByPivot('created_at', 'desc');

커스텀 중간 테이블 모델 정의하기

다대다 관계의 중간 테이블을 커스텀 모델로 표현하고 싶다면 using 메서드를 사용합니다. 커스텀 피벗 모델은 Illuminate\Database\Eloquent\Relations\Pivot을 상속해야 합니다. 다형성 다대다의 경우에는 Illuminate\Database\Eloquent\Relations\MorphPivot을 상속합니다.

예를 들어, RoleUser라는 커스텀 피벗 모델을 사용하는 Role 모델을 정의해봅시다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; class User extends Model { public function roles(): BelongsToMany { return $this->belongsToMany(Role::class)->using(RoleUser::class); } }
<?php namespace App\Models; use Illuminate\Database\Eloquent\Relations\Pivot; class RoleUser extends Pivot { // ... }

WARNING

피벗 모델에서는 SoftDeletes 트레이트를 사용할 수 없습니다. 피벗 레코드를 소프트 삭제해야 한다면 피벗 모델을 실제 Eloquent 모델로 전환하는 것을 고려하세요.

커스텀 피벗 모델과 자동 증가 ID

커스텀 피벗 모델에서 자동 증가 기본 키를 사용한다면, 모델 클래스에서 $incrementing 속성을 true로 설정해야 합니다.

/** * ID가 자동 증가함을 나타냅니다. * * @var bool */ public $incrementing = true;

다형성 관계

다형성 관계를 사용하면 하나의 자식 모델이 단일 연관 정의를 통해 여러 종류의 부모 모델에 속할 수 있습니다. 예를 들어 Comment 모델 하나로 PostVideo 모두에 댓글을 달 수 있습니다.

일대일 (다형성)

테이블 구조

일대일 다형성 관계에서는 자식 테이블에 *_id*_type 컬럼이 필요합니다. imageable_id는 부모의 ID, imageable_type은 부모 모델의 클래스명을 저장합니다.

posts 테이블:
    id - integer
    name - string

users 테이블:
    id - integer
    name - string

images 테이블:
    id - integer
    url - string
    imageable_id - integer
    imageable_type - string

모델 구조

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo; class Image extends Model { /** * 이 이미지를 소유한 부모 모델을 반환합니다. */ public function imageable(): MorphTo { return $this->morphTo(); } }
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphOne; class Post extends Model { /** * 게시글의 이미지를 반환합니다. */ public function image(): MorphOne { return $this->morphOne(Image::class, 'imageable'); } }
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphOne; class User extends Model { /** * 사용자의 이미지를 반환합니다. */ public function image(): MorphOne { return $this->morphOne(Image::class, 'imageable'); } }

관계 조회하기

테이블과 모델을 정의하고 나면, 모델을 통해 관계에 접근할 수 있습니다.

use App\Models\Post; $post = Post::find(1); $image = $post->image;

Image 모델에서 부모 모델에 접근하려면 morphTo를 호출하는 메서드명으로 접근합니다.

use App\Models\Image; $image = Image::find(1); $imageable = $image->imageable;

imageable은 해당 이미지를 소유한 모델(Post 또는 User)을 반환합니다.

일대다 (다형성)

테이블 구조

일대다 다형성 관계는 하나의 자식 모델이 여러 종류의 부모 모델에 속하면서 여러 개 존재할 수 있는 경우입니다. 예를 들어 PostVideo 모두에 Comment를 달 수 있습니다.

posts 테이블:
    id - integer
    title - string
    body - text

videos 테이블:
    id - integer
    title - string
    url - string

comments 테이블:
    id - integer
    body - text
    commentable_id - integer
    commentable_type - string

모델 구조

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo; class Comment extends Model { /** * 이 댓글이 속한 부모 모델을 반환합니다. */ public function commentable(): MorphTo { return $this->morphTo(); } }
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphMany; class Post extends Model { /** * 게시글에 달린 모든 댓글을 반환합니다. */ public function comments(): MorphMany { return $this->morphMany(Comment::class, 'commentable'); } }
<?php 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'); } }

관계 조회하기

use App\Models\Post; $post = Post::find(1); foreach ($post->comments as $comment) { // ... }

morphTo를 사용하는 관계를 통해 부모 모델에 역방향으로 접근할 수도 있습니다.

use App\Models\Comment; $comment = Comment::find(1); $commentable = $comment->commentable;

One of Many (다형성)

여러 개의 연관 모델 중 하나만 가져오는 "Has One of Many" 패턴을 다형성 관계에도 적용할 수 있습니다. 예를 들어 PostVideo 모두에서 가장 최근 댓글 하나만 가져오고 싶다면 morphOnelatestOfMany를 함께 사용합니다.

/** * 게시글의 가장 최근 댓글을 반환합니다. */ public function latestComment(): MorphOne { return $this->morphOne(Comment::class, 'commentable')->latestOfMany(); }

다대다 (다형성)

테이블 구조

다대다 다형성 관계는 조금 더 복잡합니다. 예를 들어 PostVideo 모두에 Tag를 붙일 수 있는 구조입니다.

posts 테이블:
    id - integer
    name - string

videos 테이블:
    id - integer
    name - string

tags 테이블:
    id - integer
    name - string

taggables 테이블:
    tag_id - integer
    taggable_id - integer
    taggable_type - string

모델 구조

PostVideo 모델에 morphToMany 관계를 정의합니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphToMany; class Post extends Model { /** * 게시글에 붙은 모든 태그를 반환합니다. */ public function tags(): MorphToMany { return $this->morphToMany(Tag::class, 'taggable'); } }

역방향 관계 정의하기

Tag 모델에서도 각 부모 모델에 대한 역방향 관계를 정의합니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphedByMany; class Tag extends Model { /** * 이 태그가 붙은 모든 게시글을 반환합니다. */ public function posts(): MorphedByMany { return $this->morphedByMany(Post::class, 'taggable'); } /** * 이 태그가 붙은 모든 비디오를 반환합니다. */ public function videos(): MorphedByMany { return $this->morphedByMany(Video::class, 'taggable'); } }

관계 조회하기

use App\Models\Post; $post = Post::find(1); foreach ($post->tags as $tag) { // ... }

역방향으로도 접근할 수 있습니다.

use App\Models\Tag; $tag = Tag::find(1); foreach ($tag->posts as $post) { // ... }

커스텀 다형성 타입

기본적으로 Laravel은 *_type 컬럼에 완전한 클래스명(FQCN)을 저장합니다. 예를 들어 댓글의 부모가 Post이면 App\Models\Post가 저장됩니다. 클래스명 대신 짧은 문자열("morph map")로 매핑하면 데이터베이스와 애플리케이션 코드 간 결합도를 줄일 수 있습니다.

use Illuminate\Database\Eloquent\Relations\Relation; Relation::enforceMorphMap([ 'post' => 'App\Models\Post', 'video' => 'App\Models\Video', ]);

이 설정은 AppServiceProviderboot 메서드에 추가하거나 별도의 서비스 프로바이더를 만들어 등록합니다.

특정 모델의 morph alias를 런타임에 확인하려면 getMorphClass 메서드를 사용합니다.

use App\Models\Post; $alias = (new Post)->getMorphClass(); // 'post'

반대로 alias에서 클래스명을 얻으려면 Relation::getMorphedModel을 사용합니다.

use Illuminate\Database\Eloquent\Relations\Relation; $class = Relation::getMorphedModel('post'); // 'App\Models\Post'

WARNING

기존 애플리케이션에 morph map을 추가하면, 데이터베이스에 저장된 *_type 컬럼의 값(클래스 FQCN)도 함께 마이그레이션해야 합니다.

동적 관계

resolveRelationUsing 메서드를 사용하면 런타임에 Eloquent 모델 간의 관계를 동적으로 정의할 수 있습니다. 일반 애플리케이션 개발에서는 잘 사용하지 않지만, Laravel 패키지를 개발할 때 유용합니다.

use App\Models\Order; use App\Models\Customer; Order::resolveRelationUsing('customer', function (Order $orderModel) { return $orderModel->belongsTo(Customer::class, 'customer_id'); });

WARNING

동적 관계를 정의할 때는 항상 명시적인 키 이름을 인수로 전달하세요.

Eloquent 관계

목차

소개

데이터베이스 테이블은 대부분 서로 연관되어 있습니다. 예를 들어 블로그 게시글에는 여러 댓글이 달릴 수 있고, 주문은 해당 주문을 생성한 사용자와 연결됩니다. Eloquent는 이러한 관계를 쉽고 직관적으로 다룰 수 있도록 도와주며, 다음과 같은 다양한 관계 유형을 지원합니다.

관계 정의하기

Eloquent 관계는 Eloquent 모델 클래스의 메서드로 정의합니다. 관계는 강력한 쿼리 빌더 역할도 하기 때문에, 메서드로 정의하면 메서드 체이닝과 다양한 쿼리 기능을 함께 활용할 수 있습니다. 예를 들어 posts 관계에 추가 조건을 연결할 수 있습니다.

$user->posts()->where('active', 1)->get();

본격적으로 관계를 활용하기 전에, Eloquent가 지원하는 각 관계 유형을 하나씩 살펴보겠습니다.

일대일 / Has One

일대일 관계는 가장 기본적인 데이터베이스 관계입니다. 예를 들어 User 모델이 하나의 Phone 모델과 연결될 수 있습니다. 이 관계를 정의하려면 User 모델에 phone 메서드를 추가하고, 해당 메서드에서 hasOne 메서드를 호출한 결과를 반환합니다. hasOne 메서드는 Illuminate\Database\Eloquent\Model 기반 클래스를 통해 사용할 수 있습니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasOne; class User extends Model { /** * 사용자와 연결된 전화번호를 반환합니다. */ public function phone(): HasOne { return $this->hasOne(Phone::class); } }

hasOne 메서드의 첫 번째 인수는 연결할 모델 클래스 이름입니다. 관계를 정의한 후에는 Eloquent의 동적 프로퍼티를 통해 관련 레코드에 접근할 수 있습니다. 동적 프로퍼티를 사용하면 관계 메서드를 마치 모델에 정의된 일반 프로퍼티처럼 접근할 수 있습니다.

$phone = User::find(1)->phone;

Eloquent는 부모 모델 이름을 기반으로 외래 키를 자동으로 결정합니다. 이 경우 Phone 모델에 user_id 외래 키가 있다고 가정합니다. 이 규칙을 변경하려면 hasOne 메서드의 두 번째 인수로 외래 키를 지정합니다.

return $this->hasOne(Phone::class, 'foreign_key');

또한 Eloquent는 외래 키의 값이 부모 모델의 기본 키(id)와 일치한다고 가정합니다. 기본 키가 id가 아니거나 다른 컬럼을 기준으로 관계를 조회하려면 세 번째 인수로 로컬 키를 지정합니다.

return $this->hasOne(Phone::class, 'foreign_key', 'local_key');

역방향 관계 정의하기

User 모델에서 Phone 모델에 접근할 수 있게 됐습니다. 이제 반대로 Phone 모델에서 해당 사용자에 접근할 수 있도록 역방향 관계를 정의해 보겠습니다. hasOne의 역방향 관계는 belongsTo 메서드를 사용합니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class Phone extends Model { /** * 이 전화번호를 소유한 사용자를 반환합니다. */ public function user(): BelongsTo { return $this->belongsTo(User::class); } }

user 메서드를 호출하면 Eloquent는 Phone 모델의 user_id 컬럼과 일치하는 id를 가진 User 모델을 조회합니다.

Eloquent는 관계 메서드 이름 뒤에 _id를 붙여 외래 키 이름을 결정합니다. 즉, Phone 모델에 user_id 컬럼이 있다고 가정합니다. 외래 키 이름이 다르다면 belongsTo 메서드의 두 번째 인수로 지정합니다.

/** * 이 전화번호를 소유한 사용자를 반환합니다. */ public function user(): BelongsTo { return $this->belongsTo(User::class, 'foreign_key'); }

부모 모델이 id를 기본 키로 사용하지 않거나 다른 컬럼으로 연결하고 싶다면 세 번째 인수로 부모 테이블의 키를 지정합니다.

/** * 이 전화번호를 소유한 사용자를 반환합니다. */ public function user(): BelongsTo { return $this->belongsTo(User::class, 'foreign_key', 'owner_key'); }

일대다 / Has Many

일대다 관계는 하나의 모델이 여러 자식 모델을 가질 때 사용합니다. 예를 들어 하나의 블로그 게시글에는 댓글이 여러 개 달릴 수 있습니다. 다른 관계와 마찬가지로 Eloquent 모델에 메서드를 정의하여 관계를 선언합니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; class Post extends Model { /** * 게시글에 달린 댓글 목록을 반환합니다. */ public function comments(): HasMany { return $this->hasMany(Comment::class); } }

Eloquent는 Comment 모델의 외래 키 컬럼을 자동으로 결정합니다. 관례적으로 부모 모델 이름을 스네이크 케이스로 변환한 후 _id를 붙입니다. 따라서 이 예시에서는 Comment 모델의 외래 키가 post_id라고 가정합니다.

관계 메서드를 정의한 후에는 comments 프로퍼티로 관련 댓글 컬렉션에 접근할 수 있습니다.

use App\Models\Post; $comments = Post::find(1)->comments; foreach ($comments as $comment) { // ... }

모든 관계는 쿼리 빌더로도 동작하므로, comments 메서드를 호출한 후 추가 조건을 체이닝할 수 있습니다.

$comment = Post::find(1)->comments() ->where('title', 'foo') ->first();

hasOne 메서드처럼 hasMany에도 추가 인수를 전달하여 외래 키와 로컬 키를 직접 지정할 수 있습니다.

return $this->hasMany(Comment::class, 'foreign_key'); return $this->hasMany(Comment::class, 'foreign_key', 'local_key');

자식 모델에서 부모 모델 자동 연결하기

Eager Loading을 사용하더라도 자식 모델 루프 안에서 부모 모델에 접근하면 "N+1" 쿼리 문제가 발생할 수 있습니다.

$posts = Post::with('comments')->get(); foreach ($posts as $post) { foreach ($post->comments as $comment) { echo $comment->post->title; } }

위 예시에서 댓글(comments)은 Eager Loading으로 가져왔지만, 각 Comment 모델에 부모 Post가 자동으로 설정되지 않기 때문에 $comment->post에 접근할 때마다 추가 쿼리가 발생합니다.

hasMany 관계를 정의할 때 chaperone 메서드를 호출하면 Eloquent가 자동으로 부모 모델을 자식 모델에 연결해 줍니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; class Post extends Model { /** * 게시글에 달린 댓글 목록을 반환합니다. */ public function comments(): HasMany { return $this->hasMany(Comment::class)->chaperone(); } }

또는 런타임에서 Eager Loading 시 chaperone을 적용할 수도 있습니다.

use App\Models\Post; $posts = Post::with([ 'comments' => fn ($comments) => $comments->chaperone(), ])->get();

일대다 역방향 / Belongs To

게시글의 댓글 전체에 접근할 수 있게 됐으니, 이번에는 댓글에서 부모 게시글에 접근하는 역방향 관계를 정의해 보겠습니다. hasMany의 역방향 관계는 자식 모델에서 belongsTo 메서드를 사용합니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class Comment extends Model { /** * 이 댓글이 속한 게시글을 반환합니다. */ public function post(): BelongsTo { return $this->belongsTo(Post::class); } }

관계를 정의한 후에는 동적 프로퍼티로 부모 게시글에 접근할 수 있습니다.

use App\Models\Comment; $comment = Comment::find(1); return $comment->post->title;

Eloquent는 관계 메서드 이름 뒤에 _와 부모 모델의 기본 키 컬럼명을 붙여 외래 키를 결정합니다. 따라서 이 예시에서는 comments 테이블의 외래 키가 post_id라고 가정합니다.

외래 키 이름이 다르다면 두 번째 인수로 지정합니다.

/** * 이 댓글이 속한 게시글을 반환합니다. */ public function post(): BelongsTo { return $this->belongsTo(Post::class, 'foreign_key'); }

부모 모델의 기본 키가 id가 아니거나 다른 컬럼으로 연결하려면 세 번째 인수로 지정합니다.

/** * 이 댓글이 속한 게시글을 반환합니다. */ public function post(): BelongsTo { return $this->belongsTo(Post::class, 'foreign_key', 'owner_key'); }

기본 모델 (Default Models)

belongsTo, hasOne, hasOneThrough, morphOne 관계는 관계 값이 null일 때 반환할 기본 모델을 지정할 수 있습니다. 이 패턴은 Null Object 패턴이라고 하며, 코드 곳곳의 null 조건 분기를 줄여줍니다. 아래 예시에서는 Post 모델에 연결된 사용자가 없을 경우 빈 App\Models\User 모델을 반환합니다.

/** * 게시글의 작성자를 반환합니다. */ public function user(): BelongsTo { return $this->belongsTo(User::class)->withDefault(); }

기본 모델에 특정 속성을 채우려면 withDefault 메서드에 배열 또는 클로저를 전달합니다.

/** * 게시글의 작성자를 반환합니다. */ public function user(): BelongsTo { return $this->belongsTo(User::class)->withDefault([ 'name' => '게스트 작성자', ]); } /** * 게시글의 작성자를 반환합니다. */ public function user(): BelongsTo { return $this->belongsTo(User::class)->withDefault(function (User $user, Post $post) { $user->name = '게스트 작성자'; }); }

Belongs To 관계 쿼리하기

"belongs to" 관계의 자식 모델을 조회할 때 직접 where 절을 작성할 수도 있습니다.

use App\Models\Post; $posts = Post::where('user_id', $user->id)->get();

하지만 whereBelongsTo 메서드를 사용하면 적절한 관계와 외래 키를 자동으로 파악하여 더 간결하게 작성할 수 있습니다.

$posts = Post::whereBelongsTo($user)->get();

whereBelongsTo컬렉션 인스턴스를 전달하면 컬렉션 내 모든 부모 모델에 속한 자식 모델을 가져옵니다.

$users = User::where('vip', true)->get(); $posts = Post::whereBelongsTo($users)->get();

기본적으로 Laravel은 전달된 모델의 클래스 이름을 기반으로 관계를 결정하지만, 두 번째 인수로 관계 이름을 명시적으로 지정할 수도 있습니다.

$posts = Post::whereBelongsTo($user, 'author')->get();

Has One of Many

모델이 여러 관련 모델을 가지고 있을 때, 그 중 "가장 최근" 또는 "가장 오래된" 모델 하나만 편리하게 가져오고 싶을 때가 있습니다. 예를 들어 User 모델이 여러 Order 모델과 연결되어 있지만 가장 최근 주문 하나만 가져오고 싶다면, hasOne 관계와 ofMany 관련 메서드를 조합하여 사용할 수 있습니다.

/** * 사용자의 가장 최근 주문을 반환합니다. */ public function latestOrder(): HasOne { return $this->hasOne(Order::class)->latestOfMany(); }

반대로 가장 오래된 관련 모델을 가져오려면 oldestOfMany를 사용합니다.

/** * 사용자의 첫 번째 주문을 반환합니다. */ public function oldestOrder(): HasOne { return $this->hasOne(Order::class)->oldestOfMany(); }

기본적으로 latestOfManyoldestOfMany는 모델의 기본 키를 기준으로 정렬합니다. 다른 기준으로 단일 모델을 가져오려면 ofMany 메서드를 사용합니다. 첫 번째 인수는 정렬 기준 컬럼, 두 번째 인수는 집계 함수(min 또는 max)입니다.

/** * 사용자의 가장 금액이 큰 주문을 반환합니다. */ public function largestOrder(): HasOne { return $this->hasOne(Order::class)->ofMany('price', 'max'); }

WARNING

PostgreSQL은 UUID 컬럼에 MAX 함수를 지원하지 않으므로, PostgreSQL UUID 컬럼과 Has One of Many 관계를 함께 사용할 수 없습니다.

"다수" 관계를 Has One 관계로 변환하기

latestOfMany, oldestOfMany, ofMany로 단일 모델을 조회할 때, 이미 같은 모델에 대한 "has many" 관계가 정의되어 있는 경우가 많습니다. 이 경우 one 메서드를 사용하면 기존 관계를 "has one" 관계로 간편하게 변환할 수 있습니다.

/** * 사용자의 주문 목록을 반환합니다. */ public function orders(): HasMany { return $this->hasMany(Order::class); } /** * 사용자의 가장 금액이 큰 주문을 반환합니다. */ public function largestOrder(): HasOne { return $this->orders()->one()->ofMany('price', 'max'); }

one 메서드는 HasManyThrough 관계를 HasOneThrough로 변환할 때도 사용할 수 있습니다.

public function latestDeployment(): HasOneThrough { return $this->deployments()->one()->latestOfMany(); }

고급 Has One of Many 관계

더 복잡한 "has one of many" 관계도 구성할 수 있습니다. 예를 들어 Product 모델이 여러 Price 모델을 가지고 있고, 새 가격이 등록되어도 이전 가격 데이터는 유지됩니다. 또한 미래 날짜를 published_at 컬럼에 설정하여 가격을 미리 예약 등록할 수도 있습니다.

이 경우 현재 시점 이전에 발행된 가격 중 가장 최근 것을 가져와야 하며, 발행일이 동일하다면 id가 가장 큰 것을 우선합니다. ofMany 메서드에 배열을 전달하여 복합 정렬 기준을 지정하고, 클로저로 추가 조건을 적용합니다.

/** * 상품의 현재 가격을 반환합니다. */ public function currentPricing(): HasOne { return $this->hasOne(Price::class)->ofMany([ 'published_at' => 'max', 'id' => 'max', ], function (Builder $query) { $query->where('published_at', '<', now()); }); }

"has-one-through" 관계는 중간 모델을 거쳐 다른 모델과 일대일로 연결됩니다.

예를 들어 자동차 정비 앱에서 각 Mechanic(정비사) 모델은 하나의 Car(자동차) 모델과 연결되고, 각 Car는 하나의 Owner(소유자) 모델과 연결됩니다. 정비사와 소유자 사이에는 직접적인 관계가 없지만, 정비사는 Car 모델을 통해 소유자에 접근할 수 있습니다. 이를 위한 테이블 구조는 다음과 같습니다.

mechanics
    id - integer
    name - string

cars
    id - integer
    model - string
    mechanic_id - integer

owners
    id - integer
    name - string
    car_id - integer

Mechanic 모델에서 관계를 정의합니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasOneThrough; class Mechanic extends Model { /** * 자동차의 소유자를 반환합니다. */ public function carOwner(): HasOneThrough { return $this->hasOneThrough(Owner::class, Car::class); } }

hasOneThrough 메서드의 첫 번째 인수는 최종 접근할 모델, 두 번째 인수는 중간 모델입니다.

관련 모델에 이미 관계가 정의되어 있다면 through 메서드를 사용한 유연한 문법으로도 정의할 수 있습니다.

// 문자열 기반 문법 return $this->through('cars')->has('owner'); // 동적 문법 return $this->throughCars()->hasOwner();

Eloquent의 기본 외래 키 관례를 따르지만, 필요하다면 hasOneThrough 메서드에 세 번째부터 여섯 번째까지 인수를 전달하여 키를 직접 지정할 수 있습니다.

class Mechanic extends Model { /** * 자동차의 소유자를 반환합니다. */ public function carOwner(): HasOneThrough { return $this->hasOneThrough( Owner::class, Car::class, 'mechanic_id', // cars 테이블의 외래 키 'car_id', // owners 테이블의 외래 키 'id', // mechanics 테이블의 로컬 키 'id' // cars 테이블의 로컬 키 ); } }

이미 각 모델에 관계가 정의된 경우 through 메서드 문법을 사용하면 기존 키 관례를 그대로 재사용할 수 있습니다.

// 문자열 기반 문법 return $this->through('cars')->has('owner'); // 동적 문법 return $this->throughCars()->hasOwner();

"has-many-through" 관계는 중간 모델을 통해 멀리 있는 관계에 편리하게 접근할 수 있게 해줍니다. 예를 들어 배포 플랫폼에서 Application 모델은 중간의 Environment 모델을 통해 여러 Deployment 모델에 접근할 수 있습니다. 필요한 테이블 구조는 다음과 같습니다.

applications
    id - integer
    name - string

environments
    id - integer
    application_id - integer
    name - string

deployments
    id - integer
    environment_id - integer
    commit_hash - string

Application 모델에 관계를 정의합니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasManyThrough; class Application extends Model { /** * 애플리케이션의 모든 배포 이력을 반환합니다. */ public function deployments(): HasManyThrough { return $this->hasManyThrough(Deployment::class, Environment::class); } }

hasManyThrough 메서드의 첫 번째 인수는 최종 접근할 모델, 두 번째 인수는 중간 모델입니다.

이미 각 모델에 관계가 정의된 경우 through 메서드를 사용할 수도 있습니다.

// 문자열 기반 문법 return $this->through('environments')->has('deployments'); // 동적 문법 return $this->throughEnvironments()->hasDeployments();

Deployment 모델의 테이블에는 application_id 컬럼이 없지만, hasManyThrough 관계를 통해 $application->deployments로 접근할 수 있습니다. Eloquent는 중간 Environment 모델 테이블의 application_id 컬럼으로 해당하는 환경 ID를 찾은 후, 이를 이용해 Deployment 테이블을 조회합니다.

키 관례

기본 외래 키 관례를 따르지만, 필요하다면 hasManyThrough 메서드에 세 번째부터 여섯 번째까지 인수를 전달하여 키를 직접 지정할 수 있습니다.

class Application extends Model { public function deployments(): HasManyThrough { return $this->hasManyThrough( Deployment::class, Environment::class, 'application_id', // environments 테이블의 외래 키 'environment_id', // deployments 테이블의 외래 키 'id', // applications 테이블의 로컬 키 'id' // environments 테이블의 로컬 키 ); } }

이미 각 모델에 관계가 정의된 경우 through 메서드 문법을 사용하면 기존 키 관례를 재사용할 수 있습니다.

// 문자열 기반 문법 return $this->through('environments')->has('deployments'); // 동적 문법 return $this->throughEnvironments()->hasDeployments();

범위가 지정된 관계 (Scoped Relationships)

관계에 추가 조건을 붙인 메서드를 모델에 정의하는 경우가 많습니다. 예를 들어 User 모델의 posts 관계를 기반으로 추천 게시글만 가져오는 featuredPosts 메서드를 추가할 수 있습니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; class User extends Model { /** * 사용자의 게시글 목록을 반환합니다. */ public function posts(): HasMany { return $this->hasMany(Post::class)->latest(); } /** * 사용자의 추천 게시글 목록을 반환합니다. */ public function featuredPosts(): HasMany { return $this->posts()->where('featured', true); } }

그런데 이 방식으로 featuredPosts를 통해 모델을 생성하면 featured 속성이 자동으로 true로 설정되지 않습니다. 관계 메서드를 통해 모델을 생성할 때 특정 속성을 자동으로 포함하려면 withAttributes 메서드를 사용하세요.

/** * 사용자의 추천 게시글 목록을 반환합니다. */ public function featuredPosts(): HasMany { return $this->posts()->withAttributes(['featured' => true]); }

withAttributes 메서드는 지정된 속성으로 where 조건을 쿼리에 추가하고, 이 관계를 통해 생성되는 모델에도 해당 속성을 자동으로 포함합니다.

$post = $user->featuredPosts()->create(['title' => '추천 게시글']); $post->featured; // true

where 조건 추가 없이 생성 시 속성만 설정하려면 asConditions 인수를 false로 지정합니다.

return $this->posts()->withAttributes(['featured' => true], asConditions: false);

다대다(Many to Many) 관계

hasOne, hasMany에 비해 다대다 관계는 구조가 조금 더 복잡합니다. 전형적인 예로 "사용자와 역할(Role)" 관계를 들 수 있습니다. 한 사용자는 여러 역할을 가질 수 있고, 동일한 역할을 여러 사용자가 공유할 수도 있습니다. 예를 들어 한 사용자에게 "작성자"와 "편집자" 역할을 동시에 부여할 수 있고, 그 역할들은 다른 사용자에게도 부여될 수 있습니다.

다대다 관계를 구성하려면 users, roles, role_user 세 개의 테이블이 필요합니다. role_user 테이블은 두 모델 이름을 알파벳 순으로 조합한 이름이며, user_idrole_id 컬럼을 포함하는 중간(피벗) 테이블입니다.

roles 테이블에 단순히 user_id 컬럼을 추가하는 방식으로는 하나의 역할을 여러 사용자에게 부여할 수 없습니다. 이런 이유로 별도의 중간 테이블이 필요합니다.

users
    id - integer
    name - string

roles
    id - integer
    name - string

role_user
    user_id - integer
    role_id - integer

모델 구조

다대다 관계는 belongsToMany 메서드의 결과를 반환하는 메서드를 정의하는 방식으로 구성합니다. 이 메서드는 모든 Eloquent 모델의 기반 클래스인 Illuminate\Database\Eloquent\Model에서 제공됩니다. 아래와 같이 User 모델에 roles 메서드를 정의합니다. 첫 번째 인자는 연관 모델 클래스명입니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; class User extends Model { /** * 사용자에게 부여된 역할 목록 */ public function roles(): BelongsToMany { return $this->belongsToMany(Role::class); } }

관계를 정의하고 나면 동적 속성을 통해 사용자의 역할에 접근할 수 있습니다.

use App\Models\User; $user = User::find(1); foreach ($user->roles as $role) { // ... }

모든 관계는 쿼리 빌더로도 동작하기 때문에, roles() 메서드를 직접 호출해 체이닝 방식으로 추가 조건을 걸 수도 있습니다.

$roles = User::find(1)->roles()->orderBy('name')->get();

Eloquent는 두 모델 이름을 알파벳 순으로 조합해 중간 테이블 이름을 자동으로 결정합니다. 이 규칙을 재정의하려면 belongsToMany의 두 번째 인자로 테이블 이름을 명시하면 됩니다.

return $this->belongsToMany(Role::class, 'role_user');

중간 테이블의 외래 키 컬럼 이름도 변경할 수 있습니다. 세 번째 인자는 관계를 정의하는 모델의 외래 키, 네 번째 인자는 연결되는 모델의 외래 키입니다.

return $this->belongsToMany(Role::class, 'role_user', 'user_id', 'role_id');

역방향 관계 정의

다대다 관계의 역방향도 동일하게 belongsToMany 메서드를 사용해 정의합니다. Role 모델에 users 메서드를 추가하면 됩니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; class Role extends Model { /** * 이 역할을 가진 사용자 목록 */ public function users(): BelongsToMany { return $this->belongsToMany(User::class); } }

보시다시피, 역방향 정의는 참조하는 모델 클래스(User::class)만 다를 뿐 나머지는 동일합니다. 테이블명·키 이름 커스터마이징 옵션도 동일하게 사용할 수 있습니다.

중간 테이블 컬럼 접근

다대다 관계에서는 중간 테이블의 데이터를 함께 다루는 경우가 많습니다. Eloquent는 이를 위해 편리한 방법을 제공합니다. 관계를 통해 조회된 각 모델에는 pivot 속성이 자동으로 부여되며, 이를 통해 중간 테이블 데이터에 접근할 수 있습니다.

use App\Models\User; $user = User::find(1); foreach ($user->roles as $role) { echo $role->pivot->created_at; }

기본적으로 pivot 모델에는 두 모델의 키 컬럼만 포함됩니다. 중간 테이블에 추가 컬럼이 있다면 관계 정의 시 withPivot으로 명시해야 합니다.

return $this->belongsToMany(Role::class)->withPivot('active', 'created_by');

중간 테이블에 created_at, updated_at 타임스탬프를 자동 관리하고 싶다면 withTimestamps를 추가합니다.

return $this->belongsToMany(Role::class)->withTimestamps();

WARNING

withTimestamps()를 사용하는 중간 테이블에는 반드시 created_atupdated_at 컬럼이 모두 존재해야 합니다.

`pivot` 속성 이름 변경

중간 테이블 속성의 이름을 pivot 대신 도메인에 맞는 이름으로 바꿀 수 있습니다. 예를 들어 사용자와 팟캐스트 구독 관계에서는 pivot 대신 subscription이 훨씬 직관적입니다. as 메서드를 사용하면 됩니다.

return $this->belongsToMany(Podcast::class) ->as('subscription') ->withTimestamps();

이후 데이터 접근 시 지정한 이름을 그대로 사용할 수 있습니다.

$users = User::with('podcasts')->get(); foreach ($users->flatMap->podcasts as $podcast) { echo $podcast->subscription->created_at; }

중간 테이블 컬럼 기반 쿼리 필터링

belongsToMany 관계 쿼리를 정의할 때 중간 테이블 컬럼 값을 기준으로 결과를 필터링할 수 있습니다. 사용 가능한 메서드는 다음과 같습니다.

// 승인된 역할만 조회 return $this->belongsToMany(Role::class) ->wherePivot('approved', 1); // 우선순위가 1 또는 2인 역할 조회 return $this->belongsToMany(Role::class) ->wherePivotIn('priority', [1, 2]); // 우선순위가 1, 2가 아닌 역할 조회 return $this->belongsToMany(Role::class) ->wherePivotNotIn('priority', [1, 2]); // 2020년에 구독된 팟캐스트 조회 return $this->belongsToMany(Podcast::class) ->as('subscriptions') ->wherePivotBetween('created_at', ['2020-01-01 00:00:00', '2020-12-31 00:00:00']); // 2020년 외에 구독된 팟캐스트 조회 return $this->belongsToMany(Podcast::class) ->as('subscriptions') ->wherePivotNotBetween('created_at', ['2020-01-01 00:00:00', '2020-12-31 00:00:00']); // 만료일이 없는 구독 조회 return $this->belongsToMany(Podcast::class) ->as('subscriptions') ->wherePivotNull('expired_at'); // 만료일이 있는 구독 조회 return $this->belongsToMany(Podcast::class) ->as('subscriptions') ->wherePivotNotNull('expired_at');

wherePivot은 조회 시 WHERE 조건만 추가할 뿐, 해당 관계를 통해 새 모델을 생성할 때 피벗 값을 자동으로 설정하지는 않습니다. 조회와 생성 시 모두 특정 피벗 값을 적용하려면 withPivotValue 메서드를 사용하세요.

return $this->belongsToMany(Role::class) ->withPivotValue('approved', 1);

중간 테이블 컬럼 기반 정렬

orderByPivot, orderByPivotDesc 메서드를 사용하면 중간 테이블 컬럼을 기준으로 결과를 정렬할 수 있습니다. 아래 예시는 사용자의 골드 등급 배지를 최신순으로 가져옵니다.

return $this->belongsToMany(Badge::class) ->where('rank', 'gold') ->orderByPivotDesc('created_at');

커스텀 중간 테이블 모델 정의

중간 테이블을 표현하는 커스텀 모델을 별도로 정의하고 싶다면 관계 정의 시 using 메서드를 사용하면 됩니다. 커스텀 피벗 모델을 사용하면 중간 테이블에 메서드나 캐스트 같은 추가 동작을 정의할 수 있습니다.

커스텀 피벗 모델은 Illuminate\Database\Eloquent\Relations\Pivot을 상속해야 하며, 다형성(polymorphic) 다대다 관계의 경우에는 Illuminate\Database\Eloquent\Relations\MorphPivot을 상속합니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; class Role extends Model { /** * 이 역할을 가진 사용자 목록 */ public function users(): BelongsToMany { return $this->belongsToMany(User::class)->using(RoleUser::class); } }

RoleUser 모델은 다음과 같이 Pivot 클래스를 상속해 정의합니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Relations\Pivot; class RoleUser extends Pivot { // ... }

WARNING

피벗 모델에는 SoftDeletes 트레이트를 사용할 수 없습니다. 피벗 레코드에 소프트 삭제가 필요하다면 피벗 모델을 일반 Eloquent 모델로 전환하는 방법을 검토하세요.

커스텀 피벗 모델과 자동 증가 기본 키

커스텀 피벗 모델에 자동 증가(auto-increment) 기본 키가 있는 경우, Table 속성에 incrementing: true를 지정해야 합니다.

use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Relations\Pivot; #[Table(incrementing: true)] class RoleUser extends Pivot { // ... }

다형성 관계 (Polymorphic Relationships)

다형성 관계(Polymorphic Relationship)를 사용하면 자식 모델이 단일 연관을 통해 여러 타입의 모델에 속할 수 있습니다. 예를 들어, 사용자가 블로그 포스트와 동영상을 공유할 수 있는 서비스를 만든다고 가정해 보겠습니다. 이 경우 Comment 모델은 PostVideo 모델 양쪽 모두에 속할 수 있어야 합니다. 다형성 관계를 사용하면 이를 하나의 comments 테이블로 깔끔하게 처리할 수 있습니다.

일대일 (다형성)

테이블 구조

다형성 일대일 관계는 일반 일대일 관계와 비슷하지만, 자식 모델이 단일 연관으로 여러 타입의 모델에 속할 수 있다는 점이 다릅니다. 예를 들어, PostUser 모델이 Image 모델에 대한 다형성 관계를 공유한다고 가정해 보겠습니다. 다형성 일대일 관계를 사용하면 포스트와 사용자 모두에서 참조할 수 있는 고유 이미지 테이블 하나로 관리할 수 있습니다. 테이블 구조를 살펴보겠습니다.

posts
    id - integer
    name - string

users
    id - integer
    name - string

images
    id - integer
    url - string
    imageable_type - string
    imageable_id - integer

images 테이블의 imageable_idimageable_type 컬럼에 주목하세요. imageable_id는 포스트 또는 사용자의 ID를 저장하고, imageable_type은 부모 모델의 클래스명을 저장합니다. Eloquent는 imageable 관계에 접근할 때 imageable_type 컬럼을 보고 어떤 타입의 부모 모델을 반환할지 결정합니다. 이 경우 컬럼 값은 App\Models\Post 또는 App\Models\User 중 하나가 됩니다.

모델 구조

이 관계를 구성하기 위한 모델 정의를 살펴보겠습니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo; class Image extends Model { /** * 이미지의 부모 모델(User 또는 Post)을 반환합니다. */ public function imageable(): MorphTo { return $this->morphTo(); } } use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphOne; class Post extends Model { /** * 포스트에 연결된 이미지를 반환합니다. */ public function image(): MorphOne { return $this->morphOne(Image::class, 'imageable'); } } use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphOne; class User extends Model { /** * 사용자에 연결된 이미지를 반환합니다. */ public function image(): MorphOne { return $this->morphOne(Image::class, 'imageable'); } }

관계 조회

테이블과 모델 정의가 완료되면, 모델을 통해 관계에 접근할 수 있습니다. 예를 들어, 포스트의 이미지를 가져오려면 동적 관계 프로퍼티 image에 접근하면 됩니다.

use App\Models\Post; $post = Post::find(1); $image = $post->image;

morphTo를 호출하는 메서드 이름으로 다형성 모델의 부모를 조회할 수도 있습니다. 이 경우 Image 모델의 imageable 메서드가 해당합니다. 동적 관계 프로퍼티로 접근하면 됩니다.

use App\Models\Image; $image = Image::find(1); $imageable = $image->imageable;

Image 모델의 imageable 관계는 이미지를 소유한 모델 타입에 따라 Post 또는 User 인스턴스를 반환합니다.

키 컬럼명 커스터마이즈

필요하다면 다형성 자식 모델이 사용하는 "id"와 "type" 컬럼명을 직접 지정할 수 있습니다. 이 경우 morphTo 메서드의 첫 번째 인수로 반드시 관계명을 전달해야 합니다. 보통 이 값은 메서드 이름과 일치하므로 PHP의 __FUNCTION__ 상수를 활용하면 편리합니다.

/** * 이미지가 속한 모델을 반환합니다. */ public function imageable(): MorphTo { return $this->morphTo(__FUNCTION__, 'imageable_type', 'imageable_id'); }

테이블 구조

다형성 일대다 관계는 일반 일대다 관계와 비슷하지만, 자식 모델이 단일 연관으로 여러 타입의 모델에 속할 수 있습니다. 예를 들어, 사용자가 포스트와 동영상 모두에 댓글을 달 수 있는 서비스라면, 하나의 comments 테이블로 포스트와 동영상의 댓글을 함께 관리할 수 있습니다. 필요한 테이블 구조를 살펴보겠습니다.

posts
    id - integer
    title - string
    body - text

videos
    id - integer
    title - string
    url - string

comments
    id - integer
    body - text
    commentable_type - string
    commentable_id - integer

모델 구조

이 관계를 구성하기 위한 모델 정의를 살펴보겠습니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo; class Comment extends Model { /** * 댓글의 부모 모델(Post 또는 Video)을 반환합니다. */ public function commentable(): MorphTo { return $this->morphTo(); } } use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphMany; class Post extends Model { /** * 포스트의 모든 댓글을 반환합니다. */ public function comments(): MorphMany { return $this->morphMany(Comment::class, 'commentable'); } } 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'); } }

관계 조회

테이블과 모델 정의가 완료되면 동적 관계 프로퍼티를 통해 관계에 접근할 수 있습니다. 예를 들어, 포스트의 모든 댓글을 조회하려면 comments 동적 프로퍼티를 사용합니다.

use App\Models\Post; $post = Post::find(1); foreach ($post->comments as $comment) { // ... }

다형성 자식 모델에서 부모 모델을 조회할 때도 morphTo를 호출하는 메서드 이름으로 접근합니다. 이 경우 Comment 모델의 commentable 메서드가 해당합니다.

use App\Models\Comment; $comment = Comment::find(1); $commentable = $comment->commentable;

Comment 모델의 commentable 관계는 댓글의 부모 모델 타입에 따라 Post 또는 Video 인스턴스를 반환합니다.

자식 모델에서 부모 모델 자동 하이드레이션

Eager Loading을 사용하더라도 자식 모델을 순회하면서 부모 모델에 접근하면 N+1 쿼리 문제가 발생할 수 있습니다.

$posts = Post::with('comments')->get(); foreach ($posts as $post) { foreach ($post->comments as $comment) { echo $comment->commentable->title; } }

위 예시에서 comments는 Eager Loading으로 가져오지만, 각 Comment에 부모 Post가 자동으로 채워지지 않기 때문에 N+1 문제가 발생합니다.

morphMany 관계를 정의할 때 chaperone 메서드를 호출하면 Eloquent가 자식 모델에 부모 모델을 자동으로 채워줍니다.

class Post extends Model { /** * 포스트의 모든 댓글을 반환합니다. */ public function comments(): MorphMany { return $this->morphMany(Comment::class, 'commentable')->chaperone(); } }

또는 Eager Loading 시점에 동적으로 적용할 수도 있습니다.

use App\Models\Post; $posts = Post::with([ 'comments' => fn ($comments) => $comments->chaperone(), ])->get();

다중 중 하나 (다형성)

모델이 여러 관련 모델을 가질 때, 그 중 "가장 최신" 또는 "가장 오래된" 모델 하나만 편리하게 조회하고 싶을 수 있습니다. 예를 들어, User 모델이 여러 Image 모델과 관계가 있을 때, 사용자가 가장 최근에 업로드한 이미지를 간편하게 가져오려면 morphOneofMany 계열 메서드를 조합하면 됩니다.

/** * 사용자의 가장 최근 이미지를 반환합니다. */ public function latestImage(): MorphOne { return $this->morphOne(Image::class, 'imageable')->latestOfMany(); }

가장 오래된 이미지를 조회하는 메서드도 같은 방식으로 정의할 수 있습니다.

/** * 사용자의 가장 오래된 이미지를 반환합니다. */ public function oldestImage(): MorphOne { return $this->morphOne(Image::class, 'imageable')->oldestOfMany(); }

기본적으로 latestOfManyoldestOfMany는 모델의 기본 키(정렬 가능한 값)를 기준으로 동작합니다. 다른 정렬 기준을 사용하고 싶다면 ofMany 메서드를 활용하세요. 예를 들어, 좋아요 수가 가장 많은 이미지를 조회하려면 다음과 같이 작성합니다.

/** * 사용자의 가장 인기 있는 이미지를 반환합니다. */ public function bestImage(): MorphOne { return $this->morphOne(Image::class, 'imageable')->ofMany('likes', 'max'); }

NOTE

더 복잡한 "다중 중 하나" 관계를 구성하는 것도 가능합니다. 자세한 내용은 고급 has one of many 문서를 참고하세요.

다대다 (다형성)

테이블 구조

다형성 다대다 관계는 "morph one"이나 "morph many"보다 다소 복잡합니다. 예를 들어, Post 모델과 Video 모델이 Tag 모델에 대한 다형성 관계를 공유한다고 가정해 보겠습니다. 이 경우 포스트와 동영상 모두에 연결할 수 있는 태그 테이블 하나로 관리할 수 있습니다. 필요한 테이블 구조는 다음과 같습니다.

posts
    id - integer
    name - string

videos
    id - integer
    name - string

tags
    id - integer
    name - string

taggables
    tag_id - integer
    taggable_type - string
    taggable_id - integer

NOTE

다형성 다대다 관계를 살펴보기 전에, 일반 다대다 관계 문서를 먼저 읽어두면 이해에 도움이 됩니다.

모델 구조

이제 모델에 관계를 정의할 차례입니다. PostVideo 모델 모두 Eloquent 기본 클래스가 제공하는 morphToMany 메서드를 호출하는 tags 메서드를 포함합니다.

morphToMany 메서드는 관련 모델명과 "관계명"을 인수로 받습니다. 중간 테이블 이름과 포함된 키를 기반으로 이 관계를 "taggable"로 지칭합니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphToMany; class Post extends Model { /** * 포스트의 모든 태그를 반환합니다. */ public function tags(): MorphToMany { return $this->morphToMany(Tag::class, 'taggable'); } }

역방향 관계 정의

다음으로 Tag 모델에 각 부모 모델 타입에 대한 메서드를 정의합니다. 이 예시에서는 posts 메서드와 videos 메서드를 정의합니다. 두 메서드 모두 morphedByMany 메서드의 결과를 반환해야 합니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphToMany; class Tag extends Model { /** * 이 태그가 지정된 모든 포스트를 반환합니다. */ public function posts(): MorphToMany { return $this->morphedByMany(Post::class, 'taggable'); } /** * 이 태그가 지정된 모든 동영상을 반환합니다. */ public function videos(): MorphToMany { return $this->morphedByMany(Video::class, 'taggable'); } }

관계 조회

테이블과 모델 정의가 완료되면 모델을 통해 관계에 접근할 수 있습니다. 예를 들어, 포스트의 모든 태그를 조회하려면 tags 동적 관계 프로퍼티를 사용합니다.

use App\Models\Post; $post = Post::find(1); foreach ($post->tags as $tag) { // ... }

Tag 모델에서는 morphedByMany를 호출하는 메서드 이름으로 부모 모델에 접근할 수 있습니다.

use App\Models\Tag; $tag = Tag::find(1); foreach ($tag->posts as $post) { // ... } foreach ($tag->videos as $video) { // ... }

커스텀 다형성 타입

기본적으로 Laravel은 관련 모델의 "타입"을 저장할 때 완전한 클래스명(FQCN)을 사용합니다. 예를 들어, 위의 일대다 예시에서 commentable_type 컬럼에는 App\Models\Post 또는 App\Models\Video가 저장됩니다. 그런데 이 값이 애플리케이션의 내부 클래스 구조에 직접 의존하면 나중에 클래스를 이름을 변경하거나 이동할 때 문제가 생길 수 있습니다.

이를 해결하기 위해 클래스명 대신 post, video 같은 짧은 별칭 문자열을 사용할 수 있습니다. 이렇게 하면 모델 클래스명이 바뀌어도 데이터베이스의 타입 컬럼 값은 그대로 유지됩니다.

use Illuminate\Database\Eloquent\Relations\Relation; Relation::enforceMorphMap([ 'post' => 'App\Models\Post', 'video' => 'App\Models\Video', ]);

이 설정은 App\Providers\AppServiceProviderboot 메서드에서 호출하거나, 별도의 서비스 프로바이더를 만들어 등록하면 됩니다.

런타임에 특정 모델의 morph 별칭을 확인하거나, 별칭에서 클래스명을 역조회하려면 다음 메서드를 사용하세요.

use Illuminate\Database\Eloquent\Relations\Relation; $alias = $post->getMorphClass(); $class = Relation::getMorphedModel($alias);

WARNING

기존 애플리케이션에 morph 맵을 추가할 경우, 데이터베이스의 모든 *_type 컬럼에 저장된 완전한 클래스명 값을 morph 맵에서 지정한 별칭으로 마이그레이션해야 합니다.

동적 관계

resolveRelationUsing 메서드를 사용하면 런타임에 Eloquent 모델 간의 관계를 동적으로 정의할 수 있습니다. 일반적인 애플리케이션 개발에서는 권장되지 않지만, Laravel 패키지를 개발할 때 유용할 수 있습니다.

resolveRelationUsing 메서드는 관계명을 첫 번째 인수로 받고, 두 번째 인수로는 모델 인스턴스를 받아 유효한 Eloquent 관계 정의를 반환하는 클로저를 받습니다. 동적 관계는 보통 서비스 프로바이더boot 메서드 안에서 설정합니다.

use App\Models\Order; use App\Models\Customer; Order::resolveRelationUsing('customer', function (Order $orderModel) { return $orderModel->belongsTo(Customer::class, 'customer_id'); });

WARNING

동적 관계를 정의할 때는 Eloquent 관계 메서드에 키 이름 인수를 항상 명시적으로 지정해야 합니다.

Eloquent 관계 — 관계 쿼리


관계 쿼리하기

Eloquent의 모든 관계는 메서드로 정의됩니다. 이 메서드를 호출하면, 실제로 쿼리를 실행하지 않고 관계 인스턴스만 반환합니다. 그리고 모든 관계 타입은 쿼리 빌더처럼 동작하기 때문에, 최종적으로 get() 등을 호출하기 전에 다양한 조건을 체이닝할 수 있습니다.

예를 들어, 블로그 애플리케이션에서 User 모델이 여러 Post 모델을 가지는 경우를 생각해 봅시다:

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; class User extends Model { /** * 이 사용자의 모든 게시글을 반환합니다. */ public function posts(): HasMany { return $this->hasMany(Post::class); } }

posts 관계에 추가 조건을 걸어 쿼리할 수 있습니다:

use App\Models\User; $user = User::find(1); $user->posts()->where('active', 1)->get();

Laravel 쿼리 빌더에서 사용할 수 있는 메서드는 관계 쿼리에서도 동일하게 사용할 수 있습니다.

관계 쿼리 뒤에 orWhere 체이닝 시 주의사항

관계 쿼리 뒤에 orWhere를 체이닝할 때는 주의가 필요합니다. orWhere 절은 관계 조건과 같은 수준에서 논리적으로 묶이기 때문에, 의도치 않은 결과가 나올 수 있습니다:

$user->posts() ->where('active', 1) ->orWhere('votes', '>=', 100) ->get();

위 코드가 생성하는 SQL은 다음과 같습니다. or 조건 때문에 특정 사용자에 대한 제약이 사라지고, 추천 수가 100 이상인 모든 게시글이 반환됩니다:

select * from posts where user_id = ? and active = 1 or votes >= 100

의도한 대로 동작하게 하려면, 논리 그룹을 사용해 조건을 괄호로 묶어야 합니다:

use Illuminate\Database\Eloquent\Builder; $user->posts() ->where(function (Builder $query) { return $query->where('active', 1) ->orWhere('votes', '>=', 100); }) ->get();

이렇게 하면 다음과 같이 올바른 SQL이 생성됩니다. 괄호로 묶인 조건 안에서만 or가 적용되므로, 특정 사용자에 대한 제약이 유지됩니다:

select * from posts where user_id = ? and (active = 1 or votes >= 100)

관계 메서드 vs. 동적 프로퍼티

관계에 추가 조건이 필요 없다면, 메서드 호출 대신 프로퍼티처럼 접근할 수 있습니다:

use App\Models\User; $user = User::find(1); foreach ($user->posts as $post) { // ... }

동적 관계 프로퍼티는 지연 로딩(lazy loading) 방식으로 동작합니다. 즉, 실제로 접근하는 시점에 쿼리를 실행합니다. 반복문이나 여러 모델을 다룰 때 N+1 쿼리 문제가 발생하기 쉬우므로, 미리 로드할 관계가 정해져 있다면 즉시 로딩(eager loading)을 사용하는 것이 좋습니다. 즉시 로딩은 관계 데이터를 불러오는 데 필요한 SQL 쿼리 수를 크게 줄여줍니다.

관계 존재 여부로 쿼리하기

특정 관계가 존재하는 레코드만 조회하고 싶을 때가 있습니다. 예를 들어, 댓글이 하나 이상 달린 게시글만 가져오려면 has 또는 orHas 메서드에 관계 이름을 전달합니다:

use App\Models\Post; // 댓글이 하나 이상 있는 게시글 조회 $posts = Post::has('comments')->get();

연산자와 개수를 지정해 조건을 세밀하게 조정할 수도 있습니다:

// 댓글이 3개 이상인 게시글 조회 $posts = Post::has('comments', '>=', 3)->get();

점 표기법(dot notation)으로 중첩 관계도 표현할 수 있습니다. 예를 들어, 이미지가 달린 댓글이 하나 이상 있는 게시글을 조회하려면:

// 이미지가 있는 댓글을 하나 이상 가진 게시글 조회 $posts = Post::has('comments.images')->get();

관계의 존재 여부뿐만 아니라 관계 데이터 내용까지 조건으로 걸고 싶다면 whereHas 또는 orWhereHas를 사용합니다:

use Illuminate\Database\Eloquent\Builder; // 'code'로 시작하는 내용의 댓글이 하나 이상 있는 게시글 조회 $posts = Post::whereHas('comments', function (Builder $query) { $query->where('content', 'like', 'code%'); })->get(); // 'code'로 시작하는 내용의 댓글이 10개 이상인 게시글 조회 $posts = Post::whereHas('comments', function (Builder $query) { $query->where('content', 'like', 'code%'); }, '>=', 10)->get();

WARNING

Eloquent는 현재 서로 다른 데이터베이스에 걸친 관계 존재 여부 쿼리를 지원하지 않습니다. 관계를 맺는 모델들은 반드시 같은 데이터베이스에 있어야 합니다.

다대다 관계 존재 쿼리

whereAttachedTo 메서드를 사용하면 특정 모델 또는 컬렉션과 다대다로 연결된 모델을 조회할 수 있습니다:

$users = User::whereAttachedTo($role)->get();

컬렉션 인스턴스를 전달할 수도 있습니다. 이 경우 컬렉션 내 어떤 모델과도 연결된 레코드가 반환됩니다:

$tags = Tag::whereLike('name', '%laravel%')->get(); $posts = Post::whereAttachedTo($tags)->get();

인라인 관계 존재 쿼리

간단한 단일 조건으로 관계 존재 여부를 확인하려면, 클로저 없이 더 간결하게 작성할 수 있는 whereRelation, orWhereRelation, whereMorphRelation, orWhereMorphRelation 메서드를 사용하는 것이 편리합니다. 예를 들어, 승인되지 않은 댓글이 있는 게시글을 조회할 때:

use App\Models\Post; $posts = Post::whereRelation('comments', 'is_approved', false)->get();

쿼리 빌더의 where처럼 연산자도 지정할 수 있습니다:

$posts = Post::whereRelation( 'comments', 'created_at', '>=', now()->minus(hours: 1) )->get();

관계 부재 여부로 쿼리하기

반대로, 특정 관계가 없는 레코드만 조회하고 싶을 때는 doesntHave 또는 orDoesntHave 메서드를 사용합니다. 예를 들어, 댓글이 하나도 없는 게시글만 조회하려면:

use App\Models\Post; $posts = Post::doesntHave('comments')->get();

관계가 없는 조건에 추가 내용 검사를 더하려면 whereDoesntHave 또는 orWhereDoesntHave를 사용합니다:

use Illuminate\Database\Eloquent\Builder; $posts = Post::whereDoesntHave('comments', function (Builder $query) { $query->where('content', 'like', 'code%'); })->get();

점 표기법으로 중첩 관계에도 적용할 수 있습니다. 아래 예시는 댓글이 없거나, 댓글이 있더라도 차단된 사용자가 작성한 댓글이 없는 게시글을 조회합니다:

use Illuminate\Database\Eloquent\Builder; $posts = Post::whereDoesntHave('comments.author', function (Builder $query) { $query->where('banned', 1); })->get();

Morph To 관계 쿼리하기

"morph to" 관계의 존재 여부를 쿼리하려면 whereHasMorphwhereDoesntHaveMorph 메서드를 사용합니다. 첫 번째 인자로 관계 이름, 두 번째 인자로 포함할 관련 모델 클래스 목록, 세 번째 인자로 쿼리를 커스터마이징할 클로저를 전달합니다:

use App\Models\Comment; use App\Models\Post; use App\Models\Video; use Illuminate\Database\Eloquent\Builder; // 제목이 'code'로 시작하는 Post 또는 Video에 달린 댓글 조회 $comments = Comment::whereHasMorph( 'commentable', [Post::class, Video::class], function (Builder $query) { $query->where('title', 'like', 'code%'); } )->get(); // 제목이 'code'로 시작하지 않는 Post에 달린 댓글 조회 $comments = Comment::whereDoesntHaveMorph( 'commentable', Post::class, function (Builder $query) { $query->where('title', 'like', 'code%'); } )->get();

폴리모픽 모델의 타입에 따라 다른 조건을 적용해야 할 경우, 클로저의 두 번째 인자로 $type을 받을 수 있습니다:

use Illuminate\Database\Eloquent\Builder; $comments = Comment::whereHasMorph( 'commentable', [Post::class, Video::class], function (Builder $query, string $type) { // Post일 때는 content, Video일 때는 title 컬럼으로 검색 $column = $type === Post::class ? 'content' : 'title'; $query->where($column, 'like', 'code%'); } )->get();

특정 부모 모델의 자식 댓글을 조회할 때는 whereMorphedTowhereNotMorphedTo 메서드를 사용할 수 있습니다. 이 메서드들은 주어진 모델에 맞는 morph 타입 매핑을 자동으로 처리합니다:

$comments = Comment::whereMorphedTo('commentable', $post) ->orWhereMorphedTo('commentable', $video) ->get();

모든 연관 모델 쿼리하기

가능한 폴리모픽 모델 목록을 배열로 전달하는 대신, 와일드카드 *를 사용할 수 있습니다. 이 경우 Laravel이 데이터베이스에서 가능한 모든 폴리모픽 타입을 먼저 조회한 후 쿼리를 실행합니다(추가 쿼리 1회 발생):

use Illuminate\Database\Eloquent\Builder; $comments = Comment::whereHasMorph('commentable', '*', function (Builder $query) { $query->where('title', 'like', 'foo%'); })->get();

관련 모델 집계

관련 모델 수 집계

관련 모델을 실제로 로드하지 않고 개수만 확인하고 싶을 때는 withCount 메서드를 사용합니다. 이 메서드는 결과 모델에 {관계명}_count 속성을 추가합니다.

use App\Models\Post; $posts = Post::withCount('comments')->get(); foreach ($posts as $post) { echo $post->comments_count; }

배열을 전달하면 여러 관계의 개수를 한 번에 가져올 수 있으며, 각 관계에 쿼리 조건을 추가하는 것도 가능합니다.

use Illuminate\Database\Eloquent\Builder; $posts = Post::withCount(['votes', 'comments' => function (Builder $query) { $query->where('content', 'like', 'code%'); }])->get(); echo $posts[0]->votes_count; echo $posts[0]->comments_count;

동일한 관계에 대해 여러 조건으로 집계하려면 별칭(alias)을 지정할 수 있습니다.

use Illuminate\Database\Eloquent\Builder; $posts = Post::withCount([ 'comments', 'comments as pending_comments_count' => function (Builder $query) { $query->where('approved', false); }, ])->get(); echo $posts[0]->comments_count; echo $posts[0]->pending_comments_count;

지연 카운트 로딩

부모 모델을 이미 조회한 후에 관계 개수를 불러오려면 loadCount 메서드를 사용합니다.

$book = Book::first(); $book->loadCount('genres');

쿼리 조건이 필요한 경우에는 배열로 클로저를 전달할 수 있습니다.

$book->loadCount(['reviews' => function (Builder $query) { $query->where('rating', 5); }])

withCount와 select 함께 사용하기

withCountselect와 함께 사용할 때는 반드시 select 이후에 withCount를 호출해야 합니다. 순서가 바뀌면 카운트 컬럼이 누락될 수 있습니다.

$posts = Post::select(['title', 'body']) ->withCount('comments') ->get();

기타 집계 함수

withCount 외에도 Eloquent는 withMin, withMax, withAvg, withSum, withExists 메서드를 제공합니다. 이 메서드들은 결과 모델에 {관계명}_{함수명}_{컬럼명} 형식의 속성을 추가합니다.

use App\Models\Post; $posts = Post::withSum('comments', 'votes')->get(); foreach ($posts as $post) { echo $post->comments_sum_votes; }

결과 속성에 별칭을 지정할 수도 있습니다.

$posts = Post::withSum('comments as total_comments', 'votes')->get(); foreach ($posts as $post) { echo $post->total_comments; }

loadCount와 마찬가지로, 이미 조회된 모델에 대해 지연 방식으로 집계를 수행하는 메서드도 제공됩니다.

$post = Post::first(); $post->loadSum('comments', 'votes');

select와 함께 사용할 때는 마찬가지로 select 이후에 집계 메서드를 호출해야 합니다.

$posts = Post::select(['title', 'body']) ->withExists('comments') ->get();

Morph To 관계에서의 관련 모델 수 집계

"morph to" 관계를 이거 로딩하면서, 해당 관계가 반환할 수 있는 다양한 모델 유형별로 관련 모델 개수까지 함께 가져오려면 with 메서드와 morphTo 관계의 morphWithCount 메서드를 조합해서 사용합니다.

예를 들어, PhotoPost 모델이 모두 ActivityFeed 모델을 생성할 수 있다고 가정해 봅시다. ActivityFeed 모델에는 부모 Photo 또는 Post를 가져오는 parentable이라는 "morph to" 관계가 정의되어 있습니다. 또한 Photo 모델은 여러 Tag를, Post 모델은 여러 Comment를 가지고 있습니다.

이때 ActivityFeed 목록을 조회하면서 각 부모 모델(Photo 또는 Post)과 함께, 사진에 연결된 태그 수와 게시글에 연결된 댓글 수를 한 번에 가져오려면 다음과 같이 작성합니다.

use Illuminate\Database\Eloquent\Relations\MorphTo; $activities = ActivityFeed::with([ 'parentable' => function (MorphTo $morphTo) { $morphTo->morphWithCount([ Photo::class => ['tags'], Post::class => ['comments'], ]); }])->get();

지연 카운트 로딩

이미 ActivityFeed 모델 컬렉션을 조회한 상태에서 각 parentable 모델 유형별 관련 모델 개수를 나중에 불러오려면 loadMorphCount 메서드를 사용합니다.

$activities = ActivityFeed::with('parentable')->get(); $activities->loadMorphCount('parentable', [ Photo::class => ['tags'], Post::class => ['comments'], ]);

Eager Loading (즉시 로딩)

Eloquent 관계를 프로퍼티로 접근하면 관련 모델은 "지연 로딩(lazy loading)"됩니다. 즉, 해당 프로퍼티에 실제로 접근하기 전까지는 관계 데이터를 불러오지 않습니다. 반면 **즉시 로딩(eager loading)**을 사용하면 부모 모델을 쿼리할 때 관련 모델을 함께 불러올 수 있습니다. 즉시 로딩은 "N + 1" 쿼리 문제를 해결하는 핵심 수단입니다.

N + 1 문제를 살펴보겠습니다. Book 모델이 Author 모델에 속한다고 가정해봅시다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class Book extends Model { /** * 책의 저자를 반환합니다. */ public function author(): BelongsTo { return $this->belongsTo(Author::class); } }

이제 모든 책과 저자를 출력해보겠습니다.

use App\Models\Book; $books = Book::all(); foreach ($books as $book) { echo $book->author->name; }

이 코드는 먼저 모든 책을 가져오는 쿼리를 실행하고, 각 책마다 저자를 가져오는 쿼리를 추가로 실행합니다. 책이 25권이라면 총 26번의 쿼리(책 목록 1회 + 저자 조회 25회)가 실행됩니다.

with 메서드를 사용하면 이를 단 2번의 쿼리로 줄일 수 있습니다.

$books = Book::with('author')->get(); foreach ($books as $book) { echo $book->author->name; }

실행되는 쿼리는 다음 두 가지뿐입니다.

select * from books select * from authors where id in (1, 2, 3, 4, 5, ...)

여러 관계 즉시 로딩

여러 관계를 동시에 즉시 로딩하려면 with 메서드에 배열로 전달하면 됩니다.

$books = Book::with(['author', 'publisher'])->get();

중첩 즉시 로딩

관계의 관계를 즉시 로딩하려면 "점(dot)" 문법을 사용합니다. 예를 들어, 책의 저자와 저자의 연락처를 함께 불러오려면 다음과 같이 작성합니다.

$books = Book::with('author.contacts')->get();

중첩된 즉시 로딩이 여러 개일 때는 중첩 배열 형태로 전달하면 더 가독성이 좋습니다.

$books = Book::with([ 'author' => [ 'contacts', 'publisher', ], ])->get();

morphTo 관계의 중첩 즉시 로딩

morphTo 관계와 그 관계에서 반환될 수 있는 다양한 모델의 중첩 관계를 함께 즉시 로딩하려면, with 메서드와 morphTo 관계의 morphWith 메서드를 조합합니다. 아래 모델을 예시로 살펴보겠습니다.

<?php use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo; class ActivityFeed extends Model { /** * 활동 피드 레코드의 부모를 반환합니다. */ public function parentable(): MorphTo { return $this->morphTo(); } }

이 예시에서 Event, Photo, Post 모델이 ActivityFeed 모델을 생성할 수 있다고 가정합니다. 또한 EventCalendar에 속하고, PhotoTag와 연관되며, PostAuthor에 속한다고 가정합니다.

이 관계 구조에서 ActivityFeed 인스턴스를 가져오면서 모든 parentable 모델과 각각의 중첩 관계를 즉시 로딩하려면 다음과 같이 작성합니다.

use Illuminate\Database\Eloquent\Relations\MorphTo; $activities = ActivityFeed::query() ->with(['parentable' => function (MorphTo $morphTo) { $morphTo->morphWith([ Event::class => ['calendar'], Photo::class => ['tags'], Post::class => ['author'], ]); }])->get();

특정 컬럼만 즉시 로딩

관계를 불러올 때 모든 컬럼이 필요하지 않은 경우, 가져올 컬럼을 직접 지정할 수 있습니다.

$books = Book::with('author:id,name,book_id')->get();

WARNING

이 기능을 사용할 때는 반드시 id 컬럼과 관련 외래 키 컬럼을 목록에 포함해야 합니다. 이를 생략하면 Eloquent가 관계를 올바르게 연결하지 못합니다.

기본 즉시 로딩 설정

특정 관계를 모델 조회 시 항상 즉시 로딩하고 싶다면, 모델에 $with 프로퍼티를 정의합니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class Book extends Model { /** * 항상 즉시 로딩할 관계 목록입니다. * * @var array */ protected $with = ['author']; /** * 책의 저자를 반환합니다. */ public function author(): BelongsTo { return $this->belongsTo(Author::class); } /** * 책의 장르를 반환합니다. */ public function genre(): BelongsTo { return $this->belongsTo(Genre::class); } }

특정 쿼리에서만 $with에 정의된 관계를 제외하고 싶다면 without 메서드를 사용합니다.

$books = Book::without('author')->get();

$with에 정의된 모든 관계를 무시하고 다른 관계만 로딩하려면 withOnly 메서드를 사용합니다.

$books = Book::withOnly('genre')->get();

즉시 로딩 조건 추가

즉시 로딩 시 추가 쿼리 조건을 지정하고 싶다면, with 메서드에 관계 이름을 키로, 클로저를 값으로 하는 배열을 전달합니다.

use App\Models\User; $users = User::with(['posts' => function ($query) { $query->where('title', 'like', '%코드%'); }])->get();

이 예시에서는 title에 "코드"가 포함된 게시글만 즉시 로딩합니다. 쿼리 빌더의 다른 메서드도 자유롭게 활용할 수 있습니다.

$users = User::with(['posts' => function ($query) { $query->orderBy('created_at', 'desc'); }])->get();

morphTo 관계의 즉시 로딩 조건 추가

morphTo 관계를 즉시 로딩할 때 각 관련 모델 타입별로 쿼리 조건을 추가하려면 MorphTo 관계의 constrain 메서드를 사용합니다.

use Illuminate\Database\Eloquent\Relations\MorphTo; $comments = Comment::with(['commentable' => function (MorphTo $morphTo) { $morphTo->constrain([ Post::class => function ($query) { $query->whereNull('hidden_at'); }, Video::class => function ($query) { $query->where('type', 'educational'); }, ]); }])->get();

이 예시에서는 숨겨지지 않은 게시글(hidden_at이 null)과 타입이 "educational"인 동영상만 즉시 로딩합니다.

관계 존재 여부를 조건으로 한 즉시 로딩

관계의 존재 여부를 확인하면서 동시에 해당 조건에 맞는 관련 모델을 즉시 로딩하고 싶을 때는 withWhereHas 메서드를 사용합니다. 예를 들어, featuredtrue인 게시글이 있는 사용자만 가져오면서 해당 게시글도 함께 로딩하려면 다음과 같이 작성합니다.

use App\Models\User; $users = User::withWhereHas('posts', function ($query) { $query->where('featured', true); })->get();

지연 즉시 로딩 (Lazy Eager Loading)

부모 모델을 이미 조회한 후에 관계를 즉시 로딩해야 하는 경우가 있습니다. 예를 들어, 특정 조건에 따라 관련 모델을 동적으로 로딩해야 할 때 유용합니다.

use App\Models\Book; $books = Book::all(); if ($condition) { $books->load('author', 'publisher'); }

load 메서드에도 클로저로 추가 조건을 지정할 수 있습니다.

$author->load(['books' => function ($query) { $query->orderBy('published_date', 'asc'); }]);

아직 로딩되지 않은 관계만 로딩하려면 loadMissing 메서드를 사용합니다. 이미 로딩된 관계는 다시 쿼리하지 않습니다.

$book->loadMissing('author');

morphTo 관계의 중첩 지연 즉시 로딩

morphTo 관계와 그 중첩 관계를 이미 조회한 컬렉션에서 로딩하려면 loadMorph 메서드를 사용합니다. 첫 번째 인수로 morphTo 관계명을, 두 번째 인수로 모델/관계 쌍의 배열을 전달합니다.

앞서 살펴본 ActivityFeed 모델을 기준으로 예시를 확인해보겠습니다.

<?php use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo; class ActivityFeed extends Model { /** * 활동 피드 레코드의 부모를 반환합니다. */ public function parentable(): MorphTo { return $this->morphTo(); } }

Event, Photo, Post 모델이 각각 Calendar, Tag, Author와 연관된 구조에서, 이미 조회한 컬렉션에 중첩 관계를 로딩하는 방법은 다음과 같습니다.

$activities = ActivityFeed::with('parentable') ->get() ->loadMorph('parentable', [ Event::class => ['calendar'], Photo::class => ['tags'], Post::class => ['author'], ]);

자동 즉시 로딩

Laravel은 접근하는 관계를 자동으로 즉시 로딩할 수 있습니다. 이 기능을 활성화하려면 AppServiceProviderboot 메서드에서 Model::automaticallyEagerLoadRelationships를 호출합니다.

use Illuminate\Database\Eloquent\Model; /** * 애플리케이션 서비스를 부트스트랩합니다. */ public function boot(): void { Model::automaticallyEagerLoadRelationships(); }

이 기능이 활성화되면, Laravel은 아직 로딩되지 않은 관계에 접근할 때 자동으로 해당 컬렉션 전체에 대해 관계를 로딩합니다. 예를 들어 아래 코드를 살펴보겠습니다.

use App\Models\User; $users = User::all(); foreach ($users as $user) { foreach ($user->posts as $post) { foreach ($post->comments as $comment) { echo $comment->content; } } }

자동 즉시 로딩이 없으면 각 사용자마다 게시글 쿼리가, 각 게시글마다 댓글 쿼리가 실행됩니다. 자동 즉시 로딩이 활성화되면, 첫 번째 사용자의 posts에 접근하는 시점에 모든 사용자의 게시글을 한꺼번에 로딩하고, 첫 번째 게시글의 comments에 접근하는 시점에 모든 게시글의 댓글을 한꺼번에 로딩합니다.

전역으로 활성화하지 않고 특정 컬렉션에만 적용하려면 withRelationshipAutoloading 메서드를 사용합니다.

$users = User::where('vip', true)->get(); return $users->withRelationshipAutoloading();

지연 로딩 방지

즉시 로딩은 성능에 큰 영향을 미칩니다. 실수로 지연 로딩이 발생하는 것을 방지하고 싶다면, preventLazyLoading 메서드를 사용할 수 있습니다. AppServiceProviderboot 메서드에서 호출하는 것이 일반적입니다.

보통 운영 환경에서는 설정을 강제하지 않고, 개발·스테이징 환경에서만 활성화합니다.

use Illuminate\Database\Eloquent\Model; /** * 애플리케이션 서비스를 부트스트랩합니다. */ public function boot(): void { Model::preventLazyLoading(! $this->app->isProduction()); }

지연 로딩이 방지된 상태에서 관계를 지연 로딩하려 하면 Illuminate\Database\LazyLoadingViolationException 예외가 발생합니다.

예외 대신 로그만 기록하는 등 위반 시 동작을 커스터마이즈하려면 handleLazyLoadingViolationUsing 메서드를 활용합니다.

Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation) { $class = $model::class; info("모델 [{$class}]에서 [{$relation}] 관계를 지연 로딩하려 했습니다."); });

NOTE

지연 로딩 방지는 개발 단계에서 N+1 문제를 조기에 발견하는 데 매우 효과적입니다. 로컬 환경에서 활성화해두면 코드 리뷰 전에 성능 문제를 미리 잡을 수 있습니다.

Eloquent 관계

관련 모델 삽입 및 업데이트

save 메서드

Eloquent는 관계에 새 모델을 추가할 때 편리한 메서드를 제공합니다. 예를 들어, 게시글에 새 댓글을 추가해야 한다고 가정해 봅시다. Comment 모델에 직접 post_id를 지정하는 대신, 관계의 save 메서드를 사용하면 됩니다:

use App\Models\Comment; use App\Models\Post; $comment = new Comment(['message' => '새로운 댓글입니다.']); $post = Post::find(1); $post->comments()->save($comment);

여기서 주의할 점은 $post->comments(동적 프로퍼티)가 아니라 $post->comments()(메서드 호출)를 사용한다는 것입니다. save 메서드는 새 Comment 모델에 적절한 post_id 값을 자동으로 설정합니다.

여러 관련 모델을 한 번에 저장하려면 saveMany 메서드를 사용하세요:

$post = Post::find(1); $post->comments()->saveMany([ new Comment(['message' => '첫 번째 댓글입니다.']), new Comment(['message' => '두 번째 댓글입니다.']), ]);

savesaveMany는 모델을 데이터베이스에 저장하지만, 이미 메모리에 로드된 부모 모델의 관계 컬렉션에는 새 모델이 자동으로 추가되지 않습니다. 저장 후 관계 데이터에 접근할 계획이라면, refresh 메서드로 모델과 관계를 새로 고침하세요:

$post->comments()->save($comment); $post->refresh(); // 새로 저장된 댓글을 포함한 전체 댓글 목록 $post->comments;

모델과 관계를 재귀적으로 저장하기

모델과 연결된 모든 관계를 한 번에 저장하려면 push 메서드를 사용하세요. 아래 예시에서는 Post 모델뿐만 아니라 댓글과 댓글 작성자까지 함께 저장됩니다:

$post = Post::find(1); $post->comments[0]->message = '수정된 메시지'; $post->comments[0]->author->name = '작성자 이름'; $post->push();

이벤트를 발생시키지 않고 모델과 관계를 저장하려면 pushQuietly 메서드를 사용하세요:

$post->pushQuietly();

create 메서드

savesaveMany 외에도 create 메서드를 사용할 수 있습니다. create는 속성 배열을 받아 모델을 생성하고 데이터베이스에 저장한 뒤, 생성된 모델을 반환합니다. save는 Eloquent 모델 인스턴스를 받는 반면, create는 일반 PHP 배열을 받는다는 차이가 있습니다:

use App\Models\Post; $post = Post::find(1); $comment = $post->comments()->create([ 'message' => '새로운 댓글입니다.', ]);

여러 관련 모델을 한 번에 생성하려면 createMany 메서드를 사용하세요:

$post = Post::find(1); $post->comments()->createMany([ ['message' => '첫 번째 댓글입니다.'], ['message' => '두 번째 댓글입니다.'], ]);

이벤트를 발생시키지 않고 모델을 생성하려면 createQuietlycreateManyQuietly 메서드를 사용하세요:

$user = User::find(1); $user->posts()->createQuietly([ 'title' => '게시글 제목', ]); $user->posts()->createManyQuietly([ ['title' => '첫 번째 게시글'], ['title' => '두 번째 게시글'], ]);

관계에서 모델을 생성하거나 업데이트할 때는 findOrNew, firstOrNew, firstOrCreate, updateOrCreate 메서드도 활용할 수 있습니다. 자세한 내용은 Upsert 문서를 참고하세요.

NOTE

create 메서드를 사용하기 전에 대량 할당(mass assignment) 문서를 반드시 확인하세요.

Belongs To 관계

자식 모델을 새로운 부모 모델에 연결하려면 associate 메서드를 사용하세요. 아래 예시에서 User 모델은 Account 모델에 대해 belongsTo 관계를 가집니다. associate 메서드는 자식 모델의 외래 키를 설정합니다:

use App\Models\Account; $account = Account::find(10); $user->account()->associate($account); $user->save();

자식 모델에서 부모 모델의 연결을 해제하려면 dissociate 메서드를 사용하세요. 이 메서드는 외래 키를 null로 설정합니다:

$user->account()->dissociate(); $user->save();

다대다(Many to Many) 관계

연결(Attach) / 해제(Detach)

Eloquent는 다대다 관계를 다루기 위한 편리한 메서드도 제공합니다. 예를 들어, 사용자가 여러 역할을 가질 수 있고, 역할도 여러 사용자를 가질 수 있다고 가정해 봅시다. attach 메서드를 사용하면 중간 테이블에 레코드를 추가하여 역할을 사용자에게 연결할 수 있습니다:

use App\Models\User; $user = User::find(1); $user->roles()->attach($roleId);

관계를 연결할 때 중간 테이블에 추가 데이터를 함께 저장할 수도 있습니다:

$user->roles()->attach($roleId, ['expires' => $expires]);

사용자에서 역할을 제거하려면 detach 메서드를 사용하세요. 중간 테이블의 레코드만 삭제되며, 두 모델 자체는 데이터베이스에 그대로 남습니다:

// 특정 역할 하나만 해제 $user->roles()->detach($roleId); // 모든 역할 해제 $user->roles()->detach();

attachdetach 모두 ID 배열을 받을 수 있어 여러 항목을 한 번에 처리할 수 있습니다:

$user = User::find(1); $user->roles()->detach([1, 2, 3]); $user->roles()->attach([ 1 => ['expires' => $expires], 2 => ['expires' => $expires], ]);

동기화(Sync)

sync 메서드를 사용하면 다대다 관계를 특정 ID 목록과 동기화할 수 있습니다. sync에 전달한 배열에 없는 ID는 중간 테이블에서 제거되고, 배열에 있는 ID만 중간 테이블에 남게 됩니다:

$user->roles()->sync([1, 2, 3]);

ID와 함께 중간 테이블 추가 값을 전달할 수도 있습니다:

$user->roles()->sync([1 => ['expires' => true], 2, 3]);

동기화하는 모든 모델 ID에 동일한 중간 테이블 값을 적용하려면 syncWithPivotValues 메서드를 사용하세요:

$user->roles()->syncWithPivotValues([1, 2, 3], ['active' => true]);

배열에 없는 기존 ID를 제거하지 않고 새 ID만 추가하려면 syncWithoutDetaching 메서드를 사용하세요:

$user->roles()->syncWithoutDetaching([1, 2, 3]);

토글(Toggle)

다대다 관계에서는 toggle 메서드도 제공합니다. 주어진 ID가 현재 연결되어 있으면 해제하고, 해제되어 있으면 연결합니다. 스위치처럼 상태를 반전시키는 메서드입니다:

$user->roles()->toggle([1, 2, 3]);

ID와 함께 중간 테이블 추가 값을 전달할 수도 있습니다:

$user->roles()->toggle([ 1 => ['expires' => true], 2 => ['expires' => true], ]);

트랜잭션 피벗 작업

위에서 설명한 피벗 작업들은 모두 OrFail 변형 메서드를 제공합니다(attachOrFail, detachOrFail, syncOrFail, syncWithoutDetachingOrFail, toggleOrFail). 이 메서드들은 작업을 데이터베이스 트랜잭션으로 감싸므로, 예외가 발생하면 모든 변경이 자동으로 롤백됩니다:

$user->roles()->attachOrFail([1, 2, 3]); $user->roles()->syncOrFail([1, 2, 3]);

중간 테이블 레코드 업데이트

관계의 중간 테이블에 있는 기존 레코드를 업데이트하려면 updateExistingPivot 메서드를 사용하세요. 이 메서드는 중간 테이블의 외래 키와 업데이트할 속성 배열을 인자로 받습니다:

$user = User::find(1); $user->roles()->updateExistingPivot($roleId, [ 'active' => false, ]);

부모 모델 타임스탬프 갱신 (Touching Parent Timestamps)

belongsTo 또는 belongsToMany 관계를 통해 부모 모델에 속한 자식 모델이 있을 때, 자식 모델이 수정되면 부모 모델의 updated_at 타임스탬프도 함께 갱신하고 싶은 경우가 있습니다.

예를 들어 Comment 모델이 수정될 때, 해당 댓글이 속한 Postupdated_at도 자동으로 현재 시각으로 업데이트되길 원할 수 있습니다. 이럴 때는 자식 모델에 Touches 어트리뷰트를 추가하고, 함께 갱신할 관계명을 지정하면 됩니다.

<?php namespace App\Models; use Illuminate\Database\Eloquent\Attributes\Touches; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; #[Touches(['post'])] class Comment extends Model { /** * 댓글이 속한 게시글을 반환합니다. */ public function post(): BelongsTo { return $this->belongsTo(Post::class); } }

이제 Comment 모델을 저장하면, 연결된 Postupdated_at도 자동으로 갱신됩니다. 예를 들어 게시글 목록을 최근 활동 순으로 정렬할 때, 댓글이 달린 게시글이 상단에 노출되도록 하는 용도로 유용하게 활용할 수 있습니다.

WARNING

부모 모델의 타임스탬프는 자식 모델을 Eloquent의 save 메서드로 저장할 때만 갱신됩니다. DB::table(...) 등을 통한 직접 쿼리 업데이트에서는 동작하지 않습니다.

이 문서는 Laravel 공식 문서(MIT)를 한국 개발자를 위해 번역·재구성한 것입니다.

번역일: 2026년 8월 4일