· nambak80 Blog 로그인
CodeIgniter4

CodeIgniter 4로 블로그 만들기 #07 — PostModel/Entity와 글 목록

CodeIgniter 4로 블로그 만들기 #07 — PostModel/Entity와 글 목록

앞선 두 회차에서 posts 테이블과 더미 글을 준비했습니다. 데이터는 DB에 들어 있지만, 아직 화면에는 아무것도 안 나옵니다. 이번 회차에서 그 데이터를 읽어서 목록으로 그리는 전체 흐름을 한 번에 완성합니다.

이 회차는 이 시리즈에서 처음으로 CI4의 데이터 계층 4형제가 모두 등장하는 곳입니다. Model → Entity → Controller → View, 그리고 이걸 지켜 줄 Feature 테스트까지요. 조금 길지만, 이 패턴을 한 번 제대로 익혀 두면 뒤 회차는 이 구조의 변주일 뿐입니다.

이번 회차 목표

  • PostModel을 만들어 DB 접근을 캡슐화한다.
  • Post 엔티티로 글 한 건을 도메인 객체로 감싼다(미리보기 접근자 포함).
  • Posts::index가 글을 최신순으로 읽어 뷰에 넘긴다.
  • posts/index 뷰로 목록을 그린다.
  • /posts 라우트를 등록하고, 헤더에 "글" 링크를 추가한다.
  • Feature 테스트로 목록이 실제로 그려지는지 검증한다.

1. PostModel — DB 접근의 문지기

CI4의 Model은 특정 테이블에 대한 조회·저장을 담당합니다. app/Models/PostModel.php를 만듭니다.

<?php

namespace App\Models;

use App\Entities\Post;
use CodeIgniter\Model;

class PostModel extends Model
{
    protected $table      = 'posts';
    protected $primaryKey = 'id';

    // 조회 결과를 배열이 아니라 Post 엔티티로 돌려받는다.
    protected $returnType = Post::class;

    // created_at / updated_at 을 모델이 자동으로 채운다.
    protected $useTimestamps = true;

    // 대량 할당을 허용할 필드. id 와 타임스탬프는 제외한다.
    protected $allowedFields = [
        'user_id',
        'title',
        'slug',
        'body',
    ];
}

각 속성의 의미를 짚어 봅시다.

  • $table / $primaryKey — 이 모델이 다루는 테이블과 기본키. ep05에서 만든 대로 postsid입니다.
  • $returnType = Post::class — 이게 이번 회차의 핵심입니다. 기본값이면 조회 결과가 배열(또는 stdClass)로 오는데, 이걸 Post 엔티티로 지정하면 글 한 건이 곧 Post 객체로 감싸져 돌아옵니다. 덕분에 뷰에서 $post->excerpt 같은 접근자를 바로 쓸 수 있습니다.
  • $useTimestamps = true — insert/update 시 created_at, updated_at을 모델이 자동으로 채웁니다. ep05에서 이 두 컬럼을 만들어 둔 이유가 여기서 살아납니다.
  • $allowedFields — 대량 할당(mass assignment)을 허용할 컬럼 목록입니다. id나 타임스탬프처럼 사용자가 직접 채우면 안 되는 값은 여기서 제외해, 폼에서 넘어온 값이 함부로 들어가지 못하게 막습니다. 일종의 화이트리스트 보안 장치입니다.

2. Post 엔티티 — 표시용 가공을 한 곳에

엔티티는 "글 한 건"을 나타내는 도메인 객체입니다. 화면에 보여 줄 값을 가공하는 로직을 컨트롤러나 뷰에 흩뿌리지 않고, 이 엔티티의 **접근자(getter)**에 모아 둡니다. app/Entities/Post.php를 만듭니다.

<?php

namespace App\Entities;

use CodeIgniter\Entity\Entity;

class Post extends Entity
{
    // created_at / updated_at 을 Time 객체로 다룬다.
    protected $dates = ['created_at', 'updated_at'];

    /**
     * 목록에서 보여 줄 짧은 미리보기.
     *
     * 본문의 줄바꿈을 공백으로 합치고 앞부분만 잘라 준다.
     * 뷰에서 $post->excerpt 로 접근한다.
     */
    public function getExcerpt(int $limit = 80): string
    {
        $body    = preg_replace('/\s+/u', ' ', trim((string) $this->attributes['body']));
        $excerpt = (string) $body;

        if (mb_strlen($excerpt) > $limit) {
            $excerpt = mb_substr($excerpt, 0, $limit) . '…';
        }

        return $excerpt;
    }
}

