· nambak80 Blog 로그인
CodeIgniter4

CodeIgniter 4로 블로그 만들기 #23 — 카테고리 필터와 메뉴

CodeIgniter 4로 블로그 만들기 #23 — 카테고리 필터와 메뉴

지난 회차에서 카테고리 데이터의 뼈대를 세웠습니다. categories 테이블이 생겼고, 모든 글이 하나의 카테고리에 연결됐습니다. 하지만 화면에는 아무 변화가 없었죠. 데이터는 있는데 보여 줄 곳이 없었으니까요.

이번 회차에서 그 데이터를 드디어 화면에 씁니다. 목표는 두 가지입니다. 하나는 categories/{slug} 주소로 특정 카테고리의 글만 거르는 것, 다른 하나는 목록 위에 카테고리 칩 메뉴를 그려 클릭으로 오갈 수 있게 하는 것입니다. 그리고 없는 카테고리 주소는 404로 막습니다. 이번엔 테스트를 앞세워 진행합니다.

이번 회차의 목표

  • categories/(:segment) 라우트를 등록해 슬러그를 컨트롤러로 넘긴다.
  • Posts::index가 카테고리 슬러그를 옵션 파라미터로 받아 필터한다.
  • 없는 카테고리는 404.
  • 분류 메뉴(전체 + 카테고리 칩, 활성 표시)를 그린다.
  • Feature 테스트로 필터/제외/404/메뉴 렌더를 검증한다.

1. 라우트 등록 — 같은 컨트롤러, 슬러그만 추가

핵심 아이디어는 "카테고리 목록도 결국 글 목록"이라는 것입니다. 전체 목록(posts)과 카테고리 목록(categories/web)은 화면이 똑같고, 다만 뒤엣것은 카테고리로 한 번 걸러졌을 뿐입니다. 그래서 새 컨트롤러 메서드를 만들지 않고, 기존 Posts::index재사용합니다.

app/Config/Routes.php:

$routes->get('posts', 'Posts::index');
// 카테고리별 글 목록. 목록 화면(index)을 슬러그로 거른다.
$routes->get('categories/(:segment)', 'Posts::index/$1');

(:segment)가 잡은 슬러그(web, retrospect 등)를 $1로 받아 index()의 첫 번째 인자로 넘깁니다. 즉 categories/webPosts::index('web')을 호출합니다.

2. 컨트롤러 — 슬러그를 옵션 파라미터로

index()가 슬러그를 받도록 고칩니다. 지금까지는 인자가 없었으니, 기본값 null을 준 옵션 파라미터로 만드는 게 요령입니다. 그러면 posts(인자 없음)와 categories/web(슬러그 있음)을 같은 메서드가 모두 처리합니다.

app/Controllers/Posts.php:

    /**
     * 글 목록. 카테고리 슬러그가 주어지면 그 카테고리 글만 거른다.
     *
     * `posts` → 전체, `categories/{slug}` → 해당 카테고리.
     */
    public function index(?string $categorySlug = null): string
    {
        $model = model(PostModel::class);

        // 없는 카테고리는 404. (필터가 빈 목록으로 조용히 떨어지지 않게)
        $activeCategory = null;
        if ($categorySlug !== null) {
            $activeCategory = model(CategoryModel::class)->where('slug', $categorySlug)->first();
            if ($activeCategory === null) {
                throw PageNotFoundException::forPageNotFound();
            }
            $model->where('category_id', $activeCategory->id);
        }

        $posts = $model
            ->orderBy('created_at', 'DESC')
            ->paginate(self::PER_PAGE);

        return view('posts/index', [
            'posts'          => $posts,
            'pager'          => $model->pager,
            'categories'     => model(CategoryModel::class)->menu(),
            'activeCategory' => $activeCategory,
        ]);
    }

파일 상단에는 use App\Models\CategoryModel;이 추가됩니다.

로직을 따라가 봅시다.

슬러그가 있으면 카테고리를 조회한다. CategoryModel에서 slug로 카테고리를 찾습니다. 이걸 $activeCategory에 담아 두는데, 뒤에서 필터 조건으로도 쓰고 뷰에서 "현재 어떤 카테고리인지"를 표시하는 데도 씁니다.

없는 카테고리는 404. 조회 결과가 null이면(예: categories/no-such-category) PageNotFoundException을 던집니다. 이게 왜 중요할까요? 만약 여기서 404를 던지지 않으면, 존재하지 않는 슬러그로 필터를 걸어도 그냥 빈 목록이 나옵니다. 그러면 사용자는 "이 카테고리에 글이 없구나"라고 오해하지, "이런 카테고리는 없다"는 걸 알 수 없습니다. 주석 그대로 "필터가 빈 목록으로 조용히 떨어지지 않게" 명시적으로 막는 것입니다.

