문제는 동작이 아니라 신뢰다
이전 장에서 우리는 라우트 파일 안에 게시글 목록과 상세 화면 로직을 직접 넣어 보았다. 처음에는 문제가 없어 보였다. GET /posts는 목록을 보여 주었고, GET /posts/1은 첫 번째 게시글을 보여 주었다. 브라우저에는 결과가 나왔다.
하지만 기능이 조금만 늘어나도 분위기가 달라진다. 게시글 데이터가 여러 라우트에 반복되고, 없는 게시글을 처리하는 방식도 라우트마다 달라진다. 어떤 곳은 404를 반환하고, 어떤 곳은 빈 화면을 보여 줄 수 있다. 사용자는 같은 서비스 안에서 서로 다른 반응을 경험한다. 개발자는 코드를 수정할 때마다 "이 데이터가 또 어디에 있지?" 하고 찾아야 한다.
이때 흔히 나오는 말이 있다.
"아직은 괜찮다. 동작하잖아."
하지만 웹 애플리케이션에서 동작은 출발점일 뿐이다. 중요한 것은 다음 변경을 안전하게 받아들일 수 있는가이다. 목록 화면에 작성자 이름을 추가해야 할 때, 상세 화면에 없는 게시글 처리를 바꿔야 할 때, 나중에 데이터베이스로 옮겨야 할 때 수정할 위치가 계속 늘어난다면 이미 구조는 흔들리고 있다.
이 장은 그 흔들림을 멈추는 첫 번째 전환점이다. 라우트에 머물던 로직을 컨트롤러로 옮기고, 사용자에게 보이는 HTML은 Blade 뷰로 나눈다. 이 선택을 하고 나면 Laravel 애플리케이션은 더 이상 단순한 예제 파일 묶음이 아니다. 요청을 받고, 처리하고, 화면을 돌려주는 역할이 분명한 구조로 넘어간다.
AI 생성 이미지
라우트는 입구만 맡는다
먼저 컨트롤러를 만든다. 터미널에서 프로젝트 디렉터리로 이동한 뒤 다음 명령을 실행한다.
php artisan make:controller PostController명령이 성공하면 보통 다음 위치에 파일이 만들어진다.
app/Http/Controllers/PostController.phpLaravel 버전에 따라 생성되는 기본 코드의 모양은 조금 다를 수 있다. 중요한 것은 위치와 역할이다. 컨트롤러는 요청을 처리하는 클래스다. 라우트가 "어떤 URL이 어떤 기능으로 가야 하는가"를 정한다면, 컨트롤러는 "그 기능에서 무엇을 할 것인가"를 맡는다.
이제 routes/web.php를 정리한다.
<?php
use AppHttpControllersPostController;
use IlluminateSupportFacadesRoute;
Route::get('/posts', [PostController::class, 'index'])
->name('posts.index');
Route::get('/posts/{post}', [PostController::class, 'show'])
->whereNumber('post')
->name('posts.show');이 코드는 읽는 방법이 중요하다.
GET /posts 요청이 오면 PostController의 index 메서드를 실행한다. GET /posts/{post} 요청이 오면 show 메서드를 실행한다. 라우트 이름은 각각 posts.index, posts.show로 붙였다.
여기서 whereNumber('post')는 {post} 자리에 숫자만 오도록 제한한다. 예를 들어 /posts/1은 상세 페이지 요청으로 처리되지만, /posts/abc처럼 숫자가 아닌 값은 이 라우트에 맞지 않는다. 지금 예제에서는 게시글 번호로 데이터를 찾을 것이기 때문에, 라우트 단계에서 입력의 모양을 먼저 좁혀 두는 편이 안전하다.
라우트 파일에서 데이터 배열과 조건문이 사라졌다. 대신 URL과 컨트롤러의 연결만 남았다. 이것이 라우트가 가장 잘하는 일이다.
컨트롤러에서 요청을 처리한다
이제 PostController를 열고 목록과 상세 처리를 옮겨 보자.
<?php
namespace AppHttpControllers;
class PostController extends Controller
{
public function index()
{
$posts = $this->posts();
return view('posts.index', [
'posts' => $posts,
]);
}
public function show(int $post)
{
$posts = $this->posts();
$selectedPost = $posts[$post] ?? null;
if ($selectedPost === null) {
abort(404);
}
return view('posts.show', [
'post' => $selectedPost,
]);
}
private function posts(): array
{
return [
1 => [
'id' => 1,
'title' => '첫 번째 게시글',
'body' => 'Laravel로 게시판을 만들기 시작했습니다.',
],
2 => [
'id' => 2,
'title' => '두 번째 게시글',
'body' => '라우트에서 컨트롤러로 책임을 옮겨 봅니다.',
],
];
}
}아직 데이터베이스는 사용하지 않는다. 일부러 배열을 그대로 둔다. 지금 배우려는 핵심은 데이터 저장 방식이 아니라 요청 처리의 위치이기 때문이다.
index 메서드는 게시글 목록을 준비하고 posts.index 뷰에 넘긴다. show 메서드는 URL에서 받은 게시글 번호로 데이터를 찾는다. 해당 번호의 게시글이 없으면 abort(404)를 호출한다. abort(404)는 Laravel에게 "이 요청은 찾을 수 없는 리소스다"라고 알려 주는 방법이다.
앞에서 라우트에 whereNumber('post')를 붙였기 때문에 show 메서드는 숫자 형태의 게시글 번호를 받는다고 기대할 수 있다. 그리고 컨트롤러 안에서는 그 숫자에 해당하는 게시글이 실제로 존재하는지 한 번 더 확인한다. 숫자 형식인지 확인하는 일과, 실제 데이터가 있는지 확인하는 일은 서로 다른 문제이기 때문이다.
이전에는 이런 판단이 라우트 안에 있었다. 이제는 컨트롤러 안에 있다. 차이는 작아 보이지만, 유지보수에서는 크다. 게시글을 찾지 못했을 때의 처리를 바꾸고 싶다면 show 메서드를 보면 된다. 목록 데이터를 바꾸고 싶다면 index와 posts 메서드를 보면 된다. 코드가 어디에 있는지 예측할 수 있다.
Blade는 화면을 맡는다
컨트롤러가 데이터를 준비했다면, 화면은 Blade가 맡는다. 먼저 다음 디렉터리와 파일을 만든다.
resources/views/posts/index.blade.php
resources/views/posts/show.blade.php목록 화면부터 작성한다.
<h1>게시글 목록</h1>
<ul>
@foreach ($posts as $post)
<li>
<a href="{{ route('posts.show', ['post' => $post['id']]) }}">
{{ $post['title'] }}
</a>
</li>
@endforeach
</ul>@foreach는 Blade에서 반복을 표현하는 문법이다. PHP의 foreach와 같은 역할을 하지만, HTML 안에서 읽기 좋게 쓸 수 있다.
{{ }}는 값을 화면에 출력한다. 이 문법은 기본적으로 HTML 특수 문자를 안전하게 처리한다. 예를 들어 게시글 제목에 HTML 태그처럼 보이는 문자열이 들어와도 그대로 실행되지 않도록 도와준다. 초보 단계에서는 화면에 값을 출력할 때 {{ }}를 우선 사용한다고 기억하면 된다.
route('posts.show', ['post' => $post['id']])는 이름 있는 라우트를 이용해 링크 주소를 만든다. posts.show 라우트에는 {post} 파라미터가 있으므로, 그 자리에 들어갈 값을 함께 넘겨 주는 것이다. 지금은 /posts/1처럼 짧은 주소라 직접 써도 될 것 같지만, 나중에 URL 구조가 바뀌면 차이가 난다. 링크를 직접 문자열로 여러 곳에 써 두면 모두 찾아 수정해야 한다. 라우트 이름을 사용하면 URL 생성 책임을 Laravel에 맡길 수 있다.
상세 화면은 다음처럼 작성한다.
<h1>{{ $post['title'] }}</h1>
<p>{{ $post['body'] }}</p>
<p>
<a href="{{ route('posts.index') }}">목록으로 돌아가기</a>
</p>이제 컨트롤러는 데이터를 준비하고, Blade는 그 데이터를 HTML로 보여 준다. 역할이 나뉘었다.
공식적으로는 정상이어도, 실제로는 불안할 수 있다
라우트를 확인해 보자.
php artisan route:list목록에 GET /posts와 GET /posts/{post}가 보이고, 각각 PostController@index, PostController@show 또는 비슷한 형태로 연결되어 있다면 라우트 등록은 정상이다.
하지만 라우트 목록이 정상이라고 해서 애플리케이션 전체가 완성된 것은 아니다. 브라우저에서 /posts에 접속했을 때 뷰 파일 이름을 잘못 썼다면 오류가 난다. /posts/999에 접속했을 때 404가 나와야 하는데 다른 화면이 나온다면 상세 처리에 문제가 있다. 링크를 눌렀을 때 주소가 이상하다면 라우트 이름이나 파라미터 전달을 확인해야 한다.
이 지점에서 중요한 태도는 "Laravel이 이상하다"가 아니라 "요청 흐름 중 어디에서 어긋났는가"를 묻는 것이다.
URL이 맞는가?
HTTP 메서드가 맞는가?
라우트가 등록되어 있는가?
라우트 파라미터의 모양이 기대와 맞는가?
컨트롤러 메서드가 실행되는가?
컨트롤러가 뷰에 필요한 데이터를 넘기는가?
Blade 파일 경로와 이름이 맞는가?
Blade에서 사용하는 변수 이름이 컨트롤러와 같은가?
이 질문들은 불편하지만 유용하다. 문제를 감으로 찾지 않게 해 준다. Laravel 애플리케이션은 요청이 들어오고 응답이 나가는 흐름 위에 있으므로, 오류도 그 흐름을 따라가며 찾는 것이 가장 빠르다.
중립으로 남을 수 없는 순간
작은 프로젝트에서는 구조를 미루고 싶어진다. 라우트에 조금 더 쓰고, 배열을 한 번 더 복사하고, 조건문을 하나 더 붙이면 당장은 끝난다. 하지만 그 선택이 반복되면 코드 전체의 신뢰가 낮아진다. 어떤 화면은 컨트롤러를 쓰고, 어떤 화면은 라우트에 로직이 있고, 어떤 화면은 뷰 안에서 데이터를 가공한다면 다음 개발자는 어디서부터 읽어야 할지 알 수 없다.
그래서 이 장의 선택은 단순히 파일을 옮기는 일이 아니다. 애플리케이션의 기준을 세우는 일이다.
게시글 요청은 PostController에서 처리한다. 게시글 화면은 resources/views/posts 아래에 둔다. 링크는 가능하면 라우트 이름으로 만든다. 없는 게시글은 같은 방식으로 404 처리한다.
이 기준이 생기면 다음 기능을 붙일 자리가 보인다. 게시글 작성 화면은 create 메서드로 갈 수 있다. 저장 처리는 store 메서드가 맡을 수 있다. 수정과 삭제도 각각 알맞은 메서드로 나눌 수 있다. 나중에 데이터베이스를 도입해도 라우트와 뷰를 모두 뒤엎을 필요가 줄어든다.
우리는 이제 안전한 침묵을 끝냈다. "동작하니까 괜찮다"에서 멈추지 않고, "이 코드는 다음 변경을 견딜 수 있는가"라고 묻기 시작했다. 이 질문이 불편할수록 프로젝트는 더 오래 버틸 수 있다.
다음 단계에서는 이 구조 위에 실제 데이터 저장 방식을 올릴 준비를 한다. 배열로 만든 게시글은 연습에 충분하지만, 사용자가 작성한 글을 계속 보관하려면 데이터베이스가 필요하다. 이제 진짜 애플리케이션에 가까워질 차례다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.