두 가지가 있습니다.

  • $dates — 여기 나열한 컬럼은 문자열이 아니라 CI4의 Time 객체로 변환됩니다. 그래서 뷰에서 $post->created_at->format('Y-m-d')처럼 날짜를 자유롭게 포맷할 수 있습니다.
  • getExcerpt() — 엔티티 접근자의 마법을 보여 주는 메서드입니다. 메서드 이름을 getExcerpt로 지으면, 뷰에서 $post->excerpt(camelCase 프로퍼티처럼)로 호출됩니다. 내부에서는 본문의 연속 공백·줄바꿈을 한 칸으로 합치고(preg_replace('/\s+/u', ' ', ...)), 길이가 80자를 넘으면 잘라서 을 붙입니다. mb_strlen/mb_substr을 쓴 이유는 한글 같은 멀티바이트 문자를 글자 단위로 정확히 세고 자르기 위해서입니다.

이렇게 미리보기 로직을 엔티티에 두면, 목록을 그리는 뷰가 깔끔해지고 같은 규칙을 여러 화면에서 재사용할 수 있습니다.

3. Posts 컨트롤러 — 읽어서 넘기기

이제 요청을 받아 모델에서 글을 읽고 뷰에 넘기는 컨트롤러입니다. app/Controllers/Posts.php를 만듭니다.

<?php

namespace App\Controllers;

use App\Models\PostModel;

class Posts extends BaseController
{
    public function index(): string
    {
        $posts = model(PostModel::class)
            ->orderBy('created_at', 'DESC')
            ->findAll();

        return view('posts/index', [
            'posts' => $posts,
        ]);
    }
}

model(PostModel::class)는 CI4의 헬퍼로, 모델 인스턴스를 가져옵니다. 여기에 쿼리 빌더 메서드를 이어 붙입니다. orderBy('created_at', 'DESC')로 최신 글부터 정렬하고, findAll()로 전부 읽습니다. ep06에서 created_at을 하루씩 벌려 둔 덕분에, 이 정렬이 곧 "최신 글이 위"로 이어집니다.

읽어 온 $posts(각 원소가 Post 엔티티)를 view()의 두 번째 인자로 넘기면, 뷰 안에서 $posts 변수로 쓸 수 있습니다.

4. 목록 뷰 — 카드로 그리기

app/Views/posts/index.php를 만듭니다. ep03에서 만든 공통 레이아웃을 extend해서 본문만 채웁니다.

<?= $this->extend('layouts/default') ?>

<?= $this->section('title') ?>글 목록<?= $this->endSection() ?>

<?= $this->section('content') ?>
    <h1>글 목록</h1>

    <?php if (empty($posts)): ?>
        <p>아직 작성된 글이 없습니다.</p>
    <?php else: ?>
        <ul class="post-list">
            <?php foreach ($posts as $post): ?>
                <li>
                    <h2><?= esc($post->title) ?></h2>
                    <p><?= esc($post->excerpt) ?></p>
                    <?php if ($post->created_at !== null): ?>
                        <time datetime="<?= esc($post->created_at->format('Y-m-d')) ?>">
                            <?= esc($post->created_at->format('Y-m-d')) ?>
                        </time>
                    <?php endif ?>
                </li>
            <?php endforeach ?>
        </ul>
    <?php endif ?>
<?= $this->endSection() ?>

몇 가지 습관을 짚어 둡니다.

  • 빈 목록 분기empty($posts)일 때 안내 문구를 보여 줍니다. 데이터가 없을 때의 화면을 잊지 않는 게 좋은 습관입니다.
  • esc()로 이스케이프 — 출력하는 모든 사용자 데이터는 esc()로 감쌉니다. XSS를 막는 CI4의 기본기입니다. 제목·미리보기·날짜 모두 예외 없이 이스케이프합니다.
  • $post->excerpt — 앞서 엔티티에 만든 접근자를 여기서 씁니다. 뷰에는 자르기 로직이 전혀 없고, 그저 프로퍼티를 읽듯 쓰기만 하면 됩니다.
  • .post-list 클래스 — 마크업은 시맨틱한 클래스만 달아 두고, 실제 카드 그리드 디자인은 ep03에서 넣어 둔 app.css가 자동으로 입혀 줍니다. 뷰는 구조만 책임지고 스타일은 CSS가 맡는 분업입니다.

5. 라우트와 헤더 링크

