· nambak80 Blog 로그인
CodeIgniter4

CodeIgniter 4로 블로그 만들기 #19 — 댓글 저장 백엔드

CodeIgniter 4로 블로그 만들기 #19 — 댓글 저장 백엔드

지난 회차에서 댓글을 보여 주는 데까지 왔습니다. 이제 독자가 실제로 댓글을 남길 수 있게 백엔드를 만듭니다.

이번 회차는 테스트 우선(TDD) 으로 갑니다. 녹화도 "빨강 → 초록" 순서로 보여 줍니다. 즉, 아직 없는 기능에 대한 테스트를 먼저 써서 실패시키고, 그 실패를 통과시키는 방향으로 컨트롤러·검증규칙·라우트를 만듭니다. 화면 폼은 다음 회차에서 붙이므로, 이번엔 순수하게 "저장" 로직에만 집중합니다.

준비물과 목표

  • 이전까지 완성된 저장소 (comments 테이블, CommentModel이 있는 상태)
  • Shield의 session 필터 (ep11에서 쓰기 라우트 보호에 이미 사용)

이번에 못 박을 동작 세 가지:

  1. 비로그인 사용자는 댓글을 저장할 수 없다session 필터가 막는다.
  2. 로그인 사용자가 유효한 입력을 보내면 저장된다 — 작성자(user_id)가 함께 묶인다.
  3. 내용이 비면 저장되지 않는다 — 검증규칙이 막는다.

1. 먼저 실패하는 테스트 (빨강)

기능 코드보다 테스트를 먼저 씁니다. tests/Feature/CommentStoreTest.php입니다.

<?php

namespace Tests\Feature;

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 CommentStoreTest extends CIUnitTestCase
{
    use DatabaseTestTrait;
    use FeatureTestTrait;
    use AuthenticationTesting;

    protected $namespace = null;
    protected $refresh   = true;

    protected function setUp(): void
    {
        parent::setUp();

        // 앞선 테스트의 로그인 세션이 새 나가지 않도록 비운다.
        $_SESSION = [];
        \Config\Services::resetSingle('session');
        \Config\Services::resetSingle('auth');
    }
    // ...
}

트레이트 세 개를 조합합니다.

  • DatabaseTestTrait + $refresh = true — 테스트마다 마이그레이션을 새로 돌려 DB를 깨끗한 상태로 만듭니다. $namespace = null은 앱 전체 마이그레이션(우리 것 + Shield 것)을 모두 적용하겠다는 뜻입니다.
  • FeatureTestTrait$this->call('POST', ...)로 실제 라우트를 통과시켜 엔드포인트를 호출합니다.
  • AuthenticationTesting (Shield 제공) — $this->actingAs($user)로 특정 사용자로 로그인한 상태를 흉내 냅니다.

setUp()에서 세션과 auth 싱글턴을 리셋하는 건 실무에서 자주 물리는 함정입니다. 앞 테스트에서 로그인한 상태가 다음 테스트로 새어 들어가면, "비로그인이어야 하는데 로그인된 것으로 오판"하는 유령 실패가 납니다. 매 테스트 시작 시 세션을 비워 각 테스트를 독립시킵니다.

테스트용 사용자와 글을 만드는 헬퍼입니다.

private function makeUser(): User
{
    $users = auth()->getProvider();

    $user = new User([
        'username' => 'commenter',
        'email'    => 'commenter@example.com',
        'password' => 'secret-password-123',
    ]);
    $users->save($user);

    return $users->findById($users->getInsertID());
}

private function makePost(int $userId): int
{
    $model = model(PostModel::class);
    $model->insert([
        'user_id' => $userId,
        'title'   => '댓글 달 글',
        'body'    => '본문',
    ]);

    return $model->getInsertID();
}

사용자는 Shield의 프로바이더(auth()->getProvider())를 통해 만들어야 users·auth_identities 등 관련 테이블이 함께 채워집니다. 그리고 세 가지 시나리오 테스트입니다.

