Files
Gnuboard7/docs/backend/middleware.md
T
HeuJung e21bff678f fix(core,ecommerce,board,basic,pay_kginicis,pay_nhnkcp,pay_nicepayments): 7.0.6 회귀 3건·잠복 결함 3건 해소 + 통화 하드코딩 전수 제거
7.0.6 사이클에서 유입된 회귀 3건을 출시 전에 닫는다. 추가옵션 저장소의
반환 타입 선언이 실제 반환(중첩 맵)과 어긋나 추가옵션 있는 상품의 담기·
바로구매가 죽었고, 검색 엔진이 결과 소비 시점에 SELECT 를 재작성해 정렬이
참조하던 스코어 별칭을 지웠으며, 미들웨어 targets 가 늘어난 라우트를
따라가지 못해 비회원 현금영수증이 전면 불능이었다. 셋 다 정적 검사의
사각지대였으므로 룰 2건과 계약 테스트를 함께 세워 재발을 막는다.

같은 유형의 잠복 결함 3건(빈 반환의 타입 불일치 2건, 없는 메서드 호출 1건)도
호출 경로가 생기는 순간 fatal 이므로 함께 닫았다.

브라우저 검수 중 드러난 선행 결함 5건을 이어서 수정한다. 엔진이 event 키로
선언한 액션을 React prop 으로 연결하지 않아 오류 없이 조용히 무반응이었고,
연결한 뒤에는 커스텀 컴포넌트의 이벤트 payload 가 유실됐다. 비회원 주문상세
응답에 현금영수증 키가 없어 발급 결과에 도달할 수 없었으며, 입금확인과
현금영수증 발급이 base 통화 금액을 써서 결제 통화가 다른 주문에서 각각
저장 실패와 잘못된 증빙을 냈다.

마지막으로 통화 단위를 코드·문구에서 걷어내 설정으로 이관한다. 다국어
문구의 원/円, 프론트의 KRW 고정표와 리터럴 폴백 173건, 서버 포맷의 통화
코드 리터럴이 대상이며, 기본 통화가 원이 아닌 상점에서 값은 맞고 단위만
틀린 금액이 예외 없이 나가던 문제였다. 통화 판정을 설정 단일 출처로 모으고
규칙 3건을 정적 검사로 고정했다.
2026-08-10 09:07:46 +09:00

23 KiB
Raw Blame History

미들웨어 등록 규칙

참조: 백엔드 가이드 인덱스 | 인증 시스템


TL;DR (5초 요약)

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 금지

목차


핵심 원칙

필수: 인증 필요 미들웨어는 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 에 따름 확장(모듈/플러그인)의 미들웨어 — 아래 절

append vs appendToGroup 차이

구분 append() appendToGroup()
등록 위치 전역 미들웨어 스택 특정 그룹 미들웨어 스택
실행 시점 모든 요청의 최초 그룹 미들웨어 순서에 따름
인증 상태 인증 전 인증 후 (Sanctum 이후)
적용 범위 모든 라우트 해당 그룹(web/api) 라우트만

확장 미들웨어 선언 (self-gate)

확장(모듈/플러그인)은 자기 미들웨어를 web/api 그룹에 직접 넣지 않는다. 각 확장이 getMiddleware() 로 미들웨어와 그 부착 대상을 선언하고, 코어 게이트 래퍼(ExtensionMiddlewareGate)가 요청 시점에 라우트 이름·URI 를 대상 패턴과 대조해 매칭될 때만 해당 미들웨어를 실행한다. 코어 IDV 정책의 라우트명 인덱스 조회 모델과 동일하다.

배경: 확장 라우트는 부팅 이후 지연 등록되고 확장 간 부팅 순서 보장이 없어, 부팅 시점에 다른 확장(또는 코어 특정 라우트)의 라우트를 순회해 미들웨어를 부착하는 방식은 성립하지 않는다. 요청 시점 매칭은 모든 라우트가 등록 완료된 뒤라 이 제약을 원천 회피한다.

getMiddleware() 스키마

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 으로 정밀화한다.

targets 커버리지 규율

인증·소유권 검증 같은 보호형 미들웨어는 라우트를 개별 나열하지 않고 서브트리 glob 으로 선언한다.

❌ 금지 ✅ 올바른 사용
보호 대상 라우트를 하나씩 열거 서브트리 glob (api.modules.foo.guest.orders.cash-receipt.*)
PHPDoc 에 부착 대상 개수를 기재 ("4개 라우트에만 부착") 무엇을·왜 부착/제외하는지 서술만 남긴다
상위 glob 으로 뭉뚱그리기 (guest.orders.*) 인증 전 단계 라우트를 삼키지 않는 수준까지 정밀화

개별 나열은 하위 라우트가 늘 때 갱신이 누락되어 새 엔드포인트가 무보호로 열린다. 실제로 현금영수증 라우트 2건이 추가될 때 targets 갱신이 누락돼 그 기능이 전면 불능이었다. glob 은 그 서브트리의 기본값을 '보호' 로 만들어 실패 방향을 안전한 쪽으로 뒤집는다. 개수를 문서에 박는 것 자체가 다음 누락의 씨앗이다.

