배열에서 모델로 넘어가기
지난 장에서는 posts 테이블을 만들었다. 이제 컨트롤러 안에 있던 임시 배열 대신 데이터베이스의 게시글을 읽어 화면에 보여 줄 차례다.
Laravel에서는 이 작업을 보통 Eloquent ORM으로 처리한다. ORM은 Object Relational Mapping의 줄임말이다. 관계형 데이터베이스의 행을 PHP 객체처럼 다루게 해 주는 방식이다. 즉 posts 테이블의 한 행을 Post 객체 하나로 보고, SQL을 매번 직접 쓰지 않아도 다음처럼 읽을 수 있다.
$post = Post::find(1);
echo $post->title;이 코드는 내부적으로 데이터베이스에서 id가 1인 게시글을 찾는다. 하지만 우리가 작성하는 코드는 "게시글을 찾고 제목을 읽는다"는 의도에 더 가깝다. Eloquent의 장점은 여기에 있다. SQL을 숨기는 것이 목적이 아니라, 데이터 작업을 애플리케이션 코드 안에서 읽기 쉽게 표현하는 것이 목적이다.
AI 생성 이미지
Post 모델 만들기
먼저 posts 테이블과 연결할 모델을 만든다. 지난 장에서 팩토리를 만들면서 php artisan make:model Post -f를 실행했다면 이미 app/Models/Post.php 파일이 있을 수 있다. 그 경우에는 새로 만들지 말고 기존 파일을 열어 이어서 작성하면 된다.
아직 모델이 없다면 다음 명령으로 만든다.
php artisan make:model Post그러면 보통 다음 파일이 생성된다.
app/Models/Post.php기본 모델은 매우 짧다.
<?php
namespace AppModels;
use IlluminateDatabaseEloquentModel;
class Post extends Model
{
//
}여기서 중요한 점은 Post라는 클래스 이름만으로도 Eloquent가 연결할 테이블을 추측한다는 것이다. 기본 규칙은 모델 이름을 복수형 snake_case로 바꾸는 방식이다. Post 모델은 posts 테이블에 연결되고, BlogPost 모델은 blog_posts 테이블에 연결된다.1
대부분은 이 규칙을 따르는 편이 좋다. Laravel의 기본값과 맞기 때문에 코드가 짧아지고, 다른 개발자가 보아도 예측하기 쉽다.
그래도 테이블 이름이 다르다면 직접 지정할 수 있다.
class Post extends Model
{
protected $table = 'articles';
}기본 키도 규칙이 있다. Eloquent는 기본 키 컬럼이 id라고 가정한다. 지난 장의 마이그레이션에서 $table->id()를 사용했다면 이 규칙과 맞다. 만약 기본 키 이름이 다르면 다음처럼 지정한다.
class Post extends Model
{
protected $primaryKey = 'post_id';
}또 하나의 기본 규칙은 시간 컬럼이다. 마이그레이션에서 $table->timestamps()를 사용하면 created_at, updated_at 컬럼이 생긴다. Eloquent는 레코드를 만들 때 created_at과 updated_at을 자동으로 채우고, 수정할 때 updated_at을 자동으로 갱신한다.1
이 자동 처리가 싫거나 테이블에 시간 컬럼이 없다면 다음처럼 끌 수 있다.
class Post extends Model
{
public $timestamps = false;
}하지만 게시글처럼 생성일과 수정일이 의미 있는 데이터라면 기본값을 유지하는 편이 좋다.
fillable과 guarded: 저장해도 되는 값 정하기
Eloquent에는 대량 할당이라는 기능이 있다. 폼에서 받은 값을 배열로 넘겨 한 번에 모델을 만들거나 수정하는 방식이다.
Post::create([
'title' => '첫 번째 글',
'body' => 'Eloquent로 저장한 글입니다.',
'is_published' => true,
]);편리하지만 위험도 있다. 사용자가 보내면 안 되는 컬럼까지 함께 보내면 어떻게 될까? 예를 들어 user_id, is_admin, role 같은 값을 사용자가 임의로 바꾸면 문제가 된다.
그래서 모델에는 "한 번에 저장해도 되는 컬럼"을 명시한다.
class Post extends Model
{
protected $fillable = [
'title',
'body',
'is_published',
'published_at',
];
}$fillable은 허용 목록이다. 이 배열에 들어 있는 컬럼만 create()나 update()로 대량 할당할 수 있다. 위 예제처럼 설정하면 title, body, is_published, published_at은 한 번에 저장할 수 있지만, user_id는 사용자가 보낸 배열만으로는 바뀌지 않는다.
반대로 $guarded는 보호 목록이다.
class Post extends Model
{
protected $guarded = ['id'];
}이 설정은 id만 보호하고 나머지는 허용한다는 뜻이다. 그래서 작은 예제에서는 편해 보이지만, 실제 서비스에서는 의도하지 않은 컬럼까지 열어 둘 수 있다. 학습 단계에서는 $fillable을 추천한다. 어떤 값이 저장 가능한지 눈에 바로 보이고, 실수로 민감한 컬럼을 열어 둘 가능성이 줄어든다.
컨트롤러에서 게시글 조회하기
이제 PostController에서 배열을 제거하고 실제 데이터베이스를 조회해 보자.
<?php
namespace AppHttpControllers;
use AppModelsPost;
class PostController extends Controller
{
public function index()
{
$posts = Post::orderBy('created_at', 'desc')->get();
return view('posts.index', [
'posts' => $posts,
]);
}
public function show(Post $post)
{
return view('posts.show', [
'post' => $post,
]);
}
}Post::orderBy('created_at', 'desc')->get()은 게시글을 최신순으로 가져온다. 여기서 Post:: 뒤에 이어지는 코드는 쿼리 빌더처럼 동작한다. 조건을 붙이고, 정렬을 정하고, 마지막에 get()으로 실제 조회를 실행한다.
show(Post $post)도 눈여겨보자. 라우트가 다음처럼 되어 있다면,
Route::get('/posts/{post}', [PostController::class, 'show'])
->name('posts.show');Laravel은 {post}에 들어온 값을 보고 Post 모델을 자동으로 찾는다. 이것을 라우트 모델 바인딩이라고 한다. /posts/1로 요청하면 id가 1인 Post가 $post로 들어온다. 찾지 못하면 자동으로 404 응답이 반환된다. 이전 장에서 직접 abort(404)를 호출했던 작업을 Laravel이 일관된 방식으로 처리해 주는 셈이다.
게시글이 많아지면 get()보다 페이지네이션을 사용하는 편이 좋다.
public function index()
{
$posts = Post::where('is_published', true)
->orderBy('created_at', 'desc')
->paginate(10);
return view('posts.index', compact('posts'));
}그리고 Blade에서는 링크를 출력한다.
@foreach ($posts as $post)
<article>
<h2>
<a href="{{ route('posts.show', $post) }}">
{{ $post->title }}
</a>
</h2>
<p>{{ $post->created_at->format('Y-m-d') }}</p>
</article>
@endforeach
{{ $posts->links() }}paginate(10)은 한 페이지에 10개씩 가져오고, links()는 페이지 이동 링크를 만든다. 목록 화면에서는 처음부터 페이지네이션을 고려하는 습관이 좋다. 개발 중에는 게시글이 5개뿐이어도, 실제 서비스에서는 5만 개가 될 수 있기 때문이다.
기본 저장, 조회, 수정, 삭제
Eloquent의 기본 작업은 다음 몇 가지 메서드만 알아도 시작할 수 있다.
먼저 저장이다.
$post = Post::create([
'title' => 'Laravel 게시판 만들기',
'body' => 'Eloquent를 사용해 게시글을 저장합니다.',
'is_published' => true,
]);단, 이 방식은 앞에서 본 $fillable 설정이 있어야 안전하게 동작한다. $fillable에 없는 값을 함께 넘기면 Eloquent가 대량 할당을 막아 준다.
한 건을 찾을 때는 find()를 사용한다.
$post = Post::find(1);find()는 찾지 못하면 null을 반환한다. 못 찾았을 때 404로 처리하고 싶다면 findOrFail()이 더 적합하다.
$post = Post::findOrFail(1);조건을 붙일 때는 where()를 사용한다.
$posts = Post::where('is_published', true)
->orderBy('created_at', 'desc')
->get();수정은 모델을 가져온 뒤 값을 바꾸고 save()를 호출할 수 있다.
$post = Post::findOrFail(1);
$post->title = '수정된 제목';
$post->save();또는 대량 할당으로 수정할 수도 있다.
$post->update([
'title' => '수정된 제목',
'body' => '수정된 본문',
]);삭제는 delete()를 호출한다.
$post = Post::findOrFail(1);
$post->delete();이 메서드들은 단순해 보이지만, 컨트롤러에 SQL 문자열을 흩뿌리지 않게 해 준다. 컨트롤러는 "무엇을 조회하고 어디로 보낼지"에 집중하고, 데이터베이스와 연결되는 세부 작업은 모델이 맡는다.
관계 정의하기: User, Post, Comment
웹 애플리케이션의 데이터는 보통 혼자 존재하지 않는다. 게시글은 작성자를 가지고, 댓글은 게시글에 속한다. 이런 연결을 Eloquent에서는 관계로 표현한다.
예를 들어 사용자 한 명이 여러 게시글을 쓴다면 User 모델에는 다음 관계를 정의한다.
use IlluminateDatabaseEloquentRelationsHasMany;
class User extends Authenticatable
{
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
}반대로 게시글 입장에서는 작성자 한 명에게 속한다.
use IlluminateDatabaseEloquentRelationsBelongsTo;
class Post extends Model
{
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}hasMany는 "하나가 여러 개를 가진다"는 뜻이고, belongsTo는 "이 모델이 다른 모델에 속한다"는 뜻이다. Eloquent는 기본 규칙에 따라 posts 테이블에 user_id 컬럼이 있다고 가정한다. 지난 장의 마이그레이션에서 foreignId('user_id')를 추가했다면 이 규칙과 맞다. 만약 외래 키 이름이 다르다면 관계 메서드에서 직접 지정할 수 있지만, 처음에는 user_id, post_id처럼 관례를 따르는 편이 가장 쉽다.2
댓글도 비슷하다. 댓글 테이블에는 보통 post_id 컬럼이 있어야 한다.
class Post extends Model
{
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
}class Comment extends Model
{
public function post(): BelongsTo
{
return $this->belongsTo(Post::class);
}
}이제 다음처럼 객체를 따라가며 데이터를 읽을 수 있다.
$post = Post::findOrFail(1);
echo $post->user->name;
foreach ($post->comments as $comment) {
echo $comment->body;
}다만 지난 장의 예제처럼 user_id를 nullable()로 만들었다면 작성자가 없는 게시글도 있을 수 있다. 그런 경우에는 $post->user가 null일 수 있으므로 실제 화면에서는 작성자가 없을 때의 표시도 함께 생각해야 한다.
{{ $post->user?->name ?? '작성자 없음' }}관계는 속성처럼 접근할 수도 있고, 메서드처럼 호출해 조건을 더 붙일 수도 있다.
$recentComments = $post->comments()
->orderBy('created_at', 'desc')
->get();$post->comments는 관련 댓글을 가져온 결과이고, $post->comments()는 댓글 관계 쿼리를 더 이어 갈 수 있는 출발점이다. 이 차이를 구분해야 한다.
다대다 관계: 태그 붙이기
게시글과 태그는 다대다 관계가 잘 어울린다. 게시글 하나에는 여러 태그가 붙을 수 있고, 태그 하나도 여러 게시글에 붙을 수 있다.
이 경우 보통 중간 테이블을 둔다.
posts
- id
- title
tags
- id
- name
post_tag
- post_id
- tag_idLaravel은 기본적으로 두 모델 이름의 단수형을 알파벳순으로 연결한 이름을 중간 테이블 이름으로 추측한다. 그래서 Post와 Tag 사이의 중간 테이블은 post_tag가 된다. 컬럼 이름도 각각 post_id, tag_id를 기본으로 기대한다.2
모델에는 belongsToMany를 정의한다.
use IlluminateDatabaseEloquentRelationsBelongsToMany;
class Post extends Model
{
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class);
}
}class Tag extends Model
{
public function posts(): BelongsToMany
{
return $this->belongsToMany(Post::class);
}
}이제 게시글의 태그를 읽을 수 있다.
$post = Post::with('tags')->findOrFail(1);
foreach ($post->tags as $tag) {
echo $tag->name;
}태그를 붙일 때는 다음처럼 중간 테이블을 직접 다루지 않고 관계 메서드를 사용한다.
$post->tags()->attach($tagId);이미 연결된 태그 목록을 특정 배열과 맞추고 싶다면 sync()를 사용할 수 있다.
$post->tags()->sync([1, 2, 3]);attach()는 추가에 가깝고, sync()는 현재 연결 상태를 지정한 목록과 맞추는 동작에 가깝다. 예를 들어 기존 태그가 [1, 2]였는데 sync([2, 3])을 실행하면 1번 태그 연결은 제거되고 3번 태그 연결은 추가된다. 태그 수정 폼에서는 sync()가 더 자주 쓰인다.
N+1 문제와 eager loading
관계를 편하게 읽을 수 있다는 것은 장점이지만, 아무 때나 사용하면 성능 문제가 생긴다.
다음 코드를 보자.
$posts = Post::orderBy('created_at', 'desc')->get();
foreach ($posts as $post) {
echo $post->user->name;
}겉보기에는 자연스럽다. 하지만 게시글을 가져오는 쿼리 1번이 먼저 실행되고, 반복문 안에서 각 게시글의 작성자를 가져오는 쿼리가 게시글 수만큼 추가될 수 있다. 게시글이 30개라면 총 31번의 쿼리가 실행될 수 있다. 이것을 N+1 문제라고 부른다.
해결 방법은 필요한 관계를 처음부터 함께 가져오는 것이다. 이를 eager loading이라고 한다.
$posts = Post::with('user')
->orderBy('created_at', 'desc')
->paginate(10);댓글과 태그도 함께 필요하다면 배열로 전달한다.
$posts = Post::with(['user', 'comments', 'tags'])
->orderBy('created_at', 'desc')
->paginate(10);항상 모든 관계를 가져오는 것이 좋은 것은 아니다. 목록 화면에 작성자 이름만 필요하다면 user만 가져오면 된다. 상세 화면에서 댓글이 필요할 때만 comments를 가져오는 식으로 화면의 요구에 맞추는 것이 좋다.
이미 가져온 모델에 나중에 관계를 불러올 수도 있다.
$post = Post::findOrFail(1);
$post->load(['user', 'comments']);with()는 조회하기 전에 관계를 지정하고, load()는 이미 조회한 모델에 관계를 추가로 불러온다. 둘 다 N+1 문제를 줄이는 데 사용된다.2
Accessor, Mutator, Casts로 표현 규칙 모으기
모델은 단순히 테이블과 연결되는 클래스만은 아니다. 데이터 표현 규칙을 모아 둘 수도 있다.
예를 들어 is_published 컬럼은 데이터베이스에서 0 또는 1로 저장될 수 있다. 하지만 PHP 코드에서는 참과 거짓으로 다루는 편이 자연스럽다. 이럴 때 cast를 사용한다.
class Post extends Model
{
protected function casts(): array
{
return [
'is_published' => 'boolean',
'published_at' => 'datetime',
];
}
}이제 $post->is_published는 boolean처럼 다룰 수 있고, $post->published_at은 날짜 객체처럼 format() 같은 메서드를 사용할 수 있다. 단, published_at이 아직 비어 있는 글이라면 값이 null일 수 있으므로 화면에서 사용할 때는 그 경우도 함께 처리해야 한다.
Accessor는 값을 읽을 때의 표현을 정한다. 예를 들어 목록에서 본문 일부만 보여 주는 excerpt 속성을 만들 수 있다.
use IlluminateDatabaseEloquentCastsAttribute;
use IlluminateSupportStr;
class Post extends Model
{
protected function excerpt(): Attribute
{
return Attribute::make(
get: fn () => Str::limit($this->body, 100)
);
}
}Blade에서는 실제 컬럼처럼 읽을 수 있다.
<p>{{ $post->excerpt }}</p>Mutator는 값을 저장하기 전에 다듬을 때 사용한다. 제목 앞뒤 공백을 제거하고 싶다면 다음처럼 작성할 수 있다.
protected function title(): Attribute
{
return Attribute::make(
set: fn (string $value) => trim($value)
);
}이 규칙을 컨트롤러마다 반복하면 실수하기 쉽다. 모델 안에 넣어 두면 게시글이 어디에서 저장되든 같은 규칙이 적용된다.
Scope로 자주 쓰는 조건 이름 붙이기
게시글 목록에서는 공개된 글만 자주 가져오게 된다. 매번 다음 조건을 반복하면 코드가 길어진다.
Post::where('is_published', true)
->whereNotNull('published_at')
->orderBy('created_at', 'desc')
->get();이럴 때 모델에 scope를 만들 수 있다.
use IlluminateDatabaseEloquentBuilder;
class Post extends Model
{
public function scopePublished(Builder $query): Builder
{
return $query->where('is_published', true)
->whereNotNull('published_at');
}
}사용할 때는 published()처럼 호출한다.
$posts = Post::published()
->orderBy('published_at', 'desc')
->paginate(10);Scope는 "자주 쓰는 쿼리 조건에 이름을 붙이는 방법"이다. 컨트롤러는 더 읽기 쉬워지고, 공개 글의 기준이 바뀌어도 모델 한 곳만 수정하면 된다.
모델이 커질 때의 기준
Eloquent를 배우면 많은 코드를 모델 안에 넣고 싶어진다. 하지만 모델이 모든 일을 해야 하는 것은 아니다.
모델에 두기 좋은 코드는 다음과 같다.
테이블과 컬럼의 기본 설정
관계 정의
데이터 타입 변환
값의 읽기와 저장 규칙
자주 쓰는 조회 조건
반대로 요청 처리, 화면 선택, 리다이렉트, 세션 메시지는 컨트롤러의 역할에 가깝다. 모델은 데이터의 규칙을 알고, 컨트롤러는 요청의 흐름을 조정한다. Blade는 화면을 표현한다.
이 구분을 지키면 지금까지 배운 라우트, 컨트롤러, 뷰, 데이터베이스가 자연스럽게 연결된다.
라우트
→ PostController
→ Post 모델
→ posts 테이블
→ Blade 화면Eloquent는 SQL을 몰라도 되게 만드는 도구가 아니다. 데이터베이스 작업을 애플리케이션의 언어로 읽기 쉽게 표현하게 해 주는 도구다. 기본 SQL의 개념을 이해하고, Eloquent의 모델과 관계를 함께 사용하면 Laravel 코드가 훨씬 단순해진다.
이어지는 장에서는 이 모델을 사용해 사용자가 입력한 폼 데이터를 저장한다. 그때 중요한 주제는 유효성 검증이다. 사용자가 보낸 값을 믿지 않고, 저장하기 전에 어떤 값이 올바른지 검사하는 과정을 다룬다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.