컨트롤러를 만들었으니 주소를 열어 줘야 합니다. app/Config/Routes.php에 한 줄 추가합니다.

$routes->get('posts', 'Posts::index');

그리고 공통 헤더(app/Views/partials/header.php)에 목록으로 가는 "글" 링크를 추가합니다.

<a class="nav-link" href="<?= site_url('posts') ?>">글</a>

이제 브라우저에서 http://localhost:8080/posts로 들어가거나 헤더의 "글"을 클릭하면 목록이 보입니다.

6. Feature 테스트 — 목록이 실제로 그려지는가

마지막으로, 이 화면이 앞으로도 깨지지 않게 지켜 줄 테스트를 붙입니다. tests/Feature/PostIndexTest.php를 만듭니다.

<?php

namespace Tests\Feature;

use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use CodeIgniter\Test\FeatureTestTrait;

final class PostIndexTest extends CIUnitTestCase
{
    use FeatureTestTrait;
    use DatabaseTestTrait;

    // App 네임스페이스의 마이그레이션을 매 테스트마다 새로 적용한다.
    protected $namespace = 'App';
    protected $refresh   = true;
    protected $seed      = 'App\Database\Seeds\PostSeeder';

    public function testIndexReturns200(): void
    {
        $result = $this->call('GET', 'posts');

        $result->assertStatus(200);
    }

    public function testIndexListsSeededPostTitles(): void
    {
        $result = $this->call('GET', 'posts');

        // 시더가 넣은 글 제목이 목록에 보여야 한다
        $result->assertSee('CodeIgniter 4로 블로그 만들기를 시작하며');
        $result->assertSee('시더로 현실적인 더미 데이터 채우기');
    }
}

이 테스트가 어떻게 동작하는지가 중요합니다.

  • DatabaseTestTrait + $refresh = true — 매 테스트마다 마이그레이션을 새로 적용해 깨끗한 DB에서 시작합니다. 이전 테스트의 흔적이 남지 않습니다.
  • $seed = 'App\Database\Seeds\PostSeeder' — 그 깨끗한 DB에 ep06에서 만든 시더를 돌려 더미 글 6건을 채웁니다. 즉, ep05·ep06·ep07이 여기서 한 줄로 엮여 검증됩니다.
  • FeatureTestTrait::call()$this->call('GET', 'posts')로 실제 HTTP 요청처럼 라우트를 호출합니다. 라우팅 → 컨트롤러 → 모델 → 뷰까지 전 과정을 통과합니다.
  • assertStatus(200) / assertSee(...) — 200 응답인지, 그리고 시더가 넣은 특정 제목이 화면에 실제로 보이는지 확인합니다.

TDD 리듬대로라면 이 테스트를 먼저 작성해 빨강(실패)을 본 뒤, 앞의 모델·컨트롤러·뷰를 구현해 초록으로 만드는 흐름입니다. 실행은 이렇게 합니다.

composer test
# 또는 단일 파일만
./vendor/bin/phpunit tests/Feature/PostIndexTest.php

마무리 — 커밋과 태그

이번 한 회차에서 모델·엔티티·컨트롤러·뷰·테스트·라우트·헤더를 모두 손댔습니다. CI4의 데이터 흐름 한 바퀴를 완성한 셈입니다.

git add .
git commit -m "feat: PostModel/Entity와 글 목록"
git tag ep07

다음 회차

지금은 글을 전부 findAll()로 한꺼번에 읽습니다. 글이 6건일 땐 괜찮지만, 수백·수천 건이 되면 문제입니다. 다음 글에서는 페이지네이션과 쿼리 최적화를 다룹니다. paginate()로 한 페이지씩 끊어 읽고, 뷰에 페이지 이동 UI를 붙입니다.


이번 회차 요약

  • 다루는 파일: app/Models/PostModel.php, app/Entities/Post.php, app/Controllers/Posts.php, app/Views/posts/index.php, tests/Feature/PostIndexTest.php(모두 생성), app/Config/Routes.php·app/Views/partials/header.php(수정)
  • 핵심: $returnType = Post::class로 Model과 Entity를 묶는다. 표시용 가공(getExcerpt)은 엔티티 접근자에 모으고, 뷰는 $post->excerpt로 쓰기만 한다. DatabaseTestTrait + $refresh/$seed로 깨끗한 DB에서 Feature 테스트.

다음: 페이지네이션과 쿼리 최적화

댓글 0

아직 댓글이 없습니다.

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

← 목록으로