https://github.com/gnuboard/g7/issues/128 (제보자 glitter-gim) 과 그 부수의무 전수조사에서 확정한 결함군을 닫는다. ## 디버그 라우트 게이트 (F1~F5) 게이트가 핸들러 안에 흩어져 있어 8개 라우트 중 3개에만 붙어 있었다. 빠진 쪽에 `File::cleanDirectory(storage/debug-dump)` 를 수행하는 `DELETE clear` 가 있었고, production·APP_DEBUG=false 에서도 미인증 200 으로 덤프 전체가 지워졌다. GET 4종은 User SPA catch-all 이 등록 순서상 앞서 가려 주고 있었을 뿐이라, 보호를 우연에 맡긴 구조였다. 게이트를 `bootstrap/app.php` 의 devtools 래퍼 한 곳(`debug.gate` 그룹 미들웨어)으로 올린다. 새 라우트는 자동으로 덮이고, 부착 지점이 하나라 훼손이 곧바로 드러난다. `withRouting(channels:)` 인자가 함께 등록하던 게이트 없는 `/broadcasting/auth` 는 제거하고, 채널 정의 로드만 `BroadcastServiceProvider` 로 옮겨 웹소켓 킬스위치 우회로를 없앤다. catch-all 제외 패턴에는 예약 프리픽스를 전수 추가했다. ## 확장 자산 CSS 상대 참조 (범위 추가 — 결정) `asset_url_mode=extensionless` 에서 자산 URL 이 쿼리 형태가 되면 브라우저의 기준 디렉토리가 `/api/{타입}/assets/` 로 잡혀, CSS 안의 `url('./woff2/f.woff2')` 가 존재하지 않는 주소가 된다. 사용자 템플릿 웹폰트 1건과 관리자 템플릿 국기 아이콘 약 500건이 이 상태였다. 병합 CSS 번들은 더 나빴다 — 상대 참조를 가진 확장을 번들에서 통째로 제외하고 있었는데, 번들 URL 이 내려오면 프론트는 개별 로딩을 아예 타지 않으므로 제외는 곧 그 확장 스타일이 하나도 적용되지 않음을 뜻했다. 둘 다 서빙 시점에 절대 자산 URL 로 치환한다. 최종 URL 은 런타임 모드와 캐시 버전이 정하므로 빌드 시점에는 알 수 없고, 동봉 자산은 제3자 산출물이라 원본을 손대면 상류 갱신마다 재작업이 된다. 개별 서빙 3경로와 병합 번들이 같은 해석 규칙을 공유한다 — 갈라지면 한쪽만 고쳐진 채 남는다. 이 결함군은 공통적으로 예외도 서버 로그 흔적도 남기지 않는다. 열린 엔드포인트가 정상 응답하는 것, 404 로 글꼴이 기본 서체가 되는 것이 유일한 증상이다. ## 회귀 잠금 되돌림 red 를 모집단 전원에 대해 실측했다 — 그룹 미들웨어 제거, 개별 게이트 재주입, `channels:` 복원, catch-all 예외 제거, 컨트롤러 3원의 `fileResponse` 회귀, 번들 치환 제거가 각각 어느 테스트를 red 로 만드는지 확인하고 원상 복구했다. 게이트 부착 축은 행위 테스트로 대체할 수 없다 — 가려진 라우트는 행위상 "막힌 것" 과 구분되지 않으므로, `gatherMiddleware` 를 직접 보는 등록 계약 테스트를 둔다. 치환 배선도 같은 이유로 라우트 이름 규약에서 모집단을 도출하는 계약 검사를 둔다(손으로 적은 목록은 네 번째 경로가 생기는 순간 조용히 낡는다).
567 lines
27 KiB
Markdown
567 lines
27 KiB
Markdown
# 라우트 네이밍 및 경로
|
|
|
|
> **상위 문서**: [백엔드 가이드 인덱스](./index.md)
|
|
|
|
---
|
|
|
|
## TL;DR (5초 요약)
|
|
|
|
```text
|
|
1. 모든 라우트는 name() 필수: ->name('api.users.index')
|
|
2. 접두사: api.*, web.*, vendor-module.*
|
|
3. URL: /api/admin/*, /api/auth/*, /api/public/*
|
|
4. 권한: permission: 또는 Middleware에서 체크
|
|
5. REST 패턴: index, store, show, update, destroy
|
|
6. .js/.css/.json/.map 로 끝나는 라우트 = dualSuffix/dualSuffixSegment/dualAsset 매크로 필수
|
|
7. catch-all 은 예약 프리픽스를 전수 제외 (가려진 라우트는 200 SPA 셸이라 단서가 없다)
|
|
8. 조건부로만 열리는 라우트군의 게이트 = 그룹 미들웨어 단일 지점 (핸들러 개별 게이트 금지)
|
|
```
|
|
|
|
---
|
|
|
|
## 목차
|
|
|
|
1. [라우트 이름 규칙](#라우트-이름-규칙)
|
|
2. [URL 경로 규칙](#url-경로-규칙)
|
|
3. [권한 체크](#권한-체크)
|
|
4. [라우트 정의 예시](#라우트-정의-예시)
|
|
5. [개발 체크리스트](#개발-체크리스트)
|
|
|
|
---
|
|
|
|
## 라우트 이름 규칙
|
|
|
|
### 필수 원칙
|
|
|
|
- **모든 라우트는 `name` 필수**
|
|
- 일관된 네이밍 컨벤션 준수
|
|
|
|
### 접두사 규칙
|
|
|
|
| 타입 | 접두사 | 예시 |
|
|
|------|--------|------|
|
|
| API | `api.` | `api.users.index` |
|
|
| WEB | `web.` | `web.dashboard` |
|
|
| 모듈 | `[vendor-module].` | `sirsoft-ecommerce.products.index` |
|
|
| 플러그인 | `[vendor-plugin].` | `sirsoft-payment.settings` |
|
|
|
|
### 조합 규칙
|
|
|
|
```php
|
|
// 코어 API
|
|
->name('api.users.index')
|
|
->name('api.users.store')
|
|
|
|
// 모듈 API
|
|
->name('api.sirsoft-ecommerce.products.index')
|
|
->name('api.sirsoft-ecommerce.products.store')
|
|
|
|
// 플러그인 API
|
|
->name('api.sirsoft-payment.transactions.index')
|
|
```
|
|
|
|
---
|
|
|
|
## URL 경로 규칙
|
|
|
|
### 경로 패턴
|
|
|
|
| 타입 | 패턴 | 예시 |
|
|
|------|------|------|
|
|
| 코어 | `/admin/[기능명]` | `/admin/users` |
|
|
| 모듈 | `/admin/[vendor-module]/[기능명]` | `/admin/sirsoft-ecommerce/products` |
|
|
| 플러그인 | `/admin/[vendor-plugin]/[기능명]` | `/admin/sirsoft-payment/settings` |
|
|
| 모듈 공개 API | `/api/modules/[vendor-module]/[기능명]` | `/api/modules/sirsoft-ecommerce/products` |
|
|
| 플러그인 공개 API | `/api/plugins/[vendor-plugin]/[기능명]` | `/api/plugins/sirsoft-gdpr/consent` |
|
|
|
|
### 리소스 URL 규칙
|
|
|
|
```text
|
|
# 목록 조회
|
|
GET /admin/sirsoft-ecommerce/products
|
|
|
|
# 단일 조회
|
|
GET /admin/sirsoft-ecommerce/products/{id}
|
|
|
|
# 생성
|
|
POST /admin/sirsoft-ecommerce/products
|
|
|
|
# 수정
|
|
PUT /admin/sirsoft-ecommerce/products/{id}
|
|
|
|
# 삭제
|
|
DELETE /admin/sirsoft-ecommerce/products/{id}
|
|
```
|
|
|
|
### 라이선스 API 라우트
|
|
|
|
코어 및 확장의 LICENSE 파일 내용을 반환하는 API 엔드포인트입니다.
|
|
|
|
```text
|
|
GET /api/admin/license # 코어 LICENSE 반환
|
|
GET /api/admin/modules/{identifier}/license # 모듈 LICENSE 반환
|
|
GET /api/admin/plugins/{identifier}/license # 플러그인 LICENSE 반환
|
|
GET /api/admin/templates/{identifier}/license # 템플릿 LICENSE 반환
|
|
```
|
|
|
|
### 정적 확장자로 끝나는 동적 엔드포인트
|
|
|
|
`.js` / `.css` / `.json` / `.map` 으로 끝나는 라우트는 `Route::get()` 으로 단일 등록하지 않는다.
|
|
`Route::dualSuffix()` / `dualSuffixSegment()` / `dualAsset()` 매크로로 **확장자 형태와 확장자 없는 형태를
|
|
동시에** 등록한다. 코어 라우트와 확장 라우트 파일 모두에 적용된다.
|
|
|
|
nginx/Apache 의 표준적 정적 최적화 블록은 URL 마지막 확장자로 분기하며, nginx 에서 정규식 location 은
|
|
프리픽스 location 보다 먼저 매칭된다. 그래서 `try_files ... /index.php` 폴백이 실행될 기회 없이 nginx 가
|
|
직접 파일시스템을 열려 시도해 404 가 된다. aaPanel / CyberPanel / Plesk 기본 템플릿에 들어있는 블록이라
|
|
드물지 않으며, 그런 서버에서는 해당 엔드포인트가 통째로 죽는다.
|
|
|
|
```nginx
|
|
location ~* \.(js|css|json)$ { expires max; access_log off; }
|
|
```
|
|
|
|
```php
|
|
// 잘못된 등록 — 확장자 없는 형태가 생기지 않는다
|
|
Route::get('{identifier}/routes.json', [Ctrl::class, 'getRoutes'])->name('...');
|
|
|
|
// 접미사 제거형 — routes.json + routes
|
|
Route::dualSuffix('{identifier}/routes', 'json', [Ctrl::class, 'getRoutes'])
|
|
->name('api.public.templates.routes');
|
|
|
|
// 세그먼트 강등형 — 접미사가 종류를 구분해 제거 불가 (bundle.js + bundle/js)
|
|
Route::dualSuffixSegment('bundle', 'js', [Ctrl::class, 'serveBundleJs'])
|
|
->name('api.public.modules.bundle.js');
|
|
|
|
// 쿼리 이동형 — 경로가 곧 파일명 (assets/{id}/js/a.js + assets/{id}?file=js/a.js)
|
|
Route::dualAsset('assets/{identifier}', [Ctrl::class, 'serveAsset'])
|
|
->name('api.public.templates.assets');
|
|
```
|
|
|
|
매크로가 반환하는 프록시는 `name()` 을 양쪽에 적용하며(확장자 없는 쪽은 `.extensionless` 접미사),
|
|
`middleware()` 등 나머지 호출도 두 라우트에 함께 전달한다. 한쪽에만 걸리는 가드가 생기지 않는다.
|
|
|
|
URL 을 만드는 쪽도 문자열로 직접 조립하지 않는다. 서버는 `App\Support\AssetUrl`, 프론트엔드는
|
|
`resources/js/core/support/assetUrl.ts` 를 경유해야 현재 모드에 맞는 형태가 나온다. 두 구현은 동일 규칙을
|
|
공유하므로 한쪽만 바꾸면 서버가 만든 URL 과 클라이언트가 만든 URL 이 어긋나 그 자산만 404 가 된다.
|
|
|
|
자동 차단: 정적 검사 대상 (위반 시 차단). 면제는 인라인 주석
|
|
`// audit:allow dynamic-route-static-extension reason: ...`.
|
|
|
|
### SPA catch-all 의 정적 확장자 제외
|
|
|
|
SPA catch-all(`routes/web.php` 의 admin·user 그룹)은 등록되지 않은 경로에 SPA 셸 HTML 을 돌려준다.
|
|
**정적 자산 요청이 거기 걸리면 안 된다** — 없는 `.mjs` 가 `Content-Type: text/html` 인 200 을 받고,
|
|
브라우저는 그것을 스크립트로 파싱하다 죽는다. 응답이 성공이라 `onerror` 는 발화하지 않으므로 태그
|
|
복구기도 뜨지 않는다: 예외도 404 도 없이 화면 기능만 사라진다.
|
|
|
|
제외 목록을 라우트 파일에 손으로 적으면 에셋 서빙이 허용하는 확장자와 갈라진다. 실제로 갈라져 있었고
|
|
(`mjs` · `webp` · `otf` 가 서빙은 되는데 제외 목록엔 없었다), `.json` 이 무사했던 것은 lookahead 에 끝
|
|
앵커가 없어 `.js` 에 **부분일치**한 우연이었다.
|
|
|
|
그래서 목록을 파생시킨다 — `App\Support\StaticExtensionPattern::catchAllExclusion()` 이
|
|
`Allowed{Template,Module,Plugin}FileType` 세 화이트리스트의 **합집합**에서 패턴을 만들고, 두 catch-all 이
|
|
그것을 호출한다. 합집합인 이유: 게시 트리에는 템플릿 dist 뿐 아니라 모듈·플러그인의 사용자 추가 에셋도
|
|
함께 실리므로 한 종류만 기준으로 삼으면 나머지가 서빙하는 확장자(`ico`)가 다시 빠진다.
|
|
|
|
환경 의존 게터(`getAllowedExtensions()`, 로컬에서 `map` 을 덧붙인다)는 쓰지 않는다 — `where()` 인자는
|
|
정의 시점에 평가되어 컴파일된 정규식으로 라우트 캐시에 박히므로, 패턴이 **캐시를 구운 환경**에 따라
|
|
달라지면 안 된다. 캐시 로드 경로는 이 클래스를 참조하지 않으므로 캐시 안전성도 유지된다.
|
|
|
|
라우트 정의를 바꿨으므로 배포 시 라우트 캐시를 재생성해야 한다 — 재생성 전에는 이전에 컴파일된 패턴이
|
|
그대로 쓰인다.
|
|
|
|
두 형태는 모두 영구 유지한다 — 확장자 형태를 제거하면 URL 을 하드코딩한 서드파티 확장이 깨진다.
|
|
엔드포인트별 변환 규칙 표와 프로브 판정 절차: [API 레퍼런스 진입점](./api/README.md) "자산 URL 이중 모드".
|
|
|
|
### catch-all 은 예약 프리픽스를 전수 제외한다
|
|
|
|
catch-all 은 그 뒤에 등록된 라우트를 **가린다**. Laravel 은 등록 순서대로 매칭하므로, 예약 프리픽스가
|
|
제외 목록에 없으면 그 경로의 GET 요청이 catch-all 에 먼저 걸려 SPA 셸을 받는다. 문제는 그 응답이
|
|
예외도 404 도 아닌 **200** 이라는 점이다 — 요청한 쪽에는 "라우트가 가려졌다" 는 단서가 전혀 없고,
|
|
서버 로그에도 정상 요청으로 남는다.
|
|
|
|
더 나쁜 것은 그 shadow 가 **보호처럼 보인다**는 점이다. 가려진 라우트는 도달 불가라 열려 있어도
|
|
증상이 없으므로, 게이트 누락이 있어도 드러나지 않는다. 제외 목록이 한 줄 바뀌는 순간 그 라우트가
|
|
게이트 없이 노출된다 — 보호를 등록 순서라는 우연에 맡긴 상태다.
|
|
|
|
```php
|
|
// ❌ 예약 프리픽스가 빠져 있다 — 그 경로의 GET 라우트가 조용히 가려진다
|
|
->where('any', '(?!admin)(?!api)(?!plugins)'.StaticExtensionPattern::catchAllExclusion());
|
|
|
|
// ✅ 예약 프리픽스 전수 제외
|
|
->where('any', '(?!admin)(?!api)(?!plugins)(?!_boost)(?!modules)'.StaticExtensionPattern::catchAllExclusion());
|
|
```
|
|
|
|
예약 프리픽스를 추가하는 확장·기능은 이 목록에 자기 프리픽스를 함께 넣는다. 스코프가 좁은 catch-all
|
|
(`admin/` 하위만 받는 admin catch-all 등)은 애초에 다른 프리픽스를 삼키지 않으므로 대상이 아니다.
|
|
|
|
### 디버그·개발 라우트는 그룹 단위로 게이트한다
|
|
|
|
디버그 도구처럼 "특정 조건에서만 열려야 하는" 라우트군은 판정을 **핸들러 안이 아니라 그룹
|
|
미들웨어**에 둔다. 핸들러마다 적으면 라우트를 추가할 때 게이트를 함께 적는 것을 잊게 되고, 빠뜨려도
|
|
예외도 로그도 남지 않는다 — 그 엔드포인트가 정상 응답하는 것이 유일한 증상이다.
|
|
|
|
실제로 그렇게 흩어져 있던 8개 라우트 중 게이트는 3개에만 있었고, 빠진 쪽에 디버그 데이터를 통째로
|
|
지우는 파괴적 엔드포인트가 있었다. 운영 환경·디버그 OFF 상태에서도 미인증 요청이 200 으로 통과했다.
|
|
|
|
```php
|
|
// bootstrap/app.php — 부착은 이 한 곳뿐이다
|
|
Route::middleware(['api', 'debug.gate'])
|
|
->group(base_path('routes/devtools.php'));
|
|
```
|
|
|
|
```php
|
|
// ❌ 핸들러 안의 개별 게이트 — 새 라우트에서 빠뜨리게 된다
|
|
Route::delete('clear', function () {
|
|
if (! DebugGate::isEnabled()) {
|
|
return response()->json(['status' => 'error'], 403);
|
|
}
|
|
// ...
|
|
});
|
|
|
|
// ✅ 게이트는 그룹이 부여하고, 핸들러는 본래 일만 한다
|
|
Route::delete('clear', function () {
|
|
// ...
|
|
});
|
|
```
|
|
|
|
부착 지점이 하나면 그 지점의 훼손이 곧바로 드러난다 — 라우트군 전체의 `gatherMiddleware()` 에 게이트
|
|
별칭이 들어 있는지를 단언하는 **등록 계약 테스트**를 함께 둔다. 이 축은 행위 테스트로 대체할 수 없다:
|
|
가려져 있거나 조건이 맞지 않는 라우트는 행위상 "막힌 것처럼" 보이므로, 게이트 부착 여부와 구분되지
|
|
않기 때문이다. 그 테스트에는 모집단이 비었을 때 공허하게 통과하지 않도록 건수 가드를 둔다.
|
|
|
|
게이트 미들웨어는 라우트와 함께 라우트 캐시에 구워지므로 캐시 상태에서도 유지된다. 다만 그 사실을
|
|
가정하지 말고 캐시 상태에서의 차단도 함께 검증한다 — 라우트 캐시는 확장 설치·활성화 등 수명주기
|
|
지점에서 자동 생성되므로 오히려 흔한 상태다.
|
|
|
|
---
|
|
|
|
## 권한 체크
|
|
|
|
### 권한 체크 방식
|
|
|
|
```text
|
|
주의: FormRequest의 authorize() 메서드 사용 금지
|
|
필수: 라우트에 permission 미들웨어 체인
|
|
```
|
|
|
|
### 권한 미들웨어 사용
|
|
|
|
```php
|
|
// ✅ DO: 라우트에 permission 미들웨어 사용
|
|
Route::get('/products', [ProductController::class, 'index'])
|
|
->middleware('permission:sirsoft-ecommerce.products.view')
|
|
->name('api.sirsoft-ecommerce.products.index');
|
|
|
|
// ❌ DON'T: FormRequest에서 권한 체크
|
|
class ProductRequest extends FormRequest
|
|
{
|
|
public function authorize(): bool
|
|
{
|
|
// 이 방식 사용 금지
|
|
return $this->user()->can('view-products');
|
|
}
|
|
}
|
|
```
|
|
|
|
### 권한 네이밍 규칙
|
|
|
|
```text
|
|
[vendor-module].[resource].[action]
|
|
|
|
예시:
|
|
sirsoft-ecommerce.products.view
|
|
sirsoft-ecommerce.products.create
|
|
sirsoft-ecommerce.products.edit
|
|
sirsoft-ecommerce.products.delete
|
|
```
|
|
|
|
---
|
|
|
|
## 라우트 정의 예시
|
|
|
|
### 모듈 라우트 파일
|
|
|
|
```php
|
|
// modules/_bundled/sirsoft-ecommerce/src/routes/api.php
|
|
|
|
use Illuminate\Support\Facades\Route;
|
|
use Modules\Sirsoft\Ecommerce\Controllers\Api\Admin\ProductController;
|
|
|
|
// ModuleRouteServiceProvider 가 URL prefix('api/modules/sirsoft-ecommerce')와
|
|
// name prefix('api.modules.sirsoft-ecommerce.')를 자동 적용한다.
|
|
// 라우트 파일 내부 group 에는 관리자 세그먼트('admin')만 두고, 접두는 중복 입력하지 않는다.
|
|
Route::prefix('admin')->middleware(['auth:sanctum', 'admin'])->group(function () {
|
|
// 상품 관리 (권한 체크 포함) → 최종 URL: /api/modules/sirsoft-ecommerce/admin/products
|
|
Route::get('/products', [ProductController::class, 'index'])
|
|
->middleware('permission:sirsoft-ecommerce.products.view')
|
|
->name('products.index'); // 최종 name: api.modules.sirsoft-ecommerce.products.index
|
|
|
|
Route::post('/products', [ProductController::class, 'store'])
|
|
->middleware('permission:sirsoft-ecommerce.products.create')
|
|
->name('products.store');
|
|
|
|
Route::get('/products/{id}', [ProductController::class, 'show'])
|
|
->middleware('permission:sirsoft-ecommerce.products.view')
|
|
->name('products.show');
|
|
|
|
Route::put('/products/{id}', [ProductController::class, 'update'])
|
|
->middleware('permission:sirsoft-ecommerce.products.edit')
|
|
->name('products.update');
|
|
|
|
Route::delete('/products/{id}', [ProductController::class, 'destroy'])
|
|
->middleware('permission:sirsoft-ecommerce.products.delete')
|
|
->name('products.destroy');
|
|
});
|
|
```
|
|
|
|
### 코어 라우트 파일
|
|
|
|
```php
|
|
// routes/api.php
|
|
|
|
use App\Http\Controllers\Api\Admin\UserController;
|
|
|
|
Route::prefix('admin')->middleware(['auth:sanctum', 'admin'])->group(function () {
|
|
// 사용자 관리
|
|
Route::get('/users', [UserController::class, 'index'])
|
|
->middleware('permission:users.view')
|
|
->name('api.users.index');
|
|
|
|
Route::post('/users', [UserController::class, 'store'])
|
|
->middleware('permission:users.create')
|
|
->name('api.users.store');
|
|
});
|
|
```
|
|
|
|
### 권한 바이패스 라우트 (except 옵션)
|
|
|
|
자기 자신 또는 소유자에 대해 권한 체크를 바이패스하는 라우트:
|
|
|
|
```php
|
|
// routes/api.php
|
|
|
|
Route::prefix('admin')->middleware(['auth:sanctum', 'admin'])->group(function () {
|
|
// 사용자 수정: 자기 자신은 core.users.update 권한 없이 수정 가능
|
|
Route::put('/users/{user}', [UserController::class, 'update'])
|
|
->middleware('permission:admin,core.users.update,except:self:user')
|
|
->name('api.admin.users.update');
|
|
|
|
// 메뉴 수정: 소유자는 core.menus.update 권한 없이 수정 가능 (향후 적용 예시)
|
|
Route::put('/menus/{menu}', [MenuController::class, 'update'])
|
|
->middleware('permission:admin,core.menus.update,except:owner:menu')
|
|
->name('api.admin.menus.update');
|
|
});
|
|
```
|
|
|
|
> 상세 문법: [middleware.md](middleware.md) "permission 미들웨어 except 옵션" 참조
|
|
|
|
### 사용자 컨텍스트 라우트 (permission:user)
|
|
|
|
사용자(프론트엔드) 라우트는 `permission:user,...` 미들웨어와 **user 타입 권한 식별자**를 함께 사용합니다.
|
|
|
|
```php
|
|
// routes/api.php — 사용자 알림 라우트 예시
|
|
|
|
Route::middleware('auth:sanctum')->prefix('user')->group(function () {
|
|
Route::prefix('notifications')->group(function () {
|
|
Route::get('/', [UserNotificationController::class, 'index'])
|
|
->middleware('permission:user,core.user-notifications.read')
|
|
->name('api.user.notifications.index');
|
|
|
|
Route::patch('{notification}/read', [UserNotificationController::class, 'markAsRead'])
|
|
->middleware('permission:user,core.user-notifications.update')
|
|
->name('api.user.notifications.read');
|
|
|
|
Route::delete('{notification}', [UserNotificationController::class, 'destroy'])
|
|
->middleware('permission:user,core.user-notifications.delete')
|
|
->name('api.user.notifications.destroy');
|
|
});
|
|
});
|
|
```
|
|
|
|
```text
|
|
⚠️ CRITICAL: 사용자 라우트에 admin 타입 권한 식별자를 사용하면 항상 403 응답
|
|
✅ 사용자 컨텍스트 권한은 별도 식별자(예: core.user-notifications.*)로 정의 + permission:user 미들웨어 사용
|
|
```
|
|
|
|
`permissions.identifier` 단일 unique 제약 때문에 같은 식별자로 admin/user 권한 두 행을 생성할 수 없습니다. 같은 도메인이라도 컨텍스트가 다르면 식별자를 분리하세요. 상세 규칙: [extension/permissions.md](../extension/permissions.md#권한-타입-permission-type)
|
|
|
|
### 공개 API 라우트
|
|
|
|
```php
|
|
// modules/_bundled/sirsoft-ecommerce/src/routes/api.php
|
|
|
|
use Modules\Sirsoft\Ecommerce\Controllers\Api\Public\ProductController;
|
|
|
|
// ModuleRouteServiceProvider 가 URL prefix('api/modules/sirsoft-ecommerce')와
|
|
// name prefix('api.modules.sirsoft-ecommerce.')를 자동 적용한다.
|
|
// 라우트 파일에서 prefix/name 접두를 중복 입력하지 않는다.
|
|
Route::prefix('products')->group(function () {
|
|
// 공개 상품 API (인증 불필요) → 최종 URL: /api/modules/sirsoft-ecommerce/products
|
|
Route::get('/', [ProductController::class, 'index'])
|
|
->name('public.products.index'); // 최종 name: api.modules.sirsoft-ecommerce.public.products.index
|
|
|
|
Route::get('/{id}', [ProductController::class, 'show'])
|
|
->name('public.products.show');
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 라우트 캐시
|
|
|
|
`php artisan route:cache` 를 적용한 사이트는 캐시 파일에 직렬화된 라우트만 서빙한다.
|
|
따라서 **라우트 정의를 바꾸는 모든 지점은 캐시를 함께 갱신해야 한다.**
|
|
|
|
라우트 캐시는 훅 캐시와 성질이 다르다. 훅 캐시는 항목이 없으면 스캔으로 폴백해 낡아도
|
|
안전하지만, **라우트 캐시에는 폴백이 없다** — 캐시에 없는 라우트는 그대로 404 다.
|
|
예외도 경고도 남지 않고 그 엔드포인트만 조용히 사라지므로, 확장을 만든 쪽에서는
|
|
"내 코드에는 분명히 있는데 라우트가 없다" 는 형태로만 관측된다.
|
|
|
|
### 갱신은 헬퍼 하나로만 한다
|
|
|
|
각 지점에 `route:clear` 를 흩어 놓으면 누락이 생기고, 반대로 비우기만 하면 한 번 비워진
|
|
캐시가 재생성되지 않아 성능 이점이 영구히 사라진다. `App\Support\RouteCacheHelper` 가
|
|
단일 해석 지점이다 (`ConfigCacheHelper` 와 동일한 정책).
|
|
|
|
| 메서드 | 동작 |
|
|
|--------|------|
|
|
| `RouteCacheHelper::rebuild()` | 비운 뒤 즉시 재생성. 테스트 환경·설치 미완료는 비우기까지만 |
|
|
| `RouteCacheHelper::clear()` | 재생성 없이 비우기만 — 재생성이 부적절한 흐름 중간용 |
|
|
|
|
`rebuild()` 는 `route:cache` 를 호출하며, 이 커맨드는 **새 애플리케이션을 부팅해** 라우트를
|
|
수집한다. 방금 설치·활성화한 확장의 라우트가 함께 잡히려면 그 새 부팅이 바뀐 상태를 읽어야
|
|
하므로, 굽기 전에 상태 캐시를 비워야 한다(아래 절). 재생성이 실패하면(직렬화
|
|
불가한 클로저 라우트 등) 비운 상태로 둔다 — 비어 있으면 느릴 뿐 정확하지만, 낡은 캐시는
|
|
방금 설치한 확장을 통째로 없는 것으로 만든다.
|
|
|
|
### 갱신이 필요한 지점
|
|
|
|
| 지점 | 이유 |
|
|
|------|------|
|
|
| 확장 설치 / 활성화 / 비활성화 / 삭제 / 업데이트 | 확장 라우트 등록이 활성 목록에 게이트되어 있다 |
|
|
| 코어 업데이트 · 업그레이드 스텝 | 코어 라우트 파일과 vendor 가 교체된다 |
|
|
|
|
코어 업데이트처럼 파일 교체가 진행 중인 흐름에서는 **중간에 비우고 끝에서 재생성**한다.
|
|
교체 중 상태를 캐시에 구우면 안 되기 때문이며, config 캐시가 같은 형태로 처리된다.
|
|
|
|
템플릿 설치·활성화는 갱신 대상이 아니다 — 템플릿의 `routes.json` 은 프론트엔드 라우팅이라
|
|
서버 라우트에 영향을 주지 않는다. 모듈 설정의 경로 값도 마찬가지다(서버 라우트 접두사는
|
|
`api/modules/{identifier}` 로 식별자에 고정).
|
|
|
|
### 확장 라우트는 활성 상태로 게이트된다
|
|
|
|
모듈·플러그인 라우트는 **활성 상태인 확장의 것만** 등록한다. 비활성화하면 화면·메뉴·프론트엔드
|
|
에셋은 사라지지만, 라우트 등록을 게이트하지 않으면 그 확장의 API 는 계속 호출 가능한 상태로 남는다.
|
|
컨트롤러 파일이 그대로 있으므로 요청은 정상 처리되고 오류도 로그도 남지 않아, 화면만 보고는
|
|
꺼진 기능이 여전히 동작한다는 사실을 알 수 없다.
|
|
|
|
| ❌ 금지 | ✅ 올바른 사용 |
|
|
|--------|---------------|
|
|
| 라우트 프로바이더가 디렉토리에 있는 확장 전부를 등록 | 활성 식별자 목록으로 걸러 등록 |
|
|
| 한쪽(모듈)만 게이트하고 다른 쪽(플러그인)은 무게이트 | 두 경로가 같은 기준을 공유 |
|
|
| 게이트를 개별 컨트롤러·미들웨어에 흩어 놓기 | 라우트 등록 지점 한 곳에서 판정 |
|
|
|
|
활성 목록은 상태 캐시를 공유하므로, 상태를 바꾼 쪽이 그 캐시를 비워야 다음 부팅이 새 상태를
|
|
읽는다(아래 절).
|
|
|
|
### 굽기는 상태 캐시 무효화 뒤에 온다
|
|
|
|
`route:cache` 가 부팅하는 새 애플리케이션에서 확장 라우트 프로바이더는 DB 가 아니라
|
|
**캐시된 활성 확장 목록**을 읽는다(TTL 기본 1일). 그래서 확장의 상태를 DB 에 쓴 뒤
|
|
그 상태 캐시를 비우기 **전에** 구우면, 새 부팅이 낡은 목록을 읽어 방금 바뀐 상태가
|
|
반영되지 않은 라우트가 박제된다.
|
|
|
|
```text
|
|
DB 상태 쓰기 → 상태 캐시 무효화 → 굽기(rebuild / 훅 캐시 재생성)
|
|
```
|
|
|
|
순서가 뒤집혔을 때의 결과는 방향마다 다르고, 어느 쪽도 스스로 회복되지 않는다.
|
|
|
|
| 수명주기 | 낡은 목록의 내용 | 결과 |
|
|
|---------|----------------|------|
|
|
| 활성화 | 그 확장이 없음 | 방금 켠 확장의 API 전량 404 |
|
|
| 비활성화 | 그 확장이 남아 있음 | 끈 확장의 API 가 계속 호출 가능 |
|
|
| 업데이트 | `Updating`(=비활성)으로 판정 | 업데이트 후 API 404 + 훅 리스너 누락 |
|
|
|
|
상태 캐시 무효화는 **DB 상태 쓰기 직후**에 둔다. 굽기 직전으로 옮기는 것으로는 부족하다 —
|
|
같은 목록을 읽는 굽기가 라우트 캐시 말고도 있기 때문이다(훅 매핑 캐시가 오토로드 갱신
|
|
안에서 구워진다).
|
|
|
|
업데이트 경로만 예외가 하나 있다. `Updating` 전이 직후에는 비우지 않는다 — 비우면 그 창
|
|
안에서 도는 오토로드 갱신이 DB 를 재조회해 그 확장을 비활성으로 판정하고 훅 캐시에서
|
|
리스너를 떨군다. 대신 **상태 복원 직후**에 비우고, 그 뒤 훅 캐시를 다시 굽는다. 훅 캐시
|
|
폴백은 파일 부재·손상에만 작동하므로 내용이 낡은 경우는 조용히 통과하기 때문이다.
|
|
|
|
이 순서는 정적 검사로 강제된다 — `rebuild()` 를 호출하는 수명주기 메서드를 리플렉션으로
|
|
도출해(개별 열거 금지) 각각에서 무효화가 앞서는지 확인한다.
|
|
|
|
### 캐시 안전한 라우트 작성
|
|
|
|
캐시가 걸리면 `RouteServiceProvider::boot()` 이 캐시 파일 로드로 분기해 **라우트 파일 자체가
|
|
실행되지 않는다.** 라우트 핸들러 클로저는 직렬화된 형태로 복원되지만, 파일이 실행되어야만
|
|
존재하는 심볼은 어디에도 없다.
|
|
|
|
깨지는 것은 클로저가 아니라 **오토로드되지 않는 심볼 참조**다. 클로저 자체는 캐시에서 정상
|
|
동작한다 — `routes/web.php` 의 SPA catch-all 이 그 증거다. 클래스는 오토로더가 찾아주므로
|
|
FQCN 정적 호출도 안전하다. 문제가 되는 것은 오토로드 경로가 없는 것들뿐이다.
|
|
|
|
| ❌ 금지 | ✅ 올바른 사용 |
|
|
|--------|---------------|
|
|
| 라우트 파일에 전역 함수를 선언하고 핸들러가 호출 | 로직을 클래스(`app/Support/…` 등)로 옮기고 핸들러는 위임만 |
|
|
| 파일 스코프 변수를 핸들러가 `use` 없이 참조 | 클래스 상수 또는 `use ($var)` 로 클로저에 캡처 |
|
|
| 벤더/서비스 프로바이더가 조건부로 등록하는 라우트에 의존 | 그 URI 를 G7 라우트 파일이 직접 소유 |
|
|
|
|
전역 함수 위반의 증상은 특히 추적하기 어렵다. `Call to undefined function` 500 이 나는데
|
|
예외의 `file` 이 `laravel-serializable-closure://` 라 **원인 파일이 스택에 드러나지 않는다.**
|
|
정적 검사가 라우트 파일의 함수 선언을 차단한다.
|
|
|
|
프로바이더의 `boot()` 에서 등록한 라우트가 폐기되는 이유는 별개다. `Router::setCompiledRoutes()`
|
|
가 라우트 컬렉션을 **통째로 교체**하고 이 교체가 `booted` 콜백에서 일어나므로, 그보다 앞서
|
|
등록된 것은 조건 충족 여부와 무관하게 사라진다. 프레임워크 자신의 `BroadcastManager::routes()`
|
|
는 캐시 여부를 확인하는 가드를 갖고 있지만, 모든 패키지가 그렇지는 않다. 그런 라우트에
|
|
기능이 의존한다면 해당 URI 를 G7 라우트 파일에서 등록해 캐시에 함께 구워지게 한다.
|
|
|
|
---
|
|
|
|
## 개발 체크리스트
|
|
|
|
### 라우트 정의 시 확인사항
|
|
|
|
- [ ] 라우트에 `name()` 메서드로 이름 지정
|
|
- [ ] 적절한 접두사 사용 (api., web., vendor-module.)
|
|
- [ ] URL 경로 규칙 준수 (/admin/[vendor-module]/[기능명])
|
|
- [ ] 권한이 필요한 라우트에 `permission` 미들웨어 적용
|
|
- [ ] 인증이 필요한 라우트에 `auth:sanctum` 미들웨어 적용
|
|
- [ ] 관리자 라우트에 `admin` 미들웨어 적용
|
|
- [ ] FormRequest의 `authorize()` 메서드에서 권한 체크하지 않음
|
|
- [ ] 예약 프리픽스를 새로 쓴다면 SPA catch-all 제외 목록에 추가
|
|
- [ ] 조건부로만 열리는 라우트군은 그룹 미들웨어로 게이트 (핸들러 안 개별 판정 금지)
|
|
|
|
### 라우트 테스트 확인사항
|
|
|
|
- [ ] 인증 없이 접근 시 401 반환
|
|
- [ ] 권한 없이 접근 시 403 반환
|
|
- [ ] 올바른 권한으로 접근 시 성공
|
|
- [ ] 그룹 게이트를 둔 라우트군은 `gatherMiddleware()` 부착을 단언하는 등록 계약 테스트 동반 (모집단 가드 포함)
|
|
|
|
---
|
|
|
|
## 관련 문서
|
|
|
|
- [컨트롤러 계층 구조](./controllers.md) - 컨트롤러 네이밍 규칙
|
|
- [미들웨어 등록 규칙](./middleware.md) - 미들웨어 실행 순서
|
|
- [검증 로직 구현](./validation.md) - FormRequest 사용 규칙
|
|
|
|
### SEO 라우트
|
|
|
|
| URL | 메서드 | 라우트명 | 컨트롤러 | 비고 |
|
|
|-----|--------|---------|---------|------|
|
|
| /sitemap.xml | GET | web.sitemap | SitemapController@index | catch-all보다 위에 정의 |
|
|
| /api/admin/seo/stats | GET | api.admin.seo.stats | SeoCacheController | 관리자 전용 |
|
|
| /api/admin/seo/clear-cache | POST | api.admin.seo.clear-cache | SeoCacheController | 관리자 전용 |
|
|
| /api/admin/seo/warmup | POST | api.admin.seo.warmup | SeoCacheController | 관리자 전용 |
|
|
| /api/admin/seo/cached-urls | GET | api.admin.seo.cached-urls | SeoCacheController | 관리자 전용 |
|
|
|
|
> 상세: [seo-system.md](seo-system.md)
|