읽기 전용 애플리케이션의 끝
지금까지 만든 게시판은 비교적 안전했습니다. 게시글 목록을 보고, 상세 페이지를 열고, 데이터베이스에서 값을 가져와 화면에 보여 주었습니다. 사용자는 애플리케이션을 둘러볼 수 있었지만, 애플리케이션 안의 데이터를 바꿀 수는 없었습니다.
이제 상황이 달라집니다. 사용자가 글을 작성하고 저장하는 순간, 애플리케이션은 바깥 세계의 값을 받아들입니다. 이때부터 불안이 시작됩니다.
사용자는 제목을 비워 둘 수 있습니다. 본문에 너무 긴 값을 넣을 수도 있습니다. 개발자가 의도하지 않은 필드가 함께 전송될 수도 있습니다. 브라우저 화면에서는 보이지 않는 입력값도 요청에는 포함될 수 있습니다.
이 균열은 버그 하나의 문제가 아닙니다. 애플리케이션이 어디까지 사용자를 믿을 것인지, 어느 계층에서 무엇을 책임질 것인지 정해야 하는 시점입니다.
이전 장에서 우리는 Eloquent 모델을 만들고, $fillable로 저장 가능한 컬럼을 제한했습니다. 그것은 중요한 첫 방어선이었습니다. 하지만 그것만으로 충분하지 않습니다. $fillable은 "어떤 컬럼을 저장할 수 있는가"를 제한합니다. 반면 검증은 "그 값이 저장해도 되는 값인가"를 판단합니다.
두 질문은 비슷해 보이지만 다릅니다.
protected $fillable = [
'title',
'body',
'is_published',
];위 설정은 title, body, is_published를 대량 할당할 수 있다는 뜻입니다. 하지만 title이 비어 있는지, body가 너무 짧거나 긴지, is_published가 참 또는 거짓으로 해석할 수 있는 값인지까지 확인하지는 않습니다.
또 하나 주의할 점이 있습니다. $fillable에 들어 있다고 해서 매번 그 값을 폼에서 받아야 한다는 뜻은 아닙니다. 이번 장의 작성 폼에서는 제목과 본문만 받겠습니다. 나중에 공개 여부를 폼에서 받게 된다면, 그때는 is_published에도 별도의 검증 규칙을 붙여야 합니다.
데이터를 저장하는 기능을 만들기 전에, 우리는 이 차이를 받아들여야 합니다.
AI 생성 이미지
단서는 우연히 나타나지 않는다
처음에는 사소해 보이는 문제가 생깁니다.
게시글 제목이 비어 있습니다. 목록 화면의 링크가 이상하게 보입니다. 상세 페이지에서는 제목 대신 빈 줄만 출력됩니다. 어떤 글은 작성 시간이 있는데 내용이 없습니다. 또 어떤 글은 공개 여부 값이 예상과 다릅니다.
초보자는 이런 문제를 화면에서 고치려 합니다.
{{ $post->title ?: '제목 없음' }}물론 화면에서 기본 문구를 보여 주는 처리는 필요할 수 있습니다. 하지만 이것은 원인을 해결하는 일이 아닙니다. 이미 잘못된 데이터가 데이터베이스에 들어간 뒤, 화면에서 흔적을 가리는 방식입니다.
문제의 출발점은 저장 시점입니다. 사용자가 보낸 값이 애플리케이션 안으로 들어오는 경계에서 확인되지 않았기 때문에, 뒤쪽의 모든 코드가 그 값을 감당해야 합니다.
이것이 과거의 선택이 현재의 갈등으로 이어지는 방식입니다.
라우트에서 임시 배열로 데이터를 다룰 때는 입력 문제가 잘 보이지 않았습니다. 컨트롤러로 책임을 나누고, Eloquent로 실제 데이터베이스에 저장하기 시작하자 숨겨졌던 문제가 드러납니다. 구조가 좋아졌기 때문에 문제가 생긴 것이 아닙니다. 구조가 좋아졌기 때문에 문제가 더 정확히 보이기 시작한 것입니다.
이제 게시글 작성 흐름을 추가해 보겠습니다.
// routes/web.php
use AppHttpControllersPostController;
Route::get('/posts/create', [PostController::class, 'create'])
->name('posts.create');
Route::post('/posts', [PostController::class, 'store'])
->name('posts.store');GET /posts/create는 작성 화면을 보여 줍니다. POST /posts는 사용자가 보낸 값을 저장합니다.
같은 게시글 기능이지만 두 라우트의 성격은 다릅니다. 하나는 화면을 보여 주고, 다른 하나는 데이터를 바꿉니다. 데이터를 바꾸는 요청은 더 엄격하게 다루어야 합니다.
폼은 사용자의 말이고, 요청은 증거다
작성 화면은 Blade로 만들 수 있습니다.
<!-- resources/views/posts/create.blade.php -->
<form method="POST" action="{{ route('posts.store') }}">
@csrf
<div>
<label for="title">제목</label>
<input id="title" type="text" name="title" value="{{ old('title') }}">
@error('title')
<p>{{ $message }}</p>
@enderror
</div>
<div>
<label for="body">본문</label>
<textarea id="body" name="body">{{ old('body') }}</textarea>
@error('body')
<p>{{ $message }}</p>
@enderror
</div>
<button type="submit">저장</button>
</form>여기서 @csrf는 Laravel이 제공하는 CSRF 보호 토큰을 폼에 넣습니다. CSRF는 사용자가 의도하지 않은 요청을 다른 사이트가 대신 보내는 공격을 말합니다. Laravel의 웹 라우트는 기본적으로 이 토큰을 확인해, 현재 애플리케이션에서 만든 요청 흐름인지 검사합니다.
old('title')과 old('body')도 중요합니다. 검증에 실패했을 때 사용자가 입력한 값을 다시 보여 줍니다. 사용자가 긴 본문을 입력했는데 오류 하나 때문에 모든 내용이 사라진다면, 애플리케이션은 기능적으로 맞아도 경험적으로는 불친절합니다.
폼은 사용자의 말입니다. 요청은 그 말이 서버에 도착한 기록입니다. 컨트롤러는 그 말을 그대로 믿지 않고 확인해야 합니다.
// app/Http/Controllers/PostController.php
use AppModelsPost;
use IlluminateHttpRequest;
public function create()
{
return view('posts.create');
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'body' => ['required', 'string'],
]);
$post = Post::create($validated);
return redirect()
->route('posts.show', $post)
->with('status', '게시글이 저장되었습니다.');
}$request->validate()는 요청 데이터를 검사합니다. 조건을 통과하면 검증된 값만 배열로 반환합니다. 실패하면 Laravel은 자동으로 이전 화면으로 돌아가고, 오류 메시지와 기존 입력값을 세션에 담아 전달합니다. 그래서 위의 @error와 old()가 자연스럽게 동작합니다.
이 흐름에서 중요한 점은 저장보다 검증이 먼저라는 사실입니다.
$validated = $request->validate([...]);
Post::create($validated);이 순서를 바꾸면 안 됩니다. 데이터베이스는 마지막 방어선이 될 수 있지만, 사용자에게 친절한 오류를 알려 주는 장소는 아닙니다. 데이터베이스 오류는 보통 개발자에게는 단서가 되지만, 사용자에게는 이해하기 어려운 실패로 보입니다.
책임은 한곳에 몰아넣지 않는다
입력값을 다루기 시작하면 팀 안에서도 질문이 생깁니다.
"이건 프론트엔드에서 막으면 되는 것 아닌가?"
"데이터베이스 컬럼에 NOT NULL을 걸면 충분하지 않은가?"
"모델의 $fillable이 있으니 안전하지 않은가?"
각 말에는 일부 진실이 있습니다. 하지만 어느 하나만으로 충분하지 않습니다.
브라우저의 입력 제한은 사용자 경험을 좋게 만듭니다. 예를 들어 required 속성을 사용하면 사용자가 빈 제목을 제출하기 전에 브라우저가 먼저 알려 줄 수 있습니다.
<input type="text" name="title" required>하지만 브라우저는 신뢰할 수 있는 경계가 아닙니다. 사용자는 개발자 도구나 별도 도구를 이용해 HTML 폼을 거치지 않고 요청을 보낼 수 있습니다.
데이터베이스 제약 조건은 데이터의 최종 무결성을 지키는 데 중요합니다. 제목과 본문이 NULL이면 안 된다면 마이그레이션에서도 그 의도를 표현해야 합니다.
$table->string('title');
$table->text('body');Laravel 마이그레이션에서 위와 같이 만들면 기본적으로 nullable()을 붙이지 않는 한 NULL을 허용하지 않습니다. 다만 이것이 빈 문자열까지 막아 주는 것은 아닙니다. 제목이 정말 비어 있으면 안 된다는 규칙은 서버 검증의 required 같은 규칙으로 먼저 다루는 편이 좋습니다.
데이터베이스 제약만 믿으면 오류 메시지를 세밀하게 제어하기도 어렵습니다. 사용자는 "제목을 입력해 주세요"라는 안내를 원하지, 내부 저장 오류를 보고 싶어 하지 않습니다.
모델의 $fillable은 의도하지 않은 컬럼 저장을 줄입니다. 하지만 값의 형식과 의미를 검사하지는 않습니다.
그래서 기준을 새로 세워야 합니다.
브라우저는 빠른 안내를 맡는다.
컨트롤러 또는 요청 검증 계층은 서버의 공식 검증을 맡는다.
모델은 저장 가능한 속성과 데이터 표현 규칙을 맡는다.
데이터베이스는 최종 무결성을 맡는다.
책임을 나눈다는 것은 서로에게 일을 떠넘기는 것이 아닙니다. 각 계층이 가장 잘할 수 있는 일을 맡게 하는 것입니다.
신뢰는 감정이 아니라 절차다
애플리케이션에서 신뢰는 "사용자가 나쁜 의도가 없을 것"이라고 기대하는 감정이 아닙니다. 신뢰는 확인 가능한 절차입니다.
게시글 저장 요청을 받을 때 최소한 다음 질문에 답할 수 있어야 합니다.
이 요청은 어떤 라우트로 들어오는가?
이 요청은 데이터를 변경하는가?
이 요청을 보낸 사용자는 누구인가?
어떤 필드를 받을 것인가?
각 필드는 어떤 조건을 만족해야 하는가?
검증 실패 시 사용자는 어디로 돌아가는가?
저장 성공 후 사용자는 어디로 이동하는가?
아직 이 책의 애플리케이션에는 로그인 기능이 없습니다. 따라서 "누가 작성했는가"라는 질문은 완전히 답하지 못합니다. 이 빈칸은 다음 단계에서 인증을 배우며 채우게 됩니다.
하지만 인증이 없어도 지금 정할 수 있는 기준은 있습니다. 사용자가 보낸 값은 서버에서 반드시 검증합니다. 저장에는 검증된 배열만 사용합니다. 오류가 발생하면 사용자가 다시 수정할 수 있도록 입력값과 메시지를 돌려줍니다.
이 기준이 생기면 코드의 분위기가 달라집니다.
public function store(Request $request)
{
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'body' => ['required', 'string'],
]);
$post = Post::create($validated);
return redirect()->route('posts.show', $post);
}이 코드는 길지 않습니다. 하지만 많은 결정을 담고 있습니다. 아무 값이나 저장하지 않겠다는 결정, 오류를 숨기지 않겠다는 결정, 저장 후 사용자를 새 글 상세 화면으로 보내겠다는 결정입니다.
더 이상 미룰 수 없는 압박
처음에는 검증 규칙이 두세 줄뿐입니다. 그래서 "나중에 정리해도 되지 않을까"라는 생각이 듭니다. 하지만 기능은 곧 늘어납니다.
게시글 수정이 추가됩니다. 삭제가 추가됩니다. 공개 여부가 추가됩니다. 작성자만 수정할 수 있어야 합니다. 관리자는 숨김 처리할 수 있어야 합니다. 댓글과 태그도 생깁니다.
그때마다 입력값을 대충 처리하면 균열은 넓어집니다.
특정 컨트롤러에서는 title을 필수로 검사하고, 다른 컨트롤러에서는 검사하지 않습니다. 어떤 화면은 오류 메시지를 보여 주고, 어떤 화면은 조용히 실패합니다. 어떤 저장 로직은 $request->all()을 사용하고, 어떤 로직은 검증된 배열을 사용합니다.
이런 차이는 당장 큰 장애로 보이지 않을 수 있습니다. 하지만 유지보수 단계에서는 신뢰를 갉아먹습니다. 개발자는 코드를 수정하기 전에 먼저 의심해야 합니다.
"여기서는 어떤 값이 들어올 수 있지?"
"이 필드는 정말 저장해도 되나?"
"검증 실패하면 어디로 가지?"
좋은 구조는 이런 질문을 없애지는 않습니다. 대신 질문에 답할 위치를 분명하게 만듭니다.
새로운 기준을 세우기
이 장의 전환점은 기술 하나를 추가하는 데 있지 않습니다. 기준을 바꾸는 데 있습니다.
이전까지 우리는 "요청이 오면 어떻게 화면을 보여 줄까"를 중심으로 생각했습니다. 이제는 "요청이 애플리케이션의 상태를 바꾸어도 되는가"를 먼저 물어야 합니다.
앞으로 데이터를 변경하는 기능을 만들 때는 다음 순서를 기본값으로 삼겠습니다.
라우트에서 요청의 의도를 분명히 한다.
폼에는 CSRF 토큰과 사용자에게 필요한 안내를 넣는다.
컨트롤러는 요청 데이터를 검증한다.
검증된 값만 모델에 전달한다.
저장 후에는 명확한 위치로 리다이렉트한다.
실패했을 때 사용자가 다시 시도할 수 있게 한다.
이 기준은 게시글 작성뿐 아니라 수정, 댓글 작성, 회원가입, 로그인 이후의 모든 기능에 반복해서 적용됩니다.
주인공이 더 이상 익숙한 방식으로 문제를 덮지 않기로 마음먹는 순간이 있습니다. Laravel 애플리케이션에서 그 순간은 $request->all()로 모든 값을 넘기고 싶은 유혹을 멈추는 때입니다.
// 피해야 할 예
Post::create($request->all());$fillable이 있으면 위 코드가 언제나 즉시 위험해지는 것은 아닙니다. 그래도 좋은 기준은 아닙니다. 어떤 값이 들어왔고, 그중 무엇을 저장하기로 했는지가 코드에 분명히 남지 않기 때문입니다.
대신 검증된 값만 통과시킵니다.
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'body' => ['required', 'string'],
]);
Post::create($validated);이 작은 차이가 애플리케이션의 방향을 바꿉니다. 사용자를 의심하기 위해서가 아니라, 사용자와 애플리케이션 모두를 보호하기 위해서입니다.
다음 단계에서는 이 기준을 더 단단하게 만들 것입니다. 검증 규칙이 늘어날 때 컨트롤러가 다시 무거워지지 않도록 요청 검증을 분리하고, 오류 메시지를 다듬고, 누가 어떤 작업을 할 수 있는지까지 연결해 보겠습니다.
균열은 이미 드러났습니다. 이제 필요한 것은 임시방편이 아니라, 앞으로의 기능이 같은 기준 위에 쌓이도록 만드는 선택입니다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.