CodeIgniter 4로 블로그 만들기 #25 — 검색과 필터 상태 유지
지난 두 회차로 카테고리 필터와 검색이 각각 동작하게 됐습니다. 그런데 둘을 함께 쓰려고 하면 어딘가 어색합니다.
categories/web 페이지에서 무언가를 검색하면 카테고리가 풀려 전체 검색이 돼 버립니다. 반대로 ?q=레이아웃으로 검색해 둔 상태에서 카테고리 칩을 누르면 이번엔 검색어가 사라집니다. 두 상태가 서로를 리셋하는 것이죠. 사용자 입장에서는 "카테고리 안에서 검색"이나 "검색 결과 안에서 분류 좁히기"가 안 되는 셈입니다.
이번 회차는 새 기능이 아니라 이 상태 유지 버그를 잡는 fix: 입니다. 흥미로운 점은, 고칠 곳이 뷰(폼과 메뉴 링크)뿐이라는 것입니다. 컨트롤러도 페이저도 손대지 않습니다. 왜 그런지 짚어 가며 고쳐 봅니다.
이번 회차의 목표
- 카테고리 페이지에서 검색해도 카테고리가 유지되도록 검색 폼의 action을 현재 카테고리로 보낸다.
- 검색 중에 분류를 바꿔도 검색어가 유지되도록 카테고리 메뉴 링크에
?q=를 붙인다. - 이 결합을 Feature 테스트로 못 박는다.
왜 컨트롤러와 페이저는 안 건드려도 되나
먼저 안 고쳐도 되는 것부터 짚고 갑시다.
컨트롤러의 동시 적용은 이미 된다. 지난 회차에서 검색 묶음을 groupStart/End로 감싸 카테고리 조건과 AND로 결합해 뒀습니다. 그래서 categories/web?q=레이아웃 요청이 들어오면 컨트롤러는 이미 "web 카테고리 그리고 레이아웃 검색"을 올바르게 처리합니다. 즉 백엔드는 진작에 준비돼 있었고, 문제는 그런 주소를 만들어 낼 링크가 뷰에 없었던 것뿐입니다.
페이저도 쿼리스트링을 유지한다. CI4의 $pager->links()는 기본적으로 현재 요청의 쿼리스트링을 페이지 링크에 유지합니다. 그래서 검색·카테고리 상태에서 페이지를 넘겨도 그 상태가 딸려 갑니다. 이 역시 손댈 필요가 없습니다.
결론적으로 이번 버그는 뷰가 만들어 내는 URL이 상태를 흘리는 문제입니다. 그래서 뷰만 고칩니다.
1. 검색 폼 — 현재 카테고리로 보내기
먼저 "카테고리 페이지에서 검색하면 카테고리가 풀리는" 문제입니다. 원인은 검색 폼의 action이 항상 posts로 고정돼 있었기 때문입니다.
지난 회차의 폼은 이랬습니다.
<form class="search-form" method="get" action="<?= site_url('posts') ?>" role="search">
action이 무조건 posts니까, categories/web에서 검색해도 폼은 posts?q=...로 제출돼 카테고리가 사라집니다. 이걸 현재 카테고리 주소로 보내도록 고칩니다.
app/Views/posts/index.php:
<?php // 카테고리 페이지에서 검색해도 카테고리가 풀리지 않도록 현재 카테고리로 보낸다. ?>
<form class="search-form" method="get" action="<?= esc(isset($activeCategory) && $activeCategory !== null ? $activeCategory->url : site_url('posts')) ?>" role="search">
<input type="search" name="q" value="<?= esc($search ?? '', 'attr') ?>"
placeholder="제목·본문 검색" aria-label="검색어">
<button class="btn" type="submit">검색</button>
</form>
이제 action은 활성 카테고리가 있으면 그 카테고리의 URL($activeCategory->url → categories/web)로, 없으면 전체 목록(posts)으로 향합니다. categories/web에서 검색을 제출하면 categories/web?q=검색어가 돼, 카테고리를 유지한 채 그 안에서 검색합니다.
2. 카테고리 메뉴 — 검색어 유지하기
반대 방향입니다. "검색 중에 분류를 바꾸면 검색어가 사라지는" 문제죠. 원인은 카테고리 메뉴의 링크가 순수한 카테고리 URL만 가리켰기 때문입니다.
메뉴 파셜에서 현재 검색어($search)를 쿼리스트링으로 만들어 모든 링크에 붙입니다.
app/Views/partials/category_menu.php:
<?php
/**
* 글 목록의 분류 메뉴.
*
* @var \App\Entities\Category[] $categories 전체 카테고리(이름순)
* @var \App\Entities\Category|null $activeCategory 현재 필터 중인 카테고리(없으면 전체)
* @var string|null $search 현재 검색어(있으면 링크에 유지)
*/
$activeSlug = isset($activeCategory) && $activeCategory !== null ? $activeCategory->slug : null;
// 검색 중이면 카테고리를 바꿔도 검색어가 유지되도록 링크에 ?q= 를 붙인다.
$query = isset($search) && $search !== '' ? '?' . http_build_query(['q' => $search]) : '';
?>
<nav class="category-menu" aria-label="카테고리">
<a class="chip<?= $activeSlug === null ? ' is-active' : '' ?>"
href="<?= esc(site_url('posts') . $query) ?>">전체</a>
<?php foreach ($categories as $category): ?>
<a class="chip<?= $activeSlug === $category->slug ? ' is-active' : '' ?>"
href="<?= esc($category->url . $query) ?>"><?= esc($category->name) ?></a>
<?php endforeach ?>
</nav>
http_build_query(['q' => $search])로 q=레이아웃 같은 쿼리스트링을 만들고, 검색 중일 때만($search !== '') 그걸 모든 칩 링크 끝에 붙입니다. 이제 ?q=레이아웃으로 검색해 둔 상태에서 "웹 개발" 칩을 누르면 categories/web?q=레이아웃으로 이동해, 검색어를 유지한 채 카테고리만 좁힙니다. "전체" 칩도 posts?q=레이아웃이 돼 검색은 그대로 두고 분류만 풉니다.
http_build_query를 쓴 덕분에 검색어에 공백이나 특수문자가 있어도 올바르게 인코딩됩니다.
3. 놓치기 쉬운 함정 — 이스케이프 문맥
이번 회차에서 조용히 함께 고친 부분이 하나 있습니다. 링크 이스케이프를 esc(..., 'attr')에서 기본 esc() 로 바꾼 것입니다.
지난 회차의 메뉴 링크는 이랬습니다.
href="<?= esc($category->url, 'attr') ?>"
'attr' 문맥의 이스케이프는 HTML 속성값을 안전하게 만드는 데 특화돼 있어, ?나 = 같은 문자를 HTML 엔티티로 바꿔 버릴 수 있습니다. URL 자체에는 문제없지만, 이제 링크에 ?q=레이아웃처럼 쿼리스트링이 붙기 시작하면 그 ?나 =가 엔티티(? 등)로 깨져 링크가 망가집니다.
그래서 URL 문맥에는 기본 esc()(HTML 문맥)를 씁니다.
href="<?= esc($category->url . $query) ?>"
폼의 action도 같은 이유로 기본 esc()를 씁니다. 이스케이프는 "무조건 강하게"가 아니라 문맥에 맞게 골라야 한다는 걸 보여 주는 대목입니다. 속성값 이스케이프가 항상 정답은 아닙니다.
4. 테스트 — 결합과 상태 유지
이 결합 계약을 Feature 테스트로 못 박습니다. AND 결합, 교집합, 그리고 두 방향의 상태 유지를 각각 겨냥합니다.
tests/Feature/CombinedFilterTest.php:
final class CombinedFilterTest extends CIUnitTestCase
{
use FeatureTestTrait;
use DatabaseTestTrait;
protected $namespace = null;
protected $refresh = true;
protected $seed = 'App\Database\Seeds\PostSeeder';
private const WEB_MATCH = '공통 레이아웃으로 중복 걷어내기'; // web + '레이아웃'
private const WEB_NONMATCH = '첫 테스트가 주는 안전망'; // web 이지만 '레이아웃' 아님
protected function setUp(): void
{
parent::setUp();
\Config\Services::resetSingle('pager');
}
AND 결합 — OR로 새지 않는지. 지난 회차 groupStart/End가 제대로 괄호를 쳤는지 여기서 확실히 검증합니다.
public function testCategoryAndSearchCombineAsAnd(): void
{
// codeigniter4 카테고리에는 '레이아웃' 글이 없다 → 교집합이 비어 무결과여야 한다.
// (OR 로 새면 codeigniter4 글들이 떠 버린다)
$this->call('GET', 'categories/codeigniter4', ['q' => '레이아웃'])
->assertSee('검색 결과가 없습니다');
}
codeigniter4 카테고리에는 '레이아웃' 글이 없으니, AND로 올바르게 묶였다면 교집합이 비어 무결과여야 합니다. 만약 조건이 OR로 샜다면 codeigniter4 글들이 떠 버려 이 테스트가 실패합니다. 무결과를 확인하는 것으로 결합의 정확성을 역으로 증명합니다.
public function testCombinedShowsIntersectionOnly(): void
{
$res = $this->call('GET', 'categories/web', ['q' => '레이아웃']);
$res->assertSee(self::WEB_MATCH); // web ∩ 레이아웃
$res->assertDontSee(self::WEB_NONMATCH); // web 이지만 검색어 불일치 → 빠짐
}
categories/web?q=레이아웃은 web이면서 '레이아웃'을 가진 글(교집합)만 보이고, web이지만 검색어에 안 맞는 글은 빠져야 합니다.
상태 유지 — 두 방향. 이번 회차에서 고친 뷰의 두 계약을 각각 검증합니다.
public function testCategoryMenuKeepsSearch(): void
{
// 검색 중에는 카테고리 메뉴 링크가 q 를 달고 있어야 한다(클릭해도 검색 유지).
// 페이저(posts?q=...)가 아니라 '카테고리 링크'가 q 를 가지는지를 본다.
$this->call('GET', 'posts', ['q' => 'Forge'])
->assertSee('categories/codeigniter4?q=Forge');
}
public function testSearchFormKeepsCategory(): void
{
// 카테고리 페이지의 검색 폼 action 은 그 카테고리를 가리켜야 한다(검색해도 카테고리 유지).
// 페이지에서 action= 을 쓰는 건 검색 폼뿐이라, 핵심 계약(action 값)만 검증한다.
$this->call('GET', 'categories/web')
->assertSee('action="' . site_url('categories/web') . '"');
}
testCategoryMenuKeepsSearch는 검색 중 렌더된 HTML에 categories/codeigniter4?q=Forge라는 카테고리 링크가 실제로 들어 있는지 봅니다(메뉴 파셜의 ?q= 유지 검증). testSearchFormKeepsCategory는 카테고리 페이지의 검색 폼 action이 그 카테고리를 가리키는지 봅니다. 이 두 테스트는 앞서 고친 뷰 두 곳이 없으면 각각 실패해, 상태 유지가 실제로 동작함을 보장합니다.
./vendor/bin/phpunit tests/Feature/CombinedFilterTest.php
핵심 개념 — 왜 이렇게 했나
- 상태는 URL로 실려 다닌다. 검색어와 카테고리는 결국 쿼리스트링·경로에 담깁니다. 상태가 서로를 리셋하지 않게 하려면, 뷰가 만들어 내는 링크·폼 action이 현재 상태를 물고 가야 합니다. 백엔드는 이미 동시 처리가 됩니다.
- 고칠 곳을 정확히 짚는다. 컨트롤러의 AND 결합과 페이저의 쿼리스트링 유지는 이미 되어 있었습니다. 증상만 보고 여기저기 손대는 대신, 실제 새는 지점(뷰 URL 생성)만 고쳤습니다.
- 이스케이프는 문맥에 맞게. URL에 쿼리스트링이 붙으면
'attr'이스케이프가?·=를 깨뜨립니다. 문맥(HTML vs 속성)에 맞는 이스케이프를 골라야 합니다.
마무리 — 커밋과 태그
이번 회차에서 한 일:
- 검색 폼
action을 현재 카테고리로(검색해도 카테고리 유지) - 카테고리 메뉴 링크에
?q=유지(분류 바꿔도 검색 유지) - URL 이스케이프를 문맥에 맞게 기본
esc()로 - AND 결합·교집합·양방향 상태 유지 테스트
버그 수정 회차이므로 커밋 접두사는 fix:입니다.
git add .
git commit -m "fix: 검색과 필터 상태 유지"
git tag ep25
이로써 "분류와 검색" 섹션을 마무리했습니다. 카테고리 구조를 세우고, 필터와 메뉴를 붙이고, 검색을 더한 뒤, 둘이 서로를 방해하지 않도록 상태 유지까지 마쳤습니다.
다음 회차
다음 글부터는 콘텐츠를 풍부하게 만드는 섹션입니다. 첫 주제는 마크다운 본문 렌더링입니다. 글은 마크다운 원문으로 저장하되, 화면에는 HTML로 변환해 보여 줍니다. 저장은 원문, 표시는 변환이라는 원칙과 XSS 주의점을 함께 다룹니다.
이번 회차 요약
- 다루는 파일:
Views/partials/category_menu.php·Views/posts/index.php(수정) /tests/Feature/CombinedFilterTest.php(추가)- 핵심: 상태가 서로를 리셋하던 버그를 뷰만 고쳐 해결. 폼 action은 현재 카테고리로, 메뉴 링크엔
?q=유지. 컨트롤러(AND 결합)·페이저(쿼리스트링 유지)는 이미 동작하므로 손대지 않음. URL 이스케이프는 문맥에 맞게 기본esc()로.
다음: 마크다운 본문 렌더링
댓글 0
아직 댓글이 없습니다.