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에서 만든 대로posts와id입니다.$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
아직 댓글이 없습니다.