Files
Gnuboard7/docs/backend/middleware.md
T
HeuJung b505ac7ba7 fix(core,board,ecommerce,page,gdpr): 목록 컨텍스트 왕복·엔진 렌더 파이프 결함 일괄 수정 + 공개문서 정리
세 갈래의 결함을 한 브랜치에서 정리한다.

## 목록 컨텍스트 왕복 시 URL 상태 소실 ( @jiwonpapa 님께서 제보해주셨습니다.)

목록에서 상세·형제 상세(이전/다음)·작성/수정 폼에 다녀오면 보고 있던
page/search/category/filters 가 사라지던 문제를 전 도메인에서 수정했다.

- 엔진(engine-v1.54.2): `mergeQuery: true` 만 적고 `query` 를 생략하면 병합이
 통째로 건너뛰어지던 함정을 교정 — `ActionDispatcher.handleNavigate`/`handleReplaceUrl`.
- 게시판·이커머스·페이지·회원·마이페이지·gdpr 등 9개 확장 레이아웃의 왕복 leg 전수
 적용(mergeQuery: true). 의도적 리셋(검색/필터 초기화·탭 전환·프리셋)은 면제 주석으로 구분.
- 무한스크롤 목록(브랜드·상품 공통정보·고시정보)의 새로고침이 URL 검색·정렬을 떨구던
 결함 수정.
- 재발 차단: audit 룰 `layout-list-context-navigate-merge-query`(목록 클러스터 자동 도출,
 page/필터 URL 신호 4종) + `layout-navigate-path-absolute`(navigate path 동작 키워드 금지).

## cellChildren 등 반복 렌더에서 단일 바인딩 파이프 미적용 ( @glitter-gim 님께서 제보해주셨습니다.)

목록 표의 각 칸에 넣은 날짜·숫자 서식(`{{row.x | datetime(...)}}`)이 빈 값이 되거나
서식 없는 원본으로 나오던 문제를, 표현식 판정 로직이 엔진 전역에 복제되며 갈라진
구조적 결함으로 진단하고 판정 경로를 단일화했다(engine-v1.54.3).

- `RenderHelpers`(renderItemChildren·evaluateIfCondition)·`ConditionEvaluator`·
 `DataBindingEngine.resolveObject`·`DynamicRenderer` props 5곳에 단일 바인딩 파이프 분기 추가.
- 계획: `g7-scalable-lobster.md`(렌더 경로 비대칭 결함 일괄 수정).

## 공개 문서 내부 도구 귀속 제거

release 에 포함되는 공개 문서(`docs/**`)에서 내부 audit 룰 ID 귀속 서술을
도구 비귀속 표현("정적 검사")으로 정리. 재발 차단 룰 `public-no-internal-audit-reference` 신설.

전 계층 테스트(PHPUnit·Vitest·Playwright)·회귀 테스트 동반, 버전/CHANGELOG/활성 디렉토리 동기 완료.
2026-07-26 15:17:56 +09:00

