Merge pull request from gnuboard:HeuJung/issue492

HeuJung/issue492
This commit is contained in:
정정홍
2026-08-02 16:48:52 +09:00
committed by GitHub
355 changed files with 23035 additions and 1866 deletions
+2
View File
@@ -48,6 +48,8 @@ node_modules/
# Settings JSON files (환경별 설정)
/storage/app/settings/
# 성능 계측 리포트 (g7:bench --report) — 측정값이 실행 머신 사양에 종속되므로 저장소에 축적하지 않는다
/storage/app/benchmarks/
# 확장 프론트엔드 병합 번들 캐시 (version-in-path, 런타임 생성)
/storage/app/ext-bundles/
+49 -4
View File
@@ -6,7 +6,7 @@
<!-- AUTO-GENERATED-START: docs-quick-reference -->
### 백엔드 [backend/](docs/backend/) (32개)
### 백엔드 [backend/](docs/backend/) (33개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
@@ -16,6 +16,7 @@
| [api-documentation.md](docs/backend/api-documentation.md) | API 레퍼런스 문서 규정 (API Documentation) | 모든 API 엔드포인트는 레퍼런스 문서 필수 — 메서드/URI/파라미터/응답 필드 + 요청·응답 예시 ... |
| [api-resources.md](docs/backend/api-resources.md) | API 리소스 | Resource: BaseApiResource 상속 필수 / Collection: BaseApiColl... |
| [authentication.md](docs/backend/authentication.md) | 인증 및 세션 처리 | Laravel Sanctum 토큰 전용 인증 (Bearer 토큰만 사용) |
| [benchmark.md](docs/backend/benchmark.md) | 성능 계측 시스템 (Benchmark) | `g7:bench` 가 4축(list/screen/write/batch)을 잰다 — 계측 대상은 커맨드... |
| [broadcasting.md](docs/backend/broadcasting.md) | Broadcasting (실시간 이벤트) | Laravel Reverb 사용 (WebSocket) |
| [console-confirm.md](docs/backend/console-confirm.md) | 콘솔 yes/no 프롬프트 (ConsoleConfirm) | 콘솔 커맨드의 yes/no 프롬프트는 $this->unifiedConfirm() 사용 — Laravel... |
| [controllers.md](docs/backend/controllers.md) | 컨트롤러 계층 구조 | AdminBaseController / AuthBaseController / PublicBaseCont... |
@@ -114,7 +115,7 @@
| [module-assets.md](docs/extension/module-assets.md) | 모듈 프론트엔드 에셋 시스템 | module.json에 에셋 매니페스트 정의 (js, css, loading strategy) |
| [module-basics.md](docs/extension/module-basics.md) | 모듈 개발 기초 | 디렉토리: vendor-module (예: sirsoft-ecommerce) |
| [module-commands.md](docs/extension/module-commands.md) | 모듈 Artisan 커맨드 | 목록: php artisan module:list |
| [module-i18n.md](docs/extension/module-i18n.md) | 모듈 다국어 시스템 | 백엔드: /lang/{locale}/*.php → __('vendor-module::key') |
| [module-i18n.md](docs/extension/module-i18n.md) | 모듈 다국어 시스템 | 백엔드: /src/lang/{locale}/*.php → __('vendor-module::key') ... |
| [module-identity-settings.md](docs/extension/module-identity-settings.md) | 모듈/플러그인 본인인증(IDV) 설정 통합 가이드 | 정책/목적/메시지: module.php::getIdentity{Policies,Purposes,Mess... |
| [module-layouts.md](docs/extension/module-layouts.md) | 모듈 레이아웃 시스템 | 위치: modules/_bundled/vendor-module/resources/layouts/admi... |
| [module-routing.md](docs/extension/module-routing.md) | 모듈 라우트 규칙 | URL prefix 자동: /api/modules/[vendor-module]/... |
@@ -159,7 +160,7 @@
| 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 |
|------|------|--------------|----------------|
| `gnuboard7-hello_module` | 모듈 | [docs/api/](modules/_bundled/gnuboard7-hello_module/docs/api/README.md) | 1 / 2 |
| `gnuboard7-hello_module` | 모듈 | [docs/api/](modules/_bundled/gnuboard7-hello_module/docs/api/README.md) | 1 / 7 |
| `sirsoft-board` | 모듈 | [docs/api/](modules/_bundled/sirsoft-board/docs/api/README.md) | 10 / 80 |
| `sirsoft-ecommerce` | 모듈 | [docs/api/](modules/_bundled/sirsoft-ecommerce/docs/api/README.md) | 33 / 231 |
| `sirsoft-page` | 모듈 | [docs/api/](modules/_bundled/sirsoft-page/docs/api/README.md) | 2 / 17 |
@@ -192,7 +193,7 @@
### ① 코어 → 확장 동기화 (`requires.g7_version`)
- 트리거: 코어 공개 확장 표면(`app/Extension/Abstract*`, `HookManager`, `ExtensionManager`, `ModuleManager`, `PluginManager`, `TemplateManager`, `app/Contracts/Extension/**`, `app/Extension/Helpers/**`, `app/Seo/Contracts/**`, `app/ActivityLog/**` 공개 API, 루트 `CHANGELOG.md` Added/Changed/Removed) 수정
- 트리거: 코어 공개 확장 표면(`app/Extension/Abstract*`, `HookManager`, `ExtensionManager`, `ModuleManager`, `PluginManager`, `TemplateManager`, `app/Contracts/Extension/**`, `app/Extension/Helpers/**`, `app/Repositories/Concerns/**`, `app/Seo/Contracts/**`, `app/ActivityLog/**` 공개 API, 루트 `CHANGELOG.md` Added/Changed/Removed) 수정
- 조치: 영향 받는 번들 확장의 `g7_version` 상향 + 각 확장 CHANGELOG 에 변경 기재
### ② 확장 → 확장 동기화 (`dependencies.{modules|plugins}`)
@@ -355,6 +356,44 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
> 상세: [validation.md "계층 리소스 순환 참조" / "배열 항목의 상위 스코프"](docs/backend/validation.md), [service-repository.md "중첩 리소스 스코프" / "설정 기반 한계값"](docs/backend/service-repository.md)
### 목록 조회 컬럼 프루닝과 지연 조인
| 금지 | 올바른 사용 |
|------|------------|
| `->paginate($perPage)` / `->paginate($perPage, ['*'])` (컬럼 목록 미지정) | 목록이 실제로 쓰는 컬럼만 명시. 깊은 OFFSET 이 가능한 목록은 `PaginatesWithDeferredJoin` |
| 요청 값에서 온 정렬 컬럼을 그대로 `orderBy` 에 전달 | `ResolvesSortSpec` 으로 닫힌 집합 해석 (방향만 검사하는 `in_array` 는 보호가 아니다) |
| 지연 조인의 `$query` 에 미리 `orderBy`/`with`/`select` 적용 | 필터/where 만 적용해 넘기고, 정렬·관계·컬럼은 trait 인자로 전달 |
| 쿼리에 `with()` 만 하고 `relations:` 인자 생략 | 관계는 `relations:` 로 전달 — trait 이 inner 뿐 아니라 **outer 에서도** eager load 를 지우므로 관계가 조용히 사라진다 (예외·쿼리 오류 없이 응답에서 필드만 없어져 관계를 단언하지 않는 테스트는 전부 통과) |
| 목록 SELECT 에 `SUBSTRING(content, 1, N)` 을 두고 프루닝했다고 간주 | 오버플로 페이지 읽기가 그대로 발생 — 잘라내기는 outer(`$columns`)에서만 |
| 그룹 쿼리(`groupBy`)의 총 건수를 `count()` 로 계산 | `getCountForPagination()` (서브쿼리로 감싸 그룹 수를 센다) |
| raw SQL 안에 테이블명·별칭을 문자열로 조립 | 테이블명은 `(new Model)->getTable()`, 프리픽스는 `DB::getTablePrefix()`, 별칭은 빌더(`join($table.' as uc', …)`)가 만들게 |
| `whereRaw('1 = 0')` / `where($c, DB::raw("({$sub->toSql()})"))` + `mergeBindings` | `whereIn($key, [])` / `where($c, '=', $sub)` (빌더가 바인딩까지 처리) |
| 화면 정렬 셀렉트에 게이트가 모르는 컬럼을 넣기 | `화면 옵션 ⊆ FormRequest 게이트 ⊆ Repository 화이트리스트` — 어긋나면 422 후 직전 목록이 남아 정렬된 것처럼 보인다 |
| 분류값 필터의 허용 어휘를 화면·게이트·기록 지점에 각각 리터럴로 적기 | 어휘는 Enum 단일 출처에서 파생 — `화면 필터 옵션 = 라벨 키 = 실제 기록 어휘`. 부분집합이 되면 빠진 값으로 기록된 행이 어떤 필터 조합으로도 도달 불가하고, 라벨 키가 없는 값은 목록 셀에 원시 키 문자열로 노출된다 |
| 목록 응답이 `last_page > 1` 인데 화면에 페이저·총건수가 없음 | 페이지 이동 컨트롤과 총건수를 함께 노출 — 없으면 1페이지 밖 행이 조용히 잘리고, 잘렸다는 사실조차 화면에 나타나지 않는다 |
| 쿼리 파라미터 불리언에 `boolean` 규칙만 부착 | `prepareForValidation()` 으로 `"true"`/`"false"` 정규화 (쿼리는 문자열로 도착 — 화면이 그 형태로 보내면 목록 전체가 422). 단, 해석 불가한 값은 건드리지 말 것 |
| 같은 화면의 개수 배지와 그 배지가 여는 목록이 서로 다른 엔드포인트 | 배지 계산과 목록 조회는 같은 스코프의 같은 데이터소스에서 |
| 관계 테이블 컬럼 정렬을 `join`+`groupBy` 로 구현 | `SortsByRelatedColumn` 의 상관 서브쿼리 (1:N 조인은 원 행을 부풀려 총 건수·페이지 경계를 깨고, INNER 는 자식 없는 행을 지운다) |
| 관계 정렬을 넣고 인덱스는 그대로 | `(외래키, 정렬컬럼)` 복합 인덱스 마이그레이션 동반 (서브쿼리가 행마다 실행된다) |
| 페이지네이션 정렬을 비고유 컬럼(`created_at` 등)으로만 끝내기 | 정렬 마지막에 기본키를 덧붙여 전순서 보장 (동률 구간에서 인접 페이지가 같은 행을 중복 노출하고 다른 행을 누락한다) |
> 상세: [service-repository.md "목록 조회 컬럼 프루닝과 지연 조인" / "정렬 컬럼 화이트리스트" / "화면 정렬 옵션은 게이트의 부분집합이어야 한다" / "관계 테이블 컬럼 기준 정렬" / "허용되는 Raw 쿼리"](docs/backend/service-repository.md)
### 검색 인덱스 재생성(리인덱싱)
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| 재생성을 자동 트리거(마이그레이션 종료·확장 업데이트 완료 등)에 연결 | 운영자가 **명시적으로 선택**했을 때만 수행. 인덱스 잠금·전체 재색인 비용이 운영 중 사이트를 멈춘다 |
| 재생성 체크 상태를 전역에 남겨 다음 모달 진입에 이월 | 모달 진입 시드와 제출 후 초기화 **양쪽**에서 해제. 이월되면 운영자가 아무것도 누르지 않았는데 재생성이 수행되고, 서버 옵인 가드는 정상이라 HTTP 테스트로는 드러나지 않는다 |
| 모듈·플러그인 모달이 같은 전역 키 공유 | 면마다 별도 키 (한쪽 체크가 다른 쪽으로 전이 금지) |
| 응답 헬퍼가 `JsonResource::resolve()` 만 호출해 `additional()` 유실 | 부가 데이터를 응답 최상위에 병합 — 색인 누락은 오류 없이 "검색 0건" 으로만 나타나므로 응답 페이로드가 유일한 통로다 |
| 재생성 수행을 곧 복구로 간주 | `remaining` 은 **재생성 후 재점검** 결과 — "재생성했다" 와 "복구됐다" 를 구분해 보고 |
| 점검 커맨드의 비-0 종료를 "실행 실패" 로 표시 | 이상 발견 신호다. 종료 코드와 출력을 그대로 노출 |
| 특정 엔진(FULLTEXT) 전용으로 점검·재생성 구현 | `SearchIndexMaintainer` 계약 + `core.search.index_maintainers` 훅 |
| "점검 대상 0" 과 "점검 불가" 를 같은 문구로 보고 | 구분 보고 — 뭉뚱그리면 "인덱스가 다 정상" 으로 읽힌다 |
> 상세: [search-system.md](docs/backend/search-system.md)
### Listener 데이터 접근
| 금지 | 올바른 사용 |
@@ -1040,6 +1079,7 @@ php artisan migrate:rollback
| `lang/**` | [database-guide.md](docs/database-guide.md) (다국어 섹션) |
| `routes/**` | [routing.md](docs/backend/routing.md) |
| `app/Seo/**` | [seo-system.md](docs/backend/seo-system.md) |
| `app/Benchmark/**`, `config/benchmark.php` | [benchmark.md](docs/backend/benchmark.md) |
---
@@ -1072,3 +1112,8 @@ php artisan migrate:rollback
- **ResolvesActivityLogType**: `app/ActivityLog/Traits/ResolvesActivityLogType.php`
- **ChangeDetector**: `app/ActivityLog/ChangeDetector.php`
- **CoreActivityLogListener**: `app/Listeners/CoreActivityLogListener.php`
- **BenchmarkProfileRegistry**: `app/Benchmark/BenchmarkProfileRegistry.php`
- **성능 계측 DTO**: `app/Benchmark/DTO/{BenchmarkProfile,BenchmarkRunOptions,BenchmarkResult}.php`
- **BenchmarkAxisRunner**: `app/Benchmark/Contracts/BenchmarkAxisRunner.php`
- **성능 계측 축 실행기**: `app/Benchmark/Axes/{List,Screen,Write,Batch}AxisRunner.php`
- **BenchmarkAxis**: `app/Enums/BenchmarkAxis.php`
+30 -2
View File
@@ -47,9 +47,29 @@
- 사이트에서 쓰기로 한 언어의 언어팩을 한 번에 채워 넣는 기능을 추가했습니다. 한국어·영어 외 언어(일본어 등)는 설치되어 있어야 화면에 표시되는데, 설치가 빠져 있으면 오류 없이 한국어로 대체되어 운영자가 눈치채기 어려웠습니다. 이제 언어팩 관리 목록과 명령으로 "설치가 필요한 언어"를 한 번에 채울 수 있고, 여러 번 실행해도 안전합니다.
- 언어팩 관리 목록이 "설치되어 있다고 표시되지만 실제 파일이 없어 다른 언어로 대체되는" 상태를 눈에 띄게 보여주고, 그 자리에서 다시 설치할 수 있는 버튼을 제공합니다. 예전에는 목록에 정상으로 보이는데도 실제로는 번역이 적용되지 않는 경우를 발견하기 어려웠습니다. 파일이 사라진 언어팩은 화면의 다시 설치 버튼으로도, 언어팩을 채우는 명령으로도 복구할 수 있습니다.
#### 목록 성능
- 검색 인덱스가 실제로 내용을 담고 있는지 점검하고, 비어 있으면 다시 만들 수 있는 기능을 추가했습니다. 인덱스가 비면 검색은 오류 없이 "결과 없음" 만 돌려주기 때문에 그동안은 알아차릴 방법이 없었습니다. 관리자 > 개발 도구에서 「검색 인덱스 점검」 으로 확인할 수 있고, `php artisan search:index` 로도 실행됩니다. 점검·재생성 방식은 검색 엔진마다 다르므로, 다른 검색 엔진을 쓰는 확장도 자기 방식의 점검을 등록해 같은 화면에서 함께 다룰 수 있습니다.
- 다시 만들기는 **선택할 때만** 실행됩니다. 인덱스를 다시 만드는 동안에는 해당 인덱스가 잠기거나 전체 재색인이 일어나 운영 중인 사이트에 영향을 주기 때문입니다. 확장·코어 업데이트 화면과 명령어에 「업데이트 후 검색 인덱스 재생성」 선택 항목을 두었고, 선택하지 않아도 색인이 비어 있으면 그 사실은 알려 드립니다. 사이트 규모가 크다면 선택하지 말고 접속이 적은 시간에 따로 실행하시길 권합니다.
- 업데이트 화면의 「검색 인덱스 재생성」 선택이 그 창에서만 유효하도록 바로잡았습니다. 예전에는 한 번 선택하면 창을 닫거나 업데이트를 마친 뒤에도 선택이 남아 있어, 다음 확장을 업데이트할 때 아무것도 고르지 않았는데 인덱스 재생성이 다시 실행됐습니다.
- 확장을 업데이트하면 색인이 비어 있는 검색 인덱스가 있는지 결과가 함께 전달되도록 바로잡았습니다. 예전에는 점검은 이루어졌지만 그 결과가 화면까지 오지 않아 운영자가 알 수 없었습니다.
- 모듈·플러그인 업데이트 완료 안내에 확장 이름과 버전이 표시되지 않던 문제를 수정했습니다.
- 모듈·플러그인 목록의 총 개수와 페이지 이동이 동작하지 않던 문제를 수정했습니다. 설치된 확장이 12개를 넘으면 2페이지 이후 항목이 화면에 나타나지 않았습니다.
- `php artisan module:check-updates` 등 업데이트 확인 명령이 업데이트가 있는 확장을 「최신」 으로 표시하고 요약도 "업데이트 가능 0개" 로 알리던 문제를 수정했습니다.
- 통신이 끊겨 요청이 전송되지 못했을 때 내부 식별 문구가 그대로 표시되던 것을 네트워크 오류 안내로 바꿨습니다.
- 개발 도구에서 점검 명령이 이상을 발견해 알려 준 경우를 「실행 실패」 로 표시하던 것을 종료 코드 안내로 바꿨습니다. 점검은 정상 수행된 것이며, 발견된 내용은 출력에 그대로 표시됩니다.
- 목록 화면이 뒤쪽 페이지로 갈수록 느려지는 문제를 구조적으로 해결할 수 있도록, 확장이 함께 쓸 수 있는 공통 조회 방식을 코어에 추가했습니다. 목록을 두 단계(먼저 이번 페이지에 해당하는 항목만 추려내고, 그 항목에 대해서만 본문·상세 정보를 읽기)로 나눠 읽으므로 게시글·주문·로그가 수십만 건으로 늘어나도 마지막 페이지 조회 비용이 첫 페이지와 비슷하게 유지됩니다. 목록 정렬 기준을 미리 정해 둔 항목으로만 해석하는 공통 처리도 함께 제공하므로, 확장이 목록 조회를 직접 만들 때 정렬 처리를 매번 새로 구현하지 않아도 됩니다. 주문 목록의 「발송일」처럼 정렬 기준이 다른 표에 있는 값일 때도 총 건수와 페이지 경계가 어긋나지 않게 조회하는 공통 처리를 함께 넣었습니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
#### 성능 점검
- 확장이 자기 성능 측정 대상을 직접 등록할 수 있게 했습니다. 모듈·플러그인이 자신의 목록 화면, 관리자 화면, 저장 동작, 정기 작업 중 속도를 재고 싶은 것을 선언해 두면, 사이트 관리자가 성능 점검을 실행할 때 코어 항목과 함께 측정됩니다. 확장을 설치하면 그 확장의 측정 대상이 자동으로 목록에 나타나고, 제거하면 함께 사라집니다. 화면 측정은 응답 시간과 함께 그 화면이 실행한 데이터베이스 조회 횟수를 보여주므로, 목록 자체는 빠른데 화면이 느린 원인을 찾을 수 있습니다.
### Changed
- 활동 로그·알림 발송 이력·본인인증 기록·스케줄 실행 이력·회원 목록을 뒤쪽 페이지에서도 빠르게 열 수 있도록 조회 방식을 바꿨습니다. 예전에는 페이지가 뒤로 갈수록 건너뛰는 기록의 본문·변경 내역까지 함께 읽어 느려졌지만, 이제 현재 페이지에 해당하는 기록만 상세 정보를 읽습니다. 화면에 보이는 내용은 이전과 동일합니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
- 확장이 업로드 허용 형식을 넓힐 수 있도록 `core.attachment.allowed_extensions` 필터를 추가했습니다. 관리자 설정 목록을 기준으로, 확장이 자기 기능에 필요한 형식만 덧붙일 수 있습니다.
- 활동 로그·알림 발송 이력·회원 목록의 정렬 색인을 정비했습니다. 같은 시각에 쌓인 기록이 많은 구간에서 순서를 정하느라 생기던 추가 작업이 사라져, 기록이 많은 사이트일수록 목록이 빨라집니다. 기존 사이트도 업데이트 시 자동으로 반영되며, 기록이 아주 많은 경우 이 과정에 수 분이 걸리고 그동안 해당 기록의 저장이 잠시 대기할 수 있습니다.
- 확장이 검증 규칙을 추가할 때 쓰는 필터 이름 10개를 나머지 92개와 같은 규칙(`{대상}.{동작}_validation_rules`)으로 통일했습니다. 본인인증 메시지와 알림 관련 화면이 대상이며, 예전 이름(`...filter_index_rules` 형태)을 사용하던 확장은 새 이름으로 바꿔야 검증 규칙 추가가 계속 동작합니다. 코어와 기본 제공 확장에는 이 이름을 사용하던 곳이 없어 영향이 없습니다.
- 같은 규칙에서 벗어나 있던 필터 이름 6개(플러그인 설정 저장, 비밀번호 재설정 토큰 확인, 비밀번호 확인, 변경사항 조회, 통합 검색, 프로필 사진 업로드)를 추가로 통일했습니다. 이번에는 예전 이름도 함께 계속 발행하므로 기존 확장은 수정 없이 그대로 동작하며, 예전 이름을 사용 중인 확장이 있으면 서버 기록에 한 번 안내가 남습니다. 새로 만드는 확장은 새 이름을 사용하세요.
@@ -87,6 +107,11 @@
#### 확장·데이터베이스 안정성
- 관리자 역할 관리·언어팩 관리·알림 목록에서 같은 시각에 등록된 항목이 여러 건일 때, 페이지를 넘기면 같은 항목이 두 번 보이고 다른 항목은 아예 보이지 않던 문제를 수정했습니다. 이제 페이지를 모두 넘겨도 목록에 있는 항목이 빠짐없이 한 번씩만 나옵니다.
- 메뉴 관리 목록에서 검색 조건을 하나도 걸지 않은 채 정렬만 바꾸면 순서가 그대로이던 문제를 수정했습니다. 검색 조건이 있을 때만 정렬이 반영되고 있었으며, 이제 조건 유무와 관계없이 선택한 정렬이 적용됩니다.
- 메뉴 관리 검색에서 한글 메뉴명으로 찾으면 결과가 나오지 않던 문제를 수정했습니다. 이제 한국어·영어 어느 쪽 메뉴명으로도 검색되고, 이름 정렬도 화면에 보이는 글자 기준으로 정렬됩니다.
- 목록의 열 제목을 눌렀을 때 지금 보고 있는 페이지의 줄만 순서가 바뀌면서 전체가 정렬된 것처럼 보이던 문제를 수정했습니다. 페이지를 나눠 불러오는 목록에서 열 제목 정렬을 지원하지 않는 화면은 이제 정렬 표시를 띄우지 않으므로, 정렬 결과를 잘못 읽을 일이 없습니다. 정렬은 화면 위쪽의 정렬 선택으로 이용할 수 있습니다.
- 언어팩 관리 목록이 모듈의 내장 번역을 실제 화면에 쓰이지 않는 위치 기준으로 보여주던 문제를 수정했습니다. 그 결과 일부 모듈의 한국어·영어 내장 번역이 목록에 아예 나오지 않았고, 번역 대상으로 표시된 문구를 고쳐도 화면에 반영되지 않았습니다. 이제 목록과 실제 화면의 문구 출처가 같습니다.
- 데이터베이스 연결이 일시적으로 끊기거나 확장 설치가 중간에 실패한 직후 확장 목록을 다시 만들면, 설치된 모듈·플러그인 정보가 통째로 비워지면서 게시판·쇼핑몰 등 모든 확장 화면이 500 오류를 내던 문제를 수정했습니다. 이제 확장 폴더가 그대로 남아 있는데 목록만 비어 있으면 기존 정보를 지우지 않고 보존하며, 왜 갱신하지 않았는지 로그에 남깁니다.
- 확장 목록이 손상된 상태에서 복구 명령을 실행하면 목록은 되살아나지만 확장 기능 연결이 비어 있는 채로 남아, 명령을 두 번 실행해야 완전히 복구되던 문제를 수정했습니다. 이제 한 번 실행으로 모두 복구됩니다.
- 데이터베이스에서 일부 컬럼의 설명이 비어 있던 문제를 바로잡았습니다. 회원·작성자 등 다른 표를 가리키는 컬럼이 대상이며, 업그레이드하면 이미 운영 중인 데이터베이스에도 설명이 채워집니다(직접 적어 넣은 설명은 그대로 둡니다).
@@ -111,6 +136,11 @@
- 일부 안내가 실제 문장 대신 내부 식별자(`messages.auth.unauthenticated` 같은 문자열)로 표시되던 문제를 수정했습니다. 로그인하지 않았거나 역할 권한이 없어 요청이 거절될 때, 메뉴에 역할을 지정할 때, 확장 식별자를 잘못 입력했을 때, 활동 로그를 기간·유형으로 검색할 때가 해당됩니다.
- 환경설정 저장이 실패했을 때 안내 문구에 항목 이름이 `security.password min length` 같은 내부 식별자로 표시되던 문제를 수정했습니다. 보안·업로드·SEO·고급·드라이버·메일 등 모든 설정 항목이 화면에 보이는 한글 항목명으로 안내됩니다.
- 플러그인을 GitHub 주소나 ZIP 파일로 설치할 때의 입력 안내가 내부 식별자로 표시되던 문제를 수정했습니다. 주소 형식이 잘못됐거나, ZIP이 아닌 파일을 올렸거나, 파일 용량을 초과했을 때 정상 문장으로 안내합니다.
- 목록의 열 제목을 눌러 정렬할 때, 이미 다른 열로 정렬 중이었다면 화살표는 새로 누른 열에 표시되면서 실제 정렬은 이전 열 기준으로 바뀌던 문제를 수정했습니다. 표시와 실제 정렬 기준이 어긋나 결과를 신뢰할 수 없었으며, 이제 누른 열 기준으로 한 번에 정렬됩니다.
- 목록에서 뒤쪽 페이지를 보던 중 정렬을 바꾸면 이전 페이지 번호에 그대로 머물러, 정렬을 바꿨는데 엉뚱한 구간이 보이거나 빈 목록이 나오던 문제를 수정했습니다. 이제 정렬을 바꾸면 항상 첫 페이지부터 보여줍니다. 사용자 관리·게시판 신고현황·페이지 관리 목록이 해당됩니다.
- 사용자 관리 목록에서 "페이지당 개수"가 10개로 표시되는데 실제로는 15개가 나오던 문제를 수정했습니다. 표시된 값과 실제 목록 건수가 일치합니다. 일정 관리 목록에서도 표시가 20개 목록을 10개로 잘못 안내하던 문제를 함께 바로잡았습니다.
- 알림 발송 이력에서 정렬을 "수신자명순" 또는 "제목순"으로 바꾸면 목록이 비면서 오류 안내가 뜨던 문제를 수정했습니다. 화면이 제공하는 정렬인데 서버가 거부하고 있었으며, 이제 두 정렬 모두 정상 동작합니다. 많은 이력에서도 빠르게 정렬되도록 색인을 함께 추가했습니다.
- 마이페이지 활동 목록에서 한 페이지에 표시할 개수를 0이나 음수로 요청하면 서버 오류가 나던 문제를 수정했습니다. 이제 허용 범위(1~100)로 자동 조정됩니다.
- 레이아웃 편집기 화면이 정상 동작하는데도 서버가 "페이지 없음"(404)으로 응답하던 문제를 수정했습니다. 화면은 그대로 보였지만 브라우저 캐싱과 서버 접속 기록에서 없는 페이지로 취급되던 상태입니다.
- 주소 끝에 슬래시가 붙은 형태(예: `/admin/`)로 접속하면 화면이 "페이지 없음"(404)으로 뜨던 문제를 수정했습니다. 특히 로그인이 풀린 상태에서 이렇게 접속하면 로그인 화면으로 넘어가지 않고 404가 나타났습니다. 이제 끝 슬래시가 있어도 정상적으로 같은 페이지로 연결됩니다.
@@ -1491,7 +1521,6 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
- 휴대폰 인증, 아이핀 인증 등 본인인증 기능 제공
- 사용자 관리 화면 레이아웃 확장 (본인인증 상세/폼)
- 역할 기반 접근 제어 (본인인증 관리자)
- 다국어 지원 (ko, en)
#### 템플릿: sirsoft-admin_basic (관리자)
@@ -1527,7 +1556,6 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
- CMS 페이지 표시 화면
- 에러 페이지 (403, 404, 500, 503)
- 점검 모드(maintenance) 전용 페이지
- 반응형 레이아웃 (데스크톱/태블릿/모바일)
#### 다국어 시스템 (i18n)
+141
View File
@@ -0,0 +1,141 @@
<?php
namespace App\Benchmark\Axes;
use App\Benchmark\Contracts\BenchmarkAxisRunner;
use App\Benchmark\DTO\BenchmarkProfile;
use App\Benchmark\DTO\BenchmarkResult;
use App\Benchmark\DTO\BenchmarkRunOptions;
use App\Benchmark\QueryCollector;
use App\Enums\BenchmarkAxis;
use Illuminate\Support\Facades\Artisan;
use Symfony\Component\Console\Output\BufferedOutput;
/**
* 배치 축 실행기 — 배치 커맨드 소요 시간 + 피크 메모리
*
* 배치는 한 번에 대량을 처리하므로 "느려짐"이 시간보다 메모리로 먼저 드러납니다(청크
* 없이 전건 로딩 → OOM). 그래서 시간과 함께 피크 메모리를 함께 잽니다.
*
* 다른 축과 달리 트랜잭션으로 감싸지 않습니다 — 배치 커맨드는 내부에서 커밋하거나 DDL 을
* 실행할 수 있고, 대량 처리를 긴 트랜잭션에 담으면 락 보유 시간이 계측 자체보다 위험해집니다.
* 대신 데이터를 변경하는 배치는 `--allow-write` 없이는 실행하지 않습니다.
*/
class BatchAxisRunner implements BenchmarkAxisRunner
{
public function __construct(private readonly QueryCollector $collector) {}
/**
* {@inheritDoc}
*/
public function axis(): BenchmarkAxis
{
return BenchmarkAxis::Batch;
}
/**
* {@inheritDoc}
*/
public function run(BenchmarkProfile $profile, BenchmarkRunOptions $options, ?\Closure $onProgress = null): BenchmarkResult
{
$notify = $onProgress ?? static fn (string $message) => null;
if ($profile->mutates() && ! $options->allowWrite) {
return BenchmarkResult::skipped($profile, '데이터를 변경하는 배치입니다. --allow-write 를 붙여 실행하세요.');
}
$command = (string) $profile->option('command');
$arguments = (array) $profile->option('arguments', []);
if (! array_key_exists($command, Artisan::all())) {
return BenchmarkResult::skipped($profile, "등록되지 않은 커맨드: {$command} (해당 확장이 설치되어 있는지 확인)");
}
$notify("배치 실행: {$command}");
// 피크 메모리는 프로세스 단위 누적값이라 감소하지 않는다. 실행 전 값을 함께
// 기록해야 "이 배치가 피크를 밀어올렸는지"를 판정할 수 있다.
$peakBefore = memory_get_peak_usage(true);
$usageBefore = memory_get_usage(true);
$buffer = new BufferedOutput;
$start = microtime(true);
try {
$collected = $this->collector->collect(
static fn () => Artisan::call($command, $arguments, $buffer)
);
$exitCode = (int) $collected['value'];
$queries = $collected['queries'];
} catch (\Throwable $e) {
return BenchmarkResult::skipped($profile, '계측 실패: '.$e->getMessage());
}
$elapsedMs = (microtime(true) - $start) * 1000;
$peakAfter = memory_get_peak_usage(true);
$usageAfter = memory_get_usage(true);
$summary = $this->collector->summarize($queries);
$notes = [
sprintf('실행 전 피크 %s → 실행 후 피크 %s', $this->formatBytes($peakBefore), $this->formatBytes($peakAfter)),
];
if ($peakAfter <= $peakBefore) {
$notes[] = '이 배치가 프로세스 피크를 밀어올리지 않았습니다 (실행 전 피크가 이미 더 높음).';
}
if ($exitCode !== 0) {
$notes[] = "커맨드가 실패 종료했습니다 (exit={$exitCode}) — 시간·메모리는 실패 지점까지의 값입니다.";
}
$output = trim($buffer->fetch());
if ($output !== '') {
$notes[] = '커맨드 출력: '.$output;
}
return new BenchmarkResult(
profile: $profile,
headers: ['커맨드', '종료코드', '소요(ms)', '피크 메모리', '메모리 증가', '쿼리(건)'],
rows: [[
$command,
(string) $exitCode,
number_format($elapsedMs, 1),
$this->formatBytes($peakAfter),
$this->formatBytes(max(0, $usageAfter - $usageBefore)),
number_format($summary['count']),
]],
metrics: [
'command' => $command,
'arguments' => $arguments,
'exit_code' => $exitCode,
'elapsed_ms' => round($elapsedMs, 2),
'peak_memory_before_bytes' => $peakBefore,
'peak_memory_after_bytes' => $peakAfter,
'memory_delta_bytes' => $usageAfter - $usageBefore,
'query_count' => $summary['count'],
'db_ms' => $summary['db_ms'],
],
notes: $notes,
);
}
/**
* 바이트를 사람이 읽는 단위로 바꿉니다.
*
* @param int $bytes 바이트
* @return string 포맷된 문자열
*/
private function formatBytes(int $bytes): string
{
if ($bytes >= 1024 ** 3) {
return number_format($bytes / 1024 ** 3, 2).' GB';
}
if ($bytes >= 1024 ** 2) {
return number_format($bytes / 1024 ** 2, 1).' MB';
}
return number_format($bytes / 1024, 1).' KB';
}
}
+350
View File
@@ -0,0 +1,350 @@
<?php
namespace App\Benchmark\Axes;
use App\Benchmark\Contracts\BenchmarkAxisRunner;
use App\Benchmark\DTO\BenchmarkProfile;
use App\Benchmark\DTO\BenchmarkResult;
use App\Benchmark\DTO\BenchmarkRunOptions;
use App\Benchmark\SyntheticSeeder;
use App\Enums\BenchmarkAxis;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 목록 조회 축 실행기 — 깊은 OFFSET 비용을 컬럼 폭 3축으로 계측
*
* 목록 조회는 OFFSET 이 커질수록 느려지는데, 그 원인이 "건너뛸 행의 넓은 컬럼까지 읽기"
* 인지 확인하려면 같은 필터·정렬로 (1) 전체 컬럼 (2) 실제 목록 컬럼 (3) 키 컬럼만 조회한
* 비용을 나란히 재야 합니다. 셋째 축이 지연 조인(`PaginatesWithDeferredJoin`)의 inner
* 쿼리에 해당하므로, 이 세 값의 배수가 곧 지연 조인 적용의 기대 효과입니다.
*/
class ListAxisRunner implements BenchmarkAxisRunner
{
/**
* 목록 1페이지 상한 (OFFSET 스캔 비용 대비 무시할 수준이라 고정)
*/
private const PAGE_LIMIT = 20;
/**
* 필터 선언에 쓸 수 있는 연산자 (닫힌 집합)
*
* @var array<int, string>
*/
private const FILTER_OPERATORS = ['=', '!=', '<>', '<', '<=', '>', '>=', 'like', 'in', 'not in'];
public function __construct(private readonly SyntheticSeeder $seeder) {}
/**
* {@inheritDoc}
*/
public function axis(): BenchmarkAxis
{
return BenchmarkAxis::ListQuery;
}
/**
* {@inheritDoc}
*/
public function run(BenchmarkProfile $profile, BenchmarkRunOptions $options, ?\Closure $onProgress = null): BenchmarkResult
{
$notify = $onProgress ?? static fn (string $message) => null;
$table = (string) $profile->option('table');
if (! Schema::hasTable($table)) {
return BenchmarkResult::skipped($profile, "테이블이 없습니다: {$table} (해당 확장이 설치되어 있는지 확인)");
}
if (($options->fresh || $options->seed > 0) && app()->environment('production')) {
return BenchmarkResult::skipped($profile, '운영 환경에서는 시딩/비움을 사용할 수 없습니다.');
}
$filterError = $this->validateFilters((array) $profile->option('filters', []));
if ($filterError !== null) {
return BenchmarkResult::skipped($profile, $filterError);
}
if ($options->fresh) {
$this->seeder->truncate($table);
$notify("비움: {$table}");
}
$columns = $this->existingColumns($table, (array) $profile->option('columns', ['*']));
$order = $this->existingOrder($table, (array) $profile->option('order', [['id', 'desc']]));
if ($options->seed > 0) {
$notify("시딩: {$table} ".number_format($options->seed).' 건');
$this->seeder->seed(
$table,
$options->seed,
(array) $profile->option('seed_overrides', []),
fn (int $inserted, int $total) => $notify(sprintf(' 시딩 %s / %s', number_format($inserted), number_format($total))),
// 정렬 기준 컬럼은 nullable 이어도 채운다 — 비워두면 합성 행이 전부 동률이 되어
// 계측이 정렬 인덱스가 아니라 tie-break 경로를 잰다.
array_column($order, 0),
);
}
$rows = [];
$notes = [];
foreach ($options->offsets as $offset) {
$allMs = $this->measure($profile, ['*'], $order, $offset, $options->runs);
$listMs = $this->measure($profile, $columns, $order, $offset, $options->runs);
$idOnlyMs = $this->measure($profile, ['id'], $order, $offset, $options->runs);
$rows[] = [
'offset' => $offset,
'all_ms' => $allMs,
'list_ms' => $listMs,
'id_only_ms' => $idOnlyMs,
'ratio' => $idOnlyMs > 0 ? round($allMs / $idOnlyMs, 1) : null,
];
if ($options->explain) {
$notes[] = "EXPLAIN @ OFFSET {$offset} — 목록 컬럼";
foreach ($this->explain($profile, $columns, $order, $offset) as $line) {
$notes[] = ' '.$line;
}
// 지연 조인의 inner 가 실제로 어떤 계획을 타는지가 인덱스 설계의 근거다
$notes[] = "EXPLAIN @ OFFSET {$offset} — ID 만 (지연 조인 inner)";
foreach ($this->explain($profile, ['id'], $order, $offset) as $line) {
$notes[] = ' '.$line;
}
}
}
return new BenchmarkResult(
profile: $profile,
headers: ['OFFSET', '전체 컬럼(ms)', '목록 컬럼(ms)', 'ID만(ms)', '전체÷ID'],
rows: array_map(fn (array $row) => [
number_format($row['offset']),
number_format($row['all_ms'], 1),
number_format($row['list_ms'], 1),
number_format($row['id_only_ms'], 1),
$row['ratio'] !== null ? $row['ratio'].'×' : '-',
], $rows),
metrics: [
'table' => $table,
'columns' => $columns,
'runs' => $options->runs,
'offsets' => $rows,
],
notes: $notes,
);
}
/**
* 스키마에 실제로 존재하는 컬럼만 남깁니다.
*
* `['*']` 는 "목록이 전 컬럼을 그대로 노출한다" 는 선언입니다. 응답 계약상 넓은 컬럼을
* 뺄 수 없는 목록(활동 로그의 changes, 알림 발송 이력의 body 등)이 여기 해당하며, 이
* 경우 계측의 비교 축은 select * vs select id 가 됩니다. 확장 버전에 따라 컬럼 구성이
* 달라도 계측이 죽지 않도록 스키마에 없는 컬럼은 걸러냅니다.
*
* @param string $table 테이블명
* @param array<int, string> $columns 후보 컬럼
* @return array<int, string> 존재하는 컬럼 목록
*/
private function existingColumns(string $table, array $columns): array
{
if ($columns === ['*']) {
return Schema::getColumnListing($table);
}
$existing = array_values(array_filter(
$columns,
fn (mixed $column) => is_string($column) && Schema::hasColumn($table, $column)
));
return $existing === [] ? ['id'] : $existing;
}
/**
* 스키마에 존재하는 정렬 컬럼만 남깁니다.
*
* @param string $table 테이블명
* @param array<int, array{0: string, 1: string}> $order 후보 정렬
* @return array<int, array{0: string, 1: string}> 적용 가능한 정렬 목록
*/
private function existingOrder(string $table, array $order): array
{
$existing = array_values(array_filter(
$order,
fn (mixed $spec) => is_array($spec) && isset($spec[0]) && Schema::hasColumn($table, (string) $spec[0])
));
return $existing === [] ? [['id', 'desc']] : $existing;
}
/**
* 한 조합을 여러 번 실행해 중앙값(ms)을 돌려줍니다.
*
* `DB::enableQueryLog()` 는 오버헤드와 메모리 누적이 있어 쓰지 않고 직접 시간을 잽니다.
*
* @param BenchmarkProfile $profile 프로파일
* @param array<int, string> $columns select 컬럼
* @param array<int, array{0: string, 1: string}> $order 정렬
* @param int $offset OFFSET
* @param int $runs 측정 횟수
* @return float 중앙값 (밀리초)
*/
private function measure(BenchmarkProfile $profile, array $columns, array $order, int $offset, int $runs): float
{
// 첫 회는 캐시 워밍 성격이라 버린다
$this->buildQuery($profile, $columns, $order, $offset)->get();
$samples = [];
for ($i = 0; $i < max(1, $runs); $i++) {
$start = microtime(true);
$this->buildQuery($profile, $columns, $order, $offset)->get();
$samples[] = (microtime(true) - $start) * 1000;
}
sort($samples);
return $samples[intdiv(count($samples), 2)];
}
/**
* 계측 대상 쿼리를 조립합니다.
*
* @param BenchmarkProfile $profile 프로파일
* @param array<int, string> $columns select 컬럼
* @param array<int, array{0: string, 1: string}> $order 정렬
* @param int $offset OFFSET
* @return Builder 조립된 쿼리
*/
private function buildQuery(BenchmarkProfile $profile, array $columns, array $order, int $offset): Builder
{
$table = (string) $profile->option('table');
$query = DB::table($table)->select($columns);
if ($profile->option('soft_delete', false) && Schema::hasColumn($table, 'deleted_at')) {
$query->whereNull('deleted_at');
}
$this->applyFilters($query, $table, (array) $profile->option('filters', []));
foreach ($order as $spec) {
$query->orderBy($spec[0], $spec[1] ?? 'asc');
}
return $query->offset($offset)->limit(self::PAGE_LIMIT);
}
/**
* 선언된 필터를 계측 쿼리에 적용합니다.
*
* 값이 `[연산자, 값]` 형태면 그 연산자로, 그 밖에는 등가 비교로 해석합니다. 등가 비교만
* 지원하면 화면이 실제로 거는 필터를 선언할 방법이 없는 목록이 생깁니다 — 주문 목록은
* 상태 미지정 시 임시 주문 상태를 `NOT IN` 으로 제외하므로, 그것을 선언하지 못하면
* 계측이 화면과 다른 인덱스를 타게 됩니다.
*
* 연산자는 닫힌 집합으로 해석합니다. 임의 문자열을 그대로 넘기면 선언 오타가 조용히
* 통과하거나 의도치 않은 SQL 이 조립됩니다.
*
* @param Builder $query 계측 쿼리
* @param string $table 대상 테이블
* @param array<string, mixed> $filters 선언된 필터
*/
private function applyFilters(Builder $query, string $table, array $filters): void
{
foreach ($filters as $column => $declared) {
if (! Schema::hasColumn($table, (string) $column)) {
continue;
}
// [연산자, 값] 형태가 아니면 등가 비교 (배열 값을 IN 으로 오해석하지 않도록 형태로 판정)
if (! is_array($declared) || count($declared) !== 2 || ! is_string($declared[0])) {
$query->where($column, $declared);
continue;
}
[$operator, $value] = $declared;
match (strtolower($operator)) {
'=' => $query->where($column, '=', $value),
'!=', '<>' => $query->where($column, '!=', $value),
'<' => $query->where($column, '<', $value),
'<=' => $query->where($column, '<=', $value),
'>' => $query->where($column, '>', $value),
'>=' => $query->where($column, '>=', $value),
'like' => $query->where($column, 'like', $value),
'in' => $query->whereIn($column, (array) $value),
'not in' => $query->whereNotIn($column, (array) $value),
// 도달 불가 — run() 이 실행 전에 연산자를 검증해 거부한다
default => null,
};
}
}
/**
* 선언된 필터의 연산자를 실행 전에 검증합니다.
*
* 알 수 없는 연산자를 조용히 무시하면 그 필터가 빠진 채로 측정되어, 화면과 다른 것을
* 재면서도 정상 측정으로 보고됩니다. 실행 전에 사유와 함께 거부합니다.
*
* @param array<string, mixed> $filters 선언된 필터
* @return string|null 실패 사유 (문제 없으면 null)
*/
private function validateFilters(array $filters): ?string
{
foreach ($filters as $column => $declared) {
if (! is_array($declared) || count($declared) !== 2 || ! is_string($declared[0])) {
continue;
}
if (! in_array(strtolower($declared[0]), self::FILTER_OPERATORS, true)) {
return sprintf(
'필터 %s 의 연산자를 알 수 없습니다: %s (허용: %s)',
$column,
$declared[0],
implode(', ', self::FILTER_OPERATORS)
);
}
}
return null;
}
/**
* 실행 계획을 수집합니다.
*
* @param BenchmarkProfile $profile 프로파일
* @param array<int, string> $columns select 컬럼
* @param array<int, array{0: string, 1: string}> $order 정렬
* @param int $offset OFFSET
* @return array<int, string> 실행 계획 요약 줄 목록
*/
private function explain(BenchmarkProfile $profile, array $columns, array $order, int $offset): array
{
$query = $this->buildQuery($profile, $columns, $order, $offset);
try {
$plan = DB::select('EXPLAIN '.$query->toSql(), $query->getBindings());
} catch (\Throwable $e) {
return ['실행 계획 수집 실패: '.$e->getMessage()];
}
return array_map(function ($row) {
$row = (array) $row;
return sprintf(
'type=%s key=%s rows=%s filtered=%s extra=%s',
$row['type'] ?? '-',
$row['key'] ?? '-',
$row['rows'] ?? '-',
$row['filtered'] ?? '-',
$row['Extra'] ?? '-'
);
}, $plan);
}
}
+261
View File
@@ -0,0 +1,261 @@
<?php
namespace App\Benchmark\Axes;
use App\Benchmark\BenchmarkIdentity;
use App\Benchmark\Contracts\BenchmarkAxisRunner;
use App\Benchmark\DTO\BenchmarkProfile;
use App\Benchmark\DTO\BenchmarkResult;
use App\Benchmark\DTO\BenchmarkRunOptions;
use App\Benchmark\QueryCollector;
use App\Enums\BenchmarkAxis;
use Illuminate\Contracts\Http\Kernel as HttpKernel;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
/**
* 화면 응답 축 실행기 — 화면 1장의 응답 시간 + 실행 쿼리 건수 + N+1 후보
*
* 사용자가 체감하는 단위가 "화면 한 장"이므로 네 축 중 값어치가 가장 큽니다. 목록 SELECT
* 는 빠른데 화면이 느린 경우(관계 지연 로딩으로 인한 N+1, 권한 조회 반복 등)를 잡는 것이
* 이 축의 목적입니다.
*
* 실행 방식은 라우트를 **HTTP 커널로 내부 요청** 처리하는 것입니다. `Route::dispatch` 로
* 라우트만 때리면 전역 미들웨어(인증·권한·로케일·타임존)를 건너뛰어 화면과 다른 것을 재게
* 됩니다. 커널을 쓰면 실제 요청과 같은 경로를 지납니다.
*
* 계측 계정 생성과 요청 처리 전체를 롤백되는 트랜잭션으로 감싸므로, 계측이 데이터에 흔적을
* 남기지 않습니다(`BenchmarkIdentity::withRolledBackTransaction`).
*/
class ScreenAxisRunner implements BenchmarkAxisRunner
{
public function __construct(
private readonly BenchmarkIdentity $identity,
private readonly QueryCollector $collector,
) {}
/**
* {@inheritDoc}
*/
public function axis(): BenchmarkAxis
{
return BenchmarkAxis::Screen;
}
/**
* {@inheritDoc}
*/
public function run(BenchmarkProfile $profile, BenchmarkRunOptions $options, ?\Closure $onProgress = null): BenchmarkResult
{
if ($profile->mutates() && ! $options->allowWrite) {
return BenchmarkResult::skipped($profile, '데이터를 변경하는 화면입니다. --allow-write 를 붙여 실행하세요.');
}
[$uri, $uriError] = $this->resolveUri($profile);
if ($uriError !== null) {
return BenchmarkResult::skipped($profile, $uriError);
}
// `--as` 로 지정한 계정은 트랜잭션 진입 전에 확인한다 — 없는 계정으로 계측을 시작하면
// 롤백 구간 안에서 실패해 사유만 흐려진다.
if ($options->asUser !== null && $this->identity->findExistingUser($options->asUser) === null) {
return BenchmarkResult::skipped($profile, "계측에 사용할 계정을 찾을 수 없습니다: {$options->asUser}");
}
$method = strtoupper((string) $profile->option('method', 'GET'));
$query = (array) $profile->option('query', []);
$query['per_page'] ??= $options->perPage;
// 데이터를 변경하는 화면은 반복 측정 시 2회차부터 조건이 달라진다(중복 키, 재고 소진).
// 롤백은 계측 종료 시 한 번이므로 회차 간에는 되돌려지지 않는다 → 1회만 잰다.
$runs = $profile->mutates() ? 1 : max(1, $options->runs);
$notes = [];
if ($profile->mutates()) {
$notes[] = '데이터 변경 화면이라 1회만 측정했습니다 (회차 간 조건 변화 방지).';
}
try {
$measured = $this->identity->withRolledBackTransaction(
fn () => $this->measure($profile, (string) $uri, $method, $query, $runs, $options, $onProgress)
);
} catch (\Throwable $e) {
return BenchmarkResult::skipped($profile, '계측 실패: '.$e->getMessage());
}
$summary = $measured['summary'];
foreach ($summary['n_plus_one'] as $candidate) {
$notes[] = sprintf('N+1 후보 %d회: %s', $candidate['count'], $candidate['sql']);
}
if ($summary['n_plus_one'] === []) {
$notes[] = 'N+1 후보 없음 (같은 SQL 5회 이상 반복 없음).';
}
return new BenchmarkResult(
profile: $profile,
headers: ['요청', '상태', '응답(ms)', '쿼리(건)', 'DB(ms)'],
rows: [[
$method.' '.$uri,
(string) $measured['status'],
number_format($measured['total_ms'], 1),
number_format($summary['count']),
number_format($summary['db_ms'], 1),
]],
metrics: [
'uri' => $uri,
'method' => $method,
'query' => $query,
'status' => $measured['status'],
'runs' => $runs,
'total_ms' => $measured['total_ms'],
'query_count' => $summary['count'],
'db_ms' => $summary['db_ms'],
'n_plus_one' => $summary['n_plus_one'],
'acting_user' => $measured['acting_user'],
],
notes: $notes,
);
}
/**
* 계측 대상 URI 를 해석합니다.
*
* 라우트명 선언을 권장하는 이유는 두 가지입니다 — 프리픽스를 문자열로 조립하지 않아도
* 되고, 라우트가 사라지면 계측이 조용히 404 를 재는 대신 사유를 남기고 건너뜁니다.
*
* 예외를 던지지 않고 사유 문자열을 함께 돌려주는 이유는, 이 축의 실패 계약이
* `BenchmarkResult::skipped` 이기 때문입니다 — 던지고 곧바로 잡는 우회로를 만들지 않습니다.
*
* @param BenchmarkProfile $profile 프로파일
* @return array{0: string|null, 1: string|null} [경로, 실패 사유]
*/
private function resolveUri(BenchmarkProfile $profile): array
{
$routeName = $profile->option('route');
if (is_string($routeName) && $routeName !== '') {
if (Route::getRoutes()->getByName($routeName) === null) {
return [null, "등록되지 않은 라우트명: {$routeName} (해당 확장이 설치되어 있는지 확인)"];
}
return [route($routeName, (array) $profile->option('route_params', []), false), null];
}
return ['/'.ltrim((string) $profile->option('uri'), '/'), null];
}
/**
* 내부 요청을 반복 실행해 응답 시간 중앙값과 쿼리 요약을 냅니다.
*
* @param BenchmarkProfile $profile 프로파일
* @param string $uri 경로
* @param string $method HTTP 메서드
* @param array<string, mixed> $query 쿼리스트링
* @param int $runs 측정 횟수
* @param BenchmarkRunOptions $options 실행 옵션
* @param \Closure|null $onProgress 진행 콜백
* @return array{status: int, total_ms: float, summary: array<string, mixed>, acting_user: array<string, mixed>} 계측 결과
*/
private function measure(
BenchmarkProfile $profile,
string $uri,
string $method,
array $query,
int $runs,
BenchmarkRunOptions $options,
?\Closure $onProgress,
): array {
$notify = $onProgress ?? static fn (string $message) => null;
$issued = $this->identity->issueToken(
(array) $profile->option('permissions', []),
$options->asUser !== null ? $this->identity->findExistingUser($options->asUser) : null
);
$notify(sprintf(
'인증: %s (%s)',
$issued['user']->email,
$issued['ephemeral'] ? '계측용 임시 계정 — 종료 시 롤백' : '지정 계정'
));
$samples = [];
$status = 0;
$queries = [];
// 첫 회는 라우트 매칭·컨테이너 해석 워밍이라 버린다 (측정 회차와 별도로 1회 더 실행)
for ($i = 0; $i <= $runs; $i++) {
$collected = $this->collector->collect(
fn () => $this->dispatch($uri, $method, $query, $issued['token'])
);
/** @var array{status: int, ms: float} $outcome */
$outcome = $collected['value'];
$status = $outcome['status'];
if ($i === 0) {
continue;
}
$samples[] = $outcome['ms'];
$queries = $collected['queries'];
}
sort($samples);
return [
'status' => $status,
'total_ms' => $samples[intdiv(count($samples), 2)],
'summary' => $this->collector->summarize($queries),
'acting_user' => [
'id' => $issued['user']->id,
'email' => $issued['user']->email,
'ephemeral' => $issued['ephemeral'],
],
];
}
/**
* 내부 요청 1회를 처리하고 소요 시간을 잽니다.
*
* 커널이 컨테이너의 `request` 인스턴스와 인증 가드 상태를 갈아치우므로, 처리 후
* 원상 복구합니다 — 복구하지 않으면 이어지는 회차/프로파일이 앞 요청의 인증 상태를
* 물려받아 계측이 서로 오염됩니다.
*
* @param string $uri 경로
* @param string $method HTTP 메서드
* @param array<string, mixed> $query 쿼리스트링/바디
* @param string $token Sanctum 토큰
* @return array{status: int, ms: float} 상태 코드와 소요 시간
*/
private function dispatch(string $uri, string $method, array $query, string $token): array
{
$previousRequest = app()->bound('request') ? app('request') : null;
$request = Request::create($uri, $method, $query);
$request->headers->set('Accept', 'application/json');
$request->headers->set('Authorization', 'Bearer '.$token);
$start = microtime(true);
try {
$response = app(HttpKernel::class)->handle($request);
$status = $response->getStatusCode();
// 스트리밍 응답은 본문 생성까지가 사용자 체감 시간이라 여기서 소비한다
$response->getContent();
} finally {
$ms = (microtime(true) - $start) * 1000;
Auth::forgetGuards();
if ($previousRequest !== null) {
app()->instance('request', $previousRequest);
}
}
return ['status' => $status, 'ms' => $ms];
}
}
+227
View File
@@ -0,0 +1,227 @@
<?php
namespace App\Benchmark\Axes;
use App\Benchmark\BenchmarkIdentity;
use App\Benchmark\Contracts\BenchmarkAxisRunner;
use App\Benchmark\DTO\BenchmarkProfile;
use App\Benchmark\DTO\BenchmarkResult;
use App\Benchmark\DTO\BenchmarkRunOptions;
use App\Benchmark\QueryCollector;
use App\Enums\BenchmarkAxis;
/**
* 쓰기 축 실행기 — 저장 경로 1회 소요 시간
*
* 목록 조회와 달리 저장 경로는 커맨드가 스스로 조립할 수 없습니다(주문 생성 하나에 재고·
* 쿠폰·마일리지·알림이 얽힘). 그래서 실행 자체는 소유 확장이 선언한 콜백에 맡기고, 이
* 실행기는 시간·쿼리 건수 계측과 안전장치만 담당합니다.
*
* 콜백은 `'Fqcn'`(invokable) 또는 `['Fqcn', 'method']` 형식만 허용합니다 — 코어 선언은
* `config/benchmark.php` 에 있고 이 파일은 `config:cache` 대상이라 클로저를 담을 수 없기
* 때문입니다. 확장 선언도 같은 스키마를 쓰므로 형식을 통일합니다.
*
* 선언 필드는 세 개입니다 — `prepare`(계측 제외 선행 준비, 회차마다 실행), `callback`
* (계측 대상, prepare 반환값을 인자로 받음), `cleanup`(트랜잭션이 되돌리지 못하는 잔여물 정리).
*
* 계측으로 생긴 행은 롤백되는 트랜잭션으로 되돌립니다. 트랜잭션이 되돌리지 못하는 것
* (파일·캐시·외부 호출)은 프로파일이 선언한 `cleanup` 콜백이 정리합니다.
*/
class WriteAxisRunner implements BenchmarkAxisRunner
{
public function __construct(
private readonly BenchmarkIdentity $identity,
private readonly QueryCollector $collector,
) {}
/**
* {@inheritDoc}
*/
public function axis(): BenchmarkAxis
{
return BenchmarkAxis::Write;
}
/**
* {@inheritDoc}
*/
public function run(BenchmarkProfile $profile, BenchmarkRunOptions $options, ?\Closure $onProgress = null): BenchmarkResult
{
$notify = $onProgress ?? static fn (string $message) => null;
if (! $options->allowWrite) {
return BenchmarkResult::skipped($profile, '쓰기 축입니다. --allow-write 를 붙여 실행하세요.');
}
$resolved = [];
foreach (['callback', 'prepare', 'cleanup'] as $field) {
if ($field !== 'callback' && $profile->option($field) === null) {
$resolved[$field] = null;
continue;
}
[$callable, $error] = $this->resolveCallable($profile->option($field), $profile, $field);
if ($error !== null) {
return BenchmarkResult::skipped($profile, $error);
}
$resolved[$field] = $callable;
}
$callback = $resolved['callback'];
$prepare = $resolved['prepare'];
$cleanup = $resolved['cleanup'];
$runs = max(1, $options->runs);
try {
$measured = $this->identity->withRolledBackTransaction(
fn () => $this->measure($callback, $prepare, $runs, $onProgress)
);
} catch (\Throwable $e) {
return BenchmarkResult::skipped($profile, '계측 실패: '.$e->getMessage());
} finally {
if ($cleanup !== null) {
// 트랜잭션 롤백으로 되돌지 않는 잔여물(파일·캐시)을 정리한다.
// 정리 실패가 계측 결과를 삼키면 안 되므로 사유만 알린다.
try {
$cleanup();
} catch (\Throwable $e) {
$notify('cleanup 실패: '.$e->getMessage());
}
}
}
$summary = $this->collector->summarize($measured['queries']);
return new BenchmarkResult(
profile: $profile,
headers: ['첫 회(ms)', '중앙값(ms)', '회차', '쿼리(건)', 'DB(ms)'],
rows: [[
number_format($measured['first_ms'], 1),
number_format($measured['median_ms'], 1),
(string) $runs,
number_format($summary['count']),
number_format($summary['db_ms'], 1),
]],
metrics: [
'first_ms' => $measured['first_ms'],
'median_ms' => $measured['median_ms'],
'runs' => $runs,
'samples_ms' => $measured['samples'],
'query_count' => $summary['count'],
'db_ms' => $summary['db_ms'],
'n_plus_one' => $summary['n_plus_one'],
],
notes: array_merge(
['계측으로 생긴 행은 롤백했습니다.'],
array_map(
fn (array $candidate) => sprintf('반복 쿼리 %d회: %s', $candidate['count'], $candidate['sql']),
$summary['n_plus_one']
)
),
);
}
/**
* 선언된 콜백을 호출 가능한 형태로 해석합니다.
*
* 예외를 던지지 않고 사유 문자열을 함께 돌려주는 이유는, 이 축의 실패 계약이
* `BenchmarkResult::skipped` 이기 때문입니다 — 던지고 곧바로 잡는 우회로를 만들지 않습니다.
*
* @param mixed $declared 선언값
* @param BenchmarkProfile $profile 프로파일 (오류 메시지용)
* @param string $field 선언 필드명 (오류 메시지용)
* @return array{0: callable|null, 1: string|null} [해석된 콜백, 실패 사유]
*/
private function resolveCallable(mixed $declared, BenchmarkProfile $profile, string $field): array
{
$where = $profile->qualifiedKey().' 의 '.$field;
if ($declared instanceof \Closure) {
return [null, "{$where}: 클로저는 쓸 수 없습니다 (config:cache 불가). 'Fqcn' 또는 ['Fqcn', 'method'] 형식으로 선언하세요."];
}
if (is_string($declared)) {
if (! class_exists($declared)) {
return [null, "{$where}: 클래스를 찾을 수 없습니다 — {$declared}"];
}
$instance = app($declared);
if (! is_callable($instance)) {
return [null, "{$where}: {$declared} 에 __invoke() 가 없습니다."];
}
return [$instance, null];
}
if (is_array($declared) && count($declared) === 2 && is_string($declared[0]) && is_string($declared[1])) {
[$class, $method] = $declared;
if (! class_exists($class)) {
return [null, "{$where}: 클래스를 찾을 수 없습니다 — {$class}"];
}
$instance = app($class);
if (! method_exists($instance, $method)) {
return [null, "{$where}: {$class}::{$method}() 가 없습니다."];
}
return [[$instance, $method], null];
}
return [null, "{$where}: 'Fqcn' 또는 ['Fqcn', 'method'] 형식이어야 합니다."];
}
/**
* 콜백을 반복 실행해 첫 회 시간과 중앙값을 냅니다.
*
* 첫 회를 버리지 않고 따로 보고하는 이유는, 저장 경로에서는 첫 회가 포함하는 비용
* (클래스 로딩, 설정 해석, 관계 초기 조회)이 실사용에서도 발생하기 때문입니다.
* 쿼리 요약은 마지막 회차 기준입니다 — 첫 회는 위 초기화 쿼리가 섞입니다.
*
* `prepare` 는 계측 구간 **밖**에서 회차마다 실행합니다. 저장 경로에는 선행 상태가
* 필요한 경우가 많고(주문 생성에는 임시 주문이 필요하고 임시 주문은 1회만 전환됨),
* 그 준비 비용이 측정값에 섞이면 재려던 것을 재지 못하게 됩니다.
*
* @param callable $callback 저장 경로 콜백 (prepare 반환값을 인자로 받음)
* @param callable|null $prepare 회차별 선행 준비 콜백 (계측 제외, 회차 번호를 인자로 받음)
* @param int $runs 측정 횟수
* @param \Closure|null $onProgress 진행 콜백
* @return array{first_ms: float, median_ms: float, samples: array<int, float>, queries: array<int, array{sql: string, time: float}>} 계측 결과
*/
private function measure(callable $callback, ?callable $prepare, int $runs, ?\Closure $onProgress): array
{
$notify = $onProgress ?? static fn (string $message) => null;
$samples = [];
$queries = [];
for ($i = 1; $i <= $runs; $i++) {
$notify("쓰기 계측 {$i} / {$runs}");
$context = $prepare !== null ? $prepare($i) : null;
$start = microtime(true);
$collected = $this->collector->collect(static function () use ($callback, $context) {
$callback($context);
});
$samples[] = (microtime(true) - $start) * 1000;
$queries = $collected['queries'];
}
$sorted = $samples;
sort($sorted);
return [
'first_ms' => $samples[0],
'median_ms' => $sorted[intdiv(count($sorted), 2)],
'samples' => $samples,
'queries' => $queries,
];
}
}
+144
View File
@@ -0,0 +1,144 @@
<?php
namespace App\Benchmark;
use App\Enums\ExtensionOwnerType;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* 화면 계측용 인증 주체 해석기
*
* 관리자 화면의 응답 시간은 로그인 상태에서만 잴 수 있습니다. 두 방식을 지원합니다.
*
* 기본 — 프로파일이 선언한 권한만 가진 임시 관리자를 즉석 생성해 Sanctum 토큰 발급
* `--as` — 기존 계정을 지정 (그 계정의 실제 역할/권한으로 재측정)
*
* 두 경로 모두 계정/역할/토큰 생성이라는 **쓰기**를 동반하므로, 호출자가 열어둔 트랜잭션
* 안에서 수행하고 계측 후 롤백해 흔적을 남기지 않습니다(`withRolledBackTransaction`).
* 트랜잭션이 열려 있으면 Laravel 이 읽기 쿼리도 write PDO 로 보내므로(`Connection::getReadPdo`
* 의 `transactions > 0` 분기), 읽기/쓰기 분리 환경에서도 계측 요청이 이 계정을 인증할 수
* 있습니다. 운영 DB 에서도 잔여 계정이 생기지 않는 이유가 이 롤백입니다.
*/
class BenchmarkIdentity
{
/**
* 롤백되는 트랜잭션 안에서 콜백을 실행합니다.
*
* 계측이 만든 계정·토큰과, 계측 대상이 만든 행까지 함께 되돌립니다. 쓰기 축의 결과를
* 되돌리는 것도 같은 장치를 씁니다.
*
* @param \Closure $callback 트랜잭션 안에서 실행할 작업
* @return mixed 콜백 반환값
*/
public function withRolledBackTransaction(\Closure $callback): mixed
{
DB::beginTransaction();
try {
return $callback();
} finally {
// 계측 결과 산출 여부와 무관하게 되돌린다 — 예외로 빠져나가도 잔여 데이터 없음
DB::rollBack();
}
}
/**
* 계측에 사용할 Sanctum 토큰을 발급합니다.
*
* 호출 전에 `--as` 계정 존재를 `findExistingUser()` 로 확인해야 합니다 — 계정 부재는
* 계측 실패가 아니라 실행 전 판정 대상이라 이 메서드는 그 경우를 다루지 않습니다.
*
* @param array<int, string> $permissions 임시 계정에 부여할 권한 식별자 목록
* @param User|null $actor `--as` 로 확인된 기존 계정 (null 이면 임시 계정 생성)
* @return array{token: string, user: User, ephemeral: bool} 토큰과 인증 주체
*/
public function issueToken(array $permissions = [], ?User $actor = null): array
{
$user = $actor ?? $this->makeEphemeralAdmin($permissions);
return [
'token' => $user->createToken('g7-bench-'.Str::random(8))->plainTextToken,
'user' => $user,
'ephemeral' => $actor === null,
];
}
/**
* 지정된 기존 계정을 찾습니다.
*
* @param string $identifier 계정 ID 또는 이메일
* @return User|null 찾은 계정 (없으면 null)
*/
public function findExistingUser(string $identifier): ?User
{
return ctype_digit($identifier)
? User::find((int) $identifier)
: User::where('email', $identifier)->first();
}
/**
* 선언된 권한만 가진 임시 관리자를 생성합니다.
*
* 권한을 프로파일 선언에서 받는 이유는, 계측 대상 화면이 요구하는 권한만 주어야
* 미들웨어 통과 여부까지 실제와 같아지기 때문입니다(전권 계정으로 재면 권한 검사
* 비용과 분기가 달라집니다).
*
* @param array<int, string> $permissions 권한 식별자 목록
* @return User 생성된 임시 관리자
*/
private function makeEphemeralAdmin(array $permissions): User
{
$user = User::factory()->create();
$permissionIds = [];
foreach ($permissions as $identifier) {
$identifier = (string) $identifier;
$permission = Permission::firstOrCreate(
['identifier' => $identifier],
[
'name' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'description' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
]
);
$permissionIds[] = $permission->id;
}
$benchRole = Role::create([
'identifier' => 'g7_bench_'.Str::random(8),
'name' => json_encode(['ko' => '성능 계측 전용', 'en' => 'Benchmark Only']),
'description' => json_encode(['ko' => '성능 계측 임시 역할', 'en' => 'Temporary benchmark role']),
'is_active' => true,
]);
$adminRole = Role::firstOrCreate(
['identifier' => 'admin'],
[
'name' => json_encode(['ko' => '관리자', 'en' => 'Admin']),
'description' => json_encode(['ko' => '시스템 관리자', 'en' => 'System Admin']),
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
'is_active' => true,
]
);
if ($permissionIds !== []) {
$benchRole->permissions()->sync($permissionIds);
}
$user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
$user->roles()->attach($benchRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
return $user->fresh();
}
}
+275
View File
@@ -0,0 +1,275 @@
<?php
namespace App\Benchmark;
use App\Benchmark\DTO\BenchmarkProfile;
use App\Contracts\Extension\ModuleManagerInterface;
use App\Contracts\Extension\PluginManagerInterface;
use App\Enums\BenchmarkAxis;
/**
* 계측 프로파일 레지스트리 — 코어 config + 활성 확장 선언 수집
*
* 계측 대상은 "지금 이 설치본에 실제로 존재하는 것"이어야 하므로, 커맨드에 대상을
* 하드코딩하지 않고 소유자가 선언한 것을 런타임에 모읍니다. 코어는
* `config/benchmark.php`, 확장은 `getBenchmarkProfiles()` 오버라이드가 선언 지점입니다
* (채널·알림 정의 등 기존 확장 선언 훅과 동일한 수집 모델).
*
* 확장 선언은 격리 호출합니다 — 확장 하나의 잘못된 선언이 전체 목록을 날리면 계측 자체를
* 못 하게 되므로, 실패한 선언만 사유와 함께 `warnings()` 로 드러내고 나머지는 살립니다.
* 조용히 버리지는 않습니다(버려진 선언이 곧 계측 사각이 됩니다).
*/
class BenchmarkProfileRegistry
{
/**
* 수집된 프로파일 (정규화 키 → 프로파일)
*
* @var array<string, BenchmarkProfile>|null
*/
private ?array $profiles = null;
/**
* 수집 중 발생한 선언 오류 메시지
*
* @var array<int, string>
*/
private array $warnings = [];
public function __construct(
private readonly ModuleManagerInterface $moduleManager,
private readonly PluginManagerInterface $pluginManager,
) {}
/**
* 모든 프로파일을 정규화 키 기준으로 반환합니다.
*
* @return array<string, BenchmarkProfile> 정규화 키 → 프로파일
*/
public function all(): array
{
if ($this->profiles !== null) {
return $this->profiles;
}
$profiles = [];
foreach ($this->collectCore() as $profile) {
$profiles[$profile->qualifiedKey()] = $profile;
}
foreach ($this->collectExtensions() as $profile) {
$profiles[$profile->qualifiedKey()] = $profile;
}
return $this->profiles = $profiles;
}
/**
* 특정 축의 프로파일만 반환합니다.
*
* @param BenchmarkAxis $axis 대상 축
* @return array<string, BenchmarkProfile> 정규화 키 → 프로파일
*/
public function byAxis(BenchmarkAxis $axis): array
{
return array_filter($this->all(), fn (BenchmarkProfile $profile) => $profile->axis === $axis);
}
/**
* 키로 프로파일을 해석합니다.
*
* 정규화 키(`sirsoft-ecommerce/orders`)를 우선 대조하고, 없으면 짧은 키로 찾습니다.
* 짧은 키가 둘 이상의 확장에서 선언돼 모호하면 후보 목록을 사유로 돌려줍니다 —
* 임의로 하나를 고르면 어느 확장의 목록을 잰 것인지 알 수 없게 됩니다.
*
* @param string $key 프로파일 키 (정규화 키 또는 짧은 키)
* @return array{0: BenchmarkProfile|null, 1: string|null} [찾은 프로파일, 실패 사유]
*/
public function resolve(string $key): array
{
$profiles = $this->all();
if (isset($profiles[$key])) {
return [$profiles[$key], null];
}
$matches = array_filter($profiles, fn (BenchmarkProfile $profile) => $profile->key === $key);
if ($matches === []) {
return [null, "등록되지 않은 프로파일: {$key} (--list-profiles 로 목록 확인)"];
}
if (count($matches) > 1) {
$candidates = implode(', ', array_keys($matches));
return [null, "프로파일 키가 모호합니다: {$key} — 다음 중 하나로 지정하세요: {$candidates}"];
}
return [reset($matches), null];
}
/**
* 키로 프로파일을 찾습니다. (해석 실패 시 null)
*
* 사유가 필요하면 `resolve()` 를 씁니다.
*
* @param string $key 프로파일 키 (정규화 키 또는 짧은 키)
* @return BenchmarkProfile|null 찾은 프로파일
*/
public function find(string $key): ?BenchmarkProfile
{
return $this->resolve($key)[0];
}
/**
* 수집 중 무시된 선언의 사유 목록을 반환합니다.
*
* @return array<int, string> 경고 메시지
*/
public function warnings(): array
{
$this->all();
return $this->warnings;
}
/**
* 코어 프로파일을 수집합니다.
*
* @return array<int, BenchmarkProfile> 코어 프로파일 목록
*/
private function collectCore(): array
{
$declared = config('benchmark.profiles', []);
return $this->normalize(is_array($declared) ? $declared : [], 'core', 'core');
}
/**
* 활성 모듈/플러그인 프로파일을 수집합니다.
*
* @return array<int, BenchmarkProfile> 확장 프로파일 목록
*/
private function collectExtensions(): array
{
$profiles = [];
foreach ($this->moduleManager->getActiveModules() as $module) {
$profiles = array_merge(
$profiles,
$this->normalize($this->extract($module), 'module', $module->getIdentifier())
);
}
foreach ($this->pluginManager->getActivePlugins() as $plugin) {
$profiles = array_merge(
$profiles,
$this->normalize($this->extract($plugin), 'plugin', $plugin->getIdentifier())
);
}
return $profiles;
}
/**
* 확장의 `getBenchmarkProfiles()` 선언을 격리 호출로 읽습니다.
*
* @param object $extension 확장 인스턴스
* @return array<string, mixed> 선언 배열 (실패 시 빈 배열)
*/
private function extract(object $extension): array
{
if (! method_exists($extension, 'getBenchmarkProfiles')) {
return [];
}
try {
$declared = $extension->getBenchmarkProfiles();
} catch (\Throwable $e) {
$this->warnings[] = sprintf(
'%s: getBenchmarkProfiles() 호출 실패 — %s',
method_exists($extension, 'getIdentifier') ? $extension->getIdentifier() : $extension::class,
$e->getMessage()
);
return [];
}
return is_array($declared) ? $declared : [];
}
/**
* 선언 배열을 검증해 프로파일 객체로 정규화합니다.
*
* @param array<string, mixed> $declared 선언 배열 (키 → 정의)
* @param string $sourceKind 출처 종류 (core|module|plugin)
* @param string $sourceIdentifier 출처 식별자
* @return array<int, BenchmarkProfile> 정규화된 프로파일 목록
*/
private function normalize(array $declared, string $sourceKind, string $sourceIdentifier): array
{
$profiles = [];
foreach ($declared as $key => $definition) {
if (! is_string($key) || $key === '') {
$this->warnings[] = "{$sourceIdentifier}: 프로파일 키는 빈 문자열이 아닌 문자열이어야 합니다.";
continue;
}
if (! is_array($definition)) {
$this->warnings[] = "{$sourceIdentifier}/{$key}: 프로파일 정의는 배열이어야 합니다.";
continue;
}
$axis = BenchmarkAxis::tryFrom((string) ($definition['type'] ?? ''));
if ($axis === null) {
$this->warnings[] = sprintf(
'%s/%s: 알 수 없는 type — %s (허용: %s)',
$sourceIdentifier,
$key,
var_export($definition['type'] ?? null, true),
implode('|', BenchmarkAxis::values())
);
continue;
}
$missing = array_values(array_filter(
$axis->requiredOptions(),
fn (array $group) => array_filter(
$group,
fn (string $option) => isset($definition[$option])
) === []
));
if ($missing !== []) {
$this->warnings[] = sprintf(
'%s/%s: %s 축 필수 옵션 누락 — %s',
$sourceIdentifier,
$key,
$axis->value,
implode(', ', array_map(fn (array $group) => implode('|', $group), $missing))
);
continue;
}
$label = $definition['label'] ?? null;
unset($definition['type'], $definition['label']);
$profiles[] = new BenchmarkProfile(
key: $key,
axis: $axis,
sourceKind: $sourceKind,
sourceIdentifier: $sourceIdentifier,
options: $definition,
label: is_string($label) ? $label : null,
);
}
return $profiles;
}
}
+226
View File
@@ -0,0 +1,226 @@
<?php
namespace App\Benchmark;
use App\Benchmark\DTO\BenchmarkResult;
use App\Benchmark\DTO\BenchmarkRunOptions;
use App\Enums\BenchmarkAxis;
use Illuminate\Support\Facades\DB;
/**
* 계측 결과 출력기 — 표준출력 표 / JSON / 마크다운 리포트
*
* 축을 모르고도 출력할 수 있는 이유는 `BenchmarkResult` 가 표시용 표와 기계 판독용 수치를
* 함께 담기 때문입니다. 축이 늘어도 이 클래스는 고치지 않습니다.
*
* 마크다운 리포트에 환경 정보를 반드시 함께 적습니다 — 계측값은 실행 머신·DB 버전·
* OPcache 여부에 종속되므로, 환경이 빠진 수치는 다른 리포트와 비교할 수 없는 숫자입니다.
* 문서에 옮길 수치는 이 경로로만 산출해 눈대중 기재를 막는 것이 `--report` 의 목적입니다.
*/
class BenchmarkReporter
{
/**
* 실행 환경 정보를 수집합니다.
*
* @return array<string, string> 항목 → 값
*/
public function environment(): array
{
return [
'APP_ENV' => (string) config('app.env'),
'G7 버전' => (string) config('app.version'),
'DB 연결' => (string) config('database.default'),
'DB 스키마' => $this->databaseName(),
'DB 버전' => $this->databaseVersion(),
'PHP 버전' => PHP_VERSION,
'OPcache' => function_exists('opcache_get_status') && @opcache_get_status(false) !== false ? 'on' : 'off',
'memory_limit' => (string) ini_get('memory_limit'),
'config:cache' => app()->configurationIsCached() ? 'on' : 'off',
'실행 머신' => php_uname('s').' '.php_uname('r').' / '.php_uname('m'),
];
}
/**
* JSON 으로 직렬화합니다.
*
* @param array<int, BenchmarkResult> $results 계측 결과
* @param BenchmarkRunOptions $options 실행 옵션
* @param array<int, string> $warnings 프로파일 수집 경고
* @return string JSON 문자열
*/
public function toJson(array $results, BenchmarkRunOptions $options, array $warnings = []): string
{
return (string) json_encode([
'generated_at' => now()->toIso8601String(),
'environment' => $this->environment(),
'options' => $options->toArray(),
'warnings' => $warnings,
'results' => array_map(fn (BenchmarkResult $result) => $result->toArray(), $results),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
/**
* 마크다운 리포트를 만듭니다.
*
* @param array<int, BenchmarkResult> $results 계측 결과
* @param BenchmarkRunOptions $options 실행 옵션
* @param array<int, string> $warnings 프로파일 수집 경고
* @return string 마크다운 문서
*/
public function toMarkdown(array $results, BenchmarkRunOptions $options, array $warnings = []): string
{
$lines = [
'# G7 성능 점검 리포트',
'',
'- 생성 시각: '.now()->toDateTimeString(),
'',
'## 실행 환경',
'',
'| 항목 | 값 |',
'| ------ | ------ |',
];
foreach ($this->environment() as $name => $value) {
$lines[] = "| {$name} | {$value} |";
}
$lines[] = '';
$lines[] = '계측값은 위 환경에 종속됩니다. 다른 리포트와 비교할 때는 환경이 같은지 먼저 확인하세요.';
$lines[] = '';
$lines[] = '## 실행 조건';
$lines[] = '';
$lines[] = '| 항목 | 값 |';
$lines[] = '| ------ | ------ |';
foreach ($options->toArray() as $name => $value) {
$lines[] = sprintf('| %s | %s |', $name, $this->stringify($value));
}
if ($warnings !== []) {
$lines[] = '';
$lines[] = '## 무시된 프로파일 선언';
$lines[] = '';
foreach ($warnings as $warning) {
$lines[] = '- '.$warning;
}
}
foreach (BenchmarkAxis::cases() as $axis) {
$axisResults = array_values(array_filter(
$results,
fn (BenchmarkResult $result) => $result->profile->axis === $axis
));
if ($axisResults === []) {
continue;
}
$lines[] = '';
$lines[] = sprintf('## %s (%s)', $axis->label(), $axis->value);
foreach ($axisResults as $result) {
$lines = array_merge($lines, $this->markdownSection($result));
}
}
return implode("\n", $lines)."\n";
}
/**
* 결과 1건의 마크다운 절을 만듭니다.
*
* @param BenchmarkResult $result 계측 결과
* @return array<int, string> 마크다운 줄 목록
*/
private function markdownSection(BenchmarkResult $result): array
{
$title = $result->profile->qualifiedKey();
if ($result->profile->label !== null) {
$title .= ' — '.$result->profile->label;
}
$lines = ['', '### '.$title, ''];
if ($result->skipped) {
$lines[] = '측정하지 않음: '.$result->skipReason;
return $lines;
}
$lines[] = '| '.implode(' | ', $result->headers).' |';
$lines[] = '| '.implode(' | ', array_fill(0, count($result->headers), '------')).' |';
foreach ($result->rows as $row) {
$lines[] = '| '.implode(' | ', $row).' |';
}
if ($result->notes !== []) {
$lines[] = '';
foreach ($result->notes as $note) {
// 실행 계획/SQL 은 표 안에서 깨지므로 코드 스팬으로 감싼다
$lines[] = '- '.(str_contains($note, '|') ? '`'.$note.'`' : $note);
}
}
return $lines;
}
/**
* 옵션 값을 표에 넣을 문자열로 바꿉니다.
*
* @param mixed $value 옵션 값
* @return string 표시 문자열
*/
private function stringify(mixed $value): string
{
return match (true) {
is_bool($value) => $value ? 'true' : 'false',
is_array($value) => implode(', ', array_map(fn (mixed $item) => (string) $item, $value)),
$value === null => '-',
default => (string) $value,
};
}
/**
* 계측이 사용한 DB 스키마명을 반환합니다.
*
* 읽기/쓰기 분리 설정에서는 스키마명이 `write`/`read` 하위에만 있어 최상위 `database`
* 가 비어 있습니다. 리포트에 스키마가 비면 어느 DB 를 잰 것인지 알 수 없으므로
* 세 위치를 순서대로 확인합니다.
*
* @return string 스키마명 (확인 불가 시 '-')
*/
private function databaseName(): string
{
$connection = (string) config('database.default');
foreach (['database', 'write.database', 'read.database'] as $key) {
$name = config("database.connections.{$connection}.{$key}");
if (is_string($name) && $name !== '') {
return $name;
}
}
return '-';
}
/**
* DB 서버 버전을 조회합니다.
*
* @return string DB 버전 (조회 실패 시 '-')
*/
private function databaseVersion(): string
{
try {
$row = DB::selectOne('select version() as version');
return (string) ($row->version ?? '-');
} catch (\Throwable) {
return '-';
}
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Benchmark\Contracts;
use App\Benchmark\DTO\BenchmarkProfile;
use App\Benchmark\DTO\BenchmarkResult;
use App\Benchmark\DTO\BenchmarkRunOptions;
use App\Enums\BenchmarkAxis;
/**
* 계측 축 실행기 계약
*
* 축이 늘어날 때 커맨드를 고치지 않고 실행기를 추가하도록 분리합니다. 커맨드는
* 프로파일의 `type` 으로 실행기를 고르는 일만 합니다.
*/
interface BenchmarkAxisRunner
{
/**
* 이 실행기가 담당하는 축을 반환합니다.
*
* @return BenchmarkAxis 담당 축
*/
public function axis(): BenchmarkAxis;
/**
* 프로파일 1건을 계측합니다.
*
* @param BenchmarkProfile $profile 계측 대상
* @param BenchmarkRunOptions $options 실행 옵션
* @param \Closure|null $onProgress 진행 상황 콜백 (string $message, 시딩 등 장시간 작업 알림용)
* @return BenchmarkResult 계측 결과
*/
public function run(BenchmarkProfile $profile, BenchmarkRunOptions $options, ?\Closure $onProgress = null): BenchmarkResult;
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace App\Benchmark\DTO;
use App\Enums\BenchmarkAxis;
/**
* 계측 프로파일 — 코어 config / 확장 선언에서 수집된 계측 대상 1건 (Value Object)
*
* 레지스트리가 선언 배열을 검증해 이 객체로 정규화하고, 축 실행기가 그대로 받아 씁니다.
* 축별 옵션 스키마가 서로 다르므로 공통 필드(키·축·출처·라벨)만 프로퍼티로 승격하고
* 축 고유 옵션은 `options` 에 남깁니다 — 축이 늘 때 VO 를 고치지 않아도 되게 합니다.
*/
final readonly class BenchmarkProfile
{
/**
* @param string $key 선언된 프로파일 키 (확장 내부에서만 고유)
* @param BenchmarkAxis $axis 계측 축
* @param string $sourceKind 출처 종류 (core|module|plugin)
* @param string $sourceIdentifier 출처 식별자 (코어는 'core')
* @param array<string, mixed> $options 축 고유 옵션
* @param string|null $label 표시용 설명
*/
public function __construct(
public string $key,
public BenchmarkAxis $axis,
public string $sourceKind,
public string $sourceIdentifier,
public array $options = [],
public ?string $label = null,
) {}
/**
* 출처를 포함한 전역 고유 키를 반환합니다.
*
* 서로 다른 확장이 같은 키(`orders` 등)를 선언할 수 있으므로 충돌 시에는 이 키로
* 지목합니다. 커맨드는 짧은 키가 유일할 때만 짧은 키를 허용합니다.
*
* @return string `{출처}/{키}` 형태의 정규화 키
*/
public function qualifiedKey(): string
{
return $this->sourceIdentifier.'/'.$this->key;
}
/**
* 축 고유 옵션 값을 읽습니다.
*
* @param string $name 옵션 키
* @param mixed $default 미선언 시 기본값
* @return mixed 옵션 값
*/
public function option(string $name, mixed $default = null): mixed
{
return $this->options[$name] ?? $default;
}
/**
* 이 프로파일 실행이 데이터를 변경하는지 판정합니다.
*
* 축 기본값을 쓰되, 프로파일이 `mutating` 을 명시하면 그 선언을 따릅니다.
* `screen` 축은 GET 이 아니면 변경으로 봅니다.
*
* @return bool 데이터 변경 여부
*/
public function mutates(): bool
{
$declared = $this->options['mutating'] ?? null;
if (is_bool($declared)) {
return $declared;
}
if ($this->axis === BenchmarkAxis::Screen) {
return strtoupper((string) $this->option('method', 'GET')) !== 'GET';
}
return $this->axis->mutatesByDefault();
}
/**
* 배열로 직렬화합니다. (`--json` / 리포트 출력용)
*
* @return array<string, mixed> 직렬화 결과
*/
public function toArray(): array
{
return [
'key' => $this->key,
'qualified_key' => $this->qualifiedKey(),
'axis' => $this->axis->value,
'source' => ['kind' => $this->sourceKind, 'identifier' => $this->sourceIdentifier],
'label' => $this->label,
'options' => $this->options,
];
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Benchmark\DTO;
/**
* 계측 결과 1건 — 축 실행기의 산출물 (Value Object)
*
* 출력기(`BenchmarkReporter`)가 축을 몰라도 표/JSON/마크다운을 만들 수 있도록,
* 표시용 표(`headers`/`rows`)와 기계 판독용 수치(`metrics`)를 함께 담습니다.
* 축이 늘어날 때 출력기를 고치지 않아도 되는 지점이 여기입니다.
*/
final readonly class BenchmarkResult
{
/**
* @param BenchmarkProfile $profile 계측 대상 프로파일
* @param array<int, string> $headers 표 헤더
* @param array<int, array<int, string>> $rows 표 행 (표시용 문자열)
* @param array<string, mixed> $metrics 기계 판독용 원시 수치
* @param array<int, string> $notes 부가 설명 줄 (실행 계획, N+1 후보 등)
* @param bool $skipped 실행하지 않았는지 여부
* @param string|null $skipReason 실행하지 않은 사유
*/
public function __construct(
public BenchmarkProfile $profile,
public array $headers = [],
public array $rows = [],
public array $metrics = [],
public array $notes = [],
public bool $skipped = false,
public ?string $skipReason = null,
) {}
/**
* 실행하지 않은 결과를 만듭니다.
*
* 실패/건너뜀을 결과 목록에서 빼면 리포트가 "전부 측정됨"으로 읽히므로, 사유를 담은
* 결과로 남겨 표와 JSON 양쪽에 드러냅니다.
*
* @param BenchmarkProfile $profile 대상 프로파일
* @param string $reason 건너뛴 사유
* @return self 건너뜀 결과
*/
public static function skipped(BenchmarkProfile $profile, string $reason): self
{
return new self(profile: $profile, skipped: true, skipReason: $reason);
}
/**
* 배열로 직렬화합니다.
*
* @return array<string, mixed> 직렬화 결과
*/
public function toArray(): array
{
return [
'profile' => $this->profile->qualifiedKey(),
'axis' => $this->profile->axis->value,
'label' => $this->profile->label,
'source' => ['kind' => $this->profile->sourceKind, 'identifier' => $this->profile->sourceIdentifier],
'skipped' => $this->skipped,
'skip_reason' => $this->skipReason,
'headers' => $this->headers,
'rows' => $this->rows,
'metrics' => $this->metrics,
'notes' => $this->notes,
];
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Benchmark\DTO;
/**
* 계측 실행 옵션 — 커맨드 옵션을 축 실행기로 한 번 전달하는 값 묶음 (Value Object)
*
* 축마다 쓰는 옵션이 다르지만(offsets 는 list 축만, allowWrite 는 write/batch 축만)
* 실행기 시그니처를 하나로 두기 위해 한 객체로 모읍니다.
*/
final readonly class BenchmarkRunOptions
{
/**
* @param array<int, int> $offsets 측정할 OFFSET 목록 (list 축)
* @param int $runs 측정 횟수 (첫 회는 버림)
* @param int $seed 계측 전 합성 행 시딩 건수 (0 = 시딩 안 함, list 축)
* @param bool $fresh 시딩 전 대상 테이블 비움 (list 축)
* @param bool $explain 실행 계획 수집 (list 축)
* @param bool $allowWrite 데이터 변경 축 실행 허용
* @param string|null $asUser 계측에 사용할 기존 계정 (ID 또는 이메일, screen 축)
* @param int $perPage 목록/화면 1페이지 건수
*/
public function __construct(
public array $offsets = [0],
public int $runs = 3,
public int $seed = 0,
public bool $fresh = false,
public bool $explain = false,
public bool $allowWrite = false,
public ?string $asUser = null,
public int $perPage = 20,
) {}
/**
* 배열로 직렬화합니다. (리포트의 실행 조건 기재용)
*
* @return array<string, mixed> 직렬화 결과
*/
public function toArray(): array
{
return [
'offsets' => $this->offsets,
'runs' => $this->runs,
'seed' => $this->seed,
'fresh' => $this->fresh,
'explain' => $this->explain,
'allow_write' => $this->allowWrite,
'as_user' => $this->asUser,
'per_page' => $this->perPage,
];
}
}
+203
View File
@@ -0,0 +1,203 @@
<?php
namespace App\Benchmark;
use App\Benchmark\DTO\BenchmarkProfile;
use Illuminate\Support\Facades\Schema;
/**
* 목록 프로파일 선언에서 필요한 색인을 도출하고 스키마와 대조합니다.
*
* 배경: 지연 조인은 inner 가 키 컬럼만 읽게 만들지만, 그 inner 가 **인덱스 순서 그대로**
* 끝나야 깊은 OFFSET 이 싸진다. 정렬을 덮는 인덱스가 없으면 inner 도 filesort 로 전체를
* 훑으므로 개선 폭이 사라진다. 그런데 어떤 색인이 필요한지는 지금까지 테이블마다 사람이
* 손으로 설계했고, 맞는지 검사하는 장치가 없었다.
*
* 필요한 정보는 이미 계측 프로파일에 선언돼 있다 — `filters` / `order` / `soft_delete` 를
* 이으면 그대로 색인 레시피가 된다:
*
* (등치 필터 컬럼들 → soft_delete 면 deleted_at → 정렬 컬럼들 → 기본키)
*
* 이 클래스는 그 도출을 한 곳에 두어, 새 목록이 프로파일을 선언하는 순간 필요한 색인이
* 자동으로 드러나게 한다. 색인 설계를 사람의 기억이 아니라 이미 있는 선언에 붙이는 것이라
* 호출자가 추가로 선언할 것은 없다.
*
* 등치가 아닌 필터(`in` / `not in` / 범위)는 선행 컬럼에 넣지 않는다 — 선행 컬럼이 등치로
* 고정되지 않으면 뒤따르는 정렬 컬럼을 인덱스 순서로 쓸 수 없기 때문이다. 이 판정을 틀리면
* "색인이 있는데도 filesort" 라는, 눈으로는 구분되지 않는 상태를 정상으로 보고하게 된다.
*/
class ListIndexAdvisor
{
/** 선행 컬럼으로 쓸 수 있는 연산자 (등치만) */
private const EQUALITY_OPERATORS = ['='];
/**
* 프로파일이 요구하는 색인 선행 컬럼 목록을 도출합니다.
*
* @param array<string, mixed> $options 목록 프로파일 옵션
* @return array<int, string> 선행 컬럼 순서 (등치 → deleted_at → 정렬 → 기본키)
*/
public function requiredColumns(array $options, string $keyName = 'id'): array
{
$leading = [];
foreach (($options['filters'] ?? []) as $column => $declared) {
if (is_array($declared) && count($declared) === 2 && is_string($declared[0])) {
// [연산자, 값] 형태 — 등치만 선행 컬럼 자격이 있다
if (in_array(strtolower($declared[0]), self::EQUALITY_OPERATORS, true)) {
$leading[] = (string) $column;
}
continue;
}
// 값만 준 형태는 등가 비교
$leading[] = (string) $column;
}
if ($options['soft_delete'] ?? false) {
$leading[] = 'deleted_at';
}
foreach (($options['order'] ?? []) as $spec) {
$column = is_array($spec) ? ($spec[0] ?? null) : $spec;
if (is_string($column) && $column !== '') {
$leading[] = $column;
}
}
// 정렬 끝에 기본키가 없으면 동률 구간에서 filesort 가 남는다
if (end($leading) !== $keyName) {
$leading[] = $keyName;
}
return array_values(array_unique($leading));
}
/**
* 도출한 색인이 실제 스키마에 있는지 대조합니다.
*
* @param array<string, mixed> $options 목록 프로파일 옵션
* @return array{
* table: string|null,
* required: array<int, string>,
* satisfied_by: string|null,
* partial_by: string|null,
* status: string
* } status: satisfied | tiebreak_missing | missing | table_absent | not_applicable
*/
public function inspect(array $options, string $keyName = 'id'): array
{
$table = $options['table'] ?? null;
$required = $this->requiredColumns($options, $keyName);
$result = [
'table' => $table,
'required' => $required,
'satisfied_by' => null,
'partial_by' => null,
'status' => 'not_applicable',
];
if (! is_string($table) || $table === '') {
return $result;
}
if (! Schema::hasTable($table)) {
$result['status'] = 'table_absent';
return $result;
}
// 기본키를 뺀 형태 — 색인은 있으나 tie-break 만 빠진 경우를 구분한다.
// 이 둘은 처방이 다르다: 전자는 색인 신설, 후자는 기존 색인에 기본키를 덧붙이는 교체.
$withoutKey = $required;
if (end($withoutKey) === $keyName) {
array_pop($withoutKey);
}
foreach (Schema::getIndexes($table) as $index) {
$columns = $index['columns'] ?? [];
if ($result['satisfied_by'] === null && $this->hasPrefix($columns, $required)) {
$result['satisfied_by'] = $index['name'];
}
if ($this->hasPrefix($columns, $withoutKey)) {
// UNIQUE 색인은 값이 중복되지 않으므로 그 컬럼만으로 이미 전순서다.
// 기본키를 덧붙일 필요가 없고, 붙여도 filesort 가 줄지 않는다.
if ($result['satisfied_by'] === null && ($index['unique'] ?? false)) {
$result['satisfied_by'] = $index['name'];
}
if ($result['partial_by'] === null) {
$result['partial_by'] = $index['name'];
}
}
}
$result['status'] = match (true) {
$result['satisfied_by'] !== null => 'satisfied',
$result['partial_by'] !== null => 'tiebreak_missing',
default => 'missing',
};
return $result;
}
/**
* 프로파일에 선언된 면제 사유를 돌려줍니다.
*
* 행 수가 고정되거나 상한이 작아 색인이 불필요한 목록은 프로파일에 사유를 적어 면제한다.
* 사유 없는 면제를 허용하지 않는 이유는, 면제가 쌓이면 검사 자체가 무의미해지는데
* 사유가 없으면 나중에 그 판단이 여전히 옳은지 확인할 방법이 없기 때문이다.
*
* @param array<string, mixed> $options 목록 프로파일 옵션
* @return string|null 면제 사유 (없으면 null)
*/
public function exemptionReason(array $options): ?string
{
$reason = $options['index_exempt'] ?? null;
return is_string($reason) && trim($reason) !== '' ? trim($reason) : null;
}
/**
* 프로파일 목록을 한 번에 점검합니다.
*
* @param array<string, BenchmarkProfile> $profiles 목록 축 프로파일
* @return array<int, array<string, mixed>> 프로파일별 점검 결과
*/
public function inspectAll(array $profiles): array
{
$rows = [];
foreach ($profiles as $key => $profile) {
$options = $profile->options ?? [];
$rows[] = array_merge(
['profile' => is_string($key) ? $key : $profile->qualifiedKey()],
$this->inspect($options),
['exemption' => $this->exemptionReason($options)]
);
}
return $rows;
}
/**
* 인덱스 컬럼이 요구 목록을 선행 프리픽스로 포함하는지 판정합니다.
*
* @param array<int, string> $columns 인덱스 컬럼 순서
* @param array<int, string> $required 요구 선행 컬럼
*/
private function hasPrefix(array $columns, array $required): bool
{
if ($required === [] || count($columns) < count($required)) {
return false;
}
return array_slice(array_values($columns), 0, count($required)) === array_values($required);
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
namespace App\Benchmark;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;
/**
* 실행 쿼리 수집기 — 화면/쓰기/배치 축의 쿼리 건수·시간·N+1 후보 산출
*
* 목록 SELECT 는 빨라도 화면이 느린 대표적 원인이 N+1 이므로, 응답 시간만 재고 끝내면
* 원인을 못 찾습니다. 이 수집기는 계측 구간에서 실행된 쿼리를 모아 건수·DB 시간과, 같은
* SQL 이 반복 실행된 그룹(N+1 후보)을 함께 돌려줍니다.
*
* `DB::listen` 은 한 번 등록하면 해제할 수 없으므로, 리스너는 인스턴스당 한 번만 등록하고
* 수집 여부는 플래그로 켰다 끕니다 — 계측 구간 밖의 쿼리가 섞이지 않게 합니다.
*/
class QueryCollector
{
/**
* 같은 SQL 이 이 횟수 이상 반복되면 N+1 후보로 본다
*/
private const N_PLUS_ONE_THRESHOLD = 5;
/**
* 수집 구간 여부
*/
private bool $collecting = false;
/**
* 수집된 쿼리 (sql, time)
*
* @var array<int, array{sql: string, time: float}>
*/
private array $queries = [];
public function __construct()
{
DB::listen(function (QueryExecuted $event) {
if (! $this->collecting) {
return;
}
$this->queries[] = ['sql' => $event->sql, 'time' => (float) $event->time];
});
}
/**
* 콜백 실행 구간의 쿼리를 수집합니다.
*
* @template TReturn
*
* @param \Closure(): TReturn $callback 계측 대상 작업
* @return array{value: TReturn, queries: array<int, array{sql: string, time: float}>} 반환값과 수집 결과
*/
public function collect(\Closure $callback): array
{
$this->queries = [];
$this->collecting = true;
try {
$value = $callback();
} finally {
$this->collecting = false;
}
return ['value' => $value, 'queries' => $this->queries];
}
/**
* 수집된 쿼리를 요약합니다.
*
* @param array<int, array{sql: string, time: float}> $queries 수집 결과
* @return array{count: int, db_ms: float, n_plus_one: array<int, array{count: int, sql: string}>} 요약
*/
public function summarize(array $queries): array
{
$grouped = [];
foreach ($queries as $query) {
$grouped[$query['sql']] = ($grouped[$query['sql']] ?? 0) + 1;
}
arsort($grouped);
$candidates = [];
foreach ($grouped as $sql => $count) {
if ($count < self::N_PLUS_ONE_THRESHOLD) {
continue;
}
$candidates[] = ['count' => $count, 'sql' => $sql];
}
return [
'count' => count($queries),
'db_ms' => round(array_sum(array_column($queries, 'time')), 2),
'n_plus_one' => $candidates,
];
}
}
+234
View File
@@ -0,0 +1,234 @@
<?php
namespace App\Benchmark;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
/**
* 계측용 합성 행 시딩기
*
* 깊은 OFFSET 비용은 행 수와 행 폭이 있어야 재현되므로, 계측 전에 대상 테이블을 원하는
* 규모까지 채웁니다. 스키마를 introspect 해 NOT NULL + 기본값 없는 컬럼만 타입에 맞춰
* 채우므로 계측 대상이 늘 때마다 시더를 따라 만들지 않아도 됩니다.
*
* 합성 값은 실제 부모 행을 가리키지 않으므로 외래키 제약을 일시 해제합니다. 본 시딩이
* 재현하려는 것은 대상 테이블의 OFFSET 스캔 비용(행 수·행 폭)이고 조인은 계측 대상이
* 아니므로, 부모 무결성 없이 목적에 충분합니다 — 폐기 가능한 계측용 DB 에서만 쓰는 것을
* 전제로 하며, 운영 환경 거부는 호출자(`ListAxisRunner`)가 담당합니다.
*/
class SyntheticSeeder
{
/**
* 한 번에 삽입할 행 수
*/
private const CHUNK_SIZE = 500;
/**
* 계측용 합성 행을 시딩합니다.
*
* @param string $table 대상 테이블
* @param int $count 시딩 건수
* @param array<string, mixed> $overrides 컬럼별 고정값 (실제 조회 조건과 맞추기 위한 지정)
* @param \Closure|null $onProgress 진행 콜백 (int $inserted, int $total)
* @param array<int, string> $requiredColumns nullable 이어도 반드시 채울 컬럼 (계측이 정렬 기준으로 삼는 컬럼)
*/
public function seed(string $table, int $count, array $overrides = [], ?\Closure $onProgress = null, array $requiredColumns = []): void
{
$fillable = $this->fillableColumns($table);
$required = $this->requiredColumnDefinitions($table, $requiredColumns, $fillable);
$chunk = [];
$inserted = 0;
for ($i = 1; $i <= $count; $i++) {
$chunk[] = $this->synthesizeRow($table, array_merge($fillable, $required), $overrides, $i);
if (count($chunk) >= self::CHUNK_SIZE) {
$this->insert($table, $chunk);
$inserted += count($chunk);
$chunk = [];
if ($onProgress !== null) {
$onProgress($inserted, $count);
}
}
}
if ($chunk !== []) {
$this->insert($table, $chunk);
$inserted += count($chunk);
if ($onProgress !== null) {
$onProgress($inserted, $count);
}
}
}
/**
* 대상 테이블을 비웁니다.
*
* 다른 테이블이 FK 로 참조하면 TRUNCATE 가 거부되므로 제약을 잠시 내립니다.
*
* @param string $table 대상 테이블
*/
public function truncate(string $table): void
{
Schema::withoutForeignKeyConstraints(function () use ($table) {
DB::table($table)->truncate();
});
}
/**
* 값을 반드시 채워야 하는 컬럼 목록을 반환합니다.
*
* @param string $table 대상 테이블
* @return array<int, array<string, mixed>> Schema::getColumns() 항목 목록
*/
private function fillableColumns(string $table): array
{
return array_values(array_filter(
Schema::getColumns($table),
fn (array $column) => ! ($column['auto_increment'] ?? false)
&& ! $column['nullable']
&& ($column['default'] === null)
));
}
/**
* 계측이 정렬 기준으로 삼는 컬럼의 정의를 반환합니다.
*
* `fillableColumns()` 는 "NOT NULL + 기본값 없음" 만 남기므로, Laravel 기본 `timestamps()`
* 처럼 nullable 로 선언된 `created_at` 계열은 여기서 빠진다. 그런데 목록 프로파일 대부분이
* `created_at` 으로 정렬하므로, 그대로 두면 합성 행이 전부 NULL 동률이 되어 계측이
* 정렬 인덱스가 아니라 tie-break 경로를 재게 된다.
*
* @param string $table 대상 테이블
* @param array<int, string> $names 반드시 채울 컬럼명 목록
* @param array<int, array<string, mixed>> $already 이미 채우기로 한 컬럼 목록
* @return array<int, array<string, mixed>> 추가로 채울 컬럼 정의 목록
*/
private function requiredColumnDefinitions(string $table, array $names, array $already): array
{
if ($names === []) {
return [];
}
$covered = array_column($already, 'name');
$columns = collect(Schema::getColumns($table))->keyBy('name');
$extra = [];
foreach (array_unique($names) as $name) {
if (in_array($name, $covered, true) || ! $columns->has($name)) {
continue;
}
$column = $columns->get($name);
// auto increment 키는 DB 가 채운다
if ($column['auto_increment'] ?? false) {
continue;
}
$extra[] = $column;
}
return $extra;
}
/**
* 합성 행 1건을 만듭니다.
*
* @param string $table 대상 테이블
* @param array<int, array<string, mixed>> $fillable 채워야 하는 컬럼 목록
* @param array<string, mixed> $overrides 컬럼별 고정값
* @param int $index 행 인덱스 (고유값 생성용)
* @return array<string, mixed> 합성 행
*/
private function synthesizeRow(string $table, array $fillable, array $overrides, int $index): array
{
$row = [];
foreach ($fillable as $column) {
$row[$column['name']] = array_key_exists($column['name'], $overrides)
? $overrides[$column['name']]
: $this->synthesizeValue($column, $index);
}
// NULL 허용 컬럼이라 위 루프에서 빠졌더라도, 실제 조회 조건에 쓰이는 컬럼은
// 채워야 계측이 화면과 같은 인덱스를 타게 된다.
foreach ($overrides as $name => $value) {
if (! array_key_exists($name, $row) && Schema::hasColumn($table, $name)) {
$row[$name] = $value;
}
}
return $row;
}
/**
* 합성 행을 삽입합니다. (외래키 제약 일시 해제)
*
* @param string $table 대상 테이블
* @param array<int, array<string, mixed>> $chunk 삽입할 행 묶음
*/
private function insert(string $table, array $chunk): void
{
Schema::withoutForeignKeyConstraints(function () use ($table, $chunk) {
DB::table($table)->insert($chunk);
});
}
/**
* 컬럼 타입에 맞는 합성 값을 만듭니다.
*
* @param array<string, mixed> $column Schema::getColumns() 항목
* @param int $index 행 인덱스 (고유값 생성용)
* @return mixed 합성 값
*/
private function synthesizeValue(array $column, int $index): mixed
{
$type = strtolower((string) ($column['type_name'] ?? $column['type'] ?? 'varchar'));
return match (true) {
str_contains($type, 'int') => $index,
str_contains($type, 'decimal'), str_contains($type, 'float'), str_contains($type, 'double') => 1000,
str_contains($type, 'bool'), str_contains($type, 'tinyint') => 0,
str_contains($type, 'json') => '{}',
// 시간 컬럼을 한 값으로 채우면 정렬이 전부 동률이 되어 filesort 가 지배해 버린다.
// 정렬 인덱스 효과를 재는 것이 목적이므로 행마다 값을 분산시킨다.
str_contains($type, 'date'), str_contains($type, 'time') => now()->subMinutes($index),
str_contains($type, 'text') => str_repeat('벤치마크 본문 ', 100),
default => $this->synthesizeString($column, $index),
};
}
/**
* 문자열 컬럼의 선언 길이에 맞는 합성 값을 만듭니다.
*
* 컬럼 길이를 무시하고 고정 길이 문자열을 넣으면 `varchar(10)` 류(신고 대상 타입,
* 우편번호, 로그 타입 등)에서 "Data too long" 으로 시딩 자체가 실패한다.
*
* @param array<string, mixed> $column Schema::getColumns() 항목
* @param int $index 행 인덱스 (고유값 생성용)
* @return string 합성 문자열
*/
private function synthesizeString(array $column, int $index): string
{
$length = 40;
// type 은 'varchar(10)' 형태로 선언 길이를 담고 있다
if (preg_match('/\((\d+)\)/', (string) ($column['type'] ?? ''), $m) === 1) {
$length = max(1, (int) $m[1]);
}
// 짧은 컬럼은 고유성 확보가 우선이라 인덱스 자체를 문자열로 쓴다
if ($length <= 12) {
return substr((string) $index, -$length);
}
return substr("bench-{$index}-".Str::random(8), 0, $length);
}
}
+395
View File
@@ -0,0 +1,395 @@
<?php
namespace App\Console\Commands;
use App\Benchmark\BenchmarkProfileRegistry;
use App\Benchmark\BenchmarkReporter;
use App\Benchmark\Contracts\BenchmarkAxisRunner;
use App\Benchmark\DTO\BenchmarkProfile;
use App\Benchmark\DTO\BenchmarkResult;
use App\Benchmark\DTO\BenchmarkRunOptions;
use App\Enums\BenchmarkAxis;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* 성능 계측 커맨드 — 목록 조회 / 화면 응답 / 쓰기 작업 / 배치 작업 4축
*
* 계측 대상은 소유자가 선언합니다 — 코어는 `config/benchmark.php`, 확장은
* `getBenchmarkProfiles()` 오버라이드입니다. 커맨드에 대상을 하드코딩하지 않는 이유는,
* 확장이 설치·제거되는 설치본마다 실제로 존재하는 대상이 다르기 때문입니다.
*
* 축별 실행은 `App\Benchmark\Axes\*` 실행기가 담당하고, 이 커맨드는 프로파일 선택·옵션
* 해석·출력만 합니다.
*
* tinker 스크립트 대신 커맨드로 만든 이유: tinker 는 REPL 이라 스크립트 실행 후에도 STDIN 을
* 기다려 출력이 유실된다.
*/
class BenchCommand extends Command
{
protected $signature = 'g7:bench
{--profile=* : 계측할 프로파일 키 (짧은 키 또는 출처/키 형태). 다중 지정 가능}
{--axis= : 축 단위 실행 (list|screen|write|batch)}
{--all : 등록된 모든 프로파일 실행}
{--offsets=0,20000,50000,99980 : 측정할 OFFSET 목록 (쉼표 구분, list 축)}
{--runs=3 : 측정 횟수 (첫 회는 버림)}
{--per-page=20 : 목록/화면 1페이지 건수}
{--seed=0 : 계측 전 합성 행 시딩 건수 (0 = 시딩 안 함, list 축)}
{--fresh : 시딩 전에 대상 테이블을 비움 (운영 환경에서는 거부)}
{--explain : 각 OFFSET 의 실행 계획도 함께 수집 (list 축)}
{--as= : 화면 계측에 사용할 기존 계정 (ID 또는 이메일). 미지정 시 계측용 임시 계정}
{--allow-write : 데이터를 변경하는 축(write/batch/비-GET 화면) 실행 허용}
{--database= : 계측에 사용할 데이터베이스명 (미지정 시 기본 연결. 개발 DB 오염 방지용)}
{--json : 기계 판독용 JSON 출력}
{--report= : 마크다운 리포트 저장 경로 (값 없이 --report 만 주면 storage/app/benchmarks/ 아래 자동 생성)}
{--list-profiles : 등록된 프로파일 목록 출력}';
protected $description = '목록 조회·화면 응답·쓰기·배치 4축 성능을 계측합니다 (대상은 코어 config + 확장 선언에서 수집)';
protected $aliases = ['g7:bench:pagination'];
/**
* 리포트 기본 저장 디렉토리 (저장소 미추적 — 계측값은 실행 머신 사양에 종속되므로 축적하지 않음)
*/
private const REPORT_DIR = 'app/benchmarks';
/**
* @param BenchmarkProfileRegistry $registry 프로파일 레지스트리
* @param BenchmarkReporter $reporter 결과 출력기
* @param iterable<BenchmarkAxisRunner> $runners 축 실행기 목록 (컨테이너 태그 주입)
*/
public function __construct(
private readonly BenchmarkProfileRegistry $registry,
private readonly BenchmarkReporter $reporter,
private readonly iterable $runners,
) {
parent::__construct();
}
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
$this->applyDatabaseOverride();
if ($this->option('list-profiles')) {
return $this->listProfiles();
}
[$profiles, $selectionError] = $this->selectProfiles();
if ($selectionError !== null) {
$this->error($selectionError);
return self::FAILURE;
}
if ($profiles === []) {
$this->error('계측할 프로파일이 없습니다. --profile / --axis / --all 중 하나를 지정하세요.');
return self::FAILURE;
}
$options = $this->runOptions();
// 계측 결과가 환경에 좌우되므로 실행 환경을 먼저 드러낸다
if (! $this->option('json')) {
foreach ($this->reporter->environment() as $name => $value) {
$this->line(sprintf(' %-14s %s', $name, $value));
}
$this->newLine();
}
$this->reportCollectionWarnings();
$results = [];
foreach ($profiles as $profile) {
$results[] = $this->runProfile($profile, $options);
}
return $this->output($results, $options);
}
/**
* 계측용 데이터베이스 지정을 반영합니다.
*
* 계측은 대량 시딩을 동반하므로 개발 DB 대신 폐기 가능한 DB 를 지정할 수 있어야 합니다.
* config 가 캐시된 환경에서는 `.env` / `--env` 로 연결을 바꿀 수 없어 런타임 치환이
* 유일한 수단입니다.
*/
private function applyDatabaseOverride(): void
{
$database = (string) $this->option('database');
if ($database === '') {
return;
}
$connection = config('database.default');
config([
"database.connections.{$connection}.database" => $database,
"database.connections.{$connection}.write.database" => $database,
"database.connections.{$connection}.read.database" => $database,
]);
DB::purge($connection);
}
/**
* 등록된 프로파일 목록을 출력합니다.
*
* @return int 종료 코드
*/
private function listProfiles(): int
{
$profiles = $this->registry->all();
if ($this->option('json')) {
$this->line((string) json_encode(
array_map(fn (BenchmarkProfile $profile) => $profile->toArray(), array_values($profiles)),
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
));
return self::SUCCESS;
}
$this->table(
['프로파일', '축', '출처', '설명'],
array_map(fn (BenchmarkProfile $profile) => [
$profile->qualifiedKey(),
$profile->axis->value,
$profile->sourceKind,
$profile->label ?? '-',
], array_values($profiles))
);
$this->reportCollectionWarnings();
return self::SUCCESS;
}
/**
* 실행할 프로파일을 고릅니다.
*
* @return array{0: array<int, BenchmarkProfile>, 1: string|null} [실행 대상, 실패 사유]
*/
private function selectProfiles(): array
{
$keys = array_filter((array) $this->option('profile'), 'strlen');
if ($keys !== []) {
$selected = [];
foreach ($keys as $key) {
[$profile, $error] = $this->registry->resolve((string) $key);
if ($error !== null) {
return [[], $error];
}
$selected[] = $profile;
}
return [$selected, null];
}
$axisOption = (string) $this->option('axis');
if ($axisOption !== '') {
$axis = BenchmarkAxis::tryFrom($axisOption);
if ($axis === null) {
return [[], "알 수 없는 축: {$axisOption} (허용: ".implode('|', BenchmarkAxis::values()).')'];
}
return [array_values($this->registry->byAxis($axis)), null];
}
return [$this->option('all') ? array_values($this->registry->all()) : [], null];
}
/**
* 커맨드 옵션을 실행 옵션으로 해석합니다.
*
* @return BenchmarkRunOptions 실행 옵션
*/
private function runOptions(): BenchmarkRunOptions
{
$asUser = (string) $this->option('as');
return new BenchmarkRunOptions(
offsets: $this->parseOffsets(),
runs: max(1, (int) $this->option('runs')),
seed: max(0, (int) $this->option('seed')),
fresh: (bool) $this->option('fresh'),
explain: (bool) $this->option('explain'),
allowWrite: (bool) $this->option('allow-write'),
asUser: $asUser === '' ? null : $asUser,
perPage: max(1, (int) $this->option('per-page')),
);
}
/**
* OFFSET 목록을 파싱합니다.
*
* @return array<int, int> 오름차순 정렬된 OFFSET 목록
*/
private function parseOffsets(): array
{
$offsets = array_map('intval', array_filter(explode(',', (string) $this->option('offsets')), 'strlen'));
sort($offsets);
return $offsets === [] ? [0] : $offsets;
}
/**
* 프로파일 1건을 계측합니다.
*
* @param BenchmarkProfile $profile 대상 프로파일
* @param BenchmarkRunOptions $options 실행 옵션
* @return BenchmarkResult 계측 결과
*/
private function runProfile(BenchmarkProfile $profile, BenchmarkRunOptions $options): BenchmarkResult
{
$runner = $this->runnerFor($profile->axis);
if ($runner === null) {
return BenchmarkResult::skipped($profile, "축 실행기가 없습니다: {$profile->axis->value}");
}
if (! $this->option('json')) {
$this->line(sprintf('▶ %s (%s)', $profile->qualifiedKey(), $profile->axis->label()));
}
$onProgress = $this->option('json')
? null
: fn (string $message) => $this->line(' '.$message);
return $runner->run($profile, $options, $onProgress);
}
/**
* 축에 대응하는 실행기를 찾습니다.
*
* @param BenchmarkAxis $axis 대상 축
* @return BenchmarkAxisRunner|null 실행기 (없으면 null)
*/
private function runnerFor(BenchmarkAxis $axis): ?BenchmarkAxisRunner
{
foreach ($this->runners as $runner) {
if ($runner->axis() === $axis) {
return $runner;
}
}
return null;
}
/**
* 무시된 프로파일 선언을 알립니다.
*
* 선언 오류를 조용히 버리면 그 대상이 계측 사각으로 남으므로 매 실행에 드러냅니다.
*/
private function reportCollectionWarnings(): void
{
if ($this->option('json')) {
return;
}
foreach ($this->registry->warnings() as $warning) {
$this->warn('무시된 프로파일 선언 — '.$warning);
}
}
/**
* 계측 결과를 출력합니다.
*
* @param array<int, BenchmarkResult> $results 계측 결과
* @param BenchmarkRunOptions $options 실행 옵션
* @return int 종료 코드
*/
private function output(array $results, BenchmarkRunOptions $options): int
{
$warnings = $this->registry->warnings();
if ($this->option('json')) {
$this->line($this->reporter->toJson($results, $options, $warnings));
} else {
foreach ($results as $result) {
$this->newLine();
$this->line(sprintf('%s (%s)', $result->profile->qualifiedKey(), $result->profile->axis->label()));
if ($result->skipped) {
$this->warn(' 측정하지 않음: '.$result->skipReason);
continue;
}
$this->table($result->headers, $result->rows);
foreach ($result->notes as $note) {
$this->line(' '.$note);
}
}
}
if ($this->wantsReport()) {
$path = $this->writeReport($results, $options, $warnings);
$this->newLine();
$this->info('리포트 저장: '.$path);
}
// 전부 건너뛴 실행을 성공으로 보고하면 "측정했다"로 읽히므로 실패로 돌려준다
$measured = array_filter($results, fn (BenchmarkResult $result) => ! $result->skipped);
return $measured === [] ? self::FAILURE : self::SUCCESS;
}
/**
* 리포트 출력이 요청되었는지 판정합니다.
*
* `--report` 는 값이 선택이라 `option()` 만으로는 "미지정"과 "값 없이 지정"을
* 구분할 수 없으므로 원시 입력을 확인합니다.
*
* @return bool 리포트 출력 여부
*/
private function wantsReport(): bool
{
return $this->input->hasParameterOption('--report')
|| $this->input->hasParameterOption('--report=')
|| (string) $this->option('report') !== '';
}
/**
* 마크다운 리포트를 저장합니다.
*
* @param array<int, BenchmarkResult> $results 계측 결과
* @param BenchmarkRunOptions $options 실행 옵션
* @param array<int, string> $warnings 프로파일 수집 경고
* @return string 저장된 절대 경로
*/
private function writeReport(array $results, BenchmarkRunOptions $options, array $warnings): string
{
$given = (string) $this->option('report');
$path = $given !== ''
? $given
: storage_path(self::REPORT_DIR.'/bench-'.now()->format('Ymd-His').'.md');
$directory = dirname($path);
if (! is_dir($directory)) {
mkdir($directory, 0755, true);
}
file_put_contents($path, $this->reporter->toMarkdown($results, $options, $warnings));
return $path;
}
}
@@ -7,6 +7,7 @@ use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Vendor\VendorMode;
use App\Search\SearchIndexMaintenanceManager;
use App\Services\CoreUpdateService;
use App\Services\LanguagePackService;
use Illuminate\Support\Facades\File;
@@ -109,6 +110,10 @@ trait BundledExtensionUpdatePrompt
// 확장별 전략 오버라이드
$strategies = $this->collectPerExtensionStrategies($updates, $globalStrategy, $force);
// 검색 인덱스 재생성 여부 — 인덱스 잠금·재색인 비용이 있어 운영 중인 사이트에서는
// 기본값(아니오)을 유지하고 유지보수 시간에 별도로 수행하는 것이 안전하다.
$rebuildSearchIndex = $this->askRebuildSearchIndex($force);
// 일괄 실행
return $this->executeBulkUpdate(
$moduleManager,
@@ -116,6 +121,7 @@ trait BundledExtensionUpdatePrompt
$templateManager,
$updates,
$strategies,
$rebuildSearchIndex,
);
}
@@ -200,8 +206,11 @@ trait BundledExtensionUpdatePrompt
TemplateManager $templateManager,
array $updates,
array $strategies,
bool $rebuildSearchIndex = false,
): array {
$manifest = $this->buildBundledUpdateManifest($updates, $strategies);
// 운영자의 선택을 매니페스트에 실어 spawn 자식과 in-process fallback 이 같은 결정을 따르게 한다
$manifest['rebuild_search_index'] = $rebuildSearchIndex;
if ($this->bundledManifestIsEmpty($manifest)) {
return ['success' => 0, 'failed' => 0, 'skipped' => 0, 'has_updates' => false];
}
@@ -228,6 +237,7 @@ trait BundledExtensionUpdatePrompt
$templateManager,
$updates,
$strategies,
$rebuildSearchIndex,
);
}
@@ -385,6 +395,7 @@ trait BundledExtensionUpdatePrompt
TemplateManager $templateManager,
array $updates,
array $strategies,
bool $rebuildSearchIndex = false,
): array {
$success = 0;
$failed = 0;
@@ -452,6 +463,8 @@ trait BundledExtensionUpdatePrompt
$this->newLine();
$this->info("업데이트 완료: 성공 {$success}, 실패 {$failed}");
$this->runSearchIndexRebuildIfRequested($rebuildSearchIndex);
return [
'success' => $success,
'failed' => $failed,
@@ -460,4 +473,60 @@ trait BundledExtensionUpdatePrompt
];
}
/**
* 검색 인덱스 재생성 여부를 운영자에게 확인합니다.
*
* 재생성은 인덱스 잠금 또는 전체 재색인을 유발하므로 기본값은 "아니오" 입니다.
* `--force` (무인 실행) 시에도 묻지 않고 수행하지 않습니다 — 무인 실행이
* 대용량 테이블을 잠그는 일이 없어야 합니다. 필요하면 `core:update
* --rebuild-search-index` 로 명시하거나 이후 `search:index --repair` 를 실행합니다.
*
* @param bool $force 강제(무인) 실행 여부
* @return bool 재생성 수행 여부
*/
private function askRebuildSearchIndex(bool $force): bool
{
// 커맨드가 옵션으로 이미 선택을 받았으면 그대로 따른다
if ($this->hasOption('rebuild-search-index') && (bool) $this->option('rebuild-search-index')) {
return true;
}
if ($force) {
return false;
}
$manager = app(SearchIndexMaintenanceManager::class);
// 점검을 제공하지 않는 엔진에서는 물을 것이 없다
if (! $manager->hasMaintainer() || $manager->unavailableReason() !== null) {
return false;
}
$this->newLine();
$this->line(' '.__('search.index.rebuild_cost_warning'));
return $this->unifiedConfirm(__('search.index.rebuild_after_bulk_confirm'), false);
}
/**
* 요청이 있었으면 검색 인덱스를 재생성합니다.
*
* @param bool $requested 재생성 요청 여부
* @return void
*/
private function runSearchIndexRebuildIfRequested(bool $requested): void
{
if (! $requested) {
return;
}
$report = app(SearchIndexMaintenanceManager::class)->repairStale();
$this->newLine();
$this->info('검색 인덱스: '.$report->summary());
foreach ($report->failed as $identifier => $message) {
$this->warn(' '.__('search.index.rebuild_failed_item', ['index' => $identifier, 'error' => $message]));
}
}
}
@@ -34,7 +34,8 @@ class CoreUpdateCommand extends Command
{--local : 로컬 코드베이스를 업데이트 소스로 사용 (GitHub 스킵)}
{--source= : 수동 업데이트용 소스 디렉토리 경로 (GitHub 다운로드 대신 지정 디렉토리 사용)}
{--zip= : 수동 업데이트용 ZIP 파일 경로 (GitHub 다운로드 대신 지정 ZIP 추출 사용)}
{--vendor-mode=auto : vendor 설치 모드 (auto|composer|bundled)}';
{--vendor-mode=auto : vendor 설치 모드 (auto|composer|bundled)}
{--rebuild-search-index : 번들 확장 일괄 업데이트 후 색인이 누락된 검색 인덱스를 재생성 (인덱스가 잠기거나 재색인됩니다 — 운영 중에는 유지보수 시간에 수행하세요)}';
protected $description = '그누보드7 코어를 최신 버전으로 업데이트합니다';
@@ -2,10 +2,12 @@
namespace App\Console\Commands\Core;
use App\Extension\ExtensionManager;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Vendor\VendorMode;
use App\Search\SearchIndexMaintenanceManager;
use App\Services\LanguagePackService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
@@ -45,7 +47,8 @@ class ExecuteBundledUpdatesCommand extends Command
protected $signature = 'core:execute-bundled-updates
{--manifest= : 업데이트 매니페스트 JSON 파일 경로 (필수)}
{--force : 강제 업데이트 플래그 (확장 매니저 update 호출에 전달)}';
{--force : 강제 업데이트 플래그 (확장 매니저 update 호출에 전달)}
{--rebuild-search-index : 일괄 업데이트 후 검색 인덱스 재생성 (매니페스트의 선택보다 우선)}';
protected $description = '번들 확장 일괄 업데이트를 실행합니다 (CoreUpdateCommand 내부용 — fresh PHP 프로세스에서 호출)';
@@ -63,7 +66,7 @@ class ExecuteBundledUpdatesCommand extends Command
// (Artisan::call 대신 직접 메서드 호출 — nested Artisan::call 이 outer 명령의
// output buffer 를 덮어쓰는 Laravel 동작 회피)
try {
app(\App\Extension\ExtensionManager::class)->updateComposerAutoload();
app(ExtensionManager::class)->updateComposerAutoload();
} catch (\Throwable $e) {
Log::warning('bundled update spawn 자식: updateComposerAutoload 호출 실패', [
'error' => $e->getMessage(),
@@ -77,6 +80,9 @@ class ExecuteBundledUpdatesCommand extends Command
return self::FAILURE;
}
// 부모(core:update)가 운영자 선택을 매니페스트에 실어 보낸다 — 자식이 임의로 정하지 않는다
$rebuildSearchIndex = (bool) $this->option('rebuild-search-index') || (bool) ($manifest['rebuild_search_index'] ?? false);
$modules = $manifest['modules'] ?? [];
$plugins = $manifest['plugins'] ?? [];
$templates = $manifest['templates'] ?? [];
@@ -166,6 +172,17 @@ class ExecuteBundledUpdatesCommand extends Command
$this->newLine();
$this->info("업데이트 완료: 성공 {$success}, 실패 {$failed}");
// 검색 인덱스 재생성 — 운영자가 부모 단계에서 선택했을 때만 수행 (인덱스 잠금·재색인 비용)
if ($rebuildSearchIndex) {
$report = app(SearchIndexMaintenanceManager::class)->repairStale();
$this->newLine();
$this->info('검색 인덱스: '.$report->summary());
foreach ($report->failed as $identifier => $message) {
$this->warn(' '.__('search.index.rebuild_failed_item', ['index' => $identifier, 'error' => $message]));
}
}
// 부모 프로세스가 결과를 복원할 수 있도록 표식 라인으로 페이로드 출력
$this->line(self::RESULT_PREFIX.json_encode([
'success' => $success,
@@ -174,5 +191,4 @@ class ExecuteBundledUpdatesCommand extends Command
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
}
@@ -116,7 +116,7 @@ class CheckModuleUpdatesCommand extends Command
$updateCount = 0;
foreach ($result['details'] as $detail) {
$isUpdate = $detail['update_available'] ?? false;
$isUpdate = (bool) ($detail['update_available'] ?? true);
if ($isUpdate) {
$updateCount++;
}
@@ -143,7 +143,7 @@ class CheckModuleUpdatesCommand extends Command
$this->table($headers, $tableData);
$this->newLine();
$this->info(__('modules.commands.check_updates.summary', [
'total' => count($result['details']),
'total' => $result['checked_count'] ?? count($result['details']),
'updates' => $updateCount,
]));
@@ -3,6 +3,7 @@
namespace App\Console\Commands\Module;
use App\Console\Commands\Traits\HasProgressBar;
use App\Console\Commands\Traits\RebuildsSearchIndex;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Enums\ExtensionOwnerType;
use App\Extension\ModuleManager;
@@ -17,6 +18,7 @@ use Illuminate\Support\Facades\Validator;
class InstallModuleCommand extends Command
{
use HasProgressBar;
use RebuildsSearchIndex;
/**
* The name and signature of the console command.
@@ -24,7 +26,8 @@ class InstallModuleCommand extends Command
protected $signature = 'module:install
{identifier : 설치할 모듈 식별자}
{--vendor-mode=auto : Vendor 설치 모드 (auto|composer|bundled)}
{--force : 이미 설치된 경우에도 _bundled/_pending 원본으로 활성 디렉토리를 덮어쓰고 재설치 (불완전 설치 복구)}';
{--force : 이미 설치된 경우에도 _bundled/_pending 원본으로 활성 디렉토리를 덮어쓰고 재설치 (불완전 설치 복구)}
{--rebuild-search-index : 완료 후 색인이 누락된 검색 인덱스를 재생성 (인덱스가 잠기거나 재색인됩니다 — 운영 중에는 유지보수 시간에 수행하세요)}';
/**
* The console command description.
@@ -114,6 +117,9 @@ class InstallModuleCommand extends Command
Log::info(__('modules.commands.install.success', ['module' => $identifier]));
// 검색 인덱스 재생성은 운영자가 선택했을 때만 수행한다 (인덱스 잠금·재색인 비용)
$this->handleSearchIndexRebuild();
return Command::SUCCESS;
}
@@ -4,6 +4,7 @@ namespace App\Console\Commands\Module;
use App\Console\Commands\Traits\HasProgressBar;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Console\Commands\Traits\RebuildsSearchIndex;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Enums\LayoutSourceType;
use App\Extension\ModuleManager;
@@ -16,6 +17,7 @@ class UpdateModuleCommand extends Command
{
use HasProgressBar;
use HasUnifiedConfirm;
use RebuildsSearchIndex;
/**
* The name and signature of the console command.
@@ -26,7 +28,8 @@ class UpdateModuleCommand extends Command
{--vendor-mode=auto : Vendor 설치 모드 (auto|composer|bundled)}
{--layout-strategy=overwrite : 레이아웃 전략 (overwrite|keep)}
{--source=auto : 업데이트 소스 (auto|bundled|github) — bundled 는 _bundled 만 사용(GitHub 우회)}
{--zip= : 외부 ZIP 파일 경로 (지정 시 GitHub/번들 우회 + 버전은 module.json 기준)}';
{--zip= : 외부 ZIP 파일 경로 (지정 시 GitHub/번들 우회 + 버전은 module.json 기준)}
{--rebuild-search-index : 완료 후 색인이 누락된 검색 인덱스를 재생성 (인덱스가 잠기거나 재색인됩니다 — 운영 중에는 유지보수 시간에 수행하세요)}';
/**
* The console command description.
@@ -203,6 +206,9 @@ class UpdateModuleCommand extends Command
'layout_strategy' => $layoutStrategy,
]);
// 검색 인덱스 재생성은 운영자가 선택했을 때만 수행한다 (인덱스 잠금·재색인 비용)
$this->handleSearchIndexRebuild();
return Command::SUCCESS;
}
@@ -116,7 +116,7 @@ class CheckPluginUpdatesCommand extends Command
$updateCount = 0;
foreach ($result['details'] as $detail) {
$isUpdate = $detail['update_available'] ?? false;
$isUpdate = (bool) ($detail['update_available'] ?? true);
if ($isUpdate) {
$updateCount++;
}
@@ -143,7 +143,7 @@ class CheckPluginUpdatesCommand extends Command
$this->table($headers, $tableData);
$this->newLine();
$this->info(__('plugins.commands.check_updates.summary', [
'total' => count($result['details']),
'total' => $result['checked_count'] ?? count($result['details']),
'updates' => $updateCount,
]));
@@ -3,6 +3,7 @@
namespace App\Console\Commands\Plugin;
use App\Console\Commands\Traits\HasProgressBar;
use App\Console\Commands\Traits\RebuildsSearchIndex;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Enums\ExtensionOwnerType;
use App\Extension\PluginManager;
@@ -16,6 +17,7 @@ use Illuminate\Support\Facades\Validator;
class InstallPluginCommand extends Command
{
use HasProgressBar;
use RebuildsSearchIndex;
/**
* The name and signature of the console command.
@@ -23,7 +25,8 @@ class InstallPluginCommand extends Command
protected $signature = 'plugin:install
{identifier : 설치할 플러그인 식별자}
{--vendor-mode=auto : Vendor 설치 모드 (auto|composer|bundled)}
{--force : 이미 설치된 경우에도 _bundled/_pending 원본으로 활성 디렉토리를 덮어쓰고 재설치 (불완전 설치 복구)}';
{--force : 이미 설치된 경우에도 _bundled/_pending 원본으로 활성 디렉토리를 덮어쓰고 재설치 (불완전 설치 복구)}
{--rebuild-search-index : 완료 후 색인이 누락된 검색 인덱스를 재생성 (인덱스가 잠기거나 재색인됩니다 — 운영 중에는 유지보수 시간에 수행하세요)}';
/**
* The console command description.
@@ -111,6 +114,9 @@ class InstallPluginCommand extends Command
Log::info(__('plugins.commands.install.success', ['plugin' => $identifier]));
// 검색 인덱스 재생성은 운영자가 선택했을 때만 수행한다 (인덱스 잠금·재색인 비용)
$this->handleSearchIndexRebuild();
return Command::SUCCESS;
}
@@ -4,6 +4,7 @@ namespace App\Console\Commands\Plugin;
use App\Console\Commands\Traits\HasProgressBar;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Console\Commands\Traits\RebuildsSearchIndex;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Enums\LayoutSourceType;
use App\Extension\PluginManager;
@@ -16,6 +17,7 @@ class UpdatePluginCommand extends Command
{
use HasProgressBar;
use HasUnifiedConfirm;
use RebuildsSearchIndex;
/**
* The name and signature of the console command.
@@ -26,7 +28,8 @@ class UpdatePluginCommand extends Command
{--vendor-mode=auto : Vendor 설치 모드 (auto|composer|bundled)}
{--layout-strategy=overwrite : 레이아웃 전략 (overwrite|keep)}
{--source=auto : 업데이트 소스 (auto|bundled|github) — bundled 는 _bundled 만 사용(GitHub 우회)}
{--zip= : 외부 ZIP 파일 경로 (지정 시 GitHub/번들 우회 + 버전은 plugin.json 기준)}';
{--zip= : 외부 ZIP 파일 경로 (지정 시 GitHub/번들 우회 + 버전은 plugin.json 기준)}
{--rebuild-search-index : 완료 후 색인이 누락된 검색 인덱스를 재생성 (인덱스가 잠기거나 재색인됩니다 — 운영 중에는 유지보수 시간에 수행하세요)}';
/**
* The console command description.
@@ -202,6 +205,9 @@ class UpdatePluginCommand extends Command
'layout_strategy' => $layoutStrategy,
]);
// 검색 인덱스 재생성은 운영자가 선택했을 때만 수행한다 (인덱스 잠금·재색인 비용)
$this->handleSearchIndexRebuild();
return Command::SUCCESS;
}
@@ -0,0 +1,194 @@
<?php
namespace App\Console\Commands\Search;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Enums\SearchIndexStatus;
use App\Search\DTO\SearchIndexHealth;
use App\Search\SearchIndexMaintenanceManager;
use Illuminate\Console\Command;
/**
* 검색 인덱스 점검·재생성 커맨드
*
* 인덱스는 있는데 내용이 색인되지 않아 검색이 조용히 0건을 돌려주는 상태를 검출하고,
* 운영자가 선택하면 재생성합니다. 이 상태는 예외도 로그도 남기지 않으므로
* "원래 검색이 안 되는 줄" 알고 지나가기 쉽습니다.
*
* 점검 방법은 **활성 Scout 엔진의 유지보수기**가 정합니다. 확장이 자체 검색 엔진을
* 등록하면서 유지보수기를 함께 등록하면 이 커맨드가 그대로 그 엔진을 다룹니다
* (`core.search.index_maintainers` 필터 훅).
*
* 사용 예시:
* php artisan search:index # 활성 엔진의 인덱스 점검 (읽기 전용)
* php artisan search:index --repair # 색인 누락 인덱스 재생성
* php artisan search:index --filter=table=pages # 엔진별 필터 전달
* php artisan search:index --json # 기계 판독용 출력
*/
class SearchIndexCommand extends Command
{
use HasUnifiedConfirm;
protected $signature = 'search:index
{--repair : 색인이 누락된 인덱스를 재생성 (미지정 시 점검만)}
{--filter=* : 엔진별 필터 (key=value 형태, 다중 지정 가능. FULLTEXT: table, index, samples)}
{--json : 기계 판독용 JSON 출력}';
protected $description = '활성 검색 엔진의 인덱스가 실제로 내용을 색인하고 있는지 점검하고, 누락 시 재생성합니다';
/**
* 커맨드를 실행합니다.
*
* @param SearchIndexMaintenanceManager $manager 검색 인덱스 유지보수 진입점
* @return int 종료 코드 (색인 누락 잔존 시 1)
*/
public function handle(SearchIndexMaintenanceManager $manager): int
{
$driver = $manager->driver();
$filters = $this->parseFilters();
if (! $manager->hasMaintainer()) {
$this->components->warn(__('search.index.no_maintainer', ['driver' => $driver]));
return self::SUCCESS;
}
if (($reason = $manager->unavailableReason()) !== null) {
$this->components->warn($reason);
return self::SUCCESS;
}
$results = $manager->inspect($filters);
if ($results === []) {
$this->components->warn(__('search.index.no_targets', ['driver' => $driver]));
return self::SUCCESS;
}
$report = null;
if ($this->option('repair')) {
$targets = array_values(array_filter($results, fn (SearchIndexHealth $h) => $h->needsRebuild()));
if ($targets !== [] && $this->confirmRebuild($targets)) {
$report = $manager->repairStale($filters);
$results = $manager->inspect($filters);
}
}
if ($this->option('json')) {
$this->line((string) json_encode([
'driver' => $driver,
'results' => array_map(fn (SearchIndexHealth $h) => $h->toArray(), $results),
'repair' => $report?->toArray(),
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
} else {
$this->render($driver, $results, $report?->summary());
}
$remaining = array_filter($results, fn (SearchIndexHealth $h) => $h->needsRebuild());
return ($remaining === [] && ($report === null || $report->failed === [])) ? self::SUCCESS : self::FAILURE;
}
/**
* `--filter=key=value` 옵션을 연관 배열로 해석합니다.
*
* @return array<string, string>
*/
private function parseFilters(): array
{
$filters = [];
foreach ((array) $this->option('filter') as $raw) {
if (! is_string($raw) || ! str_contains($raw, '=')) {
continue;
}
[$key, $value] = explode('=', $raw, 2);
$key = trim($key);
if ($key !== '') {
$filters[$key] = trim($value);
}
}
return $filters;
}
/**
* 재생성 대상을 보여 주고 진행 여부를 확인합니다.
*
* @param array<int, SearchIndexHealth> $targets 재생성 대상
* @return bool 진행 여부
*/
private function confirmRebuild(array $targets): bool
{
$this->newLine();
$this->components->info(__('search.index.rebuild_targets', ['count' => count($targets)]));
foreach ($targets as $target) {
$this->line(' - '.$target->identifier.' ('.$target->measurement.')');
}
$this->components->warn(__('search.index.rebuild_cost_warning'));
if ($this->unifiedConfirm(__('search.index.rebuild_confirm'), true)) {
return true;
}
$this->components->info(__('search.index.rebuild_skipped'));
return false;
}
/**
* 판정 결과를 표로 출력합니다.
*
* @param string $driver 드라이버명
* @param array<int, SearchIndexHealth> $results 판정 결과
* @param string|null $repairSummary 재생성 요약 (수행했을 때만)
* @return void
*/
private function render(string $driver, array $results, ?string $repairSummary): void
{
$rows = array_map(fn (SearchIndexHealth $health) => [
$health->identifier,
'<fg='.$health->status->consoleColor().'>'.$health->status->label().'</>',
$health->measurement,
], $results);
$this->newLine();
$this->line(' '.__('search.index.driver_label', ['driver' => $driver]));
$this->table([__('search.index.col.index'), __('search.index.col.status'), __('search.index.col.measurement')], $rows);
$counts = [];
foreach (SearchIndexStatus::cases() as $case) {
$counts[$case->value] = count(array_filter($results, fn (SearchIndexHealth $h) => $h->status === $case));
}
$this->line(' '.__('search.index.counts', [
'healthy' => $counts[SearchIndexStatus::Healthy->value],
'degraded' => $counts[SearchIndexStatus::Degraded->value],
'stale' => $counts[SearchIndexStatus::Stale->value],
'skipped' => $counts[SearchIndexStatus::Skipped->value],
'total' => count($results),
]));
if ($repairSummary !== null) {
$this->newLine();
$this->components->info($repairSummary);
}
if ($counts[SearchIndexStatus::Stale->value] > 0 && ! $this->option('repair')) {
$this->newLine();
$this->components->warn(__('search.index.stale_hint'));
}
if ($counts[SearchIndexStatus::Degraded->value] > 0) {
$this->line(' <comment>'.__('search.index.degraded_hint').'</comment>');
}
}
}
@@ -116,7 +116,7 @@ class CheckTemplateUpdatesCommand extends Command
$updateCount = 0;
foreach ($result['details'] as $detail) {
$isUpdate = $detail['update_available'] ?? false;
$isUpdate = (bool) ($detail['update_available'] ?? true);
if ($isUpdate) {
$updateCount++;
}
@@ -143,7 +143,7 @@ class CheckTemplateUpdatesCommand extends Command
$this->table($headers, $tableData);
$this->newLine();
$this->info(__('templates.commands.check_updates.summary', [
'total' => count($result['details']),
'total' => $result['checked_count'] ?? count($result['details']),
'updates' => $updateCount,
]));
@@ -0,0 +1,114 @@
<?php
namespace App\Console\Commands\Traits;
use App\Search\DTO\SearchIndexHealth;
use App\Search\DTO\SearchIndexRepairReport;
use App\Search\SearchIndexMaintenanceManager;
/**
* 설치·업데이트 커맨드에 "검색 인덱스 재생성" 선택 옵션을 제공하는 트레이트.
*
* 재생성 비용은 엔진마다 다르지만(테이블 잠금 / 전체 재색인) 어느 쪽이든 운영 중인
* 사이트에 영향을 줍니다. 그래서 **기본값은 재생성하지 않음**이고, 운영자가
* `--rebuild-search-index` 로 명시했을 때만 수행합니다.
*
* 옵션을 주지 않아도 점검 결과는 안내합니다 — 색인이 누락되면 검색이 오류 없이 0건을
* 돌려주므로, 알려주지 않으면 운영자가 알 방법이 없습니다.
*/
trait RebuildsSearchIndex
{
/**
* 커맨드 `$signature` 에 붙일 옵션 정의.
*
* @return string 옵션 시그니처 조각
*/
public static function rebuildSearchIndexOption(): string
{
return '{--rebuild-search-index : 완료 후 색인이 누락된 검색 인덱스를 재생성 (인덱스가 잠기거나 재색인됩니다 — 운영 중에는 점검 결과만 확인하고 유지보수 시간에 수행하세요)}';
}
/**
* 작업 완료 후 검색 인덱스를 점검하고, 요청이 있었으면 재생성합니다.
*
* @param bool|null $requested 재생성 요청 여부 (null 이면 커맨드 옵션에서 읽음)
* @return SearchIndexRepairReport|null 재생성 보고 (재생성을 하지 않았으면 null)
*/
protected function handleSearchIndexRebuild(?bool $requested = null): ?SearchIndexRepairReport
{
$manager = app(SearchIndexMaintenanceManager::class);
// 점검을 제공하지 않는 엔진에서는 조용히 넘어간다 (검색 자체는 정상 동작)
if (! $manager->hasMaintainer() || $manager->unavailableReason() !== null) {
return null;
}
$requested ??= (bool) $this->option('rebuild-search-index');
if ($requested) {
$report = $manager->repairStale();
$this->reportRebuild($report);
return $report;
}
$this->warnStale($manager->inspect());
return null;
}
/**
* 재생성하지 않은 경우, 색인 누락 사실만 안내합니다.
*
* @param array<int, SearchIndexHealth> $results 점검 결과
* @return void
*/
private function warnStale(array $results): void
{
$stale = array_values(array_filter($results, fn (SearchIndexHealth $h) => $h->needsRebuild()));
if ($stale === []) {
return;
}
$this->newLine();
$this->warn('⚠️ '.__('search.index.stale_after_update', ['count' => count($stale)]));
foreach ($stale as $health) {
$this->line(' - '.$health->identifier);
}
$this->line(' '.__('search.index.stale_hint'));
}
/**
* 재생성 보고를 콘솔에 출력합니다.
*
* @param SearchIndexRepairReport $report 재생성 보고
* @return void
*/
private function reportRebuild(SearchIndexRepairReport $report): void
{
$this->newLine();
if (! $report->didRebuild()) {
$this->info('✅ '.$report->summary());
return;
}
$this->info('✅ '.$report->summary());
foreach ($report->repaired as $identifier) {
$this->line(' '.__('search.index.rebuilt_item', ['index' => $identifier]));
}
foreach ($report->failed as $identifier => $message) {
$this->error(' '.__('search.index.rebuild_failed_item', ['index' => $identifier, 'error' => $message]));
}
foreach ($report->remaining as $identifier) {
$this->warn(' '.__('search.index.still_stale_item', ['index' => $identifier]));
}
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace App\Enums;
/**
* 성능 계측 축 Enum
*
* `g7:bench` 커맨드가 재는 네 가지 대상을 구분합니다. 프로파일 선언의 `type` 필드
* 값 도메인이며, 축마다 필수 옵션과 실행기(`App\Benchmark\Axes\*`)가 다릅니다.
*/
enum BenchmarkAxis: string
{
/**
* 목록 SELECT 비용 (전체 컬럼 / 목록 컬럼 / 키 컬럼 3축 비교)
*/
case ListQuery = 'list';
/**
* 화면 1장 응답 시간 + 실행 쿼리 건수 + N+1 후보
*/
case Screen = 'screen';
/**
* 저장 경로 1회 소요 시간 (주문 생성, 게시글 등록 등)
*/
case Write = 'write';
/**
* 배치 커맨드 소요 시간 + 피크 메모리
*/
case Batch = 'batch';
/**
* 사람이 읽을 수 있는 라벨
*
* @return string 축 라벨
*/
public function label(): string
{
return match ($this) {
self::ListQuery => '목록 조회',
self::Screen => '화면 응답',
self::Write => '쓰기 작업',
self::Batch => '배치 작업',
};
}
/**
* 프로파일 선언에서 반드시 채워야 하는 옵션 키 목록
*
* 각 원소는 "대안 그룹"이며, 그룹마다 최소 하나가 선언되어야 합니다
* (`screen` 축은 라우트명 또는 URI 중 하나). 레지스트리가 이 목록으로 선언을
* 검증하며, 누락된 선언은 사유와 함께 경고로 드러내고 목록에서 제외합니다
* (조용히 버리면 계측 사각이 됩니다).
*
* @return array<int, array<int, string>> 필수 옵션 대안 그룹 목록
*/
public function requiredOptions(): array
{
return match ($this) {
self::ListQuery => [['table']],
self::Screen => [['route', 'uri']],
self::Write => [['callback']],
self::Batch => [['command']],
};
}
/**
* 기본적으로 데이터를 변경하는 축인지 여부
*
* true 인 축은 `--allow-write` 없이는 실행을 거부합니다. `screen` 축은 선언한
* HTTP 메서드에 따라 달라지므로 프로파일 단위로 다시 판정합니다.
*
* @return bool 데이터 변경 축 여부
*/
public function mutatesByDefault(): bool
{
return match ($this) {
self::ListQuery, self::Screen => false,
self::Write, self::Batch => true,
};
}
/**
* 모든 값 배열
*
* @return array<int, string> 축 값 목록
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\Enums;
/**
* 검색 인덱스 건강도 등급 (엔진 중립)
*
* 판정 방법은 엔진마다 다릅니다 — 각 `SearchIndexMaintainer` 구현이 자기 방식으로
* 판정한 뒤 이 등급 중 하나로 답합니다. 코어는 등급만 보고 재생성 대상을 고릅니다.
*/
enum SearchIndexStatus: string
{
/** 정상 — 색인된 내용으로 검색이 성립한다 */
case Healthy = 'healthy';
/** 부분 — 일부만 성립. 엔진 특성일 수 있어 자동 재생성 대상이 아니다 */
case Degraded = 'degraded';
/** 색인 누락 — 검색이 성립하지 않는다. 재생성 대상 */
case Stale = 'stale';
/** 판정 불가 — 표본·연결 부재 등 (사유를 함께 기록) */
case Skipped = 'skipped';
/**
* 사용자 친화 라벨을 반환합니다.
*
* @return string lang 키 해석 결과 (locale 자동 반영)
*/
public function label(): string
{
return __('search.index.status.'.$this->value);
}
/**
* 콘솔 출력용 색상명을 반환합니다.
*
* @return string Symfony 콘솔 색상명
*/
public function consoleColor(): string
{
return match ($this) {
self::Healthy => 'green',
self::Degraded => 'yellow',
self::Stale => 'red',
self::Skipped => 'gray',
};
}
/**
* 모든 케이스의 string 값 목록.
*
* @return array<int, string>
*/
public static function allValues(): array
{
return array_column(self::cases(), 'value');
}
}
+32
View File
@@ -749,6 +749,38 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
return [];
}
/**
* 성능 계측 프로파일 정의를 반환합니다.
*
* 이 모듈이 소유한 목록/화면/저장 경로/배치 중 성능을 재고 싶은 대상을 선언합니다.
* `g7:bench` 커맨드가 코어 `config/benchmark.php` 선언과 함께 수집합니다
* (`App\Benchmark\BenchmarkProfileRegistry`). 계측 대상을 코어 커맨드에 하드코딩하지
* 않는 이유는, 확장이 설치·제거되는 설치본마다 실제로 존재하는 대상이 다르기
* 때문입니다. 키는 모듈 내부에서만 고유하면 되고, 다른 확장과 겹치면 커맨드가
* `{식별자}/{키}` 로 지목합니다.
*
* `write` 축의 `callback` 은 클로저를 쓸 수 없습니다 — 코어 선언과 스키마를 공유하고
* 코어 쪽은 `config:cache` 대상이므로, 형식을 `'Fqcn'`(invokable) 또는
* `['Fqcn', 'method']` 로 통일합니다.
*
* @return array<string, array<string, mixed>> 프로파일 키 → 정의
* [
* 'orders' => [
* 'type' => 'list', // list | screen | write | batch
* 'label' => '주문 목록',
* 'table' => 'ecommerce_orders',
* 'columns' => ['id', 'order_number', ...],
* 'order' => [['ordered_at', 'desc']],
* 'filters' => ['order_status' => 'paid'],
* 'soft_delete' => true,
* ],
* ]
*/
public function getBenchmarkProfiles(): array
{
return [];
}
/**
* 모듈 설치 시 실행할 시더 클래스 목록 반환
*
+31
View File
@@ -658,6 +658,37 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte
return [];
}
/**
* 성능 계측 프로파일 정의를 반환합니다.
*
* 이 플러그인이 소유한 목록/화면/저장 경로/배치 중 성능을 재고 싶은 대상을 선언합니다.
* `g7:bench` 커맨드가 코어 `config/benchmark.php` 선언과 함께 수집합니다
* (`App\Benchmark\BenchmarkProfileRegistry`). 계측 대상을 코어 커맨드에 하드코딩하지
* 않는 이유는, 확장이 설치·제거되는 설치본마다 실제로 존재하는 대상이 다르기
* 때문입니다. 키는 플러그인 내부에서만 고유하면 되고, 다른 확장과 겹치면 커맨드가
* `{식별자}/{키}` 로 지목합니다.
*
* `write` 축의 `callback` 은 클로저를 쓸 수 없습니다 — 코어 선언과 스키마를 공유하고
* 코어 쪽은 `config:cache` 대상이므로, 형식을 `'Fqcn'`(invokable) 또는
* `['Fqcn', 'method']` 로 통일합니다.
*
* @return array<string, array<string, mixed>> 프로파일 키 → 정의
* [
* 'consent_history' => [
* 'type' => 'list', // list | screen | write | batch
* 'label' => '동의 이력 목록',
* 'table' => 'gdpr_user_consent_histories',
* 'columns' => ['*'],
* 'order' => [['created_at', 'desc']],
* 'soft_delete' => false,
* ],
* ]
*/
public function getBenchmarkProfiles(): array
{
return [];
}
/**
* 플러그인 설치 시 실행할 시더 클래스 목록 반환
*
+4
View File
@@ -3971,9 +3971,11 @@ class ModuleManager implements ModuleManagerInterface
$moduleRecords = $this->moduleRepository->getAllKeyedByIdentifier();
$details = [];
$updatedCount = 0;
$checkedCount = 0;
foreach ($moduleRecords as $identifier => $record) {
$result = $this->checkModuleUpdate($identifier);
$checkedCount++;
// DB 갱신
$updateData = [
@@ -3997,6 +3999,7 @@ class ModuleManager implements ModuleManagerInterface
$updatedCount++;
$details[] = [
'identifier' => $identifier,
'update_available' => true,
'current_version' => $result['current_version'],
'latest_version' => $result['latest_version'],
'update_source' => $result['update_source'],
@@ -4006,6 +4009,7 @@ class ModuleManager implements ModuleManagerInterface
return [
'updated_count' => $updatedCount,
'checked_count' => $checkedCount,
'details' => $details,
];
}
+4
View File
@@ -4153,9 +4153,11 @@ class PluginManager implements PluginManagerInterface
$pluginRecords = $this->pluginRepository->getAllKeyedByIdentifier();
$details = [];
$updatedCount = 0;
$checkedCount = 0;
foreach ($pluginRecords as $identifier => $record) {
$result = $this->checkPluginUpdate($identifier);
$checkedCount++;
// DB 갱신
$updateData = [
@@ -4179,6 +4181,7 @@ class PluginManager implements PluginManagerInterface
$updatedCount++;
$details[] = [
'identifier' => $identifier,
'update_available' => true,
'current_version' => $result['current_version'],
'latest_version' => $result['latest_version'],
'update_source' => $result['update_source'],
@@ -4188,6 +4191,7 @@ class PluginManager implements PluginManagerInterface
return [
'updated_count' => $updatedCount,
'checked_count' => $checkedCount,
'details' => $details,
];
}
+4
View File
@@ -2685,9 +2685,11 @@ class TemplateManager implements TemplateManagerInterface
$templateRecords = $this->templateRepository->getAllKeyedByIdentifier();
$details = [];
$updatedCount = 0;
$checkedCount = 0;
foreach ($templateRecords as $identifier => $record) {
$result = $this->checkTemplateUpdate($identifier);
$checkedCount++;
$updateData = [
'update_available' => $result['update_available'],
@@ -2711,6 +2713,7 @@ class TemplateManager implements TemplateManagerInterface
$updatedCount++;
$details[] = [
'identifier' => $identifier,
'update_available' => true,
'current_version' => $result['current_version'],
'latest_version' => $result['latest_version'],
'update_source' => $result['update_source'],
@@ -2720,6 +2723,7 @@ class TemplateManager implements TemplateManagerInterface
return [
'updated_count' => $updatedCount,
'checked_count' => $checkedCount,
'details' => $details,
];
}
+16 -2
View File
@@ -335,6 +335,12 @@ class ResponseHelper
/**
* JSON Resource를 사용한 성공 응답을 생성합니다.
*
* `$resource->additional([...])` 로 실은 부가 데이터는 응답 최상위에 함께 나갑니다.
* `JsonResource::resolve()` 는 리소스 본문만 돌려주므로, 그것만 쓰면 호출자가 실은
* 부가 데이터가 조용히 사라집니다 — 예를 들어 확장 업데이트 응답의 `search_index`
* (색인 누락 안내)가 사라지면 검색이 0건을 돌려주는 사실을 운영자가 알 방법이 없습니다.
* `success`/`message`/`data` 키는 부가 데이터로 덮어쓰지 않습니다.
*
* @param string $messageKey 멤시지 키 (기본값: 'messages.success')
* @param JsonResource|ResourceCollection|null $resource JSON 리소스
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
@@ -351,11 +357,19 @@ class ResponseHelper
): JsonResponse {
$data = $resource ? $resource->resolve() : null;
return response()->json([
$payload = [
'success' => true,
'message' => self::trans($messageKey, $messageParams, $domain),
'data' => $data,
], $statusCode, [], self::JSON_ENCODE_OPTIONS);
];
$additional = $resource instanceof JsonResource ? (array) $resource->additional : [];
if ($additional !== []) {
$payload += array_diff_key($additional, $payload);
}
return response()->json($payload, $statusCode, [], self::JSON_ENCODE_OPTIONS);
}
/**
+27
View File
@@ -198,4 +198,31 @@ class TimezoneHelper
return Carbon::parse($normalized, static::getSiteTimezone())->utc();
}
/**
* 기간 필터의 **종료값**을 사이트 기본 타임존 기준으로 해석하여 UTC Carbon을 반환합니다.
*
* `<input type="date">` 는 시각 없이 'Y-m-d' 만 보낸다. 이 값을 그대로 datetime 비교에 쓰면
* '00:00:00' 으로 해석되어 **종료일 당일이 통째로 빠진다**. 시각이 없는 입력만 그날 끝
* (23:59:59)까지 확장하고, 시각이 함께 온 입력은 그 시각 그대로 둔다.
*
* 예: '2026-03-15' + Asia/Seoul → 2026-03-15 23:59:59 KST → 2026-03-15 14:59:59 UTC
* '2026-03-15 09:30' + Asia/Seoul → 2026-03-15 00:30:00 UTC (확장하지 않음)
*
* @param string|null $dateTimeString Y-m-d 또는 Y-m-d\TH:i / Y-m-d H:i:s 형식
* @return Carbon|null UTC Carbon 인스턴스
*/
public static function fromSiteRangeEnd(?string $dateTimeString): ?Carbon
{
if (! $dateTimeString) {
return null;
}
$normalized = trim(str_replace('T', ' ', $dateTimeString));
$dateOnly = preg_match('/^\d{4}-\d{2}-\d{2}$/', $normalized) === 1;
$parsed = Carbon::parse($normalized, static::getSiteTimezone());
return ($dateOnly ? $parsed->endOfDay() : $parsed)->utc();
}
}
@@ -29,18 +29,22 @@ class MenuController extends AdminBaseController
parent::__construct();
}
/**
* 관리용 메뉴 목록을 조회합니다.
*
* @param MenuListRequest $request 목록 필터·정렬 요청
* @return JsonResponse 메뉴 컬렉션을 담은 JSON 응답
*/
public function index(MenuListRequest $request): JsonResponse
{
try {
$filters = $request->validated();
$user = Auth::user();
// 필터가 있으면 필터링된 메뉴 조회, 없으면 전체 조회
if (isset($filters['is_active']) || ! empty($filters['filters'])) {
$menus = $this->menuService->getFilteredMenusForManagement($filters, $user);
} else {
$menus = $this->menuService->getTopLevelMenusForManagement($user);
}
// 필터 유무와 무관하게 같은 경로로 조회한다. 예전에는 필터가 없으면 filters 를
// 받지 않는 메서드로 분기했는데, 그 경로는 sort_by/sort_order 를 통째로 버려
// "필터를 하나라도 걸어야 정렬이 먹는" 상태가 됐다 (#492 D-22).
$menus = $this->menuService->getFilteredMenusForManagement($filters, $user);
return $this->successWithResource(
'menu.fetch_success',
@@ -7,6 +7,7 @@ use App\Extension\Vendor\VendorMode;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Controllers\Concerns\InjectsExtensionLanguagePacks;
use App\Http\Controllers\Concerns\OrchestratesCascadeInstall;
use App\Http\Controllers\Concerns\RebuildsSearchIndexOnDemand;
use App\Http\Requests\Extension\ChangelogRequest;
use App\Http\Requests\Module\ActivateModuleRequest;
use App\Http\Requests\Module\DeactivateModuleRequest;
@@ -38,6 +39,7 @@ class ModuleController extends AdminBaseController
{
use InjectsExtensionLanguagePacks;
use OrchestratesCascadeInstall;
use RebuildsSearchIndexOnDemand;
public function __construct(
private ModuleService $moduleService,
@@ -531,25 +533,36 @@ class ModuleController extends AdminBaseController
$force = (bool) ($validated['force'] ?? false);
$result = $this->moduleService->updateModule($moduleName, $vendorMode, $layoutStrategy, $force);
// 검색 인덱스 재생성은 운영자가 체크했을 때만 수행한다 — 인덱스 잠금·재색인 비용이
// 있어 운영 중인 사이트에서 업데이트만으로 발생해서는 안 된다.
$searchIndex = $this->rebuildSearchIndexIfRequested(
(bool) ($validated['rebuild_search_index'] ?? false)
);
$moduleInfo = $result['module_info'] ?? null;
// 메시지 치환 파라미터를 반드시 전달한다 — 누락 시 ":module"/":version"
// 플레이스홀더가 그대로 사용자에게 노출된다.
$messageParams = [
'module' => $moduleName,
'version' => $result['to_version'] ?? ($moduleInfo['version'] ?? ''),
'version' => (string) ($result['to_version'] ?? data_get($moduleInfo, 'version') ?? ''),
];
if ($moduleInfo) {
return $this->successWithResource(
'modules.update_success',
new ModuleResource($moduleInfo),
(new ModuleResource($moduleInfo))->additional(['search_index' => $searchIndex]),
200,
$messageParams
);
}
return $this->success('modules.update_success', $result, 200, $messageParams);
return $this->success(
'modules.update_success',
$result + ['search_index' => $searchIndex],
200,
$messageParams
);
} catch (ValidationException $e) {
// Service/Manager에서 이미 번역된 메시지를 errors에 포함하므로
// 첫 번째 에러를 top-level message로 직접 사용 (이중 래핑 방지)
@@ -8,6 +8,7 @@ use App\Helpers\PermissionHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Controllers\Concerns\InjectsExtensionLanguagePacks;
use App\Http\Controllers\Concerns\OrchestratesCascadeInstall;
use App\Http\Controllers\Concerns\RebuildsSearchIndexOnDemand;
use App\Http\Requests\Extension\ChangelogRequest;
use App\Http\Requests\Plugin\ActivatePluginRequest;
use App\Http\Requests\Plugin\DeactivatePluginRequest;
@@ -39,6 +40,7 @@ class PluginController extends AdminBaseController
{
use InjectsExtensionLanguagePacks;
use OrchestratesCascadeInstall;
use RebuildsSearchIndexOnDemand;
public function __construct(
private PluginService $pluginService,
@@ -538,25 +540,35 @@ class PluginController extends AdminBaseController
$force = (bool) ($validated['force'] ?? false);
$result = $this->pluginService->updatePlugin($pluginName, $vendorMode, $layoutStrategy, $force);
// 검색 인덱스 재생성은 운영자가 체크했을 때만 수행한다 — 인덱스 잠금·재색인 비용이
// 있어 운영 중인 사이트에서 업데이트만으로 발생해서는 안 된다.
$searchIndex = $this->rebuildSearchIndexIfRequested(
(bool) ($validated['rebuild_search_index'] ?? false)
);
$pluginInfo = $result['plugin_info'] ?? null;
// 메시지 치환 파라미터를 반드시 전달한다 — 누락 시 ":plugin"/":version"
// 플레이스홀더가 그대로 사용자에게 노출된다.
// 성공 메시지는 ":plugin"/":version" 치환자를 쓰므로 값을 함께 넘긴다
$messageParams = [
'plugin' => $pluginName,
'version' => $result['to_version'] ?? ($pluginInfo['version'] ?? ''),
'version' => (string) ($result['to_version'] ?? data_get($pluginInfo, 'version') ?? ''),
];
if ($pluginInfo) {
return $this->successWithResource(
'plugins.update_success',
new PluginResource($pluginInfo),
(new PluginResource($pluginInfo))->additional(['search_index' => $searchIndex]),
200,
$messageParams
);
}
return $this->success('plugins.update_success', $result, 200, $messageParams);
return $this->success(
'plugins.update_success',
$result + ['search_index' => $searchIndex],
200,
$messageParams
);
} catch (ValidationException $e) {
// Service/Manager에서 이미 번역된 메시지를 errors에 포함하므로
// 첫 번째 에러를 top-level message로 직접 사용 (이중 래핑 방지)
@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers\Concerns;
use App\Search\SearchIndexMaintenanceManager;
/**
* 관리자 화면의 설치·업데이트 응답에 "검색 인덱스 재생성" 선택 결과를 싣는 트레이트.
*
* 재생성 비용은 엔진마다 다르지만(테이블 잠금 / 전체 재색인) 어느 쪽이든 운영 중인
* 사이트에 영향을 줍니다. 그래서 **운영자가 화면에서 체크했을 때만** 수행하고,
* 체크하지 않았을 때는 점검 결과만 실어 보내 화면이 "지금 색인이 누락된 인덱스가 있다"
* 는 사실을 알릴 수 있게 합니다 — 색인이 비면 검색이 오류 없이 0건을 돌려주므로
* 알려 주지 않으면 운영자가 알 방법이 없습니다.
*/
trait RebuildsSearchIndexOnDemand
{
/**
* 요청이 있었으면 재생성하고, 없었으면 점검 결과만 반환합니다.
*
* @param bool $requested 재생성 요청 여부
* @return array<string, mixed>|null 응답에 실을 페이로드 (점검 불가 엔진이면 null)
*/
protected function rebuildSearchIndexIfRequested(bool $requested): ?array
{
$manager = app(SearchIndexMaintenanceManager::class);
// 점검을 제공하지 않는 엔진에서는 실을 정보가 없다 (검색 자체는 정상 동작)
if (! $manager->hasMaintainer() || $manager->unavailableReason() !== null) {
return null;
}
if ($requested) {
return ['rebuilt' => true] + $manager->repairStale()->toArray();
}
$stale = array_values(array_filter($manager->inspect(), fn ($health) => $health->needsRebuild()));
return [
'rebuilt' => false,
'driver' => $manager->driver(),
'stale' => array_map(fn ($health) => $health->identifier, $stale),
'stale_count' => count($stale),
];
}
}
+9 -25
View File
@@ -3,9 +3,11 @@
namespace App\Http\Middleware;
use Closure;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Http\Request;
use Illuminate\Pipeline\Pipeline;
use Illuminate\Support\Facades\Log;
use Illuminate\Session\Middleware\StartSession;
use Symfony\Component\HttpFoundation\Response;
/**
@@ -23,34 +25,16 @@ class StartApiSession
/**
* 요청을 처리합니다.
*
* @param Request $request HTTP 요청
* @param Closure $next 다음 미들웨어
* @param Request $request HTTP 요청
* @param Closure $next 다음 미들웨어
* @return Response HTTP 응답
*/
public function handle(Request $request, Closure $next): Response
{
Log::info('[StartApiSession] 미들웨어 시작', ['url' => $request->url()]);
return (new Pipeline(app()))
->send($request)
->through($this->sessionPipeline())
->then(function ($request) use ($next) {
Log::info('[StartApiSession] 세션 파이프라인 완료', [
'hasSession' => $request->hasSession(),
'sessionStarted' => $request->hasSession() ? $request->session()->isStarted() : false,
'sessionId' => $request->hasSession() ? $request->session()->getId() : null,
]);
$response = $next($request);
Log::info('[StartApiSession] 컨트롤러 응답 후', [
'status' => $response->getStatusCode(),
'cookies' => collect($response->headers->getCookies())->map(fn($c) => $c->getName())->all(),
'webGuardCheck' => \Illuminate\Support\Facades\Auth::guard('web')->check(),
]);
return $response;
});
->then(fn ($request) => $next($request));
}
/**
@@ -63,9 +47,9 @@ class StartApiSession
protected function sessionPipeline(): array
{
return [
\Illuminate\Cookie\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
];
}
}
@@ -4,6 +4,7 @@ namespace App\Http\Requests\ActivityLog;
use App\Enums\ActivityLogType;
use App\Extension\HookManager;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -33,7 +34,7 @@ class ActivityLogIndexRequest extends FormRequest
'log_type' => ['nullable', 'array'],
'log_type.*' => ['string', Rule::in(ActivityLogType::values())],
'action' => ['nullable', 'string', 'max:100'],
'user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
'user_id' => ['nullable', 'integer', Rule::exists(User::class, 'id')],
'loggable_type' => ['nullable', 'string', 'max:255'],
'search' => ['nullable', 'string', 'max:255'],
'search_type' => ['nullable', 'string', Rule::in(['all', 'action', 'description', 'ip_address'])],
@@ -41,6 +42,9 @@ class ActivityLogIndexRequest extends FormRequest
'date_from' => ['nullable', 'date'],
'date_to' => ['nullable', 'date', 'after_or_equal:date_from'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
// 게이트는 화면이 제공하는 정렬만 연다. ActivityLogRepository::SORTABLE_COLUMNS 는
// 이보다 넓게(id·user_id 포함) 두어, 확장이 이 규칙을 훅으로 넓혔을 때 저장소가
// 조용히 기본 정렬로 되돌리지 않도록 한다 (service-repository.md "정렬 컬럼 화이트리스트").
'sort_by' => ['nullable', 'string', Rule::in(['created_at', 'action', 'log_type'])],
'sort_order' => ['nullable', 'string', Rule::in(['asc', 'desc'])],
];
@@ -3,6 +3,7 @@
namespace App\Http\Requests\Module;
use App\Extension\HookManager;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
@@ -28,7 +29,7 @@ class PerformModuleUpdateRequest extends FormRequest
/**
* 요청에 적용할 검증 규칙
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
@@ -37,6 +38,8 @@ class PerformModuleUpdateRequest extends FormRequest
'vendor_mode' => ['nullable', 'string', 'in:auto,composer,bundled'],
// 코어 버전 비호환 강제 우회 플래그
'force' => ['nullable', 'boolean'],
// 업데이트 후 검색 인덱스 재생성 여부 — 인덱스 잠금·재색인 비용이 있어 기본은 미수행
'rebuild_search_index' => ['nullable', 'boolean'],
];
return HookManager::applyFilters('core.module.perform_update_validation_rules', $rules, $this);
@@ -36,7 +36,9 @@ class NotificationLogIndexRequest extends FormRequest
'extension_type' => ['nullable', 'string', 'in:core,module,plugin'],
'status' => ['nullable', 'string', Rule::enum(NotificationLogStatus::class)],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
'sort_by' => ['nullable', 'string', 'in:id,channel,notification_type,status,sent_at,created_at'],
// 허용 집합은 NotificationLogRepository::SORTABLE_COLUMNS 와 동일해야 한다.
// 게이트가 더 좁으면 화면 정렬 셀렉트가 제공하는 옵션(수신자명순/제목순)이 422 로 막힌다.
'sort_by' => ['nullable', 'string', 'in:id,channel,notification_type,status,sent_at,created_at,recipient_name,subject'],
'sort_order' => ['nullable', 'string', 'in:asc,desc'],
];
@@ -3,6 +3,7 @@
namespace App\Http\Requests\Plugin;
use App\Extension\HookManager;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
@@ -28,7 +29,7 @@ class PerformPluginUpdateRequest extends FormRequest
/**
* 요청에 적용할 검증 규칙
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
@@ -37,6 +38,8 @@ class PerformPluginUpdateRequest extends FormRequest
'vendor_mode' => ['nullable', 'string', 'in:auto,composer,bundled'],
// 코어 버전 비호환 강제 우회 플래그 (위험 인지 후 사용자 명시 필요)
'force' => ['nullable', 'boolean'],
// 업데이트 후 검색 인덱스 재생성 여부 — 인덱스 잠금·재색인 비용이 있어 기본은 미수행
'rebuild_search_index' => ['nullable', 'boolean'],
];
return HookManager::applyFilters('core.plugin.perform_update_validation_rules', $rules, $this);
+26
View File
@@ -2,6 +2,11 @@
namespace App\Providers;
use App\Benchmark\Axes\BatchAxisRunner;
use App\Benchmark\Axes\ListAxisRunner;
use App\Benchmark\Axes\ScreenAxisRunner;
use App\Benchmark\Axes\WriteAxisRunner;
use App\Console\Commands\BenchCommand;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Extension\ExtensionMiddlewareRegistryInterface;
use App\Contracts\Extension\HookListenerInterface;
@@ -119,9 +124,30 @@ class CoreServiceProvider extends ServiceProvider
$this->registerRepositoryBindings();
$this->registerExtensionManagers();
$this->registerBenchmarkAxes();
// ActivityLogManager 제거됨 — Monolog 채널(config/logging.php 'activity')로 대체
}
/**
* 성능 계측 축 실행기를 등록합니다.
*
* 축이 늘어날 때 `g7:bench` 커맨드를 고치지 않고 여기에 실행기만 추가하면 되도록
* 태그로 묶어 주입합니다. 실행기는 CLI 계측 시점에만 해석되므로 웹 요청 비용은 없습니다.
*/
private function registerBenchmarkAxes(): void
{
$this->app->tag([
ListAxisRunner::class,
ScreenAxisRunner::class,
WriteAxisRunner::class,
BatchAxisRunner::class,
], 'benchmark.axes');
$this->app->when(BenchCommand::class)
->needs('$runners')
->giveTagged('benchmark.axes');
}
/**
* Extension(모듈/플러그인) PSR-4 오토로드를 등록합니다.
*
+33 -4
View File
@@ -304,10 +304,7 @@ class LanguagePackServiceProvider extends ServiceProvider
$syncFor = function (string $scope, string $identifier, ?array $info, string $relativeBase) use ($registrar) {
$vendor = $this->resolveVendor($identifier, $info);
$version = (string) ($info['version'] ?? '1.0.0');
$langDir = $relativeBase.'/'.$identifier.'/lang';
if (! File::isDirectory(base_path($langDir))) {
$langDir = $relativeBase.'/'.$identifier.'/resources/lang';
}
$langDir = $this->resolveExtensionLangDir($scope, $identifier, $relativeBase);
$registrar->syncFromExtension($scope, $identifier, $vendor, $version, $langDir);
};
@@ -359,6 +356,38 @@ class LanguagePackServiceProvider extends ServiceProvider
});
}
/**
* 확장의 백엔드 다국어 디렉토리를 반환합니다.
*
* **런타임이 실제로 로드하는 경로와 같아야 한다** (`TranslationServiceProvider`).
* 모듈은 `src/lang`, 플러그인은 `lang` 이며, 코어의 확장 설치 검증
* (`ValidatesTranslationPath`)도 같은 규칙을 강제한다. 여기서 다른 경로를 잡으면
* 언어팩에 등록된 키와 화면에 나오는 문구의 출처가 어긋난다.
*
* 존재하지 않으면 관례 경로들을 순서대로 확인해 실제 있는 디렉토리를 돌려준다
* (제3자 확장이 다른 배치를 쓰는 경우 대비).
*
* @param string $scope 확장 스코프 (module|plugin|template)
* @param string $identifier 확장 식별자
* @param string $relativeBase base_path 기준 상위 디렉토리 (modules|plugins|templates)
* @return string base_path 기준 상대 lang 디렉토리
*/
private function resolveExtensionLangDir(string $scope, string $identifier, string $relativeBase): string
{
$primary = $scope === 'module' ? 'src/lang' : 'lang';
$candidates = array_unique([$primary, 'lang', 'src/lang', 'resources/lang']);
foreach ($candidates as $candidate) {
$dir = $relativeBase.'/'.$identifier.'/'.$candidate;
if (File::isDirectory(base_path($dir))) {
return $dir;
}
}
return $relativeBase.'/'.$identifier.'/'.$primary;
}
/**
* 확장 manifest 또는 identifier 로부터 vendor 를 추출합니다.
*
+51 -16
View File
@@ -7,7 +7,10 @@ use App\Helpers\PermissionHelper;
use App\Helpers\TimezoneHelper;
use App\Models\ActivityLog;
use App\Repositories\Concerns\HasMultipleSearchFilters;
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use App\Repositories\Concerns\ResolvesSortSpec;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
@@ -17,6 +20,25 @@ use Illuminate\Database\Eloquent\Model;
class ActivityLogRepository implements ActivityLogRepositoryInterface
{
use HasMultipleSearchFilters;
use PaginatesWithDeferredJoin;
use ResolvesSortSpec;
/**
* 활동 로그 목록 정렬 허용 컬럼
*
* 요청 값을 그대로 orderBy 에 넘기면 없는 컬럼으로 SQL 오류가 나거나 인덱스 없는 컬럼
* 정렬을 강제할 수 있다.
*
* @var array<int, string>
*/
private const SORTABLE_COLUMNS = [
'id',
'created_at',
'action',
'log_type',
'user_id',
];
/**
* 특정 모델의 활동 로그를 페이지네이션하여 조회합니다.
*
@@ -26,8 +48,8 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface
*/
public function getPaginatedForModel(Model $model, array $filters = []): LengthAwarePaginator
{
$sortOrder = $filters['sort_order'] ?? 'desc';
$query = ActivityLog::forModel($model)->orderBy('created_at', $sortOrder);
$sortOrder = $this->normalizeSortDirection($filters['sort_order'] ?? null);
$query = ActivityLog::forModel($model);
if (isset($filters['action'])) {
$query->action($filters['action']);
@@ -37,7 +59,13 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface
$query->byUser($filters['user_id']);
}
return $query->paginate($filters['per_page'] ?? 15);
// 컬럼을 좁히지 않는 이유는 getPaginated() 와 같다 — 리소스가 변경 내역을 그대로 노출한다
return $this->paginateWithDeferredJoin(
query: $query,
columns: ['*'],
sort: [['column' => 'created_at', 'direction' => $sortOrder]],
perPage: (int) ($filters['per_page'] ?? 15),
);
}
/**
@@ -48,7 +76,8 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface
*/
public function getPaginated(array $filters = []): LengthAwarePaginator
{
$query = ActivityLog::query()->with('user:id,uuid,name,email');
// 관계는 지연 조인의 outer 에서만 로드한다 (inner 는 키 컬럼만 조회한다)
$query = ActivityLog::query();
// 스코프 기반 권한 필터링 (self: 본인 로그만, role: 소유역할 로그, null: 전체)
PermissionHelper::applyPermissionScope($query, 'core.activities.read');
@@ -92,11 +121,18 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface
$query->where('created_at', '<=', TimezoneHelper::fromSiteDateTime($filters['date_to']));
}
$sortBy = $filters['sort_by'] ?? 'created_at';
$sortOrder = $filters['sort_order'] ?? 'desc';
$query->orderBy($sortBy, $sortOrder);
$sort = $this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'created_at');
return $query->paginate($filters['per_page'] ?? 15);
// 목록 컬럼을 좁히지 않는 이유: 활동 로그 리소스는 변경 내역(mediumText `changes`)과
// 부가 정보(`properties`)까지 그대로 노출하므로 컬럼을 빼면 응답 계약이 바뀐다.
// 지연 조인만으로도 이 넓은 컬럼들을 읽는 행 수가 이번 페이지 분량으로 고정된다.
return $this->paginateWithDeferredJoin(
query: $query,
columns: ['*'],
sort: $sort,
perPage: (int) ($filters['per_page'] ?? 15),
relations: ['user:id,uuid,name,email'],
);
}
/**
@@ -105,10 +141,9 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface
* action/description 필드는 영어 키를 저장하므로,
* 검색어가 번역된 라벨과 일치하는 원본 키도 함께 검색합니다.
*
* @param \Illuminate\Database\Eloquent\Builder $query 쿼리 빌더
* @param string $search 검색어
* @param string $searchType 검색 유형 (all, action, description, ip_address)
* @return void
* @param Builder $query 쿼리 빌더
* @param string $search 검색어
* @param string $searchType 검색 유형 (all, action, description, ip_address)
*/
private function applyBilingualSearch($query, string $search, string $searchType): void
{
@@ -146,7 +181,7 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface
/**
* 번역된 액션 라벨로 원본 action 값을 역추적합니다.
*
* @param string $searchLower 소문자 검색어
* @param string $searchLower 소문자 검색어
* @return array<string> 일치하는 action 값 목록
*/
private function findActionsByTranslatedLabel(string $searchLower): array
@@ -179,7 +214,7 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface
/**
* 번역된 설명 텍스트로 원본 description_key를 역추적합니다.
*
* @param string $searchLower 소문자 검색어
* @param string $searchLower 소문자 검색어
* @return array<string> 일치하는 description_key 목록
*/
private function findDescriptionKeysByTranslatedText(string $searchLower): array
@@ -200,7 +235,7 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface
/**
* 활동 로그를 삭제합니다.
*
* @param int $id 삭제할 활동 로그 ID
* @param int $id 삭제할 활동 로그 ID
* @return bool 삭제 성공 여부
*/
public function delete(int $id): bool
@@ -211,7 +246,7 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface
/**
* 여러 활동 로그를 일괄 삭제합니다.
*
* @param array<int> $ids 삭제할 활동 로그 ID 목록
* @param array<int> $ids 삭제할 활동 로그 ID 목록
* @return int 삭제된 건수
*/
public function deleteMany(array $ids): int
@@ -0,0 +1,275 @@
<?php
namespace App\Repositories\Concerns;
use Closure;
use Illuminate\Contracts\Database\Query\Expression;
use Illuminate\Contracts\Pagination\Paginator as PaginatorContract;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Collection;
/**
* 지연 조인(deferred join) 기반 목록 페이지네이션 Trait
*
* `OFFSET n` 은 건너뛸 n 건도 실제로 읽는다. 목록 SELECT 에 넓은 컬럼(text/longText/JSON)이
* 포함돼 있으면 그 n 건의 넓은 컬럼까지 함께 읽히고, 특히 InnoDB 는 행 밖 오버플로 페이지에
* 저장된 값을 앞부분만 필요해도 페이지째 읽는다. 그래서 페이지가 뒤로 갈수록 비용이 선형으로
* 커진다.
*
* 지연 조인은 이를 두 단계로 나눈다.
*
* 1. inner — 필터/정렬만으로 **키 컬럼만** 조회해 이번 페이지의 ID 집합을 구한다
* 2. outer — 그 ID 집합에 대해서만 목록 컬럼과 관계를 조회한다
*
* 넓은 컬럼을 읽는 행 수가 OFFSET 과 무관하게 `$perPage` 건으로 고정되므로 뒤쪽 페이지의
* 비용 증가가 사라진다.
*
* 호출 계약:
* - `$query` 에는 **필터/where 만** 적용한다 (`orderBy`/`with`/`select` 미적용)
* - `whereHas` 는 결과 집합을 결정하므로 그대로 둔다 — 제거되는 것은 `with`(eager load) 뿐이다
* - 관계는 **반드시 `$relations` 인자로** 넘긴다. `setEagerLoads([])` 는 inner 뿐 아니라
* **outer 에도** 적용되므로, `$query->with()` 만 하고 인자를 생략하면 관계가 로드되지 않는다.
* 이 실패는 예외도 쿼리 오류도 내지 않고 응답에서 관계 필드만 사라진다
* - 정렬은 닫힌 집합이어야 한다 (ResolvesSortSpec 참조). 인덱스 없는 컬럼 정렬은 inner 도
* 전체 스캔하므로 개선 폭이 보장되지 않는다
* - 키 컬럼은 정렬 마지막에 자동으로 덧붙는다 (동률 시 페이지 경계가 흔들리는 것을 방지)
*
* 목록 컬럼에 `SUBSTRING(content, 1, N)` 을 두는 것은 프루닝으로 인정하지 않는다. 오버플로
* 페이지 읽기가 그대로 발생하므로, 잘라내기는 outer(`$columns`)에서만 수행한다.
*
* @see ResolvesSortSpec 정렬 스펙 해석
*/
trait PaginatesWithDeferredJoin
{
/**
* 지연 조인으로 목록을 페이지네이션합니다.
*
* @param Builder $query 필터/where 만 적용된 쿼리 (orderBy/with/select 미적용)
* @param array $columns outer select 목록 (DB::raw 허용)
* @param array<int, array{column: string, direction: string}> $sort 정렬 스펙
* @param int $perPage 페이지당 건수
* @param int|null $page 현재 페이지 (null = 요청에서 해석)
* @param array<int, string> $relations outer 전용 eager load 관계
* @param array<int, string> $withCount outer 전용 관계 카운트
* @param string $keyName 키 컬럼명 (기본 id)
* @param int|null $total 전체 건수 (null = COUNT 수행, 값 지정 시 캐시 total 사용)
* @param bool $simple true = COUNT 없이 다음 페이지 유무만 판정
* @param bool $preserveIdOrder true = inner 가 정한 ID 순서를 outer 에서 그대로 복원
* @param string $pageName 페이지 쿼리 파라미터명
* @param Closure|null $outerUsing outer 에만 적용할 조인/집계 (inner 에서는 실행되지 않는다)
* @return PaginatorContract 페이지네이터 ($simple=true 면 Paginator, 아니면 LengthAwarePaginator)
*/
protected function paginateWithDeferredJoin(
Builder $query,
array $columns,
array $sort,
int $perPage,
?int $page = null,
array $relations = [],
array $withCount = [],
string $keyName = 'id',
?int $total = null,
bool $simple = false,
bool $preserveIdOrder = false,
string $pageName = 'page',
?Closure $outerUsing = null,
): PaginatorContract {
$page = $page ?: Paginator::resolveCurrentPage($pageName);
$page = max(1, $page);
$sort = $this->normalizeSortSpecForKey($sort, $keyName);
// inner — 키 컬럼만 조회한다. with() 를 비우는 이유 두 가지:
// (a) 성능 — 관계 쿼리가 inner/outer 두 번 실행된다
// (b) 정합성 — inner 는 키 컬럼만 select 하므로 관계 FK 가 없어 eager load 가 깨진다
// whereHas 는 결과 집합을 결정하는 조건이라 setEagerLoads 로 지워지지 않는다 (의도된 구분).
$inner = (clone $query)->setEagerLoads([]);
if (! $simple && $total === null) {
// Laravel 의 paginate() 와 같은 집계 경로를 쓴다. count() 는 groupBy/having 이 있는
// 쿼리에서 `select count(*) ... group by ...` 를 그대로 실행해 **첫 그룹의 행 수**를
// 총 건수로 돌려준다. getCountForPagination() 은 그룹 쿼리를 서브쿼리로 감싸므로
// 그룹 수가 정확히 나온다. toBase() 는 글로벌 스코프를 적용한 뒤 기반 쿼리를 주므로
// 소프트 삭제/필터 조건이 집계에서 누락되지 않는다.
$total = (clone $inner)->toBase()->getCountForPagination();
}
$this->applySortSpec($inner, $sort);
// simple 모드는 다음 페이지 유무 판정을 위해 한 건을 더 읽는다 (Paginator 가 잘라낸다).
// forPage() 를 쓰면 offset 이 (page-1) * (perPage+1) 로 계산돼 페이지가 넘어갈수록
// 건너뛰는 행이 어긋나므로, offset 은 perPage 기준으로 직접 지정한다.
$fetchCount = $simple ? $perPage + 1 : $perPage;
$ids = $inner
->select($inner->getModel()->qualifyColumn($keyName))
->offset(($page - 1) * $perPage)
->limit($fetchCount)
->pluck($keyName);
$items = $ids->isEmpty()
? new Collection
: $this->fetchDeferredJoinRows($query, $columns, $sort, $ids->all(), $relations, $withCount, $keyName, $preserveIdOrder, $outerUsing);
$options = [
'path' => Paginator::resolveCurrentPath(),
'pageName' => $pageName,
];
if ($simple) {
return new Paginator($items, $perPage, $page, $options);
}
return new LengthAwarePaginator($items, $total ?? $items->count(), $perPage, $page, $options);
}
/**
* outer 쿼리를 실행해 실제 행을 조회합니다.
*
* @param Builder $query 필터가 적용된 원본 쿼리 (soft delete 스코프 보존을 위해 재사용)
* @param array $columns select 목록
* @param array<int, array{column: string, direction: string}> $sort 정렬 스펙
* @param array<int, mixed> $ids 이번 페이지의 키 값 목록
* @param array<int, string> $relations eager load 관계
* @param array<int, string> $withCount 관계 카운트
* @param string $keyName 키 컬럼명
* @param bool $preserveIdOrder ID 순서 그대로 복원 여부
* @param Closure|null $outerUsing outer 전용 조인/집계
* @return Collection 조회된 행 컬렉션
*/
private function fetchDeferredJoinRows(
Builder $query,
array $columns,
array $sort,
array $ids,
array $relations,
array $withCount,
string $keyName,
bool $preserveIdOrder,
?Closure $outerUsing = null,
): Collection {
$outer = (clone $query)->setEagerLoads([]);
$qualifiedKey = $outer->getModel()->qualifyColumn($keyName);
$outer->whereIn($qualifiedKey, $ids);
// 결과 집합을 좁히지 않는 조인/집계(최근 댓글 서브쿼리, 상관 COUNT 등)는 여기서만
// 붙인다. inner 에 두면 건너뛸 행 전체에 대해 실행되므로 OFFSET 이 깊어질수록 그
// 비용이 그대로 누적된다. whereIn 뒤에 적용하므로 이번 페이지 건수에만 실행된다.
if ($outerUsing !== null) {
$outerUsing($outer);
}
if (! empty($relations)) {
$outer->with($relations);
}
if (! empty($withCount)) {
$outer->withCount($withCount);
}
if ($preserveIdOrder) {
$this->applyIdOrder($outer, $qualifiedKey, $ids);
} else {
// 정렬 스펙에 키 컬럼이 포함돼 전순서가 보장되므로, 같은 스펙을 다시 적용하면
// inner 가 정한 순서와 결과가 동일하다 (MySQL 전용 FIELD() 가 필요 없다).
$this->applySortSpec($outer, $sort);
}
return $outer->get($columns);
}
/**
* 정렬 스펙을 쿼리에 적용합니다.
*
* 컬럼이 상관 서브쿼리(Builder)면 `orderBy()` 가 서브 select 로 전개한다. 서브쿼리는
* 원 행의 키에 상관되므로 inner/outer 어느 쪽에 적용해도 같은 순서를 만든다 —
* outer 에서는 이번 페이지 건수만큼만 실행된다.
*
* @param Builder $query 대상 쿼리
* @param array<int, array{column: string|Builder|Expression, direction: string}> $sort 정렬 스펙
*/
private function applySortSpec(Builder $query, array $sort): void
{
foreach ($sort as $spec) {
$query->orderBy($spec['column'], $spec['direction']);
}
}
/**
* 정렬 스펙 끝에 키 컬럼을 덧붙여 전순서를 만듭니다.
*
* 정렬 컬럼에 중복이 많으면(상태/조회수/평점 등) 동률 행의 순서가 실행마다 달라져 페이지
* 경계에서 행이 중복되거나 누락된다. inner 와 outer 가 같은 순서를 갖도록 하는 전제이기도 하다.
*
* 정렬 컬럼은 문자열 외에 **상관 서브쿼리(Builder)** 나 Expression 도 올 수 있다
* (관계 테이블 컬럼 기준 정렬 — `SortsByRelatedColumn`). 이 경우 키 컬럼일 수 없으므로
* 전순서 보장을 위해 키 컬럼이 항상 덧붙는다.
*
* @param array<int, array{column: string|Builder|Expression, direction: string}> $sort 정렬 스펙
* @param string $keyName 키 컬럼명
* @return array<int, array{column: string|Builder|Expression, direction: string}> 키 컬럼이 보장된 정렬 스펙
*/
private function normalizeSortSpecForKey(array $sort, string $keyName): array
{
$normalized = [];
foreach ($sort as $spec) {
$column = $spec['column'] ?? null;
if (is_string($column) ? $column === '' : ! ($column instanceof Builder || $column instanceof QueryBuilder || $column instanceof Expression)) {
continue;
}
$normalized[] = [
'column' => $column,
'direction' => strtolower($spec['direction'] ?? 'desc') === 'asc' ? 'asc' : 'desc',
];
}
foreach ($normalized as $spec) {
// 이미 키 컬럼으로 정렬한다면 그것으로 전순서가 성립한다
if (! is_string($spec['column'])) {
continue;
}
if ($spec['column'] === $keyName || str_ends_with($spec['column'], '.'.$keyName)) {
return $normalized;
}
}
$lastDirection = $normalized === [] ? 'desc' : end($normalized)['direction'];
$normalized[] = ['column' => $keyName, 'direction' => $lastDirection];
return $normalized;
}
/**
* inner 가 정한 ID 순서를 outer 에서 그대로 복원합니다.
*
* outer 에 존재하지 않는 표현식(집계/조인 서브쿼리 등)으로 정렬한 경우에만 사용한다.
* MySQL 전용 `FIELD()` 대신 표준 SQL `CASE WHEN` 을 쓴다 (DB 비의존 규정).
*
* @param Builder $query 대상 쿼리
* @param string $qualifiedKey 테이블명이 붙은 키 컬럼
* @param array<int, mixed> $ids 복원할 순서의 키 값 목록
*/
private function applyIdOrder(Builder $query, string $qualifiedKey, array $ids): void
{
$wrapped = $query->getQuery()->getGrammar()->wrap($qualifiedKey);
$cases = [];
$bindings = [];
foreach (array_values($ids) as $index => $id) {
$cases[] = "WHEN {$wrapped} = ? THEN {$index}";
$bindings[] = $id;
}
$query->orderByRaw('CASE '.implode(' ', $cases).' ELSE '.count($ids).' END', $bindings);
}
}
@@ -0,0 +1,105 @@
<?php
namespace App\Repositories\Concerns;
use BackedEnum;
/**
* 요청 유래 정렬 조건을 닫힌 집합으로 해석하는 Trait
*
* 요청 파라미터(`sort_by`/`sort_order` 등)를 그대로 `orderBy()` 에 넘기면 두 가지 문제가 생긴다.
*
* 1. 존재하지 않는 컬럼이 오면 SQL 오류가 나고, 그 오류 응답이 스키마 정보를 노출한다
* 2. 인덱스가 없는 넓은 컬럼으로 정렬을 강제할 수 있어 DoS 표면이 된다
*
* (Laravel 은 컬럼명을 백틱으로 감싸므로 고전적 SQL 인젝션은 성립하지 않는다 — 위 두 가지가 실제 위험이다.)
*
* 본 Trait 은 허용 컬럼 화이트리스트로 정렬 조건을 해석하고, 지연 조인 페이지네이션이 요구하는
* 정렬 스펙 배열 형태로 돌려준다.
*
* 정렬 선택지가 4개 이하로 고정된 곳은 `match` 표현식으로 이미 닫혀 있으므로 본 Trait 이 불필요하다.
* 요청 값이 동적 컬럼으로 흘러 들어가는 곳에만 사용한다.
*
* @see PaginatesWithDeferredJoin 해석 결과를 그대로 `$sort` 인자로 받는다
*/
trait ResolvesSortSpec
{
/**
* 요청 필터에서 정렬 스펙을 해석합니다.
*
* @param array $filters 요청 유래 필터 배열
* @param array<int, string> $allowedColumns 허용 정렬 컬럼 화이트리스트
* @param string $defaultColumn 화이트리스트에 없거나 미전송일 때 사용할 컬럼
* @param string $defaultDirection 기본 정렬 방향 (asc|desc)
* @param array<string, string> $columnAliases 요청 값 → 실제 컬럼 매핑 (예: ['author' => 'author_name'])
* @param string $columnKey 정렬 컬럼이 담긴 필터 키
* @param string $directionKey 정렬 방향이 담긴 필터 키
* @return array<int, array{column: string, direction: string}> 정렬 스펙 배열
*/
protected function resolveSortSpec(
array $filters,
array $allowedColumns,
string $defaultColumn,
string $defaultDirection = 'desc',
array $columnAliases = [],
string $columnKey = 'sort_by',
string $directionKey = 'sort_order',
): array {
$column = $this->normalizeSortValue($filters[$columnKey] ?? null);
$direction = $this->normalizeSortValue($filters[$directionKey] ?? null);
if ($column !== null && isset($columnAliases[$column])) {
$column = $columnAliases[$column];
}
if ($column === null || ! in_array($column, $allowedColumns, true)) {
$column = $defaultColumn;
}
return [[
'column' => $column,
'direction' => $this->normalizeSortDirection($direction, $defaultDirection),
]];
}
/**
* 정렬 방향 문자열을 asc/desc 로 정규화합니다.
*
* @param string|null $direction 요청 값
* @param string $default 허용 값이 아닐 때 사용할 기본 방향
* @return string asc 또는 desc
*/
protected function normalizeSortDirection(?string $direction, string $default = 'desc'): string
{
$normalized = strtolower((string) $direction);
if (in_array($normalized, ['asc', 'desc'], true)) {
return $normalized;
}
$fallback = strtolower($default);
return in_array($fallback, ['asc', 'desc'], true) ? $fallback : 'desc';
}
/**
* 정렬 관련 요청 값을 문자열로 정규화합니다. (Enum/빈 문자열 흡수)
*
* @param mixed $value 요청 값 (문자열 또는 BackedEnum)
* @return string|null 정규화된 문자열 (값이 없으면 null)
*/
private function normalizeSortValue(mixed $value): ?string
{
if ($value instanceof BackedEnum) {
$value = $value->value;
}
if (! is_string($value) && ! is_int($value)) {
return null;
}
$value = trim((string) $value);
return $value === '' ? null : $value;
}
}
@@ -0,0 +1,111 @@
<?php
namespace App\Repositories\Concerns;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* 관계 테이블 컬럼 기준 정렬 Trait
*
* 목록의 정렬 기준이 원 테이블에 없고 관계 테이블에 있는 경우가 있다.
* (예: 주문 목록의 "발송일" — `shipped_at` 은 주문이 아니라 배송 테이블에 있고, 한 주문에
* 배송 행이 여러 건일 수 있다.)
*
* 이때 화면이 정렬 옵션을 제공하는데 백엔드가 그 컬럼을 모르면 422 로 조용히 실패하거나,
* 임시로 조인을 붙이면 다음 두 문제가 생긴다.
*
* 1. `JOIN` + `GROUP BY` 로 집계하면 1:N 조인이 원 행을 부풀려 총 건수와 페이지 경계가 깨진다
* 2. inner 쿼리(지연 조인)에 집계를 붙이면 건너뛸 행 전체에 대해 집계가 돌아 OFFSET 이
* 깊어질수록 비용이 그대로 누적된다 — 지연 조인을 도입한 이유가 사라진다
*
* 본 Trait 은 **상관 서브쿼리 정렬**로 두 문제를 함께 피한다. 원 행 수를 바꾸지 않으므로
* 총 건수·페이지 경계가 그대로 유지되고, `(외래키, 정렬컬럼)` 복합 인덱스가 있으면 각 행당
* 인덱스 첫 항목 한 건만 읽는다.
*
* 방향과 집계를 분리해 지정하지 않는 이유: 서브쿼리를 정렬 방향과 같은 방향으로 정렬해
* 한 건만 취하면 `desc` 는 자연히 최댓값(가장 늦은 발송), `asc` 는 최솟값(가장 이른 발송)이
* 된다. 운영자가 "최근 발송순" 을 골랐을 때 기대하는 값과 일치한다.
*
* 사용 계약:
* - 정렬 키 → 관계 스펙 맵을 리포지토리 상수(`RELATED_SORTABLE_COLUMNS`)로 선언한다
* - 그 키들은 FormRequest 의 `sort_by` 허용 목록에도 함께 들어가야 한다
* (게이트 ⊆ 저장소 불변식 — `SortWhitelistGateParityTest` 가 검사한다)
* - 정렬 대상 관계 테이블에는 `(외래키, 정렬컬럼)` 복합 인덱스를 반드시 둔다
*
* @see ResolvesSortSpec 원 테이블 컬럼 정렬 해석 (본 Trait 과 함께 사용)
* @see PaginatesWithDeferredJoin 서브쿼리 정렬 스펙을 그대로 받는다
*/
trait SortsByRelatedColumn
{
/**
* 관계 테이블 컬럼 정렬을 포함해 정렬 스펙을 해석합니다.
*
* 요청 정렬 키가 `$relatedMap` 에 있으면 상관 서브쿼리 스펙을, 그렇지 않으면
* `ResolvesSortSpec::resolveSortSpec()` 의 일반 컬럼 스펙을 돌려준다.
*
* @param array $filters 요청 유래 필터 배열
* @param array<int, string> $allowedColumns 원 테이블 허용 정렬 컬럼
* @param array<string, array{model: class-string<Model>, foreign_key: string, column: string}> $relatedMap 정렬 키 → 관계 스펙
* @param Model $parent 정렬 대상 원 모델 (상관 조건의 좌변)
* @param string $defaultColumn 기본 정렬 컬럼
* @param string $defaultDirection 기본 정렬 방향
* @param string $columnKey 정렬 컬럼이 담긴 필터 키
* @param string $directionKey 정렬 방향이 담긴 필터 키
* @return array<int, array{column: string|Builder, direction: string}> 정렬 스펙 배열
*/
protected function resolveSortSpecWithRelated(
array $filters,
array $allowedColumns,
array $relatedMap,
Model $parent,
string $defaultColumn,
string $defaultDirection = 'desc',
string $columnKey = 'sort_by',
string $directionKey = 'sort_order',
): array {
$requested = $filters[$columnKey] ?? null;
$requested = is_string($requested) ? trim($requested) : '';
if ($requested === '' || ! isset($relatedMap[$requested])) {
return $this->resolveSortSpec(
$filters,
$allowedColumns,
$defaultColumn,
$defaultDirection,
columnKey: $columnKey,
directionKey: $directionKey,
);
}
$direction = $this->normalizeSortDirection($filters[$directionKey] ?? null, $defaultDirection);
return [[
'column' => $this->relatedSortSubquery($relatedMap[$requested], $parent, $direction),
'direction' => $direction,
]];
}
/**
* 관계 테이블에서 정렬 기준값 한 건을 뽑는 상관 서브쿼리를 만듭니다.
*
* 정렬 방향과 같은 방향으로 관계 행을 정렬해 첫 건만 취한다 —
* `desc` 면 가장 늦은 값, `asc` 면 가장 이른 값이 기준이 된다.
*
* @param array{model: class-string<Model>, foreign_key: string, column: string} $spec 관계 스펙
* @param Model $parent 원 모델
* @param string $direction 정렬 방향 (asc|desc)
* @return Builder 상관 서브쿼리
*/
protected function relatedSortSubquery(array $spec, Model $parent, string $direction): Builder
{
/** @var Model $related */
$related = new $spec['model'];
return $related->newQuery()
->select($related->qualifyColumn($spec['column']))
->whereColumn($related->qualifyColumn($spec['foreign_key']), $parent->qualifyColumn($parent->getKeyName()))
->orderBy($related->qualifyColumn($spec['column']), $direction)
->limit(1);
}
}
@@ -4,11 +4,25 @@ namespace App\Repositories;
use App\Contracts\Repositories\IdentityMessageDefinitionRepositoryInterface;
use App\Models\IdentityMessageDefinition;
use App\Repositories\Concerns\ResolvesSortSpec;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
class IdentityMessageDefinitionRepository implements IdentityMessageDefinitionRepositoryInterface
{
use ResolvesSortSpec;
/** 허용 정렬 컬럼 (AdminIdentityMessageDefinitionIndexRequest 와 동일 집합) */
private const SORTABLE_COLUMNS = [
'id',
'provider_id',
'scope_type',
'scope_value',
'is_active',
'created_at',
'updated_at',
];
/**
* ID로 메시지 정의 조회.
*
@@ -180,10 +194,11 @@ class IdentityMessageDefinitionRepository implements IdentityMessageDefinitionRe
});
}
$sortBy = $filters['sort_by'] ?? 'id';
$sortOrder = $filters['sort_order'] ?? 'asc';
$query->orderBy($sortBy, $sortOrder);
foreach ($this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'id', 'asc') as $sort) {
$query->orderBy($sort['column'], $sort['direction']);
}
// audit:allow repository-paginate-column-pruning reason: 본인인증 메시지 정의 테이블 — 정의 수가 고정이고 넓은 컬럼이 없다
return $query->paginate($perPage);
}
}
@@ -252,6 +252,7 @@ class IdentityPolicyRepository implements IdentityPolicyRepositoryInterface
});
}
// audit:allow repository-paginate-column-pruning reason: 본인인증 정책 정의 테이블 — 정책 수가 고정이고 넓은 컬럼이 없다
return $query->orderByDesc('created_at')->orderByDesc('id')->paginate($perPage);
}
@@ -4,7 +4,11 @@ namespace App\Repositories;
use App\Contracts\Repositories\IdentityVerificationLogRepositoryInterface;
use App\Enums\IdentityVerificationStatus;
use App\Helpers\TimezoneHelper;
use App\Models\IdentityPolicy;
use App\Models\IdentityVerificationLog;
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Carbon;
/**
@@ -14,6 +18,8 @@ use Illuminate\Support\Carbon;
*/
class IdentityVerificationLogRepository implements IdentityVerificationLogRepositoryInterface
{
use PaginatesWithDeferredJoin;
/**
* 검증 로그를 생성합니다.
*
@@ -130,7 +136,7 @@ class IdentityVerificationLogRepository implements IdentityVerificationLogReposi
*
* @param array $filters 검색 필터
* @param int $perPage 페이지당 항목 수
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 페이지네이터
* @return LengthAwarePaginator 페이지네이터
*/
public function search(array $filters, int $perPage = 20)
{
@@ -158,7 +164,7 @@ class IdentityVerificationLogRepository implements IdentityVerificationLogReposi
if (! empty($filters['source_type'])) {
$query->whereIn('origin_policy_key', function ($q) use ($filters) {
$q->select('key')
->from('identity_policies')
->from((new IdentityPolicy)->getTable())
->where('source_type', $filters['source_type']);
if (! empty($filters['source_identifier'])) {
$q->where('source_identifier', $filters['source_identifier']);
@@ -190,21 +196,30 @@ class IdentityVerificationLogRepository implements IdentityVerificationLogReposi
}
}
// 기간 필터는 사이트 타임존 기준으로 해석한다 — created_at 은 UTC 로 저장되고
// 화면은 사이트 타임존으로 보여주므로, 입력 문자열을 그대로 비교하면 하루가 어긋난다.
// 종료값은 시각 없이 날짜만 오는 경우(`<input type="date">`) 그날 끝까지 포함한다.
if (! empty($filters['date_from'])) {
$query->where('created_at', '>=', $filters['date_from']);
$query->where('created_at', '>=', TimezoneHelper::fromSiteDateTime($filters['date_from']));
}
if (! empty($filters['date_to'])) {
$query->where('created_at', '<=', $filters['date_to']);
$query->where('created_at', '<=', TimezoneHelper::fromSiteRangeEnd($filters['date_to']));
}
$sortBy = in_array($filters['sort_by'] ?? null, ['created_at', 'attempts'], true)
? $filters['sort_by']
: 'created_at';
$sortOrder = ($filters['sort_order'] ?? 'desc') === 'asc' ? 'asc' : 'desc';
$query->orderBy($sortBy, $sortOrder);
return $query->paginate($perPage);
// 본인인증 로그는 계속 쌓이는 이력이라 뒤쪽 페이지에서 OFFSET 비용이 커진다.
// 응답이 요청/응답 payload 를 그대로 노출하므로 컬럼은 좁히지 않고 지연 조인만 적용한다.
return $this->paginateWithDeferredJoin(
query: $query,
columns: ['*'],
sort: [['column' => $sortBy, 'direction' => $sortOrder]],
perPage: $perPage,
);
}
/**
+16 -9
View File
@@ -4,10 +4,13 @@ namespace App\Repositories;
use App\Contracts\Repositories\LanguagePackRepositoryInterface;
use App\Enums\LanguagePackScope;
use App\Enums\LanguagePackSourceType;
use App\Enums\LanguagePackStatus;
use App\Enums\TextDirection;
use App\Models\LanguagePack;
use App\Repositories\Concerns\HasMultipleSearchFilters;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
@@ -127,6 +130,7 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface
*/
public function paginate(array $filters = [], int $perPage = 20): LengthAwarePaginator
{
// audit:allow repository-paginate-column-pruning reason: 언어팩 정의 테이블 — 설치된 팩 수만큼만 존재하고 넓은 컬럼이 없다
return $this->buildFilteredQuery($filters)->paginate($perPage);
}
@@ -148,9 +152,9 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface
* (실제 미설치 번들은 Service 계층에서 합쳐집니다).
*
* @param array<string, mixed> $filters 필터 조건
* @return \Illuminate\Database\Eloquent\Builder<LanguagePack> 정렬까지 적용된 쿼리 빌더
* @return Builder<LanguagePack> 정렬까지 적용된 쿼리 빌더
*/
private function buildFilteredQuery(array $filters): \Illuminate\Database\Eloquent\Builder
private function buildFilteredQuery(array $filters): Builder
{
$query = LanguagePack::query();
@@ -165,7 +169,8 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface
}
if (! empty($filters['status'])) {
if ($filters['status'] === LanguagePackStatus::Uninstalled->value) {
$query->whereRaw('1 = 0');
// 결과 없음 — 빈 whereIn 은 `0 = 1` 로 컴파일된다(raw 불필요)
$query->whereIn($query->getModel()->getKeyName(), []);
} else {
$query->where('status', $filters['status']);
}
@@ -190,7 +195,9 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface
return $query
->orderBy('scope')
->orderBy('target_identifier')
->orderBy('locale');
->orderBy('locale')
// 전순서 보장 — 위 세 컬럼 조합이 유니크하더라도 정렬 계약을 키로 닫아 둔다
->orderBy('id');
}
/**
@@ -282,10 +289,10 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface
$pack->version = (string) ($manifest['version'] ?? '0.0.0');
$pack->license = $manifest['license'] ?? null;
$pack->description = is_array($manifest['description'] ?? null) ? $manifest['description'] : null;
$pack->status = \App\Enums\LanguagePackStatus::Uninstalled->value;
$pack->status = LanguagePackStatus::Uninstalled->value;
$pack->is_protected = false; // lang-packs/_bundled/ 패키지는 사용자가 install/uninstall 자유 (요구사항 #3)
$pack->manifest = $manifest;
$pack->source_type = \App\Enums\LanguagePackSourceType::Bundled->value;
$pack->source_type = LanguagePackSourceType::Bundled->value;
$pack->source_url = $bundledIdentifier;
$pack->setAttribute('bundled_identifier', $bundledIdentifier);
@@ -335,11 +342,11 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface
$pack->locale = $locale;
$pack->locale_name = strtoupper($locale);
$pack->locale_native_name = $nativeName;
$pack->text_direction = \App\Enums\TextDirection::Ltr->value;
$pack->text_direction = TextDirection::Ltr->value;
$pack->version = $version;
$pack->status = \App\Enums\LanguagePackStatus::Active->value;
$pack->status = LanguagePackStatus::Active->value;
$pack->is_protected = true;
$pack->source_type = \App\Enums\LanguagePackSourceType::BuiltIn->value;
$pack->source_type = LanguagePackSourceType::BuiltIn->value;
$pack->source_url = $langPathRelative;
$pack->manifest = [
'identifier' => $identifier,
+113 -17
View File
@@ -3,15 +3,31 @@
namespace App\Repositories;
use App\Contracts\Repositories\MenuRepositoryInterface;
use App\Helpers\PermissionHelper;
use App\Enums\ExtensionOwnerType;
use App\Helpers\PermissionHelper;
use App\Models\Menu;
use App\Models\Module;
use App\Models\Plugin;
use App\Models\User;
use App\Repositories\Concerns\ResolvesSortSpec;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
class MenuRepository implements MenuRepositoryInterface
{
use ResolvesSortSpec;
/** 허용 정렬 컬럼 (MenuListRequest 와 동일 집합) */
private const SORTABLE_COLUMNS = ['created_at', 'name', 'slug', 'order'];
/**
* 다국어 JSON 컬럼 목록 (검색·정렬 시 로케일 경로로 풀어야 하는 컬럼)
*
* @var array<int, string>
*/
private const TRANSLATABLE_COLUMNS = ['name'];
/**
* 모든 메뉴를 조회합니다.
*
@@ -37,6 +53,14 @@ class MenuRepository implements MenuRepositoryInterface
->get();
}
/**
* 관리용 최상위 메뉴 목록을 조회합니다. (비활성 확장 메뉴 제외)
*
* @param array $activeModuleIdentifiers 활성 모듈 식별자 목록
* @param array $activePluginIdentifiers 활성 플러그인 식별자 목록
* @param User|null $user 접근 권한 판정 대상 사용자 (null 이면 역할 필터 미적용)
* @return Collection 최상위 메뉴 컬렉션 (자식 메뉴 포함)
*/
public function getTopLevelMenusForManagement(array $activeModuleIdentifiers = [], array $activePluginIdentifiers = [], ?User $user = null): Collection
{
$extensionFilter = function ($query) use ($activeModuleIdentifiers, $activePluginIdentifiers) {
@@ -67,13 +91,13 @@ class MenuRepository implements MenuRepositoryInterface
}
return $query->with(['creator', 'roles', 'children' => function ($query) use ($extensionFilter, $user) {
$query->where($extensionFilter);
if ($user) {
$query->accessibleBy($user);
}
$query->with('roles')
->orderBy('order');
}])
$query->where($extensionFilter);
if ($user) {
$query->accessibleBy($user);
}
$query->with('roles')
->orderBy('order');
}])
->orderBy('order')
->get();
}
@@ -260,7 +284,7 @@ class MenuRepository implements MenuRepositoryInterface
$q->where('extension_type', ExtensionOwnerType::Module)
->whereExists(function ($subQuery) {
$subQuery->select(DB::raw(1))
->from('modules')
->from((new Module)->getTable())
->whereColumn('modules.identifier', 'menus.extension_identifier')
->where('modules.status', 'active');
});
@@ -270,7 +294,7 @@ class MenuRepository implements MenuRepositoryInterface
$q->where('extension_type', ExtensionOwnerType::Plugin)
->whereExists(function ($subQuery) {
$subQuery->select(DB::raw(1))
->from('plugins')
->from((new Plugin)->getTable())
->whereColumn('plugins.identifier', 'menus.extension_identifier')
->where('plugins.status', 'active');
});
@@ -359,6 +383,15 @@ class MenuRepository implements MenuRepositoryInterface
return (int) Menu::where('parent_id', $parentId)->max('order');
}
/**
* 필터 조건이 적용된 최상위 메뉴 목록을 조회합니다.
*
* @param array $filters 필터 조건 배열 (sort_by/sort_order/검색 필드)
* @param array $activeModuleIdentifiers 활성 모듈 식별자 목록
* @param array $activePluginIdentifiers 활성 플러그인 식별자 목록
* @param User|null $user 접근 권한 판정 대상 사용자 (null 이면 역할 필터 미적용)
* @return Collection 최상위 메뉴 컬렉션 (자식 메뉴 포함)
*/
public function getFilteredTopLevelMenus(array $filters, array $activeModuleIdentifiers = [], array $activePluginIdentifiers = [], ?User $user = null): Collection
{
$extensionFilter = function ($query) use ($activeModuleIdentifiers, $activePluginIdentifiers) {
@@ -408,20 +441,20 @@ class MenuRepository implements MenuRepositoryInterface
$query->where(function ($q) use ($searchableFields, $value, $operator) {
foreach ($searchableFields as $searchField) {
$q->orWhere(function ($subQ) use ($searchField, $value, $operator) {
$this->applyFilterOperator($subQ, $searchField, $value, $operator);
$this->applySearchableField($subQ, $searchField, $value, $operator);
});
}
});
} elseif (in_array($field, $searchableFields)) {
$this->applyFilterOperator($query, $field, $value, $operator);
$this->applySearchableField($query, $field, $value, $operator);
}
}
}
// 정렬
$sortBy = $filters['sort_by'] ?? 'order';
$sortOrder = $filters['sort_order'] ?? 'asc';
$query->orderBy($sortBy, $sortOrder);
// 정렬 (허용 컬럼 화이트리스트로 해석)
foreach ($this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'order', 'asc') as $sort) {
$query->orderBy($this->qualifySortColumn($sort['column']), $sort['direction']);
}
// 자식 메뉴도 동일한 조건 적용하여 로드
return $query->with(['creator', 'roles', 'children' => function ($childQuery) use ($extensionFilter, $filters, $user) {
@@ -444,11 +477,74 @@ class MenuRepository implements MenuRepositoryInterface
/**
* 필터 연산자를 쿼리에 적용합니다.
*
* @param \Illuminate\Database\Eloquent\Builder $query 쿼리 빌더
* @param Builder $query 쿼리 빌더
* @param string $field 필드명
* @param string $value 검색 값
* @param string $operator 연산자 (like, eq, starts_with, ends_with)
*/
/**
* 검색 대상 필드에 조건을 적용합니다. (다국어 JSON 컬럼 인지)
*
* `name` 은 다국어 JSON 컬럼이다. JSON 은 유니코드 이스케이프(`\uXXXX`)로 저장되므로
* 컬럼 전체에 LIKE 를 걸면 한글·일본어 검색어가 **절대 매칭되지 않는다**
* (저장된 바이트는 `{"ko":"대시보드"}` 이다).
* 지원 로케일별 JSON 경로(`name->ko`)로 나눠 검색한다 — 저장소 공통 관례다.
*
* @param Builder $query 쿼리 빌더
* @param string $field 필드명
* @param string $value 검색 값
* @param string $operator 연산자 (like, eq, starts_with, ends_with)
*/
private function applySearchableField($query, string $field, string $value, string $operator): void
{
if (! in_array($field, self::TRANSLATABLE_COLUMNS, true)) {
$this->applyFilterOperator($query, $field, $value, $operator);
return;
}
$query->where(function ($q) use ($field, $value, $operator) {
foreach ($this->translatableLocales() as $locale) {
$q->orWhere(function ($subQ) use ($field, $locale, $value, $operator) {
$this->applyFilterOperator($subQ, "{$field}->{$locale}", $value, $operator);
});
}
});
}
/**
* 정렬 컬럼이 다국어 JSON 컬럼이면 현재 로케일 경로로 바꿉니다.
*
* JSON 원문으로 정렬하면 `{"ko":"\uXXXX...` 문자열 순서가 되어 사람이 읽는 순서와
* 무관해진다. 화면에 보이는 값(현재 로케일)으로 정렬해야 목록 순서가 납득 가능해진다.
*
* @param string $column 정렬 컬럼
* @return string 실제 정렬에 쓸 컬럼 표현
*/
private function qualifySortColumn(string $column): string
{
if (! in_array($column, self::TRANSLATABLE_COLUMNS, true)) {
return $column;
}
return $column.'->'.app()->getLocale();
}
/**
* 다국어 필드가 값을 가질 수 있는 로케일 목록을 반환합니다.
*
* 다국어 JSON 필드의 언어 집합은 `translatable_locales` 다 — 번역 파일이 없어도
* 데이터는 저장될 수 있으므로 UI 표시 언어(`supported_locales`)보다 넓다.
*
* @return array<int, string> 로케일 코드 목록
*/
private function translatableLocales(): array
{
$locales = config('app.translatable_locales', config('app.supported_locales', []));
return empty($locales) ? [app()->getLocale()] : array_values($locales);
}
private function applyFilterOperator($query, string $field, string $value, string $operator): void
{
switch ($operator) {
@@ -4,15 +4,28 @@ namespace App\Repositories;
use App\Contracts\Repositories\NotificationDefinitionRepositoryInterface;
use App\Models\NotificationDefinition;
use App\Repositories\Concerns\ResolvesSortSpec;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
class NotificationDefinitionRepository implements NotificationDefinitionRepositoryInterface
{
use ResolvesSortSpec;
/** 허용 정렬 컬럼 (NotificationDefinitionIndexRequest 와 동일 집합) */
private const SORTABLE_COLUMNS = [
'id',
'type',
'extension_type',
'is_active',
'created_at',
'updated_at',
];
/**
* ID로 알림 정의 조회.
*
* @param int $id
* @param int $id
* @return NotificationDefinition|null
*/
public function findById(int $id): ?NotificationDefinition
@@ -23,7 +36,7 @@ class NotificationDefinitionRepository implements NotificationDefinitionReposito
/**
* 타입으로 알림 정의 조회.
*
* @param string $type
* @param string $type
* @return NotificationDefinition|null
*/
public function findByType(string $type): ?NotificationDefinition
@@ -34,7 +47,7 @@ class NotificationDefinitionRepository implements NotificationDefinitionReposito
/**
* 활성 상태인 특정 타입 알림 정의 조회.
*
* @param string $type
* @param string $type
* @return NotificationDefinition|null
*/
public function getActiveByType(string $type): ?NotificationDefinition
@@ -58,7 +71,7 @@ class NotificationDefinitionRepository implements NotificationDefinitionReposito
* 알림 목록 응답에서 N+1 회피 목적으로 한 번 호출하여 [type => label] 맵을 생성합니다.
* 라벨 fallback 우선순위: 사용자 로케일 → ko → en → type 식별자
*
* @param string|null $locale 사용자 로케일 (null이면 app()->getLocale())
* @param string|null $locale 사용자 로케일 (null이면 app()->getLocale())
* @return array<string, string>
*/
public function getLabelMap(?string $locale = null): array
@@ -86,8 +99,8 @@ class NotificationDefinitionRepository implements NotificationDefinitionReposito
/**
* 특정 확장의 알림 정의 목록 조회.
*
* @param string $extensionType
* @param string $extensionIdentifier
* @param string $extensionType
* @param string $extensionIdentifier
* @return Collection
*/
public function getByExtension(string $extensionType, string $extensionIdentifier): Collection
@@ -98,8 +111,8 @@ class NotificationDefinitionRepository implements NotificationDefinitionReposito
/**
* 알림 정의 수정.
*
* @param NotificationDefinition $definition
* @param array $data
* @param NotificationDefinition $definition
* @param array $data
* @return NotificationDefinition
*/
public function update(NotificationDefinition $definition, array $data): NotificationDefinition
@@ -112,8 +125,8 @@ class NotificationDefinitionRepository implements NotificationDefinitionReposito
/**
* 페이지네이션 목록 조회.
*
* @param array $filters
* @param int $perPage
* @param array $filters
* @param int $perPage
* @return LengthAwarePaginator
*/
public function getPaginated(array $filters = [], int $perPage = 20): LengthAwarePaginator
@@ -148,10 +161,11 @@ class NotificationDefinitionRepository implements NotificationDefinitionReposito
});
}
$sortBy = $filters['sort_by'] ?? 'id';
$sortOrder = $filters['sort_order'] ?? 'asc';
$query->orderBy($sortBy, $sortOrder);
foreach ($this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'id', 'asc') as $sort) {
$query->orderBy($sort['column'], $sort['direction']);
}
// audit:allow repository-paginate-column-pruning reason: 알림 정의 테이블 — 정의 수가 고정이고 넓은 컬럼이 없다
return $query->paginate($perPage);
}
}
+37 -5
View File
@@ -6,12 +6,36 @@ use App\Contracts\Repositories\NotificationLogRepositoryInterface;
use App\Enums\NotificationLogStatus;
use App\Models\NotificationLog;
use App\Models\User;
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use App\Repositories\Concerns\ResolvesSortSpec;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
class NotificationLogRepository implements NotificationLogRepositoryInterface
{
use PaginatesWithDeferredJoin;
use ResolvesSortSpec;
/**
* 발송 이력 목록 정렬 허용 컬럼
*
* 요청 값을 그대로 orderBy 에 넘기면 없는 컬럼으로 SQL 오류가 나거나 인덱스 없는 컬럼
* 정렬을 강제할 수 있다.
*
* @var array<int, string>
*/
private const SORTABLE_COLUMNS = [
'id',
'sent_at',
'created_at',
'status',
'channel',
'notification_type',
'recipient_name',
'subject',
];
/**
* 최근 발송된 알림 로그를 발송 시각 최신순으로 조회합니다 (대시보드 최근 알림).
*
@@ -81,7 +105,8 @@ class NotificationLogRepository implements NotificationLogRepositoryInterface
*/
public function getPaginated(array $filters = [], int $perPage = 20, ?User $scopeUser = null): LengthAwarePaginator
{
$query = NotificationLog::with(['senderUser', 'recipientUser']);
// 관계는 지연 조인의 outer 에서만 로드한다 (inner 는 키 컬럼만 조회한다)
$query = NotificationLog::query();
// notification-logs scope: 전달된 사용자의 권한 스코프 적용
if ($scopeUser) {
@@ -125,11 +150,18 @@ class NotificationLogRepository implements NotificationLogRepositoryInterface
});
}
$sortBy = $filters['sort_by'] ?? 'sent_at';
$sortOrder = $filters['sort_order'] ?? 'desc';
$query->orderBy($sortBy, $sortOrder);
$sort = $this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'sent_at');
return $query->paginate($perPage);
// 목록 컬럼을 좁히지 않는 이유: 이 목록의 리소스는 렌더링된 본문(longText `body`)까지
// 그대로 노출하므로 컬럼을 빼면 응답 계약이 바뀐다. 지연 조인만으로도 본문을 읽는
// 행 수가 OFFSET 과 무관하게 이번 페이지 분량으로 고정된다.
return $this->paginateWithDeferredJoin(
query: $query,
columns: ['*'],
sort: $sort,
perPage: $perPage,
relations: ['senderUser', 'recipientUser'],
);
}
/**
+32 -2
View File
@@ -11,6 +11,11 @@ class NotificationRepository implements NotificationRepositoryInterface
{
/**
* 사용자의 알림 목록을 페이지네이션으로 조회합니다.
*
* @param User $user 대상 사용자
* @param array $filters 필터 조건 (read: unread|read)
* @param int $perPage 페이지당 항목 수
* @return LengthAwarePaginator 페이지네이션된 알림 목록
*/
public function getByUser(User $user, array $filters = [], int $perPage = 20): LengthAwarePaginator
{
@@ -22,11 +27,19 @@ class NotificationRepository implements NotificationRepositoryInterface
$query->whereNotNull('read_at');
}
return $query->orderBy('created_at', 'desc')->paginate($perPage);
// audit:allow repository-paginate-column-pruning reason: 사용자 1명에 종속된 알림함 —
// $user->notifications() 로 이미 좁혀지고, 목록이 표시하는 본문 자체가 data(JSON) 라
// 걷어낼 넓은 컬럼이 없다
// 정렬 마지막의 기본키는 전순서 보장용이다 — created_at 동률에서 페이지 경계가
// 흔들려 인접 페이지가 같은 알림을 중복 노출하고 다른 알림을 누락하는 것을 막는다.
return $query->orderBy('created_at', 'desc')->orderBy('id', 'desc')->paginate($perPage);
}
/**
* 사용자의 미읽음 알림 수를 반환합니다.
*
* @param User $user 대상 사용자
* @return int 미읽음 알림 수
*/
public function getUnreadCount(User $user): int
{
@@ -35,6 +48,10 @@ class NotificationRepository implements NotificationRepositoryInterface
/**
* 특정 알림을 읽음 처리합니다.
*
* @param User $user 대상 사용자
* @param string $notificationId 알림 ID
* @return DatabaseNotification|null 읽음 처리된 알림 (없으면 null)
*/
public function markAsRead(User $user, string $notificationId): ?DatabaseNotification
{
@@ -51,6 +68,10 @@ class NotificationRepository implements NotificationRepositoryInterface
/**
* 지정된 알림들을 일괄 읽음 처리합니다.
*
* @param User $user 대상 사용자
* @param array $ids 알림 ID 배열
* @return int 읽음 처리된 알림 수
*/
public function markBatchAsRead(User $user, array $ids): int
{
@@ -61,6 +82,9 @@ class NotificationRepository implements NotificationRepositoryInterface
/**
* 사용자의 모든 미읽음 알림을 읽음 처리합니다.
*
* @param User $user 대상 사용자
* @return int 읽음 처리된 알림 수
*/
public function markAllAsRead(User $user): int
{
@@ -73,6 +97,10 @@ class NotificationRepository implements NotificationRepositoryInterface
/**
* 알림을 삭제합니다.
*
* @param User $user 대상 사용자
* @param string $notificationId 알림 ID
* @return bool 삭제 성공 여부 (알림이 없으면 false)
*/
public function delete(User $user, string $notificationId): bool
{
@@ -88,7 +116,7 @@ class NotificationRepository implements NotificationRepositoryInterface
/**
* 사용자의 모든 알림을 삭제합니다.
*
* @param User $user
* @param User $user
* @return int 삭제된 알림 수
*/
public function deleteAll(User $user): int
@@ -99,6 +127,8 @@ class NotificationRepository implements NotificationRepositoryInterface
/**
* 오래된 알림을 정리합니다.
*
* @param int $readRetentionDays 읽은 알림 보관 일수 (0 이하면 정리하지 않음)
* @param int $unreadRetentionDays 미읽음 알림 보관 일수 (0 이하면 정리하지 않음)
* @return array{deleted_read: int, deleted_unread: int}
*/
public function cleanup(int $readRetentionDays, int $unreadRetentionDays): array
+6 -1
View File
@@ -215,9 +215,14 @@ class RoleRepository implements RoleRepositoryInterface
}
// 정렬 (코어/확장 소유 역할 먼저, 그 다음 사용자 생성 역할)
// 정렬 마지막의 기본키는 전순서 보장용이다. 시더가 일괄 생성한 역할은 created_at 이
// 한 타임스탬프에 몰려 있어(실측: 한 값에 최대 10행) 키가 없으면 동률 구간의 순서가
// 페이지마다 달라지고, 인접 페이지가 같은 역할을 중복 노출하면서 다른 역할을 누락한다.
$query->orderByRaw('CASE WHEN extension_type IS NOT NULL THEN 0 ELSE 1 END')
->orderBy('created_at', 'desc');
->orderBy('created_at', 'desc')
->orderBy('id', 'desc');
// audit:allow repository-paginate-column-pruning reason: 역할 정의 테이블 — 행 수가 운영상 고정(수십 건)이고 넓은 컬럼이 없다
return $query->paginate($perPage);
}
+50 -27
View File
@@ -4,15 +4,21 @@ namespace App\Repositories;
use App\Contracts\Repositories\ScheduleHistoryRepositoryInterface;
use App\Models\ScheduleHistory;
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use App\Repositories\Concerns\ResolvesSortSpec;
use Carbon\Carbon;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface
{
use PaginatesWithDeferredJoin;
use ResolvesSortSpec;
/**
* ID로 실행 이력을 찾습니다.
*
* @param int $id 이력 ID
* @param int $id 이력 ID
* @return ScheduleHistory|null 찾은 이력 모델 또는 null
*/
public function findById(int $id): ?ScheduleHistory
@@ -23,7 +29,7 @@ class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface
/**
* 새로운 실행 이력을 생성합니다.
*
* @param array $data 이력 생성 데이터
* @param array $data 이력 생성 데이터
* @return ScheduleHistory 생성된 이력 모델
*/
public function create(array $data): ScheduleHistory
@@ -34,8 +40,8 @@ class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface
/**
* 기존 실행 이력을 업데이트합니다.
*
* @param ScheduleHistory $history 업데이트할 이력 모델
* @param array $data 업데이트할 데이터
* @param ScheduleHistory $history 업데이트할 이력 모델
* @param array $data 업데이트할 데이터
* @return bool 업데이트 성공 여부
*/
public function update(ScheduleHistory $history, array $data): bool
@@ -46,7 +52,7 @@ class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface
/**
* 실행 이력을 삭제합니다.
*
* @param ScheduleHistory $history 삭제할 이력 모델
* @param ScheduleHistory $history 삭제할 이력 모델
* @return bool 삭제 성공 여부
*/
public function delete(ScheduleHistory $history): bool
@@ -57,50 +63,67 @@ class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface
/**
* 특정 스케줄의 실행 이력을 페이지네이션하여 조회합니다.
*
* @param int $scheduleId 스케줄 ID
* @param array $filters 필터 조건 배열
* @param int $scheduleId 스케줄 ID
* @param array $filters 필터 조건 배열
* @return LengthAwarePaginator 페이지네이션된 이력 목록
*/
public function getPaginatedByScheduleId(int $scheduleId, array $filters = []): LengthAwarePaginator
{
$query = ScheduleHistory::with('triggeredBy')
// 관계는 쿼리에 미리 붙이지 않는다 — 지연 조인 트레이트가 inner/outer 양쪽에서
// setEagerLoads([]) 로 지우므로, 여기서 with() 하면 outer 에서도 사라져
// ScheduleHistoryResource 의 whenLoaded('triggeredBy') 가 항상 비게 된다.
// 로드는 relations: 인자로 넘긴다.
$query = ScheduleHistory::query()
->where('schedule_id', $scheduleId);
// 상태 필터
if (!empty($filters['status'])) {
if (! empty($filters['status'])) {
$query->where('status', $filters['status']);
}
// 트리거 유형 필터
if (!empty($filters['trigger_type'])) {
if (! empty($filters['trigger_type'])) {
$query->where('trigger_type', $filters['trigger_type']);
}
// 날짜 필터
if (!empty($filters['started_from'])) {
$query->whereDate('started_at', '>=', $filters['started_from']);
// 기간 필터 — whereDate 는 컬럼에 DATE() 를 씌워 인덱스를 못 쓰게 만든다.
// 같은 결과를 내는 범위 조건으로 바꿔 started_at 인덱스를 살린다
// (종료일은 그날 23:59:59.999999 까지 포함해야 whereDate 와 동일한 경계를 갖는다).
if (! empty($filters['started_from'])) {
$query->where('started_at', '>=', Carbon::parse($filters['started_from'])->startOfDay());
}
if (!empty($filters['started_to'])) {
$query->whereDate('started_at', '<=', $filters['started_to']);
if (! empty($filters['started_to'])) {
$query->where('started_at', '<=', Carbon::parse($filters['started_to'])->endOfDay());
}
// 정렬 적용
$sortBy = $filters['sort_by'] ?? 'started_at';
$sortOrder = $filters['sort_order'] ?? 'desc';
$query->orderBy($sortBy, $sortOrder);
// 정렬 — 요청 값은 허용 목록으로만 해석한다.
// 허용 집합은 ScheduleHistoryListRequest 의 `in:` 규칙과 동일해야 한다. 저장소 목록이
// 그보다 좁으면 게이트를 통과한 정렬이 조용히 기본값으로 되돌아간다 (종료시각/소요시간).
$sort = $this->resolveSortSpec(
$filters,
['started_at', 'ended_at', 'duration', 'status'],
'started_at'
);
// 페이지네이션 적용
$perPage = $filters['per_page'] ?? 15;
$perPage = (int) ($filters['per_page'] ?? 15);
return $query->paginate($perPage);
// 실행 이력은 계속 쌓이며 출력/에러 메시지를 그대로 노출하므로, 컬럼은 좁히지 않고
// 지연 조인으로 넓은 컬럼을 읽는 행 수만 이번 페이지 분량으로 고정한다.
return $this->paginateWithDeferredJoin(
query: $query,
columns: ['*'],
sort: $sort,
perPage: $perPage,
relations: ['triggeredBy'],
);
}
/**
* 특정 스케줄의 최근 실행 이력을 조회합니다.
*
* @param int $scheduleId 스케줄 ID
* @param int $limit 조회 개수
* @param int $scheduleId 스케줄 ID
* @param int $limit 조회 개수
* @return Collection 최근 이력 컬렉션
*/
public function getRecentByScheduleId(int $scheduleId, int $limit = 10): Collection
@@ -115,7 +138,7 @@ class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface
/**
* 여러 이력을 일괄 삭제합니다.
*
* @param array $ids 이력 ID 배열
* @param array $ids 이력 ID 배열
* @return int 삭제된 레코드 수
*/
public function bulkDelete(array $ids): int
@@ -126,7 +149,7 @@ class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface
/**
* 특정 스케줄의 모든 이력을 삭제합니다.
*
* @param int $scheduleId 스케줄 ID
* @param int $scheduleId 스케줄 ID
* @return int 삭제된 레코드 수
*/
public function deleteByScheduleId(int $scheduleId): int
@@ -137,7 +160,7 @@ class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface
/**
* 특정 기간 이전의 이력을 삭제합니다.
*
* @param int $days 보관 기간 (일)
* @param int $days 보관 기간 (일)
* @return int 삭제된 레코드 수
*/
public function deleteOlderThan(int $days): int
+44 -29
View File
@@ -3,12 +3,13 @@
namespace App\Repositories;
use App\Contracts\Repositories\ScheduleRepositoryInterface;
use App\Helpers\PermissionHelper;
use App\Enums\ScheduleFrequency;
use App\Enums\ScheduleResultStatus;
use App\Enums\ScheduleType;
use App\Helpers\PermissionHelper;
use App\Models\Schedule;
use App\Repositories\Concerns\HasMultipleSearchFilters;
use App\Repositories\Concerns\ResolvesSortSpec;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
@@ -16,16 +17,29 @@ use Illuminate\Database\Eloquent\Collection;
class ScheduleRepository implements ScheduleRepositoryInterface
{
use HasMultipleSearchFilters;
use ResolvesSortSpec;
/**
* 검색 가능한 필드 목록
*/
private const SEARCHABLE_FIELDS = ['name', 'description', 'command'];
/**
* 허용 정렬 컬럼 (ScheduleListRequest 와 동일 집합)
*/
private const SORTABLE_COLUMNS = [
'created_at',
'name',
'next_run_at',
'last_run_at',
'is_active',
'last_result',
];
/**
* ID로 스케줄을 찾습니다.
*
* @param int $id 스케줄 ID
* @param int $id 스케줄 ID
* @return Schedule|null 찾은 스케줄 모델 또는 null
*/
public function findById(int $id): ?Schedule
@@ -36,7 +50,7 @@ class ScheduleRepository implements ScheduleRepositoryInterface
/**
* 새로운 스케줄을 생성합니다.
*
* @param array $data 스케줄 생성 데이터
* @param array $data 스케줄 생성 데이터
* @return Schedule 생성된 스케줄 모델
*/
public function create(array $data): Schedule
@@ -47,8 +61,8 @@ class ScheduleRepository implements ScheduleRepositoryInterface
/**
* 기존 스케줄을 업데이트합니다.
*
* @param Schedule $schedule 업데이트할 스케줄 모델
* @param array $data 업데이트할 데이터
* @param Schedule $schedule 업데이트할 스케줄 모델
* @param array $data 업데이트할 데이터
* @return bool 업데이트 성공 여부
*/
public function update(Schedule $schedule, array $data): bool
@@ -59,7 +73,7 @@ class ScheduleRepository implements ScheduleRepositoryInterface
/**
* 스케줄을 삭제합니다.
*
* @param Schedule $schedule 삭제할 스케줄 모델
* @param Schedule $schedule 삭제할 스케줄 모델
* @return bool 삭제 성공 여부
*/
public function delete(Schedule $schedule): bool
@@ -80,7 +94,7 @@ class ScheduleRepository implements ScheduleRepositoryInterface
/**
* 필터링 및 페이지네이션이 적용된 스케줄 목록을 조회합니다.
*
* @param array $filters 필터 조건 배열
* @param array $filters 필터 조건 배열
* @return LengthAwarePaginator 페이지네이션된 스케줄 목록
*/
public function getPaginatedSchedules(array $filters = []): LengthAwarePaginator
@@ -93,33 +107,34 @@ class ScheduleRepository implements ScheduleRepositoryInterface
// 필터 조건 적용
$this->applyFilters($query, $filters);
// 정렬 적용
$sortBy = $filters['sort_by'] ?? 'created_at';
$sortOrder = $filters['sort_order'] ?? 'desc';
$query->orderBy($sortBy, $sortOrder);
// 정렬 적용 (허용 컬럼 화이트리스트로 해석)
foreach ($this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'created_at') as $sort) {
$query->orderBy($sort['column'], $sort['direction']);
}
// 페이지네이션 적용
$perPage = $filters['per_page'] ?? 15;
// audit:allow repository-paginate-column-pruning reason: 스케줄 정의 테이블 — 등록된 스케줄 수만큼만 존재하고 넓은 컬럼이 없다
return $query->paginate($perPage);
}
/**
* 쿼리에 필터 조건을 적용합니다.
*
* @param Builder $query Eloquent 쿼리 빌더
* @param array $filters 적용할 필터 조건 배열
* @param Builder $query Eloquent 쿼리 빌더
* @param array $filters 적용할 필터 조건 배열
* @return void
*/
private function applyFilters(Builder $query, array $filters): void
{
// 다중 검색 조건 적용
if (!empty($filters['filters']) && is_array($filters['filters'])) {
if (! empty($filters['filters']) && is_array($filters['filters'])) {
$this->applyMultipleSearchFilters($query, $filters['filters'], self::SEARCHABLE_FIELDS);
}
// 타입 필터
if (!empty($filters['type'])) {
if (! empty($filters['type'])) {
$type = ScheduleType::tryFrom($filters['type']);
if ($type) {
$query->where('type', $type);
@@ -127,7 +142,7 @@ class ScheduleRepository implements ScheduleRepositoryInterface
}
// 주기 필터
if (!empty($filters['frequency'])) {
if (! empty($filters['frequency'])) {
$frequency = ScheduleFrequency::tryFrom($filters['frequency']);
if ($frequency) {
$query->where('frequency', $frequency);
@@ -135,7 +150,7 @@ class ScheduleRepository implements ScheduleRepositoryInterface
}
// 상태 필터
if (!empty($filters['status'])) {
if (! empty($filters['status'])) {
if ($filters['status'] === 'active') {
$query->where('is_active', true);
} elseif ($filters['status'] === 'inactive') {
@@ -144,7 +159,7 @@ class ScheduleRepository implements ScheduleRepositoryInterface
}
// 마지막 실행 결과 필터
if (!empty($filters['last_result'])) {
if (! empty($filters['last_result'])) {
$result = ScheduleResultStatus::tryFrom($filters['last_result']);
if ($result) {
$query->where('last_result', $result);
@@ -162,10 +177,10 @@ class ScheduleRepository implements ScheduleRepositoryInterface
}
// 확장 타입 필터
if (!empty($filters['extension_type'])) {
if (! empty($filters['extension_type'])) {
$query->where('extension_type', $filters['extension_type']);
if (!empty($filters['extension_identifier'])) {
if (! empty($filters['extension_identifier'])) {
$query->where('extension_identifier', $filters['extension_identifier']);
}
}
@@ -177,17 +192,17 @@ class ScheduleRepository implements ScheduleRepositoryInterface
/**
* 날짜 필터를 적용합니다.
*
* @param Builder $query Eloquent 쿼리 빌더
* @param array $filters 필터 조건 배열
* @param Builder $query Eloquent 쿼리 빌더
* @param array $filters 필터 조건 배열
* @return void
*/
private function applyDateFilters(Builder $query, array $filters): void
{
if (!empty($filters['created_from'])) {
if (! empty($filters['created_from'])) {
$query->whereDate('created_at', '>=', $filters['created_from']);
}
if (!empty($filters['created_to'])) {
if (! empty($filters['created_to'])) {
$query->whereDate('created_at', '<=', $filters['created_to']);
}
}
@@ -233,8 +248,8 @@ class ScheduleRepository implements ScheduleRepositoryInterface
/**
* 여러 스케줄의 상태를 일괄 업데이트합니다.
*
* @param array $ids 스케줄 ID 배열
* @param bool $isActive 활성화 여부
* @param array $ids 스케줄 ID 배열
* @param bool $isActive 활성화 여부
* @return int 업데이트된 레코드 수
*/
public function bulkUpdateStatus(array $ids, bool $isActive): int
@@ -245,7 +260,7 @@ class ScheduleRepository implements ScheduleRepositoryInterface
/**
* 여러 스케줄을 일괄 삭제합니다.
*
* @param array $ids 스케줄 ID 배열
* @param array $ids 스케줄 ID 배열
* @return int 삭제된 레코드 수
*/
public function bulkDelete(array $ids): int
@@ -256,13 +271,13 @@ class ScheduleRepository implements ScheduleRepositoryInterface
/**
* 스케줄을 복제합니다.
*
* @param Schedule $schedule 복제할 스케줄
* @param Schedule $schedule 복제할 스케줄
* @return Schedule 복제된 스케줄
*/
public function duplicate(Schedule $schedule): Schedule
{
$newSchedule = $schedule->replicate();
$newSchedule->name = $schedule->name . ' (' . __('schedule.copy') . ')';
$newSchedule->name = $schedule->name.' ('.__('schedule.copy').')';
$newSchedule->is_active = false;
$newSchedule->last_result = ScheduleResultStatus::Never;
$newSchedule->last_run_at = null;
+19 -8
View File
@@ -6,6 +6,8 @@ use App\Contracts\Repositories\UserRepositoryInterface;
use App\Helpers\PermissionHelper;
use App\Models\User;
use App\Repositories\Concerns\HasMultipleSearchFilters;
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use App\Repositories\Concerns\ResolvesSortSpec;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
@@ -15,12 +17,19 @@ use Laravel\Sanctum\PersonalAccessToken;
class UserRepository implements UserRepositoryInterface
{
use HasMultipleSearchFilters;
use PaginatesWithDeferredJoin;
use ResolvesSortSpec;
/**
* 검색 가능한 필드 목록
*/
private const SEARCHABLE_FIELDS = ['name', 'email'];
/**
* 허용 정렬 컬럼 (UserListRequest 와 동일 집합)
*/
private const SORTABLE_COLUMNS = ['created_at', 'name', 'email', 'last_login_at'];
/**
* 이메일로 사용자를 찾습니다.
*
@@ -100,21 +109,23 @@ class UserRepository implements UserRepositoryInterface
// 권한 스코프 필터링
PermissionHelper::applyPermissionScope($query, 'core.users.read');
// roles 관계 eager loading
$query->with('roles');
// 검색 조건 적용
$this->applyFilters($query, $filters);
// 정렬 적용
$sortBy = $filters['sort_by'] ?? 'created_at';
$sortOrder = $filters['sort_order'] ?? 'desc';
$query->orderBy($sortBy, $sortOrder);
// 정렬 적용 (허용 컬럼 화이트리스트로 해석)
$sort = $this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'created_at');
// 페이지네이션 적용
$perPage = $filters['per_page'] ?? 15;
return $query->paginate($perPage);
// 지연 조인: inner 는 id 만 훑고 roles eager loading 은 해당 페이지에만 적용된다
return $this->paginateWithDeferredJoin(
query: $query,
columns: ['*'],
sort: $sort,
perPage: $perPage,
relations: ['roles'],
);
}
/**
@@ -0,0 +1,78 @@
<?php
namespace App\Search\Contracts;
use App\Search\DTO\SearchIndexHealth;
/**
* 검색 인덱스 점검·재생성 계약
*
* Scout 엔진마다 "인덱스" 의 실체가 다릅니다 — MySQL FULLTEXT 는 테이블에 붙은 인덱스,
* Meilisearch/Elasticsearch 는 외부 서버의 인덱스입니다. 그래서 코어는 **무엇을 어떻게
* 점검·재생성할지 모르는 채** 이 계약만 호출하고, 판정 방법은 각 엔진 제공자가 정합니다.
*
* 확장(모듈/플러그인)이 자체 검색 엔진을 등록할 때 이 계약의 구현을 함께 등록하면
* 관리자 화면·커맨드·업데이트 절차가 코어 수정 없이 그 엔진을 다룹니다.
* 등록은 `core.search.index_maintainers` 필터 훅에 `드라이버명 => 구현 클래스` 로 합니다.
*
* ```php
* HookManager::addFilter('core.search.index_maintainers', function (array $maintainers) {
* $maintainers['meilisearch'] = MeilisearchIndexMaintainer::class;
*
* return $maintainers;
* });
* ```
*
* 구현하지 않은 엔진은 점검 대상에서 제외될 뿐 검색 자체는 정상 동작합니다.
*/
interface SearchIndexMaintainer
{
/**
* 이 유지보수기가 담당하는 Scout 드라이버명을 반환합니다.
*
* `config('scout.driver')` 값과 대조해 활성 엔진의 유지보수기를 고릅니다.
*
* @return string 드라이버명 (예: mysql-fulltext)
*/
public function driver(): string;
/**
* 현재 환경에서 점검을 수행할 수 있는지 반환합니다.
*
* 예) FULLTEXT 미지원 DBMS, 외부 검색 서버 미연결 등에서 false.
*
* @return bool 점검 가능 여부
*/
public function isAvailable(): bool;
/**
* 점검 불가 사유를 반환합니다.
*
* `isAvailable()` 이 false 일 때 운영자에게 보여 줄 문장입니다.
* 사유를 남기지 않으면 "점검 대상 0" 과 "점검할 수 없었음" 이 구분되지 않습니다.
*
* @return string|null 사유 (사용 가능하면 null)
*/
public function unavailableReason(): ?string;
/**
* 관리 중인 인덱스의 건강도를 판정합니다.
*
* @param array<string, mixed> $filters 엔진별 필터 (예: ['table' => 'pages'])
* @return array<int, SearchIndexHealth> 인덱스별 판정 결과
*/
public function inspect(array $filters = []): array;
/**
* 재생성이 필요하다고 판정된 인덱스를 재생성합니다.
*
* 재생성은 비용이 크고 서비스에 영향을 줄 수 있으므로, 호출자는 반드시
* 운영자의 명시적 선택을 받은 뒤에만 호출합니다.
*
* @param SearchIndexHealth $health 재생성 대상 판정 결과
* @return void
*
* @throws \Throwable 재생성 실패 시
*/
public function rebuild(SearchIndexHealth $health): void;
}
@@ -0,0 +1,65 @@
<?php
namespace App\Search\DTO;
/**
* FULLTEXT 인덱스 정의 (Value Object)
*
* `INFORMATION_SCHEMA` + `SHOW CREATE TABLE` 에서 읽어 온 실제 인덱스 구성입니다.
* 재생성 시 이 값을 그대로 다시 써서 검색 동작이 달라지지 않게 합니다.
*/
readonly class FulltextIndexDefinition
{
/**
* @param string $table 테이블명 (프리픽스 포함 — 스키마에서 읽은 실제 이름)
* @param string $name 인덱스명
* @param array<int, string> $columns 색인 컬럼 목록
* @param string|null $parser 파서명 (예: ngram). 지정되지 않은 인덱스는 null
*/
public function __construct(
public string $table,
public string $name,
public array $columns,
public ?string $parser = null,
) {}
/**
* 백틱을 두른 테이블명을 반환합니다.
*
* @return string 예: `g7_pages`
*/
public function quotedTable(): string
{
return '`'.$this->table.'`';
}
/**
* 백틱을 두른 컬럼 목록을 반환합니다.
*
* @return string 예: `title`, `content`
*/
public function quotedColumns(): string
{
return '`'.implode('`, `', $this->columns).'`';
}
/**
* `ALTER TABLE ... ADD FULLTEXT` 에 붙일 파서 절을 반환합니다.
*
* @return string 파서가 없으면 빈 문자열
*/
public function parserClause(): string
{
return $this->parser === null ? '' : ' WITH PARSER '.$this->parser;
}
/**
* 사람이 읽는 식별자를 반환합니다.
*
* @return string 예: g7_pages.ft_pages_title
*/
public function label(): string
{
return $this->table.'.'.$this->name;
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace App\Search\DTO;
use App\Enums\SearchIndexStatus;
/**
* 검색 인덱스 건강도 판정 결과 (Value Object · 엔진 중립)
*
* 어떤 엔진이든 공통으로 답할 수 있는 것만 필드로 둡니다 — 무엇을(`identifier`),
* 어떤 상태로(`status`), 무엇을 근거로(`measurement`) 판정했는가.
*
* 엔진마다 다른 세부(FULLTEXT 의 테이블·컬럼·파서, 외부 엔진의 샤드·문서 수 등)는
* `details` 에 담습니다. 코어가 그 내용을 해석하지 않으므로 새 엔진이 추가되어도
* 코어를 고칠 일이 없습니다.
*
* `context` 는 엔진 구현이 재생성에 필요한 자기 정보를 실어 두는 자리입니다
* (코어는 그대로 되돌려 줄 뿐 들여다보지 않습니다).
*/
readonly class SearchIndexHealth
{
/**
* @param string $driver 판정을 만든 Scout 드라이버명
* @param string $identifier 인덱스 식별자 (엔진 내에서 유일)
* @param SearchIndexStatus $status 판정 등급
* @param string $measurement 판정 근거 수치·사유 (사람이 읽는 한 줄)
* @param array<string, mixed> $details 엔진별 부가 정보 (표시 전용)
* @param array<string, mixed> $context 재생성에 필요한 엔진 내부 정보
*/
public function __construct(
public string $driver,
public string $identifier,
public SearchIndexStatus $status,
public string $measurement = '',
public array $details = [],
public array $context = [],
) {}
/**
* 재생성 대상인지 여부.
*
* @return bool
*/
public function needsRebuild(): bool
{
return $this->status === SearchIndexStatus::Stale;
}
/**
* 기계 판독용 배열로 반환합니다.
*
* `context` 는 엔진 내부 정보라 응답에 싣지 않습니다.
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'driver' => $this->driver,
'identifier' => $this->identifier,
'status' => $this->status->value,
'status_label' => $this->status->label(),
'measurement' => $this->measurement,
'details' => $this->details,
];
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
namespace App\Search\DTO;
/**
* 검색 인덱스 재생성 실행 보고 (Value Object · 엔진 중립)
*
* 커맨드는 콘솔 출력으로, 컨트롤러는 API 응답으로 같은 값을 씁니다.
*
* `remaining` 은 재생성 **후 다시 점검한** 잔존 목록입니다 — "재생성했다" 와
* "복구됐다" 는 다른 사실이라 따로 담습니다.
*/
readonly class SearchIndexRepairReport
{
/**
* @param string $driver 대상 Scout 드라이버명
* @param int $inspected 점검한 인덱스 수
* @param array<int, string> $repaired 재생성 성공 인덱스 식별자
* @param array<string, string> $failed 재생성 실패 [식별자 => 사유]
* @param array<int, string> $remaining 재생성 후에도 색인 누락으로 남은 식별자
* @param bool $available 이 엔진에서 점검을 수행할 수 있었는지
* @param string|null $reason 수행 불가 사유 (available=false 일 때)
*/
public function __construct(
public string $driver,
public int $inspected = 0,
public array $repaired = [],
public array $failed = [],
public array $remaining = [],
public bool $available = true,
public ?string $reason = null,
) {}
/**
* 점검을 수행할 수 없었던 경우의 보고를 만듭니다.
*
* @param string $driver 드라이버명
* @param string|null $reason 사유
* @return self
*/
public static function unavailable(string $driver, ?string $reason): self
{
return new self(driver: $driver, available: false, reason: $reason);
}
/**
* 재생성 대상이 없었을 때의 보고를 만듭니다.
*
* @param string $driver 드라이버명
* @param int $inspected 점검한 인덱스 수
* @return self
*/
public static function nothingToDo(string $driver, int $inspected): self
{
return new self(driver: $driver, inspected: $inspected);
}
/**
* 재생성을 실제로 수행했는지 여부.
*
* @return bool
*/
public function didRebuild(): bool
{
return $this->repaired !== [] || $this->failed !== [];
}
/**
* 전부 복구되었는지 여부 (실패·잔존 모두 없음).
*
* @return bool
*/
public function isClean(): bool
{
return $this->failed === [] && $this->remaining === [];
}
/**
* 한 줄 요약을 반환합니다.
*
* @return string
*/
public function summary(): string
{
if (! $this->available) {
return $this->reason ?? __('search.index.unavailable', ['driver' => $this->driver]);
}
if (! $this->didRebuild()) {
return __('search.index.report.nothing_to_do', ['count' => $this->inspected]);
}
return __('search.index.report.rebuilt', [
'inspected' => $this->inspected,
'repaired' => count($this->repaired),
'failed' => count($this->failed),
'remaining' => count($this->remaining),
]);
}
/**
* 기계 판독용 배열로 반환합니다.
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'driver' => $this->driver,
'available' => $this->available,
'reason' => $this->reason,
'inspected' => $this->inspected,
'repaired' => $this->repaired,
'failed' => $this->failed,
'remaining' => $this->remaining,
'summary' => $this->summary(),
];
}
}
@@ -0,0 +1,127 @@
<?php
namespace App\Search\Engines\Maintenance;
use App\Enums\SearchIndexStatus;
use App\Search\Contracts\SearchIndexMaintainer;
use App\Search\DTO\FulltextIndexDefinition;
use App\Search\DTO\SearchIndexHealth;
use App\Search\Engines\DatabaseFulltextEngine;
use App\Search\FulltextIndexInspector;
/**
* `mysql-fulltext` 드라이버의 인덱스 유지보수기
*
* 엔진 중립 계약(`SearchIndexMaintainer`)을 MySQL FULLTEXT 방식으로 구현합니다.
* 판정 방법(자기 매칭 프로브)과 재생성 방법(같은 정의로 DROP/ADD)은
* `FulltextIndexInspector` 가 갖고 있고, 이 클래스는 그 결과를 엔진 중립 DTO 로 옮깁니다.
*
* 다른 엔진(Meilisearch 등)은 이 클래스를 상속하지 않고 계약을 각자 구현합니다 —
* 인덱스의 실체가 다르므로 공유할 구현이 없습니다.
*/
class FulltextIndexMaintainer implements SearchIndexMaintainer
{
/** 이 유지보수기가 담당하는 Scout 드라이버명 */
public const DRIVER = 'mysql-fulltext';
/**
* @param FulltextIndexInspector $inspector FULLTEXT 인덱스 점검기
*/
public function __construct(private readonly FulltextIndexInspector $inspector) {}
/**
* {@inheritDoc}
*/
public function driver(): string
{
return self::DRIVER;
}
/**
* {@inheritDoc}
*/
public function isAvailable(): bool
{
return DatabaseFulltextEngine::supportsFulltext();
}
/**
* {@inheritDoc}
*/
public function unavailableReason(): ?string
{
return $this->isAvailable()
? null
: __('search.index.fulltext.unsupported_driver');
}
/**
* {@inheritDoc}
*
* 지원 필터: `table`(테이블명, 프리픽스 생략 가능) · `index`(인덱스명) · `samples`(표본 행 수)
*/
public function inspect(array $filters = []): array
{
$sampleSize = (int) ($filters['samples'] ?? FulltextIndexInspector::DEFAULT_SAMPLE_SIZE);
$results = $this->inspector->inspectAll(
table: isset($filters['table']) ? (string) $filters['table'] : null,
index: isset($filters['index']) ? (string) $filters['index'] : null,
sampleSize: max(1, $sampleSize),
);
return array_map(fn (array $result) => $this->toHealth($result), $results);
}
/**
* {@inheritDoc}
*/
public function rebuild(SearchIndexHealth $health): void
{
$context = $health->context;
$this->inspector->rebuild(new FulltextIndexDefinition(
table: (string) ($context['table'] ?? ''),
name: (string) ($context['name'] ?? ''),
columns: (array) ($context['columns'] ?? []),
parser: isset($context['parser']) ? (string) $context['parser'] : null,
));
}
/**
* 점검기 결과를 엔진 중립 판정 DTO 로 옮깁니다.
*
* @param array{definition: FulltextIndexDefinition, status: SearchIndexStatus, probed: int, found: int, reason: ?string} $result 점검기 결과
* @return SearchIndexHealth
*/
private function toHealth(array $result): SearchIndexHealth
{
$definition = $result['definition'];
$status = $result['status'];
$measurement = $status === SearchIndexStatus::Skipped
? (string) $result['reason']
: __('search.index.fulltext.self_match', ['found' => $result['found'], 'probed' => $result['probed']]);
return new SearchIndexHealth(
driver: self::DRIVER,
identifier: $definition->label(),
status: $status,
measurement: $measurement,
details: [
'table' => $definition->table,
'index' => $definition->name,
'columns' => $definition->columns,
'parser' => $definition->parser,
'probed' => $result['probed'],
'found' => $result['found'],
],
context: [
'table' => $definition->table,
'name' => $definition->name,
'columns' => $definition->columns,
'parser' => $definition->parser,
],
);
}
}
+367
View File
@@ -0,0 +1,367 @@
<?php
namespace App\Search;
use App\Enums\SearchIndexStatus;
use App\Search\DTO\FulltextIndexDefinition;
use App\Search\Engines\DatabaseFulltextEngine;
use Illuminate\Support\Facades\DB;
use Throwable;
/**
* FULLTEXT 인덱스 건강도 점검·복구기
*
* 인덱스는 존재하는데 **행이 색인되어 있지 않아** `MATCH ... AGAINST` 가 아무것도
* 돌려주지 않는 상태를 검출하고, 같은 정의로 재생성해 복구합니다. 이 상태는
* 오류를 남기지 않고 "검색 결과 0건" 으로만 나타나므로 로그·예외로는 드러나지 않습니다.
*
* 대상은 하드코딩하지 않습니다 — `INFORMATION_SCHEMA` 에서 현재 스키마의 FULLTEXT
* 인덱스를 전수 수집하므로 코어·모듈·플러그인이 나중에 추가하는 인덱스도 자동 포함됩니다.
*
* ## 판정 방법 — 자기 매칭(self-match)
*
* 표본 행을 뽑아 **그 행 자신의 내용에서 만든 검색어로 그 행을 찾을 수 있는지** 봅니다.
* 특정 키워드를 사람이 골라 넣지 않으므로 어떤 테이블·언어에도 그대로 적용됩니다.
*
* 토큰은 행마다 여러 개 만듭니다. 하나만 쓰면 토크나이저 특성으로 인한 실패를
* 색인 누락으로 오판합니다 — 예를 들어 ngram(`ngram_token_size=2`) 은 `Basic` 을
* `Ba/as/si/ic` 로 쪼개는데 그중 `as` 가 기본 불용어라 구문 검색이 깨집니다.
* 그래서 **한 행이 어떤 토큰으로도 자신을 찾지 못할 때만** 그 행을 실패로 셉니다.
*
* ## 판정 등급
*
* - `healthy` — 표본 전 행이 자기 자신을 찾는다
* - `degraded` — 일부만 찾는다. 토크나이저 특성일 수도, 일부 행만 누락된 것일 수도 있어
* 자동 복구 대상에서 제외하고 수치만 보고한다
* - `stale` — 표본 중 **한 행도** 자기 자신을 찾지 못한다. 색인 누락의 신호이며 복구 대상
* - `skipped` — 표본을 만들 수 없다(행이 없거나 내용이 모두 비어 토큰 추출 불가). 사유·수치 기재
*/
class FulltextIndexInspector
{
/** 인덱스당 기본 표본 행 수 */
public const DEFAULT_SAMPLE_SIZE = 5;
/** 한 행에서 만들 최대 토큰 수 */
private const MAX_TOKENS_PER_ROW = 4;
/** 토큰 최대 길이 (긴 토큰은 구문 검색 비용만 늘린다) */
private const MAX_TOKEN_LENGTH = 12;
/**
* 현재 스키마의 FULLTEXT 인덱스를 전수 수집합니다.
*
* @param string|null $table 테이블명 필터 (프리픽스 포함/미포함 모두 허용)
* @param string|null $index 인덱스명 필터
* @return array<int, FulltextIndexDefinition>
*/
public function discover(?string $table = null, ?string $index = null): array
{
if (! DatabaseFulltextEngine::supportsFulltext()) {
return [];
}
$rows = DB::select(
'SELECT TABLE_NAME AS table_name, INDEX_NAME AS index_name,
GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS columns
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND INDEX_TYPE = ?
GROUP BY TABLE_NAME, INDEX_NAME
ORDER BY TABLE_NAME, INDEX_NAME',
['FULLTEXT']
);
$prefix = DB::getTablePrefix();
$wanted = $table === null ? null : [$table, $prefix.$table];
$definitions = [];
foreach ($rows as $row) {
if ($wanted !== null && ! in_array($row->table_name, $wanted, true)) {
continue;
}
if ($index !== null && $row->index_name !== $index) {
continue;
}
$definitions[] = new FulltextIndexDefinition(
table: $row->table_name,
name: $row->index_name,
columns: explode(',', (string) $row->columns),
parser: $this->resolveParser($row->table_name, $row->index_name),
);
}
return $definitions;
}
/**
* 인덱스 하나의 건강도를 판정합니다.
*
* 엔진 중립 DTO 로의 변환은 호출자(`FulltextIndexMaintainer`)가 합니다 — 이 클래스는
* FULLTEXT 고유의 판정만 담당하고 표현 형식을 알지 않습니다.
*
* @param FulltextIndexDefinition $definition 인덱스 정의
* @param int $sampleSize 표본 행 수
* @return array{definition: FulltextIndexDefinition, status: SearchIndexStatus, probed: int, found: int, reason: string|null}
*/
public function inspect(FulltextIndexDefinition $definition, int $sampleSize = self::DEFAULT_SAMPLE_SIZE): array
{
$key = $this->resolvePrimaryKey($definition->table);
if ($key === null) {
return $this->skipped($definition, __('search.index.fulltext.skip.no_single_pk'));
}
$samples = $this->sampleRows($definition, $key, $sampleSize);
if ($samples === []) {
return $this->skipped($definition, __('search.index.fulltext.skip.no_rows'));
}
$probed = 0;
$found = 0;
foreach ($samples as $sample) {
$tokens = $this->buildTokens((string) $sample->body);
if ($tokens === []) {
continue;
}
$probed++;
if ($this->rowFindsItself($definition, $key, $sample->pk, $tokens)) {
$found++;
}
}
if ($probed === 0) {
return $this->skipped($definition, __('search.index.fulltext.skip.no_tokens'));
}
return [
'definition' => $definition,
'status' => match (true) {
$found === 0 => SearchIndexStatus::Stale,
$found < $probed => SearchIndexStatus::Degraded,
default => SearchIndexStatus::Healthy,
},
'probed' => $probed,
'found' => $found,
'reason' => null,
];
}
/**
* 전체(또는 필터된) 인덱스의 건강도를 판정합니다.
*
* @param string|null $table 테이블명 필터
* @param string|null $index 인덱스명 필터
* @param int $sampleSize 표본 행 수
* @return array<int, array{definition: FulltextIndexDefinition, status: SearchIndexStatus, probed: int, found: int, reason: string|null}>
*/
public function inspectAll(?string $table = null, ?string $index = null, int $sampleSize = self::DEFAULT_SAMPLE_SIZE): array
{
return array_map(
fn (FulltextIndexDefinition $definition) => $this->inspect($definition, $sampleSize),
$this->discover($table, $index)
);
}
/**
* 판정 불가 결과를 만듭니다.
*
* @param FulltextIndexDefinition $definition 인덱스 정의
* @param string $reason 사유
* @return array{definition: FulltextIndexDefinition, status: SearchIndexStatus, probed: int, found: int, reason: string}
*/
private function skipped(FulltextIndexDefinition $definition, string $reason): array
{
return [
'definition' => $definition,
'status' => SearchIndexStatus::Skipped,
'probed' => 0,
'found' => 0,
'reason' => $reason,
];
}
/**
* 인덱스를 같은 정의로 재생성합니다.
*
* 컬럼 구성과 파서를 그대로 유지하므로 검색 동작이 달라지지 않습니다.
* 재생성 중에는 대상 테이블이 잠기므로 대용량 테이블에서는 시간이 걸립니다.
*
* @param FulltextIndexDefinition $definition 인덱스 정의
* @return void
*
* @throws Throwable 재생성 실패 시 (드롭까지만 되고 끝나지 않도록 예외를 그대로 전파)
*/
public function rebuild(FulltextIndexDefinition $definition): void
{
$table = $definition->quotedTable();
$name = $definition->name;
DB::statement("ALTER TABLE {$table} DROP INDEX `{$name}`");
try {
DB::statement(
"ALTER TABLE {$table} ADD FULLTEXT INDEX `{$name}` ({$definition->quotedColumns()})"
.$definition->parserClause()
);
} catch (Throwable $e) {
// 드롭만 되고 끝나면 검색이 아예 죽는다 — 원 정의로 되돌린 뒤 예외를 전파한다
$this->restoreQuietly($definition);
throw $e;
}
}
/**
* 인덱스의 파서를 조회합니다.
*
* `INFORMATION_SCHEMA` 는 파서를 노출하지 않으므로 `SHOW CREATE TABLE` 정의에서 읽습니다.
*
* @param string $table 테이블명 (프리픽스 포함)
* @param string $index 인덱스명
* @return string|null 파서명 (없으면 null)
*/
private function resolveParser(string $table, string $index): ?string
{
try {
$create = DB::selectOne('SHOW CREATE TABLE `'.$table.'`');
} catch (Throwable) {
return null;
}
$sql = (array) $create;
$ddl = (string) (($sql['Create Table'] ?? $sql['Create View'] ?? ''));
$pattern = '/FULLTEXT\s+KEY\s+`'.preg_quote($index, '/').'`[^\n]*?WITH\s+PARSER\s+`?(\w+)`?/i';
return preg_match($pattern, $ddl, $m) === 1 ? $m[1] : null;
}
/**
* 테이블의 단일 컬럼 기본키를 조회합니다.
*
* @param string $table 테이블명 (프리픽스 포함)
* @return string|null 기본키 컬럼명 (복합키·부재 시 null)
*/
private function resolvePrimaryKey(string $table): ?string
{
$rows = DB::select(
'SELECT COLUMN_NAME AS column_name FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?
ORDER BY SEQ_IN_INDEX',
[$table, 'PRIMARY']
);
return count($rows) === 1 ? (string) $rows[0]->column_name : null;
}
/**
* 색인 컬럼에 내용이 있는 표본 행을 뽑습니다.
*
* @param FulltextIndexDefinition $definition 인덱스 정의
* @param string $key 기본키 컬럼명
* @param int $sampleSize 표본 행 수
* @return array<int, object> `pk`, `body` 를 가진 행 배열
*/
private function sampleRows(FulltextIndexDefinition $definition, string $key, int $sampleSize): array
{
$body = "CONCAT_WS(' ', {$definition->quotedColumns()})";
return DB::select(
"SELECT `{$key}` AS pk, {$body} AS body
FROM {$definition->quotedTable()}
WHERE {$body} <> '' AND {$body} IS NOT NULL
ORDER BY `{$key}` DESC
LIMIT ".max(1, $sampleSize)
);
}
/**
* 행 내용에서 검색 토큰 후보를 만듭니다.
*
* 비-라틴(한글·한자 등) 조각을 먼저 담습니다 — 기본 불용어 목록이 영어라
* 라틴 토큰보다 토크나이저 영향을 덜 받습니다.
*
* @param string $text 행 내용
* @return array<int, string> 토큰 후보 목록
*/
private function buildTokens(string $text): array
{
// 부울 모드 연산자(+ - > < ( ) ~ * " @)와 구분자를 제거한다
$normalized = preg_replace('/[^\p{L}\p{N}]+/u', ' ', $text) ?? '';
$pieces = array_values(array_filter(
explode(' ', $normalized),
fn (string $piece) => mb_strlen($piece) >= 2
));
if ($pieces === []) {
return [];
}
// 길이 내림차순 — 짧은 조각일수록 불용어·잡음일 확률이 높다
usort($pieces, fn (string $a, string $b) => mb_strlen($b) <=> mb_strlen($a));
$nonLatin = [];
$latin = [];
foreach ($pieces as $piece) {
$token = mb_substr($piece, 0, self::MAX_TOKEN_LENGTH);
if (preg_match('/^[\x00-\x7F]+$/', $token) === 1) {
$latin[] = $token;
} else {
$nonLatin[] = $token;
}
}
return array_slice(array_values(array_unique([...$nonLatin, ...$latin])), 0, self::MAX_TOKENS_PER_ROW);
}
/**
* 한 행이 자기 토큰 중 하나로 자신을 찾을 수 있는지 확인합니다.
*
* @param FulltextIndexDefinition $definition 인덱스 정의
* @param string $key 기본키 컬럼명
* @param mixed $pk 행의 기본키 값
* @param array<int, string> $tokens 토큰 후보
* @return bool 하나라도 찾으면 true
*/
private function rowFindsItself(FulltextIndexDefinition $definition, string $key, mixed $pk, array $tokens): bool
{
foreach ($tokens as $token) {
$hit = DB::selectOne(
"SELECT COUNT(*) AS hits FROM {$definition->quotedTable()}
WHERE `{$key}` = ? AND MATCH({$definition->quotedColumns()}) AGAINST(? IN BOOLEAN MODE)",
[$pk, '"'.$token.'"']
);
if ((int) ($hit->hits ?? 0) > 0) {
return true;
}
}
return false;
}
/**
* 재생성 실패 시 원 정의로 되돌립니다 (실패해도 원 예외를 가리지 않도록 조용히 처리).
*
* @param FulltextIndexDefinition $definition 인덱스 정의
* @return void
*/
private function restoreQuietly(FulltextIndexDefinition $definition): void
{
try {
DB::statement(
"ALTER TABLE {$definition->quotedTable()} ADD FULLTEXT INDEX `{$definition->name}` "
."({$definition->quotedColumns()})".$definition->parserClause()
);
} catch (Throwable) {
// 되돌리기까지 실패하면 원 예외가 더 중요한 정보다
}
}
}
@@ -0,0 +1,232 @@
<?php
namespace App\Search;
use App\Extension\HookManager;
use App\Search\Contracts\SearchIndexMaintainer;
use App\Search\DTO\SearchIndexHealth;
use App\Search\DTO\SearchIndexRepairReport;
use App\Search\Engines\Maintenance\FulltextIndexMaintainer;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* 검색 인덱스 점검·재생성 진입점
*
* 활성 Scout 드라이버에 해당하는 `SearchIndexMaintainer` 를 찾아 위임합니다.
* 코어는 어떤 엔진이 무엇을 어떻게 점검하는지 알지 못하며, 등급(`SearchIndexStatus`)만
* 보고 재생성 대상을 고릅니다.
*
* ## 등록
*
* 기본 제공은 `mysql-fulltext` 하나이고, 확장은 필터 훅으로 자기 엔진의 유지보수기를
* 추가합니다.
*
* ```php
* HookManager::addFilter('core.search.index_maintainers', function (array $maintainers) {
* $maintainers['meilisearch'] = MeilisearchIndexMaintainer::class;
*
* return $maintainers;
* });
* ```
*
* 유지보수기를 등록하지 않은 엔진은 점검 대상에서 빠질 뿐 검색은 그대로 동작합니다.
* 그 경우 `hasMaintainer()` 가 false 이고, 화면·커맨드는 "이 엔진은 점검을 제공하지
* 않는다" 를 명시합니다 — 점검 결과 0건과 구분되어야 하기 때문입니다.
*
* ## 재생성은 언제나 선택 사항이다
*
* 재생성 비용은 엔진에 따라 테이블 잠금(FULLTEXT)이나 전체 재색인(외부 엔진)입니다.
* 운영 중인 사이트에서 확장 업데이트만으로 그 비용이 발생하면 안 되므로,
* 이 클래스는 어떤 자동 트리거에도 연결하지 않고 호출자의 명시적 요청만 수행합니다.
*/
class SearchIndexMaintenanceManager
{
/** 확장이 유지보수기를 등록하는 필터 훅 */
public const MAINTAINERS_FILTER = 'core.search.index_maintainers';
/** 해석된 유지보수기 캐시 (요청 수명) */
private ?SearchIndexMaintainer $resolved = null;
/** 해석을 시도했는지 여부 (없음도 캐시하기 위함) */
private bool $resolvedAttempted = false;
/**
* 현재 활성 Scout 드라이버명을 반환합니다.
*
* @return string 드라이버명
*/
public function driver(): string
{
return (string) config('scout.driver', 'mysql-fulltext');
}
/**
* 등록된 유지보수기 맵을 반환합니다.
*
* @return array<string, class-string<SearchIndexMaintainer>> 드라이버명 => 구현 클래스
*/
public function registered(): array
{
$maintainers = [
'mysql-fulltext' => FulltextIndexMaintainer::class,
];
$maintainers = HookManager::applyFilters(self::MAINTAINERS_FILTER, $maintainers);
return is_array($maintainers) ? $maintainers : [];
}
/**
* 활성 드라이버의 유지보수기를 반환합니다.
*
* @return SearchIndexMaintainer|null 등록되지 않았으면 null
*/
public function maintainer(): ?SearchIndexMaintainer
{
if ($this->resolvedAttempted) {
return $this->resolved;
}
$this->resolvedAttempted = true;
$class = $this->registered()[$this->driver()] ?? null;
if ($class === null) {
return $this->resolved = null;
}
try {
$instance = app($class);
} catch (Throwable $e) {
Log::warning('검색 인덱스 유지보수기 생성 실패', [
'driver' => $this->driver(),
'class' => $class,
'error' => $e->getMessage(),
]);
return $this->resolved = null;
}
if (! $instance instanceof SearchIndexMaintainer) {
Log::warning('검색 인덱스 유지보수기가 계약을 구현하지 않음', [
'driver' => $this->driver(),
'class' => $class,
]);
return $this->resolved = null;
}
return $this->resolved = $instance;
}
/**
* 활성 엔진이 점검을 제공하는지 여부.
*
* @return bool
*/
public function hasMaintainer(): bool
{
return $this->maintainer() !== null;
}
/**
* 활성 엔진의 인덱스를 점검합니다.
*
* @param array<string, mixed> $filters 엔진별 필터
* @return array<int, SearchIndexHealth>
*/
public function inspect(array $filters = []): array
{
$maintainer = $this->maintainer();
if ($maintainer === null || ! $maintainer->isAvailable()) {
return [];
}
return $maintainer->inspect($filters);
}
/**
* 점검 불가 사유를 반환합니다.
*
* @return string|null 점검 가능하면 null
*/
public function unavailableReason(): ?string
{
$maintainer = $this->maintainer();
if ($maintainer === null) {
return __('search.index.no_maintainer', ['driver' => $this->driver()]);
}
return $maintainer->isAvailable() ? null : $maintainer->unavailableReason();
}
/**
* 색인이 누락된 인덱스를 재생성합니다.
*
* 재생성 후 **다시 점검해서** 잔존 여부를 담습니다 — 재생성했다는 사실만으로
* 복구됐다고 단정하지 않습니다.
*
* @param array<string, mixed> $filters 엔진별 필터
* @return SearchIndexRepairReport 실행 보고
*/
public function repairStale(array $filters = []): SearchIndexRepairReport
{
$maintainer = $this->maintainer();
if ($maintainer === null) {
return SearchIndexRepairReport::unavailable($this->driver(), $this->unavailableReason());
}
if (! $maintainer->isAvailable()) {
return SearchIndexRepairReport::unavailable($this->driver(), $maintainer->unavailableReason());
}
$before = $maintainer->inspect($filters);
$targets = array_values(array_filter($before, fn (SearchIndexHealth $h) => $h->needsRebuild()));
if ($targets === []) {
return SearchIndexRepairReport::nothingToDo($this->driver(), count($before));
}
$repaired = [];
$failed = [];
foreach ($targets as $target) {
try {
$maintainer->rebuild($target);
$repaired[] = $target->identifier;
} catch (Throwable $e) {
$failed[$target->identifier] = $e->getMessage();
Log::error('검색 인덱스 재생성 실패', [
'driver' => $this->driver(),
'index' => $target->identifier,
'error' => $e->getMessage(),
]);
}
}
$after = $maintainer->inspect($filters);
$remaining = array_values(array_map(
fn (SearchIndexHealth $h) => $h->identifier,
array_filter($after, fn (SearchIndexHealth $h) => $h->needsRebuild())
));
Log::info('검색 인덱스 재생성 수행', [
'driver' => $this->driver(),
'inspected' => count($after),
'repaired' => $repaired,
'failed' => array_keys($failed),
'remaining' => $remaining,
]);
return new SearchIndexRepairReport(
driver: $this->driver(),
inspected: count($after),
repaired: $repaired,
failed: $failed,
remaining: $remaining,
);
}
}
+5 -4
View File
@@ -256,9 +256,10 @@ class LanguagePackService
* 코어/번들 확장의 `lang/{ko,en}/` 디렉토리를 자동 스캔하여 가상 보호 LanguagePack
* 인스턴스 컬렉션을 반환합니다 (요구사항 #1, #2).
*
* 스캔 대상:
* 스캔 대상 — **런타임이 실제로 로드하는 경로**와 같아야 한다. 어긋나면 관리자 화면이
* "내장 번역" 으로 보여주는 것과 화면에 나오는 문구의 출처가 달라진다.
* - 코어: lang/{ko,en}/
* - 번들 모듈: modules/_bundled/{id}/resources/lang/{ko,en}/
* - 번들 모듈: modules/_bundled/{id}/src/lang/{ko,en}/ (TranslationServiceProvider 기준)
* - 번들 플러그인: plugins/_bundled/{id}/lang/{ko,en}/
* - 번들 템플릿: templates/_bundled/{id}/lang/{ko,en}.json
*
@@ -288,11 +289,11 @@ class LanguagePackService
}
}
// 번들 모듈
// 번들 모듈 (런타임 등록 경로 = src/lang)
$packs = $packs->concat($this->scanBundledExtensionLang(
bundledRoot: 'modules/_bundled',
scope: LanguagePackScope::Module->value,
langSubpath: 'resources/lang/'.$locale,
langSubpath: 'src/lang/'.$locale,
locale: $locale,
filters: $filters,
));
+116
View File
@@ -0,0 +1,116 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| 성능 계측 프로파일 (Benchmark Profiles)
|--------------------------------------------------------------------------
|
| `g7:bench` 커맨드가 재는 코어 계측 대상 선언입니다. 확장은 자기 대상을
| `getBenchmarkProfiles()` 오버라이드로 선언하며(모듈/플러그인 공통), 스키마는
| 여기와 동일합니다. 계측 대상을 커맨드에 하드코딩하지 않는 이유는, 확장이
| 설치·제거되는 설치본마다 "실제로 존재하는 대상"이 다르기 때문입니다.
|
| 공통 필드
| type : list | screen | write | batch (필수)
| label : 표시용 설명 (선택)
|
| type=list — 목록 SELECT 비용 (전체 컬럼 / 목록 컬럼 / 키 컬럼 3축)
| table : 대상 테이블 (필수)
| columns : 목록이 실제로 select 하는 컬럼. ['*'] 는 "응답 계약상 전 컬럼
| 노출이라 프루닝 불가" 선언이며 이때 비교축은 select * vs select id
| order : [[컬럼, 방향], ...]
| filters : 화면이 실제로 거는 필터. 필터 없이 재면 인덱스 선택이 달라져 화면에서
| 일어나는 일과 다른 것을 잰다. 두 형태를 받는다 —
| 등가: ['board_id' => 1]
| 연산자: ['order_status' => ['not in', [...]]]
| 연산자 닫힌 집합: = != <> < <= > >= like in "not in".
| 목록에 없는 연산자는 측정 전에 사유와 함께 거부한다.
| 선언형으로 재현 못 하는 술어(상관 서브쿼리·권한 스코프)가 지배적인
| 목록은 프로파일을 두지 않는다 — 어느 화면도 내지 않는 수치가 된다.
| soft_delete : true 면 deleted_at IS NULL 부착
| seed_overrides : 합성 시딩 시 고정할 컬럼값 (filters 와 맞출 용도)
|
| type=screen — 화면 1장 응답 시간 + 실행 쿼리 건수 + N+1 후보
| route : 라우트명 (권장 — URI 프리픽스 조립 금지, 사라지면 즉시 실패)
| route_params: 라우트 파라미터
| uri : 라우트명이 없을 때만 쓰는 원시 경로
| method : HTTP 메서드 (기본 GET)
| query : 쿼리스트링 [키 => 값]
| permissions : 계측용 임시 계정에 부여할 권한 식별자 목록
|
| type=write — 저장 경로 1회 소요 시간
| callback : 'Fqcn' (invokable) 또는 ['Fqcn', 'method']. 클로저는 config:cache 를
| 깨뜨리므로 금지하며 레지스트리가 거부한다
| cleanup : 동일 형식. 측정으로 생긴 행을 되돌린다
|
| type=batch — 배치 커맨드 소요 시간 + 피크 메모리
| command : artisan 커맨드명
| arguments : 커맨드 인자/옵션 [키 => 값]
|
*/
'profiles' => [
// --- 목록 조회 (list) -------------------------------------------------
// 활동 로그와 알림 발송 이력은 응답 계약상 넓은 컬럼(changes/properties, body)을
// 그대로 노출하므로 컬럼 프루닝이 불가하다. 지연 조인만 적용했고 계측 비교축은
// select * vs select id 다.
'activity_logs' => [
'type' => 'list',
'label' => '활동 로그 목록',
'table' => 'activity_logs',
'columns' => ['*'],
'order' => [['created_at', 'desc'], ['id', 'desc']],
'soft_delete' => false,
],
'notification_logs' => [
'type' => 'list',
'label' => '알림 발송 이력 목록',
'table' => 'notification_logs',
'columns' => ['*'],
'order' => [['created_at', 'desc'], ['id', 'desc']],
'soft_delete' => false,
],
// 회원 테이블은 소프트 삭제 컬럼이 없다 (탈퇴는 status 로 표현) — 실제 스키마 기준 선언
'users' => [
'type' => 'list',
'label' => '회원 목록',
'table' => 'users',
'columns' => ['*'],
'order' => [['created_at', 'desc'], ['id', 'desc']],
'soft_delete' => false,
],
// --- 화면 응답 (screen) -----------------------------------------------
'activity_logs_screen' => [
'type' => 'screen',
'label' => '관리자 활동 로그 목록 화면',
'route' => 'api.admin.activity-logs.index',
'query' => ['per_page' => 20],
'permissions' => ['core.activities.read'],
],
'users_screen' => [
'type' => 'screen',
'label' => '관리자 회원 목록 화면',
'route' => 'api.admin.users.index',
'query' => ['per_page' => 20],
'permissions' => ['core.users.read'],
],
// --- 배치 작업 (batch) ------------------------------------------------
'sitemap' => [
'type' => 'batch',
'label' => '사이트맵 생성',
'command' => 'seo:generate-sitemap',
'arguments' => ['--sync' => true],
],
],
];
@@ -0,0 +1,66 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 알림 발송 이력 목록의 정렬 컬럼(수신자명·제목)에 인덱스를 추가합니다.
*
* 목록은 지연 조인(PaginatesWithDeferredJoin)으로 페이지네이션되는데, 그 전제는
* "정렬은 인덱스가 있는 닫힌 집합" 이다. 인덱스가 없으면 inner 쿼리도 전체 스캔이 되어
* 깊은 OFFSET 개선 폭이 사라진다. 화면 정렬 셀렉트가 수신자명순·제목순을 제공하므로
* 두 컬럼에 인덱스를 둔다.
*
* - recipient_name: 수신자명순 정렬
* - subject: 제목순 정렬
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (! Schema::hasTable('notification_logs')) {
return;
}
$existingIndexes = array_column(Schema::getIndexes('notification_logs'), 'name');
Schema::table('notification_logs', function (Blueprint $table) use ($existingIndexes) {
if (! in_array('idx_notification_logs_recipient_name', $existingIndexes)) {
$table->index('recipient_name', 'idx_notification_logs_recipient_name');
}
if (! in_array('idx_notification_logs_subject', $existingIndexes)) {
$table->index('subject', 'idx_notification_logs_subject');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (! Schema::hasTable('notification_logs')) {
return;
}
$existingIndexes = array_column(Schema::getIndexes('notification_logs'), 'name');
Schema::table('notification_logs', function (Blueprint $table) use ($existingIndexes) {
$indexes = [
'idx_notification_logs_recipient_name',
'idx_notification_logs_subject',
];
foreach ($indexes as $index) {
if (in_array($index, $existingIndexes)) {
$table->dropIndex($index);
}
}
});
}
};
@@ -0,0 +1,86 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 코어 목록(활동 로그·알림 발송 이력·회원)의 정렬 색인 끝에 기본키를 덧붙입니다.
*
* 세 목록 모두 `created_at DESC, id DESC` 로 정렬한다. 색인이 `created_at` 에서 끝나면
* 동률 구간의 `id` 순서를 인덱스로 만들 수 없어 filesort 가 붙고, 지연 조인의 inner 가
* 인덱스 순서 그대로 끝나지 못해 깊은 OFFSET 개선 폭이 사라진다. 활동 로그처럼 같은 초에
* 여러 건이 쌓이는 테이블에서 동률 구간은 예외가 아니라 일상이다.
*
* 필요한 색인은 계측 프로파일 선언(filters / order / soft_delete)에서 도출한 것이며,
* `ListIndexCoverageTest` 가 같은 규칙으로 전 목록을 검사한다.
*
* - activity_logs : (created_at) → (created_at, id) 교체
* - notification_logs : (created_at, id) 신설 (기존 정렬 색인 없음)
* - users : (created_at) → (created_at, id) 교체
*
* 교체 대상 단일 색인은 새 색인의 좌측 프리픽스라 그대로 두면 쓰기 비용만 늘어난다.
* 새 색인을 먼저 만들고 기존 색인을 나중에 지우므로, 중간에 중단되어도 조회가 색인 없이
* 남는 구간은 없다.
*/
return new class extends Migration
{
/**
* 대상 [테이블 => [신규 색인명, 컬럼, 교체 대상 기존 색인명(없으면 null)]]
*
* @var array<string, array{0: string, 1: array<int, string>, 2: string|null}>
*/
private const TARGETS = [
'activity_logs' => ['idx_activity_logs_created_id', ['created_at', 'id'], 'g7_activity_logs_created_at_index'],
'notification_logs' => ['idx_notification_logs_created_id', ['created_at', 'id'], null],
'users' => ['idx_users_created_id', ['created_at', 'id'], 'idx_users_created_at'],
];
/**
* Run the migrations.
*/
public function up(): void
{
foreach (self::TARGETS as $table => [$newIndex, $columns, $oldIndex]) {
if (! Schema::hasTable($table)) {
continue;
}
$existing = array_column(Schema::getIndexes($table), 'name');
Schema::table($table, function (Blueprint $blueprint) use ($existing, $newIndex, $columns, $oldIndex) {
if (! in_array($newIndex, $existing, true)) {
$blueprint->index($columns, $newIndex);
}
if ($oldIndex !== null && in_array($oldIndex, $existing, true)) {
$blueprint->dropIndex($oldIndex);
}
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
foreach (self::TARGETS as $table => [$newIndex, $columns, $oldIndex]) {
if (! Schema::hasTable($table)) {
continue;
}
$existing = array_column(Schema::getIndexes($table), 'name');
Schema::table($table, function (Blueprint $blueprint) use ($existing, $newIndex, $oldIndex) {
if ($oldIndex !== null && ! in_array($oldIndex, $existing, true)) {
$blueprint->index(['created_at'], $oldIndex);
}
if (in_array($newIndex, $existing, true)) {
$blueprint->dropIndex($newIndex);
}
});
}
}
};
+3 -2
View File
@@ -9,7 +9,7 @@
| 카테고리 | 문서 수 | 링크 상태 |
|----------|---------|----------|
| [백엔드](backend/) | 33개 | 정상 |
| [백엔드](backend/) | 34개 | 정상 |
| [프론트엔드](frontend/) | 51개 | 정상 |
| [확장 시스템](extension/) | 31개 | 정상 |
| 공통 | 20개 | 정상 |
@@ -125,7 +125,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답
<!-- AUTO-GENERATED-START: docs-readme-full-list -->
## 카테고리별 전체 문서 목록
### 백엔드 (33개)
### 백엔드 (34개)
| 문서 | 제목 |
|------|------|
@@ -135,6 +135,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답
| [api-documentation.md](backend/api-documentation.md) | API 레퍼런스 문서 규정 (API Documentation) |
| [api-resources.md](backend/api-resources.md) | API 리소스 |
| [authentication.md](backend/authentication.md) | 인증 및 세션 처리 |
| [benchmark.md](backend/benchmark.md) | 성능 계측 시스템 (Benchmark) |
| [broadcasting.md](backend/broadcasting.md) | Broadcasting (실시간 이벤트) |
| [console-confirm.md](backend/console-confirm.md) | 콘솔 yes/no 프롬프트 (ConsoleConfirm) |
| [controllers.md](backend/controllers.md) | 컨트롤러 계층 구조 |
+1
View File
@@ -36,6 +36,7 @@
| [api-documentation.md](api-documentation.md) | API 레퍼런스 문서 규정 (API Documentation) | 모든 API 엔드포인트는 레퍼런스 문서 필수 — 메서드/URI/파라미터/응답 필드 +... |
| [api-resources.md](api-resources.md) | API 리소스 | Resource: BaseApiResource 상속 필수 / Collection: B... |
| [authentication.md](authentication.md) | 인증 및 세션 처리 | Laravel Sanctum 토큰 전용 인증 (Bearer 토큰만 사용) |
| [benchmark.md](benchmark.md) | 성능 계측 시스템 (Benchmark) | `g7:bench` 가 4축(list/screen/write/batch)을 잰다 — ... |
| [broadcasting.md](broadcasting.md) | Broadcasting (실시간 이벤트) | Laravel Reverb 사용 (WebSocket) |
| [console-confirm.md](console-confirm.md) | 콘솔 yes/no 프롬프트 (ConsoleConfirm) | 콘솔 커맨드의 yes/no 프롬프트는 $this->unifiedConfirm() 사용... |
| [controllers.md](controllers.md) | 컨트롤러 계층 구조 | AdminBaseController / AuthBaseController / Publ... |
+2 -2
View File
@@ -201,11 +201,11 @@ location ~* \.(js|css|json)$ { expires max; access_log off; }
> 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다.
<!-- @generated:start:api-readme-extensions -->
- **확장 수**: 13 · **엔드포인트 수**: 385
- **확장 수**: 13 · **엔드포인트 수**: 390
| 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 |
| --- | --- | --- | --- |
| `gnuboard7-hello_module` | 모듈 | [docs/api/](../../../modules/_bundled/gnuboard7-hello_module/docs/api/README.md) | 1 / 2 |
| `gnuboard7-hello_module` | 모듈 | [docs/api/](../../../modules/_bundled/gnuboard7-hello_module/docs/api/README.md) | 1 / 7 |
| `sirsoft-board` | 모듈 | [docs/api/](../../../modules/_bundled/sirsoft-board/docs/api/README.md) | 10 / 80 |
| `sirsoft-ecommerce` | 모듈 | [docs/api/](../../../modules/_bundled/sirsoft-ecommerce/docs/api/README.md) | 33 / 231 |
| `sirsoft-page` | 모듈 | [docs/api/](../../../modules/_bundled/sirsoft-page/docs/api/README.md) | 2 / 17 |
+4 -2
View File
@@ -3456,6 +3456,7 @@ Authorization: Bearer {YOUR_TOKEN}
| layout_strategy | body | string | 아니오 | `overwrite`, `keep` | 업데이트 시 레이아웃 처리 전략 (overwrite: 새 버전으로 교체, keep: 사용자 수정본 유지) |
| vendor_mode | body | string | 아니오 | `auto`, `composer`, `bundled` | 벤더 설치 모드 (auto/composer/bundled) |
| force | body | boolean | 아니오 | — | 강제 실행 여부 (안전 확인/선행 검사 우회) |
| rebuild_search_index | body | boolean | 아니오 | — | 업데이트 후 색인이 누락된 검색 인덱스를 재생성할지 여부 (기본 false). 재생성 중에는 대상 인덱스가 잠기거나 재색인되므로 운영 중인 사이트에서는 접속이 적은 시간에 별도로 수행하는 것을 권장 |
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.module.perform_update_validation_rules`).
@@ -3471,7 +3472,8 @@ Content-Type: application/json
{
"layout_strategy": "overwrite",
"vendor_mode": "auto",
"force": true
"force": true,
"rebuild_search_index": false
}
```
@@ -3494,7 +3496,7 @@ Content-Type: application/json
<!-- @generated:end -->
**설명** 특정 모듈을 최신 버전으로 업데이트합니다. `core.modules.install` 권한이 필요합니다. `layout_strategy` 로 레이아웃 처리 방식을(overwrite: 새 버전으로 교체, keep: 사용자 수정본 유지) 지정하며, `vendor_mode` 로 Composer 의존성 처리 방식을 선택합니다. 버전 제약·호환성 문제로 막힐 경우 `force: true` 로 강제 진행할 수 있습니다. `keep` 을 지정하면 사용자가 수정한 레이아웃(원본 해시와 현재 내용이 다른 레이아웃)은 갱신 대상에서 제외되어 현재 내용이 그대로 유지되고, 나머지 레이아웃만 파일 기준으로 갱신됩니다. 성공 응답 메시지에는 대상 식별자와 적용된 버전이 채워집니다.
**설명** 특정 모듈을 최신 버전으로 업데이트합니다. `core.modules.install` 권한이 필요합니다. `layout_strategy` 로 레이아웃 처리 방식을(overwrite: 새 버전으로 교체, keep: 사용자 수정본 유지) 지정하며, `vendor_mode` 로 Composer 의존성 처리 방식을 선택합니다. 버전 제약·호환성 문제로 막힐 경우 `force: true` 로 강제 진행할 수 있습니다. `keep` 을 지정하면 사용자가 수정한 레이아웃(원본 해시와 현재 내용이 다른 레이아웃)은 갱신 대상에서 제외되어 현재 내용이 그대로 유지되고, 나머지 레이아웃만 파일 기준으로 갱신됩니다. 성공 응답 메시지에는 대상 식별자와 적용된 버전이 채워집니다. `rebuild_search_index: true` 를 함께 보내면 업데이트 후 색인이 누락된 검색 인덱스를 재생성합니다 — 인덱스 잠금·재색인 비용이 있어 기본은 수행하지 않으며, 보내지 않아도 응답의 `search_index` 에 현재 누락 여부가 담깁니다.
### GET /api/modules/assets/{identifier}/{path}
+1 -1
View File
@@ -35,7 +35,7 @@
| extension_type | query | string | 아니오 | `core`, `module`, `plugin` | 확장 유형 (core/module/plugin/template) |
| status | query | string | 아니오 | — | 상태 필터 (해당 상태의 항목만 조회) |
| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 |
| sort_by | query | string | 아니오 | `id`, `channel`, `notification_type`, `status`, `sent_at`, `created_at` | 정렬 기준 필드명 |
| sort_by | query | string | 아니오 | `id`, `channel`, `notification_type`, `status`, `sent_at`, `created_at`, `recipient_name`, `subject` | 정렬 기준 필드명 (`recipient_name`·`subject` 는 관리자 화면의 "수신자명순"·"제목순" 정렬에 대응) |
| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) |
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification_log.index_validation_rules`).
+4 -2
View File
@@ -1208,6 +1208,7 @@ Authorization: Bearer {YOUR_TOKEN}
| layout_strategy | body | string | 아니오 | `overwrite`, `keep` | 레이아웃 처리 전략 (overwrite: 새 버전으로 교체, keep: 사용자 수정본 유지) |
| vendor_mode | body | string | 아니오 | `auto`, `composer`, `bundled` | 벤더 설치 모드 (auto/composer/bundled) |
| force | body | boolean | 아니오 | — | 강제 실행 여부 (안전 확인/선행 검사 우회) |
| rebuild_search_index | body | boolean | 아니오 | — | 업데이트 후 색인이 누락된 검색 인덱스를 재생성할지 여부 (기본 false). 재생성 중에는 대상 인덱스가 잠기거나 재색인되므로 운영 중인 사이트에서는 접속이 적은 시간에 별도로 수행하는 것을 권장 |
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.plugin.perform_update_validation_rules`).
@@ -1223,7 +1224,8 @@ Content-Type: application/json
{
"layout_strategy": "overwrite",
"vendor_mode": "auto",
"force": true
"force": true,
"rebuild_search_index": false
}
```
@@ -1246,7 +1248,7 @@ Content-Type: application/json
<!-- @generated:end -->
**설명** 특정 플러그인을 최신 버전으로 업데이트합니다. `core.plugins.install` 권한이 필요합니다. `layout_strategy` 로 레이아웃 처리 방식을(overwrite: 새 버전으로 교체, keep: 사용자 수정본 유지) 지정하며, `vendor_mode` 로 Composer 의존성 처리 방식을 선택합니다. 버전 제약·호환성 문제로 막힐 경우 `force: true` 로 강제 진행할 수 있습니다. `keep` 을 지정하면 사용자가 수정한 레이아웃(원본 해시와 현재 내용이 다른 레이아웃)은 갱신 대상에서 제외되어 현재 내용이 그대로 유지되고, 나머지 레이아웃만 파일 기준으로 갱신됩니다. 성공 응답 메시지에는 대상 식별자와 적용된 버전이 채워집니다.
**설명** 특정 플러그인을 최신 버전으로 업데이트합니다. `core.plugins.install` 권한이 필요합니다. `layout_strategy` 로 레이아웃 처리 방식을(overwrite: 새 버전으로 교체, keep: 사용자 수정본 유지) 지정하며, `vendor_mode` 로 Composer 의존성 처리 방식을 선택합니다. 버전 제약·호환성 문제로 막힐 경우 `force: true` 로 강제 진행할 수 있습니다. `keep` 을 지정하면 사용자가 수정한 레이아웃(원본 해시와 현재 내용이 다른 레이아웃)은 갱신 대상에서 제외되어 현재 내용이 그대로 유지되고, 나머지 레이아웃만 파일 기준으로 갱신됩니다. 성공 응답 메시지에는 대상 식별자와 적용된 버전이 채워집니다. `rebuild_search_index: true` 를 함께 보내면 업데이트 후 색인이 누락된 검색 인덱스를 재생성합니다 — 인덱스 잠금·재색인 비용이 있어 기본은 수행하지 않으며, 보내지 않아도 응답의 `search_index` 에 현재 누락 여부가 담깁니다.
### GET /api/plugins/assets/{identifier}/{path}
+186
View File
@@ -0,0 +1,186 @@
# 성능 계측 시스템 (Benchmark)
> **백엔드 가이드** | [목차로 돌아가기](README.md)
---
## TL;DR (5초 요약)
```text
1. `g7:bench` 가 4축(list/screen/write/batch)을 잰다 — 계측 대상은 커맨드에 하드코딩하지 않는다
2. 선언 지점: 코어 `config/benchmark.php`, 확장 `getBenchmarkProfiles()` — 스키마 동일
3. 화면(screen) 축은 프로파일이 선언한 권한만 가진 임시 계정으로 내부 요청 → 롤백. `--as=` 로 기존 계정 지정 가능
4. 데이터를 변경하는 축(write/batch/비-GET screen)은 `--allow-write` 없이 실행 거부
5. 문서에 옮길 수치는 `--report` 산출물에서만 옮긴다 (환경 정보 동반 — 눈대중 기재 금지)
```
---
## 목차
- [무엇을 재는가](#무엇을-재는가)
- [선언 지점](#선언-지점)
- [축별 스키마](#축별-스키마)
- [실행](#실행)
- [안전장치](#안전장치)
- [리포트](#리포트)
- [관련 문서](#관련-문서)
---
## 무엇을 재는가
| 축 | 재는 것 | 실행 방식 |
| ------ | ------ | ------ |
| `list` | 목록 SELECT 비용 (전체 컬럼 / 목록 컬럼 / 키 컬럼 3축) | 선언된 필터·정렬로 쿼리를 조립해 OFFSET 별 반복 측정 |
| `screen` | 화면 1장 응답 시간 + 실행 쿼리 건수 + N+1 후보 | 라우트를 HTTP 커널로 내부 요청 처리 |
| `write` | 저장 경로 1회 소요 시간 + 쿼리 건수 | 확장이 선언한 콜백 실행 |
| `batch` | 배치 커맨드 소요 시간 + 피크 메모리 | artisan 커맨드 실행 감싸기 |
`screen` 축이 값어치가 가장 크다 — 사용자가 체감하는 단위이고, 목록 SELECT 는 빨라도 N+1 로 느려지는 경우를 잡는다. `list` 축의 세 값 배수는 지연 조인(`PaginatesWithDeferredJoin`) 적용의 기대 효과에 대응한다 (셋째 축이 지연 조인의 inner 쿼리).
## 선언 지점
계측 대상은 **소유자가 선언한다**. 커맨드에 대상을 하드코딩하지 않는 이유는, 확장이 설치·제거되는 설치본마다 실제로 존재하는 대상이 다르기 때문이다.
| 소유자 | 선언 위치 |
| ------ | ------ |
| 코어 | `config/benchmark.php` 의 `profiles` 배열 |
| 모듈 | `module.php` 의 `getBenchmarkProfiles()` 오버라이드 |
| 플러그인 | `plugin.php` 의 `getBenchmarkProfiles()` 오버라이드 |
프로파일 키는 확장 내부에서만 고유하면 된다. 서로 다른 확장이 같은 키를 쓰면 커맨드가 `{식별자}/{키}` 로 지목하며, 짧은 키가 모호할 때는 후보를 나열하고 실행을 거부한다 (임의로 하나를 고르면 어느 확장의 목록을 잰 것인지 알 수 없게 된다).
```php
// modules/_bundled/sirsoft-ecommerce/module.php
public function getBenchmarkProfiles(): array
{
return [
'orders' => [
'type' => 'list',
'label' => '주문 목록',
'table' => 'ecommerce_orders',
// 컬럼을 다시 적지 않고 Repository 상수를 참조한다 — 다시 적으면 계측이 조용히 낡는다
'columns' => OrderRepository::LIST_COLUMNS,
'order' => [['ordered_at', 'desc'], ['id', 'desc']],
'soft_delete' => true,
],
];
}
```
선언이 잘못된 프로파일은 **조용히 버리지 않는다** — 사유와 함께 경고로 드러내고 목록에서 제외한다 (버려진 선언이 곧 계측 사각이 된다).
## 축별 스키마
공통 필드는 `type`(필수)과 `label`(선택)이다.
### type=list
| 필드 | 설명 |
| ------ | ------ |
| `table` | 대상 테이블 (필수) |
| `columns` | 목록이 실제로 select 하는 컬럼. `['*']` 는 "응답 계약상 전 컬럼 노출이라 프루닝 불가" 선언이며 이때 비교축은 `select *` vs `select id` |
| `order` | `[[컬럼, 방향], ...]` |
| `filters` | 화면이 실제로 거는 필터. 필터 없이 재면 인덱스 선택이 달라져 화면에서 일어나는 일과 다른 것을 잰다 (아래 형식) |
| `soft_delete` | true 면 `deleted_at IS NULL` 부착 |
| `seed_overrides` | 합성 시딩 시 고정할 컬럼값 (`filters` 와 맞출 용도) |
`filters` 는 등가 비교와 연산자 형태를 함께 받는다. 등가 비교만 지원하면 화면이 실제로 거는 필터를 선언할 수 없는 목록이 생긴다 — 주문 목록은 상태 미지정 시 임시 주문 상태를 `NOT IN` 으로 제외한다.
```php
'filters' => [
'board_id' => 1, // 등가 비교
'order_status' => ['not in', OrderStatusEnum::listHiddenValues()], // 연산자 형태
'created_at' => ['>=', '2026-01-01'],
],
```
연산자는 닫힌 집합이다 — `=` `!=` `<>` `<` `<=` `>` `>=` `like` `in` `not in`. 목록에 없는 연산자를 선언하면 측정 전에 사유와 함께 거부한다(조용히 빠진 필터로 측정하면 화면과 다른 것을 재면서 정상 측정으로 보고된다). 값을 Enum·상수로 참조해 도메인 SSoT 와 어긋나지 않게 한다.
선언형 필터로 재현할 수 없는 술어(상관 서브쿼리, 권한 스코프 등)가 목록의 지배적 조건이라면 **프로파일을 두지 않는다** — 어느 화면도 내지 않는 수치가 되기 때문이다.
### type=screen
| 필드 | 설명 |
| ------ | ------ |
| `route` | 라우트명 (권장). 라우트가 사라지면 조용히 404 를 재는 대신 즉시 실패한다 |
| `route_params` | 라우트 파라미터 |
| `uri` | 라우트명이 없을 때만 쓰는 원시 경로 |
| `method` | HTTP 메서드 (기본 `GET`) |
| `query` | 쿼리스트링 `[키 => 값]` |
| `permissions` | 계측용 임시 계정에 부여할 권한 식별자 목록 |
권한을 프로파일에서 받는 이유는, 계측 대상 화면이 요구하는 권한만 주어야 미들웨어 통과 여부까지 실제와 같아지기 때문이다 (전권 계정으로 재면 권한 검사 비용과 분기가 달라진다).
### type=write
| 필드 | 설명 |
| ------ | ------ |
| `prepare` | 회차별 선행 준비 콜백 (**계측 구간 밖**에서 실행, 회차 번호를 인자로 받음) |
| `callback` | 계측 대상 콜백 (`prepare` 반환값을 인자로 받음) |
| `cleanup` | 트랜잭션 롤백으로 되돌지 않는 잔여물(파일·캐시) 정리 |
콜백은 `'Fqcn'`(invokable) 또는 `['Fqcn', 'method']` 형식만 허용한다 — 코어 선언이 `config/benchmark.php` 에 있고 이 파일은 `config:cache` 대상이라 클로저를 담을 수 없으므로, 확장 선언도 같은 형식으로 통일한다. 클로저를 선언하면 레지스트리가 사유와 함께 거부한다.
`prepare` 를 계측 구간 밖에 두는 이유는, 저장 경로에는 선행 상태가 필요한 경우가 많고(주문 생성에는 임시 주문이 필요하고 임시 주문은 1회만 전환됨) 그 준비 비용이 측정값에 섞이면 재려던 것을 재지 못하기 때문이다.
### type=batch
| 필드 | 설명 |
| ------ | ------ |
| `command` | artisan 커맨드명 |
| `arguments` | 커맨드 인자/옵션 `[키 => 값]` |
## 실행
```bash
# 등록된 프로파일 목록
php artisan g7:bench --list-profiles
# 프로파일 단위
php artisan g7:bench --profile=core/users_screen
php artisan g7:bench --profile=sirsoft-ecommerce/orders --offsets=0,20000,50000,199980 --runs=3 --explain
# 축 단위 / 전체
php artisan g7:bench --axis=screen
php artisan g7:bench --all --allow-write
# 깊은 OFFSET 계측 (대량 합성 행 시딩 — 폐기 가능한 DB 에서만)
php artisan --env=testing g7:bench --profile=sirsoft-board/board_posts --fresh --seed=200000
php artisan g7:bench --profile=sirsoft-board/board_posts --database=g7_bench --seed=200000
# 기계 판독 / 리포트
php artisan g7:bench --axis=list --json
php artisan g7:bench --all --allow-write --report
php artisan g7:bench --all --allow-write --report=C:/tmp/before.md
```
`g7:bench:pagination` 은 이전 이름의 별칭으로 남아 있어 그대로 호출된다 (옵션은 `--profile=` 기준).
## 안전장치
| 장치 | 동작 |
| ------ | ------ |
| `--allow-write` | 데이터를 변경하는 축(`write`/`batch`/비-GET `screen`)은 이 플래그 없이 실행을 거부 |
| 롤백 트랜잭션 | `screen`/`write` 축은 계측 계정 생성부터 요청·저장 처리까지 롤백되는 트랜잭션 안에서 실행 → 운영 DB 에서도 잔여 데이터가 남지 않음 |
| 운영 환경 시딩 거부 | `--seed`/`--fresh` 는 `production` 에서 거부 |
| `--database=` | 계측용 DB 지정. config 가 캐시된 환경에서 연결을 바꿀 유일한 수단 |
트랜잭션이 열려 있으면 Laravel 이 읽기 쿼리도 write PDO 로 보내므로(`Connection::getReadPdo` 의 `transactions > 0` 분기), 읽기/쓰기 분리 환경에서도 계측 요청이 임시 계정을 인증할 수 있다.
`batch` 축은 트랜잭션으로 감싸지 않는다 — 배치 커맨드는 내부에서 커밋하거나 DDL 을 실행할 수 있고, 대량 처리를 긴 트랜잭션에 담으면 락 보유 시간이 계측 자체보다 위험해진다.
## 리포트
`--report` 는 마크다운 리포트를 `storage/app/benchmarks/` 에 저장한다 (`--report=<경로>` 로 임의 경로 지정). 이 디렉토리는 Git 미추적이다 — 계측값은 실행 머신·DB 버전·OPcache 여부에 종속되므로 저장소에 축적하면 서로 비교 불가능한 수치가 섞인다.
리포트는 환경 정보(APP_ENV, DB 버전, PHP 버전, OPcache, `config:cache`, 실행 머신)와 실행 조건을 축별 표 앞에 함께 적는다. **문서에 옮길 수치는 이 산출물에서만 옮긴다** — 눈대중·기억으로 적은 수치는 어느 환경에서 나온 값인지 확인할 수 없다.
측정하지 못한 프로파일은 목록에서 빠지지 않고 사유와 함께 남는다. 전부 건너뛴 실행은 종료 코드 1 로 끝난다 (성공으로 보고하면 "측정했다"로 읽힌다).
## 관련 문서
- [service-repository.md](service-repository.md) — 목록 조회 컬럼 프루닝과 지연 조인
- [cheatsheet.md](../cheatsheet.md) — 커맨드 요약
- [hooks.md](../extension/hooks.md) — 확장 선언 훅 일반
+121 -2
View File
@@ -11,8 +11,9 @@
1. Laravel Scout + DatabaseFulltextEngine: MySQL FULLTEXT + ngram 기반 검색 (기본 드라이버)
2. FulltextSearchable 인터페이스: searchableColumns() + searchableWeights() 구현 필수
3. LIKE fallback 자동 적용: FULLTEXT 미지원 DBMS(SQLite, PostgreSQL)에서 자동 전환
4. 확장 포인트: core.search.engine_drivers 필터 훅으로 Meilisearch 등 커스텀 엔진 등록 가능
5. AsUnicodeJson 캐스트: JSON 컬럼 FULLTEXT 검색 시 한글 \uXXXX 이스케이프 방지 필수
4. 확장 포인트: core.search.engine_drivers(엔진) + core.search.index_maintainers(인덱스 점검) 필터 훅
5. 인덱스 재생성은 언제나 선택 사항 — 자동 트리거 없음 (테이블 잠금·전체 재색인 비용)
6. AsUnicodeJson 캐스트: JSON 컬럼 FULLTEXT 검색 시 한글 \uXXXX 이스케이프 방지 필수
```
---
@@ -56,6 +57,10 @@ DatabaseFulltextEngine
| `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` 점검·재생성 커맨드 |
**설계 원칙**:
@@ -225,6 +230,120 @@ $this->app->resolving(EngineManager::class, function (EngineManager $manager) us
});
```
### core.search.index_maintainers 필터 훅
검색 엔진은 "인덱스가 있는데 내용이 색인되어 있지 않은" 상태가 될 수 있습니다. 이때 검색은
**오류 없이 0건**을 돌려주므로 예외도 로그도 남지 않고, 운영자는 "원래 검색이 안 되는 줄" 알고
지나갑니다. `SearchIndexMaintainer` 계약이 그 상태를 점검·복구하는 방법을 엔진마다 정의합니다.
인덱스의 실체가 엔진마다 다르므로(FULLTEXT = 테이블에 붙은 인덱스, Meilisearch/Elasticsearch =
외부 서버의 인덱스) 코어는 판정 방법을 알지 못한 채 계약만 호출하고, 등급(`SearchIndexStatus`)만
보고 재생성 대상을 고릅니다.
```php
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 인덱스도 커맨드 수정 없이 포함됩니다.
```bash
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 # 기계 판독용 출력
```
---
## 마이그레이션
+399
View File
@@ -13,6 +13,7 @@
4. 검증 로직은 FormRequest에서 (Service에 검증 금지)
5. 다중 검색은 HasMultipleSearchFilters Trait 사용
6. Service 에서 Model 직접 인스턴스화 금지 — Repository 의 build/factory 메서드 위임 (가상 모델 합성 포함)
7. 목록 조회는 컬럼 목록 명시 + 깊은 OFFSET 이 가능한 목록은 PaginatesWithDeferredJoin Trait 사용
```
---
@@ -25,6 +26,8 @@
- [트랜잭션 및 관계 삭제 패턴](#트랜잭션-및-관계-삭제-패턴)
- [상태 변경 자동 처리](#상태-변경-자동-처리)
- [Repository 클래스](#repository-클래스)
- [목록 조회 컬럼 프루닝과 지연 조인](#목록-조회-컬럼-프루닝과-지연-조인)
- [페이지네이션 정렬의 전순서 보장](#페이지네이션-정렬의-전순서-보장)
- [다중 검색 필터 Trait](#다중-검색-필터-trait-hasmultiplesearchfilters)
- [모듈에서 Repository 인터페이스 바인딩](#모듈에서-repository-인터페이스-바인딩)
- [중첩 리소스 스코프](#중첩-리소스-스코프)
@@ -708,6 +711,49 @@ $query->selectRaw('sales_status, COUNT(*) as count');
$query->whereColumn('stock_quantity', '<=', 'safe_stock_quantity');
```
##### Raw 안에 테이블명·별칭을 쓰지 않는다
Raw 를 쓰더라도 **테이블명·별칭·프리픽스를 문자열로 조립하지 않는다.** 조립한 순간 프리픽스 처리를 사람이 떠안게 되고, 빌더가 만든 SQL 과 어긋나 조용히 깨진다.
Laravel 은 별칭에도 프리픽스를 붙인다(`Grammar::wrapAliasedTable`). `join('board_comments as uc', ...)` 는 `g7_board_comments as g7_uc` 가 되므로 `where('uc.content', ...)` 도 `g7_uc.content` 로 맞아떨어진다. 별칭을 `DB::raw` 로 직접 만들면 그 별칭만 프리픽스가 빠져, 같은 별칭을 참조하는 빌더 조건이 전부 어긋난다.
```php
// ❌ DON'T: 별칭을 raw 로 만들고 프리픽스를 손으로 처리 — where('uc.content') 가 g7_uc 를 찾아 실패한다
$prefix = DB::getTablePrefix();
$query->join(DB::raw("{$prefix}board_comments AS uc"), function ($join) {
$join->on(DB::raw("{$prefix}board_posts.id"), '=', DB::raw('uc.post_id'));
});
// ✅ DO: 빌더가 별칭과 프리픽스를 함께 처리하게 둔다
$query->join((new Comment)->getTable().' as uc', function ($join) use ($postsTable) {
$join->on("{$postsTable}.id", '=', 'uc.post_id');
});
```
부득이 raw 안에 테이블명이 필요하면(예: `LEFT(...)`, `SUBSTRING(...)`) **모델에서 얻은 이름**과 **연결 설정에서 읽은 프리픽스**를 쓴다.
```php
// ❌ DON'T
DB::raw("LEFT(`{$prefix}board_posts`.`content`, 300) as excerpt")
// ✅ DO
$postsTable = (new Post)->getTable();
DB::raw('LEFT(`'.DB::getTablePrefix().$postsTable.'`.`content`, 300) as excerpt')
```
##### 빌더로 대체 가능한 raw 관용구
| Raw | 빌더 대체 | 비고 |
|---|---|---|
| `whereRaw('1 = 0')` / `whereRaw('0 = 1')` | `whereIn($model->getKeyName(), [])` | 빈 `whereIn` 이 정확히 `0 = 1` 로 컴파일된다 |
| `where($col, DB::raw("({$sub->toSql()})"))` + `mergeBindings` | `where($col, '=', $sub)` | 빌더를 값으로 넘기면 바인딩이 자동 병합된다 |
| `addSelect(DB::raw("({$subSql}) as alias"))` | `addSelect(['alias' => $sub])` | 서브쿼리 select 는 빌더를 그대로 받는다 |
| `whereRaw("... CASE ... IN (?,?)")` | 갈래별 `orWhere(fn ($q) => $q->where(...)->whereIn(...))` | 대상 종류에 따라 갈리는 조건은 갈래별 빌더 조건으로 |
| 상관 서브쿼리 문자열 | `whereColumn()` 으로 외부 컬럼 참조 | 값 비교는 `where()` 로 바인딩 |
| 조인 + `groupBy` 로 존재 판정 | `whereHas()` | 행 증폭이 없어 inner 스캔이 가벼워지고 그룹 COUNT 도 불필요 |
집계 함수(`COUNT`/`SUM`/`COALESCE`)와 DB 고유 함수(`MATCH ... AGAINST`, `JSON_CONTAINS`, `LEFT`, `SUBSTRING`)는 빌더에 대응 표현이 없으므로 raw 로 남긴다. 이때도 **값은 바인딩**하고 테이블명은 넣지 않는다.
### 패턴
```php
@@ -788,6 +834,359 @@ public function getAll(): Collection
---
## 목록 조회 컬럼 프루닝과 지연 조인
### 규칙
- 목록 조회는 `paginate($perPage)` 처럼 컬럼 인자를 생략하지 않는다. 화면이 실제로 쓰는 컬럼만 명시한다 (인자를 생략하면 `select *` 로 폴백해 넓은 컬럼까지 전부 읽는다).
- 행이 계속 늘어나는 테이블(게시글·주문·활동 로그·알림 발송 이력 등)의 목록은 `PaginatesWithDeferredJoin` Trait 을 사용한다.
### 배경
`OFFSET n` 은 건너뛸 n 건을 읽지 않고 넘기는 것이 아니라 **실제로 읽은 뒤 버린다.** 목록 SELECT 에 `text`/`longText`/JSON 컬럼이 포함돼 있으면 버릴 n 건의 넓은 컬럼까지 함께 읽힌다. 게다가 InnoDB 는 행에 다 담기지 않는 값을 오버플로 페이지에 따로 저장하므로, 앞부분만 필요해도 그 페이지를 읽는다.
게시글 테이블 20만 행 기준 실측 (각 3회 중앙값, 첫 회 버림):
```bash
php artisan g7:bench --profile=sirsoft-board/board_posts --seed=200000 --offsets=0,20000,50000,199980 --runs=3 --explain
```
| OFFSET | 목록 컬럼 | ID 만 조회 | 배수 |
|---:|---:|---:|---:|
| 0 | 4.1ms | 3.9ms | 1.0× |
| 20,000 | 1,682.6ms | 139.8ms | 12.0× |
| 50,000 | 1,623.8ms | 90.3ms | 18.0× |
| 199,980 | 1,811.2ms | 93.9ms | 19.3× |
같은 계측의 실행 계획을 보면 원인이 하나 더 드러난다. OFFSET 0 은 `(board_id, created_at)` 인덱스를 역방향으로 훑어 정렬 없이 끝나지만, OFFSET 이 커지면 옵티마이저가 소프트 삭제 단독 인덱스(`deleted_at`)로 갈아타면서 `filesort` 가 붙는다. 넓은 컬럼을 걷어내는 것과 정렬을 인덱스로 덮는 것은 함께 가야 한다.
`SUBSTRING(content, 1, N)` 을 목록 컬럼에 두는 것은 프루닝이 아니다. 앞 N 바이트를 얻기 위해 오버플로 페이지를 그대로 읽기 때문이다. 잘라내기는 지연 조인의 outer 로 옮긴다.
계측 대상 프로파일 선언 방법과 나머지 3축(화면 응답·쓰기·배치)은 [benchmark.md](benchmark.md) 를 참조한다.
### 사용
```php
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use App\Repositories\Concerns\ResolvesSortSpec;
class OrderRepository implements OrderRepositoryInterface
{
use PaginatesWithDeferredJoin;
use ResolvesSortSpec;
public function paginate(array $filters, int $perPage): LengthAwarePaginator
{
$query = $this->model->newQuery();
$this->applyFilters($query, $filters); // where 만 적용
return $this->paginateWithDeferredJoin(
query: $query,
columns: ['id', 'order_number', 'status', 'total_amount', 'ordered_at'],
sort: $this->resolveSortSpec($filters, ['id', 'ordered_at', 'total_amount'], 'ordered_at'),
perPage: $perPage,
relations: ['user', 'items'],
);
}
}
```
### 계약
| 항목 | 규칙 |
| ------ | ------ |
| `$query` | 필터/where 만 적용한다. `orderBy`/`with`/`select` 는 Trait 이 담당한다 |
| `whereHas` | 결과 집합을 결정하는 조건이므로 그대로 둔다. Trait 이 지우는 것은 `with`(eager load) 뿐이다 |
| 관계 | **반드시 `relations:` 인자로 넘긴다.** Trait 은 inner 뿐 아니라 **outer 에서도** `setEagerLoads([])` 로 eager load 를 지우므로, `$query->with()` 만 하고 인자를 생략하면 관계가 로드되지 않는다 |
| 정렬 | 닫힌 집합이어야 한다. 인덱스 없는 임의 컬럼 정렬은 inner 도 전체 스캔하므로 개선 폭이 보장되지 않는다 |
| 키 컬럼 | 정렬 스펙 끝에 자동으로 덧붙는다. 동률 행의 순서가 흔들려 페이지 경계에서 행이 중복·누락되는 것을 막는다 |
관계 계약을 어겨도 예외나 쿼리 오류는 나지 않는다. 응답에서 관계 필드만 사라지므로, 그 필드를 단언하지 않는 테스트는 전부 통과한다. 실제로 스케줄 실행 이력의 실행자(`triggered_by`)가 이 형태로 유실됐다. `DeferredJoinCallSiteContractTest` 가 호출처를 스캔해 이 조합을 차단한다.
`paginate` / `simplePaginate` / 캐시 total 주입은 인자로 구분한다 — inner 쿼리는 동일하고 COUNT 수행 여부와 반환 클래스만 달라진다.
| 호출자 상황 | `$simple` | `$total` | 반환 |
| ------ | ------ | ------ | ------ |
| 일반 목록 | `false` | `null` | `LengthAwarePaginator` (COUNT 1회) |
| COUNT 를 피하고 다음 페이지 유무만 필요 | `true` | — | `Paginator` |
| 총 건수를 이미 캐시해 둠 | `false` | 캐시값 | `LengthAwarePaginator` (COUNT 없음) |
정렬 순서 복원은 `whereIn` + 같은 정렬 스펙 재적용으로 한다. 키 컬럼이 정렬에 포함돼 전순서가 성립하므로 결과가 inner 순서와 동일하다. MySQL 전용 `FIELD()` 는 쓰지 않는다. outer 에 존재하지 않는 표현식(집계·조인 서브쿼리)으로 정렬해 재현이 불가능한 경우에만 `$preserveIdOrder = true` 로 표준 SQL `CASE WHEN` 경로를 쓴다.
### 정렬 컬럼 화이트리스트
요청 값을 그대로 `orderBy()` 에 넘기지 않는다. Laravel 이 컬럼명을 백틱으로 감싸므로 SQL 인젝션은 성립하지 않지만, (a) 없는 컬럼으로 인한 SQL 오류가 스키마 정보를 노출하고 (b) 인덱스 없는 넓은 컬럼 정렬을 강제해 DoS 표면이 된다.
정렬 선택지가 4개 이하로 고정된 곳은 `match` 표현식으로 이미 닫혀 있으므로 그대로 두고, 요청 값이 동적 컬럼으로 흘러 들어가는 곳만 `ResolvesSortSpec` 을 쓴다.
허용 컬럼 집합은 대응하는 FormRequest 의 `Rule::in(...)` 과 같은 값으로 맞추고, 그 사실을 상수 주석에 남긴다. 요청이 아닌 경로(콘솔 커맨드·내부 호출)로도 같은 Repository 메서드가 불릴 수 있으므로, 화이트리스트는 FormRequest 하나에만 두지 않고 쿼리를 만드는 자리에도 둔다.
**게이트보다 좁게 두지 않는다.** 허용 값의 SSoT 는 요청을 실제로 거절하는 FormRequest 이고, Repository 상수는 그 아래에 깔리는 안전망이다. 상수가 게이트의 부분집합이면 **검증을 통과한 정렬이 조용히 기본값으로 되돌아간다** — 422 도 로그도 남지 않아 "정렬 버튼이 안 먹는다" 로만 관측된다. FormRequest 가 `HookManager::applyFilters` 로 확장에 열려 있는 목록은 확장이 정렬 컬럼을 늘릴 수 있으므로, 상수를 게이트보다 넓게 두는 편이 안전하다. 상수를 새로 만들거나 고칠 때는 대응 FormRequest 의 `in:` 값을 눈으로 대조하고, 컬럼명이 실재하는지 마이그레이션에서 확인한다.
**방향 검증은 컬럼 검증이 아니다.** 다음은 정렬 방향만 닫아 두고 컬럼은 그대로 흘려보낸 형태이며, 사람이 읽어도 `in_array` 가 있어 안전해 보이지만 실제로는 무방비다.
```php
// ❌ $sortOrder 만 검사 — $sortBy 는 그대로 orderBy 로 들어간다
$sortBy = $filters['sort_by'] ?? 'created_at';
$sortOrder = strtolower($filters['sort_order'] ?? 'desc');
if (! in_array($sortOrder, ['asc', 'desc'])) {
$sortOrder = 'desc';
}
$query->orderBy($sortBy, $sortOrder);
```
```php
// ✅ 컬럼·방향을 한 번에 해석 (방향 정규화는 Trait 이 담당)
foreach ($this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'created_at') as $sort) {
$query->orderBy($sort['column'], $sort['direction']);
}
```
`repository-sort-column-whitelist` 룰은 **정렬 컬럼 변수 자체를 인자로 받는** `in_array`/`match`/`array_key_exists` 또는 `resolveSortSpec` 만 화이트리스트로 인정한다.
### 화면 정렬 옵션은 게이트의 부분집합이어야 한다
앞 절의 불변식(게이트 ⊆ 저장소)은 백엔드 두 계층 사이의 관계다. 그 위에 하나가 더 있다.
```text
화면 정렬 옵션 ⊆ FormRequest 게이트 ⊆ Repository 화이트리스트
```
레이아웃의 정렬 셀렉트가 게이트에 없는 컬럼을 제공하면 그 옵션을 고르는 순간 **422 가 나고 목록은 직전 결과 그대로 남는다.** 셀렉트 라벨만 새 값으로 바뀌므로 운영자는 정렬이 적용됐다고 읽는다. 토스트도 뜨지 않는다(목록 데이터소스의 오류 경로라 화면에 노출되지 않는다).
정렬 옵션을 추가할 때는 세 곳을 함께 본다.
1. 레이아웃의 `options[].value` (`{col}_{asc|desc}` 형태로 `sort_by` 를 만든다)
2. FormRequest 의 `sort_by` `in:` / `Rule::in`
3. Repository 의 `SORTABLE_COLUMNS` (원 테이블 컬럼) 또는 `RELATED_SORTABLE_COLUMNS` (관계 테이블 컬럼)
정렬 기준 컬럼이 **원 테이블에 없으면** 다음 절의 관계 정렬을 쓴다. 화면에서 옵션을 지우는 것은 마지막 수단이다 — 운영자가 쓰던 기능이 사라진다.
`layout-sort-option-gate-parity` 룰이 레이아웃 정렬 옵션과 게이트를 정적으로 대조한다.
### 분류값 필터의 허용 어휘도 같은 불변식을 따른다
정렬에서 성립하는 포함관계는 **분류값 필터**(출처·유형·상태 등)에서도 그대로 성립한다. 다만 어긋나는 방향이 반대다 — 정렬은 화면이 게이트보다 넓어서 422 가 나고, 필터는 화면이 **좁아서** 아무 오류 없이 일부 행이 사라진다.
```text
화면 필터 옵션 = 라벨 키 = 실제 기록 어휘 (부분집합이 아니라 일치여야 한다)
```
빠진 값으로 기록된 행에는 두 가지가 동시에 일어난다.
| 어긋난 지점 | 증상 | 화면에 오류가 나는가 |
|---|---|---|
| 화면 필터 옵션에 그 값이 없음 | 그 값의 행은 **어떤 필터 조합으로도 도달 불가** — "전체" 로만 스쳐 지나간다 | 아니다 |
| 라벨 키가 없음 | 목록 셀에 `vendor-plugin.admin.x.source.그값` 원시 키 문자열이 노출 | 아니다 |
그래서 어휘는 **Enum 하나에서 파생**시킨다. FormRequest 의 `in:` 문자열, 리스너·서비스의 기록 리터럴, 레이아웃 체크박스, lang 파일에 같은 목록을 네 번 적으면 한 곳만 늘어난 순간 위 두 증상이 조용히 생긴다.
```php
enum ConsentSource: string
{
case Banner = 'banner';
case Register = 'register'; // 리스너가 기록하는 값도 반드시 여기에
case MypageRenewAll = 'mypage_renew_all'; // 서버 자체 기록 경로도 마찬가지
/** 공개 요청으로 지정 가능한 값 — 서버 자체 기록 경로는 제외 */
public static function requestSelectableValues(): array { /* ... */ }
}
```
라벨은 **백엔드와 프론트 두 표면 모두**에 정의한다. 관리자 화면의 `$t:` 는 `resources/lang/{locale}.json` 에서 해석되므로 `lang/{locale}/messages.php` 만 채우면 화면에는 여전히 원시 키가 남는다. 치환 문법도 다르다 — 백엔드는 `:name`, 프론트 엔진(`TranslationEngine.replaceParams`)은 `{name}` / `{{name}}` 만 치환한다.
어느 enum 이 어느 화면의 어휘인지는 의미 추론이 필요해 정적 검출이 어렵다(`coverage.json` 의 `filter-vocabulary-enum-parity` = `manual-only`). 확장별로 정합 테스트를 둔다 — 예: `ConsentSourceVocabularyParityTest` (기록 리터럴 ⊆ enum / 화면 옵션 = enum / ko·en × 백엔드·프론트 라벨 전수).
### 목록에 `last_page > 1` 이면 화면에도 페이저가 있어야 한다
응답이 페이지네이션인데 화면이 1페이지만 렌더하고 페이지 이동 컨트롤을 두지 않으면, 나머지 페이지는 **어떤 조작으로도 도달할 수 없다.** 총건수 표시까지 없으면 잘렸다는 사실 자체가 화면에 나타나지 않아 "이력이 그것뿐" 으로 읽힌다(감사 이력·정책 버전처럼 입증 책임이 있는 목록에서는 특히 위험하다).
데이터가 얕은 개발 환경에서는 `last_page` 가 늘 1 이라 이 결함이 드러나지 않는다. 페이지네이션 응답을 소비하는 화면을 만들 때는 `per_page` 를 줄이거나 행을 늘려 **2페이지 이상인 상태를 한 번은 눈으로 확인**한다.
### 관계 테이블 컬럼 기준 정렬 (`SortsByRelatedColumn`)
정렬 기준이 원 테이블이 아니라 관계 테이블에 있는 경우가 있다. 주문 목록의 "발송일" 이 그렇다 — `shipped_at` 은 주문이 아니라 배송 테이블에 있고, 한 주문에 배송 행이 여러 건일 수 있다.
이때 **조인으로 해결하지 않는다.** 두 가지가 함께 깨진다.
| 방식 | 무엇이 깨지는가 |
|---|---|
| `join` + `groupBy` | 1:N 조인이 원 행을 부풀린다 → 총 건수가 늘고 페이지 경계가 어긋난다 |
| `leftJoin` 만 | 자식이 여러 건인 부모가 목록에 중복 노출된다 |
| `join` (INNER) | 자식이 없는 부모(미발송 주문)가 목록에서 사라진다 |
| inner 쿼리에 집계 | 건너뛸 행 전체에 집계가 돌아 OFFSET 이 깊어질수록 비용이 누적된다 — 지연 조인을 넣은 이유가 사라진다 |
**상관 서브쿼리 정렬**을 쓴다. 원 행 수를 바꾸지 않으므로 총 건수·페이지 경계가 그대로 유지되고, 자식이 없는 행도 남는다(값이 `NULL` 로 정렬될 뿐이다).
```php
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use App\Repositories\Concerns\ResolvesSortSpec;
use App\Repositories\Concerns\SortsByRelatedColumn;
class OrderRepository implements OrderRepositoryInterface
{
use PaginatesWithDeferredJoin;
use ResolvesSortSpec;
use SortsByRelatedColumn;
/** 원 테이블 컬럼 */
private const SORTABLE_COLUMNS = ['ordered_at', 'paid_at', 'total_amount'];
/** 관계 테이블 컬럼 — `(외래키, 정렬컬럼)` 복합 인덱스가 전제 */
private const RELATED_SORTABLE_COLUMNS = [
'shipped_at' => [
'model' => OrderShipping::class,
'foreign_key' => 'order_id',
'column' => 'shipped_at',
],
];
public function getListWithFilters(array $filters, int $perPage): LengthAwarePaginator
{
// ... 필터/where 적용 ...
$sort = $this->resolveSortSpecWithRelated(
$filters,
self::SORTABLE_COLUMNS,
self::RELATED_SORTABLE_COLUMNS,
$this->model,
'ordered_at',
);
return $this->paginateWithDeferredJoin(
query: $query,
columns: self::LIST_COLUMNS,
sort: $sort,
perPage: $perPage,
);
}
}
```
**집계 함수를 인자로 받지 않는 이유.** 서브쿼리를 정렬 방향과 같은 방향으로 정렬해 한 건만 취하면, `desc` 는 자연히 최댓값(가장 늦은 발송), `asc` 는 최솟값(가장 이른 발송)이 된다. 운영자가 "최근 발송순" 을 골랐을 때 기대하는 값과 일치하고, `MAX()`/`MIN()` 을 따로 지정하다 방향과 어긋나는 실수가 생기지 않는다.
**복합 인덱스는 선택이 아니다.** 서브쿼리는 목록 행마다 한 번씩 실행되므로 `(외래키, 정렬컬럼)` 인덱스가 없으면 행 수만큼 관계 테이블을 스캔한다. 외래키 단일 인덱스만 있으면 정렬 컬럼이 인덱스에 없어 매번 행을 읽어 정렬한다. 관계 정렬을 추가하는 변경에는 **인덱스 마이그레이션을 함께 넣는다.**
```php
$table->index(['order_id', 'shipped_at'], 'ecommerce_order_shippings_order_id_shipped_at_index');
```
정렬 스펙의 `column` 은 문자열 외에 `Builder`/`Expression` 도 올 수 있다. `PaginatesWithDeferredJoin` 이 이를 그대로 받아 inner/outer 양쪽에 적용한다 — 서브쿼리가 원 행의 키에 상관되므로 어느 쪽에 적용해도 같은 순서가 나오고, outer 에서는 이번 페이지 건수만큼만 실행된다. 키 컬럼은 전순서 보장을 위해 항상 마지막에 덧붙는다.
**게이트에도 함께 올린다.** `RELATED_SORTABLE_COLUMNS` 의 키는 FormRequest 의 `sort_by` 허용 목록에 들어가야 한다. `SortWhitelistGateParityTest` 가 두 상수를 합쳐 게이트 ⊆ 저장소 불변식을 검사한다.
### 예외
설정/정의성 테이블처럼 행 수가 고정이고 넓은 컬럼이 없으면 컬럼 목록 없이 페이지네이션해도 된다. 다만 그 판단 근거를 코드 옆에 남긴다.
```php
// audit:allow repository-paginate-column-pruning reason: 역할 정의 테이블 — 행 수 고정(<100), 넓은 컬럼 없음
return $query->paginate($perPage);
```
면제 주석은 **위반 줄에 인접**해야 인식된다. 메서드 첫 줄에 두면 체인 중간의 `->paginate(...)` 줄과 사이에 코드가 끼어 인식되지 않는다.
```php
// ✅ 체인 안에서는 ->paginate 바로 위에 둔다
return $this->model->newQuery()
->where('user_id', $userId)
->orderByDesc('created_at')
// audit:allow repository-paginate-column-pruning reason: 사용자 1명에 종속된 목록 — OFFSET 이 깊어질 수 없다
->paginate($perPage);
```
면제 사유로 인정되는 판단 근거는 다음 세 가지다. 어느 것도 아니면 지연 조인으로 전환한다.
| 근거 | 성립 조건 | 예 |
|---|---|---|
| 정의/템플릿 테이블 | 행 수가 운영자가 등록한 수에 묶이고 넓은 컬럼이 없다 | 게시판 정의, 배송정책, 상품 안내 템플릿 |
| 부모 1건에 종속 | `where(부모FK)` 로 좁혀져 OFFSET 이 깊어질 수 없다 | 상품 1건의 문의, 사용자 1명의 찜 |
| ID 집합으로 선한정 | `whereIn('id', $matchedIds)` 로 스캔 대상이 이미 묶였다 | FULLTEXT 매칭 후 조회하는 통합검색 |
"부모 1건에 종속" 은 그 부모가 가질 수 있는 자식 수를 실제로 따져 본 뒤에만 쓴다. 대량 발급 쿠폰 1건의 발급 이력처럼 부모가 하나여도 수십만 행이 되는 경우는 이 근거가 성립하지 않는다.
컨트롤러가 Service 에 목록 조회를 위임하는 형태(`$this->service->paginate(...)`)는 컬럼 선택 책임이 위임받은 Repository 에 있으므로 룰이 검사하지 않는다.
### outer 의 `columns: ['*']` 는 위반이 아니다
지연 조인의 목적인 **OFFSET 구간의 넓은 컬럼 읽기**는 inner 가 키 컬럼만 읽는 것으로 이미 사라진다. outer 가 읽는 행 수는 페이지 크기로 고정되므로 `columns: ['*']` 여도 뒤쪽 페이지의 비용 증가는 없다. 넓은 컬럼을 가진 테이블에서 목록 컬럼을 좁히는 것은 그 위에 얹는 추가 최적화이고, 화면이 실제로 쓰는 컬럼이 분명할 때 적용한다.
다만 `columns:` 자체를 생략하면 outer 가 무엇을 읽는지 호출처에서 드러나지 않으므로 항상 명시한다. `DeferredJoinCallSiteContractTest` 가 호출처를 스캔해 `columns:`/`sort:` 명시 여부를 전수 검사한다.
### 페이지네이션 정렬의 전순서 보장
페이지네이션 목록의 정렬은 **고유 키로 닫는다**. 정렬이 비고유 컬럼만으로 끝나면 동률 구간의
행 순서가 페이지마다 달라질 수 있고(SQL 표준은 동률의 순서를 보장하지 않는다), 그 결과
**인접 페이지가 같은 행을 중복 노출하면서 다른 행은 어느 페이지에도 나오지 않는다.**
```php
// ❌ created_at 동률 구간에서 페이지 경계가 흔들린다
$query->orderBy('created_at', 'desc')->paginate($perPage);
// ✅ 정렬 마지막에 기본키를 덧붙여 전순서를 만든다
$query->orderBy('created_at', 'desc')->orderBy('id', 'desc')->paginate($perPage);
```
`ResolvesSortSpec` / `PaginatesWithDeferredJoin` 를 경유하는 목록은 트레이트가 키 컬럼을 자동으로
덧붙이므로 이 규율을 이미 만족한다. 손으로 `orderBy` 를 조립한 뒤 `paginate()` 하는 지점만
위험하다.
동률은 드문 일이 아니다 — 시더·일괄 등록·대량 마이그레이션은 여러 행에 같은 `created_at` 을
남긴다(#492 실측: `roles.created_at` 한 타임스탬프에 최대 10행, 110건 목록에서 고유 키 109개).
판정은 눈으로 하지 않는다. 전 페이지를 순회해 키를 모으고 **합집합 크기 = 총건수**, 인접 페이지
교집합 = ∅ 를 확인한다.
### 필요한 색인은 선언에서 도출한다
지연 조인은 inner 가 키 컬럼만 읽게 만들지만, 그 inner 가 **인덱스 순서 그대로** 끝나야 깊은 OFFSET 이 싸진다. 정렬을 덮는 색인이 없으면 inner 도 filesort 로 전체를 훑으므로 개선 폭이 사라진다. 즉 지연 조인과 색인은 함께 가야 하며, 한쪽만 해두면 화면에서는 "여전히 뒤로 갈수록 느리다" 로만 드러난다.
어떤 색인이 필요한지는 계측 프로파일 선언에 이미 들어 있다. `filters` · `order` · `soft_delete` 를 이으면 그대로 레시피가 된다:
```text
(등치 필터 컬럼들 → soft_delete 면 deleted_at → 정렬 컬럼들 → 기본키)
```
`App\Benchmark\ListIndexAdvisor` 가 이 도출을 담당하고, `ListIndexCoverageTest` 가 전 목록을 검사한다. **새 목록이 프로파일을 선언하면 필요한 색인이 자동으로 검사 대상이 되므로**, 색인 설계를 테이블마다 손으로 반복하지 않아도 된다.
| 판정 | 뜻 | 처방 |
| ------ | ------ | ------ |
| `satisfied` | 도출한 선행 컬럼을 그대로 덮는 색인이 있다 | — |
| `tiebreak_missing` | 색인이 정렬 컬럼에서 끝나 동률 구간에 filesort 가 남는다 | 기존 색인 끝에 기본키를 덧붙여 **교체** (좌측 프리픽스라 남기면 쓰기 비용만 늘어난다) |
| `missing` | 정렬을 덮는 색인이 없다 | 도출한 순서대로 색인 신설 |
주의할 점 둘.
- **등치가 아닌 필터는 선행 컬럼이 될 수 없다.** `in` / `not in` / 범위 조건은 그 지점에서 등치 사슬을 끊으므로 뒤따르는 정렬 컬럼을 인덱스 순서로 쓸 수 없다. 이 판정을 틀리면 "색인이 있는데도 filesort" 라는, 눈으로는 구분되지 않는 상태를 정상으로 보고하게 된다.
- **UNIQUE 색인은 기본키를 덧붙일 필요가 없다.** 값이 중복되지 않아 그 컬럼만으로 이미 전순서다.
색인이 불필요한 목록(행 수가 고정이거나 상한이 작은 정의성 데이터)은 프로파일에 사유와 함께 면제를 선언한다. 사유 없는 면제를 받지 않는 이유는, 면제가 쌓이면 검사 자체가 무의미해지는데 사유가 없으면 그 판단이 여전히 옳은지 나중에 확인할 방법이 없기 때문이다.
```php
'index_exempt' => '페이지는 사이트 구조를 구성하는 정의성 데이터라 ... 수천을 넘기 시작하면 (created_at, id) 색인을 추가할 것.',
```
모듈/플러그인 프로파일은 **그 확장의 테스트 스위트에서** 검사한다. 확장 테이블은 그 스위트에서만 마이그레이션되므로 코어 스위트에 몰면 테이블이 없어 건너뛰고 초록인 채 미검사가 된다. 같은 이유로 "테이블이 없으면 skip" 도 하지 않고 실패로 보고한다.
### 정적 검사 대상
| 검출 대상 | 처리 |
|---|---|
| `->paginate($perPage)` / `->paginate($perPage, ['*'])` — 컬럼 목록 미지정 또는 전체 컬럼 | 차단 |
| 페이지네이션 정렬이 비고유 컬럼으로만 끝남 (기본키 타이브레이크 부재) | 차단 |
| 그룹 쿼리의 총 건수를 `count()` 로 계산 (`getCountForPagination()` 필요) | 차단 |
| 요청 값에서 온 정렬 컬럼을 닫힌 집합 검증 없이 `orderBy` 에 전달 | 경고 |
검사 범위는 `app/Repositories/**`, `modules|plugins/_bundled/**/Repositories/**` 와 컨트롤러 3계열이다. 미수정 파일까지 확인하려면 변경분이 아니라 저장소 전체를 대상으로 검사한다 — 변경 파일만 보는 방식으로는 이미 저장소에 있던 위반이 드러나지 않는다.
호출 계약 중 `relations:` 항목은 정적 룰이 아니라 테스트(`DeferredJoinCallSiteContractTest`)가 검사한다. 쿼리에 `with()` 를 붙이고 `relations:` 를 넘기지 않으면 관계가 조용히 사라지는데, 이 실패는 예외도 쿼리 오류도 내지 않아 관계 필드를 단언하지 않는 HTTP 테스트는 전부 통과한다.
검사 범위는 `app/Repositories/**`, `modules|plugins/_bundled/**/Repositories/**` 와 컨트롤러 3계열이다. 미수정 파일까지 확인하려면 변경분이 아니라 저장소 전체를 대상으로 검사한다 — 변경 파일만 보는 방식으로는 이미 저장소에 있던 위반이 드러나지 않는다.
---
## 다중 검색 필터 Trait (HasMultipleSearchFilters)
목록 API에서 다중 검색 조건을 지원해야 할 때 `HasMultipleSearchFilters` Trait을 사용합니다.
+124
View File
@@ -14,6 +14,8 @@
5. 훅 확장: HookManager::applyFilters()로 동적 규칙 추가
6. 필수: Rule::exists(Model::class, 'col') 사용 (문자열 테이블명 사용 금지)
7. 런타임 조건부 검증: 드라이버/모드별 분기 시 메서드 추출 패턴 사용
8. Store/Update 조건부 규칙은 대칭 — 한쪽 누락 시 과잉 검증(수정 실패) 또는 검증 우회
9. min/size 가 붙은 필드는 required_if 로 조건부화 불가 → exclude_if (조건 필드가 nullable 이면 exclude_unless)
```
---
@@ -26,6 +28,9 @@
- [prepareForValidation() 데이터 전처리](#prepareforvalidation-데이터-전처리)
- [훅 기반 동적 Validation Rules 확장](#훅-기반-동적-validation-rules-확장)
- [런타임 조건부 Validation Rules (드라이버/모드별 분기)](#런타임-조건부-validation-rules-드라이버모드별-분기)
- [조건부 Validation 원칙](#조건부-validation-원칙)
- [`exclude_if` vs `exclude_unless` — 조건 필드가 nullable 일 때](#exclude_if-vs-exclude_unless--조건-필드가-nullable-일-때)
- [조건 필드가 요청에 없을 때 — 저장값 주입](#조건-필드가-요청에-없을-때--저장값-주입)
- [권한 체크 방식](#권한-체크-방식)
- [Custom Rule 검증 메시지 다국어 처리](#custom-rule-검증-메시지-다국어-처리)
- [다국어 필드 검증 규칙](#다국어-필드-검증-규칙)
@@ -34,6 +39,7 @@
- [exists/unique 검증 규칙](#existsunique-검증-규칙)
- [계층 리소스 순환 참조](#계층-리소스-순환-참조)
- [배열 항목의 상위 스코프](#배열-항목의-상위-스코프)
- [관대한 기존 동작을 규칙으로 승격하지 않는다](#관대한-기존-동작을-규칙으로-승격하지-않는다)
- [Custom Rule 개발 체크리스트](#custom-rule-개발-체크리스트)
---
@@ -522,6 +528,7 @@ class TestMailRequest extends FormRequest
| 기본값 제공 | `$this->input('mailer', 'smtp')` — 미전송 시 기본 드라이버 적용 |
| Store/Update 정책 일치 | 같은 리소스의 Store 와 Update 는 동일 필드에 동일한 조건부 규칙을 적용한다. 한쪽에만 `exclude_if` 등이 있으면 다른 쪽에서만 저장이 막힌다 |
| `sometimes` 는 부분 수정 지원 수단이 아니다 | 관리자 폼은 조회 응답 전체를 그대로 PUT 하므로 요청에는 항상 모든 키가 존재한다. 토글 OFF 상태의 종속 필드는 `sometimes` 가 아니라 `exclude_if` 로 검증에서 배제한다 |
| 상호 필수 조합은 결과 상태로 판정 | `required_with`/`required_without` 는 "상대 필드 부재" 자체가 발화 조건이라 부분 수정 요청과 상충한다. Update 에서는 페이로드 단독이 아니라 "기존 레코드 + 페이로드" 결과 상태를 기준으로 판정한다 |
`exclude_if` 는 규칙 배열의 **첫 번째**에 둔다. 배제는 규칙 루프 도중 발동하며 이미 추가된 실패 메시지는 회수되지 않으므로, 앞선 규칙이 먼저 실패하면 배제되어도 오류가 남는다.
@@ -533,6 +540,68 @@ class TestMailRequest extends FormRequest
'allowed_extensions' => ['exclude_if:use_file_upload,false', 'sometimes', 'required', 'array', 'min:1'],
```
조건부 규칙의 종류에 따라 Update 로 옮기는 방식이 달라진다.
| Store 규칙 | Update 에 옮기는 방식 |
| ------ | ------ |
| `required_if:other,value` | 그대로 복사한다. 조건 필드가 요청에 없으면 발화하지 않아 부분 수정과 충돌하지 않는다 |
| `exclude_if` / `exclude_unless` | 그대로 복사한다. 조건 필드가 요청에 없으면 배제가 동작하지 않으므로(`ValidatesAttributes::validateExcludeIf`), 부분 수정 경로가 있으면 `prepareForValidation()` 에서 기존 레코드 값을 주입해 판정 기준을 세운다 |
| `required_with` / `required_without` | 그대로 복사하면 안 된다. 상대 필드가 요청에 없다는 사실만으로 발화하므로, 그 조합과 무관한 필드 하나만 바꾸는 요청이 전부 422 가 된다 |
`required_with`/`required_without` 는 "요청에 무엇이 실려 왔는지"가 아니라 "저장 후 레코드가 유효한지"를 묻는 규칙이다. 따라서 Update 에서는 요청에 없는 필드를 저장된 값으로 메운 뒤 판정한다. 이때 폼 제출 키와 저장 컬럼명이 다르면(예: 해외 주소를 `intl_city` 로 제출하고 `city` 컬럼에 저장) 메우는 쪽에서 그 매핑을 반영해야 한다 — 매핑을 빠뜨리면 저장된 값이 있는데도 빈 값으로 보여 정상 수정 경로가 막힌다.
```php
// ❌ DON'T: Store 규칙을 그대로 복사 — 기본 배송지 토글만 바꾸는 요청이 422
'zipcode' => 'required_without:intl_postal_code|nullable|string|max:10',
// ✅ DO: 결과 상태(기존 레코드 + 페이로드) 기준으로 withValidator() 에서 판정
$validator->after(function (Validator $validator) {
$existing = app(UserAddressRepositoryInterface::class)->findByUserIdAndId($userId, $addressId);
$effective = array_key_exists('zipcode', $payload) ? $payload['zipcode'] : $existing?->zipcode;
// ... 국가별 필수 조합 판정 후 $validator->errors()->add(...)
});
```
이 방식은 `formrequest-store-update-conditional-symmetry` 룰의 토큰 비교로는 "누락"으로 보이므로, 해당 필드에 `// audit:allow formrequest-store-update-conditional-symmetry reason: ...` 로 판정 위치를 남긴다.
### `exclude_if` vs `exclude_unless` — 조건 필드가 nullable 일 때
`exclude_if:other,false` 의 값 비교는 strict 다. 조건 필드가 `nullable|boolean` 이라 `null` 이 올 수 있으면 `null !== false` 라 매칭되지 않아 배제가 일어나지 않는다. "true 가 아니면 제외" 를 뜻하는 `exclude_unless:other,true` 는 `null`/`false`/미전송을 모두 포괄한다.
```php
// 조건 필드가 boolean 고정 — false 가 확실히 온다
'allowed_extensions' => ['exclude_if:use_file_upload,false', 'sometimes', 'required', 'array', 'min:1'],
// 조건 필드가 nullable — null 이 올 수 있다
'basic_defaults.allowed_extensions' => ['exclude_unless:basic_defaults.use_file_upload,true', 'sometimes', 'required', 'array', 'min:1'],
```
**중첩 배열의 조건 필드는 dot notation 으로 쓴다.** `exclude_unless:use_file_upload,true` 처럼 최상위 키만 적으면 요청 데이터에 그 키가 없어 조건이 성립하지 않고, 배제가 조용히 동작하지 않는다. 검증 대상 필드가 `basic_defaults.allowed_extensions` 라면 조건 필드도 `basic_defaults.use_file_upload` 여야 한다.
### 조건 필드가 요청에 없을 때 — 저장값 주입
`exclude_if`/`exclude_unless` 는 **조건 필드가 데이터에 없으면 배제하지 않는다**(`ValidatesAttributes::validateExcludeIf`). 부분 수정 경로가 있는 Update 요청은 종속 필드만 전송되고 조건 토글은 빠질 수 있으므로, `prepareForValidation()` 에서 기존 레코드의 값을 주입해 판정 기준을 세운다.
```php
protected function prepareForValidation(): void
{
$data = $this->all();
// 종속 필드가 왔는데 조건 토글이 없으면 저장값으로 판정 기준을 세운다
if (array_key_exists('allowed_extensions', $data) && ! array_key_exists('use_file_upload', $data)) {
$board = $this->route('board');
if ($board !== null && isset($board->use_file_upload)) {
$data['use_file_upload'] = (bool) $board->use_file_upload;
}
}
$this->merge($data);
}
```
주입 조건에 **종속 필드가 실제로 전송되었는지**를 반드시 포함한다. 무조건 주입하면 사용자가 보내지도 않은 토글이 검증 대상으로 올라와, 그 필드와 무관한 부분 수정이 조건부 규칙의 영향을 받는다.
자동 차단: 정적 검사 대상. store/update 사이의 조건부 검증 비대칭은 차단되고, `sometimes` + `array` + `min` 조합이 무효화되는 형태는 경고로 보고된다.
---
@@ -1088,6 +1157,61 @@ Service 에도 동일한 검증을 두어 FormRequest 를 우회한 내부/훅
---
## 관대한 기존 동작을 규칙으로 승격하지 않는다
컨트롤러가 base `Request` 를 주입받던 코드를 FormRequest 로 옮길 때, 그 컨트롤러가 하던 일이
**거부**였는지 **수용**이었는지 먼저 읽는다. 수용하던 동작(클램핑·폴백·별칭 매핑)을 `rules()` 로
올리면 200 이던 응답이 422 가 된다. 위반을 없애는 것이 목적이지 계약을 바꾸는 것이 아니다.
```php
// 원본 컨트롤러 — 상한 초과를 거부하지 않고 상한까지 반환한다
$limit = min((int) $request->input('limit', 20), 50);
// ❌ 규칙으로 승격 — `?limit=100` 이 200 → 422 (기존 링크·북마크가 깨진다)
public function rules(): array
{
return ['limit' => ['nullable', 'integer', 'max:50']];
}
// ✅ 규칙은 타입만 닫고, 상한은 접근자가 클램프한다
public function rules(): array
{
return ['limit' => ['nullable', 'integer', 'min:0']];
}
public function limit(): int
{
return min((int) $this->validated('limit', 20), self::MAX_LIMIT);
}
```
같은 원칙이 어휘에도 적용된다. 저장소가 미지원 값을 기본 분기로 처리해 왔다면 `Rule::in` 으로
막는 순간 그 값을 담은 URL 이 전부 깨진다. 어휘를 닫아야 할 별도 이유(예: 요청 값이 캐시 키에
그대로 들어가 키 공간이 무한히 늘어남)가 있어도, **접근자가 닫힌 집합만 반환**하게 하면 응답
계약을 건드리지 않고 목적을 달성한다.
```php
// ✅ 응답은 종전과 동일(미지원 값 → year), 캐시 키만 정규화된다
public function period(): string
{
$period = $this->validated('period');
if ($period === null || $period === '') {
return self::DEFAULT_PERIOD;
}
return in_array((string) $period, self::RESOLVED_PERIODS, true) ? (string) $period : 'year';
}
```
`Rule::in` 승격이 적절한 경우는 **입력원이 화면 컨트롤 하나뿐**일 때다 — 관리자 DataGrid 의 필터
select 처럼 사용자가 URL 을 손으로 만들 일이 없는 면. 판정 기준은 "북마크 가능한 공개 경로인가".
기존 테스트가 이 전환에서 RED 가 되면 그 테스트가 무엇을 고정하고 있는지 먼저 읽는다. 리팩토링에서
테스트가 깨질 때의 기본 해석은 "테스트가 낡았다" 가 아니라 "계약을 바꿨다" 이다.
---
## Custom Rule 개발 체크리스트
- [ ] `/lang/ko/validation.php`에 한국어 메시지 추가
+44
View File
@@ -108,6 +108,31 @@ vendor/bin/pint --dirty
---
## 목록 조회 (컬럼 프루닝 · 지연 조인)
```php
// 계속 쌓이는 목록 = 지연 조인. 정렬 컬럼이 요청 값이면 닫힌 집합으로 해석.
use App\Repositories\Concerns\{PaginatesWithDeferredJoin, ResolvesSortSpec};
return $this->paginateWithDeferredJoin(
query: $query, // 필터/where 만. orderBy·with·select 금지
columns: self::LIST_COLUMNS, // ['*'] 도 위반 아님
sort: $this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'created_at'),
perPage: $perPage,
relations: ['user'], // 관계는 반드시 이 인자로 (with() 는 지워진다)
);
```
| 함정 | 결과 |
| ------ | ------ |
| `$query->with()` 만 하고 `relations:` 생략 | 관계가 조용히 사라짐 — 예외·오류 없음 |
| `SUBSTRING(content,1,N)` 을 inner 에 둠 | 오버플로 페이지 읽기 그대로 발생 (프루닝 아님) |
| 정렬을 비고유 컬럼으로만 끝냄 | 페이지 경계에서 행 중복·누락 (trait 이 PK 자동 append) |
상세: [service-repository.md](backend/service-repository.md#목록-조회-컬럼-프루닝과-지연-조인)
---
## 마이그레이션
```bash
@@ -255,6 +280,25 @@ php artisan seo:generate-sitemap --rebuild # 전체 재생성 (mode=full)
php artisan seo:generate-sitemap --mode=full|auto|incremental # 재생성 모드 지정
```
### 성능 계측 Artisan 커맨드
```bash
# 4축(목록/화면/쓰기/배치) 성능 계측. 계측 대상은 코어 config/benchmark.php + 확장 getBenchmarkProfiles() 선언.
# 상세: docs/backend/benchmark.md
php artisan g7:bench --list-profiles # 등록된 프로파일 목록
php artisan g7:bench --profile=core/users_screen # 화면 1장 응답 시간 + 쿼리 건수 + N+1 후보
php artisan g7:bench --axis=list # 축 단위
php artisan g7:bench --all --allow-write --report # 전체 + 마크다운 리포트 (storage/app/benchmarks/)
# 깊은 OFFSET 계측 — 대량 합성 행을 시딩하므로 운영 데이터가 있는 환경에서 --seed/--fresh 를 쓰지 않는다.
php artisan --env=testing g7:bench --profile=sirsoft-board/board_posts --fresh --seed=200000
php artisan --env=testing g7:bench --profile=sirsoft-board/board_posts --offsets=0,20000,50000,199980 --runs=3 --explain
php artisan g7:bench --profile=sirsoft-ecommerce/orders --json # 기계 판독용
# 데이터를 변경하는 축(write/batch/비-GET screen)은 --allow-write 없이 거부된다.
php artisan g7:bench --profile=sirsoft-ecommerce/order_create --allow-write
```
### API 문서 Artisan 커맨드
```bash
+19
View File
@@ -221,6 +221,25 @@ public function up(): void
}
```
#### 정렬을 덮는 복합 인덱스의 선행 컬럼
인덱스가 `ORDER BY` 를 덮으려면 **선행 컬럼이 모두 등치로 고정**되고 그 다음 컬럼이 정렬 컬럼이어야 한다. 선택적 필터 컬럼을 선행에 두면, 그 필터를 걸지 않은 기본 진입에서 정렬을 못 덮어 filesort 가 그대로 남는다.
```php
// ❌ status 는 선택적 필터 — 필터 없는 기본 목록에서 created_at 정렬을 못 덮는다
$table->index(['status', 'created_at']);
// ✅ SoftDeletes 가 항상 `deleted_at IS NULL` 을 붙이므로 선행 컬럼이 고정된다
$table->index(['deleted_at', 'created_at']);
```
필터 조합이 다양한 목록은 단일 최적 인덱스가 없다. **항상 걸리는 조건**을 선행에 두고 하나만 추가한다 — 조합마다 인덱스를 추가하면 쓰기 비용만 늘어난다.
카디널리티가 극히 낮은 컬럼(`deleted_at` 처럼 대부분 NULL)의 **단독** 인덱스는 옵티마이저가 좁은 range 로 오판해 index_merge 후보로 올리므로 거의 항상 해롭다. 다만 기존 단독 인덱스를 제거할 때는 다른 쿼리가 그것에 의존할 수 있으므로, 제거 전 해당 컬럼을 쓰는 쿼리를 전수 확인한다.
> 후속 검토 항목: `g7_board_posts_deleted_at_index`(단독) — 지연 조인 전환으로 index_merge 는 사라졌으나 인덱스 자체는 남아 있다. 의존 쿼리 전수 확인 후 제거 여부를 별도로 판단한다.
> `board_posts` 에 정렬 전용 복합 인덱스를 **추가하지 않기로** 판정한 근거: 지연 조인 적용 후 재측정에서 index_merge 가 소멸했고 inner 가 기존 `idx_board_posts_list_count` 를 커버링으로 사용한다. 후보 인덱스 `(board_id, is_notice, parent_id, deleted_at, id)` 를 실제로 만들어 강제 비교했을 때의 이득이 1.6배에 그쳐 쓰기 비용 대비 근거가 부족하다. 계측 데이터가 단일 게시판·공지 없음·답글 없음의 균일 분포라 두 후보의 선택도가 같아지는 한계가 있어, 실데이터 분포에서 재확인이 필요하다.
#### `->change()` 패턴의 멱등성
`->change()` 는 이미 존재하는 컬럼의 타입/제약 변경이므로 컬럼 부재 fatal 위험은 없다. 단, MariaDB/MySQL 의 일부 ALTER 가 비-멱등인 경우 (예: enum 값 추가 후 재실행) 가 있으므로 마이그레이션 작성자가 의도 검증 필요. 정적 검사는 `->change()` 라인을 자동 제외하므로 false positive 미발생.
+3 -3
View File
@@ -75,9 +75,9 @@ injection 또는 modals/data_sources/scripts/init_actions 보유, EP: 병합할
필수: Layout Extension 시스템을 통한 동적 UI 주입
필수: 모듈 비활성화 시 관련 UI 자동 숨김
✅ 필수: 확장 컴포넌트에 ExtensionBadge 표시 (관리자 UI)
필수: 플러그인은 완전한 레이아웃 등록 불가 → 확장 지점(layout_extensions)만 사용
(예외: settings.json 환경설정 레이아웃은 registerPluginLayouts()로 등록)
필수: 모듈만 완전한 레이아웃 등록 가능 (admin/user 모두)
필수: 플러그인이 자기 화면을 가질 때 라우트는 plugins/{identifier}/ 네임스페이스 안에 선언
(다른 확장이 소유한 경로를 선언하면 설치 순서에 따라 화면이 조용히 바뀐다)
필수: 다른 확장의 화면에 UI 를 끼워 넣을 때는 확장 지점(layout_extensions) 사용
```
---
+1
View File
@@ -142,6 +142,7 @@
| `getDependencies()` | `[]` | 의존성 |
| `getMetadata()` | `[]` | 메타데이터 |
| `getMiddleware()` | `[]` | 확장 미들웨어 선언 (self-gate targets) — `{class, groups, timing?, targets}` ([middleware.md](../backend/middleware.md)) |
| `getBenchmarkProfiles()` | `[]` | 성능 계측 대상 선언 (`g7:bench` 가 수집) — 목록/화면/쓰기/배치 4축 ([benchmark.md](../backend/benchmark.md)) |
| `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견). **`g7_version >= 7.0.0-beta.5` 인 모듈은 신규 step 이 `AbstractUpgradeStep` 상속 의무** ([upgrade-step-guide §13](upgrade-step-guide.md)) — 미상속 시 `ModuleManager::runUpgradeSteps` 가 `RuntimeException` throw |
#### 동적 권한/역할/메뉴 보존 규칙
+12 -3
View File
@@ -7,8 +7,8 @@
## TL;DR (5초 요약)
```text
1. 백엔드: /lang/{locale}/*.php → __('vendor-module::key')
2. 프론트엔드: /resources/lang/{locale}.json → $t:key
1. 백엔드: /src/lang/{locale}/*.php → __('vendor-module::key') — 이 경로만 로드된다
2. 프론트엔드: /resources/lang/{locale}.json → $t:key (resources/lang 아래 *.php 는 미로드)
3. JSON에 moduleIdentifier 포함 금지! (자동 병합됨)
4. 지원 언어: ko, en 필수
5. 키 충돌 방지: 모듈별 고유 prefix 사용 권장
@@ -36,9 +36,18 @@
| 구분 | 파일 경로 | 형식 | 사용처 |
|------|----------|------|--------|
| 백엔드 | `/lang/{locale}/*.php` | PHP 배열 | Laravel `__()` 함수 |
| 백엔드 | `/src/lang/{locale}/*.php` | PHP 배열 | Laravel `__()` 함수 |
| 프론트엔드 | `/resources/lang/{locale}.json` | JSON | 레이아웃 JSON `$t:` 문법 |
> 모듈의 백엔드 다국어는 `src/lang` **한 곳**이다. `TranslationServiceProvider` 가 그 경로만
> 등록하고, 확장 설치 검증(`ValidatesTranslationPath`)도 같은 경로를 강제한다. 언어팩 시스템
> (`LanguagePackService` · `LanguagePackServiceProvider`) 도 같은 경로를 스캔한다.
>
> `resources/lang/{locale}/*.php` 에 백엔드 문구를 두면 **아무도 읽지 않는다** — 화면에는
> `src/lang` 값이 나오고, 그 파일을 고쳐도 반영되지 않으며, 그 사실이 화면에 드러나지 않는다.
> `resources/lang` 아래에서 유효한 것은 프런트엔드용 `{locale}.json` 과 `partial/{locale}/*.json`
> 뿐이다. (플러그인은 `lang/{locale}/*.php`, 템플릿은 `lang/{locale}.json` 이 각각 그 자리다.)
> **코어 자체 다국어 자원도 동일 구조** — 코어는 `lang/{ko,en}/*.php` (백엔드) +
> `lang/{ko,en}.json` (+ `lang/partial/{ko,en}/*.json`) (프론트엔드) 를 사용한다.
> 모듈/플러그인/템플릿/코어 모두 같은 디렉토리 구조와 $partial 디렉티브 메커니즘을
+13 -6
View File
@@ -241,6 +241,7 @@ class Plugin implements PluginInterface
| `getDependencies()` | `[]` | 의존하는 모듈/플러그인 목록 |
| `getHookListeners()` | `[]` | 훅 리스너 클래스 목록 |
| `getMiddleware()` | `[]` | 확장 미들웨어 선언 (self-gate targets) — `{class, groups, timing?, targets}` ([middleware.md](../backend/middleware.md)) |
| `getBenchmarkProfiles()` | `[]` | 성능 계측 대상 선언 (`g7:bench` 가 수집) — 목록/화면/쓰기/배치 4축 ([benchmark.md](../backend/benchmark.md)) |
| `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견). **`g7_version >= 7.0.0-beta.5` 인 플러그인은 신규 step 이 `AbstractUpgradeStep` 상속 의무** ([upgrade-step-guide §13](upgrade-step-guide.md)) — 미상속 시 `PluginManager::runUpgradeSteps` 가 `RuntimeException` throw |
> **동적 식별자 보존 규칙**: `Permission::updateOrCreate()` / `Role::firstOrCreate()` 등으로 런타임에 생성한 엔티티는 업데이트 시 `cleanupStalePluginEntries` 에 의해 "정적 정의에 없는 고아 레코드" 로 판정되어 삭제될 위험이 있습니다. 이를 방지하려면 동적 식별자 목록을 위 3개 훅에서 반환하세요 — 정적 정의 + 동적 식별자가 병합된 expected 목록을 기준으로 판정되어 보존됩니다. 상세는 [extension-update-system.md](extension-update-system.md) 참조.
@@ -1039,19 +1040,25 @@ $response = Http::withToken($token)
// 변경 시에만 캐시 갱신
```
#### 6. 레이아웃 제한 (Layout Restriction)
#### 6. 레이아웃 소유와 라우트 네임스페이스 (Layout Ownership)
플러그인은 모듈과 달리 완전한 레이아웃을 등록할 수 없습니다.
플러그인도 자기 화면(완전한 레이아웃)을 가질 수 있습니다. 다만 그 화면의 라우트는
플러그인 네임스페이스 안에 선언해야 합니다.
```text
필수: 페이지 레이아웃은 모듈만 등록 가능 (플러그인은 layout_extensions만 사용)
허용: admin/plugin_settings.json (환경설정 UI) — 유일한 완전 레이아웃
✅ 필수: 설정 외 UI 확장은 layout_extensions(확장 지점/Overlay)로만 가능
필수: 플러그인이 소유하는 화면의 라우트는 plugins/{identifier}/ 아래에 선언
예) "*/admin/plugins/sirsoft-gdpr/consent-log"
필수: 다른 확장이 소유한 화면에 UI 를 끼워 넣을 때는 layout_extensions(확장 지점/Overlay) 사용
```
라우트 네임스페이스를 지키지 않으면, 같은 경로를 소유한 모듈/템플릿과 어느 쪽이 이기는지가
설치 순서에 좌우됩니다. 화면이 조용히 다른 것으로 바뀌고, 어느 확장 때문인지 화면만 봐서는
알 수 없습니다. 정적 검사가 이 위반을 차단합니다.
| 방식 | 모듈 | 플러그인 |
|------|------|---------|
| 임의 페이지 레이아웃 (admin/user) | 가능 | 금지 |
| 자기 네임스페이스 화면 (`plugins/{id}/...`) | 해당 없음 | 가능 |
| 임의 경로 페이지 레이아웃 (`admin/users` 등) | 가능 | 금지 (경로 침범) |
| admin/plugin_settings.json (환경설정) | 가능 | 가능 |
| layout_extensions (확장 지점) | 가능 | 가능 |
@@ -4,6 +4,18 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.9] - 2026-08-01
### Added
- 통신이 끊겼을 때 표시되는 네트워크 오류 안내 문구 일본어 번역 추가 — 종전에는 내부 식별 문구가 영문 그대로 노출됐습니다.
## [1.0.8] - 2026-08-01
### Added
- 검색 인덱스 점검·재생성(`search:index`) 화면 문구 일본어 번역 추가 — 판정 등급(정상/부분적/미반영/판정불가), 점검 결과 표, 재생성 확인·경고 문구가 일본어 로케일에서 자연스럽게 표시됩니다.
## [1.0.7] - 2026-07-22
### Added
@@ -14,4 +14,46 @@ return [
'per_page_min' => 'ページあたりの項目数は1以上である必要があります。',
'per_page_max' => 'ページあたりの項目数は最大100個まで可能です。',
],
'index' => [
'status' => [
'healthy' => '正常',
'degraded' => '部分的',
'stale' => 'インデックス未反映',
'skipped' => '判定不可',
],
'no_maintainer' => '現在の検索エンジン(:driver)はインデックス点検を提供していません。エンジン提供元が点検機能を登録すると、この画面で併せて扱えます。',
'unavailable' => '現在の検索エンジン(:driver)のインデックスを点検できません。',
'no_targets' => '検索エンジン(:driver)に点検対象のインデックスがありません。',
'driver_label' => '検索エンジン: :driver',
'col' => [
'index' => 'インデックス',
'status' => '判定',
'measurement' => '測定',
],
'counts' => '正常 :healthy · 部分的 :degraded · 未反映 :stale · 判定不可 :skipped (合計 :total)',
'rebuild_targets' => '再生成対象 :count件',
'rebuild_cost_warning' => '再生成中は対象インデックスがロックされるか再インデックスされます。稼働中のサイトではメンテナンス時間帯に実行してください。',
'rebuild_confirm' => '上記のインデックスを再生成しますか?',
'rebuild_after_bulk_confirm' => '一括アップデート後に検索インデックスを再生成しますか? (既定: いいえ — 稼働中であればメンテナンス時間帯に別途実行してください)',
'rebuild_skipped' => '再生成をスキップします。',
'stale_hint' => 'インデックスが未反映のものがあります。`php artisan search:index --repair` で再生成してください。',
'stale_after_update' => 'インデックスが未反映の検索インデックスが :count 件あり、該当する検索が結果を返しません。',
'rebuilt_item' => '再生成: :index',
'rebuild_failed_item' => '再生成失敗: :index — :error',
'still_stale_item' => '再生成後も未反映: :index',
'degraded_hint' => '「部分的」はエンジンのトークン処理特性の可能性があるため、自動再生成の対象外です。必要であれば個別に確認してください。',
'report' => [
'nothing_to_do' => '点検 :count件 · 再生成対象なし',
'rebuilt' => '点検 :inspected件 · 再生成 :repaired件 · 失敗 :failed件 · 残存 :remaining件',
],
'fulltext' => [
'unsupported_driver' => '現在のDBドライバはFULLTEXTをサポートしていません (LIKE検索で動作し、点検対象はありません)。',
'self_match' => ':found/:probed 行が自分自身を検出',
'skip' => [
'no_single_pk' => '単一カラムの主キーがないため、行単位の判定ができません',
'no_rows' => 'インデックス対象カラムに内容がある行がありません',
'no_tokens' => 'サンプル行から検索トークンを作成できません (内容が短すぎます)',
],
],
],
];
@@ -6,5 +6,6 @@
"component_load_failed_with_name": "コンポーネント({componentName})の読み込みに失敗しました",
"translation_load_failed": "多言語ファイルの読み込みに失敗しました",
"translation_load_failed_with_locale": "{locale}多言語ファイルの読み込みに失敗しました",
"template_init_error": "テンプレートエンジンの初期化中にエラーが発生しました"
"template_init_error": "テンプレートエンジンの初期化中にエラーが発生しました",
"network_request_failed": "リクエストを送信できませんでした。ネットワーク接続を確認してから再度お試しください。"
}
@@ -12,7 +12,7 @@
"en": "G7 core Japanese language pack (bundled)",
"ja": "G7 コア 日本語 言語パック(バンドル)"
},
"version": "1.0.7",
"version": "1.0.9",
"license": "MIT",
"scope": "core",
"target_identifier": null,
@@ -4,6 +4,16 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.7] - 2026-07-31
### Added
- 상품 문의 처리가 실패했을 때 그 사유를 그대로 보여주는 안내 문구의 일본어 번역을 추가했습니다.
### Removed
- 어디에서도 사용하지 않는 안내 문구 11건의 일본어 번역을 정리했습니다. 한국어 원본이 실제 화면에 쓰이지 않는 위치에 있어 번역만 남아 있던 항목입니다.
## [1.0.6] - 2026-07-22
### Added
@@ -279,8 +279,6 @@ return [
'select_items_message' => '商品を選択してください',
'checkout' => '注文する',
'continue_shopping' => 'ショッピングを続ける',
'updated' => 'カートが修正されました。',
'removed' => 'カートから削除されました。',
'reorder_added' => '過去の注文の商品をカートに追加しました。',
'reorder_failed' => '再注文に失敗しました。',
'reorder_option_not_found' => '商品オプションはもう存在しません。',
@@ -319,9 +317,6 @@ return [
'invalid_target' => '当該商品には適用できないクーポンです。',
'already_used' => '既に使用されたクーポンです。',
'not_found' => 'クーポンが見つかりません。',
'applied' => 'クーポンが適用されました。',
'removed' => 'クーポンが解除されました。',
'invalid' => '無効なクーポンです。',
'per_user_limit_exceeded' => 'このクーポンの使用可能回数を超過しました。',
],
'mileage' => [
@@ -1102,6 +1097,7 @@ return [
'reply_deleted' => '回答が削除されました。',
'reply_delete_failed' => '回答の削除に失敗しました。',
'reply_not_found' => '回答が見つかりません。',
'operation_failed_reason' => ':reason',
],
'address' => [
'created' => '配送先が追加されました。',
@@ -1121,15 +1117,8 @@ return [
'name_duplicate' => '同じ名前の配送先が既に存在します。上書きしますか?',
'auto_saved_label' => '新しい配送先',
],
'product' => [
'not_found' => '商品が見つかりません。',
'not_available' => '現在、購入できない商品です。',
'option_not_found' => '商品オプションが見つかりません。',
],
'product' => [],
'payment' => [
'method_not_supported' => 'サポートされていない決済方法です。',
'dbank_account_required' => '銀行振込の口座を選択してください。',
'depositor_name_required' => '振込人名を入力してください。',
'provider_not_found' => '決済プロバイダーが見つかりません。',
'client_config_success' => '決済クライアント設定を取得しました。',
],
@@ -12,7 +12,7 @@
"en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)",
"ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)"
},
"version": "1.0.6",
"version": "1.0.7",
"license": "MIT",
"scope": "module",
"target_identifier": "sirsoft-ecommerce",

Some files were not shown because too many files have changed in this diff Show More