전날의 성공이 남긴 불편한 흔적
전날 우리는 게시글 CRUD를 완성했다. 목록을 보고, 글을 작성하고, 수정하고, 삭제할 수 있었다. 화면은 자연스럽게 움직였고, 컨트롤러와 라우트도 제자리를 찾은 듯했다.
하지만 다음 날 프로젝트를 다시 열면 다른 것이 보인다. 기능이 잘 작동한다는 사실과, 안전하게 작동한다는 사실은 다르다.
브라우저에서 게시글 상세 페이지를 열고 주소를 살펴보자.
/posts/3/edit이 주소는 3번 게시글의 수정 화면이다. 문제는 너무 단순하다. 로그인하지 않은 사용자도 이 주소를 알면 접근할 수 있다면 어떻게 될까. 더 나아가 다른 사람이 쓴 글의 번호만 바꾸어 수정 화면에 들어갈 수 있다면 어떻게 될까.
삭제도 마찬가지다.
<form method="POST" action="{{ route('posts.destroy', $post) }}">
@csrf
@method('DELETE')
<button type="submit">삭제</button>
</form>이 폼은 정상적인 삭제 요청을 만든다. 그러나 서버가 "이 요청을 보낸 사람이 정말 이 글을 삭제해도 되는 사람인가?"를 확인하지 않는다면, 버튼의 위치나 화면의 친절함은 안전장치가 아니다.
전날에는 기능의 완성이 중요했다. 오늘은 그 기능이 누구에게 열려 있는지가 중요해진다.
AI 생성 이미지
분위기가 달라지는 순간
작은 게시판을 혼자 만들 때는 모든 사용자가 나 자신처럼 느껴진다. 그래서 작성, 수정, 삭제를 모두 열어 두어도 크게 이상하지 않다. 하지만 애플리케이션에 사용자가 생기는 순간 상황은 달라진다.
게시글에는 작성자가 있어야 한다. 댓글에는 댓글을 남긴 사람이 있어야 한다. 관리자 화면은 관리자만 들어가야 한다. 비공개 글은 작성자와 허용된 사람만 봐야 한다.
즉, 데이터는 더 이상 독립적으로 존재하지 않는다. 데이터는 사람과 연결된다.
이전 장까지 만든 posts 테이블은 게시글 자체를 저장하는 데 집중했다. 그러나 실무적인 게시판에는 보통 다음 질문이 따라온다.
이 글은 누가 작성했는가?
지금 요청한 사용자는 로그인했는가?
로그인했다면 이 글의 작성자인가?
작성자가 아니라면 수정이나 삭제를 막아야 하지 않는가?
관리자는 예외적으로 처리할 것인가?
이 질문을 무시하면 애플리케이션은 겉으로는 완성되어 보여도 안쪽에서는 계속 흔들린다.
신뢰하던 사람과의 대화
코드를 함께 보던 동료가 조용히 묻는다.
"지금 이 글, 아무나 수정할 수 있지 않나요?"
처음에는 대수롭지 않게 들릴 수 있다. 아직 학습용 프로젝트이고, 실제 사용자가 있는 것도 아니다. 하지만 질문의 방향은 정확하다. 문제는 현재 사용자가 몇 명인지가 아니다. 구조가 어떤 가능성을 열어 두고 있는지가 문제다.
컨트롤러의 update 메서드를 떠올려 보자.
public function update(Request $request, Post $post)
{
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'body' => ['required', 'string'],
]);
$post->update($validated);
return redirect()->route('posts.show', $post);
}이 코드는 입력값을 검증한다. 제목과 본문이 비어 있지 않은지 확인하고, 검증된 값만 저장한다. 이전 장에서 우리는 이것이 중요하다고 배웠다.
하지만 이 코드는 한 가지를 묻지 않는다.
이 사용자가 이 게시글을 수정해도 되는가?입력값 검증은 값의 형식과 내용을 확인한다. 권한 검사는 사용자의 자격을 확인한다. 둘은 비슷해 보이지만 완전히 다른 책임이다.
예를 들어 어떤 요청이 다음 두 조건을 모두 만족할 수 있다.
제목과 본문은 올바른 형식이다.
그러나 요청한 사용자는 이 글의 작성자가 아니다.
이때 서버는 저장을 거부해야 한다. 값이 올바르더라도 사람이 올바르지 않기 때문이다.
인증과 권한은 다르다
이 지점에서 두 단어를 구분해야 한다.
인증은 "당신은 누구인가?"를 확인하는 절차다. 보통 로그인으로 처리한다. 이메일과 비밀번호를 확인하고, 이후 요청에서 같은 사용자인지 세션으로 기억한다.
권한은 "당신이 이 작업을 해도 되는가?"를 판단하는 절차다. 로그인한 사용자라고 해서 모든 글을 삭제할 수 있는 것은 아니다. 작성자만 삭제할 수 있게 할 수도 있고, 관리자는 예외로 허용할 수도 있다.
정리하면 다음과 같다.
| 질문 | 개념 | 예시 |
|---|---|---|
| 누구인가? | 인증 | 로그인한 사용자인가? |
| 해도 되는가? | 권한 | 이 글의 작성자인가? |
Laravel에서는 이 둘을 각각 다른 도구로 다룬다. 로그인 여부는 미들웨어로 막을 수 있다.
Route::resource('posts', PostController::class)
->middleware('auth');이렇게 하면 게시글 관련 라우트에 접근하기 전에 로그인 여부를 확인할 수 있다. 다만 이것은 "로그인했는가?"만 확인한다. 로그인한 사용자 A가 사용자 B의 글을 수정하는 문제는 여전히 남아 있다.
그래서 게시글 단위의 권한 판단이 필요하다.
if ($post->user_id !== auth()->id()) {
abort(403);
}이 코드는 단순하지만 중요한 경계를 만든다. 403은 "요청한 대상은 알지만, 이 작업을 허용할 수 없다"는 의미의 응답이다. 404가 "없다"에 가깝다면, 403은 "권한이 없다"에 가깝다. 로그인하지 않은 사용자를 로그인 페이지로 보내는 것과, 로그인한 사용자에게 특정 작업을 거부하는 것은 서로 다른 상황이다.
나중에는 이 판단을 컨트롤러 곳곳에 직접 쓰기보다 Policy라는 별도 클래스로 옮길 수 있다. 하지만 지금 중요한 것은 도구의 이름이 아니다. 서버가 반드시 권한을 판단해야 한다는 사실이다.
과거 선택이 남긴 빚
이전에는 빠르게 배우기 위해 단순한 길을 택했다. 배열로 데이터를 만들었고, 그다음 데이터베이스로 옮겼다. 검증 없이 저장하던 흐름을 고쳐 $request->validate()를 넣었다. 라우트에 몰려 있던 로직을 컨트롤러로 나누었다.
그 선택들은 틀리지 않았다. 학습에는 순서가 필요하다. 처음부터 모든 안전장치를 넣으면 구조를 이해하기 어렵다.
그러나 이제는 다음 단계로 넘어가야 한다.
현재의 게시판에는 작성자 개념이 약하다. 글이 누구의 것인지 알 수 없다면 권한을 판단할 수도 없다. 권한을 판단하려면 데이터 구조부터 달라져야 한다.
posts 테이블에는 작성자를 가리키는 컬럼이 필요하다.
$table->foreignId('user_id')->constrained()->cascadeOnDelete();새 프로젝트에서 처음부터 테이블을 만든다면 이 한 줄로 방향이 분명해진다. 이미 게시글 데이터가 들어 있는 테이블에 나중에 user_id를 추가한다면 기존 글의 작성자를 어떻게 채울지도 함께 고민해야 한다. 비어 있는 user_id를 허용할지, 임시 사용자에게 연결할지, 기존 데이터를 정리한 뒤 외래 키를 걸지에 따라 마이그레이션 방식이 달라질 수 있다.
그리고 Post 모델은 작성자와 연결되어야 한다.
public function user()
{
return $this->belongsTo(User::class);
}User 모델도 자신이 작성한 게시글을 알 수 있어야 한다.
public function posts()
{
return $this->hasMany(Post::class);
}이 관계가 생기면 질문이 가능해진다.
이 글의 user_id는 현재 로그인한 사용자의 id와 같은가?권한은 추상적인 감각이 아니다. 결국 데이터의 관계를 근거로 판단한다.
직접 드러나지 않는 압박
보안 문제는 늘 큰 사건처럼 나타나지 않는다. 처음에는 작은 흔적으로 온다.
수정 화면 주소를 직접 입력했더니 열린다. 삭제 버튼은 화면에 없지만 요청을 만들면 처리된다. 로그인하지 않았는데 작성 페이지가 보인다. 다른 사람의 글 번호를 넣었는데 수정이 된다.
이런 흔적은 조용하다. 오류 메시지를 크게 내지 않는다. 테스트 데이터가 적을 때는 문제처럼 보이지도 않는다. 하지만 실제 사용자가 들어오면 이 조용한 틈은 곧 신뢰의 문제로 바뀐다.
Laravel의 CSRF 보호는 사용자가 의도하지 않은 외부 요청을 막는 데 도움을 준다. 입력값 검증은 잘못된 값이 저장되는 일을 줄인다. $fillable은 저장 가능한 컬럼을 제한한다.
그러나 이 셋은 "누가 이 작업을 할 수 있는가?"를 대신 판단하지 않는다.
따라서 CRUD 이후의 다음 문턱은 명확하다.
로그인하지 않은 사용자는 글 작성 화면에 들어갈 수 없어야 한다.
글은 반드시 작성자와 함께 저장되어야 한다.
수정과 삭제는 작성자 또는 허용된 사용자만 할 수 있어야 한다.
화면에서 버튼을 숨기는 것만으로 만족하지 말고, 서버에서 다시 검사해야 한다.
화면은 사용자 경험을 위한 장치다. 최종 판단은 서버가 해야 한다.
서버가 지켜야 할 선
예를 들어 상세 화면에서 작성자에게만 수정 버튼을 보여 줄 수 있다.
@auth
@if ($post->user_id === auth()->id())
<a href="{{ route('posts.edit', $post) }}">수정</a>
@endif
@endauth이 코드는 좋은 시작이다. 사용자가 자신과 관련 없는 버튼을 보지 않게 해 준다. 하지만 이것만으로는 부족하다. 사용자는 버튼을 누르지 않고도 URL을 직접 입력할 수 있다.
그래서 컨트롤러에도 같은 판단이 있어야 한다.
public function edit(Post $post)
{
if ($post->user_id !== auth()->id()) {
abort(403);
}
return view('posts.edit', compact('post'));
}처음에는 중복처럼 보일 수 있다. 하지만 두 코드의 목적은 다르다.
Blade의 조건문은 화면을 정리한다. 컨트롤러의 조건문은 서버의 경계를 지킨다. 사용자의 편의를 위한 코드와 애플리케이션의 안전을 위한 코드는 같은 모양이어도 역할이 다르다.
그리고 이 검사는 수정 화면을 보여 주는 edit에서만 끝나면 안 된다. 실제 데이터가 바뀌는 update, 삭제가 일어나는 destroy에서도 같은 기준이 적용되어야 한다. 수정 화면 접근을 막았더라도, 누군가 직접 PUT이나 DELETE 요청을 만들 수 있기 때문이다.
이 차이를 이해하면 권한 제어를 더 침착하게 설계할 수 있다.
더 이상 방관할 수 없는 이유
게시판은 이제 단순한 예제가 아니다. 라우트, 컨트롤러, 뷰, 모델, 마이그레이션, 검증이 모두 연결되어 있다. 하나의 요청이 들어오면 여러 층을 지나 데이터베이스를 바꾸고, 그 결과가 다시 화면에 나타난다.
그래서 권한 문제도 한 군데만 고쳐서 끝나지 않는다.
라우트는 로그인한 사용자만 통과시킬지 결정해야 한다. 컨트롤러는 현재 사용자가 해당 모델을 다룰 수 있는지 확인해야 한다. 모델은 사용자와 게시글의 관계를 표현해야 한다. 뷰는 사용자에게 허용된 행동만 보여 주어야 한다. 테스트는 이 흐름이 실제로 지켜지는지 확인해야 한다.
이제 선택지는 두 가지다.
하나는 "학습용이니까 괜찮다"고 생각하고 넘어가는 것이다. 다른 하나는 지금 이 시점에서 애플리케이션의 기준을 세우는 것이다.
앞으로의 기능은 이 기준 위에 쌓인다. 댓글을 만들 때도, 관리자 화면을 만들 때도, 파일 업로드를 붙일 때도 같은 질문이 반복된다.
누가 이 작업을 할 수 있는가?이 질문을 피하면 기능이 늘어날수록 불안정해진다. 반대로 지금 받아들이면 이후의 구조는 훨씬 단단해진다.
다음 발걸음
이 장의 결론은 하나다. 우리는 더 이상 "작동하는 CRUD"에 머물 수 없다.
다음 장에서 할 첫걸음은 구체적이다.
로그인 기능을 준비한다.
게시글을 사용자와 연결한다.
글 작성 시 현재 로그인한 사용자의
id를 함께 저장한다.수정과 삭제에서 작성자 여부를 검사한다.
화면과 서버 양쪽에서 허용된 행동만 통과시킨다.
이 작업은 단순히 보안 기능을 추가하는 일이 아니다. 애플리케이션이 사용자라는 현실을 받아들이는 과정이다.
게시글은 이제 아무의 것도 아닌 데이터가 아니다. 누군가의 선택과 책임이 남은 기록이다. 그리고 서버는 그 경계를 기억해야 한다.
이 문턱을 넘으면, 우리는 비로소 실제 서비스에 가까운 Laravel 애플리케이션을 만들기 시작한다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.