필터 적용. 카테고리가 유효하면 $model->where('category_id', $activeCategory->id)로 조건을 겁니다. 지난 회차에 인덱스를 걸어 둔 그 컬럼입니다.

메뉴 데이터도 항상 넘긴다. 전체 목록이든 카테고리 목록이든, 뷰에는 항상 categories(메뉴용 전체 목록)와 activeCategory(현재 활성 카테고리, 없으면 null)를 함께 넘깁니다. 메뉴는 어느 목록 화면에서나 보여야 하니까요.

3. 분류 메뉴 파셜

메뉴 마크업은 재사용 가능한 파셜로 분리합니다.

app/Views/partials/category_menu.php:

<?php
/**
 * 글 목록의 분류 메뉴.
 *
 * @var \App\Entities\Category[]      $categories     전체 카테고리(이름순)
 * @var \App\Entities\Category|null   $activeCategory  현재 필터 중인 카테고리(없으면 전체)
 */
$activeSlug = isset($activeCategory) && $activeCategory !== null ? $activeCategory->slug : null;
?>
<nav class="category-menu" aria-label="카테고리">
    <a class="chip<?= $activeSlug === null ? ' is-active' : '' ?>" href="<?= site_url('posts') ?>">전체</a>
    <?php foreach ($categories as $category): ?>
        <a class="chip<?= $activeSlug === $category->slug ? ' is-active' : '' ?>"
           href="<?= esc($category->url, 'attr') ?>"><?= esc($category->name) ?></a>
    <?php endforeach ?>
</nav>

메뉴는 맨 앞에 "전체" 칩을 놓고, 그 뒤로 카테고리들을 나열합니다. 각 칩의 링크는 엔티티의 $category->url(지난 회차에서 만든 getUrl() 접근자)로 만듭니다.

활성 표시가 이 파셜의 핵심입니다. 현재 활성 슬러그($activeSlug)와 비교해서, 지금 보고 있는 카테고리에만 is-active 클래스를 붙입니다. 전체 목록이면 $activeSlugnull이라 "전체" 칩이 활성화됩니다. 그래서 사용자는 지금 어떤 분류를 보고 있는지 한눈에 압니다.

aria-label="카테고리"로 이 <nav>가 무엇인지 스크린리더에 알려 접근성도 챙깁니다.

4. 목록 뷰에 메뉴 끼우기

목록 뷰에 메뉴를 포함하고, 제목을 활성 카테고리 이름으로 바꿉니다.

app/Views/posts/index.php:

    <h1 class="page-title">
        <?= isset($activeCategory) && $activeCategory !== null ? esc($activeCategory->name) : '글 목록' ?>
    </h1>

    <?= $this->include('partials/category_menu') ?>

전체 목록이면 제목은 "글 목록"이고, 카테고리로 걸러진 상태면 "웹 개발"처럼 카테고리 이름이 제목이 됩니다. 사용자가 주소만 보지 않아도 현재 위치를 제목으로 알 수 있습니다.

$this->include('partials/category_menu')는 컨트롤러가 넘긴 categories, activeCategory 변수를 그대로 물려받아 메뉴를 그립니다.

5. 스타일

칩의 활성 상태와 메뉴 레이아웃 CSS를 더합니다.

public/assets/css/app.css:

