엘로퀀트 ORM 시작하기
엘로퀀트 ORM 시작하기 (Eloquent: Getting Started)
Laravel에 내장된 엘로퀀트(Eloquent)는 오브젝트-관계 매퍼(ORM)로, 데이터베이스와의 상호작용을 즐겁게 만들어줘요. 엘로퀀트를 쓰면 각 데이터베이스 테이블에 대응하는 "모델(Model)"이 있어서 그 테이블과 소통해요. 모델을 통해 레코드를 조회할 뿐 아니라 삽입·수정·삭제까지 할 수 있죠. 이 문서에서는 모델 생성, 규칙, 조회, 삽입/수정, 대량 할당, 소프트 삭제, 스코프, 이벤트까지 Laravel 12 기준으로 익혀볼게요.
모델 클래스 생성하기
모델은 보통 app\Models 디렉터리에 있고 Illuminate\Database\Eloquent\Model 클래스를 상속해요. make:model Artisan 커맨드로 생성하죠. 마이그레이션·팩토리·시더·컨트롤러·폼 리퀘스트·폴리시 등 다양한 클래스를 함께 생성하는 옵션들도 있고, 조합해서 한 번에 만들 수도 있어요.
php artisan make:model Flight
php artisan make:model Flight --migration
php artisan make:model Flight --controller --resource --requests
php artisan make:model Flight --all
model:show 커맨드는 코드를 훑지 않고도 모델의 모든 속성과 관계를 한눈에 보여줘요.
php artisan model:show Flight
엘로퀀트 모델 관례 (Conventions)
make:model로 생성된 모델은 app/Models에 위치해요. 기본 모델 클래스는 아주 단순해요.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Flight extends Model
{
// ...
}
테이블 이름과 기본 키
관례로 클래스 이름의 "스네이크 케이스" 복수형이 테이블 이름이 돼요. Flight 모델은 flights 테이블에 저장되고, AirTrafficController는 air_traffic_controllers에 저장된다고 가정해요. 관례에 맞지 않으면 $table 프로퍼티로 직접 지정할 수 있어요.
엘로퀀트는 각 모델 테이블에 id라는 기본 키가 있다고 가정하고, 이를 정수로 자동 캐스팅해요. 다른 컬럼을 쓰려면 $primaryKey로, 자동 증가가 아니면 $incrementing = false로, 정수가 아니면 $keyType = 'string'으로 지정해요. 참고로 엘로퀀트는 복합(composite) 기본 키는 지원하지 않아요.
class Flight extends Model
{
protected $primaryKey = 'flight_id';
public $incrementing = false;
protected $keyType = 'string';
}
UUID·ULID 키
자동 증가 정수 대신 UUID나 ULID를 기본 키로 쓸 수도 있어요. HasUuids 트레이트를 쓰면 UUIDv7 식별자를 생성하는데, 사전식 정렬이 가능해 인덱스 저장에 효율적이에요. 생성 과정을 바꾸려면 newUniqueId를, 어떤 컬럼에 적용할지는 uniqueIds를 오버라이드해요. HasUlids 트레이트는 26자 길이의 ULID를 써요.
use Illuminate\Database\Eloquent\Concerns\HasUuids;
class Article extends Model
{
use HasUuids;
}
$article = Article::create(['title' => 'Traveling to Europe']);
$article->id; // "018f2b5c-6a7f-7b12-9d6f-2f8a4e0c9c11"
타임스탬프와 기본값
기본적으로 엘로퀀트는 created_at·updated_at 컬럼이 있다고 가정하고 생성·수정 시 자동으로 값을 설정해요. 관리를 끄려면 $timestamps = false로, 저장 형식을 바꾸려면 $dateFormat으로, 컬럼 이름을 바꾸려면 CREATED_AT·UPDATED_AT 상수로 지정해요. updated_at을 건드리고 싶지 않은 작업은 withoutTimestamps 안에서 실행하면 돼요.
class Flight extends Model
{
public $timestamps = false;
protected $dateFormat = 'U';
public const CREATED_AT = 'creation_date';
public const UPDATED_AT = 'updated_date';
}
모델이 항상 다른 DB 커넥션을 쓰게 하려면 $connection을, 새 인스턴스의 기본 속성값을 두고 싶으면 $attributes 배열을 정의해요.
엘로퀀트 엄격성 설정
Laravel은 몇 가지 메서드로 엘로퀀트의 동작과 "엄격성"을 설정할 수 있게 해줘요. Model::preventLazyLoading은 지연 로딩을 막고, Model::preventSilentlyDiscardingAttributes는 채울 수 없는 속성을 조용히 버리는 대신 예외를 던지게 해요. 보통 AppServiceProvider::boot에서 비프로덕션 환경에만 적용해요.
public function boot(): void
{
Model::preventLazyLoading(! $this->app->isProduction());
Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction());
}
모델 조회하기 (Retrieving Models)
각 엘로퀀트 모델은 강력한 쿼리 빌더로 생각할 수 있어요. all 메서드로 테이블의 모든 레코드를 가져오고, 쿼리를 추가한 뒤 get으로 결과를 받아요.
use App\Models\Flight;
foreach (Flight::all() as $flight) {
echo $flight->name;
}
$flights = Flight::where('active', 1)
->orderBy('name')
->limit(10)
->get();
모델 인스턴스를 DB에서 다시 가져오고 싶다면 fresh(기존 인스턴스는 그대로 두고 재조회)나 refresh(기존 인스턴스를 새 데이터로 재수화)를 써요.
컬렉션
all·get은 일반 PHP 배열이 아니라 Illuminate\Database\Eloquent\Collection 인스턴스를 돌려줘요. 이 컬렉션은 Laravel의 기본 컬렉션 클래스가 제공하는 다양한 도우미 메서드들을 상속해요. 예를 들어 reject로 클로저 결과에 따라 모델을 제거할 수 있죠.
$flights = Flight::where('destination', 'Paris')->get();
$flights = $flights->reject(function (Flight $flight) {
return $flight->cancelled;
});
결과 청크 처리
all이나 get으로 수만 개의 레코드를 한 번에 로드하면 메모리가 고갈될 수 있어요. chunk 메서드는 청크 단위로 모델을 조회해 클로저에 넘겨서 메모리 사용을 크게 줄여줘요. 순회 중에 갱신할 컬럼으로 결과를 필터링한다면, 일관되지 않은 결과를 피하기 위해 chunkById를 써야 해요.
Flight::where('departed', true)
->chunkById(200, function (Collection $flights) {
$flights->each->update(['departed' => false]);
}, column: 'id');
lazy 메서드는 내부적으로 청크로 쿼리를 실행하되, 결과를 단일 스트림처럼 다루는 평탄화된 LazyCollection으로 돌려줘요. cursor 메서드는 단일 DB 쿼리만 실행하고 모델을 실제 순회할 때 수화해서, 한 번에 메모리에 하나의 모델만 유지해요. 단, cursor는 관계를 eager load할 수 없어서 그게 필요하면 lazy를 써야 해요.
단일 모델·집계 조회
find, first, firstWhere 메서드는 모델 컬렉션이 아니라 단일 모델 인스턴스를 돌려줘요. 결과가 없을 때 다른 동작을 하려면 findOr·firstOr가 클로저를 실행하고, 예외를 던지려면 findOrFail·firstOrFail을 써요. ModelNotFoundException이 잡히지 않으면 자동으로 404 응답이 클라이언트에 전송돼요.
$flight = Flight::find(1);
$flight = Flight::where('active', 1)->first();
$flight = Flight::where('legs', '>', 3)->firstOrFail();
firstOrCreate는 주어진 속성으로 레코드를 찾고 없으면 삽입하며, firstOrNew는 찾고 없으면 (아직 저장되지 않은) 새 모델 인스턴스를 돌려줘요. 집계 값은 쿼리 빌더의 count·sum·max 등을 그대로 쓸 수 있어요.
모델 삽입·수정하기
삽입
새 모델 인스턴스를 만들어 속성을 설정하고 save()를 호출하면 레코드가 삽입돼요. created_at·updated_at은 자동으로 설정돼요. 한 문장으로는 create 메서드를 쓸 수 있는데, 이때는 반드시 $fillable이나 $guarded 프로퍼티를 지정해야 해요. 모든 모델이 기본적으로 대량 할당(mass assignment) 취약점으로부터 보호되기 때문이에요.
$flight = Flight::create([
'name' => 'London to Paris',
]);
수정
DB에 이미 있는 모델을 수정하려면 조회해서 속성을 바꾸고 save()를 호출해요. updateOrCreate는 조건에 맞는 모델이 있으면 갱신하고 없으면 생성해요. 모델의 수명 주기 동안 생성됐는지 확인하려면 wasRecentlyCreated를 써요. 쿼리 조건에 맞는 모든 모델을 일괄 갱신할 수도 있는데, 이때는 모델 이벤트가 발생하지 않아요.
$flight = Flight::find(1);
$flight->name = 'Paris to London';
$flight->save();
$flight = Flight::updateOrCreate(
['departure' => 'Oakland', 'destination' => 'San Diego'],
['price' => 99, 'discounted' => 1]
);
모델의 내부 상태 변화를 살펴보려면 isDirty·isClean·wasChanged를, 원래 속성값을 보려면 getOriginal을, 마지막 저장 때 바뀐 속성과 이전 값을 보려면 getChanges·getPrevious를 써요.
대량 할당 (Mass Assignment)
대량 할당 취약점은 사용자가 예상치 못한 HTTP 요청 필드를 보내 예상 밖의 DB 컬럼을 바꾸는 상황이에요. 예를 들어 악의적인 사용자가 is_admin 파라미터를 보내 자신을 관리자로 승격시킬 수 있어요. 그래서 기본적으로 보호가 켜져 있고, $fillable에 대량 할당할 속성을 명시하거나 $guarded로 제외할 속성을 지정해요. 모든 속성을 허용하려면 $guarded = []로 하되, fill·create·update에 넘기는 배열을 항상 직접 검증해야 해요. $fillable에 없는 속성을 조용히 버리는 대신 예외를 원하면 preventSilentlyDiscardingAttributes를 호출해요.
class Flight extends Model
{
protected $fillable = ['name'];
}
Upsert
upsert 메서드는 레코드를 한 번의 원자적(atomic) 작업으로 갱신하거나 생성해요. 첫 인자는 삽입·갱신할 값, 둘째 인자는 레코드를 식별하는 컬럼들, 셋째 인자는 기존 레코드가 있을 때 갱신할 컬럼들이에요. 타이머스탬프가 켜져 있으면 자동으로 설정돼요.
Flight::upsert([
['departure' => 'Oakland', 'destination' => 'San Diego', 'price' => 99],
['departure' => 'Chicago', 'destination' => 'New York', 'price' => 150]
], uniqueBy: ['departure', 'destination'], update: ['price']);
모델 삭제하기
모델 인스턴스에서 delete()를 호출하거나, 기본 키를 알면 조회 없이 destroy()로 삭제해요. destroy는 각 모델을 개별 로드해 delete를 호출하므로 이벤트가 제대로 발동돼요. 쿼리 조건에 맞는 모든 모델을 일괄 삭제할 수도 있는데, 이때 모델 이벤트는 발동되지 않아요.
$flight = Flight::find(1);
$flight->delete();
Flight::destroy(1, 2, 3);
소프트 삭제 (Soft Deleting)
레코드를 실제로 지우는 대신, deleted_at 속성을 설정해 "소프트 삭제"할 수 있어요. SoftDeletes 트레이트를 모델에 추가하고, 스키마 빌더의 softDeletes() 헬퍼로 deleted_at 컬럼을 만들어요. 소프트 삭제 모델을 쿼리하면 삭제된 모델은 자동으로 결과에서 제외돼요.
use Illuminate\Database\Eloquent\SoftDeletes;
class Flight extends Model
{
use SoftDeletes;
}
trashed()로 소프트 삭제 여부를 확인하고, restore()로 복원하며, forceDelete()로 완전히 제거해요. 삭제된 모델까지 결과에 포함하려면 withTrashed(), 삭제된 모델만 가져오려면 onlyTrashed()를 써요.
$flight->restore();
$flight->forceDelete();
Flight::withTrashed()->where('airline_id', 1)->get();
Flight::onlyTrashed()->where('airline_id', 1)->get();
모델 프루닝(Pruning)
주기적으로 더 이상 필요 없는 모델을 삭제하고 싶다면 Prunable 트레이트를 추가하고 prunable 메서드로 삭제 대상 쿼리를 정의해요. 그리고 routes/console.php에서 model:prune 커맨드를 스케줄해요. MassPrunable 트레이트는 일괄 삭제 쿼리로 처리해 더 효율적이지만, pruning 메서드와 모델 이벤트는 발동되지 않아요.
use Illuminate\Database\Eloquent\Prunable;
class Flight extends Model
{
use Prunable;
public function prunable(): Builder
{
return static::where('created_at', '<=', now()->minus(months: 1));
}
}
Schedule::command('model:prune')->daily();
쿼리 스코프 (Query Scopes)
전역 스코프
전역 스코프는 특정 모델의 모든 쿼리에 제약을 추가해요. Laravel의 소프트 삭제도 전역 스코프를 이용해 "삭제되지 않은" 모델만 조회해요. make:scope 커맨드로 생성하고, Scope 인터페이스의 apply 메서드를 구현해요. #[ScopedBy] 속성 또는 booted에서 addGlobalScope로 등록해요. 특정 쿼리에서만 제거하려면 withoutGlobalScope를 써요.
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class AncientScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where('created_at', '<', now()->minus(years: 2000));
}
}
로컬 스코프
로컬 스코프는 애플리케이션 전반에서 쉽게 재사용할 수 있는 공통 쿼리 제약의 묶음이에요. Eloquent 메서드에 #[Scope] 속성을 붙여 정의하고, 쿼리에서 User::popular()->active()->get()처럼 체이닝해 호출해요. 파라미터를 받는 동적 스코프는 $query 뒤에 인자를 추가하면 돼요.
use Illuminate\Database\Eloquent\Attributes\Scope;
class User extends Model
{
#[Scope]
protected function popular(Builder $query): void
{
$query->where('votes', '>', 100);
}
#[Scope]
protected function ofType(Builder $query, string $type): void
{
$query->where('type', $type);
}
}
모델 비교와 이벤트
두 모델이 같은 기본 키·테이블·DB 커넥션을 갖는지 확인하려면 is·isNot 메서드를 써요. 관계에서 쿼리 없이 관련 모델을 비교할 때 특히 유용해요.
엘로퀀트 모델은 수명 주기의 여러 지점(retrieved, creating, created, updating, updated, saving, saved, deleting, deleted, restoring, restored 등)에서 이벤트를 발생시켜요. -ing으로 끝나는 이벤트는 변경이 저장되기 전에, -ed로 끝나는 이벤트는 저장된 후에 발동돼요. 클로저로 수신기를 등록하거나, $dispatchesEvents 프로퍼티로 이벤트를 매핑할 수 있어요.
class User extends Model
{
protected static function booted(): void
{
static::created(function (User $user) {
// ...
});
}
}
많은 이벤트를 한 모델에서 수신한다면 옵저버(Observer) 클래스로 묶을 수 있어요. make:observer 커맨드로 생성하고 #[ObservedBy] 속성이나 observe 메서드로 등록해요. DB 트랜잭션이 커밋된 뒤에만 처리하려면 ShouldHandleEventsAfterCommit 인터페이스를 구현해요. 모델이 발동하는 모든 이벤트를 일시적으로 막으려면 withoutEvents나 saveQuietly·deleteQuietly 같은 *Quietly 메서드를 써요.
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
#[ObservedBy([UserObserver::class])]
class User extends Authenticatable
{
// ...
}
더 알아보기 (Learn more)
- Laravel 공식 문서의 Eloquent: Getting Started 페이지
- Eloquent Relationships 문서에서 모델 관계 다루기
- Migrations 문서에서 테이블·컬럼 스키마 정의하기
- Query Builder 문서에서 엘로퀀트와 함께 쓰는 쿼리 빌더 메서드 익히기