· nambak80 Blog 로그인
CodeIgniter4

CodeIgniter 4로 블로그 만들기 #21 — 댓글 삭제와 권한

CodeIgniter 4로 블로그 만들기 #21 — 댓글 삭제와 권한

댓글을 표시하고, 저장하고, 로그인 사용자에게 폼을 노출하는 데까지 왔습니다. 댓글 섹션의 마지막 조각은 삭제입니다.

삭제는 단순히 "지우는 버튼"을 다는 문제가 아니라 권한의 문제입니다. 아무나 남의 댓글을 지우면 안 되죠. 이번 회차의 규칙은 이렇습니다.

댓글을 지울 수 있는 사람 = 댓글 작성자 본인 · 글 작성자 · 관리자(admin)

"글 작성자"를 넣은 건, 자기 글에 달린 스팸이나 부적절한 댓글을 글쓴이가 직접 정리할 수 있어야 하기 때문입니다. 이 권한 조합을 컨트롤러 가드화면 버튼 노출 양쪽에서 일치시키고, 특히 제3자 차단을 테스트로 못 박습니다.

준비물과 목표

  • 지금까지 완성된 저장소 (Comments 컨트롤러, 댓글 목록·폼이 동작하는 상태)
  • Shield의 그룹 개념 — $user->inGroup('admin'), $admin->addGroup('admin')

이번에 만들 것:

  1. CommentDeleteTest — 작성자 / 글작성자 / 제3자(403) / 관리자 (실패 → 통과)
  2. Comments::delete() + canDelete() 가드
  3. 삭제 라우트
  4. 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()usernameemail을 인자로 받도록 바뀌었습니다. 그리고 권한별로 다섯 개의 테스트를 씁니다.

(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 그룹 안이므로 비로그인은 여기서 리다이렉트됩니다 — 그래서 testGuestCannotDeleteCommentassertRedirect()로 통과합니다. 로그인은 했지만 권한이 없는 경우(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 테이블을 새로 만들고, 기존 postscategory_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

아직 댓글이 없습니다.

로그인 후 댓글을 남길 수 있습니다.

← 목록으로