public function testGuestCannotStoreComment(): void
{
    $postId = $this->makePost(1);

    $result = $this->call('POST', "posts/{$postId}/comments", [
        'body' => '게스트 댓글',
    ]);

    $result->assertRedirect();
    $this->dontSeeInDatabase('comments', ['body' => '게스트 댓글']);
}

public function testLoggedInUserCanStoreComment(): void
{
    $user   = $this->makeUser();
    $postId = $this->makePost($user->id);

    $result = $this->actingAs($user)->call('POST', "posts/{$postId}/comments", [
        'body' => '좋은 글이네요!',
    ]);

    $result->assertRedirect();
    $this->seeInDatabase('comments', [
        'post_id' => $postId,
        'user_id' => $user->id,
        'body'    => '좋은 글이네요!',
    ]);
}

public function testValidationFailsWithEmptyBody(): void
{
    $user   = $this->makeUser();
    $postId = $this->makePost($user->id);

    $result = $this->actingAs($user)->call('POST', "posts/{$postId}/comments", [
        'body' => '',
    ]);

    $result->assertRedirect();
    $this->dontSeeInDatabase('comments', ['post_id' => $postId]);
}

세 테스트가 검증하는 핵심:

  • 비로그인: actingAs 없이 호출 → 리다이렉트되고 DB에 저장 안 됨(dontSeeInDatabase).
  • 로그인 + 유효: 저장되고, 특히 user_id가 요청 본문에 없는데도 로그인 사용자로 묶여 있음(seeInDatabaseuser_id까지 확인).
  • 빈 내용: 리다이렉트되지만 DB엔 저장 안 됨.

이 시점에서 테스트를 돌리면 라우트도 컨트롤러도 없으니 전부 빨강입니다. 그게 정상입니다.

composer test -- --filter CommentStoreTest

2. 라우트 등록 (session 그룹 안에)

비로그인 차단은 컨트롤러에서 if로 막는 게 아니라, 라우트를 session 필터 그룹 안에 넣어 프레임워크가 막게 합니다. 이전에 만든 쓰기 라우트 그룹에 한 줄 추가합니다.

$routes->group('', ['filter' => 'session'], static function ($routes) {
    // ... 기존 글 쓰기 라우트들 ...
    $routes->post('posts/(:num)/comments', 'Comments::store/$1'); // 댓글 저장
});

['filter' => 'session'] 그룹 안의 라우트는 로그인하지 않은 요청이 오면 Shield가 자동으로 로그인 페이지로 리다이렉트합니다. 그래서 testGuestCannotStoreCommentassertRedirect()로 통과합니다 — 가드를 컨트롤러에 중복해서 짤 필요가 없습니다.

3. 검증규칙 추가

빈 내용을 막는 규칙을 CommentModel에 넣습니다.

protected $validationRules = [
    'body' => 'required',
];

protected $validationMessages = [
    'body' => [
        'required' => '댓글 내용을 입력해 주세요.',
    ],
];

모델에 $validationRules를 두면 insert() 시 자동으로 검증이 돌고, 실패하면 insert()false를 반환합니다. 메시지를 한국어로 커스터마이징해 두면 나중에 폼에서 그대로 보여 줄 수 있습니다(ep20).

4. Comments 컨트롤러 (초록)

이제 저장 로직입니다. 새 컨트롤러 Comments를 만듭니다.

<?php

namespace App\Controllers;

use App\Models\CommentModel;
use App\Models\PostModel;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\RedirectResponse;