.chip:hover { opacity: .85; }
.chip.is-active { background: var(--color-accent); color: #fff; }

/* category filter menu */
.category-menu { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 20px; }

활성 칩은 배경을 강조색으로 채워(is-active) 나머지 칩과 확실히 구분합니다. 메뉴는 flexbox로 가로 배치하되 flex-wrap으로 좁은 화면에서 자연스럽게 줄바꿈되게 했습니다.

6. 테스트 — 필터/제외/404/메뉴

이번 회차의 계약을 Feature 테스트로 못 박습니다. 시더가 카테고리 3개와 글 6건을 채우고 서로 연결하니(codeigniter4=3글, web=2글, retrospect=1글), 이 분포를 기준으로 검증합니다.

tests/Feature/CategoryFilterTest.php:

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

    // 공통 헤더가 auth() 를 호출하므로 전체 마이그레이션이 필요하다.
    protected $namespace = null;
    protected $refresh   = true;
    protected $seed      = 'App\Database\Seeds\PostSeeder';

    private const WEB_POST_A    = '공통 레이아웃으로 중복 걷어내기';        // web
    private const WEB_POST_B    = '첫 테스트가 주는 안전망';                // web
    private const CI4_POST      = '라우팅과 컨트롤러, 요청은 어디로 흐르는가'; // codeigniter4
    private const RETRO_POST    = '시더로 현실적인 더미 데이터 채우기';      // retrospect

    protected function setUp(): void
    {
        parent::setUp();

        // 페이저는 공유 서비스라 앞 테스트의 currentPage 가 남는다. 매번 새로.
        \Config\Services::resetSingle('pager');
    }

$seedPostSeeder만 지정했는데도 테스트가 도는 건, 지난 회차에서 PostSeeder가 "카테고리가 비어 있으면 스스로 보강"하도록 만들어 뒀기 때문입니다. setUp()에서 pager 서비스를 매번 리셋하는 건, 페이저가 공유 싱글턴이라 앞 테스트의 페이지 상태가 남아 다음 테스트를 오염시키는 걸 막기 위함입니다.

핵심 테스트 몇 개를 봅시다.

    public function testShowsOnlyPostsInThatCategory(): void
    {
        $res = $this->call('GET', 'categories/web');
        $res->assertSee(self::WEB_POST_A);
        $res->assertSee(self::WEB_POST_B);
    }

    public function testExcludesPostsFromOtherCategories(): void
    {
        $res = $this->call('GET', 'categories/web');
        $res->assertDontSee(self::CI4_POST);   // codeigniter4 글은 빠진다
        $res->assertDontSee(self::RETRO_POST); // retrospect 글도 빠진다
    }

categories/web을 열면 web 카테고리의 두 글은 보이고(assertSee), 다른 카테고리 글은 안 보여야 합니다(assertDontSee). "보인다"만 검증하면 필터가 실제로 거르는지 알 수 없습니다. "안 보인다"까지 함께 봐야 필터가 제대로 작동한다고 말할 수 있습니다.

    public function testUnknownCategoryReturns404(): void
    {
        // 404 는 Feature 테스트에서 응답으로 변환되지 않고 예외로 전파된다.
        $this->expectException(PageNotFoundException::class);
        $this->call('GET', 'categories/no-such-category');
    }

없는 카테고리는 PageNotFoundException이 던져지는지 검증합니다. Feature 테스트에서 이 예외는 HTTP 응답으로 변환되지 않고 예외 그대로 전파되므로, expectException으로 잡습니다.

    public function testListPageRendersCategoryMenu(): void
    {
        // 분류 메뉴가 카테고리 이름을 노출한다.
        $res = $this->call('GET', 'posts');
        $res->assertSee('CodeIgniter 4');
        $res->assertSee('웹 개발');
        $res->assertSee('회고');
    }

마지막으로, 전체 목록 화면에 세 카테고리 이름이 모두 메뉴로 렌더되는지 확인합니다.

./vendor/bin/phpunit tests/Feature/CategoryFilterTest.php

핵심 개념 — 왜 이렇게 했나

  • 하나의 컨트롤러로 두 경로. 전체 목록과 카테고리 목록은 본질적으로 같은 화면입니다. 옵션 파라미터(?string $categorySlug = null)로 한 메서드가 둘 다 처리하면 중복이 없습니다.
  • 없는 카테고리는 조용히 비우지 말고 404. 빈 목록은 "글이 없다"로 오해됩니다. 존재하지 않는 자원은 명시적으로 404를 던져 "이런 건 없다"를 분명히 합니다.
  • 활성 상태를 화면에 반영. is-active 칩과 제목 치환으로, 사용자가 지금 어느 분류를 보고 있는지 URL을 읽지 않아도 알게 합니다.

마무리 — 커밋과 태그

이번 회차에서 한 일:

  • categories/(:segment) 라우트로 슬러그 전달
  • Posts::index가 카테고리 필터(없으면 404)
  • 분류 메뉴 파셜 + 활성 표시
  • 목록 제목을 활성 카테고리 이름으로
  • 필터/제외/404/메뉴 렌더 테스트
git add .
git commit -m "feat: 카테고리 필터와 메뉴"
git tag ep23

다음 회차

다음 글에서는 기본 검색을 붙입니다. ?q= 검색어로 제목과 본문을 Query Builder의 like()로 뒤져, 매칭되는 글만 보여 줍니다. 결과가 없을 때의 안내 메시지까지, 역시 테스트를 앞세워 만듭니다.


이번 회차 요약

  • 다루는 파일: Config/Routes.php·Controllers/Posts.php·Views/posts/index.php·public/assets/css/app.css(수정) / Views/partials/category_menu.php·tests/Feature/CategoryFilterTest.php(추가)
  • 핵심: 옵션 파라미터로 전체·카테고리 목록을 한 메서드가 처리. 없는 카테고리는 PageNotFoundException으로 404. 활성 칩·제목으로 현재 분류를 표시.

다음: 기본 검색

댓글 0

아직 댓글이 없습니다.

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

← 목록으로