다만 상위 glob 이 인증 전 단계 라우트(토큰 발급 등)를 삼키면 기능이 통째로 막힌다 — 토큰을 받으러 오는 요청에는 아직 토큰이 없기 때문이다. 삼키는 경우에만 한 단계 아래로 정밀화하고, 제외 이유를 PHPDoc 에 남긴다.

targets 정합성은 정적 검사가 불가능하다. 라우트 전체 이름이 확장 라우트 파일의 접미부와 모듈 로더가 부팅 시 붙이는 prefix 의 조합으로 결정될 뿐 아니라, "이 라우트가 보호되어야 하는가" 자체가 의미적 판단이기 때문이다. 그러므로 확장은 계약 테스트를 둔다:

  • Route::getRoutes() 에서 그 서브트리 라우트를 전수 수집하고, 클래스 상수로 선언한 면제 목록(사유 주석 필수)을 뺀 나머지가 전부 resolveForRoute() 결과에 그 미들웨어를 포함하는지 단언한다. 모집단을 라우트 테이블에서 파생시키므로, 새 라우트를 추가하고 targets 를 안 고치면 테스트를 손대지 않아도 자동으로 실패한다.
  • 면제 대상은 미부착임을 별도로 단언한다 (상위 glob 으로 뭉뚱그리는 회귀 차단).
  • 케이스 간 오염을 막으려면 ExtensionMiddlewareRegistry::flush() 를 setUp/tearDown 양쪽에서 호출한다 — 인덱스가 태그 캐시다.

참조 구현: modules/_bundled/sirsoft-ecommerce/tests/Unit/Providers/GuestOrderTokenMiddlewareRegistrationTest.php

미부착 시 응답이 다른 원인의 응답과 구별되지 않을 수 있다는 점도 함께 본다. 예컨대 토큰 미들웨어의 404 와 FormRequest 의 404 가 같은 코드·메시지면, "토큰 없이는 실패한다" 는 테스트는 미들웨어가 아예 없어도 통과한다. 구별을 위해 미들웨어 쪽 상태코드나 메시지를 갈라서는 안 된다 — 리소스 존재 여부가 응답으로 새어 열거 공격 표면이 생긴다. 동일 응답을 유지하고, 부착 증명은 위 계약 테스트가 담당한다.

이중 매칭 (라우트명 + 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

->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


permission 미들웨어 — scope_type 기반 접근 제어

변경 이력: except:, only:, menu: 옵션은 scope_type 시스템으로 대체되어 폐기되었습니다. (2026-03-10)

문법

permission:{type},{permission}[,{requireAll}]

옵션 파라미터 없이 권한 타입과 식별자만 전달합니다.

처리 흐름

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 정책 (복수 역할 보유 시)

우선순위: null(전체) > 'role'(소유역할) > 'self'(본인)

- 여러 역할 중 하나라도 scope_type=null → 전체 접근
- 전부 non-null이면 가장 넓은 범위 적용 (role > self)
- 예: 역할A(scope=self) + 역할B(scope=role) → role 적용

DB 구조

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

사용 예시

// 관리자 컨텍스트 — 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)

⚠️ 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 참조.

목록 엔드포인트 필터링

미들웨어는 모델 바인딩이 없는 목록 엔드포인트를 통과시킵니다. 목록 필터링은 Repository에서 PermissionHelper::applyPermissionScope()로 처리합니다.

// 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)

아래 옵션들은 제거되었습니다. 사용 시 미들웨어가 인식하지 않습니다.

- 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이면서 인증된 사용자에게는 추가 정보를 제공해야 하는 경우 사용합니다.

동작 흐름

요청 → Bearer 토큰 확인
├── 토큰 없음 → guest로 통과
├── 토큰 유효 → Sanctum 인증 진행 (인증된 사용자)
├── 토큰 만료 → guest로 통과 (공개 페이지 접근 허용)
└── 토큰 무효(위조) → 401 Unauthorized

등록 방식

// bootstrap/app.php
$middleware->alias([
    'optional.sanctum' => \App\Http\Middleware\OptionalSanctumMiddleware::class,
]);

사용 예시

// 레이아웃 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

문제 상황과 해결책

❌ 잘못된 예시 - 전역 미들웨어로 등록

// ❌ DON'T: 전역 미들웨어로 등록 - 인증 전에 실행됨
$middleware->append([
    \App\Http\Middleware\SetLocale::class,
    \App\Http\Middleware\SetTimezone::class,
]);

// SetTimezone 미들웨어 내부
if (Auth::check()) {  // 항상 false! Sanctum 인증 전이므로
    return Auth::user()->timezone;
}

✅ 올바른 예시 - 그룹 미들웨어로 등록

// ✅ 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;
}

디버깅 방법

미들웨어에서 인증 상태 확인이 필요할 때:

// 미들웨어 내부에 로그 추가
\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: 타임존 설정 미들웨어

관련 문서


참고 이력

SEO 미들웨어

항목 값
클래스 App\Seo\SeoMiddleware
별칭 seo
등록 위치 User catch-all 라우트 그룹에만
금지 전역 등록 / Admin 라우트 부착

상세: seo-system.md