CodeIgniter 4로 블로그 만들기 #21 — 댓글 삭제와 권한
댓글을 표시하고, 저장하고, 로그인 사용자에게 폼을 노출하는 데까지 왔습니다. 댓글 섹션의 마지막 조각은 삭제입니다.
삭제는 단순히 "지우는 버튼"을 다는 문제가 아니라 권한의 문제입니다. 아무나 남의 댓글을 지우면 안 되죠. 이번 회차의 규칙은 이렇습니다.
댓글을 지울 수 있는 사람 = 댓글 작성자 본인 · 글 작성자 · 관리자(admin)
"글 작성자"를 넣은 건, 자기 글에 달린 스팸이나 부적절한 댓글을 글쓴이가 직접 정리할 수 있어야 하기 때문입니다. 이 권한 조합을 컨트롤러 가드와 화면 버튼 노출 양쪽에서 일치시키고, 특히 제3자 차단을 테스트로 못 박습니다.
준비물과 목표
- 지금까지 완성된 저장소 (
Comments컨트롤러, 댓글 목록·폼이 동작하는 상태) - Shield의 그룹 개념 —
$user->inGroup('admin'),$admin->addGroup('admin')
이번에 만들 것:
CommentDeleteTest— 작성자 / 글작성자 / 제3자(403) / 관리자 (실패 → 통과)Comments::delete()+canDelete()가드- 삭제 라우트
comments/_list.php— 권한 있을 때만 삭제 버튼
1. 먼저 실패하는 테스트 (빨강)
권한 조합이 핵심이니, 경우의 수를 테스트로 먼저 나열합니다. tests/Feature/CommentDeleteTest.php입니다. 트레이트와 setUp()은 앞선 댓글 테스트들과 같습니다.
<?php
namespace Tests\Feature;
use App\Models\CommentModel;
use App\Models\PostModel;
use CodeIgniter\Shield\Entities\User;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Shield\Test\AuthenticationTesting;
final class CommentDeleteTest extends CIUnitTestCase
{
use DatabaseTestTrait;
use FeatureTestTrait;
use AuthenticationTesting;
protected $namespace = null;
protected $refresh = true;
// setUp(): 세션·auth 리셋
private function makeUser(string $username, string $email): User { /* ... */ }
private function makePost(int $userId): int { /* ... */ }
private function makeComment(int $postId, int $userId): int
{
$model = model(CommentModel::class);
$model->insert([
'post_id' => $postId,
'user_id' => $userId,
'body' => '지울 댓글',
]);
return $model->getInsertID();
}
}
이번엔 사용자를 여러 명(글 작성자·댓글 작성자·제3자·관리자) 만들어야 해서, makeUser()가 username과 email을 인자로 받도록 바뀌었습니다. 그리고 권한별로 다섯 개의 테스트를 씁니다.
(1) 비로그인은 삭제 불가 — session 필터가 막습니다.
public function testGuestCannotDeleteComment(): void
{
$postId = $this->makePost(1);
$commentId = $this->makeComment($postId, 1);
$result = $this->call('POST', "comments/{$commentId}/delete");
$result->assertRedirect();
$this->seeInDatabase('comments', ['id' => $commentId]);
}
(2) 댓글 작성자 본인은 자기 댓글 삭제 가능
public function testCommentAuthorCanDeleteOwnComment(): void
{
$author = $this->makeUser('postwriter', 'pw@example.com');
$commenter = $this->makeUser('commenter', 'c@example.com');
$postId = $this->makePost($author->id);
$commentId = $this->makeComment($postId, $commenter->id);
$result = $this->actingAs($commenter)->call('POST', "comments/{$commentId}/delete");
$result->assertRedirect();
$this->dontSeeInDatabase('comments', ['id' => $commentId]);
}
(3) 글 작성자는 남이 단 댓글도 삭제 가능
public function testPostAuthorCanDeleteAnyComment(): void
{
$author = $this->makeUser('postwriter', 'pw@example.com');
$commenter = $this->makeUser('commenter', 'c@example.com');
$postId = $this->makePost($author->id);
$commentId = $this->makeComment($postId, $commenter->id);
// 글 작성자는 남이 단 댓글도 지울 수 있다.
$result = $this->actingAs($author)->call('POST', "comments/{$commentId}/delete");
$result->assertRedirect();
$this->dontSeeInDatabase('comments', ['id' => $commentId]);
}
(4) 제3자는 403으로 차단 — 이게 이번 회차에서 제일 중요합니다.
public function testOtherUserCannotDeleteComment(): void
{
$author = $this->makeUser('postwriter', 'pw@example.com');
$commenter = $this->makeUser('commenter', 'c@example.com');
$other = $this->makeUser('other', 'o@example.com');
$postId = $this->makePost($author->id);
$commentId = $this->makeComment($postId, $commenter->id);
$result = $this->actingAs($other)->call('POST', "comments/{$commentId}/delete");
$result->assertStatus(403);
$this->seeInDatabase('comments', ['id' => $commentId]);
}
로그인은 했지만 권한이 없는 제3자는 리다이렉트가 아니라 403(Forbidden) 이어야 합니다. 그리고 댓글은 여전히 DB에 남아 있어야 합니다(seeInDatabase).
(5) 관리자는 아무 댓글이나 삭제 가능
public function testAdminCanDeleteAnyComment(): void
{
$author = $this->makeUser('postwriter', 'pw@example.com');
$commenter = $this->makeUser('commenter', 'c@example.com');
$postId = $this->makePost($author->id);
$commentId = $this->makeComment($postId, $commenter->id);
$admin = $this->makeUser('admin', 'admin@example.com');
$admin->addGroup('admin');
$result = $this->actingAs($admin)->call('POST', "comments/{$commentId}/delete");
$result->assertRedirect();
$this->dontSeeInDatabase('comments', ['id' => $commentId]);
}
$admin->addGroup('admin')으로 Shield 그룹을 부여한 뒤, 남의 글·남의 댓글을 지울 수 있는지 확인합니다.
이 다섯 테스트는 라우트도 delete()도 없으니 모두 빨강입니다.
2. 삭제 라우트 등록
삭제도 쓰기 동작이니 session 그룹에 넣습니다. 라우트는 글이 아니라 댓글 ID 기준입니다.
$routes->group('', ['filter' => 'session'], static function ($routes) {
// ... 기존 라우트들 ...
$routes->post('posts/(:num)/comments', 'Comments::store/$1'); // 댓글 저장
$routes->post('comments/(:num)/delete', 'Comments::delete/$1'); // 댓글 삭제
});
session 그룹 안이므로 비로그인은 여기서 리다이렉트됩니다 — 그래서 testGuestCannotDeleteComment가 assertRedirect()로 통과합니다. 로그인은 했지만 권한이 없는 경우(403)는 필터가 아니라 컨트롤러 가드의 몫입니다.
3. 컨트롤러 가드 (초록)
Comments 컨트롤러에 delete()와 권한 판정 헬퍼 canDelete()를 더합니다.
use App\Entities\Comment;
use App\Entities\Post;
use CodeIgniter\HTTP\ResponseInterface;
// ...
/**
* 댓글을 삭제한다. 댓글 작성자 본인·글 작성자·관리자만 가능하다.
*/
public function delete(int $commentId): ResponseInterface|RedirectResponse
{
$model = model(CommentModel::class);
$comment = $model->find($commentId);
if ($comment === null) {
throw PageNotFoundException::forPageNotFound();
}
$post = model(PostModel::class)->find((int) $comment->post_id);
// 컨트롤러 가드: 권한이 없으면 403 으로 막는다.
if (! $this->canDelete($comment, $post)) {
return $this->response->setStatusCode(403, '삭제 권한이 없습니다.');
}
$model->delete($commentId);
$to = $post !== null ? 'posts/' . $post->slug : 'posts';
return redirect()->to($to)->with('message', '댓글이 삭제되었습니다.');
}
/**
* 댓글 작성자 본인이거나, 글 작성자이거나, 관리자면 삭제할 수 있다.
*/
private function canDelete(Comment $comment, ?Post $post): bool
{
$user = auth()->user();
if ($user === null) {
return false;
}
// 댓글 작성자 본인
if ((int) $comment->user_id === (int) $user->id) {
return true;
}
// 글 작성자(자기 글의 댓글을 정리할 수 있다)
if ($post !== null && (int) $post->user_id === (int) $user->id) {
return true;
}
return $user->inGroup('admin');
}
설계 의도를 짚습니다.
- 없는 댓글 → 404, 권한 없음 → 403. 두 실패를 다른 상태 코드로 구분합니다. "존재하지 않음"과 "존재하지만 권한 없음"은 다른 상황이기 때문입니다. 반환 타입이
ResponseInterface|RedirectResponse인 이유가 이것입니다(403 응답 또는 성공 리다이렉트). - 권한 판정을
canDelete()로 분리했습니다. 컨트롤러 액션은 "찾고 → 가드 → 실행 → 리다이렉트" 흐름만 읽히고, 복잡한 권한 규칙은 한 곳에 모입니다. canDelete()의 세 갈래가 곧 권한 규칙 그대로입니다: 댓글 작성자 → 글 작성자 → 관리자. 앞 조건에서 걸리면 바로true, 끝까지 안 걸리면inGroup('admin')결과를 돌려줍니다. 타입 비교는(int)캐스팅으로 안전하게 맞춥니다(요청·DB에서 온 값이 문자열일 수 있어서).- 삭제 후에는 글의 slug 페이지로 되돌아가 플래시 메시지를 남깁니다. 글이 어떤 이유로 사라졌다면 목록으로 폴백합니다.
이제 다섯 테스트가 모두 초록입니다. 특히 testOtherUserCannotDeleteComment가 403으로 통과하는지 꼭 확인하세요 — 권한 로직의 안전망입니다.
4. 화면에 삭제 버튼 노출
마지막으로, 삭제할 수 있는 사람에게만 목록에서 삭제 버튼을 보여 줍니다. 이때 화면의 노출 조건과 컨트롤러의 가드 규칙이 일치해야 합니다. comments/_list.php를 수정하는데, 글 작성자 판정을 위해 부분 뷰에 $post도 넘겨야 합니다.
먼저 상세 뷰에서 목록 include에 $post를 추가합니다.
<?= $this->include('comments/_list', ['comments' => $comments, 'post' => $post]) ?>
그리고 _list.php의 각 댓글 메타 영역에 삭제 버튼을 답니다.
<?php // 댓글 작성자 본인·글 작성자·관리자에게만 삭제 버튼을 노출한다. ?>
<?php $canDelete = auth()->loggedIn() && (
(int) $comment->user_id === (int) auth()->id()
|| (int) $post->user_id === (int) auth()->id()
|| auth()->user()->inGroup('admin')
); ?>
<?php if ($canDelete): ?>
<form action="<?= site_url('comments/' . $comment->id . '/delete') ?>" method="post"
onsubmit="return confirm('댓글을 삭제하시겠습니까?');" style="display:inline">
<?= csrf_field() ?>
<button type="submit" class="comment-delete">삭제</button>
</form>
<?php endif ?>
- 뷰의
$canDelete조건이 컨트롤러canDelete()와 똑같은 세 갈래(댓글 작성자 · 글 작성자 · 관리자)입니다. 화면과 서버가 어긋나면, 버튼이 보이는데 눌러도 403이 나거나(혹은 그 반대) 하는 혼란이 생깁니다. 두 곳을 나란히 두고 규칙을 맞춥니다. confirm()으로 실수 삭제를 한 번 막고,csrf_field()로 CSRF를 방지합니다(글 삭제 폼과 같은 패턴).- 물론 화면에서 버튼을 숨기는 건 UX일 뿐, 진짜 방어선은 서버의
canDelete()가드입니다. 버튼이 안 보여도 POST를 직접 쏘면 403으로 막힙니다.
삭제 버튼 스타일도 app.css에 더합니다.
.comment-delete { margin-left: auto; background: none; border: none; padding: 0; font-size: 12px; color: var(--color-ink-4); cursor: pointer; }
.comment-delete:hover { color: var(--color-danger); }
확인
composer test -- --filter CommentDeleteTest
php spark serve
브라우저에서:
- 내가 쓴 댓글 옆에는 "삭제"가 보이고, 남이 쓴 댓글 옆에는 안 보입니다.
- 내 글에 달린 남의 댓글에는 (글 작성자로서) "삭제"가 보입니다.
- 관리자 계정으로 보면 모든 댓글에 "삭제"가 보입니다.
- 삭제를 누르면 확인창 → slug 페이지로 돌아오며 "댓글이 삭제되었습니다." 플래시가 뜹니다.
이걸로 댓글 섹션(ep18–ep21) 이 표시·저장·폼·삭제까지 온전히 완성됐습니다.
마무리 — 커밋과 태그
이번 회차에서 한 일:
CommentDeleteTest— 작성자 / 글작성자 / 제3자 403 / 관리자(실패 → 통과)Comments::delete()+canDelete()가드(댓글 작성자 OR 글 작성자 OR admin)- 삭제 라우트
post('comments/(:num)/delete')를session그룹에 comments/_list.php— 권한 있을 때만 삭제 버튼,$post전달
git add .
git commit -m "feat: 댓글 삭제와 권한"
git tag ep21
다음 회차
댓글 섹션을 마쳤으니, 다음 섹션은 분류와 검색입니다. 다음 글에서는 카테고리 구조를 만듭니다. categories 테이블을 새로 만들고, 기존 posts에 category_id를 더하는 테이블 변경 마이그레이션을 다루며, CategoryModel/Category 엔티티로 글을 분류할 기반을 놓습니다.
이번 회차 요약
- 다루는 파일:
tests/Feature/CommentDeleteTest.php(생성) /app/Controllers/Comments.php·app/Config/Routes.php·app/Views/comments/_list.php·app/Views/posts/show.php·public/assets/css/app.css(수정)- 핵심: 삭제 권한은 "댓글 작성자 · 글 작성자 · 관리자". 컨트롤러
canDelete()가드로 제3자는 403, 화면 버튼 노출 조건도 같은 규칙으로 일치시킨다. 없음(404)과 권한 없음(403)을 구분.
다음: 카테고리 구조
댓글 0
아직 댓글이 없습니다.