class Comments extends BaseController
{
    /**
     * 글 한 건에 댓글을 저장한다. (세션 필터로 로그인 사용자만 접근)
     */
    public function store(int $postId): RedirectResponse
    {
        $post = model(PostModel::class)->find($postId);

        // 없는 글에는 댓글을 달 수 없다.
        if ($post === null) {
            throw PageNotFoundException::forPageNotFound();
        }

        $model = model(CommentModel::class);

        $data = [
            'post_id' => $postId,
            'user_id' => auth()->id(),
            'body'    => $this->request->getPost('body'),
        ];

        // 검증 실패 시: 입력값을 들고 글 상세로 되돌아간다.
        if (! $model->insert($data)) {
            return redirect()->back()
                ->withInput()
                ->with('errors', $model->errors());
        }

        return redirect()->to('posts/' . $post->slug)
            ->with('message', '댓글이 등록되었습니다.');
    }
}

한 줄씩 의도를 읽어 봅니다.

  • $post === null → 404. 없는 글 ID로 댓글을 밀어 넣는 시도를 먼저 차단합니다. slug로 리다이렉트하려면 어차피 글 객체가 필요하기도 합니다.
  • 'user_id' => auth()->id(). 이게 이번 회차의 핵심 보안 습관입니다. 작성자를 요청 본문에서 받지 않고 서버가 로그인 세션에서 뽑아 묶습니다. 사용자가 폼에 user_id를 위조해 보내도 무시됩니다. body만 사용자 입력(getPost('body'))에서 받습니다.
  • 검증 실패 시 redirect()->back()->withInput()->with('errors', ...). 이전에 글 저장 때 익힌 패턴 그대로입니다. withInput()으로 방금 친 내용을 세션에 담아 되돌아가고(폼이 비지 않도록), 에러 메시지도 플래시로 넘깁니다. 이 errors/old 값은 다음 회차의 댓글 폼에서 꺼내 씁니다.
  • 성공 시 posts/{slug}로 리다이렉트 + 플래시 메시지. slug 기반 URL로 되돌아가 방금 단 댓글을 바로 확인할 수 있습니다. PRG(Post-Redirect-Get) 패턴이라 새로고침 시 중복 전송도 막습니다.

확인 — 빨강에서 초록으로

이제 테스트를 다시 돌립니다.

composer test -- --filter CommentStoreTest

세 테스트가 모두 초록으로 바뀌면 성공입니다.

  • testGuestCannotStoreCommentsession 필터가 비로그인을 리다이렉트, 저장 안 됨 ✔
  • testLoggedInUserCanStoreCommentuser_id까지 서버가 묶어 저장 ✔
  • testValidationFailsWithEmptyBodyrequired 규칙이 빈 내용 거부 ✔

마무리 — 커밋과 태그

이번 회차에서 한 일:

  • CommentStoreTest — 비로그인 차단 / 유효 저장 / 빈 내용 거부(실패 → 통과)
  • CommentModelbody required 검증규칙
  • Comments::store() — 글 존재 확인 + user_id를 서버에서 묶기 + 검증/저장
  • 라우트 post('posts/(:num)/comments')session 그룹에 등록
git add .
git commit -m "feat: 댓글 저장 백엔드"
git tag ep19

다음 회차

저장 백엔드는 완성됐지만, 아직 브라우저에는 댓글을 입력할 폼이 없습니다. 다음 글에서는 댓글 폼과 인증 가드를 다룹니다. 로그인 사용자에게만 폼을 노출하고 비로그인에게는 로그인 유도를 보여 주는데, 이 "폼 노출 가드"는 흔히 빠뜨리는 부분이라 테스트로 확실히 해놓습니다.


이번 회차 요약

  • 다루는 파일: tests/Feature/CommentStoreTest.php·app/Controllers/Comments.php(생성) / app/Models/CommentModel.php·app/Config/Routes.php(수정)
  • 핵심: 테스트 우선(빨강 → 초록). 비로그인 차단은 session 필터 그룹에 맡기고, 작성자는 auth()->id()로 서버가 묶어 위조를 막는다. 검증 실패 시 withInput().

다음: 댓글 폼과 인증 가드

댓글 0

아직 댓글이 없습니다.

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

← 목록으로