486 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 미들웨어 등록 규칙
> **참조**: [백엔드 가이드 인덱스](index.md) | [인증 시스템](authentication.md)
---
## TL;DR (5초 요약)
```text
1. 인증 필요 미들웨어 → 전역 등록 금지!
2. 그룹 등록: appendToGroup('web'|'api', [...])
3. 실행 순서: 전역 → 그룹(web/api) → 라우트 → 컨트롤러
4. permission 미들웨어: scope_type 기반 접근 제어 (except/only/menu 옵션 폐기)
5. scope 체크: Permission.resource_route_key + owner_key + role_permissions.scope_type
6. 확장 미들웨어 → getMiddleware() 선언(self-gate). SP Kernel 직접 조작·라우트 파일 자기 FQCN 금지
```
---
## 목차
- [핵심 원칙](#핵심-원칙)
- [미들웨어 실행 순서](#미들웨어-실행-순서)
- [미들웨어 등록 방식](#미들웨어-등록-방식)
- [확장 미들웨어 선언 (self-gate)](#확장-미들웨어-선언-self-gate)
- [올바른 등록 예시](#올바른-등록-예시)
- [문제 상황과 해결책](#문제-상황과-해결책)
- [디버깅 방법](#디버깅-방법)
- [미들웨어 개발 체크리스트](#미들웨어-개발-체크리스트)
- [관련 파일](#관련-파일)
---
## 핵심 원칙
```text
필수: 인증 필요 미들웨어는 appendToGroup('api') 사용 (전역 등록 금지)
필수: appendToGroup('web'|'api', [...])으로 그룹 미들웨어에 등록
```
**핵심 이해사항**:
- **미들웨어 실행 순서**: 전역 미들웨어는 web/api 그룹 미들웨어보다 **먼저** 실행됨
- **인증 미들웨어 위치**: Sanctum 인증 미들웨어는 api 그룹에 등록되어 있음
- **결과**: 전역 미들웨어에서 `Auth::check()`, `Auth::user()`를 호출하면 항상 `false`/`null` 반환
---
## 미들웨어 실행 순서
```
요청 → 전역 미들웨어 → 그룹 미들웨어(web/api) → 라우트 미들웨어 → 컨트롤러
↑ ↑
Auth 불가능 Auth 가능
(인증 전) (인증 후)
```
### 실행 순서 상세
1. **전역 미들웨어** (`append()`, `prepend()`)
- 모든 요청에 대해 가장 먼저 실행
- 인증 처리 전이므로 `Auth::check()` = `false`
2. **그룹 미들웨어** (`appendToGroup('web'|'api', ...)`)
- web 또는 api 그룹에 속한 라우트에서 실행
- Sanctum 인증 미들웨어 이후 실행
- `Auth::check()` 사용 가능
3. **라우트 미들웨어** (`alias()`)
- 특정 라우트에만 적용
- 가장 마지막에 실행
---
## 미들웨어 등록 방식
### 등록 방식 비교 표
| 방식 | 실행 시점 | Auth 사용 | 사용 사례 |
|------|----------|----------|----------|
| `append()` | 인증 **전** | ❌ 불가 | CORS, 로깅 등 |
| `prepend()` | 인증 **전** (최우선) | ❌ 불가 | 보안 헤더 등 |
| `appendToGroup('api', ...)` | 인증 **후** | ✅ 가능 | 사용자별 설정 |
| `appendToGroup('web', ...)` | 인증 **후** | ✅ 가능 | 사용자별 설정 |
| `alias()` | 라우트 지정 시 | ✅ 가능 | 권한 체크 등 |
| `getMiddleware()` (확장 self-gate) | 요청 시점 대상 매칭 | timing 에 따름 | 확장(모듈/플러그인)의 미들웨어 — [아래 절](#확장-미들웨어-선언-self-gate) |
### append vs appendToGroup 차이
| 구분 | `append()` | `appendToGroup()` |
|------|-----------|-------------------|
| **등록 위치** | 전역 미들웨어 스택 | 특정 그룹 미들웨어 스택 |
| **실행 시점** | 모든 요청의 최초 | 그룹 미들웨어 순서에 따름 |
| **인증 상태** | 인증 전 | 인증 후 (Sanctum 이후) |
| **적용 범위** | 모든 라우트 | 해당 그룹(web/api) 라우트만 |
---
## 확장 미들웨어 선언 (self-gate)
확장(모듈/플러그인)은 자기 미들웨어를 web/api 그룹에 직접 넣지 않는다. 각 확장이 `getMiddleware()` 로 미들웨어와 그 부착 대상을 선언하고, 코어 게이트 래퍼(`ExtensionMiddlewareGate`)가 요청 시점에 라우트 이름·URI 를 대상 패턴과 대조해 매칭될 때만 해당 미들웨어를 실행한다. 코어 IDV 정책의 라우트명 인덱스 조회 모델과 동일하다.
배경: 확장 라우트는 부팅 이후 지연 등록되고 확장 간 부팅 순서 보장이 없어, 부팅 시점에 다른 확장(또는 코어 특정 라우트)의 라우트를 순회해 미들웨어를 부착하는 방식은 성립하지 않는다. 요청 시점 매칭은 모든 라우트가 등록 완료된 뒤라 이 제약을 원천 회피한다.
### getMiddleware() 스키마
```php
public function getMiddleware(): array
{
return [
[
'class' => VerifyGuestOrderToken::class, // 미들웨어 FQCN (class_exists 검증)
'groups' => ['api'], // ['web'] | ['api'] | ['web','api']
'timing' => 'after_core', // 'after_core'(기본) | 'before_core'
'targets' => ['self'], // 라우트명/URI 패턴 배열 (필수, 아래 카탈로그)
],
];
}
```
| 필드 | 필수 | 의미 |
|------|------|------|
| `class` | O | 미들웨어 FQCN. `class_exists` 실패 시 등록 거부 |
| `groups` | O | `['web']` \| `['api']` \| `['web','api']`. 빈 배열·미허용 그룹은 등록 거부 |
| `timing` | X | `after_core`(기본, 코어 그룹 미들웨어 뒤) \| `before_core`(코어 전처리 전체보다 먼저). `before_core` 는 인증 결과·로케일에 의존하는 미들웨어에 쓰지 않는다 (응답 사전차단/전처리 전용) |
| `targets` | O | 부착 대상 패턴 배열. 누락·빈 배열 시 등록 거부 |
### targets 카탈로그
| 값 | 의미 | 매칭 방식 |
|----|------|----------|
| `self` | 자기 확장 라우트 전체 | 항목 groups 의 각 그룹마다 `{group}.modules.{id}.*` / `{group}.plugins.{id}.*` prefix 로 치환 |
| `all_extensions` | 모든 확장 라우트 (코어 제외) | 라우트명이 `*.modules.*` 또는 `*.plugins.*` |
| `core` | 코어 라우트 전체 (확장 제외) | 라우트명이 확장 prefix 가 **아닌** 것 (negative 판별). 무명 라우트는 core 로 간주하지 않음 |
| `everything` (별칭 `*`) | 코어 + 모든 확장 (전부) | 무조건 매칭 (무명 라우트 포함) |
| `module:{id}` | 특정 모듈 라우트 전체 | `*.modules.{id}.*` |
| `plugin:{id}` | 특정 플러그인 라우트 전체 | `*.plugins.{id}.*` |
| 원시 glob 문자열 | 임의 라우트 이름 패턴 (`api.modules.foo.cart.*`) | `Str::is()` 라우트명 매칭 |
| brace 문자열 | `{a,b}` 단일 그룹 전개 | 전개 후 각 glob 매칭 |
| `/` 로 시작하는 URI 패턴 | 임의 요청 URI 패턴 (`/`, `/admin/*`) — 무명 라우트 타게팅용 | `$request->path()` 매칭 |
`everything`·`*`, `all_extensions`, `core` 는 코어·타 확장에 개입하는 **광역 타게팅** 이다. 선언에 대상이 명문화되므로 감사 가능하며, 리뷰 시 주의 대상으로 표기한다. 자기 라우트 중 일부만 노려야 하면 `self` 대신 원시 glob 으로 정밀화한다.
### 이중 매칭 (라우트명 + URI) — 무명 catch-all 대응
target 이 `/` 로 시작하면 **URI 패턴**(`$request->path()` 매칭), 아니면 **라우트명 패턴**(`$request->route()?->getName()` 매칭)이다. 라우트명은 dot-notation 이라 `/` 로 시작하지 않으므로 구분이 명확하다. 코어 SSR 셸 catch-all 라우트는 `->name()` 이 없어(무명) 라우트명으로 타게팅할 수 없다 → 이런 무명 라우트를 대상으로 하려면 URI 패턴(`/`)이나 `everything` 을 쓴다. 라우트명 계열 target 은 무명 라우트에서 항상 miss 한다.
### 게이트 동작과 무효화
- 게이트 래퍼는 코어가 bootstrap 에서 web/api × before_core/after_core 로 4회 등록한다. 매칭 확장 미들웨어가 없으면 no-op 이라 확장 0개 환경에서도 무해하다.
- registry 인덱스는 **활성 확장만** 포함한다. 비활성 확장의 미들웨어는 게이트가 실행하지 않는다.
- 인덱스는 확장 활성/비활성/설치/제거/리로드 시 자동 무효화된다.
### 금지 패턴
| 금지 | 올바른 방식 |
|------|------------|
| 확장 SP `boot()` 에서 `HttpKernel::append/prepend/pushMiddlewareToGroup()` 직접 호출 | `getMiddleware()` 선언 |
| 확장 SP 에서 `aliasMiddleware()` 직접 호출 | `getMiddleware()` 선언 |
| 확장 라우트 파일에서 자기 `Http\Middleware\` FQCN 을 `->middleware(Foo::class)` 로 부착 | `getMiddleware()` 선언 |
자동 차단: 위 규율은 모두 정적 검사 대상 (위반 시 차단). 서드파티 확장의 구방식은 Laravel 공식 API 라 런타임 차단은 없고 문서로 안내한다.
---
## 올바른 등록 예시
### bootstrap/app.php
```php
->withMiddleware(function (Middleware $middleware): void {
// ✅ SetLocale, SetTimezone은 인증 후 실행되어야 사용자 설정을 읽을 수 있음
$localeTimezoneMiddleware = [
\App\Http\Middleware\SetLocale::class,
\App\Http\Middleware\SetTimezone::class,
];
$middleware->appendToGroup('web', $localeTimezoneMiddleware);
$middleware->appendToGroup('api', $localeTimezoneMiddleware);
// 권한 관련 미들웨어 등록 (별칭)
$middleware->alias([
'admin' => \App\Http\Middleware\AdminMiddleware::class,
'permission' => \App\Http\Middleware\PermissionMiddleware::class,
'start.api.session' => \App\Http\Middleware\StartApiSession::class,
]);
})
```
> **참고**: `EnsureFrontendRequestsAreStateful` (stateful)은 제거되었습니다. API 인증은 Bearer 토큰 전용이며, 세션은 `start.api.session` 미들웨어로 로그인/로그아웃 라우트에서만 생성됩니다. 상세: [authentication.md](authentication.md)
---
## permission 미들웨어 — scope_type 기반 접근 제어
> **변경 이력**: `except:`, `only:`, `menu:` 옵션은 scope_type 시스템으로 대체되어 **폐기**되었습니다. (2026-03-10)
### 문법
```text
permission:{type},{permission}[,{requireAll}]
```
옵션 파라미터 없이 권한 타입과 식별자만 전달합니다.
### 처리 흐름
```text
1. 권한 타입 검증 (admin/user)
2. 동적 파라미터 치환 ({slug} → 실제 값)
3. 권한 체크 (인증/게스트)
→ 권한 없음 → 403
→ 권한 있음 → Step 4로 진행
4. scope_type 스코프 체크:
a. Permission 조회 (static 캐시) → resource_route_key, owner_key
b. resource_route_key가 null → 통과 (시스템 리소스)
c. $request->route(resource_route_key) → Model resolve
d. Model 없음 → 통과 (list 엔드포인트)
e. 사용자의 effective scope 확인 (union 정책)
f. scope=null → 통과 (전체 접근)
g. scope='self' → $model->{owner_key} === $user->id → 아니면 403
h. scope='role' → 리소스 소유자가 내 역할을 공유하는지 → 아니면 403
```
### scope_type 값 정의
| 값 | 의미 | 상세 접근 체크 | 목록 필터링 |
|---|---|---|---|
| `null` | 전체 접근 (제한 없음) | 항상 통과 | 필터 미적용 |
| `'self'` | 본인 리소스만 | `$model->{owner_key} === $user->id` | `WHERE {owner_key} = {user_id}` |
| `'role'` | 내 역할 범위 리소스 | 리소스 소유자가 내 역할 공유 | `WHERE {owner_key} IN (역할 공유 사용자 IDs)` |
### union 정책 (복수 역할 보유 시)
```text
우선순위: null(전체) > 'role'(소유역할) > 'self'(본인)
- 여러 역할 중 하나라도 scope_type=null → 전체 접근
- 전부 non-null이면 가장 넓은 범위 적용 (role > self)
- 예: 역할A(scope=self) + 역할B(scope=role) → role 적용
```
### DB 구조
```text
permissions 테이블:
- resource_route_key VARCHAR(50) NULL — 라우트 파라미터명 (예: 'user', 'menu', 'product')
- owner_key VARCHAR(50) NULL — 소유자 식별 컬럼 (예: 'id', 'created_by', 'user_id')
role_permissions 피벗:
- scope_type ENUM('self', 'role') NULL DEFAULT NULL
```
### 사용 예시
```php
// 관리자 컨텍스트 — permission:admin + admin 타입 권한 식별자
Route::get('{user}', [AdminUserController::class, 'show'])
->middleware('permission:admin,core.users.read');
Route::put('{user}', [AdminUserController::class, 'update'])
->middleware('permission:admin,core.users.update');
Route::put('{menu}', [AdminMenuController::class, 'update'])
->middleware('permission:admin,core.menus.update');
// 사용자 컨텍스트 — permission:user + user 타입 권한 식별자
Route::get('/api/user/notifications', [UserNotificationController::class, 'index'])
->middleware('permission:user,core.user-notifications.read');
Route::patch('/api/user/notifications/{notification}/read', [UserNotificationController::class, 'markAsRead'])
->middleware('permission:user,core.user-notifications.update');
Route::delete('/api/user/notifications/{notification}', [UserNotificationController::class, 'destroy'])
->middleware('permission:user,core.user-notifications.delete');
```
### 권한 type 일치 규칙 (CRITICAL)
```text
⚠️ CRITICAL: PermissionMiddleware는 (식별자, type) 두 필드 모두 매칭하여 권한 행을 조회합니다.
✅ permission:admin,xxx → DB의 (identifier='xxx', type='admin') 행 필요
✅ permission:user,xxx → DB의 (identifier='xxx', type='user') 행 필요
❌ 사용자 라우트에 admin 타입 권한 식별자를 사용하면 항상 403 (type 불일치)
```
`permissions.identifier` 컬럼은 단일 unique 제약이므로 같은 식별자로 admin/user 두 행을 동시에 만들 수 없습니다. 사용자 컨텍스트 권한이 필요하면 **별도 식별자**(예: `core.user-notifications.*`)를 정의하세요. 상세 규칙은 [extension/permissions.md](../extension/permissions.md#권한-타입-permission-type) 참조.
### 목록 엔드포인트 필터링
미들웨어는 모델 바인딩이 없는 목록 엔드포인트를 **통과**시킵니다.
목록 필터링은 **Repository**에서 `PermissionHelper::applyPermissionScope()`로 처리합니다.
```php
// Repository에서 한 줄로 적용
$query = User::query();
PermissionHelper::applyPermissionScope($query, 'core.users.read');
```
### 관련 핵심 메서드
| 메서드 | 위치 | 용도 |
|--------|------|------|
| `PermissionHelper::checkScopeAccess()` | 미들웨어 (상세 접근) | 모델 바인딩된 리소스의 scope 체크 |
| `PermissionHelper::applyPermissionScope()` | Repository (목록 필터링) | 쿼리에 scope WHERE 조건 추가 |
| `User::getEffectiveScopeForPermission()` | 모델 | union 정책에 따른 effective scope 반환 |
### resource_route_key/owner_key가 null인 권한
시스템 리소스 (ActivityLog, Module, Plugin, Template, Permission, Settings 등)는 `resource_route_key`와 `owner_key`가 null이므로 scope 체크가 자동 스킵됩니다.
### 폐기된 옵션 (DEPRECATED)
```text
아래 옵션들은 제거되었습니다. 사용 시 미들웨어가 인식하지 않습니다.
- except:self:{param} → scope_type='self'로 대체
- except:owner:{param} → scope_type='self'로 대체
- only:self:{param} → scope_type='self'로 대체
- only:owner:{param} → scope_type='self'로 대체
- menu:{slug} → 제거 (메뉴 접근 제어는 scope_type으로 불필요)
```
---
## OptionalSanctumMiddleware (선택적 인증)
공개 API이면서 인증된 사용자에게는 추가 정보를 제공해야 하는 경우 사용합니다.
### 동작 흐름
```text
요청 → Bearer 토큰 확인
├── 토큰 없음 → guest로 통과
├── 토큰 유효 → Sanctum 인증 진행 (인증된 사용자)
├── 토큰 만료 → guest로 통과 (공개 페이지 접근 허용)
└── 토큰 무효(위조) → 401 Unauthorized
```
### 등록 방식
```php
// bootstrap/app.php
$middleware->alias([
'optional.sanctum' => \App\Http\Middleware\OptionalSanctumMiddleware::class,
]);
```
### 사용 예시
```php
// 레이아웃 API: 비회원도 접근 가능하지만, 인증 사용자에게는 권한 기반 UI 제공
Route::middleware('optional.sanctum')
->get('/layouts/{name}.json', [LayoutController::class, 'show']);
```
### 일반 Sanctum과의 차이
| 상황 | `auth:sanctum` | `optional.sanctum` |
| ------ | -------------- | -------------------- |
| 토큰 없음 | 401 | guest 통과 |
| 토큰 유효 | 인증 | 인증 |
| 토큰 만료 | 401 | guest 통과 |
| 토큰 무효 | 401 | 401 |
### 구현 파일
- `app/Http/Middleware/OptionalSanctumMiddleware.php`
---
## 문제 상황과 해결책
### ❌ 잘못된 예시 - 전역 미들웨어로 등록
```php
// ❌ DON'T: 전역 미들웨어로 등록 - 인증 전에 실행됨
$middleware->append([
\App\Http\Middleware\SetLocale::class,
\App\Http\Middleware\SetTimezone::class,
]);
// SetTimezone 미들웨어 내부
if (Auth::check()) { // 항상 false! Sanctum 인증 전이므로
return Auth::user()->timezone;
}
```
### ✅ 올바른 예시 - 그룹 미들웨어로 등록
```php
// ✅ DO: 그룹 미들웨어로 등록 - 인증 후에 실행됨
$middleware->appendToGroup('web', [
\App\Http\Middleware\SetLocale::class,
\App\Http\Middleware\SetTimezone::class,
]);
$middleware->appendToGroup('api', [
\App\Http\Middleware\SetLocale::class,
\App\Http\Middleware\SetTimezone::class,
]);
// SetTimezone 미들웨어 내부
if (Auth::check()) { // ✅ 정상 작동! Sanctum 인증 후이므로
return Auth::user()->timezone;
}
```
---
## 디버깅 방법
미들웨어에서 인증 상태 확인이 필요할 때:
```php
// 미들웨어 내부에 로그 추가
\Log::info('Middleware debug', [
'auth_check' => Auth::check(),
'user_id' => Auth::id(),
'user_timezone' => Auth::user()?->timezone,
]);
```
### 디버깅 결과 해석
| `auth_check` 값 | 의미 | 조치 |
|----------------|------|------|
| `false` | 인증 전에 미들웨어 실행됨 | `appendToGroup()`으로 변경 |
| `true` | 정상적으로 인증 후 실행됨 | 문제 없음 |
---
## 미들웨어 개발 체크리스트
### 신규 미들웨어 개발 시
- [ ] `Auth::check()` 또는 `Auth::user()` 사용 여부 확인
- [ ] 인증 필요 시 `appendToGroup('web'|'api', ...)` 사용
- [ ] 인증 불필요 시 `append()` 또는 `prepend()` 사용
- [ ] 로그로 인증 상태 검증
- [ ] web과 api 그룹 모두에 등록 필요 여부 확인
### 등록 위치 결정 가이드
```
미들웨어에서 Auth 사용?
├── YES → appendToGroup('web'|'api', ...)
└── NO → append() 또는 prepend()
```
---
## 관련 파일
- `bootstrap/app.php`: 미들웨어 등록
- `app/Http/Middleware/PermissionMiddleware.php`: permission 미들웨어 (scope_type 체크 포함)
- `app/Helpers/PermissionHelper.php`: checkScopeAccess, applyPermissionScope 메서드
- `app/Models/User.php`: getEffectiveScopeForPermission (union 정책)
- `app/Http/Middleware/StartApiSession.php`: API 세션 미들웨어 (로그인/로그아웃 전용)
- `app/Http/Middleware/SetLocale.php`: 로케일 설정 미들웨어
- `app/Http/Middleware/SetTimezone.php`: 타임존 설정 미들웨어
---
## 관련 문서
- [권한 시스템](../extension/permissions.md) - scope_type 시스템 상세, resource_route_key/owner_key 매핑
- [인증 시스템](authentication.md) - Sanctum 인증 및 세션 처리
- [서비스 프로바이더 안전성](service-provider.md) - 프로바이더 등록 규칙
- [백엔드 가이드 인덱스](index.md) - 전체 가이드 목록
---
## 참고 이력
- [SetTimezone 미들웨어 리팩토링](../../history/20251127_1402_SetTimezone미들웨어리팩토링.md)
### SEO 미들웨어
| 항목 | 값 |
|------|-----|
| 클래스 | `App\Seo\SeoMiddleware` |
| 별칭 | `seo` |
| 등록 위치 | User catch-all 라우트 그룹에만 |
| 금지 | 전역 등록 / Admin 라우트 부착 |
> 상세: [seo-system.md](seo-system.md)