기능은 완성됐지만 신뢰는 완성되지 않았다
이전 장에서 우리는 중요한 사실을 확인했다. 게시글 CRUD는 겉으로는 잘 동작했다. 목록이 보였고, 글을 만들 수 있었고, 수정과 삭제도 가능했다. 하지만 그 기능을 누가 실행하는지는 제대로 따지지 않았다.
이 상태의 게시판은 작은 학습 예제에서는 문제가 없어 보인다. 혼자 만들고 혼자 쓰기 때문이다. 하지만 사용자가 둘 이상이 되는 순간, 애플리케이션은 공동체가 된다. 누군가 쓴 글은 그 사람의 흔적이고, 수정과 삭제는 그 흔적에 영향을 주는 행동이다.
그래서 권한 문제는 단순한 보안 기능이 아니다. 애플리케이션 안에서 사용자 사이의 신뢰를 지키는 장치다.
이 장에서는 게시글을 현재 로그인한 사용자와 연결하고, 작성자만 수정하거나 삭제할 수 있게 만든다. 아직 모든 권한 설계를 완성하지는 않는다. 먼저 가장 중요한 균열을 막는다.
AI 생성 이미지
인증과 권한을 다시 나누어 보기
비슷해 보이지만 인증과 권한은 다르다.
인증은 "당신은 누구인가?"를 확인한다. 로그인한 사용자인지 확인하는 일이 여기에 속한다.
권한은 "당신이 이 작업을 해도 되는가?"를 판단한다. 로그인한 사용자라도 다른 사람의 글을 삭제할 수는 없어야 한다.
게시글 애플리케이션에서 필요한 판단은 두 단계다.
글을 작성하려면 로그인해야 한다.
글을 수정하거나 삭제하려면 그 글의 작성자여야 한다.
첫 번째는 auth 미들웨어로 처리할 수 있다. 두 번째는 게시글의 user_id와 현재 로그인한 사용자의 ID를 비교해야 한다.
게시글에 작성자 연결하기
먼저 posts 테이블에 작성자 정보를 저장할 컬럼이 있어야 한다. 이전 장에서 이야기한 것처럼 user_id는 게시글이 어떤 사용자에게 속하는지 나타내는 외래 키다.
이미 posts 테이블을 만들었다면 새 마이그레이션을 추가한다.
php artisan make:migration add_user_id_to_posts_table --table=posts마이그레이션 파일에는 다음처럼 작성한다.
use IlluminateDatabaseMigrationsMigration;
use IlluminateDatabaseSchemaBlueprint;
use IlluminateSupportFacadesSchema;
return new class extends Migration
{
public function up(): void
{
Schema::table('posts', function (Blueprint $table) {
$table->foreignId('user_id')
->after('id')
->constrained()
->cascadeOnDelete();
});
}
public function down(): void
{
Schema::table('posts', function (Blueprint $table) {
$table->dropConstrainedForeignId('user_id');
});
}
};foreignId('user_id')는 정수형 외래 키 컬럼을 만든다. constrained()는 기본 규칙에 따라 users 테이블의 id를 참조하게 한다. cascadeOnDelete()는 사용자가 삭제될 때 그 사용자의 게시글도 함께 삭제되도록 설정한다.
학습용 게시판에서는 이해하기 쉬운 선택이다. 다만 실제 서비스에서는 사용자를 삭제해도 게시글은 남겨야 할 수 있다. 그런 경우에는 작성자 이름을 별도로 보관하거나, nullOnDelete() 같은 다른 전략을 검토해야 한다.
마이그레이션을 실행한다.
php artisan migrate만약 이미 게시글 데이터가 있는데 user_id가 비어 있어서 오류가 난다면, 개발 중에는 데이터를 초기화하고 다시 실행할 수 있다.
php artisan migrate:fresh --seed단, 이 명령은 데이터베이스를 비운 뒤 다시 만든다. 실제 운영 서버에서는 사용하면 안 된다.
모델 관계 정의하기
데이터베이스에 연결 정보가 생겼다면, Eloquent 모델에도 관계를 알려 주어야 한다.
Post 모델에 작성자 관계를 추가한다.
namespace AppModels;
use IlluminateDatabaseEloquentModel;
use IlluminateDatabaseEloquentRelationsBelongsTo;
class Post extends Model
{
protected $fillable = [
'title',
'content',
'is_published',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}여기서 일부러 user_id를 $fillable에 넣지 않았다. 작성자는 사용자가 폼에서 직접 고르는 값이 아니라, 서버가 현재 로그인 정보를 보고 정해야 하는 값이기 때문이다.
이 차이가 중요하다. 사용자가 보낸 값은 검증해야 하고, 사용자의 신원에서 결정되는 값은 서버에서 직접 넣어야 한다.
반대로 User 모델에는 사용자가 가진 게시글 목록을 정의할 수 있다.
namespace AppModels;
use IlluminateDatabaseEloquentRelationsHasMany;
use IlluminateFoundationAuthUser as Authenticatable;
class User extends Authenticatable
{
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
}이제 코드는 더 자연스럽게 읽힌다.
$user->posts; // 이 사용자가 쓴 게시글 목록
$post->user; // 이 게시글의 작성자관계는 단순히 편한 문법이 아니다. 애플리케이션의 의미를 코드에 남기는 방법이다.
글 작성은 로그인한 사용자만 가능하게 하기
이제 라우트를 보호한다. 게시글 목록과 상세 화면은 누구나 볼 수 있게 두고, 작성·수정·삭제는 로그인한 사용자만 접근하게 만들 수 있다.
routes/web.php를 다음처럼 나눌 수 있다.
use AppHttpControllersPostController;
use IlluminateSupportFacadesRoute;
Route::resource('posts', PostController::class)
->only(['index', 'show']);
Route::middleware('auth')->group(function () {
Route::resource('posts', PostController::class)
->except(['index', 'show']);
});이 구조는 의도를 분명하게 보여 준다.
조회는 공개 기능이고, 변경은 로그인한 사용자 기능이다.
만약 Laravel 인증 스캐폴딩을 아직 설치하지 않았다면, 프로젝트 상황에 맞게 Breeze, Jetstream, Fortify 같은 선택지를 검토할 수 있다. 이 책에서는 핵심 흐름을 이해하는 데 집중한다. 중요한 점은 auth 미들웨어가 로그인 여부를 확인하고, 로그인하지 않은 사용자는 보호된 라우트에 접근하지 못하게 한다는 사실이다.
저장할 때 작성자를 서버에서 정하기
이제 store 메서드를 수정한다.
기존 코드는 아마 다음과 비슷했을 것이다.
public function store(Request $request)
{
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'content' => ['required', 'string'],
]);
Post::create($validated);
return redirect()->route('posts.index');
}이 코드는 게시글 내용은 저장하지만, 누가 썼는지는 저장하지 않는다. 이제 현재 로그인한 사용자의 게시글로 만들어야 한다.
public function store(Request $request)
{
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'content' => ['required', 'string'],
]);
$post = $request->user()->posts()->create($validated);
return redirect()
->route('posts.show', $post)
->with('status', '게시글을 작성했습니다.');
}$request->user()는 현재 로그인한 사용자를 반환한다. 그리고 posts() 관계를 통해 글을 만들면 Laravel은 자동으로 user_id를 채운다.
이 방식은 다음 코드보다 안전하고 의미가 분명하다.
Post::create([
...$validated,
'user_id' => $request->user()->id,
]);두 방식 모두 동작할 수 있지만, 관계를 통해 생성하면 "이 사용자의 게시글을 만든다"는 의도가 더 잘 드러난다.
화면에서 먼저 신호를 줄 것
작성자가 아닌 사용자에게 수정 버튼을 보여 주지 않는 것은 좋은 사용자 경험이다. 사용자가 눌러도 실패할 버튼을 미리 숨기면 혼란이 줄어든다.
상세 화면에서 다음처럼 처리할 수 있다.
@if (auth()->check() && auth()->id() === $post->user_id)
<a href="{{ route('posts.edit', $post) }}">수정</a>
<form method="POST" action="{{ route('posts.destroy', $post) }}">
@csrf
@method('DELETE')
<button type="submit">삭제</button>
</form>
@endifauth()->check()는 로그인 여부를 확인한다. auth()->id()는 현재 로그인한 사용자의 ID를 가져온다. 이 값이 게시글의 user_id와 같을 때만 수정과 삭제 버튼을 보여 준다.
하지만 여기서 멈추면 안 된다. 화면에서 버튼을 숨기는 것은 안내일 뿐이다. 서버가 권한을 검사하지 않으면, 사용자는 직접 URL을 입력하거나 요청을 만들어 수정과 삭제를 시도할 수 있다.
컨트롤러에서 반드시 다시 검사하기
수정 폼을 보여 주는 edit, 실제 수정하는 update, 삭제하는 destroy에서 작성자 여부를 확인해야 한다.
가장 단순한 방법은 abort_if()를 사용하는 것이다.
public function edit(Post $post)
{
abort_if($post->user_id !== request()->user()->id, 403);
return view('posts.edit', compact('post'));
}403은 서버가 요청을 이해했지만 허용하지 않는다는 의미다. 로그인하지 않아서 막힌 것이 아니라, 로그인했더라도 이 작업을 할 권한이 없다는 뜻이다.
update와 destroy에도 같은 검사를 넣는다.
public function update(Request $request, Post $post)
{
abort_if($post->user_id !== $request->user()->id, 403);
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'content' => ['required', 'string'],
]);
$post->update($validated);
return redirect()
->route('posts.show', $post)
->with('status', '게시글을 수정했습니다.');
}
public function destroy(Request $request, Post $post)
{
abort_if($post->user_id !== $request->user()->id, 403);
$post->delete();
return redirect()
->route('posts.index')
->with('status', '게시글을 삭제했습니다.');
}이제 게시글 변경 요청은 두 문턱을 지난다.
먼저 auth 미들웨어가 로그인 여부를 확인한다. 그다음 컨트롤러가 작성자 여부를 확인한다.
이 둘은 서로 대체할 수 없다. 로그인 확인만으로는 부족하고, 작성자 확인만으로는 로그인하지 않은 요청을 다루기 어렵다. 각자 맡은 역할이 다르다.
반복되는 검사는 다음 전환점의 단서다
지금 코드는 안전한 방향으로 크게 나아갔다. 하지만 새로운 흔적도 남았다.
edit, update, destroy에 같은 권한 검사 코드가 반복된다.
abort_if($post->user_id !== $request->user()->id, 403);반복은 나쁜 것이기만 한 것은 아니다. 처음에는 반복 덕분에 흐름이 눈에 잘 보인다. 초보자에게는 숨겨진 추상화보다 명시적인 코드가 더 도움이 된다.
하지만 같은 판단이 여러 곳에 흩어지면, 나중에 규칙이 바뀔 때 문제가 생긴다.
예를 들어 관리자는 모든 글을 수정할 수 있게 하고 싶다면 어떻게 해야 할까? 비공개 글은 작성자와 관리자만 볼 수 있게 하려면 어디를 고쳐야 할까? 팀원이 한 메서드에서는 권한 검사를 고치고, 다른 메서드에서는 빠뜨리면 어떻게 될까?
이 질문들이 다음 장으로 이어진다. Laravel에는 이런 권한 판단을 한곳에 모으는 방법이 있다. 바로 Policy다. Policy는 "누가 어떤 모델에 대해 어떤 행동을 할 수 있는가"를 클래스 안에 정리하게 해 준다.
아직은 Policy로 넘어가기 전에, 우리가 직접 작성한 검사 흐름을 충분히 이해해야 한다.
현재 상태 점검하기
여기까지 구현했다면 다음 순서로 확인한다.
로그인하지 않은 상태에서
/posts/create에 접근한다.로그인 페이지로 이동하거나 접근이 차단되는지 확인한다.
로그인 후 게시글을 작성한다.
데이터베이스의
posts.user_id에 현재 사용자 ID가 저장되는지 확인한다.다른 사용자로 로그인해 같은 게시글 상세 화면에 접근한다.
수정·삭제 버튼이 보이지 않는지 확인한다.
직접
/posts/{post}/edit주소로 접근했을 때403이 나오는지 확인한다.직접 수정 또는 삭제 요청을 보내도 막히는지 확인한다.
중요한 것은 버튼이 보이는지만 확인하지 않는 것이다. 웹 애플리케이션의 안전성은 화면이 아니라 요청 처리 흐름에서 결정된다.
이제 관망할 수 없다
게시판은 이제 단순한 CRUD 예제를 넘어섰다. 데이터는 사용자와 연결되었고, 변경 작업에는 로그인과 작성자 확인이 필요해졌다.
이 변화는 작아 보이지만 구조적으로는 큰 전환점이다.
처음에는 라우트가 URL을 받았다. 그다음 컨트롤러가 요청을 처리했다. Eloquent 모델은 데이터베이스와 연결되었고, 검증은 사용자의 입력을 걸러 냈다. 이제 인증과 권한이 그 흐름 위에 올라왔다.
각 계층은 서로를 대신하지 않는다.
라우트는 입구를 정한다. 미들웨어는 들어올 수 있는 사용자인지 먼저 확인한다. 컨트롤러는 요청을 처리하면서 필요한 권한을 검사한다. 모델 관계는 게시글과 사용자의 연결을 표현한다. Blade는 그 결과를 사용자에게 보여 준다.
흔들린 신뢰를 회복하는 방법은 감으로 코드를 덧붙이는 것이 아니다. 요청이 어디서 들어오고, 어느 단계에서 무엇을 확인해야 하는지 차분히 나누는 것이다.
다음 장에서는 반복되는 권한 판단을 더 유지보수하기 좋은 구조로 옮긴다. 지금까지 컨트롤러 안에서 직접 확인하던 작성자 여부를 Policy로 정리하고, 화면과 서버 양쪽에서 같은 규칙을 사용하도록 만들어 볼 것이다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.