PostModel/Entity와 글 목록

PostModel/Entity와 글 목록

지난 회차까지 정적 페이지와 공통 레이아웃을 만들었다면, 이번 회차부터는 드디어 데이터를 다룬다. posts 테이블에 쌓인 글(ep05에서 만든 테이블 · ep06 시더로 넣은 더미 글)을 읽어 와 목록 화면에 그리는 것이 목표다.

핵심은 CodeIgniter 4가 권하는 Model + Entity 조합이다. 조회·검증·타임스탬프 같은 "테이블 단위의 일"은 Model이 맡고, "글 한 건을 어떻게 표현할까" 하는 도메인 로직은 Entity가 맡는다. 컨트롤러와 뷰는 가능한 한 얇게 둔다.

따라 하기 전 준비 이 회차는 ep05의 마이그레이션과 ep06의 시더가 끝나 있다고 가정한다. 먼저 아래를 실행해 posts 테이블과 더미 글을 준비하자.

php spark migrate
php spark db:seed DatabaseSeeder

1. PostModel — 테이블과의 창구

먼저 posts 테이블을 다루는 Model을 만든다.

// app/Models/PostModel.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',
    ];
}

세 가지 설정이 이 회차의 의도를 그대로 담고 있다.

$returnType = Post::class 가 가장 중요하다. 이걸 지정하면 findAll()이 평범한 배열이나 stdClass가 아니라 우리가 만든 Post 엔티티의 묶음을 돌려준다. 덕분에 화면 가공 로직을 엔티티 한 곳에 모을 수 있다. (이 줄을 깜빡하면 조회 결과가 배열로 와서 $post->title이 동작하지 않는다 — 입문자가 자주 겪는 실수다.)

$useTimestamps = true 는 글을 저장할 때 created_at / updated_at 을 모델이 알아서 채워 준다는 뜻이다. 정렬 기준이 되는 값이라 처음부터 켜 둔다. 단, 이게 동작하려면 마이그레이션에 두 컬럼이 있어야 한다(ep05에서 만들어 뒀다).

$allowedFields 는 대량 할당(mass assignment)을 허용할 화이트리스트다. id 와 타임스탬프를 빼 두면, 사용자 입력이 그대로 들어오는 작성/수정 회차에서 의도치 않은 필드가 덮어써지는 사고를 막을 수 있다. 여기 적힌 user_id(작성자)와 slug(URL용)는 이번 회차에선 쓰지 않는다 — user_id는 ep13(작성자 연결), slug는 ep17(slug 기반 URL)에서 채운다. 필드만 미리 열어 두는 셈이다.

2. Post 엔티티 — 글 한 건의 표현

조회 결과 한 줄 한 줄이 이 엔티티로 감싸진다. 화면에 보여 줄 가공은 컨트롤러나 뷰가 아니라 여기 접근자(getter) 에 모은다. 의도를 코드에 남기기 위해 클래스 주석도 함께 둔다.

// app/Entities/Post.php
namespace App\Entities;

use CodeIgniter\Entity\Entity;

/**
 * 글 한 건을 나타내는 도메인 객체.
 *
 * Model 의 $returnType 으로 지정되어, 조회 결과가 이 엔티티로 감싸진다.
 * 화면 표시용 가공은 컨트롤러/뷰가 아니라 여기 접근자(getXxx)에 모아 둔다.
 */
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 에 대해 한 가지 짚고 넘어가자. CodeIgniter 4의 Entity기본적으로 created_at / updated_at / deleted_atTime 객체로 다룬다. 즉 이 줄이 없어도 두 컬럼은 변환된다. 여기서 굳이 명시한 건 "이 엔티티가 날짜를 Time으로 본다"는 의도를 코드에 드러내기 위함이다. 덕분에 뷰에서 $post->created_at->format('Y-m-d') 처럼 날짜 포매팅을 자연스럽게 쓸 수 있다. (최신 버전에선 $casts로도 날짜 캐스팅이 가능하지만, 이 강좌에선 $dates로 충분하다.)

getExcerpt() 는 엔티티 접근자의 좋은 예다. 뷰에서 $post->excerpt 라고만 쓰면 이 메서드가 호출된다(get + Excerpt). 미리보기 가공 규칙이 바뀌어도 고칠 곳은 이 메서드 하나뿐이다.

코드에서 두 가지를 눈여겨보자. 첫째, $this->body 가 아니라 $this->attributes['body'] 를 읽는다. 만약 $this->body로 접근하면 CI4가 다시 접근자(getBody)를 찾으려 하므로, 원시 값이 필요할 땐 attributes 배열을 직접 읽는 게 안전하다. 둘째, 한글이 글자 단위로 잘리도록 mb_strlen / mb_substr 를 쓰고, 본문의 연속 공백·줄바꿈은 정규식 /\s+/u로 한 칸으로 정리한다. (정상 조회에서는 body가 항상 채워져 있다 — 부분 SELECT처럼 값이 없을 수 있는 상황을 대비하려면 $this->attributes['body'] ?? '' 로 방어할 수 있다.)

