목록 조회 성능 축(깊은 OFFSET·정렬·색인)과 그 검증 계층에서 계획서 전수검수와 3회에 걸친 감사가 지적한 항목을 처리했다. 세부 경위는 의 각 회차 문서에 있다. 검증 계층 — 컨트롤러가 base Request 를 직접 주입받던 확장 13곳을 전용 FormRequest 로 옮겼다. 옮기면서 기존 동작 계약은 그대로 두었다: 상한 초과 limit 을 거부하지 않고 상한까지 반환하던 공개 API, per_page 를 범위로 조정하던 관리자 목록, 미지원 period 를 year 로 해석하던 인기글 목록 모두 종전과 같은 응답을 낸다. 상한/폴백을 rules 로 승격시키면 200 이던 응답이 422 가 되어 기존 링크가 깨지므로, 규칙은 타입만 닫고 클램프·폴백은 접근자가 맡는다. period 는 접근자가 닫힌 집합만 반환해 캐시 키 공간도 함께 닫힌다. ckeditor5 이미지 업로드만 ResponseHelper 봉투를 쓰지 않는다. 응답을 파싱하는 주체가 CDN 으로 로드되는 상위 CKEditor5 43.3.1 의 SimpleUploadAdapter 라 규약을 바꿀 수 없어, 각 응답 지점에 사유를 명시한 면제를 부착하고 근거를 API 문서에 남겼다. 하네스 — 룰 5개의 대상 경로 패턴이 매처와 맞지 않아 번들 확장 컨트롤러가 검사 대상에서 통째로 빠져 있었다. 패턴을 고치자 확장 위반 17건이 드러나 전건 처리했다. severity 오타가 요약 집계 양쪽에 안 잡혀 "0 error" 로 보고되던 문제도 런너 사전 검증으로 막았다. 정렬 게이트 대조 하네스는 관계 정렬 변형만 쓰는 저장소를 탐지하지 못한 채 통과시키고 있었다. 탐지·제외·인자 파싱을 함께 고치고 단위 테스트를 신설했다.
19 KiB
Scout 검색 엔진 시스템 (Search System)
중요도: 높음 관련 문서: service-repository.md | hooks.md | database-guide.md
TL;DR (5초 요약)
1. Laravel Scout + DatabaseFulltextEngine: MySQL FULLTEXT + ngram 기반 검색 (기본 드라이버)
2. FulltextSearchable 인터페이스: searchableColumns() + searchableWeights() 구현 필수
3. LIKE fallback 자동 적용: FULLTEXT 미지원 DBMS(SQLite, PostgreSQL)에서 자동 전환
4. 확장 포인트: core.search.engine_drivers(엔진) + core.search.index_maintainers(인덱스 점검) 필터 훅
5. 인덱스 재생성은 언제나 선택 사항 — 자동 트리거 없음 (테이블 잠금·전체 재색인 비용)
6. AsUnicodeJson 캐스트: JSON 컬럼 FULLTEXT 검색 시 한글 \uXXXX 이스케이프 방지 필수
목차
아키텍처 개요
G7은 Laravel Scout를 통해 검색 기능을 제공하며, 기본 검색 엔진으로 DatabaseFulltextEngine을 사용합니다.
핵심 구조:
Controller/Service
↓ Model::search($keyword)
Laravel Scout (EngineManager)
↓ SCOUT_DRIVER 기반 엔진 선택
DatabaseFulltextEngine
↓ FulltextSearchable 인터페이스 참조
├── MySQL/MariaDB → MATCH...AGAINST IN BOOLEAN MODE
└── SQLite/PostgreSQL → LIKE fallback
핵심 컴포넌트:
| 파일 | 역할 |
|---|---|
app/Search/Contracts/FulltextSearchable.php |
검색 대상 컬럼/가중치 정의 인터페이스 |
app/Search/Engines/DatabaseFulltextEngine.php |
MySQL FULLTEXT + ngram Scout 엔진 |
app/Providers/ScoutServiceProvider.php |
엔진 등록 + 필터 훅 처리 |
app/Casts/AsUnicodeJson.php |
FULLTEXT ngram용 UTF-8 JSON 캐스트 |
config/scout.php |
Scout 설정 (드라이버, 큐, 소프트삭제 등) |
app/Search/Contracts/SearchIndexMaintainer.php |
인덱스 점검·재생성 계약 (엔진 중립) |
app/Search/SearchIndexMaintenanceManager.php |
활성 드라이버의 점검기 해석 + 재생성 진입점 |
app/Search/Engines/Maintenance/FulltextIndexMaintainer.php |
mysql-fulltext 드라이버의 점검기 구현 |
app/Console/Commands/Search/SearchIndexCommand.php |
search:index 점검·재생성 커맨드 |
설계 원칙:
- MySQL 테이블 자체가 인덱스 소스 -- 외부 검색 서버 불필요
update(),delete(),flush(),createIndex(),deleteIndex()는 모두 no-op (MySQL이 자동 관리)- FULLTEXT 미지원 DBMS에서 LIKE fallback 자동 적용 (테스트 환경 SQLite 호환)
FulltextSearchable 인터페이스
App\Search\Contracts\FulltextSearchable 인터페이스를 구현한 모델만 DatabaseFulltextEngine에서 검색됩니다.
필수 메서드
| 메서드 | 반환 타입 | 설명 |
|---|---|---|
searchableColumns() |
array<string> |
FULLTEXT 검색 대상 컬럼명 배열 |
searchableWeights() |
array<string, float> |
컬럼별 검색 가중치 (높을수록 상위 노출) |
구현 예시 (Product 모델)
use App\Search\Contracts\FulltextSearchable;
use Laravel\Scout\Searchable;
class Product extends Model implements FulltextSearchable
{
use Searchable;
// FULLTEXT 검색 대상 컬럼
public function searchableColumns(): array
{
return ['name', 'description'];
}
// 컬럼별 가중치 (name 매칭이 description보다 2배 높은 점수)
public function searchableWeights(): array
{
return [
'name' => 2.0,
'description' => 1.0,
];
}
}
가중치 기반 스코어 계산
검색 결과는 _ft_score 가상 컬럼으로 관련성 점수가 부여됩니다:
-- MySQL에서 생성되는 쿼리 (예시)
SELECT products.*,
(MATCH(`name`) AGAINST(? IN BOOLEAN MODE) * 2.0
+ MATCH(`description`) AGAINST(? IN BOOLEAN MODE) * 1.0) as _ft_score
FROM products
WHERE (MATCH(`name`) AGAINST(? IN BOOLEAN MODE)
OR MATCH(`description`) AGAINST(? IN BOOLEAN MODE))
ORDER BY _ft_score DESC
LIKE fallback 시 _ft_score는 항상 0 (관련성 순위 불가).
현재 FulltextSearchable 구현 모델
| 모듈 | 모델 | 검색 컬럼 |
|---|---|---|
| sirsoft-ecommerce | Product | name, description |
| sirsoft-ecommerce | Category | name |
| sirsoft-ecommerce | Brand | name |
| sirsoft-ecommerce | Coupon | name |
| sirsoft-ecommerce | ProductCommonInfo | name |
| sirsoft-board | Post | (모델 참조) |
| sirsoft-page | Page | (모델 참조) |
검색 엔진 드라이버
기본 드라이버: mysql-fulltext
DatabaseFulltextEngine은 MySQL FULLTEXT + ngram 파서를 활용합니다:
- MySQL 8.0+:
MATCH...AGAINST IN BOOLEAN MODE+ ngram 파서 (한글 2글자 토큰 분리) - MariaDB:
MATCH...AGAINST IN BOOLEAN MODE+ 기본 파서 (ngram 미지원) - SQLite/PostgreSQL:
LIKE %keyword%fallback (개발/테스트 환경 호환)
DBMS 지원 판단
// FULLTEXT 지원 여부 (MySQL, MariaDB만 true)
DatabaseFulltextEngine::supportsFulltext();
// ngram 파서 지원 여부 (MySQL만 true, MariaDB는 false)
DatabaseFulltextEngine::supportsNgramParser();
SCOUT_DRIVER 전환
.env에서 드라이버를 변경하면 즉시 적용됩니다:
# 기본값: MySQL FULLTEXT
SCOUT_DRIVER=mysql-fulltext
# 플러그인에서 등록한 드라이버로 전환
SCOUT_DRIVER=meilisearch
whereFulltext() 정적 헬퍼
Scout Builder를 사용할 수 없는 곳 (관계 검색, 서브쿼리 등)에서 FULLTEXT 조건을 직접 추가합니다:
use App\Search\Engines\DatabaseFulltextEngine;
// Repository에서 사용 예시
$query = Post::query();
DatabaseFulltextEngine::whereFulltext($query, 'content', $keyword);
DatabaseFulltextEngine::whereFulltext($query, 'title', $keyword, 'or');
DBMS에 따라 자동 분기:
- MySQL/MariaDB:
WHERE MATCH(\content`) AGAINST(? IN BOOLEAN MODE)` - 그 외:
WHERE content LIKE '%keyword%'
확장 포인트
core.search.engine_drivers 필터 훅
ScoutServiceProvider에서 core.search.engine_drivers 필터 훅을 통해 플러그인이 추가 검색 엔진을 등록할 수 있습니다.
// 플러그인 ServiceProvider에서 등록
use App\Extension\HookManager;
public function boot(): void
{
HookManager::addFilter('core.search.engine_drivers', function (array $drivers) {
$drivers['meilisearch'] = \App\Search\Engines\MeilisearchEngine::class;
return $drivers;
});
}
등록 후 .env에서 SCOUT_DRIVER=meilisearch로 전환하면 해당 엔진이 사용됩니다.
ScoutServiceProvider 동작 흐름
// app/Providers/ScoutServiceProvider.php
// 1. 기본 드라이버 맵
$drivers = ['mysql-fulltext' => DatabaseFulltextEngine::class];
// 2. 필터 훅으로 플러그인 드라이버 수집
$drivers = HookManager::applyFilters('core.search.engine_drivers', $drivers);
// 3. EngineManager에 모든 드라이버 등록
$this->app->resolving(EngineManager::class, function (EngineManager $manager) use ($drivers) {
foreach ($drivers as $name => $engineClass) {
$manager->extend($name, fn () => $this->app->make($engineClass));
}
});
core.search.index_maintainers 필터 훅
검색 엔진은 "인덱스가 있는데 내용이 색인되어 있지 않은" 상태가 될 수 있습니다. 이때 검색은
오류 없이 0건을 돌려주므로 예외도 로그도 남지 않고, 운영자는 "원래 검색이 안 되는 줄" 알고
지나갑니다. SearchIndexMaintainer 계약이 그 상태를 점검·복구하는 방법을 엔진마다 정의합니다.
인덱스의 실체가 엔진마다 다르므로(FULLTEXT = 테이블에 붙은 인덱스, Meilisearch/Elasticsearch =
외부 서버의 인덱스) 코어는 판정 방법을 알지 못한 채 계약만 호출하고, 등급(SearchIndexStatus)만
보고 재생성 대상을 고릅니다.
use App\Extension\HookManager;
use App\Search\SearchIndexMaintenanceManager;
HookManager::addFilter(SearchIndexMaintenanceManager::MAINTAINERS_FILTER, function (array $maintainers) {
$maintainers['meilisearch'] = MeilisearchIndexMaintainer::class;
return $maintainers;
});
구현할 메서드
| 메서드 | 반환 | 설명 |
|---|---|---|
driver() |
string |
담당 Scout 드라이버명 (config('scout.driver') 와 대조) |
isAvailable() |
bool |
현재 환경에서 점검 가능 여부 (미지원 DBMS·서버 미연결 등) |
unavailableReason() |
?string |
점검 불가 사유. "점검 대상 0" 과 "점검할 수 없었음" 은 구분되어야 한다 |
inspect(array $filters) |
SearchIndexHealth[] |
인덱스별 판정. 엔진별 세부는 details, 재생성에 필요한 자기 정보는 context 에 담는다 |
rebuild(SearchIndexHealth $health) |
void |
재생성 (context 를 그대로 되돌려받는다) |
판정 등급 (App\Enums\SearchIndexStatus)
| 등급 | 뜻 | 재생성 대상 |
|---|---|---|
healthy |
색인된 내용으로 검색이 성립 | — |
degraded |
일부만 성립. 토크나이저 특성(불용어 등)일 수 있다 | ✕ |
stale |
검색이 성립하지 않음 | ✅ |
skipped |
표본·연결 부재로 판정 불가 (사유 기재) | — |
유지보수기를 등록하지 않은 엔진은 점검 대상에서 빠질 뿐 검색은 그대로 동작합니다. 화면·커맨드는 그 경우 "이 엔진은 점검을 제공하지 않는다" 를 명시합니다.
재생성은 언제나 선택 사항이다
재생성 비용은 엔진에 따라 테이블 잠금(FULLTEXT)이나 전체 재색인(외부 엔진)입니다. 운영 중인 사이트에서 확장을 업데이트했다는 이유만으로 그 비용이 발생해서는 안 되므로, 재생성은 어떤 자동 트리거에도 연결하지 않고 운영자가 명시적으로 선택했을 때만 수행합니다.
| 경로 | 선택 방법 | 기본값 |
|---|---|---|
search:index --repair |
옵션 + 확인 프롬프트 | 점검만 |
module:update / plugin:update / module:install / plugin:install |
--rebuild-search-index |
미수행 |
core:update (번들 확장 일괄 업데이트 단계) |
대화형 확인 또는 --rebuild-search-index |
미수행 |
| 관리자 화면 확장 업데이트 모달 | 「업데이트 후 색인이 누락된 검색 인덱스를 재생성」 체크박스 | 미체크 |
선택하지 않아도 누락 사실은 안내합니다 — 알려주지 않으면 운영자가 알 방법이 없기 때문입니다.
--force(무인 실행)에서는 묻지도 재생성하지도 않습니다. 무인 실행이 대용량 테이블을 잠그는 일이
없어야 합니다.
선택은 그 창에서만 유효하다 — 화면 상태를 이월하지 않는다
재생성 체크는 모달을 열 때마다 해제된 상태로 시작해야 합니다. 체크 상태를 전역 상태에 남겨 두면 한 번 체크한 운영자가 다음 확장을 업데이트할 때 아무것도 누르지 않았는데 재생성이 다시 수행됩니다. 서버의 옵인 가드(요청에 실린 값만 신뢰)는 정상 동작하므로 HTTP 레벨 테스트로는 드러나지 않습니다 — 화면이 이미 체크된 값을 보내기 때문입니다.
| ❌ 금지 | ✅ 올바른 사용 |
|---|---|
모달을 여는 setState 시드에 재생성 키를 빼 둠 |
시드와 제출 후 초기화 양쪽에 <x>RebuildSearchIndex: false |
| 제출 성공 후 다른 상태만 되돌리고 재생성 체크는 그대로 둠 | onSuccess 초기화 목록에 재생성 키 포함 |
| 모듈·플러그인이 같은 전역 키를 공유 | 면마다 별도 키 (한쪽 체크가 다른 쪽으로 전이되면 안 됨) |
정적 고정: templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-extension-update-rebuild-optin.test.tsx
종단 고정: tests/Playwright/specs/admin/extension-update-search-index-optin.spec.ts
점검 결과는 반드시 호출자에게 도달해야 한다
색인이 비면 검색은 오류 없이 0건을 돌려줍니다. 운영자가 알 수 있는 유일한 통로가 응답에 실린
search_index 페이로드이므로, 그 페이로드가 중간에서 사라지면 점검 기능 자체가 무의미해집니다.
| ❌ 금지 | ✅ 올바른 사용 |
|---|---|
응답 헬퍼가 JsonResource::resolve() 만 호출 (부가 데이터 유실) |
ResponseHelper::successWithResource 가 additional() 을 응답 최상위에 병합 |
| 재생성 여부만 알리고 잔존 여부는 생략 | rebuilt / stale·stale_count (미수행) 또는 repaired·failed·remaining (수행) |
| 재생성 성공을 곧 복구로 간주 | remaining 은 재생성 후 재점검 결과 — "재생성했다" 와 "복구됐다" 를 구분 |
점검의 비-0 종료는 "실패" 가 아니다
search:index 는 색인 누락이 남아 있으면 종료 코드 1 을 돌려줍니다. 점검 자체는 정상 수행된 것이며,
이는 CI 에서 이상을 감지하기 위한 신호입니다. 실행 결과를 화면에 표시하는 도구(개발 도구 대시보드 등)가
이를 「실행 실패」로 적으면 운영자는 도구가 고장난 것으로 읽고 정작 발견된 색인 누락을 놓칩니다.
종료 코드와 출력을 그대로 보여 주고, 실패로 단정하지 않습니다.
mysql-fulltext 의 판정 방법 — 자기 매칭(self-match)
기본 드라이버의 유지보수기는 표본 행을 뽑아 그 행 자신의 내용에서 만든 검색어로 그 행을 찾을 수 있는지 봅니다. 특정 키워드를 사람이 골라 넣지 않으므로 어떤 테이블·언어에도 그대로 적용됩니다.
토큰은 행마다 여러 개 만들고, 한 행이 어떤 토큰으로도 자신을 찾지 못할 때만 실패로 셉니다.
하나만 쓰면 토크나이저 특성을 색인 누락으로 오판합니다 — 예를 들어 ngram(ngram_token_size=2)은
Basic 을 Ba/as/si/ic 로 쪼개는데 그중 as 가 기본 불용어라 구문 검색이 깨집니다.
대상은 하드코딩하지 않고 INFORMATION_SCHEMA 에서 전수 수집하므로, 확장이 나중에 추가하는
FULLTEXT 인덱스도 커맨드 수정 없이 포함됩니다.
php artisan search:index # 활성 엔진의 인덱스 점검 (읽기 전용)
php artisan search:index --repair # 색인 누락 인덱스 재생성
php artisan search:index --filter=table=pages # 엔진별 필터 (FULLTEXT: table, index, samples)
php artisan search:index --json # 기계 판독용 출력
마이그레이션
addFulltextIndex() 헬퍼
DatabaseFulltextEngine::addFulltextIndex()는 DBMS별 조건부 DDL을 처리합니다:
use App\Search\Engines\DatabaseFulltextEngine;
// 마이그레이션 up()에서 사용
public function up(): void
{
DatabaseFulltextEngine::addFulltextIndex(
'ecommerce_products', // 테이블명 (prefix 제외)
'ft_ecommerce_products_name', // 인덱스명
'name' // 대상 컬럼 (string 또는 array)
);
}
DBMS별 동작:
| DBMS | 생성되는 DDL |
|---|---|
| MySQL 8.0+ | ALTER TABLE ... ADD FULLTEXT INDEX ... WITH PARSER ngram |
| MariaDB | ALTER TABLE ... ADD FULLTEXT INDEX ... (ngram 없음) |
| SQLite/PostgreSQL | 스킵 (no-op) |
마이그레이션 down() 패턴
public function down(): void
{
if (! Schema::hasTable('ecommerce_products')) {
return;
}
$indexes = array_column(Schema::getIndexes('ecommerce_products'), 'name');
Schema::table('ecommerce_products', function (Blueprint $table) use ($indexes) {
if (in_array('ft_ecommerce_products_name', $indexes)) {
$table->dropIndex('ft_ecommerce_products_name');
}
});
}
인덱스 네이밍 규칙
ft_{테이블명}_{컬럼명}
예: ft_ecommerce_products_name, ft_ecommerce_products_description
복합 컬럼 인덱스
// 여러 컬럼을 하나의 FULLTEXT 인덱스로 생성
DatabaseFulltextEngine::addFulltextIndex(
'posts',
'ft_posts_title_content',
['title', 'content'] // 배열 전달
);
AsUnicodeJson 캐스트
문제
Laravel 기본 array 캐스트는 json_encode()로 한글을 \uXXXX로 이스케이프합니다:
// 기본 array 캐스트: \uXXXX 이스케이프
{"ko": "\uc0c1\ud488\uba85"}
// AsUnicodeJson 캐스트: 실제 UTF-8
{"ko": "상품명"}
MySQL ngram 토크나이저는 실제 UTF-8 문자를 기준으로 토큰을 생성하므로, \uXXXX 이스케이프된 데이터에서는 한글 검색이 동작하지 않습니다.
사용법
use App\Casts\AsUnicodeJson;
class Product extends Model
{
protected $casts = [
'name' => AsUnicodeJson::class, // FULLTEXT 검색 대상 JSON 컬럼
'description' => AsUnicodeJson::class, // FULLTEXT 검색 대상 JSON 컬럼
'meta_keywords' => 'array', // 검색 대상 아닌 컬럼은 기본 캐스트 사용 가능
];
}
적용 대상
FULLTEXT 인덱스가 걸리는 JSON 타입 컬럼에는 반드시 AsUnicodeJson 캐스트를 사용합니다. 내부적으로 JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES 플래그를 사용합니다.
환경설정
config/scout.php 주요 설정
| 키 | 기본값 | 설명 |
|---|---|---|
driver |
mysql-fulltext |
검색 엔진 드라이버 (SCOUT_DRIVER 환경변수) |
prefix |
'' |
인덱스 접두사 |
queue |
false |
인덱스 동기화 큐 사용 여부 |
soft_delete |
true |
소프트 삭제 레코드 필터링 |
after_commit |
false |
DB 트랜잭션 커밋 후 인덱스 동기화 |
chunk.searchable |
500 |
대량 인덱싱 시 청크 크기 |
.env 설정
# 검색 엔진 드라이버 (기본: mysql-fulltext)
SCOUT_DRIVER=mysql-fulltext
# 인덱스 접두사 (선택)
SCOUT_PREFIX=
# 인덱스 동기화 큐 사용 (선택)
SCOUT_QUEUE=false
참고:
mysql-fulltext드라이버는 MySQL 테이블 자체가 인덱스이므로SCOUT_QUEUE,SCOUT_PREFIX는 실질적으로 사용되지 않습니다. 외부 검색 엔진(Meilisearch 등)으로 전환 시 의미가 있습니다.
관련 문서
- Service-Repository 패턴 - Repository에서 whereFulltext() 사용
- 훅 시스템 - core.search.engine_drivers 필터 훅
- 데이터베이스 가이드 - 마이그레이션 규칙
- 플러그인 개발 - 검색 엔진 플러그인 개발