CodeIgniter 4로 블로그 만들기 #19 — 댓글 저장 백엔드
지난 회차에서 댓글을 보여 주는 데까지 왔습니다. 이제 독자가 실제로 댓글을 남길 수 있게 백엔드를 만듭니다.
이번 회차는 테스트 우선(TDD) 으로 갑니다. 녹화도 "빨강 → 초록" 순서로 보여 줍니다. 즉, 아직 없는 기능에 대한 테스트를 먼저 써서 실패시키고, 그 실패를 통과시키는 방향으로 컨트롤러·검증규칙·라우트를 만듭니다. 화면 폼은 다음 회차에서 붙이므로, 이번엔 순수하게 "저장" 로직에만 집중합니다.
준비물과 목표
- 이전까지 완성된 저장소 (
comments테이블,CommentModel이 있는 상태) - Shield의
session필터 (ep11에서 쓰기 라우트 보호에 이미 사용)
이번에 못 박을 동작 세 가지:
- 비로그인 사용자는 댓글을 저장할 수 없다 —
session필터가 막는다. - 로그인 사용자가 유효한 입력을 보내면 저장된다 — 작성자(
user_id)가 함께 묶인다. - 내용이 비면 저장되지 않는다 — 검증규칙이 막는다.
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가 요청 본문에 없는데도 로그인 사용자로 묶여 있음(seeInDatabase로user_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가 자동으로 로그인 페이지로 리다이렉트합니다. 그래서 testGuestCannotStoreComment가 assertRedirect()로 통과합니다 — 가드를 컨트롤러에 중복해서 짤 필요가 없습니다.
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
세 테스트가 모두 초록으로 바뀌면 성공입니다.
testGuestCannotStoreComment—session필터가 비로그인을 리다이렉트, 저장 안 됨 ✔testLoggedInUserCanStoreComment—user_id까지 서버가 묶어 저장 ✔testValidationFailsWithEmptyBody—required규칙이 빈 내용 거부 ✔
마무리 — 커밋과 태그
이번 회차에서 한 일:
CommentStoreTest— 비로그인 차단 / 유효 저장 / 빈 내용 거부(실패 → 통과)CommentModel에body 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
아직 댓글이 없습니다.