참고로 getExcerpt(int $limit = 80) 에는 길이 인자가 있지만, 프로퍼티처럼 $post->excerpt 로 부르면 기본값 80이 쓰인다. 길이를 바꾸고 싶으면 $post->getExcerpt(120) 처럼 메서드로 직접 호출하면 된다.

3. Posts 컨트롤러 — 얇게 유지하기

컨트롤러는 "모델에게 시키고, 뷰에 넘긴다"가 전부다.

// app/Controllers/Posts.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) 헬퍼로 모델 인스턴스를 얻는다. new PostModel() 로 직접 만들 수도 있지만, model() 헬퍼는 같은 인스턴스를 재사용(공유 인스턴스)하고 테스트에서 모킹하기도 쉬워 CI4에서 권장하는 방식이다. 이렇게 얻은 모델을 created_at 내림차순으로 정렬해 최신 글이 위로 오게 한다. 비즈니스 로직을 모델·엔티티에 미뤄 둔 덕분에 index() 가 이렇게 짧게 끝난다.

4. 목록 뷰 — 레이아웃 위에 얹기

ep03에서 만든 공통 레이아웃을 extend 하고, content 섹션에 목록을 그린다.

<!-- app/Views/posts/index.php -->
<?= $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() ?>

세 가지를 눈여겨보자. 첫째, 모든 출력은 esc() 로 감싼다 — 사용자 입력에서 비롯된 글 제목·본문을 그대로 찍으면 XSS의 통로가 되므로 출력 시 항상 이스케이프한다. 둘째, 글이 없을 때를 위한 빈 상태("아직 작성된 글이 없습니다") 분기를 처음부터 넣어 둔다. 셋째, 마크업은 시맨틱 클래스(.post-list)만 쓰고 디자인은 ep03의 app.css 가 입혀 준다 — .post-list 는 거기서 2열 카드 그리드로 그려진다. 목록 제목(<h1>)의 스타일은 ep09에서 .page-title 로 다듬을 예정이니, 지금은 기본 마크업으로 둔다.

라우트와 헤더 내비게이션도 한 줄씩 추가한다.

// app/Config/Routes.php
$routes->get('posts', 'Posts::index');
<!-- app/Views/partials/header.php -->
<a class="nav-link" href="<?= site_url('posts') ?>">글</a>

여기까지 하고 php spark serve/posts 에 접속하면, 시더가 넣은 글들이 최신순으로 카드 목록에 보인다.

(여기에 글 목록 화면 스크린샷)

5. 테스트 — 목록이 실제로 보이는가

이 프로젝트는 "실패 → 구현 → 통과"를 같은 커밋에 담는 TDD를 지향한다. 먼저 테스트를 빨갛게 만든다. 아직 Posts 컨트롤러와 라우트가 없으니 /posts 는 404로 떨어지고 테스트는 실패한다. 위 1~4절을 구현하면 같은 테스트가 초록으로 바뀐다.

// tests/Feature/PostIndexTest.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 네임스페이스의 마이그레이션을 매 테스트마다 새로 적용하고,
    // PostSeeder 로 더미 글을 채운다.
    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 로 지정한 PostSeeder(ep06에서 만든 시더)가 더미 글을 채운다. 그 위에서 assertStatus(200) 으로 응답 코드를, assertSee() 로 실제 글 제목이 화면에 그려졌는지 확인한다. 테스트는 다음으로 실행한다.

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

직접 해보기

  • getExcerpt 의 기본 길이를 60자로 바꿔 목록 미리보기가 어떻게 달라지는지 확인해 보자.
  • 컨트롤러의 orderBy(..., 'DESC')'ASC' 로 바꿔 정렬이 뒤집히는 걸 관찰해 보자.

흔한 실수: $returnType 을 빠뜨리면 조회 결과가 배열로 와서 $post->title 에서 에러가 난다. 또 $useTimestamps = true 인데 마이그레이션에 created_at 컬럼이 없으면 저장이 실패한다.

다음 회차 예고

지금은 모든 글을 findAll() 로 한 번에 불러온다. 글이 수백 개가 되면? ep08(페이지네이션과 쿼리 최적화) 에서 페이지 단위로 끊어 읽고 N+1을 피하는 법을 다룬다. 본문을 마크다운으로 변환해 보여 주는 것은 ep26 예정이다(지금은 원문 그대로 저장·표시).


이번 회차 요약

추가/수정 파일

  • A: app/Models/PostModel.php$allowedFields, $returnType = Post::class, $useTimestamps
  • A: app/Entities/Post.phpexcerpt 접근자
  • A: app/Controllers/Posts.phpindex()
  • A: app/Views/posts/index.php
  • A: tests/Feature/PostIndexTest.php
  • M: app/Config/Routes.php$routes->get('posts', 'Posts::index')
  • M: app/Views/partials/header.php — "글" 링크 추가

학습 포인트: Model + Entity 조합. 조회·검증·타임스탬프는 Model이, 표시용 가공은 Entity 접근자가 맡는다. 컨트롤러는 얇게, 출력은 esc() 로 이스케이프, 빈 상태 분기는 처음부터. 테스트는 빨강 → 초록으로 같은 커밋에.

원본 변경 내역: PR #7 · 태그 ep07

댓글 0

아직 댓글이 없습니다.

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

← 목록으로