잘 돌아가는 코드가 항상 좋은 구조는 아니다
지난 장에서 우리는 /posts로 시작하는 게시글 URL을 라우트로 연결했다. 브라우저가 GET /posts를 요청하면 목록을 보여 주고, GET /posts/1을 요청하면 특정 게시글을 보여 주는 식이었다. 처음에는 이 정도만으로도 충분해 보인다.
use IlluminateSupportFacadesRoute;
Route::get('/posts', function () {
return '게시글 목록';
});
Route::get('/posts/{post}', function (string $post) {
return "게시글 상세: {$post}";
});작은 예제에서는 라우트 파일 안에 코드를 직접 써도 문제가 잘 드러나지 않는다. 파일도 짧고, 요청 흐름도 눈에 들어온다. 하지만 기능이 하나씩 늘어나면 분위기가 달라진다.
게시글 목록에는 제목만 보여 주면 될까? 작성자 이름도 필요하다. 상세 화면에는 댓글 수가 필요하다. 새 글 작성 화면도 있어야 하고, 저장할 때는 입력값 검사가 필요하다. 관리자만 삭제할 수 있게 해야 할 수도 있다.
이때부터 라우트 파일은 조용히 무거워진다. 처음에는 편리함처럼 보였던 코드가 어느 순간부터 불안한 신호를 보낸다. 이 장에서는 그 신호를 살펴본다. 그리고 왜 컨트롤러가 필요한지, 단순한 규칙이 아니라 실제 필요에서 이해해 본다.
AI 생성 이미지
첫 번째 균열: 라우트 파일이 너무 많은 일을 한다
라우트의 본래 역할은 URL과 실행할 코드를 연결하는 것이다. 말하자면 입구를 정하는 일이다. 그런데 라우트 안에서 데이터를 만들고, 조건을 검사하고, 화면까지 직접 고르면 역할이 섞이기 시작한다.
예를 들어 게시글 목록을 임시 배열로 보여 준다고 해 보자.
Route::get('/posts', function () {
$posts = [
['id' => 1, 'title' => '첫 번째 글'],
['id' => 2, 'title' => '두 번째 글'],
];
return view('posts.index', [
'posts' => $posts,
]);
});아직은 괜찮아 보인다. 하지만 상세 페이지도 추가하면 비슷한 데이터가 반복된다.
Route::get('/posts/{post}', function (string $post) {
$posts = [
['id' => 1, 'title' => '첫 번째 글', 'body' => '본문입니다.'],
['id' => 2, 'title' => '두 번째 글', 'body' => '다른 본문입니다.'],
];
$foundPost = collect($posts)->firstWhere('id', (int) $post);
if (! $foundPost) {
abort(404);
}
return view('posts.show', [
'post' => $foundPost,
]);
});여기에는 몇 가지 불편한 흔적이 있다.
게시글 데이터가 여러 라우트에 흩어져 있다.
목록과 상세 화면의 책임이 라우트 파일 안에 섞여 있다.
게시글을 찾지 못했을 때의 처리도 라우트 안에 들어 있다.
나중에 데이터베이스로 바꾸면 여러 곳을 동시에 고쳐야 한다.
이런 코드는 당장 실행은 된다. 그래서 더 조심해야 한다. 실행되는 코드는 문제를 숨길 수 있다. 화면이 나오기 때문에 구조의 불편함을 늦게 알아차리기 쉽다.
두 번째 균열: 주변 코드의 말이 달라진다
프로젝트가 커지면 코드가 서로 다른 방식으로 같은 일을 하기 시작한다. 한 라우트에서는 게시글이 없을 때 abort(404)를 사용하고, 다른 라우트에서는 문자열을 반환할 수 있다.
Route::get('/posts/{post}', function (string $post) {
if ($post === '0') {
abort(404);
}
return "게시글 {$post}";
});
Route::get('/admin/posts/{post}', function (string $post) {
if ($post === '0') {
return '게시글을 찾을 수 없습니다.';
}
return "관리자 게시글 {$post}";
});두 코드는 비슷한 상황을 다르게 처리한다. 사용자에게는 서로 다른 화면이 보이고, 개발자에게는 원인을 추적하기 어려운 코드가 남는다.
이런 차이는 작은 태도처럼 보이지만, 시간이 지나면 팀 전체의 불신으로 이어진다. "이 URL에서는 왜 404가 나오고, 저 URL에서는 왜 문장이 나오지?"라는 질문이 생긴다. 코드가 일관되지 않으면 기능을 추가할 때마다 먼저 의심부터 해야 한다.
좋은 구조는 거창한 패턴에서 시작하지 않는다. 같은 종류의 일을 같은 위치에서, 비슷한 방식으로 처리하게 만드는 데서 시작한다.
감춰진 흔적 찾기: 라우트 목록 확인하기
Laravel에서는 현재 등록된 라우트를 터미널에서 확인할 수 있다.
php artisan route:list이 명령은 URL, HTTP 메서드, 라우트 이름, 연결된 동작을 보여 준다. 클로저 라우트로 작성한 경우에는 컨트롤러 이름이 아니라 익명 함수, 즉 Closure 형태의 동작으로 표시된다.
처음에는 문제가 아니다. 하지만 라우트가 많아졌는데 대부분이 클로저라면, 기능의 실제 위치를 찾기 어려워진다. 라우트 파일을 열어 한 줄씩 읽어야 한다. 목록 화면, 상세 화면, 저장 처리, 수정 처리, 삭제 처리가 모두 routes/web.php 안에 있으면 라우트 파일은 더 이상 입구가 아니라 작업장이 된다.
여기서 우리가 발견해야 할 흔적은 이것이다.
라우트 파일이 길어지는 것은 단순히 줄 수가 늘어난 문제가 아니다. 애플리케이션의 책임이 한곳에 몰리고 있다는 신호다.
이 신호를 외면하면 당장은 빠르다. 그러나 다음 기능을 추가할 때 더 많은 시간을 잃는다.
컨트롤러는 책임을 옮기는 장소다
컨트롤러는 요청을 처리하는 클래스다. 라우트가 "어디로 갈지"를 정한다면, 컨트롤러는 "가서 무엇을 할지"를 담당한다.
게시글 기능을 컨트롤러로 옮겨 보자.
php artisan make:controller PostController명령을 실행하면 보통 app/Http/Controllers/PostController.php 파일이 만들어진다. 파일을 열어 다음처럼 작성한다.
<?php
namespace AppHttpControllers;
class PostController extends Controller
{
public function index()
{
$posts = [
['id' => 1, 'title' => '첫 번째 글'],
['id' => 2, 'title' => '두 번째 글'],
];
return view('posts.index', [
'posts' => $posts,
]);
}
public function show(string $post)
{
$posts = [
['id' => 1, 'title' => '첫 번째 글', 'body' => '본문입니다.'],
['id' => 2, 'title' => '두 번째 글', 'body' => '다른 본문입니다.'],
];
$foundPost = collect($posts)->firstWhere('id', (int) $post);
if (! $foundPost) {
abort(404);
}
return view('posts.show', [
'post' => $foundPost,
]);
}
}이제 라우트 파일은 훨씬 간결해진다.
use AppHttpControllersPostController;
use IlluminateSupportFacadesRoute;
Route::get('/posts', [PostController::class, 'index'])
->name('posts.index');
Route::get('/posts/{post}', [PostController::class, 'show'])
->name('posts.show');겉으로는 파일만 나눈 것처럼 보일 수 있다. 하지만 변화는 분명하다.
라우트 파일은 URL 연결에 집중한다. PostController는 게시글 요청 처리에 집중한다. 나중에 게시글 저장, 수정, 삭제 기능이 추가되어도 관련 메서드를 같은 컨트롤러 안에서 찾을 수 있다.
물론 이것만으로 모든 중복이 사라지는 것은 아니다. 위 예제에서도 임시 배열 데이터는 아직 index()와 show() 안에 각각 남아 있다. 다만 이제 우리는 문제의 위치를 더 좁혀 볼 수 있다. 라우트 파일 전체를 뒤지는 대신, 게시글 요청을 담당하는 컨트롤러 안에서 다음 개선 지점을 찾게 된다.
이 흐름은 Laravel이 자주 사용하는 MVC 패턴과도 이어진다. MVC는 애플리케이션을 모델(Model), 뷰(View), 컨트롤러(Controller)로 나누어 생각하는 방식이다. 지금 단계에서는 모델을 깊게 다루지 않아도 괜찮다. 우선 컨트롤러가 요청 처리를 맡고, 뷰가 화면을 맡고, 라우트가 URL 연결을 맡는다는 감각만 잡으면 충분하다.
평온해 보이던 관계에 생기는 거리감
컨트롤러로 옮긴 뒤에도 모든 문제가 해결되는 것은 아니다. 아직 게시글 데이터는 배열이다. 곧 데이터베이스가 필요해질 것이다. 입력값 검증도 아직 없다. 로그인한 사용자만 글을 작성하게 만들지도 않았다.
하지만 지금 중요한 것은 완벽한 구조가 아니다. 문제가 어디에 있는지 보이기 시작했다는 점이다.
처음에는 라우트, 데이터, 화면이 가까이 붙어 있어서 편했다. 그러나 기능이 늘어나자 그 가까움이 부담이 되었다. 서로 너무 많은 것을 알고 있었기 때문이다.
라우트가 게시글 데이터의 모양을 알고 있다.
화면을 고르는 코드와 데이터를 찾는 코드가 섞여 있다.
오류 처리 방식이 기능마다 달라질 수 있다.
이후 데이터베이스로 바꿀 때 수정 범위가 커진다.
이것이 구조의 균열이다. 오류처럼 빨갛게 드러나지 않는다. 대신 코드 읽는 속도를 늦추고, 수정할 때의 불안을 키운다.
좋은 개발자는 이런 신호를 무시하지 않는다. 아직 큰 사고가 나지 않았을 때 책임을 나누고, 다음 변경을 받아들일 자리를 만든다.
침묵이 안전해 보일 때
초보자는 종종 이런 생각을 한다.
"아직 잘 모르니까 일단 라우트에 계속 쓰자."
이 선택은 안전해 보인다. 새 파일을 만들 필요도 없고, 클래스 문법을 더 생각하지 않아도 된다. 지금 당장 화면을 띄우는 데는 문제가 없다.
하지만 침묵에도 비용이 있다. 구조의 불편함을 알면서도 넘기면 다음 단계에서 더 어려운 문제로 돌아온다. 저장 기능을 추가할 때, 입력값 검증을 넣을 때, 데이터베이스에서 게시글을 가져올 때, 인증을 붙일 때마다 같은 질문이 반복된다.
"이 코드는 어디에 있어야 하지?"
컨트롤러는 그 질문에 대한 첫 번째 답이다. 모든 코드를 컨트롤러에 몰아넣으라는 뜻은 아니다. 지금 단계에서는 라우트와 요청 처리의 책임을 나누는 것만으로 충분하다.
다음 선택을 위한 준비
이제 우리의 게시판 애플리케이션은 중요한 지점에 섰다. 라우트만으로도 화면을 만들 수 있다는 것은 확인했다. 동시에 라우트만으로 계속 가기에는 불안하다는 것도 확인했다.
앞으로 게시글 기능은 더 현실적인 형태를 갖게 된다. 목록은 데이터베이스에서 가져와야 하고, 상세 화면은 존재하지 않는 게시글을 정확히 처리해야 한다. 글 작성 화면에서는 사용자가 보낸 값을 믿지 말고 검증해야 한다.
이 모든 흐름은 결국 하나의 방향으로 이어진다.
라우트는 입구를 맡고, 컨트롤러는 요청 처리를 맡고, 뷰는 화면을 맡는다. 나중에 모델과 데이터베이스가 들어오면 데이터 접근의 책임도 따로 나뉜다.
지금 발견한 균열은 실패가 아니다. 오히려 애플리케이션이 커질 준비가 되었다는 신호다. 문제는 더 이상 보이지 않는 곳에 숨어 있지 않다. 우리는 그 흔적을 보았고, 이제 외면하기 어렵다.
다음 단계에서는 이 선택을 실제 구조로 굳힌다. PostController를 중심으로 게시글 목록과 상세 화면을 정리하고, Blade 뷰와 연결해 사용자가 볼 수 있는 화면을 만들어 갈 것이다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.