· nambak80 Blog 로그인
CodeIgniter4

CodeIgniter 4로 블로그 만들기 #12 — 글 저장 백엔드와 검증

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::new
  • POST 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만 추립니다.
  • 임시 slugposts 테이블의 slugNOT 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

아직 댓글이 없습니다.

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

← 목록으로