CodeIgniter 4로 블로그 만들기 #12 — 글 저장 백엔드와 검증
이제 글을 쓰는 기능을 만듭니다. 그런데 화면(폼)부터 만들지 않습니다. 이번 회차는 백엔드 먼저입니다. 저장 라우트, 컨트롤러의 저장 로직, 모델의 검증 규칙까지 만들고, 그것이 제대로 동작하는지 테스트로 먼저 못 박은 뒤 구현합니다.
이 순서에는 이유가 있습니다. 폼 화면은 "보기 좋게" 다듬는 부분이 많아 본질을 흐리기 쉽습니다. 반면 "로그인한 사람만 저장할 수 있는가", "빈 제목은 거부되는가" 같은 규칙은 화면과 무관하게 반드시 지켜져야 합니다. 그래서 이 규칙들을 먼저 테스트로 고정합니다. 이것이 이 강좌가 채택한 테스트 우선(TDD) 방식입니다.
준비물 / 목표
- 지금까지 완성된 상태 (
session필터 그룹 골격, 헤더 분기). - 이번 회차에서 하는 일:
- 실패하는 Feature 테스트를 먼저 작성 (게스트 차단 / 저장 성공 / 검증 실패)
session그룹에posts/new·posts(POST) 라우트 추가- 컨트롤러에
new()·create()구현 - 모델에 검증 규칙 추가
- 테스트를 초록으로
1. 빨간불 먼저 — 실패 테스트 작성
구현 코드보다 테스트를 먼저 씁니다. 아직 create()도 라우트도 없으니, 이 테스트는 당연히 실패(빨강)합니다. 우리가 검증하려는 세 가지 규칙은 이렇습니다.
tests/Feature/PostStoreTest.php:
final class PostStoreTest extends CIUnitTestCase
{
use DatabaseTestTrait;
use FeatureTestTrait;
use AuthenticationTesting;
protected $namespace = null;
protected $refresh = true;
private function makeUser(): User
{
$users = auth()->getProvider();
$user = new User([
'username' => 'writer',
'email' => 'writer@example.com',
'password' => 'secret-password-123',
]);
$users->save($user);
return $users->findById($users->getInsertID());
}
AuthenticationTesting은 Shield가 제공하는 테스트 트레이트로, 뒤에 나올 actingAs()(특정 사용자로 로그인한 척하기)를 쓸 수 있게 해 줍니다. makeUser()는 테스트용 사용자 한 명을 만들어 돌려주는 헬퍼입니다.
이제 세 개의 테스트입니다. 먼저 게스트(비로그인)는 저장할 수 없어야 합니다.
public function testGuestCannotStorePost(): void
{
// 로그인하지 않으면 session 필터가 막아 글이 저장되지 않는다.
$result = $this->call('POST', 'posts', [
'title' => '게스트 글',
'body' => '저장되면 안 된다.',
]);
$result->assertRedirect();
$this->dontSeeInDatabase('posts', ['title' => '게스트 글']);
}
actingAs() 없이 그냥 POST하면 로그인 상태가 아닙니다. session 필터가 로그인 페이지로 리다이렉트해야 하고(assertRedirect()), DB에는 그 글이 없어야 합니다(dontSeeInDatabase()).
다음, 로그인한 사용자는 유효한 입력이면 저장돼야 합니다.
public function testLoggedInUserCanStorePost(): void
{
$result = $this->actingAs($this->makeUser())
->call('POST', 'posts', [
'title' => '새 글 제목',
'body' => '새 글 본문입니다.',
]);
// 저장 후 목록으로 리다이렉트한다.
$result->assertRedirect();
$this->seeInDatabase('posts', [
'title' => '새 글 제목',
'body' => '새 글 본문입니다.',
]);
}
actingAs($this->makeUser())로 그 사용자로 로그인한 척하고 POST합니다. 성공하면 목록으로 리다이렉트하고, DB에 글이 있어야 합니다(seeInDatabase()).
마지막, 검증에 실패하면 저장되지 않아야 합니다.
public function testValidationFailsWithEmptyTitle(): void
{
$result = $this->actingAs($this->makeUser())
->call('POST', 'posts', [
'title' => '',
'body' => '제목 없는 본문',
]);
// 검증 실패 시 저장하지 않고 폼으로 되돌린다.
$result->assertRedirect();
$this->dontSeeInDatabase('posts', ['body' => '제목 없는 본문']);
}
제목을 비운 채 보내면, 리다이렉트로 폼에 되돌아가되 DB에는 저장되면 안 됩니다.
이 시점에 테스트를 돌리면 전부 빨강입니다. 라우트도 컨트롤러 메서드도 없기 때문입니다. 이제 초록으로 만들어 갑니다.
2. 라우트 채우기
이전 회차에서 만들어 둔 session 필터 그룹의 빈 골격에 라우트를 채웁니다.
$routes->group('', ['filter' => 'session'], static function ($routes) {
$routes->get('posts/new', 'Posts::new'); // 글 작성 폼
$routes->post('posts', 'Posts::create'); // 글 저장
});
GET posts/new→ 작성 폼을 보여 주는Posts::newPOST posts→ 폼이 제출한 데이터를 저장하는Posts::create
두 라우트 모두 그룹 안에 있으므로 자동으로 session 필터가 걸립니다. 이것이 이전 회차에서 그룹 골격을 먼저 만들어 둔 이유입니다. 게스트 차단 테스트가 초록이 되는 근거가 바로 이 필터입니다.
3. 컨트롤러 — new()와 create()
app/Controllers/Posts.php에 두 메서드를 더합니다. 먼저 폼을 보여 주는 new()입니다.
public function new(): string
{
return view('posts/create');
}
폼 뷰(posts/create)는 다음 회차에서 만듭니다. 지금은 저장 로직에 집중합니다.
핵심은 create()입니다.
public function create(): RedirectResponse
{
$model = model(PostModel::class);
// allowedFields 에 든 값만 추려서 받는다.
$data = $this->request->getPost(['title', 'body']);
// 임시 slug. posts.slug 가 NOT NULL UNIQUE 라 지금은 값을 채워 둔다.
// 나중에 제목 기반 생성 + 중복 처리를 PostModel::beforeInsert 로 옮긴다.
$data['slug'] = 'post-' . bin2hex(random_bytes(6));
// 검증 실패 시: 입력값을 그대로 들고 폼으로 되돌아간다.
if (! $model->insert($data)) {
return redirect()->back()
->withInput()
->with('errors', $model->errors());
}
// 저장 성공 시: 목록으로 이동한다.
return redirect()->to('posts');
}
하나씩 봅시다.
$this->request->getPost(['title', 'body'])— POST 데이터 중 우리가 허용한 필드만 골라 받습니다. 폼에 딴 값이 섞여 들어와도 title·body만 추립니다.- 임시 slug —
posts테이블의slug는NOT NULL UNIQUE입니다(ep05에서 그렇게 설계). 지금은 저장 자체가 목적이라,random_bytes로 겹치지 않는 임시 슬러그를 채워 둡니다. 제목 기반 슬러그 생성과 중복 처리는 ep17에서PostModel::beforeInsert로 옮깁니다. 지금은 "임시로 통과"시켜 두는 것입니다. $model->insert($data)— 모델에 저장을 맡깁니다. 모델에 검증 규칙이 걸려 있으면 insert 전에 자동으로 검증하고, 실패하면false를 돌려줍니다.- 검증 실패 시
redirect()->back()->withInput()— 이번 회차의 가장 중요한 대목입니다.back()은 방금 온 폼으로 되돌리고,withInput()은 사용자가 입력한 값을 세션에 잠깐 저장해 폼에서 다시 꺼낼 수 있게 합니다. 사용자가 길게 쓴 본문이 검증 실패 한 번에 날아가면 안 되니까요.with('errors', ...)로 에러 메시지도 함께 넘깁니다. (다음 회차 폼에서old()로 이 값을 되살립니다.) - 성공 시
redirect()->to('posts')— 목록으로 보냅니다.
create()가 RedirectResponse를 반환하도록 타입을 명시한 것도 눈여겨보세요. 이 메서드는 뷰가 아니라 항상 리다이렉트로 끝납니다.
4. 모델 — 검증 규칙
저장 성공/실패를 가르는 규칙은 컨트롤러가 아니라 모델에 둡니다. app/Models/PostModel.php:
// 저장 전 검증 규칙. insert/update 시 자동으로 적용된다.
protected $validationRules = [
'title' => 'required|max_length[255]',
'body' => 'required',
];
// 검증 실패 메시지(필요한 것만 한국어로 덮어쓴다).
protected $validationMessages = [
'title' => [
'required' => '제목을 입력해 주세요.',
'max_length' => '제목은 255자를 넘을 수 없습니다.',
],
'body' => [
'required' => '본문을 입력해 주세요.',
],
];
$validationRules에 규칙을 두면 $model->insert()·update()가 자동으로 검증을 실행합니다. 컨트롤러가 검증을 직접 호출하지 않아도 되고, 규칙이 모델 한 곳에 모여 있어 나중에 수정에서도 같은 규칙이 재사용됩니다. $validationMessages로는 실패 메시지를 필요한 것만 한국어로 바꿉니다.
이 규칙이 있으니, 빈 제목으로 저장하려던 세 번째 테스트에서 insert()가 false를 돌려주고, 컨트롤러가 back()->withInput()으로 되돌립니다. 세 테스트가 모두 초록이 됩니다.
핵심 개념 — CSRF와 withInput()
CSRF. ep10에서 CSRF 보호를 session 기반으로 켰습니다. 그래서 실제 브라우저에서 POST하려면 폼에 CSRF 토큰이 있어야 합니다(다음 회차에서 csrf_field()로 넣습니다). 반면 이번 테스트가 토큰 없이도 통과하는 이유는, CI4의 테스트 환경이 CSRF 검사를 우회하도록 구성돼 있기 때문입니다. "테스트에서는 통과하지만 실제 폼에는 토큰이 필요하다"는 점을 기억해 두세요.
withInput(). 폼 검증에서 가장 자주 빠뜨리는 부분입니다. 검증 실패로 폼에 되돌아갔는데 입력값이 전부 비어 있으면 사용자 경험이 최악입니다. withInput()은 실패한 입력을 세션에 한 번만(flashdata) 저장해, 폼에서 old()로 되살릴 수 있게 해 줍니다. 검증 실패 → back()->withInput() → 폼에서 old(), 이 세 박자가 CI4 폼 처리의 정석입니다.
마무리 — 커밋과 태그
이번 회차에서 한 일:
- 실패 테스트(
PostStoreTest) 먼저 작성: 게스트 차단 / 저장 성공 / 검증 실패 session그룹에GET posts/new·POST posts라우트 추가- 컨트롤러
new()(폼 자리)·create()(검증 + 저장 +withInput()) 구현 - 모델에
$validationRules·$validationMessages추가 - 테스트 초록 확인
TDD 회차이므로 "빨강 → 구현 → 초록"을 같은 커밋에 담습니다.
git add .
git commit -m "feat: 글 저장 백엔드와 검증"
git tag ep12
다음 회차
다음 글에서는 이 백엔드에 붙일 작성 폼 화면을 만듭니다. old()로 입력값을 되살리고, 검증 에러를 목록으로 보여 주고, csrf_field()로 토큰을 넣습니다. 그리고 저장되는 글에 user_id = auth()->id()로 작성자를 묶어, 헤더에 "글쓰기" 링크를 노출합니다.
이번 회차 요약
- 다루는 파일:
tests/Feature/PostStoreTest.php(추가) /app/Config/Routes.php·app/Controllers/Posts.php·app/Models/PostModel.php(수정)- 핵심: 백엔드를 TDD로. "게스트 차단·저장 성공·검증 실패" 실패 테스트를 먼저 짜고 구현으로 통과시킨다. 검증 규칙은 모델에, 검증 실패 시
redirect()->back()->withInput()으로 입력값을 보존한다. CSRF는 세션 기반(폼에는 토큰 필요, 테스트는 우회).
다음: 글 작성 폼과 작성자 연결
댓글 0
아직 댓글이 없습니다.