CodeIgniter 4로 블로그 만들기 #24 — 기본 검색
카테고리로 글을 분류하고 거를 수 있게 됐습니다. 하지만 "특정 단어가 들어간 글"을 찾으려면 아직도 눈으로 목록을 훑어야 합니다. 이번 회차에서 그걸 해결합니다. 제목과 본문을 검색어로 뒤지는 기본 검색 기능입니다.
검색의 핵심은 Query Builder의 like()입니다. SQL의 LIKE '%검색어%'를 안전하게 만들어 주는 메서드죠. 여기에 "제목 또는 본문 매칭"과 "카테고리 조건과의 AND 결합"을 어떻게 짜맞추는지가 이번 회차의 배울 거리입니다. 역시 테스트를 앞세워 진행합니다.
이번 회차의 목표
Posts::index가?q=검색어로 제목 OR 본문을like검색한다.- 검색어를 카테고리 조건과 올바르게 AND로 묶는다.
- 검색 폼(입력값 유지)과 무결과 메시지를 화면에 넣는다.
- Feature 테스트로 제목/본문 매칭·무관 글 제외·무결과 메시지를 검증한다.
1. 컨트롤러 — like() 검색과 groupStart/End
지난 회차의 index()에 검색 로직을 얹습니다. 카테고리 필터 블록 뒤에, 페이지네이션 앞에 검색 조건을 추가합니다.
app/Controllers/Posts.php:
/**
* 글 목록. 카테고리 슬러그가 주어지면 그 카테고리 글만 거르고,
* 검색어(q)가 주어지면 제목·본문을 like 로 찾는다.
*
* `posts` → 전체, `categories/{slug}` → 해당 카테고리, `?q=...` → 검색.
*/
public function index(?string $categorySlug = null): string
{
// ... (카테고리 필터 블록은 그대로) ...
// 검색어가 있으면 제목 OR 본문에서 찾는다. 다른 조건(카테고리)과 AND 로 묶이도록
// like 묶음을 groupStart/End 로 감싼다.
$search = trim((string) $this->request->getGet('q'));
if ($search !== '') {
$model->groupStart()
->like('title', $search)
->orLike('body', $search)
->groupEnd();
}
$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,
'search' => $search,
]);
}
한 줄씩 뜯어봅시다.
검색어를 정규화한다. $this->request->getGet('q')로 쿼리스트링의 q를 읽고, trim()으로 앞뒤 공백을 없앤 뒤 (string)으로 형변환합니다. q가 아예 없으면 getGet은 null을 주는데, (string) null은 빈 문자열이 되므로 안전합니다. 그리고 if ($search !== '')로 빈 검색은 아무 조건도 걸지 않고 넘어갑니다. 빈칸으로 검색하면 전체 목록이 나와야 하니까요.
제목 OR 본문. like('title', $search)에 orLike('body', $search)를 이어, "제목에 있거나 본문에 있으면" 매칭되게 합니다. like는 자동으로 양쪽에 %를 붙여 부분 일치로 만들고, 값을 바인딩해 SQL 인젝션을 막아 줍니다.
groupStart/End로 묶는 이유 — 이게 이번 회차의 함정입니다. 카테고리 필터가 이미 where('category_id', ...)를 걸어 둔 상태일 수 있습니다. 여기에 그냥 like + orLike를 이어 붙이면 SQL이 이렇게 됩니다.
WHERE category_id = 2 AND title LIKE '%레이아웃%' OR body LIKE '%레이아웃%'
SQL에서 AND는 OR보다 우선순위가 높아, 이건 사실상 (category_id = 2 AND title LIKE ...) OR (body LIKE ...)로 읽힙니다. 즉 마지막 OR body LIKE 절이 카테고리 조건을 무시하고 본문만 일치하면 다른 카테고리 글까지 끌어옵니다. 필터가 새는 것이죠.
이걸 막으려고 검색 묶음을 groupStart() … groupEnd()로 감쌉니다. 그러면 괄호가 쳐져서 이렇게 됩니다.
WHERE category_id = 2 AND (title LIKE '%레이아웃%' OR body LIKE '%레이아웃%')
카테고리 조건(AND)과 검색 묶음이 깔끔하게 결합됩니다. 이번 회차에서는 화면에 카테고리+검색 동시 UI가 없지만, 컨트롤러 설계 단계에서 미리 올바른 결합을 잡아 두는 것입니다. (실제로 이 설계 덕분에 다음 회차의 동시 필터가 손대지 않아도 동작합니다.)
검색어를 뷰로 넘긴다. 'search' => $search를 뷰 데이터에 추가합니다. 검색 후에도 입력창에 검색어가 남아 있어야 하고, 무결과 메시지에도 검색어를 쓰기 때문입니다.
2. 뷰 — 검색 폼과 무결과 메시지
목록 뷰에 검색 폼과, 결과가 없을 때의 안내를 넣습니다.
app/Views/posts/index.php:
<?= $this->include('partials/category_menu') ?>
<form class="search-form" method="get" action="<?= site_url('posts') ?>" role="search">
<input type="search" name="q" value="<?= esc($search ?? '', 'attr') ?>"
placeholder="제목·본문 검색" aria-label="검색어">
<button class="btn" type="submit">검색</button>
</form>
<?php if (empty($posts)): ?>
<?php if (! empty($search)): ?>
<p class="empty">'<?= esc($search) ?>'에 대한 검색 결과가 없습니다.</p>
<?php else: ?>
<p class="empty">아직 작성된 글이 없습니다.</p>
<?php endif ?>
<?php else: ?>
<ul class="post-list">
<!-- ... 글 목록 ... -->
</ul>
<?= $pager->links() ?>
<?php endif ?>
폼은 GET. method="get"이라 제출하면 ?q=검색어가 주소에 붙습니다. 검색 결과 URL이 공유·북마크 가능해지고, 새로고침해도 검색이 유지됩니다. 검색처럼 "읽기" 성격의 동작은 GET이 자연스럽습니다.
입력값 유지. value="<?= esc($search ?? '', 'attr') ?>"로 방금 검색한 단어를 입력창에 다시 채웁니다. 검색 후 빈 칸이 되면 사용자는 무엇을 검색했는지 잊기 쉬운데, 값을 유지하면 검색어를 조금 고쳐 재검색하기도 편합니다. esc(..., 'attr')로 속성 문맥에 맞게 이스케이프해 안전하게 출력합니다.
무결과 메시지의 분기. 목록이 비었을 때 두 경우를 구분합니다. 검색 중($search가 비어 있지 않음)이면 '단어'에 대한 검색 결과가 없습니다로, 검색이 아니면 아직 작성된 글이 없습니다로 안내합니다. "검색 결과 0건"과 "글 자체가 없음"은 사용자에게 전혀 다른 상황이라, 메시지를 나눠야 오해가 없습니다.
type="search", role="search", aria-label로 이 폼이 검색이라는 의미도 함께 실어 줍니다.
3. 스타일
검색 폼과 무결과 문구의 CSS를 더합니다.
public/assets/css/app.css:
/* search */
.search-form { display: flex; gap: 8px; margin: 0 0 24px; }
.search-form input { flex: 1; padding: 10px 12px; border: 1px solid var(--color-line); border-radius: var(--radius); font-family: var(--font-sans); font-size: 15px; color: var(--color-ink); background: var(--color-paper); }
.search-form input:focus { outline: none; border-color: var(--color-accent); }
.empty { color: var(--color-ink-3); padding: 24px 0; }
입력창이 flex: 1로 남는 폭을 채우고 버튼이 오른쪽에 붙습니다. 포커스 시 테두리가 강조색으로 바뀌어 지금 입력 중임을 알려 줍니다. 무결과 문구는 흐린 색으로 눈에 덜 튀게 했습니다.
4. 테스트 — 제목/본문 매칭과 무결과
검색의 계약을 Feature 테스트로 못 박습니다. 시더 더미 글을 기준으로, "제목만 일치", "본문만 일치", "무결과" 시나리오를 각각 겨냥합니다.
tests/Feature/SearchTest.php:
final class SearchTest extends CIUnitTestCase
{
use FeatureTestTrait;
use DatabaseTestTrait;
protected $namespace = null;
protected $refresh = true;
protected $seed = 'App\Database\Seeds\PostSeeder';
private const LAYOUT_POST = '공통 레이아웃으로 중복 걷어내기';
private const MIGRATION_POST = '마이그레이션으로 스키마를 코드로 남기기';
// 검색어와 무관하면서, 페이지네이션이 아니라 '검색' 때문에 빠져야 하는 글
// (가장 최신 글이라 검색이 없으면 항상 1페이지에 보인다 → 제외 검증에 적합)
private const NEWEST_POST = 'CodeIgniter 4로 블로그 만들기를 시작하며';
테스트 데이터 선택이 영리합니다. NEWEST_POST는 시더가 넣는 가장 최신 글이라, 검색을 안 하면 정렬상 항상 1페이지에 보입니다. 그래서 이 글이 검색 후 사라진다면, 그건 페이지네이션이 아니라 검색이 걸러 낸 것이라고 확신할 수 있습니다. 제외 검증의 기준으로 딱 맞습니다.
public function testSearchMatchesTitle(): void
{
$this->call('GET', 'posts', ['q' => '레이아웃'])->assertSee(self::LAYOUT_POST);
}
public function testSearchExcludesNonMatching(): void
{
// '레이아웃' 으로 찾으면, 평소 1페이지에 보이던 최신 글도 빠진다(검색이 거른 것).
$this->call('GET', 'posts', ['q' => '레이아웃'])->assertDontSee(self::NEWEST_POST);
}
public function testSearchMatchesBody(): void
{
// 'Forge' 는 본문에만 있다 → 본문 검색이 동작하고, 무관한 최신 글은 빠진다.
$res = $this->call('GET', 'posts', ['q' => 'Forge']);
$res->assertSee(self::MIGRATION_POST);
$res->assertDontSee(self::NEWEST_POST);
}
레이아웃은 제목에만 있는 단어라 제목 검색을, Forge는 본문에만 있는 단어라 본문 검색을 각각 검증합니다. orLike('body', ...)가 없으면 Forge 테스트가 실패하니, 본문 매칭이 실제로 동작하는지 확인할 수 있습니다.
public function testEmptyQueryShowsAllPosts(): void
{
// 빈 검색어는 전체 목록(최신 글)을 그대로 보여 준다.
$this->call('GET', 'posts', ['q' => ''])->assertSee(self::NEWEST_POST);
}
public function testNoResultsShowsMessage(): void
{
$res = $this->call('GET', 'posts', ['q' => '존재하지않는검색어xyz']);
$res->assertSee('검색 결과가 없습니다');
$res->assertDontSee(self::LAYOUT_POST);
}
빈 검색어(q=)는 전체 목록을 그대로 보여 주는지(컨트롤러의 if ($search !== '') 가드), 아무 글도 없는 검색어는 무결과 메시지를 띄우는지 확인합니다.
./vendor/bin/phpunit tests/Feature/SearchTest.php
핵심 개념 — 왜 이렇게 했나
like()는 안전한 부분 일치. 값을 바인딩하고%를 자동으로 붙여, 직접 SQL을 조립할 때 생기는 인젝션 위험 없이 검색을 만듭니다.groupStart/End로 OR 묶음을 괄호 친다.AND가OR보다 강하게 묶이는 SQL 특성 때문에, 검색의 OR 묶음을 감싸지 않으면 앞선 카테고리 조건이 샙니다. 괄호로 결합 우선순위를 명시합니다.- GET 검색 + 값 유지 + 무결과 분기. 검색은 공유·북마크 가능한 GET으로, 입력값은 다시 채워 재검색을 쉽게, "결과 0건"과 "글 없음"은 다른 메시지로.
마무리 — 커밋과 태그
이번 회차에서 한 일:
?q=로 제목 OR 본문like검색groupStart/End로 카테고리 조건과 AND 결합- 검색 폼(값 유지) + 무결과 메시지
- 제목/본문 매칭·제외·무결과 테스트
git add .
git commit -m "feat: 기본 검색"
git tag ep24
다음 회차
다음 글에서는 이번에 심어 둔 씨앗을 마무리합니다. 검색과 카테고리, 페이지가 서로를 리셋하던 상태 유지 버그를 잡습니다. 카테고리 페이지에서 검색해도 카테고리가 풀리지 않고, 분류를 바꿔도 검색어가 유지되도록 폼과 메뉴 링크를 손봅니다.
이번 회차 요약
- 다루는 파일:
Controllers/Posts.php·Views/posts/index.php·public/assets/css/app.css(수정) /tests/Feature/SearchTest.php(추가)- 핵심: Query Builder
like/orLike로 제목·본문 검색.groupStart/End로 OR 묶음을 괄호 쳐 카테고리 조건과 올바르게 AND 결합. 빈 검색은 전체, 무결과는 전용 메시지.
다음: 검색과 필터 상태 유지
댓글 0
아직 댓글이 없습니다.