Eloquent: 관계
업데이트됨번역일: 2026년 9월 30일
이 페이지는 원문이 업데이트되어 번역이 갱신되었습니다.
- 원문 수정
- 2026년 9월 30일
- 번역 갱신
- 2026년 9월 30일
Eloquent: 관계
소개
데이터베이스 테이블은 서로 연관되어 있는 경우가 많습니다. 예를 들어 블로그 글 하나에는 여러 개의 댓글이 달릴 수 있고, 주문 하나는 그 주문을 생성한 사용자와 연결되어 있을 수 있습니다. Eloquent는 이런 관계들을 손쉽게 다룰 수 있도록 지원하며, 다양한 종류의 관계를 처리할 수 있습니다.
- 일대일
- 일대다
- 다대다
- 단일 다형 관계 (Has One Through)
- 다중 다형 관계 (Has Many Through)
- 일대일 (다형)
- 일대다 (다형)
- 다수 중 하나 (다형)
- 다대다 (다형)
관계 정의하기
Eloquent의 관계는 Eloquent 모델 클래스 안에 메서드로 정의합니다. 관계는 강력한 쿼리 빌더 역할도 겸하기 때문에, 메서드로 관계를 정의하면 강력한 메서드 체이닝과 쿼리 기능을 그대로 활용할 수 있습니다. 예를 들어 다음과 같이 posts 관계에 추가 쿼리 조건을 체이닝할 수 있습니다.
$user->posts()->where('active', 1)->get();관계를 자세히 다루기에 앞서, Eloquent가 지원하는 각 관계 유형을 정의하는 방법부터 살펴보겠습니다.
일대일 / Has One
일대일 관계는 가장 기본적인 형태의 데이터베이스 관계입니다. 예를 들어 User 모델 하나는 Phone 모델 하나와 연결될 수 있습니다. 이 관계를 정의하려면 User 모델에 phone 메서드를 추가합니다. 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 컬럼 값이 Phone 레코드의 user_id 컬럼에 있다고 가정하는 것입니다. 만약 관계에서 id가 아닌 다른 기본 키 값, 혹은 모델의 $primaryKey 프로퍼티와 다른 값을 사용하고 싶다면, hasOne 메서드에 세 번째 인수를 전달할 수 있습니다.
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 컬럼이 있다고 가정하는 것입니다. 하지만 만약 Phone 모델의 외래 키가 user_id가 아니라면, belongsTo 메서드의 두 번째 인수로 사용자 지정 키 이름을 전달할 수 있습니다.
/**
* 이 전화번호를 소유한 사용자를 가져옵니다.
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'foreign_key');
}부모 모델이 id를 기본 키로 사용하지 않거나, 다른 컬럼을 이용해 연관된 모델을 찾고 싶다면 belongsTo 메서드에 세 번째 인수로 부모 테이블의 사용자 지정 키를 전달할 수 있습니다.
/**
* 이 전화번호를 소유한 사용자를 가져옵니다.
*/
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 프로퍼티에 접근하여 컬렉션의 형태로 관련된 댓글들을 조회할 수 있습니다. Eloquent의 "동적 관계 프로퍼티" 덕분에, 마치 모델에 정의된 프로퍼티인 것처럼 관계 메서드에 접근할 수 있다는 점을 기억하세요.
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');일대다 (역방향) / 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);
}
}관계를 정의한 후에는 post라는 "동적 관계 프로퍼티"에 접근하여 댓글이 속한 Post 모델을 조회할 수 있습니다.
use App\Models\Comment;
$comment = Comment::find(1);
return $comment->post->title;위 예제에서 Eloquent는 Comment 모델의 post_id 컬럼과 일치하는 id를 가진 Post 모델을 찾으려고 시도합니다.
Eloquent는 관계 메서드의 이름을 확인하고, 메서드 이름 뒤에 _와 부모 테이블의 기본 키 컬럼명을 붙여서 기본 외래 키 이름을 결정합니다. 즉, 이 예제에서 Eloquent는 Comment 테이블에서 Post 모델과 연결하는 외래 키가 post_id라고 가정합니다.
하지만 이 관계의 외래 키가 이런 관례를 따르지 않는다면, belongsTo 메서드의 두 번째 인수로 사용자 지정 외래 키 이름을 전달할 수 있습니다.
/**
* 이 댓글이 속한 게시글을 가져옵니다.
*/
public function post(): BelongsTo
{
return $this->belongsTo(Post::class, 'foreign_key');
}부모 모델이 id를 기본 키로 사용하지 않거나, 다른 컬럼을 이용해 연관된 모델을 찾고 싶다면 belongsTo 메서드에 세 번째 인수로 부모 테이블의 사용자 지정 키를 지정할 수 있습니다.
/**
* 이 댓글이 속한 게시글을 가져옵니다.
*/
public function post(): BelongsTo
{
return $this->belongsTo(Post::class, 'foreign_key', 'owner_key');
}기본 모델 지정하기
belongsTo, hasOne, hasOneThrough, morphOne 관계는 지정된 관계가 null일 경우 반환할 기본 모델을 지정할 수 있도록 withDefault 메서드를 제공합니다. 이 패턴은 흔히 Null 객체 패턴이라고 불리며, 코드에서 조건문을 줄이는 데 도움을 줍니다. 다음 예제에서는 Post 모델에 사용자가 연결되어 있지 않을 경우 빈 App\Models\User 모델을 반환하도록 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 조건절을 만들어 해당하는 Eloquent 모델을 조회할 수도 있습니다.
use App\Models\Post;
$posts = Post::where('user_id', $user->id)->get();하지만 이런 방식보다는 whereBelongsTo 메서드를 사용하는 것이 더 편리할 수 있습니다. whereBelongsTo는 주어진 모델에 맞는 올바른 관계와 외래 키를 자동으로 결정해줍니다.
$posts = Post::whereBelongsTo($user)->get();또한 whereBelongsTo 메서드에는 컬렉션 인스턴스를 전달할 수도 있습니다. 이 경우 Laravel은 컬렉션 안에 있는 부모 모델들 중 하나에 속하는 모든 모델을 조회합니다.
$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();
}마찬가지로, 관계상 가장 "오래된" 즉 최초의 관련 모델을 조회하는 메서드도 정의할 수 있습니다.
/**
* 사용자의 가장 오래된 주문을 가져옵니다.
*/
public function oldestOrder(): HasOne
{
return $this->hasOne(Order::class)->oldestOfMany();
}기본적으로 latestOfMany와 oldestOfMany 메서드는 모델의 기본 키(정렬 가능해야 함)를 기준으로 최신 또는 가장 오래된 관련 모델을 조회합니다. 하지만 다른 정렬 기준을 이용해서 다수의 관계 중 하나의 모델만 조회하고 싶을 때도 있을 것입니다.
예를 들어 ofMany 메서드를 사용하면, 사용자의 주문 중 가장 가격이 높은 주문을 조회할 수 있습니다. ofMany 메서드는 첫 번째 인수로 정렬 기준이 되는 컬럼을, 두 번째 인수로는 관련 모델을 조회할 때 사용할 집계 함수(min 또는 max)를 받습니다.
/**
* 사용자의 가장 큰 주문을 가져옵니다.
*/
public function largestOrder(): HasOne
{
return $this->hasOne(Order::class)->ofMany('price', 'max');
}NOTE
PostgreSQL은 UUID 컬럼에 대해 MAX 함수를 실행하는 것을 지원하지 않기 때문에, PostgreSQL의 UUID 컬럼과 다수 중 하나(one-of-many) 관계를 함께 사용할 수 없습니다.
"다수" 관계를 "Has One" 관계로 변환하기
보통 latestOfMany, oldestOfMany, ofMany 메서드를 사용해 "다수 중 하나" 관계를 조회할 때는, 이미 같은 모델에 대해 정의된 "has many" 관계가 있는 경우가 많습니다. 편의를 위해 Laravel은 관계 인스턴스의 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 관계로 변환할 수도 있습니다.
고급 "다수 중 하나" 관계 활용
더 복잡한 "다수 중 하나" 관계를 만드는 것도 가능합니다. 예를 들어 Product 모델이 여러 Price 모델을 가지고 있는데, 새로운 가격 정보가 등록되더라도 이전 가격 데이터는 미래 시점을 위해 시스템에 그대로 남아 있는 경우를 생각해봅시다.
또한 제품에 대해 published_at 컬럼 값이 미래인, 즉 아직 유효하지 않은 새로운 가격 데이터가 미리 등록되어 있을 수도 있습니다. 즉, 발행일이 미래가 아니면서 가장 최근에 발행된 가격을 조회해야 합니다. 게다가 같은 발행일을 가진 가격이 두 개 있다면, ID가 더 큰 가격을 우선으로 선택해야 합니다.
이를 구현하려면 ofMany 메서드에 정렬 기준이 되는 컬럼들의 배열을 전달해야 합니다. 그리고 관계에 whereClosure를 통해 published_at이 미래가 아니어야 한다는 조건을 추가해줍니다.
/**
* 제품의 현재 가격을 가져옵니다.
*/
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 모델과 연결되고, 각 Car 모델은 하나의 Owner 모델과 연결되어 있다고 가정해봅시다. 정비사와 자동차 소유자는 데이터베이스상 직접적인 관계를 맺고 있지 않지만, 정비사는 자동차를 통해서 소유자에게 접근할 수 있습니다. 이 관계에 필요한 테이블 구조를 살펴보겠습니다.
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 메서드를 사용하고 그 관계에 이름을 부여하는 방식으로 "has-one-through" 관계를 유창하게(fluent하게) 정의할 수도 있습니다. 예를 들어 Mechanic 모델이 cars 관계를, Car 모델이 owner 관계를 가지고 있다면, 자동차를 거쳐 정비사와 소유자를 연결하는 "has-one-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 메서드를 사용해 유창하게 "has-one-through" 관계를 정의할 수 있습니다. 이 방식은 키 규칙을 재사용할 수 있다는 장점이 있습니다.
// 문자열 기반 문법...
return $this->through('cars')->has('owner');
// 동적 문법...
return $this->throughCars()->hasOwner();Has Many Through (다중 다형 관계)
"has-many-through" 관계는 다른 모델과의 원거리(distant) 일대다 관계를 편리하게 정의할 수 있는 방법을 제공합니다. 예를 들어 Laravel Cloud와 같은 배포 플랫폼을 만든다고 가정해보겠습니다. 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 메서드를 사용하고 그 관계에 이름을 부여하는 방식으로 "has-many-through" 관계를 유창하게 정의할 수도 있습니다. 예를 들어 Application 모델이 environments 관계를, Environment 모델이 deployments 관계를 가지고 있다면, 환경(environment)을 거쳐 애플리케이션과 배포 내역을 연결하는 "has-many-through" 관계를 다음과 같이 정의할 수 있습니다.
// 문자열 기반 문법...
return $this->through('environments')->has('deployments');
// 동적 문법...
return $this->throughEnvironments()->hasDeployments();이런 배포 관련 예제에서 볼 수 있듯이, Application 모델 자체에는 deployments 테이블이 존재하지 않지만, $application->deployments를 통해 애플리케이션의 배포 내역에 접근할 수 있습니다. 이 관계를 조회하기 위해, Eloquent는 중간에 있는 Environment 모델의 테이블을 확인합니다. 필요한 데이터를 찾은 후, Eloquent는 그 결과를 이용해 Deployment 테이블을 쿼리합니다.
관계 흐름을 다음과 같이 정리할 수 있습니다.
키 규칙
관계를 조회할 때 일반적인 Eloquent 외래 키 규칙이 사용됩니다. 관계의 키를 사용자 지정하고 싶다면 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 메서드를 사용해 유창하게 "has-many-through" 관계를 정의할 수 있습니다. 이 방식은 키 규칙을 재사용할 수 있다는 장점이 있습니다.
// 문자열 기반 문법...
return $this->through('environments')->has('deployments');
// 동적 문법...
return $this->throughEnvironments()->hasDeployments();Eloquent 관계
소개
데이터베이스 테이블은 서로 관계를 맺고 있는 경우가 많습니다. 예를 들어 블로그 게시글에는 여러 개의 댓글이 달릴 수 있고, 주문은 그 주문을 넣은 사용자와 연결되어 있습니다. Eloquent는 이러한 관계를 손쉽게 정의하고 다룰 수 있도록 도와주며, 다음과 같은 다양한 관계 유형을 지원합니다.
Eloquent 관계
관계 정의하기
Eloquent 관계는 Eloquent 모델 클래스에 메서드 형태로 정의합니다. 관계는 동시에 강력한 쿼리 빌더 역할도 하기 때문에, 메서드로 정의해두면 메서드 체이닝을 통해 유연하게 쿼리를 이어갈 수 있다는 장점이 있습니다. 예를 들어 다음과 같이 posts 관계에 추가 조건을 체이닝할 수 있습니다.
$user->posts()->where('active', 1)->get();본격적으로 관계를 활용하기 전에, Eloquent가 지원하는 관계 유형들을 하나씩 정의하는 방법부터 살펴보겠습니다.
1:1 관계 / hasOne
1:1 관계는 데이터베이스 관계 중 가장 기본적인 형태입니다. 예를 들어 User 모델 하나는 하나의 Phone 모델과 연결될 수 있습니다. 이 관계를 정의하려면 User 모델에 phone 메서드를 추가하면 됩니다. 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의 동적 프로퍼티(dynamic property)를 통해 연관된 레코드를 가져올 수 있습니다. 동적 프로퍼티를 사용하면 관계 메서드를 마치 모델에 정의된 프로퍼티처럼 접근할 수 있습니다.
$phone = User::find(1)->phone;Eloquent는 부모 모델의 이름을 기준으로 관계의 외래 키(foreign key)를 자동으로 결정합니다. 이 예제에서는 Phone 모델에 user_id라는 외래 키가 있다고 자동으로 가정합니다. 이 규칙을 변경하고 싶다면 hasOne 메서드의 두 번째 인자로 원하는 외래 키명을 전달하면 됩니다.
return $this->hasOne(Phone::class, 'foreign_key');또한 Eloquent는 외래 키의 값이 부모 모델의 기본 키(primary key) 값과 일치한다고 가정합니다. 즉, Phone 레코드의 user_id 컬럼에서 사용자의 id 값을 찾으려고 합니다. id가 아닌 다른 컬럼을 로컬 키(local key)로 사용하고 싶다면 hasOne 메서드의 세 번째 인자로 전달하면 됩니다.
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 컬럼이 있다고 가정합니다. 하지만 Phone 모델의 외래 키가 user_id가 아니라면, belongsTo 메서드의 두 번째 인자로 원하는 키 이름을 전달할 수 있습니다.
/**
* 이 전화번호를 소유한 사용자를 조회합니다.
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'foreign_key');
}부모 모델의 기본 키가 id가 아니거나, 다른 컬럼을 기준으로 연관 모델을 찾고 싶다면 belongsTo 메서드의 세 번째 인자로 부모 테이블의 커스텀 키를 지정하면 됩니다.
/**
* 이 전화번호를 소유한 사용자를 조회합니다.
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'foreign_key', 'owner_key');
}1:N 관계 / hasMany
1:N 관계는 하나의 부모 모델이 여러 개의 자식 모델을 가질 때 사용합니다. 예를 들어 게시글 하나에는 무수히 많은 댓글이 달릴 수 있습니다. 다른 Eloquent 관계와 마찬가지로, 1:N 관계도 모델에 메서드를 정의하는 방식으로 만듭니다.
<?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 모델의 적절한 외래 키 컬럼을 자동으로 판단합니다. 관례상 부모 모델 이름을 스네이크 케이스(snake case)로 변환한 뒤 _id를 붙입니다. 따라서 이 예제에서는 Comment 모델의 외래 키 컬럼을 post_id로 가정합니다.
관계 메서드를 정의했다면 comments 프로퍼티에 접근해서 연관된 댓글의 컬렉션을 가져올 수 있습니다. Eloquent가 제공하는 "동적 관계 프로퍼티" 덕분에 관계 메서드를 마치 모델에 정의된 프로퍼티처럼 사용할 수 있습니다.
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');자식 모델에 부모 모델 자동으로 채워 넣기
Eloquent의 즉시 로딩(eager loading)을 사용하더라도, 자식 모델을 순회하면서 그 안에서 다시 부모 모델에 접근하면 "N+1" 쿼리 문제가 발생할 수 있습니다.
$posts = Post::with('comments')->get();
foreach ($posts as $post) {
foreach ($post->comments as $comment) {
echo $comment->post->title;
}
}위 예제에서는 Post마다 comments를 즉시 로딩했음에도 불구하고 "N+1" 쿼리 문제가 발생합니다. 왜냐하면 Eloquent가 각 Comment 자식 모델에 부모 Post를 자동으로 채워 넣지 않기 때문입니다.
Eloquent가 부모 모델을 자식 모델에 자동으로 채워 넣도록 하려면, hasMany 관계를 정의할 때 chaperone 메서드를 호출하면 됩니다.
<?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();
}
}또는 관계 정의 시점이 아니라 즉시 로딩을 실행하는 시점에만 이 기능을 사용하고 싶다면, 즉시 로딩 시 chaperone 메서드를 호출하면 됩니다.
use App\Models\Post;
$posts = Post::with([
'comments' => fn ($comments) => $comments->chaperone(),
])->get();NOTE
"N+1" 문제와 즉시 로딩 개념이 처음이라면, 이는 반복문 안에서 매번 새로운 쿼리를 실행하는 대신 필요한 데이터를 미리 한 번에 가져오는 최적화 기법이라고 이해하면 됩니다. chaperone은 여기서 한 걸음 더 나아가, 자식에서 부모로 되돌아가는 접근까지도 추가 쿼리 없이 처리해주는 기능입니다.
1:N 관계 역방향 / belongsTo
이제 게시글의 모든 댓글에 접근할 수 있게 되었으니, 반대로 댓글에서 부모 게시글에 접근할 수 있도록 관계를 정의해보겠습니다. 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);
}
}관계를 정의했다면 "동적 관계 프로퍼티"인 post에 접근해서 댓글의 부모 게시글을 가져올 수 있습니다.
use App\Models\Comment;
$comment = Comment::find(1);
return $comment->post->title;위 예제에서 Eloquent는 Comment 모델의 post_id 컬럼 값과 일치하는 id를 가진 Post 모델을 찾습니다.
Eloquent는 관계 메서드의 이름 뒤에 _와 부모 모델의 기본 키 컬럼명을 붙여서 기본 외래 키 이름을 결정합니다. 따라서 이 예제에서는 comments 테이블에 있는 Post 모델의 외래 키를 post_id로 가정합니다.
하지만 관계의 외래 키가 이 관례를 따르지 않는다면, belongsTo 메서드의 두 번째 인자로 원하는 외래 키 이름을 전달할 수 있습니다.
/**
* 이 댓글이 달린 게시글을 조회합니다.
*/
public function post(): BelongsTo
{
return $this->belongsTo(Post::class, 'foreign_key');
}부모 모델의 기본 키가 id가 아니거나, 다른 컬럼을 기준으로 연관 모델을 찾고 싶다면 belongsTo 메서드의 세 번째 인자로 부모 테이블의 커스텀 키를 지정하면 됩니다.
/**
* 이 댓글이 달린 게시글을 조회합니다.
*/
public function post(): BelongsTo
{
return $this->belongsTo(Post::class, 'foreign_key', 'owner_key');
}기본 모델(Default Models)
belongsTo, hasOne, hasOneThrough, morphOne 관계에서는 연관된 모델이 null일 때 대신 반환할 기본 모델을 정의할 수 있습니다. 이 패턴을 흔히 널 오브젝트 패턴(Null Object pattern)이라고 부르며, 코드 곳곳에 있는 조건문 검사를 줄여줍니다. 아래 예제에서 user 관계는 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' => 'Guest Author',
]);
}
/**
* 게시글의 작성자를 조회합니다.
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class)->withDefault(function (User $user, Post $post) {
$user->name = 'Guest Author';
});
}belongsTo 관계 조회하기
"belongs to" 관계의 자식 모델을 조회할 때, where 절을 직접 작성해서 원하는 Eloquent 모델을 가져올 수도 있습니다.
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();기본적으로 라라벨은 전달된 모델의 클래스명을 기준으로 관계를 유추합니다. 다른 관계명을 사용하고 싶다면 whereBelongsTo 메서드의 두 번째 인자로 관계명을 직접 지정하면 됩니다.
$posts = Post::whereBelongsTo($user, 'author')->get();여러 개 중 하나만 가져오는 hasOne (Has One of Many)
경우에 따라 한 모델이 여러 연관 모델을 가지고 있지만, 그중 "가장 최근" 또는 "가장 오래된" 항목 하나만 편하게 가져오고 싶을 때가 있습니다. 예를 들어 User 모델은 여러 개의 Order 모델과 연관되어 있지만, 사용자가 가장 최근에 주문한 내역 하나만 간편하게 다루고 싶을 수 있습니다. 이럴 때는 hasOne 관계와 ofMany 계열 메서드를 함께 사용하면 됩니다.
/**
* 사용자의 가장 최근 주문을 조회합니다.
*/
public function latestOrder(): HasOne
{
return $this->hasOne(Order::class)->latestOfMany();
}마찬가지로 "가장 오래된", 즉 첫 번째로 생성된 연관 모델을 가져오는 메서드도 정의할 수 있습니다.
/**
* 사용자의 가장 오래된 주문을 조회합니다.
*/
public function oldestOrder(): HasOne
{
return $this->hasOne(Order::class)->oldestOfMany();
}기본적으로 latestOfMany와 oldestOfMany 메서드는 정렬 가능한 기본 키를 기준으로 최신/최초 모델을 가져옵니다. 하지만 때로는 다른 정렬 기준으로 하나의 모델만 골라내고 싶을 수도 있습니다.
예를 들어 ofMany 메서드를 사용하면 사용자의 가장 비싼 주문을 가져올 수 있습니다. ofMany 메서드는 첫 번째 인자로 정렬 기준이 되는 컬럼을, 두 번째 인자로 적용할 집계 함수(min 또는 max)를 받습니다.
/**
* 사용자의 가장 비싼 주문을 조회합니다.
*/
public function largestOrder(): HasOne
{
return $this->hasOne(Order::class)->ofMany('price', 'max');
}WARNING
PostgreSQL은 UUID 컬럼에 대해 MAX 함수 실행을 지원하지 않으므로, PostgreSQL의 UUID 컬럼과 함께 "여러 개 중 하나만 가져오는" 관계를 사용할 수 없습니다.
"N개" 관계를 "hasOne" 관계로 변환하기
latestOfMany, oldestOfMany, ofMany 메서드로 단일 모델을 조회할 때, 이미 같은 모델에 대한 "hasMany" 관계가 정의되어 있는 경우가 많습니다. 이럴 때는 관계에 one 메서드를 호출해서 손쉽게 "hasOne" 관계로 변환할 수 있습니다.
/**
* 사용자의 주문 목록을 조회합니다.
*/
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();
}더 복잡한 "여러 개 중 하나만 가져오는" 관계
좀 더 복잡한 형태의 "여러 개 중 하나만 가져오는" 관계도 구성할 수 있습니다. 예를 들어 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
"has-one-through" 관계는 다른 모델과의 1:1 관계를 정의하지만, 중간에 위치한 세 번째 모델을 거쳐서 다른 모델의 인스턴스 하나와 연결된다는 점이 특징입니다.
예를 들어 자동차 정비소 애플리케이션에서, 각 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 메서드에 그 관계들의 이름을 전달해서 좀 더 유창(fluent)하게 "has-one-through" 관계를 정의할 수도 있습니다. 예를 들어 Mechanic 모델에 cars 관계가, Car 모델에 owner 관계가 각각 정의되어 있다면 다음과 같이 정비사와 차주를 연결하는 관계를 정의할 수 있습니다.
// 문자열 기반 문법...
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 메서드로 좀 더 유창하게 "has-one-through" 관계를 정의할 수 있습니다. 이 방식은 기존에 정의된 관계들의 키 관례를 그대로 재사용할 수 있다는 장점이 있습니다.
// 문자열 기반 문법...
return $this->through('cars')->has('owner');
// 동적 문법...
return $this->throughCars()->hasOwner();Has Many Through
"has-many-through" 관계는 중간 관계를 거쳐서 멀리 떨어진 연관 데이터에 편리하게 접근할 수 있는 방법을 제공합니다. 예를 들어 Laravel Cloud와 같은 배포 플랫폼을 만든다고 가정해봅시다. 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 메서드에 그 관계들의 이름을 전달해서 좀 더 유창하게 "has-many-through" 관계를 정의할 수도 있습니다. 예를 들어 Application 모델에 environments 관계가, Environment 모델에 deployments 관계가 각각 정의되어 있다면 다음과 같이 애플리케이션과 배포 내역을 연결하는 관계를 정의할 수 있습니다.
// 문자열 기반 문법...
return $this->through('environments')->has('deployments');
// 동적 문법...
return $this->throughEnvironments()->hasDeployments();Deployment 모델의 테이블에는 application_id 컬럼이 없지만, hasManyThrough 관계 덕분에 $application->deployments로 애플리케이션의 배포 내역에 접근할 수 있습니다. 이 모델들을 조회할 때 Eloquent는 중간 모델인 Environment 테이블의 application_id 컬럼을 먼저 확인합니다. 관련된 환경(environment) ID들을 찾은 다음, 그 ID들을 이용해 Deployment 테이블을 조회하는 방식입니다.
키 관례
관계의 쿼리를 실행할 때는 일반적인 Eloquent 외래 키 관례가 적용됩니다. 관계의 키를 직접 지정하고 싶다면 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 메서드로 좀 더 유창하게 "has-many-through" 관계를 정의할 수 있습니다. 이 방식은 기존에 정의된 관계들의 키 관례를 그대로 재사용할 수 있다는 장점이 있습니다.
// 문자열 기반 문법...
return $this->through('environments')->has('deployments');
// 동적 문법...
return $this->throughEnvironments()->hasDeployments();스코프가 적용된 관계 (Scoped Relationships)
모델에 관계를 제약하는 별도의 메서드를 추가하는 경우가 흔히 있습니다. 예를 들어 User 모델에 featuredPosts 메서드를 추가해서, 기존의 posts 관계에 where 조건을 덧붙여 제약할 수 있습니다.
<?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();
}
/**
* 사용자의 주요(featured) 게시글을 조회합니다.
*/
public function featuredPosts(): HasMany
{
return $this->posts()->where('featured', true);
}
}하지만 featuredPosts 메서드를 통해 모델을 생성하더라도 featured 속성이 자동으로 true로 설정되지는 않습니다. 관계 메서드를 통해 모델을 생성할 때, 그 관계로 생성되는 모든 모델에 특정 속성값을 함께 지정하고 싶다면 관계 쿼리를 만들 때 withAttributes 메서드를 사용하면 됩니다.
/**
* 사용자의 주요(featured) 게시글을 조회합니다.
*/
public function featuredPosts(): HasMany
{
return $this->posts()->withAttributes(['featured' => true]);
}withAttributes 메서드는 전달받은 속성을 기준으로 쿼리에 where 조건을 추가하고, 동시에 이 관계 메서드로 모델을 생성할 때도 해당 속성값을 함께 채워 넣습니다.
$post = $user->featuredPosts()->create(['title' => 'Featured Post']);
$post->featured; // truewithAttributes 메서드가 쿼리에 where 조건을 추가하지 않도록 하려면, asConditions 인자를 false로 지정하면 됩니다.
return $this->posts()->withAttributes(['featured' => true], asConditions: false);Many to Many (다대다) 관계
다대다 관계는 hasOne이나 hasMany보다 조금 더 복잡한 구조를 가집니다. 대표적인 예로 "사용자가 여러 역할(role)을 가지고, 그 역할을 다른 사용자들도 공유하는" 경우를 들 수 있습니다. 예를 들어 한 사용자는 "작성자"와 "편집자" 역할을 동시에 가질 수 있고, 이 역할들은 다른 사용자에게도 똑같이 부여될 수 있습니다. 즉, 사용자는 여러 역할을 가지고, 역할 또한 여러 사용자에게 속합니다.
테이블 구조
이런 관계를 정의하려면 users, roles, role_user 이렇게 세 개의 테이블이 필요합니다. role_user 테이블 이름은 연관된 두 모델명을 알파벳 순으로 나열해서 만든 이름이며, user_id와 role_id 컬럼을 가지고 두 테이블을 이어주는 중간 테이블(intermediate table) 역할을 합니다.
역할 하나가 여러 사용자에게 속할 수 있으므로, roles 테이블에 단순히 user_id 컬럼을 두는 방식으로는 해결할 수 없습니다. 그렇게 하면 역할 하나가 단 한 명의 사용자에게만 속하게 되어버리기 때문입니다. 여러 사용자에게 하나의 역할을 배정할 수 있으려면 role_user라는 중간 테이블이 반드시 필요합니다. 전체 테이블 구조를 정리하면 다음과 같습니다.
users
id - integer
name - string
roles
id - integer
name - string
role_user
user_id - integer
role_id - integer모델 구조
다대다 관계는 belongsToMany 메서드의 결과를 반환하는 메서드를 정의해서 만듭니다. 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);
}
}관계를 정의했다면, roles 동적 관계 속성을 통해 사용자의 역할 목록에 접근할 수 있습니다.
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 모델에서 정의한 것과 거의 동일한 방식으로 정의하며, App\Models\User 모델을 참조한다는 점만 다릅니다. 동일하게 belongsToMany 메서드를 사용하므로, 다대다 관계의 "반대 방향"을 정의할 때도 테이블명이나 키 커스터마이징 옵션을 그대로 사용할 수 있습니다.
중간 테이블 컬럼 조회하기
이미 살펴봤듯이, 다대다 관계를 다루려면 중간 테이블이 반드시 필요합니다. Eloquent는 이 중간 테이블을 다루는 편리한 방법을 제공합니다. 예를 들어 User 모델이 여러 Role 모델과 관계를 맺고 있다고 해봅시다. 이 관계에 접근한 뒤, 각 모델의 pivot 속성을 통해 중간 테이블 데이터에 접근할 수 있습니다.
use App\Models\User;
$user = User::find(1);
foreach ($user->roles as $role) {
echo $role->pivot->created_at;
}조회된 각 Role 모델에는 자동으로 pivot 속성이 부여됩니다. 이 속성은 중간 테이블을 표현하는 모델을 담고 있습니다.
기본적으로 pivot 모델에는 두 모델의 키 값만 담깁니다. 만약 중간 테이블에 추가 컬럼이 있다면, 관계를 정의할 때 명시적으로 지정해줘야 합니다.
return $this->belongsToMany(Role::class)->withPivot('active', 'created_by');중간 테이블에 created_at, updated_at 타임스탬프를 두고 Eloquent가 자동으로 관리하도록 하려면, 관계를 정의할 때 withTimestamps 메서드를 호출하세요.
return $this->belongsToMany(Role::class)->withTimestamps();WARNING
Eloquent가 자동으로 관리하는 타임스탬프를 사용하는 중간 테이블에는 created_at과 updated_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 관계를 정의할 때 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');wherePivot은 쿼리에 where 조건절을 추가할 뿐, 이 관계를 통해 새 모델을 생성할 때 지정한 값을 자동으로 채워주지는 않습니다. 특정 pivot 값으로 조회와 생성을 모두 처리하고 싶다면 withPivotValue 메서드를 사용하세요.
return $this->belongsToMany(Role::class)
->withPivotValue('approved', 1);중간 테이블 컬럼으로 정렬하기
orderByPivot, orderByPivotDesc 메서드를 사용하면 belongsToMany 관계 쿼리의 결과를 중간 테이블 컬럼 기준으로 정렬할 수 있습니다. 다음 예제는 사용자가 최근에 획득한 배지 순으로 조회하는 예시입니다.
return $this->belongsToMany(Badge::class)
->where('rank', 'gold')
->orderByPivotDesc('created_at');커스텀 중간 테이블 모델 정의하기
다대다 관계의 중간 테이블을 표현하는 커스텀 모델을 직접 정의하고 싶다면, 관계를 정의할 때 using 메서드를 호출하면 됩니다. 커스텀 pivot 모델을 사용하면 메서드나 캐스트 같은 추가 동작을 pivot 모델에 정의할 수 있는 이점이 있습니다.
커스텀 다대다 pivot 모델은 Illuminate\Database\Eloquent\Relations\Pivot 클래스를 상속해야 하며, 커스텀 다형성(polymorphic) 다대다 pivot 모델은 Illuminate\Database\Eloquent\Relations\MorphPivot 클래스를 상속해야 합니다. 예를 들어 Role 모델이 커스텀 pivot 모델인 RoleUser를 사용하도록 정의해보겠습니다.
<?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 모델을 정의할 때는 Illuminate\Database\Eloquent\Relations\Pivot 클래스를 상속해야 합니다.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\Pivot;
class RoleUser extends Pivot
{
// ...
}WARNING
pivot 모델에는 SoftDeletes 트레이트를 사용할 수 없습니다. pivot 레코드에 소프트 삭제가 필요하다면, pivot 모델을 일반 Eloquent 모델로 전환하는 것을 고려하세요.
커스텀 Pivot 모델과 자동 증가 ID
커스텀 pivot 모델을 사용하는 다대다 관계를 정의했고, 그 pivot 모델이 자동 증가(auto-incrementing) 기본 키를 가지고 있다면, 커스텀 pivot 모델 클래스에 Table 어트리뷰트를 사용해 incrementing을 true로 지정해야 합니다.
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Relations\Pivot;
#[Table(incrementing: true)]
class RoleUser extends Pivot
{
// ...
}Pivot 관계 자동 하이드레이션
커스텀 pivot 모델에서 선언 모델과 연관 모델에 대한 belongsTo 관계를 정의했다면, chaperone 메서드를 호출해서 각 pivot 모델에 해당 관계들을 자동으로 채워 넣을(hydrate) 수 있습니다. 이렇게 하면 pivot을 통해 모델에 접근할 때 추가 쿼리가 발생하는 것을 막을 수 있습니다.
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\Pivot;
class RoleUser extends Pivot
{
public function role(): BelongsTo
{
return $this->belongsTo(Role::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
class Role extends Model
{
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class)
->using(RoleUser::class)
->chaperone();
}
}Eloquent는 pivot 관계 이름을 자동으로 추론하려고 시도합니다. pivot 모델이 표준적이지 않은 이름을 사용한다면, chaperone에 선언 모델과 연관 모델의 관계 이름을 직접 전달하세요.
return $this->belongsToMany(User::class)
->using(RoleUser::class)
->chaperone(declaring: 'role', related: 'user');Eloquent 관계
다형성 관계 (Polymorphic Relationships)
다형성 관계를 사용하면 하나의 연관관계만으로 자식 모델이 여러 종류의 부모 모델에 속할 수 있습니다. 예를 들어 게시글(post)과 동영상(video)을 공유할 수 있는 애플리케이션을 만든다고 가정해봅시다. 이런 경우 Comment 모델은 Post 모델에도, Video 모델에도 속할 수 있어야 합니다. 이럴 때 다형성 관계를 사용합니다.
일대일 (다형성)
테이블 구조
일대일 다형성 관계는 일반적인 일대일 관계와 비슷하지만, 자식 모델이 하나의 연관관계로 여러 종류의 부모 모델에 속할 수 있다는 점이 다릅니다. 예를 들어 블로그의 Post와 User가 Image 모델과 다형성 관계를 공유한다고 생각해보세요. 일대일 다형성 관계를 사용하면 게시글과 사용자 모두와 연결될 수 있는 하나의 이미지 테이블을 둘 수 있습니다. 먼저 테이블 구조부터 살펴보겠습니다.
posts
id - integer
name - string
users
id - integer
name - string
images
id - integer
url - string
imageable_type - string
imageable_id - integerimages 테이블의 imageable_id와 imageable_type 컬럼을 주목하세요. imageable_id 컬럼에는 게시글 또는 사용자의 ID 값이 저장되고, imageable_type 컬럼에는 부모 모델의 클래스명이 저장됩니다. imageable_type 컬럼은 Eloquent가 imageable 관계에 접근할 때 어떤 "타입"의 부모 모델을 반환해야 하는지 판단하는 데 사용됩니다. 이 예시에서는 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
{
/**
* 이 이미지가 속한 부모 imageable 모델(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
{
/**
* 이 댓글이 속한 부모 commentable 모델(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 인스턴스를 반환합니다.
자식 모델에 부모 모델 자동으로 채우기
Eloquent의 즉시 로딩(eager loading)을 사용하더라도, 자식 모델을 반복하면서 부모 모델에 접근하려고 하면 "N + 1" 쿼리 문제가 발생할 수 있습니다.
$posts = Post::with('comments')->get();
foreach ($posts as $post) {
foreach ($post->comments as $comment) {
echo $comment->commentable->title;
}
}위 예시에서는 "N + 1" 쿼리 문제가 발생합니다. 왜냐하면 각 Post 모델의 댓글을 즉시 로딩했음에도 불구하고, Eloquent가 각 자식 Comment 모델에 부모 Post를 자동으로 채워 넣어주지는 않기 때문입니다.
Eloquent가 자식 모델에 부모 모델을 자동으로 채워 넣도록 하려면, morphMany 관계를 정의할 때 chaperone 메서드를 호출하면 됩니다.
class Post extends Model
{
/**
* 게시글의 모든 댓글을 가져옵니다.
*/
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable')->chaperone();
}
}또는 실행 시점에 부모 모델 자동 채우기를 선택적으로 적용하고 싶다면, 관계를 즉시 로딩할 때 chaperone 메서드를 호출할 수도 있습니다.
use App\Models\Post;
$posts = Post::with([
'comments' => fn ($comments) => $comments->chaperone(),
])->get();다중 관계 중 하나만 조회하기 (다형성)
때로는 하나의 모델이 여러 관련 모델을 가지고 있지만, 그중에서 "가장 최신" 또는 "가장 오래된" 관련 모델만 손쉽게 조회하고 싶을 수 있습니다. 예를 들어 User 모델이 여러 Image 모델과 연관되어 있을 때, 사용자가 가장 최근에 업로드한 이미지에 편리하게 접근할 수 있는 방법이 필요할 수 있습니다. 이런 경우 morphOne 관계와 ofMany 계열 메서드를 함께 사용하면 됩니다.
/**
* 사용자의 가장 최근 이미지를 가져옵니다.
*/
public function latestImage(): MorphOne
{
return $this->morphOne(Image::class, 'imageable')->latestOfMany();
}마찬가지로, 관계에서 "가장 오래된" 즉 첫 번째 모델을 조회하는 메서드도 정의할 수 있습니다.
/**
* 사용자의 가장 오래된 이미지를 가져옵니다.
*/
public function oldestImage(): MorphOne
{
return $this->morphOne(Image::class, 'imageable')->oldestOfMany();
}기본적으로 latestOfMany와 oldestOfMany 메서드는 정렬 가능한 기본 키(primary key)를 기준으로 가장 최신 또는 가장 오래된 관련 모델을 조회합니다. 하지만 때로는 더 큰 관계에서 다른 정렬 기준으로 단일 모델을 조회하고 싶을 수 있습니다.
예를 들어 ofMany 메서드를 사용하면 사용자가 가장 많이 "좋아요"를 받은 이미지를 조회할 수 있습니다. ofMany 메서드는 첫 번째 인자로 정렬 기준이 될 컬럼을, 두 번째 인자로 관련 모델을 조회할 때 적용할 집계 함수(min 또는 max)를 받습니다.
/**
* 사용자의 가장 인기 있는 이미지를 가져옵니다.
*/
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 - integerNOTE
다형성 다대다 관계를 본격적으로 살펴보기 전에, 일반적인 다대다 관계 문서를 먼저 읽어보는 것이 도움이 될 수 있습니다.
모델 구조
이제 모델에 관계를 정의할 차례입니다. Post와 Video 모델 모두 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 메서드의 결과를 반환해야 합니다.
morphedByMany 메서드는 관련 모델의 이름과 "관계 이름"을 인자로 받습니다. 앞서 정한 중간 테이블 이름과 키를 기준으로, 이 관계 역시 "taggable"이라고 부르겠습니다.
<?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) {
// ...
}다형성 자식 모델에서 morphedByMany를 호출하는 메서드의 이름을 통해 다형성 관계의 부모를 조회할 수도 있습니다. 이 예시에서는 Tag 모델의 posts 메서드와 videos 메서드가 이에 해당합니다.
use App\Models\Tag;
$tag = Tag::find(1);
foreach ($tag->posts as $post) {
// ...
}
foreach ($tag->videos as $video) {
// ...
}다형성 타입 커스터마이징
기본적으로 라라벨은 관련 모델의 "타입"을 저장할 때 완전한 네임스페이스가 포함된 클래스명을 사용합니다. 예를 들어 앞서 살펴본 일대다 관계 예시에서 Comment 모델이 Post나 Video에 속할 수 있는 경우, 기본 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',
]);enforceMorphMap 메서드는 App\Providers\AppServiceProvider 클래스의 boot 메서드 안에서 호출하거나, 원한다면 별도의 서비스 프로바이더를 만들어 호출해도 됩니다.
특정 모델의 morph 별칭(alias)은 모델의 getMorphClass 메서드를 통해 실행 시점에 확인할 수 있습니다. 반대로 morph 별칭에 해당하는 완전한 클래스명은 Relation::getMorphedModel 메서드로 확인할 수 있습니다.
use Illuminate\Database\Eloquent\Relations\Relation;
$alias = $post->getMorphClass();
$class = Relation::getMorphedModel($alias);WARNING
기존에 운영 중인 애플리케이션에 "morph map"을 새로 추가하는 경우, 데이터베이스에 저장된 다형성 *_type 컬럼 값 중 여전히 완전한 클래스명을 담고 있는 값들은 모두 "map"에서 지정한 이름으로 변환해주어야 합니다.
동적 관계 (Dynamic Relationships)
resolveRelationUsing 메서드를 사용하면 실행 시점에 Eloquent 모델 간의 관계를 정의할 수 있습니다. 일반적인 애플리케이션 개발에는 권장되지 않지만, 라라벨 패키지를 개발할 때는 유용하게 쓰일 수 있습니다.
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 관계는 쿼리 빌더 역할도 겸하기 때문에, 실제로 데이터베이스에 SQL을 실행하기 전에 원하는 조건을 계속 체이닝해서 추가할 수 있습니다.
예를 들어, 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을 생성합니다. 논리 그룹으로 조건이 제대로 묶여서 쿼리가 특정 사용자로 정확히 제한되는 것을 확인할 수 있습니다:
select *
from posts
where user_id = ? and (active = 1 or votes >= 100)관계 메서드 vs. 동적 프로퍼티
관계 쿼리에 추가 조건을 붙일 필요가 없다면, 관계를 마치 프로퍼티인 것처럼 접근할 수도 있습니다. 예를 들어 앞서 사용한 User와 Post 모델을 계속 사용한다면, 다음과 같이 사용자의 모든 게시글에 접근할 수 있습니다:
use App\Models\User;
$user = User::find(1);
foreach ($user->posts as $post) {
// ...
}동적 관계 프로퍼티는 "지연 로딩(lazy loading)" 방식으로 동작합니다. 즉, 실제로 접근하는 시점에만 관계 데이터를 로드합니다. 이런 이유로 나중에 접근할 것이 확실한 관계는 즉시 로딩(eager loading)을 미리 적용하는 경우가 많습니다. 즉시 로딩을 사용하면 모델의 관계를 불러오는 데 필요한 SQL 쿼리 수를 크게 줄일 수 있습니다.
NOTE
지연 로딩과 즉시 로딩의 차이는 "필요할 때마다 그때그때 가져오는 것"과 "미리 한 번에 가져오는 것"의 차이라고 생각하면 됩니다. 반복문 안에서 관계 프로퍼티에 접근하면 N+1 쿼리 문제가 발생하기 쉬우니 주의하세요.
관계 존재 여부로 쿼리하기
모델 레코드를 조회할 때, 관계의 존재 여부를 기준으로 결과를 제한하고 싶을 때가 있습니다. 예를 들어 댓글이 하나 이상 달린 블로그 게시글만 조회하고 싶다고 해봅시다. 이럴 때는 관계 이름을 has나 orHas 메서드에 전달하면 됩니다:
use App\Models\Post;
// 댓글이 하나 이상 있는 모든 게시글을 조회...
$posts = Post::has('comments')->get();연산자와 개수를 지정해서 쿼리를 더 세밀하게 조정할 수도 있습니다:
// 댓글이 3개 이상인 모든 게시글을 조회...
$posts = Post::has('comments', '>=', 3)->get();"점(dot) 표기법"을 사용하면 중첩된 has 구문도 구성할 수 있습니다. 예를 들어 이미지가 첨부된 댓글이 하나 이상 있는 게시글을 조회하려면 다음과 같이 작성합니다:
// 이미지가 첨부된 댓글이 하나 이상 있는 게시글을 조회...
$posts = Post::has('comments.images')->get();더 강력한 기능이 필요하다면 whereHas와 orWhereHas 메서드를 사용해서 has 쿼리에 추가 조건을 정의할 수 있습니다. 예를 들어 댓글 내용을 검사하는 경우입니다:
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();whereAttachedTo 메서드에는 컬렉션 인스턴스를 전달할 수도 있습니다. 이 경우 Laravel은 컬렉션에 담긴 모델들 중 하나라도 연결된 모델을 가져옵니다:
$tags = Tag::whereLike('name', '%laravel%')->get();
$posts = Post::whereAttachedTo($tags)->get();인라인 관계 존재 여부 쿼리
관계 쿼리에 간단한 where 조건 하나만 붙여서 존재 여부를 확인하고 싶다면, 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 메서드로 doesntHave 쿼리에 추가 조건을 붙일 수 있습니다. 예를 들어 댓글 내용을 검사하는 경우입니다:
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" 관계의 존재 여부를 쿼리하려면 whereHasMorph와 whereDoesntHaveMorph 메서드를 사용합니다. 이 메서드들은 첫 번째 인수로 관계 이름을 받고, 두 번째 인수로 쿼리에 포함하고 싶은 연관 모델들의 이름을 받습니다. 마지막으로 관계 쿼리를 커스터마이징할 클로저를 전달할 수 있습니다:
use App\Models\Comment;
use App\Models\Post;
use App\Models\Video;
use Illuminate\Database\Eloquent\Builder;
// 제목이 code%로 시작하는 게시글이나 동영상에 달린 댓글을 조회...
$comments = Comment::whereHasMorph(
'commentable',
[Post::class, Video::class],
function (Builder $query) {
$query->where('title', 'like', 'code%');
}
)->get();
// 제목이 code%로 시작하지 않는 게시글에 달린 댓글을 조회...
$comments = Comment::whereDoesntHaveMorph(
'commentable',
Post::class,
function (Builder $query) {
$query->where('title', 'like', 'code%');
}
)->get();경우에 따라 연관된 폴리모픽 모델의 "타입"을 기준으로 조건을 추가해야 할 수도 있습니다. whereHasMorph 메서드에 전달하는 클로저는 두 번째 인수로 $type 값을 받을 수 있는데, 이 인수를 통해 현재 어떤 타입에 대한 쿼리가 구성되고 있는지 확인할 수 있습니다:
use Illuminate\Database\Eloquent\Builder;
$comments = Comment::whereHasMorph(
'commentable',
[Post::class, Video::class],
function (Builder $query, string $type) {
$column = $type === Post::class ? 'content' : 'title';
$query->where($column, 'like', 'code%');
}
)->get();때로는 "morph to" 관계의 부모에 해당하는 자식들을 쿼리하고 싶을 수도 있습니다. 이럴 때는 whereMorphedTo와 whereNotMorphedTo 메서드를 사용하면 되는데, 이 메서드들은 전달받은 모델에 맞는 적절한 morph 타입 매핑을 자동으로 알아서 처리해줍니다. 첫 번째 인수로 morphTo 관계 이름을, 두 번째 인수로 연관된 부모 모델을 전달합니다:
$comments = Comment::whereMorphedTo('commentable', $post)
->orWhereMorphedTo('commentable', $video)
->get();가능한 모든 연관 모델 쿼리하기
폴리모픽 모델들을 배열로 일일이 전달하는 대신, 와일드카드 값인 *를 전달할 수도 있습니다. 이렇게 하면 Laravel이 데이터베이스에서 가능한 모든 폴리모픽 타입을 조회하도록 지시하게 됩니다. 이 작업을 수행하기 위해 Laravel은 추가 쿼리를 한 번 더 실행합니다:
use Illuminate\Database\Eloquent\Builder;
$comments = Comment::whereHasMorph('commentable', '*', function (Builder $query) {
$query->where('title', 'like', 'foo%');
})->get();연관 모델 집계
연관 모델 개수 세기
연관된 모델들을 실제로 로드하지 않고 개수만 세고 싶을 때가 있습니다. 이럴 때는 withCount 메서드를 사용하면 됩니다. withCount 메서드는 조회된 모델에 {relation}_count라는 속성을 추가해 줍니다:
use App\Models\Post;
$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
echo $post->comments_count;
}withCount 메서드에 배열을 전달하면 여러 관계에 대한 "개수"를 한 번에 추가할 수 있고, 쿼리에 추가 조건도 걸 수 있습니다:
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);
}]);관계 개수 세기와 커스텀 select 구문
withCount를 select 구문과 함께 사용한다면, 반드시 select 메서드를 호출한 이후에 withCount를 호출해야 합니다:
$posts = Post::select(['title', 'body'])
->withCount('comments')
->get();그 외 집계 함수
withCount 외에도 Eloquent는 withMin, withMax, withAvg, withSum, withExists 메서드를 제공합니다. 이 메서드들은 조회된 모델에 {relation}_{function}_{column} 형태의 속성을 추가합니다:
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와 마찬가지로, 이 메서드들에도 지연 로딩 버전이 존재합니다. 즉, 이미 조회해 둔 Eloquent 모델에 대해서도 이러한 집계 연산을 추가로 수행할 수 있습니다:
$post = Post::first();
$post->loadSum('comments', 'votes');이 집계 메서드들을 select 구문과 함께 사용한다면, 마찬가지로 select 메서드를 호출한 이후에 집계 메서드를 호출해야 합니다:
$posts = Post::select(['title', 'body'])
->withExists('comments')
->get();Morph To 관계에서 연관 모델 개수 세기
"morph to" 관계를 즉시 로딩(eager load)하면서, 그 관계가 반환할 수 있는 여러 종류의 엔티티에 대한 연관 모델 개수까지 함께 로드하고 싶다면, with 메서드와 morphTo 관계의 morphWithCount 메서드를 조합해서 사용할 수 있습니다.
예를 들어 Photo와 Post 모델이 각각 ActivityFeed 모델을 생성할 수 있다고 가정해 봅시다. ActivityFeed 모델은 parentable이라는 이름의 "morph to" 관계를 정의하고 있어서, 각 ActivityFeed 인스턴스에 대해 부모가 되는 Photo 또는 Post 모델을 가져올 수 있습니다. 추가로 Photo 모델은 여러 개의 Tag 모델과 "일대다" 관계를, Post 모델은 여러 개의 Comment 모델과 "일대다" 관계를 가진다고 가정하겠습니다.
이제 ActivityFeed 인스턴스들을 조회하면서 각 인스턴스의 parentable 부모 모델을 즉시 로딩하고, 동시에 부모가 사진(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'],
]);NOTE
morphWithCount와 loadMorphCount는 다형성(polymorphic) 관계에서 부모 타입별로 서로 다른 연관 관계를 집계해야 할 때 특히 유용합니다. 일반적인 withCount만으로는 처리할 수 없는, 타입에 따라 달라지는 집계 요구사항을 깔끔하게 해결해 줍니다.
Eager Loading (즉시 로딩)
Eloquent 관계를 프로퍼티처럼 접근하면 관련 모델은 "지연 로딩(lazy loading)"됩니다. 즉, 실제로 그 프로퍼티에 접근하는 시점이 되어서야 관계 데이터를 조회하는 쿼리가 실행됩니다. 하지만 Eloquent는 부모 모델을 조회하는 시점에 관계를 함께 "즉시 로딩(eager loading)"할 수도 있습니다. 즉시 로딩을 사용하면 그 유명한 "N + 1 쿼리 문제"를 해결할 수 있습니다.
N + 1 쿼리 문제가 무엇인지 예를 들어보겠습니다. Author 모델에 속한(belongsTo) Book 모델이 있다고 가정해봅시다:
<?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;
}이 반복문은 먼저 books 테이블의 모든 행을 가져오는 쿼리 1개를 실행한 다음, 각 책마다 저자를 조회하기 위한 쿼리를 추가로 실행합니다. 즉, 책이 25권이라면 위 코드는 총 26번의 쿼리를 실행합니다. 책 목록을 가져오는 쿼리 1개와, 각 책의 저자를 조회하는 쿼리 25개입니다.
다행히 즉시 로딩을 사용하면 이 작업을 단 2번의 쿼리로 줄일 수 있습니다. 쿼리를 작성할 때 with 메서드를 사용해 어떤 관계를 즉시 로딩할지 지정할 수 있습니다:
$books = Book::with('author')->get();
foreach ($books as $book) {
echo $book->author->name;
}이 작업에서는 단 2개의 쿼리만 실행됩니다. 모든 책을 조회하는 쿼리 1개와, 그 책들의 저자를 한 번에 조회하는 쿼리 1개입니다:
select * from books
select * from authors where id in (1, 2, 3, 4, 5, ...)여러 관계 즉시 로딩하기
여러 개의 관계를 한 번에 즉시 로딩해야 할 때는 with 메서드에 관계 이름의 배열을 전달하면 됩니다:
$books = Book::with(['author', 'publisher'])->get();중첩된 즉시 로딩
관계의 관계까지 즉시 로딩하려면 "닷(.)" 문법을 사용할 수 있습니다. 예를 들어 책의 저자뿐만 아니라 그 저자의 개인 연락처까지 함께 즉시 로딩해보겠습니다:
$books = Book::with('author.contacts')->get();또는 with 메서드에 중첩된 배열을 전달하는 방식으로 중첩 관계를 지정할 수도 있습니다. 여러 개의 중첩 관계를 즉시 로딩할 때 이 방식이 더 편리할 수 있습니다:
$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 모델을 생성할 수 있다고 가정합니다. 또한 Event 모델은 Calendar 모델에 속하고, Photo 모델은 Tag 모델과 연관되며, Post 모델은 Author 모델에 속한다고 가정합니다.
이러한 모델 정의와 관계를 바탕으로, 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();특정 컬럼만 즉시 로딩하기
관계에서 항상 모든 컬럼이 필요한 것은 아닙니다. 이런 경우를 위해 Eloquent는 즉시 로딩할 관계에서 어떤 컬럼을 가져올지 지정할 수 있게 해줍니다:
$books = Book::with('author:id,name,book_id')->get();WARNING
이 기능을 사용할 때는 조회할 컬럼 목록에 항상 id 컬럼과 관련된 외래 키 컬럼을 포함해야 합니다.
기본적으로 즉시 로딩하기
모델을 조회할 때마다 항상 특정 관계를 함께 로딩하고 싶을 수 있습니다. 이럴 때는 모델에 $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', '%code%');
}])->get();이 예제에서 Eloquent는 title 컬럼에 "code"라는 단어가 포함된 게시글만 즉시 로딩합니다. 이 밖에도 쿼리 빌더의 다른 메서드들을 호출해 즉시 로딩 쿼리를 더 세밀하게 조정할 수 있습니다:
$users = User::with(['posts' => function ($query) {
$query->orderBy('created_at', 'desc');
}])->get();morphTo 관계의 즉시 로딩에 조건 추가하기
morphTo 관계를 즉시 로딩할 때, Eloquent는 관련된 모델의 종류마다 별도의 쿼리를 실행합니다. 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();이 예제에서 Eloquent는 숨김 처리되지 않은 게시글과, type 값이 "educational"인 동영상만 즉시 로딩합니다.
관계 존재 여부와 함께 즉시 로딩 조건 지정하기
관계가 존재하는지 확인하는 조건을 걸면서, 동시에 그 조건에 맞는 관계 데이터를 즉시 로딩해야 하는 경우가 있습니다. 예를 들어 특정 조건에 맞는 Post 모델을 가진 User 모델만 조회하면서, 그 조건에 일치하는 게시글까지 함께 즉시 로딩하고 싶을 수 있습니다. 이럴 때는 withWhereHas 메서드를 사용하면 됩니다:
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');
}즉시 로딩 쿼리에 추가 조건을 걸어야 한다면, 로딩할 관계를 키로 하는 배열을 전달하면 됩니다. 이때 배열 값은 쿼리 인스턴스를 인자로 받는 클로저여야 합니다:
$author->load(['books' => function ($query) {
$query->orderBy('published_date', 'asc');
}]);아직 로딩되지 않은 경우에만 관계를 로딩하고 싶다면 loadMissing 메서드를 사용하세요:
$book->loadMissing('author');중첩된 지연 즉시 로딩과 morphTo
morphTo 관계를 즉시 로딩하면서, 그 관계가 반환할 수 있는 여러 모델의 중첩 관계까지 함께 로딩하고 싶다면 loadMorph 메서드를 사용할 수 있습니다.
이 메서드는 첫 번째 인자로 morphTo 관계의 이름을, 두 번째 인자로 모델과 관계를 짝지은 배열을 받습니다. 다음 모델을 예로 살펴보겠습니다:
<?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 모델을 생성할 수 있다고 가정합니다. 또한 Event 모델은 Calendar 모델에 속하고, Photo 모델은 Tag 모델과 연관되며, Post 모델은 Author 모델에 속한다고 가정합니다.
이러한 모델 정의와 관계를 바탕으로, ActivityFeed 모델 인스턴스를 조회한 뒤 parentable 모델과 각 모델의 중첩 관계까지 즉시 로딩할 수 있습니다:
$activities = ActivityFeed::with('parentable')
->get()
->loadMorph('parentable', [
Event::class => ['calendar'],
Photo::class => ['tags'],
Post::class => ['author'],
]);자동 즉시 로딩
대부분의 경우 Laravel은 여러분이 접근하는 관계를 자동으로 즉시 로딩해줄 수 있습니다. 이 기능을 활성화하려면 애플리케이션의 AppServiceProvider에 있는 boot 메서드에서 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;
}
}
}일반적으로 위 코드는 각 사용자의 게시글을 조회하기 위한 쿼리와, 각 게시글의 댓글을 조회하기 위한 쿼리를 사용자와 게시글 수만큼 각각 실행합니다. 하지만 automaticallyEagerLoadRelationships 기능이 활성화되어 있으면, users 컬렉션에 담긴 사용자 중 한 명의 게시글에 접근하는 순간 Laravel이 전체 사용자의 게시글을 지연 즉시 로딩합니다. 마찬가지로, 조회된 게시글 중 하나의 댓글에 접근하면 원래 조회했던 모든 게시글의 댓글이 한 번에 지연 즉시 로딩됩니다.
이 기능을 애플리케이션 전역에 활성화하고 싶지 않다면, 컬렉션 인스턴스에서 withRelationshipAutoloading 메서드를 호출해 특정 Eloquent 컬렉션에 대해서만 이 기능을 사용할 수도 있습니다:
$users = User::where('vip', true)->get();
return $users->withRelationshipAutoloading();지연 로딩 방지하기
앞서 살펴봤듯이 관계를 즉시 로딩하면 애플리케이션 성능에 큰 도움이 될 수 있습니다. 원한다면 Laravel이 관계의 지연 로딩 자체를 항상 막도록 설정할 수도 있습니다. 이를 위해서는 Eloquent 기본 모델 클래스가 제공하는 preventLazyLoading 메서드를 호출하면 됩니다. 보통은 애플리케이션의 AppServiceProvider 클래스에 있는 boot 메서드 안에서 이 메서드를 호출합니다.
preventLazyLoading 메서드는 지연 로딩을 막을지 여부를 나타내는 boolean 값을 선택적으로 인자로 받습니다. 예를 들어 프로덕션이 아닌 환경에서만 지연 로딩을 비활성화하도록 설정하면, 실수로 지연 로딩 관계가 프로덕션 코드에 남아 있더라도 프로덕션 환경은 평소처럼 정상 동작하게 만들 수 있습니다:
use Illuminate\Database\Eloquent\Model;
/**
* 애플리케이션 서비스를 부트스트랩합니다.
*/
public function boot(): void
{
Model::preventLazyLoading(! $this->app->isProduction());
}지연 로딩을 막은 이후에는, 애플리케이션이 Eloquent 관계를 지연 로딩하려고 시도할 때마다 Illuminate\Database\LazyLoadingViolationException 예외가 발생합니다.
handleLazyLoadingViolationUsing 메서드를 사용하면 지연 로딩 위반이 발생했을 때의 동작 방식을 원하는 대로 커스터마이징할 수 있습니다. 예를 들어 다음과 같이 하면, 지연 로딩 위반이 발생했을 때 예외를 던져 애플리케이션 실행을 중단하는 대신 로그만 남기도록 만들 수 있습니다:
Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation) {
$class = $model::class;
info("[{$class}] 모델에서 [{$relation}] 관계를 지연 로딩하려고 시도했습니다.");
});Eloquent 관계
관련 모델 삽입 및 수정
save 메서드
Eloquent는 관계에 새로운 모델을 추가할 수 있는 편리한 메서드를 제공합니다. 예를 들어 게시글(post)에 새 댓글을 추가한다고 가정해 봅시다. Comment 모델에 post_id 속성을 직접 설정하는 대신, 관계의 save 메서드를 사용해 댓글을 추가할 수 있습니다.
use App\Models\Comment;
use App\Models\Post;
$comment = new Comment(['message' => 'A new comment.']);
$post = Post::find(1);
$post->comments()->save($comment);여기서 comments 관계를 동적 속성으로 접근한 것이 아니라, comments 메서드를 호출해서 관계 인스턴스를 얻었다는 점에 주목하세요. save 메서드는 새로 생성되는 Comment 모델에 알맞은 post_id 값을 자동으로 설정해줍니다.
여러 개의 관련 모델을 한 번에 저장해야 한다면 saveMany 메서드를 사용할 수 있습니다.
$post = Post::find(1);
$post->comments()->saveMany([
new Comment(['message' => 'A new comment.']),
new Comment(['message' => 'Another new comment.']),
]);save와 saveMany 메서드는 전달받은 모델 인스턴스를 데이터베이스에 저장하지만, 부모 모델에 이미 로드되어 있는 메모리상의 관계 컬렉션에는 새로 저장된 모델을 자동으로 추가해주지 않습니다. save나 saveMany를 사용한 뒤 해당 관계에 다시 접근할 계획이라면, refresh 메서드로 모델과 그 관계를 다시 불러오는 것이 좋습니다.
$post->comments()->save($comment);
$post->refresh();
// 새로 저장한 댓글을 포함한 전체 댓글 목록...
$post->comments;모델과 관계를 재귀적으로 저장하기
모델뿐 아니라 그 모델에 연결된 모든 관계까지 한꺼번에 save하고 싶다면 push 메서드를 사용할 수 있습니다. 아래 예시에서는 Post 모델은 물론, 그 댓글들과 댓글 작성자까지 모두 저장됩니다.
$post = Post::find(1);
$post->comments[0]->message = 'Message';
$post->comments[0]->author->name = 'Author Name';
$post->push();pushQuietly 메서드를 사용하면 어떤 이벤트도 발생시키지 않고 모델과 관계를 저장할 수 있습니다.
$post->pushQuietly();create 메서드
save, saveMany 메서드 외에도 create 메서드를 사용할 수 있습니다. 이 메서드는 속성 배열을 받아 새 모델을 생성하고 데이터베이스에 삽입합니다. save와의 차이점은, save는 완전한 Eloquent 모델 인스턴스를 받는 반면 create는 일반 PHP 배열(array)을 받는다는 점입니다. create 메서드는 새로 생성된 모델을 반환합니다.
use App\Models\Post;
$post = Post::find(1);
$comment = $post->comments()->create([
'message' => 'A new comment.',
]);여러 개의 관련 모델을 한 번에 생성하려면 createMany 메서드를 사용하세요.
$post = Post::find(1);
$post->comments()->createMany([
['message' => 'A new comment.'],
['message' => 'Another new comment.'],
]);createQuietly와 createManyQuietly 메서드를 사용하면 이벤트를 발생시키지 않고 모델을 생성할 수 있습니다.
$user = User::find(1);
$user->posts()->createQuietly([
'title' => 'Post title.',
]);
$user->posts()->createManyQuietly([
['title' => 'First post.'],
['title' => 'Second post.'],
]);이 밖에도 findOrNew, firstOrNew, firstOrCreate, updateOrCreate 메서드를 사용해 관계 위에서 모델을 생성하거나 수정할 수 있습니다.
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();다대다 관계
연결하기 / 연결 해제하기
Eloquent는 다대다 관계를 다룰 때도 편리한 메서드를 제공합니다. 사용자(user) 한 명이 여러 역할(role)을 가질 수 있고, 하나의 역할도 여러 사용자에게 속할 수 있는 예시를 살펴보겠습니다. attach 메서드를 사용하면 중간 테이블(intermediate table)에 레코드를 삽입해서 사용자에게 역할을 연결할 수 있습니다.
use App\Models\User;
$user = User::find(1);
$user->roles()->attach($roleId);관계를 연결할 때 중간 테이블에 함께 저장할 추가 데이터를 배열로 전달할 수도 있습니다.
$user->roles()->attach($roleId, ['expires' => $expires]);때로는 사용자에게서 특정 역할을 제거해야 할 수도 있습니다. 다대다 관계의 레코드를 제거하려면 detach 메서드를 사용하세요. detach 메서드는 중간 테이블에서 해당 레코드를 삭제하지만, 두 모델 자체는 데이터베이스에 그대로 남아 있습니다.
// 사용자에게서 특정 역할 하나만 제거...
$user->roles()->detach($roleId);
// 사용자의 모든 역할 제거...
$user->roles()->detach();편의를 위해 attach와 detach는 ID 배열도 입력으로 받을 수 있습니다.
$user = User::find(1);
$user->roles()->detach([1, 2, 3]);
$user->roles()->attach([
1 => ['expires' => $expires],
2 => ['expires' => $expires],
]);연결 동기화하기
sync 메서드를 사용해서도 다대다 관계를 구성할 수 있습니다. sync 메서드는 중간 테이블에 존재해야 할 ID 배열을 인자로 받습니다. 전달한 배열에 포함되지 않은 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를 제거하고 싶지 않다면 syncWithoutDetaching 메서드를 사용할 수 있습니다.
$user->roles()->syncWithoutDetaching([1, 2, 3]);연결 토글하기
다대다 관계는 주어진 관련 모델 ID들의 연결 상태를 "토글(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,
]);부모 모델의 타임스탬프 갱신하기
Comment가 Post에 속하는 것처럼, 어떤 모델이 다른 모델과 belongsTo나 belongsToMany 관계를 정의하고 있을 때, 자식 모델이 수정되면 부모 모델의 타임스탬프도 함께 갱신하고 싶은 경우가 있습니다.
예를 들어, Comment 모델이 수정될 때 이 댓글이 속한 Post의 updated_at 타임스탬프를 현재 시각으로 자동 갱신("touch")하고 싶을 수 있습니다. 이를 위해서는 자식 모델에 Touches 어트리뷰트를 사용하여, 자식 모델이 수정될 때 updated_at 타임스탬프를 함께 갱신할 관계의 이름을 지정하면 됩니다:
<?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);
}
}WARNING
부모 모델의 타임스탬프는 자식 모델이 Eloquent의 save 메서드를 통해 저장될 때만 갱신됩니다.