CRUD가 애플리케이션의 기본 골격인 이유
게시판은 글을 보여 주기만 해서는 완성되지 않는다. 사용자는 글을 작성하고, 다시 읽고, 잘못 쓴 내용을 고치고, 더 이상 필요 없는 글을 삭제한다. 이 네 가지 동작을 보통 CRUD라고 부른다.
Create: 새 데이터를 만든다.
Read: 데이터를 조회한다.
Update: 기존 데이터를 수정한다.
Delete: 데이터를 삭제한다.
거의 모든 웹 서비스는 이 흐름을 가진다. 상품 관리, 댓글, 공지사항, 프로필, 주문 내역도 결국 어떤 리소스를 만들고 읽고 고치고 지우는 구조에서 출발한다. 그래서 Laravel에서 CRUD를 한 번 제대로 익히면 이후 기능을 만들 때 반복해서 사용할 수 있는 기본 루틴이 생긴다.
이 장에서는 지금까지 만든 posts 리소스를 기준으로 게시글 목록, 상세, 작성, 저장, 수정 폼, 수정 처리, 삭제 처리를 RESTful 방식으로 정리한다. RESTful 설계에서는 URL이 대상을 나타내고, HTTP 메서드가 행동을 나타낸다. 예를 들어 /posts/3은 3번 게시글이라는 대상을 가리키고, GET, PATCH, DELETE는 각각 조회, 수정, 삭제라는 의도를 표현한다.
AI 생성 이미지
리소스 라우트로 일곱 개 동작 묶기
게시글 CRUD에 필요한 라우트를 직접 하나씩 작성할 수도 있다.
Route::get('/posts', [PostController::class, 'index'])->name('posts.index');
Route::get('/posts/create', [PostController::class, 'create'])->name('posts.create');
Route::post('/posts', [PostController::class, 'store'])->name('posts.store');하지만 CRUD는 Laravel에서 워낙 자주 쓰이는 구조라서 하나의 리소스 라우트로 묶을 수 있다.
// routes/web.php
use AppHttpControllersPostController;
use IlluminateSupportFacadesRoute;
Route::resource('posts', PostController::class);컨트롤러가 아직 없다면 다음 명령으로 리소스 컨트롤러를 만들 수 있다. 이미 PostController가 있다면 새로 만들지 말고 필요한 메서드만 추가하면 된다.
php artisan make:controller PostController --resource --model=Post버전에 따라 Artisan 명령 옵션은 조금씩 달라질 수 있다. 헷갈리면 php artisan make:controller --help로 현재 프로젝트에서 사용할 수 있는 옵션을 확인하자.
Route::resource('posts', PostController::class)는 다음 라우트를 한 번에 등록한다.
| HTTP 메서드 | URL | 컨트롤러 메서드 | 라우트 이름 | 역할 |
|---|---|---|---|---|
| GET | /posts | index | posts.index | 목록 |
| GET | /posts/create | create | posts.create | 작성 폼 |
| POST | /posts | store | posts.store | 저장 |
| GET | /posts/{post} | show | posts.show | 상세 |
| GET | /posts/{post}/edit | edit | posts.edit | 수정 폼 |
| PUT/PATCH | /posts/{post} | update | posts.update | 수정 처리 |
| DELETE | /posts/{post} | destroy | posts.destroy | 삭제 |
라우트가 제대로 등록되었는지는 다음 명령으로 확인한다.
php artisan route:list --name=posts이제 라우트 파일은 다시 단순해졌다. URL과 컨트롤러를 연결하는 입구 역할에 집중하고, 실제 요청 처리는 PostController가 맡는다.
컨트롤러에서 CRUD 흐름 완성하기
먼저 Post 모델에 대량 할당을 허용할 컬럼이 있는지 확인한다. 지난 장에서 작성했다면 그대로 사용하면 된다.
// app/Models/Post.php
protected $fillable = [
'title',
'body',
];이제 PostController에 일곱 개 메서드를 채운다.
<?php
namespace AppHttpControllers;
use AppModelsPost;
use IlluminateHttpRequest;
class PostController extends Controller
{
public function index(Request $request)
{
$keyword = trim((string) $request->input('keyword', ''));
$sort = $request->input('sort', 'latest');
$query = Post::query();
if ($keyword !== '') {
$query->where(function ($q) use ($keyword) {
$q->where('title', 'like', '%' . $keyword . '%')
->orWhere('body', 'like', '%' . $keyword . '%');
});
}
if ($sort === 'oldest') {
$query->oldest();
} elseif ($sort === 'title') {
$query->orderBy('title');
} else {
$sort = 'latest';
$query->latest();
}
$posts = $query->paginate(10)->withQueryString();
return view('posts.index', compact('posts', 'keyword', 'sort'));
}
public function create()
{
return view('posts.create', ['post' => new Post()]);
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => ['required', 'string', 'max:120'],
'body' => ['required', 'string'],
]);
$post = Post::create($validated);
return redirect()
->route('posts.show', $post)
->with('status', '게시글을 작성했습니다.');
}
public function show(Post $post)
{
return view('posts.show', compact('post'));
}
public function edit(Post $post)
{
return view('posts.edit', compact('post'));
}
public function update(Request $request, Post $post)
{
$validated = $request->validate([
'title' => ['required', 'string', 'max:120'],
'body' => ['required', 'string'],
]);
$post->update($validated);
return redirect()
->route('posts.show', $post)
->with('status', '게시글을 수정했습니다.');
}
public function destroy(Post $post)
{
$post->delete();
return redirect()
->route('posts.index')
->with('status', '게시글을 삭제했습니다.');
}
}store와 update에는 같은 검증 규칙이 반복된다. 지금은 흐름을 익히기 위해 컨트롤러 안에 두어도 괜찮다. 다만 프로젝트가 커지면 이 검증 규칙은 Form Request로 분리하는 편이 좋다. 그러면 컨트롤러는 요청 처리 흐름에 더 집중하고, 검증 규칙은 별도의 클래스에서 관리할 수 있다.
Route Model Binding으로 모델을 바로 받기
show(Post $post), edit(Post $post), update(Request $request, Post $post), destroy(Post $post)를 보면 컨트롤러가 숫자 ID가 아니라 Post 객체를 직접 받는다. 이것이 Route Model Binding이다.
리소스 라우트의 URL에는 {post}가 들어 있다.
/posts/{post}사용자가 /posts/3으로 들어오면 Laravel은 {post} 값인 3을 보고 Post 모델에서 해당 데이터를 찾는다. 데이터가 있으면 컨트롤러에 Post $post 객체를 넘겨 주고, 없으면 404 응답을 반환한다.
덕분에 컨트롤러마다 다음 코드를 반복하지 않아도 된다.
$post = Post::findOrFail($id);중요한 점은 라우트 파라미터 이름과 컨트롤러 인자의 모델 이름이 자연스럽게 대응되어야 한다는 것이다. Route::resource('posts', ...)가 만드는 파라미터는 {post}이고, 컨트롤러에서는 Post $post로 받는다. 이 약속을 따르면 조회 실패 처리까지 Laravel이 일관되게 맡아 준다.
작성 폼과 수정 폼 연결하기
작성과 수정은 화면이 비슷하다. 둘 다 제목과 본문을 입력받는다. 그래서 입력 필드 부분을 부분 뷰로 빼면 중복을 줄일 수 있다.
{{-- resources/views/posts/_form.blade.php --}}
<div>
<label for='title'>제목</label>
<input id='title' name='title' type='text' value='{{ old('title', $post->title) }}'>
@error('title')
<p>{{ $message }}</p>
@enderror
</div>
<div>
<label for='body'>본문</label>
<textarea id='body' name='body'>{{ old('body', $post->body) }}</textarea>
@error('body')
<p>{{ $message }}</p>
@enderror
</div>
<button type='submit'>{{ $buttonText }}</button>작성 화면은 POST /posts로 데이터를 보낸다.
{{-- resources/views/posts/create.blade.php --}}
<h2>게시글 작성</h2>
<form method='POST' action='{{ route('posts.store') }}'>
@csrf
@include('posts._form', ['buttonText' => '작성'])
</form>수정 화면은 기존 게시글을 보여 주고, 저장할 때 PATCH /posts/{post}로 보낸다.
{{-- resources/views/posts/edit.blade.php --}}
<h2>게시글 수정</h2>
<form method='POST' action='{{ route('posts.update', $post) }}'>
@csrf
@method('PATCH')
@include('posts._form', ['buttonText' => '수정'])
</form>여기서 @method('PATCH')가 중요하다. HTML 폼은 기본적으로 GET과 POST만 직접 보낼 수 있다. 하지만 RESTful 라우트에서는 수정에 PATCH 또는 PUT, 삭제에 DELETE를 사용한다. Laravel은 폼 안의 숨은 _method 값을 보고 실제 요청 의도를 PATCH, DELETE로 해석한다. Blade의 @method는 이 숨은 필드를 만들어 주는 문법이다.
상세 화면과 삭제 흐름 만들기
store와 update는 저장을 마친 뒤 posts.show 라우트로 이동한다. 따라서 상세 화면에서도 상태 메시지를 보여 주고, 수정과 삭제로 이어지는 버튼을 준비해 두면 흐름이 자연스럽다.
{{-- resources/views/posts/show.blade.php --}}
@if (session('status'))
<p>{{ session('status') }}</p>
@endif
<h2>{{ $post->title }}</h2>
<div>
{{ $post->body }}
</div>
<p>
<a href='{{ route('posts.index') }}'>목록으로</a>
<a href='{{ route('posts.edit', $post) }}'>수정</a>
</p>
<form method='POST' action='{{ route('posts.destroy', $post) }}' onsubmit='return confirm("정말 삭제할까요?")'>
@csrf
@method('DELETE')
<button type='submit'>삭제</button>
</form>삭제는 데이터를 바꾸는 요청이다. 그래서 단순 링크인 <a> 태그로 처리하지 않는 편이 안전하다. 링크는 기본적으로 GET 요청이고, GET은 데이터를 조회하는 용도로 남겨 두는 것이 좋다. 삭제처럼 상태를 바꾸는 동작은 위 예제처럼 폼으로 보내고, @method('DELETE')로 삭제 의도를 표현한다.
@csrf는 사용자가 의도하지 않은 외부 요청을 막기 위한 보호 장치다. 작성, 수정, 삭제처럼 상태를 바꾸는 폼에는 반드시 포함해야 한다. Blade의 {{ }} 출력은 기본적으로 HTML을 이스케이프하므로, 사용자가 입력한 제목이나 본문을 화면에 표시할 때도 기본적인 XSS 위험을 줄여 준다.
목록에 검색, 정렬, 페이지네이션 추가하기
실제 서비스의 목록은 단순히 전체 데이터를 한 번에 보여 주지 않는다. 글이 많아지면 페이지를 나누어야 하고, 사용자는 원하는 글을 검색하거나 정렬 기준을 바꾸고 싶어 한다.
앞에서 본 index 메서드는 세 가지를 처리한다.
첫째, keyword가 있으면 제목이나 본문에서 검색한다. 이때 where와 orWhere를 괄호로 묶기 위해 where(function ($q) { ... }) 구조를 사용했다. 나중에 공개 글만 조회하는 조건 같은 것이 추가되어도 검색 조건이 엉키지 않게 하기 위해서다.
둘째, 정렬 값은 허용한 값만 사용한다. 사용자가 보낸 값을 그대로 컬럼명으로 쓰면 위험하다. 예제에서는 latest, oldest, title만 허용하고, 그 외 값은 latest로 돌린다.
셋째, paginate(10)으로 한 페이지에 10개씩 보여 준다. withQueryString()은 다음 페이지로 이동해도 현재 검색어와 정렬 조건이 유지되게 한다.
목록 Blade는 다음처럼 작성할 수 있다.
{{-- resources/views/posts/index.blade.php --}}
<h2>게시글 목록</h2>
@if (session('status'))
<p>{{ session('status') }}</p>
@endif
<p>
<a href='{{ route('posts.create') }}'>새 글 작성</a>
</p>
<form method='GET' action='{{ route('posts.index') }}'>
<input name='keyword' value='{{ $keyword }}' placeholder='검색어'>
<select name='sort'>
<option value='latest' @selected($sort === 'latest')>최신순</option>
<option value='oldest' @selected($sort === 'oldest')>오래된순</option>
<option value='title' @selected($sort === 'title')>제목순</option>
</select>
<button type='submit'>검색</button>
</form>
<ul>
@forelse ($posts as $post)
<li>
<a href='{{ route('posts.show', $post) }}'>
{{ $post->title }}
</a>
<a href='{{ route('posts.edit', $post) }}'>수정</a>
</li>
@empty
<li>게시글이 없습니다.</li>
@endforelse
</ul>
{{ $posts->links() }}links()는 페이지 이동 링크를 출력한다. Laravel의 페이지네이션은 단순히 화면 링크만 만드는 기능이 아니다. 컨트롤러의 쿼리, 현재 페이지 번호, Blade의 링크 출력이 함께 맞물려 동작한다. 그래서 목록 기능을 만들 때는 처음부터 get() 대신 paginate()를 고려하는 것이 좋다.
CRUD 흐름 점검하기
기능을 완성한 뒤에는 코드만 보지 말고 실제 요청 흐름을 따라가며 확인한다.
GET /posts에서 목록이 보이는가?검색어와 정렬 조건을 바꿨을 때 URL 쿼리 문자열이 유지되는가?
GET /posts/create에서 작성 폼이 보이는가?빈 제목으로 저장했을 때 검증 오류가 보이는가?
정상 저장 후 상세 페이지로 이동하고 상태 메시지가 보이는가?
GET /posts/{post}/edit에서 기존 값이 채워지는가?수정 후 변경된 내용이 상세 페이지에 반영되는가?
삭제 후 목록으로 돌아오고, 삭제한 글의 상세 URL은 404가 되는가?
이 점검 목록은 단순한 테스트 절차가 아니다. Laravel 애플리케이션의 기본 구조를 확인하는 방법이다. 라우트가 요청을 받고, 컨트롤러가 모델을 조회하거나 저장하고, 검증 실패 시 이전 화면으로 돌아가고, 성공 시 리다이렉트하며, Blade가 결과를 보여 준다.
이제 게시판은 CRUD의 기본 골격을 갖추었다. 하지만 아직 중요한 질문이 남아 있다. 지금 구조에서는 누구나 글을 작성하고, 수정하고, 삭제할 수 있다. 실제 서비스에서는 사용자가 누구인지 확인해야 하고, 그 사용자가 해당 글을 수정하거나 삭제할 권한이 있는지도 판단해야 한다. 다음 단계에서는 인증과 권한 제어를 연결해 CRUD 기능을 더 안전한 서비스 구조로 발전시킬 수 있다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.