CodeIgniter 4로 블로그 만들기 #09 — 글 상세 페이지와 목록 연결
지난 회차까지 글 목록은 페이지네이션까지 갖춰 제법 그럴듯해졌습니다. 하지만 목록의 제목을 눌러도 아무 일도 일어나지 않습니다. 블로그라면 당연히 제목을 클릭하면 그 글의 전문이 나와야죠.
이번 회차의 목표는 글 상세 페이지를 만들고, 목록과 상세를 링크로 잇는 것입니다. 그 과정에서 CI4의 두 가지 기본기, 즉 URL 세그먼트로 값 받기와 없는 리소스에 404 응답하기를 익힙니다. 이걸로 섹션 2("글 읽기의 기초")가 마무리됩니다.
이번 회차 목표
posts/(:segment)라우트로 slug를 받는다.Posts::show($slug)가 slug로 글 한 건을 찾아 보여 준다.- 없는 slug에는
PageNotFoundException으로 404를 응답한다. - 상세 뷰(
posts/show.php)로 제목·날짜·본문을 그린다. - 목록 제목에 상세 링크를 걸어 목록↔상세를 잇는다.
- Feature 테스트로 존재 글 200·본문 표시와 없는 글 404를 검증한다.
1. 라우트 — slug를 세그먼트로
app/Config/Routes.php에 상세 라우트를 추가합니다.
$routes->get('posts', 'Posts::index');
$routes->get('posts/(:segment)', 'Posts::show/$1');
(:segment)는 CI4의 라우트 플레이스홀더로, URL의 한 조각(슬래시로 구분된 구간)을 매칭합니다. posts/codeigniter4-blog-start로 들어오면 codeigniter4-blog-start 부분이 잡혀서, $1을 통해 컨트롤러 메서드의 첫 인자로 전달됩니다. 즉 Posts::show('codeigniter4-blog-start')가 호출됩니다.
우리는 글을 id가 아니라 slug로 식별합니다. posts/17보다 posts/codeigniter4-blog-start가 사람에게도 검색엔진에게도 친절하기 때문입니다. ep05에서 slug에 유니크 인덱스를 걸어 둔 덕분에, slug 하나가 글 하나를 정확히 가리킵니다.
2. 컨트롤러 — show(), 그리고 없는 글은 404
app/Controllers/Posts.php에 show() 메서드를 추가합니다.
use App\Models\PostModel;
use CodeIgniter\Exceptions\PageNotFoundException;
// ...
public function show(string $slug): string
{
$post = model(PostModel::class)
->where('slug', $slug)
->first();
// 없는 글은 404 로 응답한다.
if ($post === null) {
throw PageNotFoundException::forPageNotFound();
}
return view('posts/show', [
'post' => $post,
]);
}
핵심은 두 가지입니다.
where('slug', $slug)->first()— slug로 조건을 걸고first()로 한 건만 가져옵니다. 결과는 ep07에서 지정한$returnType덕분에Post엔티티입니다. 글이 있으면 엔티티가, 없으면null이 옵니다.- 없으면
PageNotFoundException— 존재하지 않는 slug(예: 오타난 주소, 삭제된 글)로 들어오면null이 오는데, 이때 빈 화면을 그리거나 에러를 내는 대신 명시적으로 404를 던집니다.PageNotFoundException::forPageNotFound()를throw하면 CI4가 이를 404 응답으로 처리해, 개발 환경에선 상세 에러 페이지를, 운영에선 사용자용 404 페이지를 보여 줍니다.
"없는 리소스에는 404"는 웹의 기본 예의입니다. 잘못된 주소에 200을 주면 검색엔진이 빈 페이지를 색인하는 등 여러 문제가 생깁니다.
3. 상세 뷰 — 제목·날짜·본문
app/Views/posts/show.php를 만듭니다.
<?= $this->extend('layouts/default') ?>
<?= $this->section('title') ?><?= esc($post->title) ?><?= $this->endSection() ?>
<?= $this->section('content') ?>
<article class="post">
<h1><?= esc($post->title) ?></h1>
<?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 ?>
<?php // 본문은 지금은 평문이다. 마크다운 렌더링은 ep26에서 다룬다. ?>
<div class="post-body">
<?= nl2br(esc($post->body)) ?>
</div>
</article>
<p><a href="<?= site_url('posts') ?>">← 목록으로</a></p>
<?= $this->endSection() ?>
몇 가지 짚어 봅니다.
title섹션에 글 제목 —<title>태그에 들어갈 값을 글 제목으로 채워, 브라우저 탭과 검색 결과에 글 제목이 뜨게 합니다.nl2br(esc($post->body))의 순서 — 이 순서가 중요합니다. 먼저esc()로 이스케이프해 본문 안의 HTML/스크립트를 무력화한 뒤, 그다음nl2br()로 줄바꿈만<br>로 바꿉니다. 순서를 뒤집어nl2br을 먼저 하면esc가 우리가 넣은<br>까지 이스케이프해 버려 줄바꿈이 살지 않습니다. ep06 시더에서 본문에\n\n으로 문단을 나눠 둔 게 여기서 살아납니다.- 본문은 아직 평문 — 지금은 마크다운을 렌더링하지 않습니다. 주석에 적힌 대로 마크다운 본문 렌더링은 ep26에서 다룹니다. 지금 단계에서는 평문 + 줄바꿈이면 충분합니다.
.post-body/.post클래스 — 읽기 좋은 본문 타이포그래피는 ep03의app.css가 자동으로 입혀 줍니다.- "← 목록으로" 링크 — 상세에서 목록으로 돌아가는 길을 열어 둡니다.
4. 목록에서 상세로 링크 걸기
이제 목록의 제목을 상세로 이어 줍니다. app/Views/posts/index.php에서 제목을 링크로 감쌉니다.
<h2><a href="<?= site_url('posts/' . $post->slug) ?>"><?= esc($post->title) ?></a></h2>
site_url('posts/' . $post->slug)로 각 글의 상세 주소를 만듭니다. 이제 목록의 제목을 클릭하면 그 글의 상세로 이동하고, 상세의 "← 목록으로"로 다시 돌아올 수 있습니다. 목록↔상세의 왕복이 완성됐습니다.
이번 회차에서 목록 제목에 .page-title 클래스도 함께 붙습니다.
<h1 class="page-title">글 목록</h1>
디자인 토큰이 적용된 페이지 제목 스타일을 목록 화면에도 반영하는 마무리 손질입니다.
5. Feature 테스트 — 200·본문·404
상세 페이지의 세 가지 계약을 테스트로 못 박습니다. tests/Feature/PostShowTest.php를 만듭니다.
<?php
namespace Tests\Feature;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use CodeIgniter\Test\FeatureTestTrait;
final class PostShowTest extends CIUnitTestCase
{
use FeatureTestTrait;
use DatabaseTestTrait;
protected $namespace = 'App';
protected $refresh = true;
protected $seed = 'App\Database\Seeds\PostSeeder';
// 시더가 넣는 첫 글
private const EXISTING_SLUG = 'codeigniter4-blog-start';
private const EXISTING_TITLE = 'CodeIgniter 4로 블로그 만들기를 시작하며';
public function testShowExistingPostReturns200(): void
{
$this->call('GET', 'posts/' . self::EXISTING_SLUG)->assertStatus(200);
}
public function testShowDisplaysTitleAndBody(): void
{
$result = $this->call('GET', 'posts/' . self::EXISTING_SLUG);
$result->assertSee(self::EXISTING_TITLE);
// 본문 일부도 보여야 한다
$result->assertSee('한 회차씩 쌓아 올리며');
}
public function testShowMissingPostThrows404(): void
{
// 없는 글은 PageNotFoundException(=404)을 던진다.
$this->expectException(PageNotFoundException::class);
$this->call('GET', 'posts/no-such-slug');
}
}
각 테스트의 의도입니다.
testShowExistingPostReturns200— 시더가 넣은 실제 slug로 들어가면 200이 온다.$refresh/$seed조합으로 매번 깨끗한 DB에 시더를 돌려, 이 slug가 항상 존재함을 보장합니다.testShowDisplaysTitleAndBody— 제목뿐 아니라 **본문 일부(한 회차씩 쌓아 올리며)**까지 화면에 실제로 보이는지 확인합니다. 컨트롤러가 글을 제대로 찾아 뷰에 넘겼다는 증거입니다.testShowMissingPostThrows404— 존재하지 않는 slug(no-such-slug)로 들어오면PageNotFoundException이 던져지는지expectException으로 검증합니다. "없는 글은 404"라는 계약을 코드로 못 박아 둡니다.
./vendor/bin/phpunit tests/Feature/PostShowTest.php
마무리 — 커밋과 태그
이걸로 "글 읽기"의 기초가 완성됐습니다. 목록에서 글을 훑고, 제목을 눌러 전문을 읽고, 다시 목록으로 돌아오는 한 바퀴가 모두 동작합니다.
git add .
git commit -m "feat: 글 상세 페이지와 목록 연결"
git tag ep09
다음 회차
지금까지 만든 건 전부 "읽기"였습니다. 아무나 글을 쓸 수는 없죠. 다음 글부터 섹션 3으로 넘어가 CodeIgniter 공식 인증 라이브러리 Shield를 도입합니다. php spark shield:setup으로 인증 설정과 users 등 테이블을 만들고, 로그인의 토대를 놓습니다. (그리고 users 테이블이 생기면서, 미뤄 둔 작성자 조인 N+1 회피의 재료도 갖춰집니다.)
이번 회차 요약
- 다루는 파일:
app/Views/posts/show.php,tests/Feature/PostShowTest.php(생성),app/Controllers/Posts.php·app/Config/Routes.php·app/Views/posts/index.php(수정)- 핵심:
(:segment)로 slug를 받아where('slug', ...)->first()로 한 건 조회, 없으면PageNotFoundException으로 404. 본문은nl2br(esc(...))순서로(이스케이프 먼저) 평문 표시 — 마크다운은 ep26. 목록 제목에 상세 링크로 목록↔상세 연결.
다음: Shield 인증 도입
댓글 0
아직 댓글이 없습니다.