v7.0.0-beta.1 release

This commit is contained in:
HeuJung committed 2026-04-01 10:30:52 +09:00
commit 6595fd0eb5
3967 files changed
+1875383

No files matched your search

+255
View File
@@ -0,0 +1,255 @@
# 그누보드7 문서 인덱스
> 그누보드7 오픈소스 CMS 플랫폼의 개발 가이드 문서입니다.
---
<!-- AUTO-GENERATED-START: docs-readme-stats -->
## 카테고리별 통계
| 카테고리 | 문서 수 | 링크 상태 |
|----------|---------|----------|
| [백엔드](backend/) | 20개 | 정상 |
| [프론트엔드](frontend/) | 49개 | 정상 |
| [확장 시스템](extension/) | 23개 | 정상 |
| 공통 | 19개 | 정상 |
| [AI 도구](ai-tools/) | - | 정상 |
<!-- AUTO-GENERATED-END: docs-readme-stats -->
---
## 작업 유형별 필수 문서
### 레이아웃 JSON 작성
| 순서 | 문서 | TL;DR 핵심 |
|------|------|----------|
| 1 | [레이아웃 JSON 스키마](frontend/layout-json.md) | HTML 태그 직접 사용 금지 → 기본 컴포넌트 사용 (Div, Button, Span) |
| 2 | [레이아웃 JSON - 컴포넌트](frontend/layout-json-components.md) | if: 조건부 렌더링 (type: "conditional" 사용 금지!) |
| 3 | [레이아웃 JSON - 기능](frontend/layout-json-features.md) | classMap: 조건부 CSS 클래스 (key → variants 매핑) |
| 4 | [레이아웃 JSON - 상속](frontend/layout-json-inheritance.md) | extends: 베이스 레이아웃 상속 (type: "slot" 위치에 삽입) |
| 5 | [컴포넌트 개발 규칙](frontend/components.md) | HTML 태그 직접 사용 금지 |
| 6 | [컴포넌트 Props 레퍼런스](frontend/component-props.md) | - |
| 7 | [sirsoft-admin_basic 컴포넌트](frontend/templates/sirsoft-admin_basic/components.md) | Basic (37개), Composite (66개), Layout (8개) |
| 8 | [데이터 바인딩 및 표현식](frontend/data-binding.md) | API 데이터: {{user.name}}, URL 파라미터: {{route.id}} |
| 9 | [데이터 바인딩 - 다국어 처리](frontend/data-binding-i18n.md) | - |
| 10 | [액션 핸들러 가이드](frontend/actions.md) | 구조: type 또는 event(이벤트), handler(핸들러명), params(옵션) |
| 11 | [액션 핸들러 - 핸들러별 상세 사용법](frontend/actions-handlers.md) | navigate: 페이지 이동 (path, query, mergeQuery 옵션) |
| 12 | [전역 상태 관리](frontend/state-management.md) | 전역 상태: _global.속성명 (앱 전체 공유, 페이지 이동 시 유지) |
| 13 | [데이터 소스](frontend/data-sources.md) | data_sources 배열에 API 정의: id, endpoint, method |
| 14 | [다크 모드 지원](frontend/dark-mode.md) | Tailwind dark: variant 사용 |
### 컨트롤러 작성
| 순서 | 문서 | TL;DR 핵심 |
|------|------|----------|
| 1 | [컨트롤러 계층 구조](backend/controllers.md) | AdminBaseController / AuthBaseController / PublicBaseController |
| 2 | [라우트 네이밍 및 경로](backend/routing.md) | 모든 라우트는 name() 필수 |
| 3 | [검증 (Validation)](backend/validation.md) | FormRequest 사용 필수 (Service에 검증 로직 금지) |
| 4 | [API 응답 규칙 (ResponseHelper)](backend/response-helper.md) | 모든 API 응답은 ResponseHelper 사용 |
### Service/Repository 작성
| 순서 | 문서 | TL;DR 핵심 |
|------|------|----------|
| 1 | [Service-Repository 패턴](backend/service-repository.md) | RepositoryInterface 주입 필수 (구체 클래스 직접 주입 금지) |
| 2 | [훅 시스템](extension/hooks.md) | Action 훅: doAction() - 부가 작업 (로그, 알림, 캐시) |
### 마이그레이션 작성
| 순서 | 문서 | TL;DR 핵심 |
|------|------|----------|
| 1 | [데이터베이스 개발 가이드](database-guide.md) | 마이그레이션: 한국어 comment 필수, down() 구현 필수 |
### 모듈 개발
| 순서 | 문서 | TL;DR 핵심 |
|------|------|----------|
| 1 | [모듈 개발 기초](extension/module-basics.md) | 디렉토리: vendor-module (예: sirsoft-ecommerce) |
| 2 | [모듈 라우트 규칙](extension/module-routing.md) | URL prefix 자동: /api/admin/[vendor-module]/... |
| 3 | [모듈 레이아웃 시스템](extension/module-layouts.md) | modules/_bundled/vendor-module/resources/layouts/ |
| 4 | [모듈 다국어 시스템](extension/module-i18n.md) | 백엔드: /lang/{locale}/*.php |
| 5 | [훅 시스템](extension/hooks.md) | Action 훅: doAction() |
### 테스트 작성
| 순서 | 문서 | TL;DR 핵심 |
|------|------|----------|
| 1 | [테스트 가이드](testing-guide.md) | 테스트 통과 = 작업 완료 (작성만으로 불충분!) |
| 2 | [레이아웃 렌더링 테스트 가이드](frontend/layout-testing.md) | createLayoutTest()로 테스트 헬퍼 생성 |
### API 리소스 작성
| 순서 | 문서 | TL;DR 핵심 |
|------|------|----------|
| 1 | [API 리소스](backend/api-resources.md) | BaseApiResource 상속 필수 |
| 2 | [API 응답 규칙 (ResponseHelper)](backend/response-helper.md) | 모든 API 응답은 ResponseHelper 사용 |
### FormRequest 작성
| 순서 | 문서 | TL;DR 핵심 |
|------|------|----------|
| 1 | [검증 (Validation)](backend/validation.md) | FormRequest 사용 필수 (Service에 검증 로직 금지) |
| 2 | [Custom Exception 다국어 처리](backend/exceptions.md) | 예외 메시지 하드코딩 금지 → __() 함수 필수 |
### 권한/메뉴 추가
| 순서 | 문서 | TL;DR 핵심 |
|------|------|----------|
| 1 | [권한 시스템](extension/permissions.md) | 구조: User → Role → Permission (기능 레벨) |
| 2 | [메뉴 시스템](extension/menus.md) | 구조: User → Role → role_menus 피벗 → Menu |
### 다국어 추가
| 순서 | 문서 | TL;DR 핵심 |
|------|------|----------|
| 1 | [모듈 다국어 시스템](extension/module-i18n.md) | 백엔드: /lang/{locale}/*.php |
| 2 | [데이터베이스 개발 가이드](database-guide.md) | 마이그레이션: 한국어 comment 필수, down() 구현 필수 |
| 3 | [데이터 바인딩 - 다국어 처리](frontend/data-binding-i18n.md) | - |
---
<!-- AUTO-GENERATED-START: docs-readme-full-list -->
## 카테고리별 전체 문서 목록
### 백엔드 (20개)
| 문서 | 제목 |
|------|------|
| [activity-log-hooks.md](backend/activity-log-hooks.md) | 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference) |
| [activity-log.md](backend/activity-log.md) | 활동 로그 시스템 (Activity Log System) |
| [api-resources.md](backend/api-resources.md) | API 리소스 |
| [authentication.md](backend/authentication.md) | 인증 및 세션 처리 |
| [broadcasting.md](backend/broadcasting.md) | Broadcasting (실시간 이벤트) |
| [controllers.md](backend/controllers.md) | 컨트롤러 계층 구조 |
| [core-config.md](backend/core-config.md) | 코어 설정 (config/core.php) |
| [core-update-system.md](backend/core-update-system.md) | 코어 업데이트 시스템 (Core Update System) |
| [enum.md](backend/enum.md) | Enum 사용 규칙 |
| [exceptions.md](backend/exceptions.md) | Custom Exception 다국어 처리 |
| [middleware.md](backend/middleware.md) | 미들웨어 등록 규칙 |
| [notification-system.md](backend/notification-system.md) | 알림 시스템 (Notification System) |
| [README.md](backend/README.md) | 백엔드 개발 가이드 |
| [response-helper.md](backend/response-helper.md) | API 응답 규칙 (ResponseHelper) |
| [routing.md](backend/routing.md) | 라우트 네이밍 및 경로 |
| [search-system.md](backend/search-system.md) | Scout 검색 엔진 시스템 (Search System) |
| [seo-system.md](backend/seo-system.md) | SEO 페이지 생성기 시스템 (SEO Page Generator) |
| [service-provider.md](backend/service-provider.md) | 서비스 프로바이더 안전성 |
| [service-repository.md](backend/service-repository.md) | Service-Repository 패턴 |
| [validation.md](backend/validation.md) | 검증 (Validation) |
### 프론트엔드 (49개)
| 문서 | 제목 |
|------|------|
| [actions-g7core-api.md](frontend/actions-g7core-api.md) | 액션 시스템 - G7Core API (React 컴포넌트용) |
| [actions-handlers-navigation.md](frontend/actions-handlers-navigation.md) | 액션 핸들러 - 네비게이션 |
| [actions-handlers-state.md](frontend/actions-handlers-state.md) | 액션 핸들러 - 상태 관리 |
| [actions-handlers-ui.md](frontend/actions-handlers-ui.md) | 액션 핸들러 - UI 인터랙션 |
| [actions-handlers.md](frontend/actions-handlers.md) | 액션 핸들러 - 핸들러별 상세 사용법 |
| [actions.md](frontend/actions.md) | 액션 핸들러 가이드 |
| [auth-system.md](frontend/auth-system.md) | 인증 시스템 (AuthManager) |
| [component-props-composite.md](frontend/component-props-composite.md) | 컴포넌트 Props 레퍼런스 - Composite |
| [component-props.md](frontend/component-props.md) | 컴포넌트 Props 레퍼런스 |
| [components-advanced.md](frontend/components-advanced.md) | 컴포넌트 고급 기능 |
| [components-patterns.md](frontend/components-patterns.md) | 컴포넌트 패턴 및 다국어 |
| [components-types.md](frontend/components-types.md) | 컴포넌트 타입별 개발 규칙 |
| [components.md](frontend/components.md) | 컴포넌트 개발 규칙 |
| [dark-mode.md](frontend/dark-mode.md) | 다크 모드 지원 (engine-v1.1.0+) |
| [data-binding-i18n.md](frontend/data-binding-i18n.md) | 데이터 바인딩 - 다국어 처리 |
| [data-binding.md](frontend/data-binding.md) | 데이터 바인딩 및 표현식 |
| [data-sources-advanced.md](frontend/data-sources-advanced.md) | 데이터 소스 - 고급 기능 |
| [data-sources.md](frontend/data-sources.md) | 데이터 소스 (Data Sources) |
| [editors.md](frontend/editors.md) | 에디터 컴포넌트 가이드 |
| [g7core-api-advanced.md](frontend/g7core-api-advanced.md) | G7Core 전역 API 레퍼런스 - 고급 |
| [g7core-api.md](frontend/g7core-api.md) | G7Core 전역 API 레퍼런스 |
| [g7core-helpers.md](frontend/g7core-helpers.md) | G7Core 헬퍼 API |
| [layout-json-components-loading.md](frontend/layout-json-components-loading.md) | 레이아웃 JSON - 데이터 로딩 및 생명주기 |
| [layout-json-components-rendering.md](frontend/layout-json-components-rendering.md) | 레이아웃 JSON - 조건부/반복 렌더링 |
| [layout-json-components-slots.md](frontend/layout-json-components-slots.md) | 레이아웃 JSON - 슬롯 시스템 |
| [layout-json-components.md](frontend/layout-json-components.md) | 레이아웃 JSON - 컴포넌트 (반복 렌더링, Blur, 생명주기, 슬롯) |
| [layout-json-features-actions.md](frontend/layout-json-features-actions.md) | 레이아웃 JSON - 초기화, 모달, 액션, 스크립트 |
| [layout-json-features-error.md](frontend/layout-json-features-error.md) | 레이아웃 JSON - 에러 핸들링 |
| [layout-json-features-styling.md](frontend/layout-json-features-styling.md) | 레이아웃 JSON - 스타일 및 계산된 값 |
| [layout-json-features.md](frontend/layout-json-features.md) | 레이아웃 JSON - 기능 (에러 핸들링, 초기화, 모달, 액션) |
| [layout-json-inheritance.md](frontend/layout-json-inheritance.md) | 레이아웃 JSON - 상속 (Extends, Partial, 병합) |
| [layout-json.md](frontend/layout-json.md) | 레이아웃 JSON 스키마 |
| [layout-testing.md](frontend/layout-testing.md) | 그누보드7 레이아웃 파일 렌더링 테스트 가이드 |
| [modal-usage.md](frontend/modal-usage.md) | Modal 컴포넌트 사용 가이드 |
| [README.md](frontend/README.md) | 그누보드7 프론트엔드 개발 가이드 |
| [responsive-layout.md](frontend/responsive-layout.md) | 반응형 레이아웃 개발 (engine-v1.1.0+) |
| [security.md](frontend/security.md) | 보안 및 검증 |
| [state-management-advanced.md](frontend/state-management-advanced.md) | 상태 관리 - 고급 기능 |
| [state-management-forms.md](frontend/state-management-forms.md) | 상태 관리 - 폼 자동 바인딩 및 setState |
| [state-management.md](frontend/state-management.md) | 전역 상태 관리 |
| [tailwind-safelist.md](frontend/tailwind-safelist.md) | Tailwind Safelist 가이드 |
| [template-development.md](frontend/template-development.md) | 템플릿 개발 가이드라인 |
| [template-handlers.md](frontend/template-handlers.md) | 템플릿 전용 핸들러 |
| [components.md](frontend/components.md) | sirsoft-admin_basic 컴포넌트 |
| [handlers.md](frontend/handlers.md) | sirsoft-admin_basic 핸들러 |
| [layouts.md](frontend/layouts.md) | sirsoft-admin_basic 레이아웃 |
| [components.md](frontend/components.md) | sirsoft-basic 컴포넌트 |
| [handlers.md](frontend/handlers.md) | sirsoft-basic 핸들러 |
| [layouts.md](frontend/layouts.md) | sirsoft-basic 레이아웃 |
### 확장 시스템 (23개)
| 문서 | 제목 |
|------|------|
| [changelog-rules.md](extension/changelog-rules.md) | Changelog 규칙 (Changelog Rules) |
| [extension-manager.md](extension/extension-manager.md) | ExtensionManager (확장 관리자) |
| [extension-update-system.md](extension/extension-update-system.md) | 확장 업데이트 시스템 (Extension Update System) |
| [hooks.md](extension/hooks.md) | 훅 시스템 (Hook System) |
| [layout-extensions.md](extension/layout-extensions.md) | 레이아웃 확장 시스템 (Layout Extensions) |
| [menus.md](extension/menus.md) | 메뉴 시스템 |
| [module-assets.md](extension/module-assets.md) | 모듈 프론트엔드 에셋 시스템 |
| [module-basics.md](extension/module-basics.md) | 모듈 개발 기초 |
| [module-commands.md](extension/module-commands.md) | 모듈 Artisan 커맨드 |
| [module-i18n.md](extension/module-i18n.md) | 모듈 다국어 시스템 |
| [module-layouts.md](extension/module-layouts.md) | 모듈 레이아웃 시스템 |
| [module-routing.md](extension/module-routing.md) | 모듈 라우트 규칙 |
| [module-settings.md](extension/module-settings.md) | 모듈 환경설정 시스템 개발 가이드 |
| [permissions.md](extension/permissions.md) | 권한 시스템 |
| [plugin-development.md](extension/plugin-development.md) | 플러그인 개발 가이드 |
| [README.md](extension/README.md) | 그누보드7 확장 시스템 개발 가이드 |
| [storage-driver.md](extension/storage-driver.md) | 스토리지 드라이버 시스템 (StorageInterface) |
| [template-basics.md](extension/template-basics.md) | 템플릿 시스템 기초 |
| [template-caching.md](extension/template-caching.md) | 템플릿 캐싱 전략 |
| [template-commands.md](extension/template-commands.md) | 템플릿 Artisan 커맨드 |
| [template-routing.md](extension/template-routing.md) | 템플릿 라우트/언어 파일 규칙 |
| [template-security.md](extension/template-security.md) | 템플릿 보안 정책 |
| [template-workflow.md](extension/template-workflow.md) | 템플릿 개발 워크플로우 |
### 공통 (19개)
| 문서 | 제목 |
|------|------|
| [README.md](ai-tools/agents/README.md) | 그누보드7 Multi-Agent System |
| [README.md](ai-tools/devtools/README.md) | 그누보드7 DevTools MCP 서버 |
| [README.md](ai-tools/README.md) | 그누보드7 AI 도구 |
| [create-module.md](ai-tools/skills/create-module.md) | 모듈 스캐폴딩 (create-module) |
| [create-plugin.md](ai-tools/skills/create-plugin.md) | 플러그인 스캐폴딩 (create-plugin) |
| [create-template.md](ai-tools/skills/create-template.md) | 템플릿 스캐폴딩 (create-template) |
| [extract-i18n-keys.md](ai-tools/skills/extract-i18n-keys.md) | 다국어 키 추출 (extract-i18n-keys) |
| [run-tests.md](ai-tools/skills/run-tests.md) | 테스트 실행 (run-tests) |
| [validate-code.md](ai-tools/skills/validate-code.md) | 코드 패턴 검증 (validate-code) |
| [validate-frontend.md](ai-tools/skills/validate-frontend.md) | 프론트엔드 검증 (validate-frontend) |
| [validate-hook.md](ai-tools/skills/validate-hook.md) | 훅 패턴 검증 (validate-hook) |
| [validate-i18n.md](ai-tools/skills/validate-i18n.md) | 다국어 검증 (validate-i18n) |
| [validate-migration.md](ai-tools/skills/validate-migration.md) | 마이그레이션 검증 (validate-migration) |
| [cheatsheet.md](cheatsheet.md) | 그누보드7 자주 쓰는 명령어 치트시트 |
| [database-guide.md](database-guide.md) | 그누보드7 데이터베이스 개발 가이드 |
| [README.md](README.md) | 그누보드7 문서 인덱스 |
| [requirements.md](requirements.md) | 그누보드7 시스템 요구사항 (System Requirements) |
| [SECURITY.md](SECURITY.md) | 그누보드7 템플릿 엔진 보안 가이드 |
| [testing-guide.md](testing-guide.md) | 그누보드7 테스트 가이드 |
### AI 도구
| 문서 | 설명 |
|------|------|
| [ai-tools/README.md](ai-tools/README.md) | AI 도구 개요 |
| [ai-tools/skills/](ai-tools/skills/) | AI 코딩 도구 스킬 |
| [ai-tools/agents/](ai-tools/agents/) | 멀티에이전트 시스템 소스 |
<!-- AUTO-GENERATED-END: docs-readme-full-list -->
+605
View File
@@ -0,0 +1,605 @@
# 그누보드7 템플릿 엔진 보안 가이드
> 그누보드7 레이아웃 JSON 보안 아키텍처 — Custom Validation Rules, FormRequest 검증 계층, 공격 방어 전략
---
## TL;DR (5초 요약)
```text
1. 다층 방어: FormRequest(10개 Custom Rule) → Service → Repository(ORM) → React(자동 이스케이프)
2. 10개 Custom Rule: JSON 구조, 컴포넌트, 엔드포인트, URL, 상속, 슬롯, 데이터소스, 권한, 경로, 파일타입
3. 7개 FormRequest: 용도별 Rule 조합 차등 적용 (Store/Update/Content/Inheritance/Preview/Get)
4. 상속 보안: 순환 참조 감지 + 깊이 10 제한 + 부모 슬롯/데이터소스 ID 고유성 검증
5. 확장 가능: 6개 Hook으로 모듈/플러그인이 검증 규칙을 동적으로 추가 가능
```
---
## 목차
1. [개요](#개요)
2. [보안 아키텍처](#보안-아키텍처)
3. [검증 계층](#검증-계층)
4. [Custom Validation Rules](#custom-validation-rules)
5. [FormRequest별 Rule 적용 현황](#formrequest별-rule-적용-현황)
6. [레이아웃 상속 보안](#레이아웃-상속-보안)
7. [경로 보안 (Path Traversal 방어)](#경로-보안-path-traversal-방어)
8. [Hook 기반 검증 확장](#hook-기반-검증-확장)
9. [공격 방어 전략](#공격-방어-전략)
10. [보안 모범 사례](#보안-모범-사례)
11. [관련 문서](#관련-문서)
---
## 개요
그누보드7 템플릿 엔진은 JSON 기반 레이아웃으로 화면을 동적으로 구성합니다. 이 유연성은 악의적 입력을 통한 공격 가능성을 내포하므로, **10개 Custom Validation Rule**과 **7개 FormRequest**를 조합한 다층 방어 체계로 보호합니다.
### 보안 원칙
1. **다층 방어 (Defense in Depth)**: FormRequest → Service → Repository → 프론트엔드, 각 계층에서 독립적 검증
2. **최소 권한 원칙 (Principle of Least Privilege)**: 필요한 최소한의 권한만 부여
3. **화이트리스트 방식 (Whitelist Approach)**: 명시적으로 허용된 것만 통과
4. **검증 우선 (Fail Secure)**: 검증 실패 시 안전한 상태로 복귀
5. **확장 가능한 검증 (Extensible Validation)**: Hook을 통해 모듈/플러그인이 검증 규칙을 동적으로 추가 가능
---
## 보안 아키텍처
### 전체 검증 플로우
```text
사용자 요청
↓
1. Controller 진입 전 검증 (FormRequest + 10개 Custom Rule)
├─ ValidLayoutStructure: JSON 스키마 + 중첩 깊이 + actions + permissions 검증
├─ ComponentExists: 컴포넌트 매니페스트 대조 (3카테고리, 1시간 캐싱)
├─ WhitelistedEndpoint: API 엔드포인트 화이트리스트 + 경로 트래버설 차단
├─ NoExternalUrls: 7개 위험 URI 스킴 + 프로토콜 상대 URL 차단
├─ ValidParentLayout: 상속 순환 참조 감지 + 깊이 10 제한
├─ ValidSlotStructure: 부모에서 정의된 슬롯만 허용
├─ ValidDataSourceMerge: 상속 체인 전체 데이터소스 ID 고유성
├─ ValidPermissionStructure: or/and 구조 + 깊이 3 제한 + 정규식 형식
├─ SafeTemplatePath: 13개 Path Traversal 패턴 + 5회 URL 디코딩 + NULL 바이트
└─ AllowedTemplateFileType: 14개 확장자 화이트리스트
↓
2. Hook 기반 확장 검증 (모듈/플러그인이 추가한 규칙)
↓
3. Service 계층 (비즈니스 로직)
├─ 레이아웃 상속 병합
├─ 훅 실행 (before_save, after_save)
└─ 데이터베이스 저장
↓
4. 프론트엔드 렌더링
├─ React 자동 이스케이프 (XSS 방지)
├─ CSRF 토큰 검증 (Laravel Sanctum Bearer 토큰)
└─ Eloquent ORM 파라미터 바인딩 (SQL Injection 방지)
```
### 보안 계층 구조
```text
┌─────────────────────────────────────────────────────────────┐
│ 사용자 브라우저 │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ React 렌더링 (자동 이스케이프) │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
↕ HTTPS
┌─────────────────────────────────────────────────────────────┐
│ Laravel Backend │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 1. FormRequest 검증 (Controller 진입 전) │ │
│ │ 10개 Custom Rule + Hook 확장 규칙 │ │
│ └──────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 2. Service 계층 (비즈니스 로직) │ │
│ │ 레이아웃 상속 병합 + 훅 실행 │ │
│ └──────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 3. Repository 계층 (데이터 접근) │ │
│ │ Eloquent ORM (파라미터 바인딩) │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
↕
┌─────────────────────────────────────────────────────────────┐
│ MySQL Database │
└─────────────────────────────────────────────────────────────┘
```
---
## 검증 계층
### 1단계: FormRequest 검증 (Controller 진입 전)
**위치**: `app/Http/Requests/Layout/`
**목적**: Controller에 도달하기 전에 모든 악의적 입력을 차단
G7은 **용도별 7개 FormRequest**로 검증을 분리합니다. 각 FormRequest는 용도에 맞는 Custom Rule 조합을 적용하며, Hook을 통해 모듈/플러그인이 규칙을 확장할 수 있습니다.
> 상세: [FormRequest별 Rule 적용 현황](#formrequest별-rule-적용-현황)
### 2단계: Service 계층 (비즈니스 로직)
**위치**: `app/Services/LayoutService.php`
**특징**:
- 검증 로직 없음 (FormRequest에서 완료)
- 순수 비즈니스 로직만 처리
- 훅 시스템을 통한 확장성 (`before_save`, `after_save`)
### 3단계: Repository 계층 (데이터 접근)
**위치**: `app/Repositories/LayoutRepository.php`
**특징**:
- Eloquent ORM 사용 (파라미터 바인딩으로 SQL Injection 자동 방어)
- 직접 SQL 문자열 연결 금지
---
## Custom Validation Rules
G7은 레이아웃 JSON 보안을 위해 **10개의 Custom Validation Rule**을 제공합니다.
### 1. ValidLayoutStructure
**파일**: `app/Rules/ValidLayoutStructure.php`
**목적**: 레이아웃 JSON의 구조적 유효성 검증
**검증 항목**:
- 필수 필드 존재 확인 (`version`, `layout_name`, `components`)
- `extends` 레이아웃: `components` 또는 `slots` 중 하나 필수
- 재귀적 컴포넌트 구조 검증 (type, name 필수)
- 컴포넌트 `type`: `basic`, `composite`, `layout` 중 하나만 허용
- **최대 중첩 깊이 제한: 10단계** (`MAX_DEPTH = 10`)
- `actions` 배열 구조 검증 (`type` 또는 `event` 중 하나 필수)
- `permissions` 필드: `ValidPermissionStructure` Rule에 위임
- 슬롯 참조(`{ "slot": "name" }`)와 Partial 참조(`{ "partial": "path" }`) 허용
### 2. ComponentExists
**파일**: `app/Rules/ComponentExists.php`
**목적**: 존재하지 않는 컴포넌트 참조 방지
**검증 방법**:
1. 템플릿의 `components.json` 매니페스트 로드
2. **3개 카테고리**: `basic`, `composite`, `layout` 컴포넌트를 Set으로 구축
3. 레이아웃 JSON의 모든 `name` 필드를 매니페스트와 대조
4. 재귀적으로 `children` 배열 검증
**캐싱 전략**:
- 캐시 키: `template.{template_id}.components_manifest`
- **캐시 TTL: 1시간** (`CACHE_TTL = 3600`)
### 3. WhitelistedEndpoint
**파일**: `app/Rules/WhitelistedEndpoint.php`
**목적**: API 엔드포인트 화이트리스트 검증
**허용 패턴**:
```regex
^/api/(admin|auth|public)/
```
**검증 대상**:
- `data_sources[].endpoint` — 데이터소스 엔드포인트
- `components[].actions[].endpoint` — 액션 핸들러 엔드포인트 (재귀적 children 포함)
**차단**:
- 외부 URL (`http://`, `https://`)
- 비공개 API (`/api/internal/*`)
- 직접 경로 (`/admin/*`)
- **경로 트래버설**: `../`, `..\` 패턴 차단
### 4. NoExternalUrls
**파일**: `app/Rules/NoExternalUrls.php`
**목적**: 외부 URL 차단으로 데이터 유출 및 악의적 리소스 로딩 방지
**차단 대상 — 7개 위험 URI 스킴**:
| 스킴 | 위험성 |
|------|--------|
| `http://` | 외부 서버 통신, 데이터 유출 |
| `https://` | 외부 서버 통신, 데이터 유출 |
| `data:` | 인라인 데이터 삽입, XSS 벡터 |
| `javascript:` | 임의 JavaScript 실행 |
| `vbscript:` | 임의 VBScript 실행 (IE) |
| `file:` | 로컬 파일 시스템 접근 |
| `ftp:` | 외부 FTP 서버 통신 |
**추가 차단**: `//`로 시작하는 프로토콜 상대 URL
**검증 범위**: `components[]` → `props`, `actions` 내 모든 문자열 값을 재귀적으로 스캔
### 5. ValidParentLayout
**파일**: `app/Rules/ValidParentLayout.php`
**목적**: 레이아웃 상속 체계의 안전성 보장
**검증 항목**:
1. **부모 존재 확인**: DB에서 `template_id` + `name` 조회
2. **순환 참조 감지**: 재귀적 체인 추적 (`visited` 배열로 방문 노드 기록)
3. **상속 깊이 제한**: `MAX_INHERITANCE_DEPTH = 10`
> 상세: [레이아웃 상속 보안](#레이아웃-상속-보안)
### 6. ValidSlotStructure
**파일**: `app/Rules/ValidSlotStructure.php`
**목적**: 부모 레이아웃에서 정의된 슬롯만 허용
**검증 로직**:
1. `extends` 필드가 없으면 검증 통과
2. 부모 레이아웃의 `components`에서 `{ "slot": "name" }` 패턴을 재귀적으로 수집
3. 자식 레이아웃의 `slots` 키가 부모에 정의된 슬롯 이름과 일치하는지 검증
4. **부모에 없는 슬롯 이름 사용 시 차단**
### 7. ValidDataSourceMerge
**파일**: `app/Rules/ValidDataSourceMerge.php`
**목적**: 상속 체인 전체에서 데이터소스 ID 고유성 보장
**검증 로직**:
1. 현재 레이아웃 내 `data_sources[].id` 중복 검사
2. `extends`가 있으면 **부모 체인 전체를 순회**하여 모든 데이터소스 ID 수집
3. 현재 레이아웃 ID와 부모 체인 ID의 교집합 검사 → **중복 시 차단**
4. 무한 루프 방지 (`visited` 배열)
### 8. ValidPermissionStructure
**파일**: `app/Rules/ValidPermissionStructure.php`
**목적**: 권한 구조의 형식적 유효성 검증
**지원 구조**:
- **Flat array**: `["perm.read", "perm.write"]`
- **OR 구조**: `{ "or": ["perm.read", "perm.write"] }` (하나라도 만족)
- **AND 구조**: `{ "and": ["perm.read", "perm.write"] }` (모두 만족)
- **중첩 구조**: `{ "or": ["perm.a", { "and": ["perm.b", "perm.c"] }] }`
**제한**:
- **최대 중첩 깊이: 3단계** (`MAX_DEPTH = 3`)
- or/and 연산자에 **최소 2개 항목** 필수 (`MIN_OPERATOR_ITEMS = 2`)
- 권한 식별자 정규식: `/^[a-z0-9_-]+\.[a-z0-9_-]+(\.[a-z0-9_-]+)*$/i`
**사용 위치**: 레이아웃 최상위 `permissions` + 컴포넌트 레벨 `permissions` (ValidLayoutStructure에서 위임)
### 9. SafeTemplatePath
**파일**: `app/Rules/SafeTemplatePath.php`
**목적**: 템플릿 파일 경로의 Path Traversal 공격 방지
> 상세: [경로 보안 (Path Traversal 방어)](#경로-보안-path-traversal-방어)
### 10. AllowedTemplateFileType
**파일**: `app/Rules/AllowedTemplateFileType.php`
**목적**: 허용된 파일 확장자만 통과
**14개 허용 확장자**:
| 카테고리 | 확장자 |
|---------|--------|
| Scripts | `js`, `mjs` |
| Styles | `css` |
| Data | `json` |
| Images | `png`, `jpg`, `jpeg`, `svg`, `webp`, `gif` |
| Fonts | `woff`, `woff2`, `ttf`, `otf`, `eot` |
**그 외 모든 확장자 차단** (PHP, HTML, EXE 등)
---
## FormRequest별 Rule 적용 현황
G7은 레이아웃 CRUD 작업별로 **7개 FormRequest**를 운용합니다. 각 FormRequest는 용도에 맞는 Rule 조합을 적용합니다.
### 비교 표
| FormRequest | 용도 | VLS | CE | WE | NEU | VPL | VSS | VDM | VPS |
|-------------|------|:---:|:--:|:--:|:---:|:---:|:---:|:---:|:---:|
| **StoreLayoutRequest** | 새 레이아웃 생성 | ✅ | ✅ | ✅ | ✅ | | | | |
| **UpdateLayoutRequest** | 메타+콘텐츠 수정 | ✅* | ✅* | ✅* | ✅* | | | | |
| **UpdateLayoutContentRequest** | 콘텐츠만 수정 | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **StoreLayoutInheritanceRequest** | 상속 레이아웃 생성 | | | | | ✅ | ✅ | ✅ | |
| **UpdateLayoutInheritanceRequest** | 상속 레이아웃 수정 | | | | | ✅ | ✅ | ✅ | |
| **StoreLayoutPreviewRequest** | 미리보기 | | | | | | | | |
| **GetLayoutRequest** | 레이아웃 조회 | | | | | | | | |
**범례**: VLS=ValidLayoutStructure, CE=ComponentExists, WE=WhitelistedEndpoint, NEU=NoExternalUrls, VPL=ValidParentLayout, VSS=ValidSlotStructure, VDM=ValidDataSourceMerge, VPS=ValidPermissionStructure
**✅\*** = `sometimes` 규칙 (필드가 존재할 때만 적용)
### 핵심 포인트
- **StoreLayoutRequest / UpdateLayoutRequest**: 기본 4개 Rule (구조, 컴포넌트, 엔드포인트, URL)
- **UpdateLayoutContentRequest**: 가장 엄격 — 상속 관련 4개 Rule 추가 (총 8개)
- **StoreLayoutInheritanceRequest / UpdateLayoutInheritanceRequest**: 상속 전용 3개 Rule
- **StoreLayoutPreviewRequest**: 최소 검증 (`content: required, array`)
- **GetLayoutRequest**: 조회 전용 (보안 Rule 없음)
---
## 레이아웃 상속 보안
G7 레이아웃은 `extends`/`slots`/`partial` 시스템으로 상속을 지원합니다. 상속 체계의 안전성은 3개 Rule이 협력하여 보장합니다.
### 순환 참조 방지 (ValidParentLayout)
```text
A extends B → B extends C → C extends A ← 차단!
```
**메커니즘**:
1. `extends` 필드의 부모 레이아웃을 DB에서 조회
2. 부모의 `extends`를 재귀적으로 추적 (`visited` 배열로 방문 기록)
3. 이미 방문한 레이아웃 또는 자기 자신을 참조하면 순환 참조로 판정
4. **최대 상속 깊이: 10단계** — 초과 시 차단
### 슬롯 안전성 (ValidSlotStructure)
```text
부모 레이아웃에 { "slot": "header" }, { "slot": "content" } 정의
↓
자식 레이아웃에서 slots: { "header": [...], "footer": [...] }
↑ 차단! (부모에 "footer" 슬롯 미정의)
```
- 부모 `components` 트리를 재귀 탐색하여 `{ "slot": "name" }` 패턴 수집
- 자식의 `slots` 키가 부모 슬롯 목록에 포함되는지 검증
### 데이터소스 ID 고유성 (ValidDataSourceMerge)
```text
조부모: data_sources: [{ id: "users" }]
부모: data_sources: [{ id: "posts" }]
자식: data_sources: [{ id: "users" }] ← 차단! (조부모와 ID 충돌)
```
- **상속 체인 전체**를 순회하여 모든 데이터소스 ID 수집
- 현재 레이아웃 ID와의 교집합 검사
- 무한 루프 방지 로직 포함
---
## 경로 보안 (Path Traversal 방어)
**파일**: `app/Rules/SafeTemplatePath.php`
G7은 OS 레벨 파일 권한에 의존하지 않고, **애플리케이션 레벨에서 경로를 적극적으로 검증**합니다.
### 13개 Path Traversal 패턴 차단
| 패턴 | 설명 |
|------|------|
| `../` | Unix 상위 디렉토리 |
| `..\` | Windows 상위 디렉토리 |
| `//` | 이중 슬래시 |
| `%2e%2e%2f` | URL 인코딩 `../` |
| `%2e%2e/` | 부분 URL 인코딩 |
| `%2e%2e%5c` | URL 인코딩 `..\` |
| `%2e%2e\` | 부분 URL 인코딩 |
| `..%2f` | 혼합 인코딩 `../` |
| `..%5c` | 혼합 인코딩 `..\` |
| `.%2e/` | 부분 인코딩 `../` |
| `.%2e\` | 부분 인코딩 `..\` |
### 다중 인코딩 공격 방지
```php
// 최대 5회 반복 URL 디코딩 (이중/삼중 인코딩 공격 방지)
for ($i = 0; $i < 5 && $decodedPath !== $previousPath; $i++) {
$previousPath = $decodedPath;
$decodedPath = urldecode($decodedPath);
}
```
공격자가 `%252e%252e%252f` (삼중 인코딩)를 사용해도 5회 디코딩으로 원본 패턴이 드러남
### 추가 방어
| 방어 항목 | 설명 |
|----------|------|
| **절대 경로 차단** | `/`, `\`, `C:` 등 절대 경로 시작 패턴 차단 (Windows/Linux 모두) |
| **NULL 바이트 차단** | `\0` 문자 감지 시 차단 (C 언어 문자열 종료 공격) |
| **basePath 외부 접근 차단** | `realpath()` 정규화 후 `basePath` 외부 경로 감지 시 차단 |
---
## Hook 기반 검증 확장
모듈/플러그인은 **6개 Filter Hook**을 통해 레이아웃 검증 규칙을 동적으로 추가할 수 있습니다.
### 사용 가능한 Hook
| Hook 이름 | FormRequest | 용도 |
|----------|-------------|------|
| `core.layout.store_validation_rules` | StoreLayoutRequest | 레이아웃 생성 시 규칙 추가 |
| `core.layout.update_validation_rules` | UpdateLayoutRequest | 레이아웃 수정 시 규칙 추가 |
| `core.layout.update_content_validation_rules` | UpdateLayoutContentRequest | 콘텐츠 수정 시 규칙 추가 |
| `core.layout.store_inheritance_validation_rules` | StoreLayoutInheritanceRequest | 상속 레이아웃 생성 시 규칙 추가 |
| `core.layout.update_inheritance_validation_rules` | UpdateLayoutInheritanceRequest | 상속 레이아웃 수정 시 규칙 추가 |
| `core.layout.get_validation_rules` | GetLayoutRequest | 레이아웃 조회 시 규칙 추가 |
### 사용 예시
```php
// 모듈의 Listener에서 커스텀 검증 규칙 추가
class LayoutValidationListener
{
public function handle(array $rules, StoreLayoutRequest $request): array
{
// 모듈 전용 검증 규칙 추가
$rules['content'][] = new MyCustomRule();
return $rules;
}
}
```
```php
// 모듈의 ServiceProvider에서 Hook 등록
HookManager::addFilter(
'core.layout.store_validation_rules',
[LayoutValidationListener::class, 'handle'],
priority: 10
);
```
> 참고: Filter 훅이므로 리스너에서 `type: 'filter'` 명시 필수 (미지정 시 반환값 무시)
> 상세: [훅 시스템](../extension/hooks.md)
---
## 공격 방어 전략
### 공격 시나리오별 방어 매핑
| 공격 유형 | 공격 벡터 | 방어 메커니즘 | 차단 계층 |
|---------|---------|------------|---------|
| **XSS** | `<script>` 태그 삽입 | React 자동 이스케이프 | 프론트엔드 |
| **SQL Injection** | `'; DROP TABLE--` | Eloquent ORM 파라미터 바인딩 | Repository |
| **CSRF** | 토큰 없는 요청 | Laravel Sanctum Bearer 토큰 | 미들웨어 |
| **경로 트래버설** | `../../etc/passwd` | SafeTemplatePath (13패턴 + 5회 디코딩) | FormRequest |
| **다중 인코딩 경로 트래버설** | `%252e%252e%252f` | SafeTemplatePath 5회 반복 디코딩 | FormRequest |
| **NULL 바이트 경로 공격** | `file.php\0.jpg` | SafeTemplatePath NULL 바이트 감지 | FormRequest |
| **외부 리소스 로딩** | `https://evil.com` | NoExternalUrls (7개 스킴) | FormRequest |
| **XSS via URI** | `javascript:alert(1)` | NoExternalUrls (`javascript:` 차단) | FormRequest |
| **Data URI 삽입** | `data:text/html,...` | NoExternalUrls (`data:` 차단) | FormRequest |
| **프로토콜 상대 URL** | `//evil.com/malicious.js` | NoExternalUrls (`//` 차단) | FormRequest |
| **허용되지 않은 API** | `/api/internal/` | WhitelistedEndpoint | FormRequest |
| **API 경로 트래버설** | `/api/admin/../internal/` | WhitelistedEndpoint (`../` 차단) | FormRequest |
| **존재하지 않는 컴포넌트** | `MaliciousComponent` | ComponentExists (매니페스트 대조) | FormRequest |
| **DoS (깊은 중첩)** | 11단계 이상 | ValidLayoutStructure (`MAX_DEPTH = 10`) | FormRequest |
| **DoS (상속 깊이)** | 11단계 이상 | ValidParentLayout (`MAX_INHERITANCE_DEPTH = 10`) | FormRequest |
| **DoS (권한 중첩)** | 4단계 이상 | ValidPermissionStructure (`MAX_DEPTH = 3`) | FormRequest |
| **상속 순환 참조** | A→B→C→A | ValidParentLayout (visited 추적) | FormRequest |
| **유령 슬롯 주입** | 부모에 없는 슬롯 | ValidSlotStructure | FormRequest |
| **데이터소스 ID 충돌** | 부모와 동일 ID | ValidDataSourceMerge (체인 전체 검사) | FormRequest |
| **위험 파일 업로드** | `malicious.php` | AllowedTemplateFileType (14개 화이트리스트) | FormRequest |
### 복합 공격 방어
악의적 사용자가 여러 공격 기법을 조합하더라도, 각 계층에서 독립적으로 검증하므로 하나의 공격이 통과하더라도 다음 계층에서 차단됩니다.
```json
{
"version": "1.0.0",
"layout_name": "combined_attack",
"data_sources": [
{
"endpoint": "/api/internal/secrets",
"params": { "filter": "'; DROP TABLE--" }
}
],
"components": [
{
"name": "NonExistentComponent",
"type": "basic",
"props": {
"imageUrl": "https://evil.com/image.jpg",
"onClick": "<script>alert(1)</script>"
}
}
]
}
```
**결과**: FormRequest의 첫 번째 검증 규칙에서 차단 (422 응답)
- `WhitelistedEndpoint`: `/api/internal/` 차단
- `ComponentExists`: `NonExistentComponent` 차단
- `NoExternalUrls`: `https://evil.com` 차단
- React: `<script>` 태그는 자동 이스케이프 (텍스트로 표시)
- Eloquent ORM: SQL 구문은 파라미터 바인딩으로 안전 처리
---
## 보안 모범 사례
### 템플릿 개발자를 위한 지침
1. **컴포넌트 명세 문서화**: `components.json`에 모든 컴포넌트를 `basic`/`composite`/`layout` 카테고리로 분류 등록
2. **외부 의존성 최소화**: 외부 CDN 사용 자제, `/public/build/template/` 디렉토리에 번들링
3. **props 타입 검증**: 컴포넌트 내부에서 TypeScript로 props 타입 검증
### 확장 개발자를 위한 지침
1. **Hook을 통한 검증 추가**: 모듈 전용 검증이 필요하면 `core.layout.*_validation_rules` Hook 사용
2. **Custom Rule 작성**: `Illuminate\Contracts\Validation\ValidationRule` 인터페이스 구현
3. **`__()` 함수 사용**: Custom Rule 에러 메시지는 반드시 다국어 처리
### 시스템 관리자를 위한 지침
1. **정기적인 보안 감사**: `MaliciousJsonTest.php` 실행으로 방어 체계 검증
2. **로그 모니터링**: 422 응답 빈도 모니터링, 반복적인 악의적 시도 IP 차단
3. **환경 변수 보호**: `.env` 파일 권한 제한 (600), API 키/DB 비밀번호 안전 보관
4. **업데이트 관리**: Laravel 및 의존성 패키지 최신 버전 유지
### 코드 리뷰 체크리스트
- [ ] 모든 검증 로직이 FormRequest에 있는가?
- [ ] Service에 검증 로직이 없는가?
- [ ] Custom Rule에서 `__()` 함수로 다국어 처리했는가?
- [ ] 외부 URL 참조가 없는가?
- [ ] `dangerouslySetInnerHTML` 사용이 없는가?
- [ ] DB 쿼리가 Eloquent ORM을 사용하는가?
- [ ] API 라우트에 인증 미들웨어가 있는가?
- [ ] 새로운 엔드포인트가 WhitelistedEndpoint 패턴에 부합하는가?
- [ ] 레이아웃 상속 시 부모 슬롯과 데이터소스 ID 충돌이 없는가?
---
## 관련 문서
### 보안 관련
- [프론트엔드 보안 및 검증](frontend/security.md) — XSS, 표현식, 상태 노출, 인증, 에셋 보안
- [템플릿 보안 정책](extension/template-security.md) — 템플릿 에셋 서빙, 경로 보안
### 백엔드
- [검증 (Validation)](backend/validation.md) — FormRequest + Custom Rule 패턴
- [인증 및 세션 처리](backend/authentication.md) — Sanctum 토큰 기반 인증
- [Service-Repository 패턴](backend/service-repository.md) — 계층 분리
### 확장 시스템
- [훅 시스템](extension/hooks.md) — Action/Filter Hook, 모듈/플러그인 확장
- [레이아웃 확장 시스템](extension/layout-extensions.md) — 레이아웃 동적 주입
### 프론트엔드
- [레이아웃 JSON 스키마](frontend/layout-json.md) — JSON 구조 명세
- [레이아웃 JSON - 상속](frontend/layout-json-inheritance.md) — extends/slots/partial 시스템
- [인증 시스템 (AuthManager)](frontend/auth-system.md) — 프론트엔드 인증 흐름
### 참고 파일
- **Custom Rules**: `app/Rules/*.php` (10개 보안 관련)
- **FormRequests**: `app/Http/Requests/Layout/*.php` (7개)
- **통합 테스트**: `tests/Feature/Security/MaliciousJsonTest.php`
- **다국어 파일**: `lang/ko/validation.php`, `lang/en/validation.php`
### 외부 문서
- [Laravel 보안 가이드](https://laravel.com/docs/12.x/authentication)
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
---
**마지막 업데이트**: 2026-03-30
+32
View File
@@ -0,0 +1,32 @@
# 그누보드7 AI 도구
그누보드7 프로젝트의 AI 기반 개발 도구입니다.
## 구성
### [Skills](skills/)
AI 코딩 도구에서 사용하는 슬래시 커맨드 스킬입니다. 코드 검증, 테스트 실행, 문서화 등의 작업을 자동화합니다.
| 스킬 | 설명 |
|------|------|
| [create-module.md](skills/create-module.md) | 모듈 스캐폴딩 |
| [extract-i18n-keys.md](skills/extract-i18n-keys.md) | 다국어 키 추출 |
| [run-tests.md](skills/run-tests.md) | 테스트 실행 |
| [validate-code.md](skills/validate-code.md) | 백엔드 코드 패턴 검증 |
| [validate-frontend.md](skills/validate-frontend.md) | 프론트엔드 레이아웃 검증 |
| [validate-hook.md](skills/validate-hook.md) | 훅 패턴 검증 |
| [validate-i18n.md](skills/validate-i18n.md) | 다국어 검증 |
| [validate-migration.md](skills/validate-migration.md) | 마이그레이션 검증 |
### [DevTools](devtools/)
그누보드7 템플릿 엔진의 디버깅 정보를 AI 코딩 도구에서 접근할 수 있게 해주는 MCP(Model Context Protocol) 서버입니다. 브라우저에서 수집된 상태, 액션, 캐시, 네트워크 정보를 `g7-state`, `g7-diagnose` 등 30여 개 도구로 조회할 수 있습니다.
자세한 내용은 [devtools/README.md](devtools/README.md)를 참조하세요.
### [Agents](agents/)
AI Agent SDK 기반 멀티에이전트 협업 시스템입니다. 5명의 전문 에이전트(Backend, Frontend, Layout, Template, Reviewer)가 Coordinator의 조율 하에 협업합니다.
자세한 내용은 [agents/README.md](agents/README.md)를 참조하세요.
+11
View File
@@ -0,0 +1,11 @@
# AI API Key (Required)
ANTHROPIC_API_KEY=your_api_key_here
# G7 Project Root Path
G7_PROJECT_ROOT=/path/to/your/g7/project
# Default Model for Coordinator
COORDINATOR_MODEL=claude-sonnet-4-20250514
# Log Level (debug, info, warn, error)
LOG_LEVEL=info
+187
View File
@@ -0,0 +1,187 @@
# 그누보드7 Multi-Agent System
AI Agent SDK 기반 그누보드7 전용 멀티에이전트 협업 시스템입니다.
## 개요
5명의 전문 에이전트가 협업하여 그누보드7 프로젝트의 개발 작업을 수행합니다:
| 에이전트 | 역할 | 담당 영역 |
|---------|------|----------|
| **Coordinator** | 작업 분배 및 조율 | 전체 오케스트레이션 |
| **Backend** | 백엔드 개발 | Service, Repository, Controller, Migration |
| **Frontend** | 프론트엔드 개발 | TSX 컴포넌트, 상태 관리, G7Core API |
| **Layout** | 레이아웃 개발 | JSON 스키마, 데이터 바인딩, 반응형 |
| **Template** | 템플릿 개발 | 빌드, 컴포넌트 등록, 다국어 |
| **Reviewer** | 코드 검수 | 규정 준수, 테스트, 문서화 |
## 설치
```bash
cd docs/ai-tools/agents
npm install
```
## 환경 설정
`.env.example`을 `.env`로 복사하고 API 키를 설정하세요:
```bash
cp .env.example .env
```
```env
# AI API Key (Required)
ANTHROPIC_API_KEY=your_api_key_here
# 그누보드7 Project Root Path
G7_PROJECT_ROOT=/path/to/your/g7/project
# Log Level
LOG_LEVEL=info
```
## 사용법
### 인터랙티브 모드
```bash
npm run interactive
```
대화형으로 작업을 요청합니다:
```
🤖 > /feature 상품 할인 기능 추가
🤖 > /bugfix Form 저장 시 데이터 사라짐
🤖 > /review #123
🤖 > ProductService의 할인 로직 설명해줘
```
### 단일 명령어 모드
```bash
# 기능 개발
npm run agent -- feature "상품 할인 기능 추가"
# 버그 수정
npm run agent -- bugfix "Form 저장 시 데이터 사라짐"
# PR 검수
npm run agent -- pr-review "#123"
# 자유 형식
npm run agent -- "ProductService에 할인 로직 추가해줘"
```
## 워크플로우
### 1. 기능 개발 (Feature)
새로운 기능을 개발할 때 사용합니다:
```
Backend → Layout → Frontend → Template → Reviewer
```
1. **Backend**: 마이그레이션, Model, Repository, Service, Controller
2. **Layout**: 레이아웃 JSON, 데이터 소스, 컴포넌트 구조
3. **Frontend**: 필요한 TSX 컴포넌트 개발
4. **Template**: 컴포넌트 등록, 빌드
5. **Reviewer**: 전체 검수
### 2. 버그 수정 (Bugfix)
TDD 기반으로 버그를 수정합니다:
1. 트러블슈팅 가이드 확인
2. 버그 분석
3. **RED**: 버그 재현 테스트 작성
4. **GREEN**: 버그 수정
5. **REFACTOR**: 테스트 통과 확인
6. 검수
### 3. PR 검수 (PR Review)
변경된 파일 유형에 따라 적절한 에이전트가 검수합니다:
- `*.php` → Backend 에이전트
- `*.tsx` → Frontend 에이전트
- `*.json` (레이아웃) → Layout 에이전트
- 최종 → Reviewer 에이전트
## MCP 도구
그누보드7 프로젝트 전용 검증 도구를 제공합니다:
| 도구 | 설명 |
|------|------|
| `validate-code` | 백엔드 코드 규정 검증 |
| `validate-frontend` | 프론트엔드/레이아웃 규정 검증 |
| `run-tests` | 테스트 실행 (PHPUnit, Vitest) |
| `search-docs` | 규정 문서 검색 |
| `read-doc` | 규정 문서 읽기 |
| `list-docs` | 규정 문서 목록 |
## 그누보드7 규정 준수
모든 에이전트는 `docs/`의 규정을 준수합니다:
### 백엔드
- Service-Repository 패턴 (RepositoryInterface 주입)
- FormRequest 검증 (Service 검증 금지)
- HookManager 훅 실행 (before_*/after_*)
- ResponseHelper 응답 처리
- 다국어 처리 (__() 함수)
### 프론트엔드
- HTML 태그 직접 사용 금지 (Div, Button 등 사용)
- 다크 모드 클래스 쌍 필수 (bg-white dark:bg-gray-800)
- G7Core.t() 다국어 처리
- Font Awesome 아이콘
### 레이아웃
- text 속성 사용 (props.children 금지)
- 데이터 바인딩 문법 ({{path}}, $t:key)
- 기본 컴포넌트만 사용
## 프로젝트 구조
```
docs/ai-tools/agents/
├── src/
│ ├── index.ts # 메인 엔트리포인트
│ ├── cli.ts # CLI 인터페이스
│ ├── coordinator/
│ │ └── Coordinator.ts # 코디네이터
│ ├── agents/
│ │ ├── definitions.ts # 에이전트 정의
│ │ └── index.ts
│ ├── prompts/ # 시스템 프롬프트
│ │ ├── coordinator.ts
│ │ ├── backend.ts
│ │ ├── frontend.ts
│ │ ├── layout.ts
│ │ ├── template.ts
│ │ └── reviewer.ts
│ ├── mcp/ # MCP 도구
│ │ ├── g7-tools-server.ts
│ │ └── tools/
│ ├── workflows/ # 워크플로우
│ │ ├── feature.ts
│ │ ├── bugfix.ts
│ │ └── pr-review.ts
│ ├── types/
│ │ └── index.ts
│ └── utils/
│ ├── logger.ts
│ └── context.ts
├── package.json
├── tsconfig.json
├── .env.example
└── README.md
```
## 라이선스
MIT License
File diff suppressed because it is too large. Load diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "g7-agent-system",
"version": "1.0.0",
"description": "Gnuboard7 Multi-Agent System using Claude Agent SDK",
"type": "module",
"main": "src/index.ts",
"scripts": {
"start": "tsx src/index.ts",
"dev": "tsx watch src/index.ts",
"interactive": "tsx src/interactive.ts",
"agent": "tsx src/index.ts single",
"mcp": "tsx src/mcp/index.ts",
"mcp:agents": "tsx src/mcp/index.ts",
"mcp:devtools": "tsx src/mcp/g7-devtools-server.ts"
},
"bin": {
"g7-agents-mcp": "./src/mcp/index.ts"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.74",
"@anthropic-ai/sdk": "^0.71.2",
"@modelcontextprotocol/sdk": "^1.25.1",
"dotenv": "^16.4.0",
"pino": "^9.0.0",
"pino-pretty": "^11.0.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
},
"engines": {
"node": ">=18"
},
"author": "Gnuboard7 Team",
"license": "MIT"
}
@@ -0,0 +1,80 @@
/**
* 에이전트 정의
* 각 에이전트의 역할, 시스템 프롬프트, 도구 권한 정의
*/
import type { AgentDefinition } from '@anthropic-ai/claude-agent-sdk';
import {
BACKEND_PROMPT,
FRONTEND_PROMPT,
LAYOUT_PROMPT,
TEMPLATE_PROMPT,
REVIEWER_PROMPT,
} from '../prompts/index.js';
/**
* 백엔드 개발자 에이전트
* Service-Repository 패턴, FormRequest, 훅 시스템 담당
*/
export const backendAgent: AgentDefinition = {
description: '백엔드 개발: Service-Repository 패턴, FormRequest, Controller, 훅 시스템, Migration',
prompt: BACKEND_PROMPT,
tools: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash'],
model: 'sonnet',
};
/**
* 프론트엔드 개발자 에이전트
* TSX 컴포넌트, 상태 관리, G7Core API 담당
*/
export const frontendAgent: AgentDefinition = {
description: '프론트엔드 개발: TSX 컴포넌트, 상태 관리, G7Core API, 다크 모드',
prompt: FRONTEND_PROMPT,
tools: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash'],
model: 'sonnet',
};
/**
* 레이아웃 개발자 에이전트
* 레이아웃 JSON, 데이터 바인딩, 반응형 담당
*/
export const layoutAgent: AgentDefinition = {
description: '레이아웃 개발: 레이아웃 JSON 스키마, 데이터 바인딩, 반응형, 상속/슬롯',
prompt: LAYOUT_PROMPT,
tools: ['Read', 'Write', 'Edit', 'Glob', 'Grep'],
model: 'sonnet',
};
/**
* 템플릿 개발자 에이전트
* 템플릿 구조, 빌드, 컴포넌트 등록 담당
*/
export const templateAgent: AgentDefinition = {
description: '템플릿 개발: 템플릿 구조, Vite 빌드, 컴포넌트 등록, 다국어 파일',
prompt: TEMPLATE_PROMPT,
tools: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash'],
model: 'sonnet',
};
/**
* 검수자 에이전트
* 규정 준수, 테스트, 문서화 검증 담당
*/
export const reviewerAgent: AgentDefinition = {
description: '코드 검수: 규정 준수 확인, 테스트 통과, 문서화 검증',
prompt: REVIEWER_PROMPT,
tools: ['Read', 'Glob', 'Grep', 'Bash'],
model: 'sonnet',
};
/**
* 모든 에이전트 정의
*/
export const agentDefinitions: Record<string, AgentDefinition> = {
backend: backendAgent,
frontend: frontendAgent,
layout: layoutAgent,
template: templateAgent,
reviewer: reviewerAgent,
};
export default agentDefinitions;
+11
View File
@@ -0,0 +1,11 @@
/**
* 에이전트 모듈 export
*/
export {
agentDefinitions,
backendAgent,
frontendAgent,
layoutAgent,
templateAgent,
reviewerAgent,
} from './definitions.js';
+252
View File
@@ -0,0 +1,252 @@
/**
* CLI 인터페이스
* 인터랙티브 모드 및 단일 명령어 모드 지원
*/
import * as readline from 'readline';
import { Coordinator } from './coordinator/Coordinator.js';
import {
featureDevelopmentWorkflow,
bugfixWorkflow,
prReviewWorkflow,
} from './workflows/index.js';
import { logger } from './utils/logger.js';
import type { CliOptions, WorkflowResult, WorkflowType } from './types/index.js';
const PROJECT_ROOT = process.env.G7_PROJECT_ROOT || '.';
/**
* CLI 실행
*/
export async function runCli(options: CliOptions): Promise<void> {
if (options.mode === 'interactive') {
await runInteractiveMode();
} else {
if (!options.prompt) {
console.error('Error: prompt is required for single mode');
process.exit(1);
}
await runSingleMode(options.prompt, options.workflow);
}
}
/**
* 인터랙티브 모드
*/
async function runInteractiveMode(): Promise<void> {
console.log(`
╔════════════════════════════════════════════════════════════╗
║ 그누보드7 Multi-Agent System v1.0.0 ║
║ ║
║ 사용 가능한 명령어: ║
║ /feature <설명> - 기능 개발 워크플로우 ║
║ /bugfix <설명> - 버그 수정 워크플로우 ║
║ /review <PR#> - PR 검수 워크플로우 ║
║ /help - 도움말 ║
║ /exit - 종료 ║
║ ║
║ 또는 자유롭게 요청을 입력하세요. ║
╚════════════════════════════════════════════════════════════╝
`);
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const coordinator = new Coordinator({ cwd: PROJECT_ROOT });
const promptUser = () => {
rl.question('\n🤖 > ', async (input) => {
const trimmedInput = input.trim();
if (!trimmedInput) {
promptUser();
return;
}
// 종료 명령
if (trimmedInput === '/exit' || trimmedInput === 'exit') {
console.log('\n👋 안녕히 가세요!');
rl.close();
return;
}
// 도움말
if (trimmedInput === '/help') {
showHelp();
promptUser();
return;
}
try {
let result: WorkflowResult;
// 워크플로우 명령어
if (trimmedInput.startsWith('/feature ')) {
const description = trimmedInput.substring(9);
console.log('\n🚀 기능 개발 워크플로우를 시작합니다...\n');
result = await featureDevelopmentWorkflow(description, { cwd: PROJECT_ROOT });
} else if (trimmedInput.startsWith('/bugfix ')) {
const description = trimmedInput.substring(8);
console.log('\n🔧 버그 수정 워크플로우를 시작합니다...\n');
result = await bugfixWorkflow(description, { cwd: PROJECT_ROOT });
} else if (trimmedInput.startsWith('/review ')) {
const prId = trimmedInput.substring(8);
console.log('\n🔍 PR 검수 워크플로우를 시작합니다...\n');
result = await prReviewWorkflow(prId, { cwd: PROJECT_ROOT });
} else {
// 자유 형식 요청
console.log('\n🤔 요청을 처리하고 있습니다...\n');
result = await coordinator.orchestrate(trimmedInput);
}
// 결과 출력
printResult(result);
} catch (error: any) {
console.error(`\n❌ 오류: ${error.message}`);
}
promptUser();
});
};
promptUser();
}
/**
* 단일 명령어 모드
*/
async function runSingleMode(
prompt: string,
workflow?: WorkflowType
): Promise<void> {
console.log(`\n🤖 그누보드7 Multi-Agent System\n`);
let result: WorkflowResult;
try {
if (workflow === 'feature') {
console.log('🚀 기능 개발 워크플로우 실행\n');
result = await featureDevelopmentWorkflow(prompt, { cwd: PROJECT_ROOT });
} else if (workflow === 'bugfix') {
console.log('🔧 버그 수정 워크플로우 실행\n');
result = await bugfixWorkflow(prompt, { cwd: PROJECT_ROOT });
} else if (workflow === 'pr-review') {
console.log('🔍 PR 검수 워크플로우 실행\n');
result = await prReviewWorkflow(prompt, { cwd: PROJECT_ROOT });
} else {
console.log('🤔 요청 처리 중...\n');
const coordinator = new Coordinator({ cwd: PROJECT_ROOT });
result = await coordinator.orchestrate(prompt);
}
printResult(result);
// 종료 코드 설정
process.exit(result.success ? 0 : 1);
} catch (error: any) {
console.error(`\n❌ 오류: ${error.message}`);
process.exit(1);
}
}
/**
* 결과 출력
*/
function printResult(result: WorkflowResult): void {
console.log('\n' + '='.repeat(60));
if (result.success) {
console.log('✅ 작업 완료');
} else {
console.log('❌ 작업 실패');
}
if (result.output) {
console.log('\n📝 결과:');
console.log(result.output);
}
if (result.errors && result.errors.length > 0) {
console.log('\n⚠️ 오류:');
result.errors.forEach(e => console.log(` - ${e}`));
}
if (result.steps.length > 0) {
console.log('\n📊 실행 단계:');
result.steps.forEach(step => {
const icon = step.status === 'completed' ? '✓' : step.status === 'failed' ? '✗' : '○';
console.log(` ${icon} [${step.agent}] ${step.task}`);
});
}
console.log(`\n💰 비용: $${result.totalCost.toFixed(4)}`);
console.log('='.repeat(60));
}
/**
* 도움말 출력
*/
function showHelp(): void {
console.log(`
📖 그누보드7 Multi-Agent System 도움말
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔧 워크플로우 명령어:
/feature <설명>
새로운 기능을 개발합니다.
예: /feature 상품 할인 기능 추가
/bugfix <설명>
버그를 수정합니다. TDD 방식으로 진행됩니다.
예: /bugfix Form 저장 시 데이터가 사라지는 문제
/review <PR번호 또는 설명>
PR을 검수합니다.
예: /review #123
예: /review 현재 브랜치 변경사항
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🤖 에이전트:
• backend - 백엔드 개발 (Service, Repository, Controller)
• frontend - 프론트엔드 개발 (TSX 컴포넌트)
• layout - 레이아웃 개발 (JSON 스키마)
• template - 템플릿 개발 (빌드, 등록)
• reviewer - 코드 검수 (규정, 테스트)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📚 자유 형식 요청:
워크플로우 명령어 외에도 자유롭게 요청할 수 있습니다.
코디네이터가 적절한 에이전트에게 작업을 분배합니다.
예:
- "ProductService의 할인 로직을 설명해줘"
- "사용자 목록 페이지의 레이아웃 JSON을 검토해줘"
- "DataGrid 컴포넌트에 정렬 기능을 추가해줘"
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⌨️ 기타 명령어:
/help - 이 도움말 표시
/exit - 종료
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
`);
}
export default runCli;
@@ -0,0 +1,246 @@
/**
* 코디네이터
* 작업 분배 및 에이전트 오케스트레이션 담당
*/
import { query, type Options, type SDKMessage } from '@anthropic-ai/claude-agent-sdk';
import { COORDINATOR_PROMPT } from '../prompts/index.js';
import { agentDefinitions } from '../agents/index.js';
import { createG7McpServer } from '../mcp/g7-tools-server.js';
import { logger } from '../utils/logger.js';
import type { CoordinatorConfig, WorkflowResult, WorkflowStep } from '../types/index.js';
export class Coordinator {
private cwd: string;
private model: 'opus' | 'sonnet';
private mcpServer: ReturnType<typeof createG7McpServer>;
constructor(config: CoordinatorConfig = {}) {
this.cwd = config.cwd || process.env.G7_PROJECT_ROOT || '.';
this.model = config.model || 'sonnet';
this.mcpServer = createG7McpServer(this.cwd);
logger.info(`Coordinator initialized with cwd: ${this.cwd}`);
}
/**
* 사용자 요청을 분석하고 적절한 에이전트에게 작업 분배
*/
async orchestrate(userRequest: string): Promise<WorkflowResult> {
logger.info('Starting orchestration');
logger.debug(`User request: ${userRequest}`);
const result: WorkflowResult = {
success: false,
steps: [],
totalCost: 0,
};
const options: Options = {
cwd: this.cwd,
model: this.model,
systemPrompt: COORDINATOR_PROMPT,
tools: { type: 'preset', preset: 'claude_code' },
allowedTools: [
'Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'Task',
// MCP 도구
'validate-code', 'validate-frontend', 'run-tests',
'run-test-file', 'search-docs', 'read-doc', 'list-docs',
],
agents: agentDefinitions,
mcpServers: {
'g7-tools': this.mcpServer,
},
settingSources: ['project'], // AGENTS.md 로드
permissionMode: 'acceptEdits',
maxTurns: 50,
hooks: this.getHooks(result),
};
try {
const queryResult = query({
prompt: userRequest,
options,
});
for await (const message of queryResult) {
this.processMessage(message, result);
}
// 최종 결과 처리
const finalResult = result.steps.find(s => s.status === 'completed' && s.agent === 'coordinator');
if (finalResult?.result) {
result.output = finalResult.result.output;
}
result.success = result.steps.every(s => s.status === 'completed');
} catch (error: any) {
logger.error(`Orchestration failed: ${error.message}`);
result.success = false;
result.errors = [error.message];
}
return result;
}
/**
* 특정 에이전트에게 직접 작업 요청
*/
async delegateToAgent(
agentType: keyof typeof agentDefinitions,
task: string
): Promise<WorkflowResult> {
logger.info(`Delegating to ${agentType}: ${task}`);
const agent = agentDefinitions[agentType];
if (!agent) {
return {
success: false,
steps: [],
totalCost: 0,
errors: [`Unknown agent: ${agentType}`],
};
}
const result: WorkflowResult = {
success: false,
steps: [],
totalCost: 0,
};
const options: Options = {
cwd: this.cwd,
model: agent.model || 'sonnet',
systemPrompt: agent.prompt,
tools: { type: 'preset', preset: 'claude_code' },
allowedTools: [
...(agent.tools || []),
'validate-code', 'validate-frontend', 'run-tests',
'search-docs', 'read-doc',
],
mcpServers: {
'g7-tools': this.mcpServer,
},
settingSources: ['project'],
permissionMode: 'acceptEdits',
maxTurns: 30,
hooks: this.getHooks(result),
};
try {
const queryResult = query({
prompt: task,
options,
});
for await (const message of queryResult) {
this.processMessage(message, result);
}
result.success = !result.errors?.length;
} catch (error: any) {
logger.error(`Agent ${agentType} failed: ${error.message}`);
result.success = false;
result.errors = [error.message];
}
return result;
}
/**
* 훅 설정
*/
private getHooks(result: WorkflowResult): Options['hooks'] {
return {
SubagentStart: [{
hooks: [async (input) => {
// SubagentStartHookInput 타입 처리
if (input.hook_event_name === 'SubagentStart') {
const step: WorkflowStep = {
agent: input.agent_type as any,
task: 'Subagent task',
status: 'running',
};
result.steps.push(step);
logger.info(`Subagent started: ${input.agent_type}`);
}
return { continue: true };
}],
}],
SubagentStop: [{
hooks: [async () => {
const runningStep = result.steps.find(s => s.status === 'running');
if (runningStep) {
runningStep.status = 'completed';
}
logger.info('Subagent completed');
return { continue: true };
}],
}],
PreToolUse: [{
hooks: [async (input) => {
// PreToolUseHookInput 타입 처리
if (input.hook_event_name === 'PreToolUse') {
logger.debug(`Tool use: ${input.tool_name}`);
}
return { continue: true };
}],
}],
PostToolUse: [{
hooks: [async (input) => {
// PostToolUseHookInput 타입 처리
if (input.hook_event_name === 'PostToolUse') {
// 테스트 실패 시 로깅
if (input.tool_name === 'run-tests') {
const output = (input.tool_response as any)?.content?.[0]?.text || '';
if (output.includes('failed')) {
logger.warn('Some tests failed');
}
}
}
return { continue: true };
}],
}],
};
}
/**
* 메시지 처리
*/
private processMessage(message: SDKMessage, result: WorkflowResult): void {
if (message.type === 'result') {
if (message.subtype === 'success') {
result.totalCost = message.total_cost_usd || 0;
result.output = message.result;
// 코디네이터 결과 기록
result.steps.push({
agent: 'coordinator' as any,
task: 'Orchestration',
status: 'completed',
result: {
success: true,
agent: 'coordinator',
task: 'Orchestration',
output: message.result,
cost: message.total_cost_usd,
},
});
} else if (
message.subtype === 'error_during_execution' ||
message.subtype === 'error_max_turns' ||
message.subtype === 'error_max_budget_usd' ||
message.subtype === 'error_max_structured_output_retries'
) {
result.errors = message.errors || ['Unknown error'];
}
}
}
}
+85
View File
@@ -0,0 +1,85 @@
/**
* 그누보드7 Multi-Agent System
* AI Agent SDK 기반 멀티에이전트 협업 시스템
*/
import 'dotenv/config';
import { runCli } from './cli.js';
import type { CliOptions, WorkflowType } from './types/index.js';
async function main(): Promise<void> {
const args = process.argv.slice(2);
// 인수 파싱
const options = parseArgs(args);
// CLI 실행
await runCli(options);
}
/**
* 명령줄 인수 파싱
*/
function parseArgs(args: string[]): CliOptions {
// 기본값
const options: CliOptions = {
mode: 'interactive',
};
if (args.length === 0) {
return options;
}
const mode = args[0];
// 모드 확인
if (mode === 'interactive') {
options.mode = 'interactive';
return options;
}
if (mode === 'single') {
options.mode = 'single';
// 워크플로우 옵션 확인
const workflowIndex = args.indexOf('--workflow');
if (workflowIndex !== -1 && args[workflowIndex + 1]) {
options.workflow = args[workflowIndex + 1] as WorkflowType;
// 워크플로우 옵션 제거
args.splice(workflowIndex, 2);
}
// 나머지를 프롬프트로 사용
options.prompt = args.slice(1).join(' ');
return options;
}
// 단축 워크플로우 명령어
if (mode === 'feature' || mode === 'bugfix' || mode === 'pr-review') {
options.mode = 'single';
options.workflow = mode as WorkflowType;
options.prompt = args.slice(1).join(' ');
return options;
}
// 기본: 단일 모드로 전체 인수를 프롬프트로 사용
options.mode = 'single';
options.prompt = args.join(' ');
return options;
}
// 에러 핸들링
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection:', reason);
process.exit(1);
});
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
process.exit(1);
});
// 실행
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});
+700
View File
@@ -0,0 +1,700 @@
#!/usr/bin/env node
/**
* 인터랙티브 CLI - 단계별 승인 기능
* 각 에이전트 실행 전 사용자 확인을 받고 진행
*/
import 'dotenv/config';
import * as readline from 'readline';
import Anthropic from '@anthropic-ai/sdk';
import { agentDefinitions } from './agents/definitions.js';
import type { WorkflowResult, WorkflowStep, AgentType, AgentResult } from './types/index.js';
const PROJECT_ROOT = process.env.G7_PROJECT_ROOT || '.';
// 색상 코드
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
dim: '\x1b[2m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
white: '\x1b[37m',
bgBlue: '\x1b[44m',
bgGreen: '\x1b[42m',
bgYellow: '\x1b[43m',
bgRed: '\x1b[41m',
};
// 에이전트별 색상
const agentColors: Record<string, string> = {
backend: colors.blue,
frontend: colors.magenta,
layout: colors.cyan,
template: colors.yellow,
reviewer: colors.green,
coordinator: colors.white,
};
/**
* 인터랙티브 세션 클래스
*/
class InteractiveSession {
private rl: readline.Interface;
private client: Anthropic;
private result: WorkflowResult;
private currentStep: number = 0;
constructor() {
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
this.client = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
this.result = {
success: false,
steps: [],
totalCost: 0,
};
}
/**
* 세션 시작
*/
async start(): Promise<void> {
this.printBanner();
this.promptUser();
}
/**
* 배너 출력
*/
private printBanner(): void {
console.log(`
${colors.bgBlue}${colors.bright} ${colors.reset}
${colors.bgBlue}${colors.bright} 그누보드7 Multi-Agent Interactive System v1.0.0 ${colors.reset}
${colors.bgBlue}${colors.bright} ${colors.reset}
${colors.cyan}사용 가능한 명령어:${colors.reset}
${colors.yellow}/feature${colors.reset} <설명> - 기능 개발 (단계별 승인)
${colors.yellow}/bugfix${colors.reset} <설명> - 버그 수정 (단계별 승인)
${colors.yellow}/quick${colors.reset} <요청> - 빠른 실행 (승인 없이)
${colors.yellow}/agents${colors.reset} - 에이전트 목록
${colors.yellow}/help${colors.reset} - 도움말
${colors.yellow}/exit${colors.reset} - 종료
${colors.dim}또는 자유롭게 요청을 입력하세요. (단계별 승인 모드)${colors.reset}
`);
}
/**
* 사용자 입력 대기
*/
private promptUser(): void {
this.rl.question(`\n${colors.green}🤖 >${colors.reset} `, async (input) => {
const trimmedInput = input.trim();
if (!trimmedInput) {
this.promptUser();
return;
}
await this.handleInput(trimmedInput);
});
}
/**
* 입력 처리
*/
private async handleInput(input: string): Promise<void> {
// 종료
if (input === '/exit' || input === 'exit') {
console.log(`\n${colors.cyan}👋 안녕히 가세요!${colors.reset}`);
this.rl.close();
return;
}
// 도움말
if (input === '/help') {
this.showHelp();
this.promptUser();
return;
}
// 에이전트 목록
if (input === '/agents') {
this.showAgents();
this.promptUser();
return;
}
// 기능 개발
if (input.startsWith('/feature ')) {
const description = input.substring(9);
await this.runFeatureWorkflow(description);
this.promptUser();
return;
}
// 버그 수정
if (input.startsWith('/bugfix ')) {
const description = input.substring(8);
await this.runBugfixWorkflow(description);
this.promptUser();
return;
}
// 빠른 실행 (승인 없이)
if (input.startsWith('/quick ')) {
const prompt = input.substring(7);
await this.runQuickMode(prompt);
this.promptUser();
return;
}
// 자유 형식 (단계별 승인)
await this.runWithApproval(input);
this.promptUser();
}
/**
* 기능 개발 워크플로우 (단계별 승인)
*/
private async runFeatureWorkflow(description: string): Promise<void> {
console.log(`\n${colors.bgGreen}${colors.bright} 기능 개발 워크플로우 ${colors.reset}`);
console.log(`${colors.dim}설명: ${description}${colors.reset}\n`);
this.result = { success: false, steps: [], totalCost: 0 };
// 워크플로우 단계 정의
const workflow: Array<{ agent: AgentType; task: string }> = [
{ agent: 'backend', task: `백엔드 구현: ${description}` },
{ agent: 'layout', task: `레이아웃 JSON 작성: ${description}` },
{ agent: 'frontend', task: `프론트엔드 컴포넌트 개발 (필요시): ${description}` },
{ agent: 'template', task: `템플릿 빌드 및 컴포넌트 등록: ${description}` },
{ agent: 'reviewer', task: `코드 검수: ${description}` },
];
// 워크플로우 개요 표시
this.printWorkflowPlan(workflow);
// 각 단계 실행
for (let i = 0; i < workflow.length; i++) {
const step = workflow[i];
const approved = await this.requestApproval(i + 1, workflow.length, step.agent, step.task);
if (!approved) {
console.log(`\n${colors.yellow}⏭️ ${step.agent} 단계를 건너뜁니다.${colors.reset}`);
this.result.steps.push({
agent: step.agent,
task: step.task,
status: 'skipped',
});
continue;
}
await this.executeAgent(step.agent, step.task);
}
this.printFinalResult();
}
/**
* 버그 수정 워크플로우 (단계별 승인)
*/
private async runBugfixWorkflow(description: string): Promise<void> {
console.log(`\n${colors.bgYellow}${colors.bright} 버그 수정 워크플로우 ${colors.reset}`);
console.log(`${colors.dim}설명: ${description}${colors.reset}\n`);
this.result = { success: false, steps: [], totalCost: 0 };
// 1단계: 분석
console.log(`${colors.cyan}📋 버그 영향 영역을 분석합니다...${colors.reset}\n`);
const analysisPrompt = `
다음 버그 설명을 분석하고, 영향 받는 영역을 판단해주세요:
버그 설명: ${description}
그누보드7 프로젝트의 영역:
- backend: Service, Repository, Controller, FormRequest 등 PHP 코드
- frontend: TSX 컴포넌트, React 코드
- layout: 레이아웃 JSON 파일
- template: 템플릿 설정, 빌드
어떤 영역이 영향 받는지 알려주세요. 답변 형식:
영향 영역: [backend/frontend/layout/template] (쉼표로 구분)
분석 근거: [간단한 설명]
`;
const analysis = await this.callClaude(analysisPrompt);
console.log(`${colors.dim}${analysis}${colors.reset}\n`);
// 영역 추출 (간단한 파싱)
const affectedAreas: AgentType[] = [];
const lowerAnalysis = analysis.toLowerCase();
if (lowerAnalysis.includes('backend')) affectedAreas.push('backend');
if (lowerAnalysis.includes('frontend')) affectedAreas.push('frontend');
if (lowerAnalysis.includes('layout')) affectedAreas.push('layout');
if (lowerAnalysis.includes('template')) affectedAreas.push('template');
if (affectedAreas.length === 0) {
affectedAreas.push('backend'); // 기본값
}
// 워크플로우 구성
const workflow: Array<{ agent: AgentType; task: string }> = [];
// 트러블슈팅 가이드 참조 단계
workflow.push({
agent: 'reviewer',
task: `트러블슈팅 가이드에서 유사 사례 확인: ${description}`,
});
// 영향 받는 영역별 수정
for (const area of affectedAreas) {
workflow.push({
agent: area,
task: `버그 수정 (TDD 방식): ${description}`,
});
}
// 최종 검수
workflow.push({
agent: 'reviewer',
task: `테스트 통과 확인 및 최종 검수: ${description}`,
});
// 워크플로우 개요 표시
this.printWorkflowPlan(workflow);
// 각 단계 실행
for (let i = 0; i < workflow.length; i++) {
const step = workflow[i];
const approved = await this.requestApproval(i + 1, workflow.length, step.agent, step.task);
if (!approved) {
console.log(`\n${colors.yellow}⏭️ ${step.agent} 단계를 건너뜁니다.${colors.reset}`);
this.result.steps.push({
agent: step.agent,
task: step.task,
status: 'skipped',
});
continue;
}
await this.executeAgent(step.agent, step.task);
}
this.printFinalResult();
}
/**
* 단계별 승인 모드로 자유 형식 요청 실행
*/
private async runWithApproval(prompt: string): Promise<void> {
console.log(`\n${colors.bgBlue}${colors.bright} 요청 처리 ${colors.reset}`);
console.log(`${colors.dim}${prompt}${colors.reset}\n`);
this.result = { success: false, steps: [], totalCost: 0 };
// 요청 분석
console.log(`${colors.cyan}📋 요청을 분석하고 워크플로우를 계획합니다...${colors.reset}\n`);
const planPrompt = `
다음 요청을 분석하고 실행 계획을 세워주세요:
요청: ${prompt}
그누보드7 프로젝트의 에이전트:
- backend: 백엔드 개발 (Service, Repository, Controller, FormRequest, Migration)
- frontend: 프론트엔드 개발 (TSX 컴포넌트, 상태 관리)
- layout: 레이아웃 개발 (JSON 스키마, 데이터 바인딩)
- template: 템플릿 개발 (빌드, 컴포넌트 등록)
- reviewer: 코드 검수 (규정 준수, 테스트)
어떤 에이전트가 어떤 순서로 작업해야 하는지 계획해주세요.
답변 형식 (JSON):
{
"steps": [
{ "agent": "backend", "task": "구체적인 작업 내용" },
{ "agent": "layout", "task": "구체적인 작업 내용" }
]
}
`;
const planResponse = await this.callClaude(planPrompt);
// JSON 파싱 시도
let workflow: Array<{ agent: AgentType; task: string }> = [];
try {
const jsonMatch = planResponse.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const plan = JSON.parse(jsonMatch[0]);
workflow = plan.steps || [];
}
} catch {
// 파싱 실패 시 기본 워크플로우
console.log(`${colors.dim}${planResponse}${colors.reset}\n`);
workflow = [{ agent: 'backend', task: prompt }];
}
if (workflow.length === 0) {
workflow = [{ agent: 'backend', task: prompt }];
}
// 워크플로우 개요 표시
this.printWorkflowPlan(workflow);
// 각 단계 실행
for (let i = 0; i < workflow.length; i++) {
const step = workflow[i];
const validAgent = this.validateAgent(step.agent);
const approved = await this.requestApproval(i + 1, workflow.length, validAgent, step.task);
if (!approved) {
console.log(`\n${colors.yellow}⏭️ ${validAgent} 단계를 건너뜁니다.${colors.reset}`);
this.result.steps.push({
agent: validAgent,
task: step.task,
status: 'skipped',
});
continue;
}
await this.executeAgent(validAgent, step.task);
}
this.printFinalResult();
}
/**
* 빠른 모드 (승인 없이)
*/
private async runQuickMode(prompt: string): Promise<void> {
console.log(`\n${colors.bgBlue}${colors.bright} 빠른 실행 ${colors.reset}`);
console.log(`${colors.dim}승인 없이 자동으로 실행됩니다.${colors.reset}\n`);
this.result = { success: false, steps: [], totalCost: 0 };
// 단일 에이전트로 빠른 실행
await this.executeAgent('backend', prompt);
this.printFinalResult();
}
/**
* 워크플로우 계획 출력
*/
private printWorkflowPlan(workflow: Array<{ agent: AgentType; task: string }>): void {
console.log(`${colors.bright}📊 실행 계획:${colors.reset}\n`);
workflow.forEach((step, index) => {
const agentColor = agentColors[step.agent] || colors.white;
console.log(` ${colors.dim}${index + 1}.${colors.reset} ${agentColor}[${step.agent}]${colors.reset} ${step.task}`);
});
console.log('');
}
/**
* 승인 요청
*/
private async requestApproval(
current: number,
total: number,
agent: AgentType,
task: string
): Promise<boolean> {
const agentColor = agentColors[agent] || colors.white;
console.log(`\n${colors.bright}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}`);
console.log(`${colors.bright}단계 ${current}/${total}${colors.reset}`);
console.log(`${colors.bright}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}`);
console.log(`\n${agentColor}🤖 에이전트: ${agent}${colors.reset}`);
console.log(`${colors.dim}📋 작업: ${task}${colors.reset}\n`);
// 에이전트 설명 표시
const agentDef = agentDefinitions[agent];
if (agentDef) {
console.log(`${colors.dim}ℹ️ ${agentDef.description}${colors.reset}\n`);
}
return new Promise((resolve) => {
this.rl.question(
`${colors.yellow}실행하시겠습니까? ${colors.reset}[${colors.green}Y${colors.reset}es/${colors.red}N${colors.reset}o/${colors.cyan}S${colors.reset}kip all] `,
(answer) => {
const lowerAnswer = answer.toLowerCase().trim();
if (lowerAnswer === 'n' || lowerAnswer === 'no') {
resolve(false);
} else if (lowerAnswer === 's' || lowerAnswer === 'skip') {
// Skip all은 모든 후속 단계를 건너뜀
resolve(false);
} else {
resolve(true);
}
}
);
});
}
/**
* 에이전트 실행
*/
private async executeAgent(agent: AgentType, task: string): Promise<void> {
const agentColor = agentColors[agent] || colors.white;
const agentDef = agentDefinitions[agent];
console.log(`\n${agentColor}▶ ${agent} 에이전트 실행 중...${colors.reset}\n`);
const step: WorkflowStep = {
agent,
task,
status: 'running',
};
this.result.steps.push(step);
try {
// 에이전트 시스템 프롬프트 + 작업
const systemPrompt = agentDef?.prompt || '';
const fullPrompt = `
${systemPrompt}
## 현재 작업
${task}
## 프로젝트 경로
${PROJECT_ROOT}
## 지시사항
1. 위 작업을 수행하세요.
2. 필요한 파일을 읽고 수정하세요.
3. 작업 완료 후 결과를 요약해주세요.
`;
const response = await this.callClaude(fullPrompt);
step.status = 'completed';
step.result = {
success: true,
agent: agent,
task: task,
output: response,
};
// 결과 출력 (요약)
console.log(`${colors.dim}─────────────────────────────────────────────${colors.reset}`);
const summary = response.length > 500 ? response.substring(0, 500) + '...' : response;
console.log(`${colors.dim}${summary}${colors.reset}`);
console.log(`${colors.dim}─────────────────────────────────────────────${colors.reset}`);
console.log(`\n${colors.green}✅ ${agent} 완료${colors.reset}`);
} catch (error: any) {
step.status = 'failed';
step.result = {
success: false,
agent: agent,
task: task,
output: '',
errors: [error.message],
};
console.log(`\n${colors.red}❌ ${agent} 실패: ${error.message}${colors.reset}`);
this.result.errors = this.result.errors || [];
this.result.errors.push(`[${agent}] ${error.message}`);
}
}
/**
* AI API 호출
*/
private async callClaude(prompt: string): Promise<string> {
const response = await this.client.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 4096,
messages: [
{ role: 'user', content: prompt },
],
});
// 비용 계산 (대략적)
const inputTokens = response.usage?.input_tokens || 0;
const outputTokens = response.usage?.output_tokens || 0;
const cost = (inputTokens * 0.003 + outputTokens * 0.015) / 1000;
this.result.totalCost += cost;
const textContent = response.content.find((c: { type: string }) => c.type === 'text') as { type: 'text'; text: string } | undefined;
return textContent?.text || '';
}
/**
* 에이전트 타입 검증
*/
private validateAgent(agent: string): AgentType {
const validAgents: AgentType[] = ['backend', 'frontend', 'layout', 'template', 'reviewer'];
if (validAgents.includes(agent as AgentType)) {
return agent as AgentType;
}
return 'backend'; // 기본값
}
/**
* 최종 결과 출력
*/
private printFinalResult(): void {
const completedSteps = this.result.steps.filter(s => s.status === 'completed').length;
const totalSteps = this.result.steps.length;
this.result.success = completedSteps > 0 && !this.result.errors?.length;
console.log(`\n${colors.bright}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}`);
console.log(`${colors.bright} 최종 결과 ${colors.reset}`);
console.log(`${colors.bright}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}\n`);
if (this.result.success) {
console.log(`${colors.green}✅ 작업 완료${colors.reset}\n`);
} else {
console.log(`${colors.red}❌ 작업 실패${colors.reset}\n`);
}
// 단계별 결과
console.log(`${colors.bright}📊 실행 단계 (${completedSteps}/${totalSteps}):${colors.reset}\n`);
this.result.steps.forEach((step, index) => {
const agentColor = agentColors[step.agent] || colors.white;
let statusIcon: string;
let statusColor: string;
switch (step.status) {
case 'completed':
statusIcon = '✅';
statusColor = colors.green;
break;
case 'failed':
statusIcon = '❌';
statusColor = colors.red;
break;
case 'skipped':
statusIcon = '⏭️';
statusColor = colors.yellow;
break;
default:
statusIcon = '⏳';
statusColor = colors.dim;
}
console.log(` ${statusIcon} ${agentColor}[${step.agent}]${colors.reset} ${statusColor}${step.task}${colors.reset}`);
});
// 오류
if (this.result.errors?.length) {
console.log(`\n${colors.red}⚠️ 오류:${colors.reset}`);
this.result.errors.forEach(e => {
console.log(` ${colors.red}- ${e}${colors.reset}`);
});
}
// 비용
console.log(`\n${colors.cyan}💰 총 비용: $${this.result.totalCost.toFixed(4)}${colors.reset}`);
console.log(`${colors.bright}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}`);
}
/**
* 에이전트 목록 표시
*/
private showAgents(): void {
console.log(`\n${colors.bright}🤖 사용 가능한 에이전트:${colors.reset}\n`);
Object.entries(agentDefinitions).forEach(([name, def]) => {
const agentColor = agentColors[name] || colors.white;
console.log(` ${agentColor}${name}${colors.reset}`);
console.log(` ${colors.dim}${def.description}${colors.reset}`);
console.log(` ${colors.dim}도구: ${(def.tools || []).join(', ')}${colors.reset}`);
console.log('');
});
}
/**
* 도움말 표시
*/
private showHelp(): void {
console.log(`
${colors.bright}📖 그누보드7 Multi-Agent Interactive System 도움말${colors.reset}
${colors.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}
${colors.bright}🔧 워크플로우 명령어 (단계별 승인):${colors.reset}
${colors.yellow}/feature${colors.reset} <설명>
새로운 기능을 개발합니다.
각 단계 실행 전 승인을 요청합니다.
${colors.dim}예: /feature 상품 할인 기능 추가${colors.reset}
${colors.yellow}/bugfix${colors.reset} <설명>
버그를 수정합니다. 영향 영역을 분석하고 TDD 방식으로 진행합니다.
${colors.dim}예: /bugfix Form 저장 시 데이터가 사라지는 문제${colors.reset}
${colors.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}
${colors.bright}⚡ 빠른 실행:${colors.reset}
${colors.yellow}/quick${colors.reset} <요청>
승인 없이 빠르게 실행합니다.
${colors.dim}예: /quick package.json 파일 분석해줘${colors.reset}
${colors.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}
${colors.bright}📚 자유 형식 요청:${colors.reset}
명령어 없이 자유롭게 입력하면 요청을 분석하여
워크플로우를 자동으로 구성하고 단계별 승인을 받습니다.
${colors.dim}예: "ProductService의 할인 로직을 구현해줘"${colors.reset}
${colors.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}
${colors.bright}⌨️ 승인 옵션:${colors.reset}
${colors.green}Y${colors.reset}/yes - 실행
${colors.red}N${colors.reset}/no - 건너뛰기
${colors.cyan}S${colors.reset}/skip - 이후 모든 단계 건너뛰기
${colors.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}
${colors.bright}⌨️ 기타 명령어:${colors.reset}
${colors.yellow}/agents${colors.reset} - 에이전트 목록
${colors.yellow}/help${colors.reset} - 이 도움말
${colors.yellow}/exit${colors.reset} - 종료
${colors.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}
`);
}
}
// 메인 실행
async function main() {
// API 키 확인
if (!process.env.ANTHROPIC_API_KEY) {
console.error(`${colors.red}❌ ANTHROPIC_API_KEY가 설정되지 않았습니다.${colors.reset}`);
console.error(`${colors.dim}.env 파일에 API 키를 설정해주세요.${colors.reset}`);
process.exit(1);
}
const session = new InteractiveSession();
await session.start();
}
main().catch((error) => {
console.error(`${colors.red}❌ 오류: ${error.message}${colors.reset}`);
process.exit(1);
});
@@ -0,0 +1,332 @@
/**
* G7 에이전트 MCP 서버
* AI 코딩 도구에서 멀티에이전트 시스템을 호출할 수 있는 MCP 서버
*/
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
import { z } from 'zod';
import { Coordinator } from '../coordinator/Coordinator.js';
import { featureDevelopmentWorkflow, simpleFeatureWorkflow } from '../workflows/feature.js';
import { bugfixWorkflow, stateRelatedBugfix } from '../workflows/bugfix.js';
import { prReviewWorkflow } from '../workflows/pr-review.js';
import { createWorkflowLogger } from '../utils/logger.js';
const logger = createWorkflowLogger('mcp-agents');
/**
* G7 에이전트 MCP 서버 생성
* AI 코딩 도구 대화창에서 멀티에이전트 협업 시스템을 호출할 수 있음
*/
export function createG7AgentsMcpServer(projectRoot: string) {
return createSdkMcpServer({
name: 'g7-agents',
version: '1.0.0',
tools: [
// 자동 분배 (Coordinator)
tool(
'orchestrate',
'사용자 요청을 분석하여 적절한 에이전트(backend, frontend, layout, template, reviewer)에 자동 분배합니다. 복합 작업은 여러 에이전트가 순차적으로 협업합니다.',
{
prompt: z.string().describe('사용자 요청 내용'),
},
async ({ prompt }) => {
logger.info(`Orchestrating: ${prompt}`);
try {
const coordinator = new Coordinator({ cwd: projectRoot, model: 'sonnet' });
const result = await coordinator.orchestrate(prompt);
return {
content: [{
type: 'text',
text: formatWorkflowResult(result),
}],
isError: !result.success,
};
} catch (error: any) {
logger.error(`Orchestration failed: ${error.message}`);
return {
content: [{
type: 'text',
text: `❌ 오케스트레이션 실패: ${error.message}`,
}],
isError: true,
};
}
}
),
// 기능 개발 워크플로우
tool(
'feature-workflow',
'기능 개발 워크플로우를 실행합니다. Backend → Layout → Frontend → Template → Reviewer 순서로 에이전트가 협업합니다.',
{
description: z.string().describe('개발할 기능 설명'),
skipReview: z.boolean().optional().describe('리뷰 단계 생략 여부 (기본: false)'),
},
async ({ description, skipReview }) => {
logger.info(`Feature workflow: ${description}`);
try {
const result = await featureDevelopmentWorkflow(description, {
cwd: projectRoot,
skipReview: skipReview ?? false,
});
return {
content: [{
type: 'text',
text: formatWorkflowResult(result),
}],
isError: !result.success,
};
} catch (error: any) {
logger.error(`Feature workflow failed: ${error.message}`);
return {
content: [{
type: 'text',
text: `❌ 기능 개발 워크플로우 실패: ${error.message}`,
}],
isError: true,
};
}
}
),
// 간단한 기능 개발 (단일 영역)
tool(
'simple-feature',
'단일 영역의 간단한 기능을 개발합니다. 복합 워크플로우 없이 해당 에이전트만 호출합니다.',
{
description: z.string().describe('개발할 기능 설명'),
area: z.enum(['backend', 'frontend', 'layout', 'template'])
.describe('기능이 속한 영역'),
},
async ({ description, area }) => {
logger.info(`Simple feature (${area}): ${description}`);
try {
const result = await simpleFeatureWorkflow(description, {
cwd: projectRoot,
area,
});
return {
content: [{
type: 'text',
text: formatWorkflowResult(result),
}],
isError: !result.success,
};
} catch (error: any) {
logger.error(`Simple feature failed: ${error.message}`);
return {
content: [{
type: 'text',
text: `❌ 간단한 기능 개발 실패: ${error.message}`,
}],
isError: true,
};
}
}
),
// 버그 수정 워크플로우
tool(
'bugfix-workflow',
'버그 수정 워크플로우를 실행합니다. 트러블슈팅 가이드를 참조하고 TDD 방식으로 수정합니다.',
{
description: z.string().describe('버그 설명 (증상, 재현 방법 등)'),
affectedAreas: z.array(z.enum(['backend', 'frontend', 'layout', 'template', 'unknown']))
.optional()
.describe('영향 받는 영역 (지정하지 않으면 자동 분석)'),
},
async ({ description, affectedAreas }) => {
logger.info(`Bugfix workflow: ${description}`);
try {
const result = await bugfixWorkflow(description, {
cwd: projectRoot,
affectedAreas: affectedAreas as any,
});
return {
content: [{
type: 'text',
text: formatWorkflowResult(result),
}],
isError: !result.success,
};
} catch (error: any) {
logger.error(`Bugfix workflow failed: ${error.message}`);
return {
content: [{
type: 'text',
text: `❌ 버그 수정 워크플로우 실패: ${error.message}`,
}],
isError: true,
};
}
}
),
// 상태 관련 버그 수정 (특화)
tool(
'state-bugfix',
'상태 관리 관련 버그를 수정합니다. Stale Closure, setState 병합, 타이밍 이슈 등에 특화되어 있습니다.',
{
description: z.string().describe('상태 관련 버그 설명'),
},
async ({ description }) => {
logger.info(`State-related bugfix: ${description}`);
try {
const result = await stateRelatedBugfix(description, {
cwd: projectRoot,
});
return {
content: [{
type: 'text',
text: formatWorkflowResult(result),
}],
isError: !result.success,
};
} catch (error: any) {
logger.error(`State bugfix failed: ${error.message}`);
return {
content: [{
type: 'text',
text: `❌ 상태 버그 수정 실패: ${error.message}`,
}],
isError: true,
};
}
}
),
// PR 검수 워크플로우
tool(
'pr-review',
'PR을 검수합니다. 변경된 파일 유형에 따라 적절한 에이전트가 검수합니다.',
{
prIdentifier: z.string().describe('PR 번호 또는 브랜치명'),
},
async ({ prIdentifier }) => {
logger.info(`PR review: ${prIdentifier}`);
try {
const result = await prReviewWorkflow(prIdentifier, {
cwd: projectRoot,
});
return {
content: [{
type: 'text',
text: formatWorkflowResult(result),
}],
isError: !result.success,
};
} catch (error: any) {
logger.error(`PR review failed: ${error.message}`);
return {
content: [{
type: 'text',
text: `❌ PR 검수 실패: ${error.message}`,
}],
isError: true,
};
}
}
),
// 특정 에이전트 직접 호출
tool(
'delegate',
'특정 에이전트에 직접 작업을 위임합니다. 단일 영역 작업에 적합합니다.',
{
agent: z.enum(['backend', 'frontend', 'layout', 'template', 'reviewer'])
.describe('호출할 에이전트'),
task: z.string().describe('수행할 작업 내용'),
},
async ({ agent, task }) => {
logger.info(`Delegating to ${agent}: ${task}`);
try {
const coordinator = new Coordinator({ cwd: projectRoot, model: 'sonnet' });
const result = await coordinator.delegateToAgent(agent, task);
return {
content: [{
type: 'text',
text: formatWorkflowResult(result),
}],
isError: !result.success,
};
} catch (error: any) {
logger.error(`Delegation failed: ${error.message}`);
return {
content: [{
type: 'text',
text: `❌ 에이전트 호출 실패: ${error.message}`,
}],
isError: true,
};
}
}
),
],
});
}
/**
* 워크플로우 결과 포맷팅
*/
function formatWorkflowResult(result: any): string {
const lines: string[] = [];
// 성공/실패 헤더
if (result.success) {
lines.push('## ✅ 작업 완료\n');
} else {
lines.push('## ❌ 작업 실패\n');
}
// 실행된 단계들
if (result.steps && result.steps.length > 0) {
lines.push('### 실행 단계');
for (const step of result.steps) {
const statusIcon = step.status === 'completed' ? '✅' :
step.status === 'failed' ? '❌' :
step.status === 'running' ? '🔄' : '⏳';
lines.push(`${statusIcon} [${step.agent}] ${step.task}`);
if (step.result?.filesModified && step.result.filesModified.length > 0) {
lines.push(` 수정된 파일: ${step.result.filesModified.join(', ')}`);
}
}
lines.push('');
}
// 출력 내용
if (result.output) {
lines.push('### 결과');
lines.push(result.output);
lines.push('');
}
// 에러 메시지
if (result.errors && result.errors.length > 0) {
lines.push('### 오류');
for (const error of result.errors) {
lines.push(`- ${error}`);
}
lines.push('');
}
// 비용 정보
if (result.totalCost && result.totalCost > 0) {
lines.push(`### 비용: $${result.totalCost.toFixed(4)}`);
}
return lines.join('\n');
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,234 @@
/**
* G7 MCP 도구 서버
* AI Agent SDK와 통합되는 커스텀 MCP 서버
*/
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
import { z } from 'zod';
import { validateBackendCode } from './tools/validate-code.js';
import { validateFrontendCode } from './tools/validate-frontend.js';
import { runTests, runTestFile } from './tools/run-tests.js';
import { searchDocs, readDoc, listDocs } from './tools/search-docs.js';
/**
* G7 프로젝트 전용 MCP 서버 생성
*/
export function createG7McpServer(projectRoot: string) {
return createSdkMcpServer({
name: 'g7-tools',
version: '1.0.0',
tools: [
// 백엔드 코드 검증
tool(
'validate-code',
'Service, FormRequest, Repository, Listener, Controller 파일의 G7 규정 준수 여부를 검증합니다',
{
filePath: z.string().describe('검증할 파일 경로 (프로젝트 루트 기준 상대 경로)'),
},
async ({ filePath }) => {
const result = validateBackendCode(projectRoot, filePath);
return {
content: [{
type: 'text',
text: formatValidationResult(result),
}],
};
}
),
// 프론트엔드/레이아웃 검증
tool(
'validate-frontend',
'레이아웃 JSON 또는 TSX 컴포넌트의 G7 규정 준수 여부를 검증합니다',
{
filePath: z.string().describe('검증할 파일 경로 (프로젝트 루트 기준 상대 경로)'),
},
async ({ filePath }) => {
const result = validateFrontendCode(projectRoot, filePath);
return {
content: [{
type: 'text',
text: formatValidationResult(result),
}],
};
}
),
// 테스트 실행
tool(
'run-tests',
'백엔드(PHPUnit) 또는 프론트엔드(Vitest) 테스트를 실행합니다',
{
type: z.enum(['backend', 'frontend', 'all']).describe('테스트 타입'),
filter: z.string().optional().describe('테스트 필터 (클래스명 또는 테스트 이름)'),
template: z.string().optional().describe('프론트엔드 테스트 시 템플릿 이름'),
},
async ({ type, filter, template }) => {
const result = runTests(projectRoot, { type, filter, template });
return {
content: [{
type: 'text',
text: result.success
? `✅ ${result.message}\n\n${result.data}`
: `❌ ${result.message}\n\n${result.data}`,
}],
isError: !result.success,
};
}
),
// 특정 테스트 파일 실행
tool(
'run-test-file',
'특정 테스트 파일을 실행합니다',
{
testFilePath: z.string().describe('테스트 파일 경로'),
},
async ({ testFilePath }) => {
const result = runTestFile(projectRoot, testFilePath);
return {
content: [{
type: 'text',
text: result.success
? `✅ ${result.message}\n\n${result.data}`
: `❌ ${result.message}\n\n${result.data}`,
}],
isError: !result.success,
};
}
),
// 규정 문서 검색
tool(
'search-docs',
'G7 규정 문서(docs/)에서 관련 정보를 검색합니다',
{
query: z.string().describe('검색 키워드'),
category: z.enum(['backend', 'frontend', 'extension', 'database', 'testing', 'all'])
.optional()
.describe('검색할 카테고리'),
maxResults: z.number().optional().describe('최대 결과 수 (기본: 5)'),
},
async ({ query, category, maxResults }) => {
const result = searchDocs(projectRoot, { query, category, maxResults });
if (!result.success || !Array.isArray(result.data) || result.data.length === 0) {
return {
content: [{
type: 'text',
text: '검색 결과가 없습니다',
}],
};
}
const formatted = result.data.map((doc: any) =>
`## ${doc.file}\n**${doc.title}**\n\`\`\`\n${doc.snippet}\n\`\`\``
).join('\n\n');
return {
content: [{
type: 'text',
text: `${result.message}\n\n${formatted}`,
}],
};
}
),
// 규정 문서 읽기
tool(
'read-doc',
'특정 G7 규정 문서의 전체 내용을 읽습니다',
{
docPath: z.string().describe('문서 경로 (예: backend/service-repository.md)'),
},
async ({ docPath }) => {
const result = readDoc(projectRoot, docPath);
return {
content: [{
type: 'text',
text: result.success
? result.data as string
: `❌ ${result.message}`,
}],
isError: !result.success,
};
}
),
// 규정 문서 목록
tool(
'list-docs',
'G7 규정 문서 목록을 조회합니다',
{
category: z.enum(['backend', 'frontend', 'extension', 'database', 'testing', 'all'])
.optional()
.describe('조회할 카테고리'),
},
async ({ category }) => {
const result = listDocs(projectRoot, category);
if (!result.success || !Array.isArray(result.data)) {
return {
content: [{
type: 'text',
text: '문서 목록을 조회할 수 없습니다',
}],
};
}
const formatted = result.data.map((doc: any) =>
`- ${doc.path}: ${doc.title}`
).join('\n');
return {
content: [{
type: 'text',
text: `${result.message}\n\n${formatted}`,
}],
};
}
),
],
});
}
/**
* 검증 결과 포맷팅
*/
function formatValidationResult(result: { valid: boolean; issues: any[] }): string {
if (result.valid && result.issues.length === 0) {
return '✅ 검증 통과: G7 규정을 준수합니다.';
}
const errors = result.issues.filter(i => i.severity === 'error');
const warnings = result.issues.filter(i => i.severity === 'warning');
let output = result.valid
? '⚠️ 검증 통과 (경고 있음)\n\n'
: '❌ 검증 실패\n\n';
if (errors.length > 0) {
output += '### 오류\n';
for (const issue of errors) {
output += `- [${issue.rule}] ${issue.file}`;
if (issue.line) output += `:${issue.line}`;
output += `\n ${issue.message}\n`;
}
output += '\n';
}
if (warnings.length > 0) {
output += '### 경고\n';
for (const issue of warnings) {
output += `- [${issue.rule}] ${issue.file}`;
if (issue.line) output += `:${issue.line}`;
output += `\n ${issue.message}\n`;
}
}
return output;
}
+310
View File
@@ -0,0 +1,310 @@
#!/usr/bin/env node
/**
* 그누보드7 에이전트 MCP 서버 진입점
* AI 코딩 도구에서 멀티에이전트 시스템을 호출할 수 있는 MCP 서버
*
* 사용법:
* .mcp.json에 등록하여 AI 코딩 도구에서 자동으로 호출
*
* 환경 변수:
* G7_PROJECT_ROOT: 프로젝트 루트 경로 (기본: .)
* ANTHROPIC_API_KEY: Anthropic API 키 (필수)
* LOG_LEVEL: 로그 레벨 (기본: info)
*/
import 'dotenv/config';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { Coordinator } from '../coordinator/Coordinator.js';
import { featureDevelopmentWorkflow, simpleFeatureWorkflow } from '../workflows/feature.js';
import { bugfixWorkflow, stateRelatedBugfix } from '../workflows/bugfix.js';
import { prReviewWorkflow } from '../workflows/pr-review.js';
import { logger, createWorkflowLogger } from '../utils/logger.js';
const workflowLogger = createWorkflowLogger('mcp-agents');
// 프로젝트 루트 설정
const projectRoot = process.env.G7_PROJECT_ROOT || '.';
// API 키 확인
if (!process.env.ANTHROPIC_API_KEY) {
logger.warn('ANTHROPIC_API_KEY가 설정되지 않았습니다. 에이전트 호출 시 오류가 발생할 수 있습니다.');
}
// MCP 서버 생성
const server = new McpServer({
name: 'g7-agents',
version: '1.0.0',
});
/**
* 워크플로우 결과 포맷팅
*/
function formatWorkflowResult(result: any): string {
const lines: string[] = [];
if (result.success) {
lines.push('## ✅ 작업 완료\n');
} else {
lines.push('## ❌ 작업 실패\n');
}
if (result.steps && result.steps.length > 0) {
lines.push('### 실행 단계');
for (const step of result.steps) {
const statusIcon = step.status === 'completed' ? '✅' :
step.status === 'failed' ? '❌' :
step.status === 'running' ? '🔄' : '⏳';
lines.push(`${statusIcon} [${step.agent}] ${step.task}`);
if (step.result?.filesModified && step.result.filesModified.length > 0) {
lines.push(` 수정된 파일: ${step.result.filesModified.join(', ')}`);
}
}
lines.push('');
}
if (result.output) {
lines.push('### 결과');
lines.push(result.output);
lines.push('');
}
if (result.errors && result.errors.length > 0) {
lines.push('### 오류');
for (const error of result.errors) {
lines.push(`- ${error}`);
}
lines.push('');
}
if (result.totalCost && result.totalCost > 0) {
lines.push(`### 비용: $${result.totalCost.toFixed(4)}`);
}
return lines.join('\n');
}
// Tool: orchestrate - 자동 분배
server.tool(
'orchestrate',
'사용자 요청을 분석하여 적절한 에이전트(backend, frontend, layout, template, reviewer)에 자동 분배합니다.',
{
prompt: z.string().describe('사용자 요청 내용'),
},
async ({ prompt }) => {
workflowLogger.info(`Orchestrating: ${prompt}`);
try {
const coordinator = new Coordinator({ cwd: projectRoot, model: 'sonnet' });
const result = await coordinator.orchestrate(prompt);
return {
content: [{ type: 'text', text: formatWorkflowResult(result) }],
isError: !result.success,
};
} catch (error: any) {
workflowLogger.error(`Orchestration failed: ${error.message}`);
return {
content: [{ type: 'text', text: `❌ 오케스트레이션 실패: ${error.message}` }],
isError: true,
};
}
}
);
// Tool: feature-workflow - 기능 개발
server.tool(
'feature-workflow',
'기능 개발 워크플로우를 실행합니다. Backend → Layout → Frontend → Template → Reviewer 순서로 에이전트가 협업합니다.',
{
description: z.string().describe('개발할 기능 설명'),
skipReview: z.boolean().optional().describe('리뷰 단계 생략 여부 (기본: false)'),
},
async ({ description, skipReview }) => {
workflowLogger.info(`Feature workflow: ${description}`);
try {
const result = await featureDevelopmentWorkflow(description, {
cwd: projectRoot,
skipReview: skipReview ?? false,
});
return {
content: [{ type: 'text', text: formatWorkflowResult(result) }],
isError: !result.success,
};
} catch (error: any) {
workflowLogger.error(`Feature workflow failed: ${error.message}`);
return {
content: [{ type: 'text', text: `❌ 기능 개발 워크플로우 실패: ${error.message}` }],
isError: true,
};
}
}
);
// Tool: simple-feature - 단일 영역 기능 개발
server.tool(
'simple-feature',
'단일 영역의 간단한 기능을 개발합니다.',
{
description: z.string().describe('개발할 기능 설명'),
area: z.enum(['backend', 'frontend', 'layout', 'template']).describe('기능이 속한 영역'),
},
async ({ description, area }) => {
workflowLogger.info(`Simple feature (${area}): ${description}`);
try {
const result = await simpleFeatureWorkflow(description, {
cwd: projectRoot,
area,
});
return {
content: [{ type: 'text', text: formatWorkflowResult(result) }],
isError: !result.success,
};
} catch (error: any) {
workflowLogger.error(`Simple feature failed: ${error.message}`);
return {
content: [{ type: 'text', text: `❌ 간단한 기능 개발 실패: ${error.message}` }],
isError: true,
};
}
}
);
// Tool: bugfix-workflow - 버그 수정
server.tool(
'bugfix-workflow',
'버그 수정 워크플로우를 실행합니다. 트러블슈팅 가이드를 참조하고 TDD 방식으로 수정합니다.',
{
description: z.string().describe('버그 설명'),
affectedAreas: z.array(z.enum(['backend', 'frontend', 'layout', 'template', 'unknown'])).optional()
.describe('영향 받는 영역 (지정하지 않으면 자동 분석)'),
},
async ({ description, affectedAreas }) => {
workflowLogger.info(`Bugfix workflow: ${description}`);
try {
const result = await bugfixWorkflow(description, {
cwd: projectRoot,
affectedAreas: affectedAreas as any,
});
return {
content: [{ type: 'text', text: formatWorkflowResult(result) }],
isError: !result.success,
};
} catch (error: any) {
workflowLogger.error(`Bugfix workflow failed: ${error.message}`);
return {
content: [{ type: 'text', text: `❌ 버그 수정 워크플로우 실패: ${error.message}` }],
isError: true,
};
}
}
);
// Tool: state-bugfix - 상태 관련 버그 수정
server.tool(
'state-bugfix',
'상태 관리 관련 버그를 수정합니다. Stale Closure, setState 병합, 타이밍 이슈 등에 특화되어 있습니다.',
{
description: z.string().describe('상태 관련 버그 설명'),
},
async ({ description }) => {
workflowLogger.info(`State-related bugfix: ${description}`);
try {
const result = await stateRelatedBugfix(description, {
cwd: projectRoot,
});
return {
content: [{ type: 'text', text: formatWorkflowResult(result) }],
isError: !result.success,
};
} catch (error: any) {
workflowLogger.error(`State bugfix failed: ${error.message}`);
return {
content: [{ type: 'text', text: `❌ 상태 버그 수정 실패: ${error.message}` }],
isError: true,
};
}
}
);
// Tool: pr-review - PR 검수
server.tool(
'pr-review',
'PR을 검수합니다. 변경된 파일 유형에 따라 적절한 에이전트가 검수합니다.',
{
prIdentifier: z.string().describe('PR 번호 또는 브랜치명'),
},
async ({ prIdentifier }) => {
workflowLogger.info(`PR review: ${prIdentifier}`);
try {
const result = await prReviewWorkflow(prIdentifier, {
cwd: projectRoot,
});
return {
content: [{ type: 'text', text: formatWorkflowResult(result) }],
isError: !result.success,
};
} catch (error: any) {
workflowLogger.error(`PR review failed: ${error.message}`);
return {
content: [{ type: 'text', text: `❌ PR 검수 실패: ${error.message}` }],
isError: true,
};
}
}
);
// Tool: delegate - 특정 에이전트 직접 호출
server.tool(
'delegate',
'특정 에이전트에 직접 작업을 위임합니다.',
{
agent: z.enum(['backend', 'frontend', 'layout', 'template', 'reviewer']).describe('호출할 에이전트'),
task: z.string().describe('수행할 작업 내용'),
},
async ({ agent, task }) => {
workflowLogger.info(`Delegating to ${agent}: ${task}`);
try {
const coordinator = new Coordinator({ cwd: projectRoot, model: 'sonnet' });
const result = await coordinator.delegateToAgent(agent, task);
return {
content: [{ type: 'text', text: formatWorkflowResult(result) }],
isError: !result.success,
};
} catch (error: any) {
workflowLogger.error(`Delegation failed: ${error.message}`);
return {
content: [{ type: 'text', text: `❌ 에이전트 호출 실패: ${error.message}` }],
isError: true,
};
}
}
);
// 서버 시작
async function main() {
logger.info(`그누보드7 에이전트 MCP 서버 시작 (프로젝트: ${projectRoot})`);
const transport = new StdioServerTransport();
await server.connect(transport);
logger.info('MCP 서버가 stdio 모드로 실행 중입니다.');
}
main().catch((error) => {
logger.error(`MCP 서버 시작 실패: ${error.message}`);
process.exit(1);
});
@@ -0,0 +1,174 @@
/**
* 테스트 실행 도구
* PHPUnit (백엔드) 및 Vitest (프론트엔드) 테스트 실행
*/
import { execSync } from 'child_process';
import type { ToolResult } from '../../types/index.js';
export type TestType = 'backend' | 'frontend' | 'all';
export interface TestOptions {
type: TestType;
filter?: string;
template?: string;
}
export function runTests(
projectRoot: string,
options: TestOptions
): ToolResult {
const { type, filter, template } = options;
const results: string[] = [];
let success = true;
try {
// 백엔드 테스트
if (type === 'backend' || type === 'all') {
const backendResult = runBackendTests(projectRoot, filter);
results.push('=== Backend Tests ===');
results.push(backendResult.output);
if (!backendResult.success) {
success = false;
}
}
// 프론트엔드 테스트
if (type === 'frontend' || type === 'all') {
const frontendResult = runFrontendTests(projectRoot, filter, template);
results.push('=== Frontend Tests ===');
results.push(frontendResult.output);
if (!frontendResult.success) {
success = false;
}
}
return {
success,
message: success ? '모든 테스트 통과' : '일부 테스트 실패',
data: results.join('\n\n'),
};
} catch (error: any) {
return {
success: false,
message: '테스트 실행 중 오류 발생',
data: error.message,
};
}
}
function runBackendTests(
projectRoot: string,
filter?: string
): { success: boolean; output: string } {
try {
const cmd = filter
? `php artisan test --filter=${filter}`
: 'php artisan test';
const output = execSync(cmd, {
cwd: projectRoot,
encoding: 'utf-8',
timeout: 300000, // 5분 타임아웃
});
// 테스트 결과 파싱
const passMatch = output.match(/Tests:\s*(\d+)\s*passed/);
const failMatch = output.match(/(\d+)\s*failed/);
const passed = passMatch ? parseInt(passMatch[1]) : 0;
const failed = failMatch ? parseInt(failMatch[1]) : 0;
return {
success: failed === 0,
output: `Passed: ${passed}, Failed: ${failed}\n${output}`,
};
} catch (error: any) {
return {
success: false,
output: error.stdout || error.message,
};
}
}
function runFrontendTests(
projectRoot: string,
filter?: string,
template?: string
): { success: boolean; output: string } {
try {
let cwd = projectRoot;
let cmd = 'npm run test:run';
// 템플릿 지정 시 해당 디렉토리에서 실행
if (template) {
cwd = `${projectRoot}/templates/${template}`;
}
if (filter) {
cmd += ` -- ${filter}`;
}
// Windows에서는 PowerShell 래퍼 필수
const fullCmd = `powershell -Command "${cmd}"`;
const output = execSync(fullCmd, {
cwd,
encoding: 'utf-8',
timeout: 300000, // 5분 타임아웃
});
// Vitest 결과 파싱
const passMatch = output.match(/(\d+)\s*passed/);
const failMatch = output.match(/(\d+)\s*failed/);
const passed = passMatch ? parseInt(passMatch[1]) : 0;
const failed = failMatch ? parseInt(failMatch[1]) : 0;
return {
success: failed === 0,
output: `Passed: ${passed}, Failed: ${failed}\n${output}`,
};
} catch (error: any) {
return {
success: false,
output: error.stdout || error.message,
};
}
}
/**
* 특정 테스트 파일 실행
*/
export function runTestFile(
projectRoot: string,
testFilePath: string
): ToolResult {
const isPhp = testFilePath.endsWith('.php');
const isTs = testFilePath.endsWith('.ts') || testFilePath.endsWith('.tsx');
if (isPhp) {
// PHPUnit 테스트
const testClass = testFilePath
.replace(/\//g, '\\')
.replace('.php', '')
.replace('tests\\', 'Tests\\');
return runTests(projectRoot, {
type: 'backend',
filter: testClass,
});
} else if (isTs) {
// Vitest 테스트
const testName = testFilePath.split('/').pop()?.replace(/\.(test|spec)\.(ts|tsx)$/, '');
return runTests(projectRoot, {
type: 'frontend',
filter: testName,
});
}
return {
success: false,
message: '지원하지 않는 테스트 파일 형식입니다',
};
}
@@ -0,0 +1,275 @@
/**
* 규정 문서 검색 도구
* docs 디렉토리에서 규정 문서 검색
*/
import * as fs from 'fs';
import * as path from 'path';
import type { ToolResult } from '../../types/index.js';
export type DocCategory = 'backend' | 'frontend' | 'extension' | 'database' | 'testing' | 'all';
export interface SearchOptions {
query: string;
category?: DocCategory;
maxResults?: number;
}
export interface DocSearchResult {
file: string;
title: string;
snippet: string;
relevance: number;
}
export function searchDocs(
projectRoot: string,
options: SearchOptions
): ToolResult {
const { query, category = 'all', maxResults = 5 } = options;
const docsPath = path.join(projectRoot, 'docs');
const results: DocSearchResult[] = [];
const searchPaths = category === 'all'
? [docsPath]
: [path.join(docsPath, category)];
// 특수 파일 매핑
const specialFiles: Record<string, string> = {
database: path.join(docsPath, 'database-guide.md'),
testing: path.join(docsPath, 'testing-guide.md'),
};
// 카테고리별 특수 파일 추가
if (category !== 'all' && specialFiles[category]) {
searchPaths.push(specialFiles[category]);
}
for (const searchPath of searchPaths) {
if (fs.existsSync(searchPath)) {
if (fs.statSync(searchPath).isDirectory()) {
searchDirectory(searchPath, docsPath, query, results);
} else {
searchFile(searchPath, docsPath, query, results);
}
}
}
// 관련도 순으로 정렬
results.sort((a, b) => b.relevance - a.relevance);
// 결과 제한
const limitedResults = results.slice(0, maxResults);
if (limitedResults.length === 0) {
return {
success: true,
message: '검색 결과가 없습니다',
data: [],
};
}
return {
success: true,
message: `${limitedResults.length}개의 문서를 찾았습니다`,
data: limitedResults,
};
}
function searchDirectory(
dirPath: string,
docsPath: string,
query: string,
results: DocSearchResult[]
): void {
try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
searchDirectory(fullPath, docsPath, query, results);
} else if (entry.name.endsWith('.md')) {
searchFile(fullPath, docsPath, query, results);
}
}
} catch (error) {
// 디렉토리 접근 오류 무시
}
}
function searchFile(
filePath: string,
docsPath: string,
query: string,
results: DocSearchResult[]
): void {
try {
const content = fs.readFileSync(filePath, 'utf-8');
const relativePath = path.relative(docsPath, filePath);
const lowerContent = content.toLowerCase();
const lowerQuery = query.toLowerCase();
// 쿼리가 포함되어 있는지 확인
if (!lowerContent.includes(lowerQuery)) {
return;
}
// 제목 추출 (첫 번째 # 라인)
const titleMatch = content.match(/^#\s+(.+)$/m);
const title = titleMatch ? titleMatch[1] : relativePath;
// 관련도 계산
let relevance = 0;
// 파일명에 쿼리 포함
if (relativePath.toLowerCase().includes(lowerQuery)) {
relevance += 10;
}
// 제목에 쿼리 포함
if (title.toLowerCase().includes(lowerQuery)) {
relevance += 5;
}
// 쿼리 등장 횟수
const matches = lowerContent.split(lowerQuery).length - 1;
relevance += Math.min(matches, 10);
// TL;DR 섹션에 포함
if (content.includes('TL;DR') && content.split('TL;DR')[1]?.toLowerCase().includes(lowerQuery)) {
relevance += 3;
}
// 스니펫 추출
const snippet = extractSnippet(content, query);
results.push({
file: relativePath,
title,
snippet,
relevance,
});
} catch (error) {
// 파일 읽기 오류 무시
}
}
function extractSnippet(content: string, query: string): string {
const lowerContent = content.toLowerCase();
const lowerQuery = query.toLowerCase();
const index = lowerContent.indexOf(lowerQuery);
if (index === -1) {
// TL;DR 섹션이 있으면 그것을 반환
const tldrMatch = content.match(/## TL;DR[^#]*(```[\s\S]*?```)/);
if (tldrMatch) {
return tldrMatch[1].substring(0, 300) + '...';
}
// 첫 문단 반환
const lines = content.split('\n').filter(l => l.trim() && !l.startsWith('#'));
return lines.slice(0, 3).join('\n').substring(0, 300) + '...';
}
// 쿼리 주변 컨텍스트 추출
const start = Math.max(0, index - 100);
const end = Math.min(content.length, index + query.length + 200);
let snippet = content.substring(start, end);
// 시작/끝 정리
if (start > 0) {
snippet = '...' + snippet.substring(snippet.indexOf(' ') + 1);
}
if (end < content.length) {
snippet = snippet.substring(0, snippet.lastIndexOf(' ')) + '...';
}
return snippet;
}
/**
* 특정 문서 읽기
*/
export function readDoc(
projectRoot: string,
docPath: string
): ToolResult {
const fullPath = path.join(projectRoot, 'docs', docPath);
if (!fs.existsSync(fullPath)) {
return {
success: false,
message: `문서를 찾을 수 없습니다: ${docPath}`,
};
}
try {
const content = fs.readFileSync(fullPath, 'utf-8');
return {
success: true,
message: `문서 로드 완료: ${docPath}`,
data: content,
};
} catch (error: any) {
return {
success: false,
message: `문서 읽기 오류: ${error.message}`,
};
}
}
/**
* 규정 문서 목록 조회
*/
export function listDocs(
projectRoot: string,
category?: DocCategory
): ToolResult {
const docsPath = path.join(projectRoot, 'docs');
const docs: { path: string; title: string }[] = [];
function scanDir(dir: string) {
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relativePath = path.relative(docsPath, fullPath);
if (entry.isDirectory()) {
if (!category || relativePath === category) {
scanDir(fullPath);
}
} else if (entry.name.endsWith('.md')) {
// 카테고리 필터링
if (category && category !== 'all') {
if (!relativePath.startsWith(category)) {
continue;
}
}
const content = fs.readFileSync(fullPath, 'utf-8');
const titleMatch = content.match(/^#\s+(.+)$/m);
const title = titleMatch ? titleMatch[1] : entry.name;
docs.push({
path: relativePath,
title,
});
}
}
} catch (error) {
// 오류 무시
}
}
scanDir(docsPath);
return {
success: true,
message: `${docs.length}개의 문서를 찾았습니다`,
data: docs,
};
}
@@ -0,0 +1,272 @@
/**
* 백엔드 코드 검증 도구
* Service, FormRequest, Repository, Listener 규정 검증
*/
import * as fs from 'fs';
import * as path from 'path';
import type { ValidationResult, ValidationIssue } from '../../types/index.js';
export function validateBackendCode(
projectRoot: string,
filePath: string
): ValidationResult {
const fullPath = path.join(projectRoot, filePath);
const issues: ValidationIssue[] = [];
if (!fs.existsSync(fullPath)) {
return {
valid: false,
issues: [{
file: filePath,
rule: 'file-exists',
message: '파일이 존재하지 않습니다',
severity: 'error',
}],
};
}
const content = fs.readFileSync(fullPath, 'utf-8');
const lines = content.split('\n');
// Service 파일 검증
if (filePath.includes('/Services/')) {
validateService(filePath, content, lines, issues);
}
// Repository 파일 검증
if (filePath.includes('/Repositories/')) {
validateRepository(filePath, content, lines, issues);
}
// FormRequest 파일 검증
if (filePath.includes('/Requests/')) {
validateFormRequest(filePath, content, lines, issues);
}
// Controller 파일 검증
if (filePath.includes('/Controllers/')) {
validateController(filePath, content, lines, issues);
}
// Listener 파일 검증
if (filePath.includes('/Listeners/')) {
validateListener(filePath, content, lines, issues);
}
return {
valid: issues.filter(i => i.severity === 'error').length === 0,
issues,
};
}
function validateService(
filePath: string,
content: string,
lines: string[],
issues: ValidationIssue[]
): void {
// Repository 구체 클래스 직접 주입 검사
const constructorMatch = content.match(/public function __construct\([^)]+\)/s);
if (constructorMatch) {
const constructorContent = constructorMatch[0];
// Interface가 아닌 Repository 직접 주입 검사
const repoPattern = /(\w+Repository)\s+\$/;
const match = constructorContent.match(repoPattern);
if (match && !match[1].includes('Interface')) {
const lineNum = findLineNumber(lines, match[0]);
issues.push({
file: filePath,
line: lineNum,
rule: 'repository-interface',
message: `Repository 구체 클래스 직접 주입 금지. ${match[1]}Interface를 사용하세요.`,
severity: 'error',
});
}
}
// Service에서 Validator 사용 검사
if (content.includes('Validator::') || content.includes('\\Validator')) {
const lineNum = findLineNumber(lines, 'Validator');
issues.push({
file: filePath,
line: lineNum,
rule: 'no-validation-in-service',
message: 'Service에서 검증 로직 금지. FormRequest를 사용하세요.',
severity: 'error',
});
}
// 훅 사용 검사 (create, update, delete 메서드에서)
const methodPatterns = [
/public function create\w*\([^)]*\)/,
/public function update\w*\([^)]*\)/,
/public function delete\w*\([^)]*\)/,
];
for (const pattern of methodPatterns) {
if (pattern.test(content)) {
if (!content.includes('HookManager::doAction') && !content.includes('HookManager::applyFilters')) {
issues.push({
file: filePath,
rule: 'hook-usage',
message: 'Service의 CUD 메서드에서 훅 미사용. before_*/after_* 훅을 추가하세요.',
severity: 'warning',
});
break;
}
}
}
// 다국어 처리 검사
if (content.includes('throw new') && !content.includes('__(')) {
const lineNum = findLineNumber(lines, 'throw new');
issues.push({
file: filePath,
line: lineNum,
rule: 'i18n-exception',
message: '예외 메시지 하드코딩 금지. __() 함수를 사용하세요.',
severity: 'warning',
});
}
}
function validateRepository(
filePath: string,
content: string,
lines: string[],
issues: ValidationIssue[]
): void {
// Interface 구현 검사
if (!content.includes('implements') || !content.includes('Interface')) {
issues.push({
file: filePath,
rule: 'repository-implements-interface',
message: 'Repository는 해당 Interface를 구현해야 합니다.',
severity: 'warning',
});
}
}
function validateFormRequest(
filePath: string,
content: string,
lines: string[],
issues: ValidationIssue[]
): void {
// authorize() 검사 - 권한 체크 금지
const authorizeMatch = content.match(/public function authorize\(\)[^{]*\{([^}]+)\}/s);
if (authorizeMatch) {
const authorizeBody = authorizeMatch[1];
if (authorizeBody.includes('->can(') || authorizeBody.includes('Gate::')) {
const lineNum = findLineNumber(lines, 'authorize');
issues.push({
file: filePath,
line: lineNum,
rule: 'no-auth-in-form-request',
message: 'FormRequest.authorize()에서 권한 체크 금지. 라우트 미들웨어를 사용하세요.',
severity: 'warning',
});
}
}
// 다국어 메시지 검사
if (content.includes('messages()') && !content.includes('__(')) {
issues.push({
file: filePath,
rule: 'i18n-validation-messages',
message: '검증 메시지 다국어 처리 필요. __() 함수를 사용하세요.',
severity: 'warning',
});
}
}
function validateController(
filePath: string,
content: string,
lines: string[],
issues: ValidationIssue[]
): void {
// Repository 직접 주입 검사
if (content.includes('Repository $') && !content.includes('Service $')) {
const lineNum = findLineNumber(lines, 'Repository $');
issues.push({
file: filePath,
line: lineNum,
rule: 'controller-uses-service',
message: 'Controller에서 Repository 직접 주입 금지. Service를 주입하세요.',
severity: 'error',
});
}
// 인라인 검증 검사
if (content.includes('$request->validate(') || content.includes('Validator::make')) {
const lineNum = findLineNumber(lines, 'validate');
issues.push({
file: filePath,
line: lineNum,
rule: 'no-inline-validation',
message: 'Controller에서 인라인 검증 금지. FormRequest를 사용하세요.',
severity: 'error',
});
}
// ResponseHelper 사용 검사
if (content.includes('return response()->json') && !content.includes('ResponseHelper')) {
const lineNum = findLineNumber(lines, 'response()->json');
issues.push({
file: filePath,
line: lineNum,
rule: 'use-response-helper',
message: 'response()->json 대신 ResponseHelper를 사용하세요.',
severity: 'warning',
});
}
}
function validateListener(
filePath: string,
content: string,
lines: string[],
issues: ValidationIssue[]
): void {
// HookListenerInterface 구현 검사
if (!content.includes('HookListenerInterface')) {
issues.push({
file: filePath,
rule: 'listener-interface',
message: 'Listener는 HookListenerInterface를 구현해야 합니다.',
severity: 'error',
});
}
// getSubscribedHooks 메서드 검사
if (!content.includes('getSubscribedHooks')) {
issues.push({
file: filePath,
rule: 'subscribed-hooks-method',
message: 'getSubscribedHooks() 메서드가 필요합니다.',
severity: 'error',
});
}
// Filter 훅의 type 검사
if (content.includes('applyFilters') || content.includes('filter_')) {
if (!content.includes("'type' => 'filter'") && !content.includes('"type" => "filter"')) {
issues.push({
file: filePath,
rule: 'filter-hook-type',
message: "Filter 훅은 'type' => 'filter'를 명시해야 합니다.",
severity: 'warning',
});
}
}
}
function findLineNumber(lines: string[], searchText: string): number {
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes(searchText)) {
return i + 1;
}
}
return 0;
}
@@ -0,0 +1,268 @@
/**
* 프론트엔드/레이아웃 검증 도구
* TSX 컴포넌트 및 레이아웃 JSON 규정 검증
*/
import * as fs from 'fs';
import * as path from 'path';
import type { ValidationResult, ValidationIssue } from '../../types/index.js';
export function validateFrontendCode(
projectRoot: string,
filePath: string
): ValidationResult {
const fullPath = path.join(projectRoot, filePath);
const issues: ValidationIssue[] = [];
if (!fs.existsSync(fullPath)) {
return {
valid: false,
issues: [{
file: filePath,
rule: 'file-exists',
message: '파일이 존재하지 않습니다',
severity: 'error',
}],
};
}
const content = fs.readFileSync(fullPath, 'utf-8');
if (filePath.endsWith('.json')) {
validateLayoutJson(filePath, content, issues);
} else if (filePath.endsWith('.tsx')) {
validateTsxComponent(filePath, content, issues);
}
return {
valid: issues.filter(i => i.severity === 'error').length === 0,
issues,
};
}
function validateLayoutJson(
filePath: string,
content: string,
issues: ValidationIssue[]
): void {
let json: any;
try {
json = JSON.parse(content);
} catch (error) {
issues.push({
file: filePath,
rule: 'valid-json',
message: 'JSON 파싱 오류',
severity: 'error',
});
return;
}
// 필수 필드 검사
const requiredFields = ['version', 'layout_name', 'components'];
for (const field of requiredFields) {
if (!(field in json)) {
issues.push({
file: filePath,
rule: 'required-fields',
message: `필수 필드 누락: ${field}`,
severity: 'error',
});
}
}
// 컴포넌트 검증
if (Array.isArray(json.components)) {
validateComponents(filePath, json.components, issues);
}
}
function validateComponents(
filePath: string,
components: any[],
issues: ValidationIssue[],
parentPath = 'components'
): void {
for (let i = 0; i < components.length; i++) {
const comp = components[i];
const compPath = `${parentPath}[${i}]`;
// HTML 태그명 검사
if (comp.name) {
const htmlTags = ['div', 'span', 'button', 'input', 'form', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'a', 'ul', 'li', 'table', 'tr', 'td', 'th', 'thead', 'tbody', 'section', 'nav', 'img'];
if (htmlTags.includes(comp.name.toLowerCase())) {
issues.push({
file: filePath,
rule: 'no-html-tags',
message: `HTML 태그 직접 사용 금지: "${comp.name}" (${compPath}). 기본 컴포넌트(${comp.name.charAt(0).toUpperCase() + comp.name.slice(1)})를 사용하세요.`,
severity: 'error',
});
}
}
// props.children 사용 검사
if (comp.props?.children && typeof comp.props.children === 'string') {
issues.push({
file: filePath,
rule: 'no-props-children',
message: `props.children 사용 금지 (${compPath}). "text" 속성을 사용하세요.`,
severity: 'error',
});
}
// 하드코딩 텍스트 검사 (한글 포함)
if (comp.text && typeof comp.text === 'string') {
const koreanPattern = /[가-힣]/;
if (koreanPattern.test(comp.text) && !comp.text.startsWith('$t:') && !comp.text.includes('{{')) {
issues.push({
file: filePath,
rule: 'i18n-text',
message: `하드코딩된 텍스트 (${compPath}): "${comp.text}". $t: 접두사를 사용하세요.`,
severity: 'warning',
});
}
}
// 다크 모드 클래스 검사
if (comp.props?.className) {
const className = comp.props.className;
validateDarkModeClasses(filePath, className, compPath, issues);
}
// children 재귀 검증
if (Array.isArray(comp.children)) {
validateComponents(filePath, comp.children, issues, `${compPath}.children`);
}
}
}
function validateTsxComponent(
filePath: string,
content: string,
issues: ValidationIssue[]
): void {
const lines = content.split('\n');
// HTML 태그 직접 사용 검사
const htmlTagPatterns = [
{ pattern: /<div\s|<div>/, tag: 'div', replacement: 'Div' },
{ pattern: /<span\s|<span>/, tag: 'span', replacement: 'Span' },
{ pattern: /<button\s|<button>/, tag: 'button', replacement: 'Button' },
{ pattern: /<input\s|<input>|<input\/>/, tag: 'input', replacement: 'Input' },
{ pattern: /<form\s|<form>/, tag: 'form', replacement: 'Form' },
{ pattern: /<p\s|<p>/, tag: 'p', replacement: 'P' },
{ pattern: /<h1\s|<h1>/, tag: 'h1', replacement: 'H1' },
{ pattern: /<h2\s|<h2>/, tag: 'h2', replacement: 'H2' },
{ pattern: /<h3\s|<h3>/, tag: 'h3', replacement: 'H3' },
{ pattern: /<a\s|<a>/, tag: 'a', replacement: 'A' },
];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineNum = i + 1;
// 주석 무시
if (line.trim().startsWith('//') || line.trim().startsWith('/*')) {
continue;
}
for (const { pattern, tag, replacement } of htmlTagPatterns) {
if (pattern.test(line)) {
issues.push({
file: filePath,
line: lineNum,
rule: 'no-html-tags',
message: `HTML 태그 직접 사용 금지: <${tag}>. ${replacement} 컴포넌트를 사용하세요.`,
severity: 'error',
});
}
}
// 다크 모드 클래스 검사
const classNameMatch = line.match(/className=["'`]([^"'`]+)["'`]/);
if (classNameMatch) {
validateDarkModeClasses(filePath, classNameMatch[1], `line ${lineNum}`, issues);
}
}
// 다국어 처리 검사
if (!content.includes('G7Core') && !content.includes('window.G7Core')) {
// 하드코딩된 한글 텍스트 검사
const koreanPattern = />([가-힣\s]+)</g;
let match;
while ((match = koreanPattern.exec(content)) !== null) {
// JSX 내의 한글 텍스트
if (!match[1].trim()) continue;
issues.push({
file: filePath,
rule: 'i18n-text',
message: `하드코딩된 텍스트: "${match[1]}". G7Core.t()를 사용하세요.`,
severity: 'warning',
});
}
}
// Font Awesome 외 아이콘 라이브러리 검사
const iconLibraries = [
'@heroicons/react',
'@mui/icons-material',
'react-icons',
'lucide-react',
];
for (const lib of iconLibraries) {
if (content.includes(lib)) {
issues.push({
file: filePath,
rule: 'icon-library',
message: `허용되지 않은 아이콘 라이브러리: ${lib}. Font Awesome 6.4.0만 사용하세요.`,
severity: 'error',
});
}
}
}
function validateDarkModeClasses(
filePath: string,
className: string,
location: string,
issues: ValidationIssue[]
): void {
// 배경색 검사
const bgLightPatterns = ['bg-white', 'bg-gray-50', 'bg-gray-100'];
const bgDarkPatterns = ['dark:bg-gray-800', 'dark:bg-gray-700', 'dark:bg-gray-900'];
for (const lightClass of bgLightPatterns) {
if (className.includes(lightClass)) {
const hasDarkVariant = bgDarkPatterns.some(dc => className.includes(dc));
if (!hasDarkVariant) {
issues.push({
file: filePath,
rule: 'dark-mode-pair',
message: `다크 모드 클래스 누락 (${location}): "${lightClass}"에 대한 dark: variant가 필요합니다.`,
severity: 'warning',
});
}
}
}
// 텍스트 색상 검사
if (className.includes('text-gray-900') && !className.includes('dark:text-')) {
issues.push({
file: filePath,
rule: 'dark-mode-pair',
message: `다크 모드 클래스 누락 (${location}): "text-gray-900"에 대한 dark:text- variant가 필요합니다.`,
severity: 'warning',
});
}
// 테두리 색상 검사
if (className.includes('border-gray-200') && !className.includes('dark:border-')) {
issues.push({
file: filePath,
rule: 'dark-mode-pair',
message: `다크 모드 클래스 누락 (${location}): "border-gray-200"에 대한 dark:border- variant가 필요합니다.`,
severity: 'warning',
});
}
}
+180
View File
@@ -0,0 +1,180 @@
/**
* 백엔드 개발자 에이전트 시스템 프롬프트
* Service-Repository 패턴, FormRequest, 훅 시스템 담당
*/
export const BACKEND_PROMPT = `
당신은 그누보드7의 백엔드 개발 전문가입니다.
20년차 PHP Laravel 개발자로서 Service-Repository 패턴과 훅 시스템에 능숙합니다.
## 전문 영역
- Service-Repository 패턴 구현
- FormRequest 검증 로직
- 훅 시스템 (Action/Filter)
- API 리소스 및 응답 처리
- 마이그레이션 및 시더
## 핵심 규칙 (CRITICAL)
### 1. 의존성 주입
\`\`\`php
// ✅ 올바른 방법: Interface 주입
public function __construct(private ProductRepositoryInterface $repo) {}
// ❌ 금지: 구체 클래스 직접 주입
public function __construct(private ProductRepository $repo) {}
\`\`\`
### 2. Service에서 훅 실행 (필수)
\`\`\`php
public function createProduct(array $data): Product
{
// 1. before 훅
HookManager::doAction('module.product.before_create', $data);
// 2. filter 훅
$data = HookManager::applyFilters('module.product.filter_data', $data);
// 3. 실제 로직
$product = $this->repo->create($data);
// 4. after 훅
HookManager::doAction('module.product.after_create', $product);
return $product;
}
\`\`\`
### 3. 검증 로직 분리 (CRITICAL)
\`\`\`php
// ✅ FormRequest에서 검증
class StoreProductRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'price' => ['required', 'numeric', 'min:0'],
];
}
}
// ❌ Service에서 검증 금지
class ProductService
{
public function create(array $data)
{
// 여기서 Validator 사용 금지!
}
}
\`\`\`
### 4. 컨트롤러 계층 구조
\`\`\`
BaseApiController (최상위)
├── AdminBaseController (관리자)
├── AuthBaseController (인증된 사용자)
└── PublicBaseController (공개 API)
\`\`\`
### 5. ResponseHelper 사용
\`\`\`php
// ✅ 인수 순서 주의: 메시지가 먼저!
return ResponseHelper::success('messages.created', $data, 201);
return ResponseHelper::error('messages.failed', null, 400);
// ❌ 인수 순서 오류
return ResponseHelper::success($data, 'messages.created');
\`\`\`
### 6. 마이그레이션 규칙
- 네이밍: create_[table]_table, add_[col]_to_[table]_table
- 모든 컬럼에 한국어 comment 필수
- enum/boolean은 값 설명 포함
- down() 메서드 반드시 구현
### 7. 훅 네이밍 규칙
\`\`\`
[vendor-module].[entity].[action]_[timing]
예시:
sirsoft-ecommerce.product.before_create
sirsoft-ecommerce.product.after_update
sirsoft-ecommerce.product.filter_create_data
\`\`\`
### 8. 훅 기반 Validation Rules 확장 (CRITICAL)
\`\`\`php
// 코어 FormRequest는 반드시 훅 제공
public function rules(): array
{
$rules = [
'name' => 'required|string|max:255',
];
// 모듈/플러그인이 validation rules 동적 추가 가능
return HookManager::applyFilters('core.user.update_validation_rules', $rules, $this);
}
// 모듈 Listener에서 필드 추가
public function addValidationRules(array $rules): array
{
return array_merge($rules, [
'notify_post_complete' => 'nullable|boolean',
]);
}
\`\`\`
### 9. 다중 검색 필터 Trait
\`\`\`php
use App\Repositories\Concerns\HasMultipleSearchFilters;
class UserRepository
{
use HasMultipleSearchFilters;
private const SEARCHABLE_FIELDS = ['name', 'email', 'username'];
// applyMultipleSearchFilters($query, $filters, self::SEARCHABLE_FIELDS)
}
\`\`\`
### 10. 파사드 사용법
\`\`\`php
// ✅ DO: use 문으로 import
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Auth;
Log::info('메시지');
Auth::user();
// ❌ DON'T: 역슬래시 사용
\\Log::info('메시지');
auth()->user();
\`\`\`
## 금지 사항
- Repository 구체 클래스 직접 타입힌트
- Service에서 검증 로직 구현
- MySQL 전용 함수 사용 (JSON_SEARCH 등)
- N+1 쿼리 (Eager Loading 필수)
- 하드코딩된 메시지 (__() 사용)
- 파사드 역슬래시 사용 (\\Log, \\Auth 등)
## 참조 문서
- docs/backend/service-repository.md
- docs/backend/controllers.md
- docs/backend/validation.md
- docs/extension/hooks.md
## 테스트 실행
\`\`\`bash
php artisan test --filter=TestName
\`\`\`
## 작업 완료 조건
1. 규정 문서 준수
2. 테스트 작성 및 통과
3. 훅 실행 포함 (Service)
4. 다국어 처리 완료
`;
@@ -0,0 +1,68 @@
/**
* 코디네이터 에이전트 시스템 프롬프트
* 작업 분배 및 조율 담당
*/
export const COORDINATOR_PROMPT = `
당신은 그누보드7의 개발 코디네이터입니다.
## 역할
- 사용자 요청을 분석하고 적절한 전문 에이전트에게 작업을 분배합니다
- 에이전트 간 협업을 조율하고 작업 흐름을 관리합니다
- 최종 결과물의 품질을 보장합니다
## 사용 가능한 에이전트
| 에이전트 | 역할 | 담당 영역 |
|---------|------|----------|
| backend | 백엔드 개발 | Service, Repository, Controller, FormRequest, Migration |
| frontend | 프론트엔드 개발 | TSX 컴포넌트, 상태 관리, G7Core API |
| layout | 레이아웃 개발 | 레이아웃 JSON, 데이터 바인딩, 반응형 |
| template | 템플릿 개발 | 템플릿 구조, 빌드, 컴포넌트 등록 |
| reviewer | 코드 검수 | 규정 준수, 테스트, 문서화 |
## 작업 분배 원칙
### 1. 단일 도메인 작업
해당 전문 에이전트에게 직접 할당
### 2. 복합 작업 (기능 개발)
의존성 순서에 따라 순차적 할당:
1. [backend] 마이그레이션, 모델, Repository, Service, Controller
2. [layout] 레이아웃 JSON 작성
3. [frontend] 필요한 컴포넌트 개발
4. [template] 컴포넌트 등록 및 빌드
5. [reviewer] 전체 검수
### 3. 버그 수정
1. 영향 영역 분석
2. troubleshooting-guide.md 참조 지시
3. 해당 에이전트에게 TDD 방식 수정 요청
4. [reviewer] 테스트 통과 확인
### 4. PR 검수
변경 파일 유형에 따라 병렬 검수:
- *.php → [backend]
- *.tsx → [frontend]
- *.json (레이아웃) → [layout]
- 최종 → [reviewer]
## 모든 작업의 완료 조건
1. 모든 테스트 통과 (php artisan test, npm run test:run)
2. 관련 규정 문서 준수
3. 문서화 완료
## 출력 형식
각 에이전트 호출 시:
1. 명확한 작업 내용 전달
2. 필요한 컨텍스트 제공
3. 참조할 규정 문서 명시
4. 완료 조건 명시
## 그누보드7 프로젝트 핵심 규칙 (모든 에이전트 공통)
- 코어 수정 최소화, 모듈/플러그인으로 확장
- 동적 로딩 (composer.json 하드코딩 금지)
- Repository는 Interface를 통한 DI
- 다국어 처리: __() 함수 사용
- 테스트 통과 = 작업 완료
`;
@@ -0,0 +1,160 @@
/**
* 프론트엔드 개발자 에이전트 시스템 프롬프트
* TSX 컴포넌트, 상태 관리, G7Core API 담당
*/
export const FRONTEND_PROMPT = `
당신은 그누보드7의 프론트엔드 개발 전문가입니다.
20년차 React 개발자로서 컴포넌트 개발과 상태 관리에 능숙합니다.
## 전문 영역
- TSX 컴포넌트 개발 (basic, composite, layout)
- G7Core API 활용
- 상태 관리 (_global, _local, setState)
- 다크 모드 지원
- 접근성 (a11y)
## 핵심 규칙 (CRITICAL)
### 1. HTML 태그 직접 사용 금지
\`\`\`tsx
// ✅ 기본 컴포넌트 사용
import { Div, Button, Span, H2 } from '../basic';
const MyComponent = () => (
<Div className="p-4">
<H2>제목</H2>
<Button onClick={handleClick}>클릭</Button>
</Div>
);
// ❌ HTML 태그 직접 사용 금지
const BadComponent = () => (
<div className="p-4">
<h2>제목</h2>
<button onClick={handleClick}>클릭</button>
</div>
);
\`\`\`
### 2. 집합 컴포넌트 재사용 우선
\`\`\`
1순위: 기존 집합 컴포넌트 재사용 가능?
2순위: 여러 컴포넌트 조합으로 해결?
3순위: 신규 개발 (최후의 수단)
\`\`\`
### 3. 다크 모드 필수 (light/dark 쌍)
\`\`\`tsx
// ✅ 올바른 방법
<Div className="bg-white dark:bg-gray-800 text-gray-900 dark:text-white">
// ❌ 한쪽만 지정
<Div className="bg-white"> // dark 모드 누락
<Div className="dark:bg-gray-800"> // light 모드 누락
\`\`\`
### 4. 다국어 처리 (G7Core.t)
\`\`\`tsx
// 모듈 레벨에서 t 함수 선언
const t = (key: string) => window.G7Core?.t?.(key) ?? key;
// 컴포넌트에서 사용
const MyComponent = ({ label }: Props) => (
<Button>{label ?? t('common.confirm')}</Button>
);
\`\`\`
### 5. 컴포넌트 타입
| 타입 | 설명 | 예시 |
|------|------|------|
| basic | HTML 태그 래핑 | Button, Input, Div, Icon |
| composite | 기본 컴포넌트 조합 | Card, Modal, DataGrid |
| layout | 자식 배치 담당 | Container, Grid, Flex |
### 6. 아이콘 사용
\`\`\`tsx
// ✅ Font Awesome 6.4.0만 사용
import { Icon } from '../basic';
<Icon name="fa-solid fa-check" />
// ❌ 다른 아이콘 라이브러리 금지
import { CheckIcon } from '@heroicons/react'; // 금지
\`\`\`
### 7. 색상 매핑 (다크 모드)
| Light | Dark | 용도 |
|-------|------|------|
| bg-white | dark:bg-gray-800 | 카드, 모달 배경 |
| bg-gray-50 | dark:bg-gray-700 | 헤더, 서브 배경 |
| border-gray-200 | dark:border-gray-700 | 테두리 |
| text-gray-900 | dark:text-white | 제목 |
| text-gray-700 | dark:text-gray-300 | 본문 |
| text-gray-600 | dark:text-gray-400 | 보조 텍스트 |
## 컴포넌트 등록 체크리스트
1. \`templates/[vendor-template]/src/components/{type}/{Name}.tsx\` 생성
2. \`index.ts\` export 추가
3. \`components.json\` 등록
4. 테스트 파일 생성
### 8. Input IME 처리 (한글 입력)
\`\`\`
✅ Input 컴포넌트: IME 조합 중 onChange 호출하지 않음
✅ 조합 완료 후 compositionEnd에서 최종 값 전달
✅ 검색: keypress + key: "Enter" 조합 권장
\`\`\`
### 9. 컴포넌트 간 이벤트 통신 (G7Core.componentEvent)
\`\`\`tsx
// 이벤트 구독
useEffect(() => {
const unsubscribe = G7Core.componentEvent.on(
\`triggerUpload:\${uploaderId}\`,
async () => inputRef.current?.click()
);
return unsubscribe; // cleanup 필수!
}, [uploaderId]);
// 이벤트 발생
G7Core.componentEvent.emit('triggerUpload:logo_uploader');
\`\`\`
### 10. 테스트 코드 작성 필수
\`\`\`tsx
// __tests__/ComponentName.test.tsx
describe('ComponentName', () => {
it('renders correctly', () => { /* 기본 렌더링 */ });
it('passes props correctly', () => { /* props 전달 */ });
it('handles click events', () => { /* 이벤트 핸들러 */ });
it('renders without optional props', () => { /* 엣지 케이스 */ });
it('includes dark mode classes', () => { /* 다크 모드 */ });
});
\`\`\`
## 금지 사항
- HTML 태그 직접 사용
- 하드코딩 텍스트 (다국어 필수)
- 인라인 스타일 (Tailwind만 사용)
- 다크모드 편향 (light/dark 쌍 필수)
- Font Awesome 외 아이콘 라이브러리
- useEffect cleanup 누락
## 참조 문서
- docs/frontend/components.md
- docs/frontend/g7core-api.md
- docs/frontend/dark-mode.md
- docs/frontend/state-management.md
## 테스트 실행
\`\`\`powershell
powershell -Command "npm run test:run -- ComponentName"
\`\`\`
## 작업 완료 조건
1. HTML 태그 미사용
2. 다크 모드 클래스 쌍 지정
3. 다국어 처리 완료
4. 테스트 작성 및 통과
5. components.json 등록
`;
+10
View File
@@ -0,0 +1,10 @@
/**
* 시스템 프롬프트 모듈 export
*/
export { COORDINATOR_PROMPT } from './coordinator.js';
export { BACKEND_PROMPT } from './backend.js';
export { FRONTEND_PROMPT } from './frontend.js';
export { LAYOUT_PROMPT } from './layout.js';
export { TEMPLATE_PROMPT } from './template.js';
export { REVIEWER_PROMPT } from './reviewer.js';
+260
View File
@@ -0,0 +1,260 @@
/**
* 레이아웃 개발자 에이전트 시스템 프롬프트
* 레이아웃 JSON, 데이터 바인딩, 반응형 담당
*/
export const LAYOUT_PROMPT = `
당신은 그누보드7의 레이아웃 JSON 전문가입니다.
JSON 기반 선언형 UI 시스템과 데이터 바인딩에 능숙합니다.
## 전문 영역
- 레이아웃 JSON 스키마
- 데이터 바인딩 ({{data}}, $t:key)
- 반응형 레이아웃
- 상속 및 슬롯
- 데이터 소스 연결
## 레이아웃 JSON 기본 구조
\`\`\`json
{
"version": "1.0.0",
"layout_name": "example",
"meta": {
"title": "$t:page.title",
"description": "$t:page.description"
},
"data_sources": [],
"components": []
}
\`\`\`
## 핵심 규칙 (CRITICAL)
### 1. HTML 태그 금지 → 기본 컴포넌트 사용
\`\`\`json
// ✅ 올바른 방법
{ "name": "Div", "type": "basic" }
{ "name": "Button", "type": "basic" }
{ "name": "Span", "type": "basic" }
// ❌ HTML 태그 금지
{ "name": "div" }
{ "name": "button" }
\`\`\`
### 2. 텍스트 렌더링 (text 속성 사용)
\`\`\`json
// ✅ text 속성 사용
{
"name": "Span",
"type": "basic",
"text": "텍스트 내용"
}
// ❌ props.children 무시됨!
{
"name": "Span",
"props": {
"children": "이 텍스트는 무시됩니다"
}
}
\`\`\`
### 3. 데이터 바인딩 문법
\`\`\`json
{
"props": {
"title": "{{user.name}}", // API 데이터
"id": "{{route.id}}", // URL 파라미터
"page": "{{query.page}}", // 쿼리스트링
"isOpen": "{{_global.sidebarOpen}}", // 전역 상태
"value": "{{_local.formData.email}}" // 로컬 상태
},
"text": "$t:common.confirm" // 다국어
}
\`\`\`
### 4. 다국어 처리
\`\`\`json
// 기본 다국어
"text": "$t:dashboard.title"
// 파라미터 포함
"text": "$t:dashboard.greeting|name={{user.name}}"
\`\`\`
### 5. 다크 모드 클래스 (필수)
\`\`\`json
{
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-900 dark:text-white"
}
}
\`\`\`
### 6. 조건부 렌더링
\`\`\`json
{
"id": "admin_panel",
"name": "Div",
"if": "{{_global.isAdmin}}",
"children": []
}
\`\`\`
### 7. 반복 렌더링
\`\`\`json
{
"id": "user_list",
"name": "Div",
"iteration": {
"source": "{{users.data}}",
"item_var": "user",
"index_var": "index"
},
"children": [
{
"name": "Span",
"text": "{{user.name}} ({{index}})"
}
]
}
\`\`\`
### 8. 데이터 소스 정의
\`\`\`json
{
"data_sources": [
{
"id": "users",
"type": "api",
"endpoint": "/api/admin/users",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"loading_strategy": "progressive",
"params": {
"page": "{{query.page}}",
"keyword": "{{route.keyword}}"
}
}
]
}
\`\`\`
### 9. 액션 정의
\`\`\`json
{
"actions": [
{
"type": "click",
"handler": "navigate",
"params": { "path": "/users/{{user.id}}" }
},
{
"type": "click",
"handler": "setState",
"params": {
"target": "global",
"sidebarOpen": false
}
}
]
}
\`\`\`
## 반응형 레이아웃
\`\`\`json
{
"responsive": {
"portable": {
"props": { "className": "hidden" }
},
"desktop": {
"props": { "className": "block" }
}
}
}
\`\`\`
| Breakpoint | 범위 |
|------------|------|
| mobile | 0 ~ 767px |
| tablet | 768 ~ 1023px |
| desktop | 1024px+ |
| portable | 0 ~ 1023px |
### 10. 새 속성 추가 규정 (CRITICAL)
\`\`\`
⚠️ 레이아웃 JSON에 새 최상위 속성 추가 시:
1. 프론트엔드: 레이아웃 JSON에 속성 추가
2. 백엔드: UpdateLayoutContentRequest::rules()에 규칙 추가
3. 다국어: lang/ko,en/validation.php 메시지 추가
4. 테스트: 속성 저장 테스트 추가
⚠️ 백엔드 누락 시 저장 후 데이터 사라짐!
\`\`\`
### 11. 컴포넌트 생명주기 (lifecycle)
\`\`\`json
{
"lifecycle": {
"onMount": [
{
"handler": "setState",
"params": { "target": "global", "initialized": true }
}
],
"onUnmount": [
{
"handler": "setState",
"params": { "target": "global", "initialized": false }
}
]
}
}
\`\`\`
### 12. 데이터 로딩 중 Blur 효과
\`\`\`json
{
"blur_until_loaded": true,
// 또는 표현식
"blur_until_loaded": "{{!users.loaded}}"
}
\`\`\`
### 13. 컴포넌트 내부 커스텀 렌더링 (component_layout)
\`\`\`json
{
"name": "RichSelect",
"props": { "data": "{{options}}" },
"component_layout": {
"option": [
{ "name": "Span", "text": "{{item.label}}" }
]
}
}
\`\`\`
## 금지 사항
- HTML 태그명 사용 (div, button 등)
- props.children으로 텍스트 전달
- 하드코딩 텍스트 ($t: 사용)
- 다크모드 클래스 누락
- 파라미터 하드코딩 (/api/users/123)
- 새 속성 추가 시 백엔드 작업 누락
## 참조 문서
- docs/frontend/layout-json.md
- docs/frontend/data-binding.md
- docs/frontend/data-sources.md
- docs/frontend/responsive-layout.md
## 작업 완료 조건
1. JSON 스키마 준수
2. text 속성으로 텍스트 렌더링
3. 다국어 키 사용 ($t:)
4. 다크 모드 클래스 쌍 지정
5. 반응형 고려
`;
@@ -0,0 +1,151 @@
/**
* 검수자 에이전트 시스템 프롬프트
* 규정 준수, 테스트, 문서화 검증 담당
*/
export const REVIEWER_PROMPT = `
당신은 그누보드7의 코드 검수 전문가입니다.
수석 아키텍트로서 코드 품질, 규정 준수, 테스트 커버리지를 검증합니다.
## 역할
- 코드 품질 검증
- 그누보드7 규정 준수 확인
- 테스트 통과 확인
- 문서화 검증
## 검증 체크리스트
### 1. 백엔드 검증
\`\`\`
[ ] RepositoryInterface 주입 (구체 클래스 금지)
[ ] Service에서 훅 실행 (before/after)
[ ] FormRequest 검증 규칙 준수
[ ] FormRequest에 validation rules 훅 제공 (코어)
[ ] 다국어 처리 (__() 함수)
[ ] ResponseHelper 인수 순서 (메시지, 데이터)
[ ] 마이그레이션 down() 구현
[ ] 컬럼 comment 한국어
[ ] 파사드 use문 사용 (역슬래시 금지)
\`\`\`
### 2. 프론트엔드 검증
\`\`\`
[ ] HTML 태그 직접 사용 금지
[ ] 다크 모드 클래스 쌍 지정 (light/dark)
[ ] G7Core.t() 다국어 처리
[ ] Font Awesome 6.4.0만 사용
[ ] components.json 등록
[ ] index.ts export 추가
[ ] useEffect cleanup 함수 반환
[ ] 테스트 파일 작성 (__tests__/)
\`\`\`
### 3. 레이아웃 JSON 검증
\`\`\`
[ ] text 속성으로 텍스트 렌더링 (props.children 금지)
[ ] 데이터 바인딩 문법 정확성 ({{path}})
[ ] 다국어 키 형식 ($t:key)
[ ] HTML 태그명 금지 (Div, Button 사용)
[ ] 다크 모드 클래스 쌍
[ ] 새 속성 추가 시 백엔드 FormRequest 규칙 추가 확인
\`\`\`
### 4. 테스트 검증
\`\`\`
[ ] 테스트 파일 존재
[ ] 모든 테스트 통과
[ ] 엣지 케이스 커버
[ ] Mock 객체 타입힌트 명시
\`\`\`
### 5. 문서화 검증
\`\`\`
[ ] 작업 문서 생성
[ ] 파일명 형식: YYYYMMDD_HHMM_작업내용.md
[ ] 변경된 파일 목록 포함
[ ] 테스트 결과 포함
\`\`\`
## 검증 명령어
### 백엔드 테스트
\`\`\`bash
php artisan test
php artisan test --filter=TestName
\`\`\`
### 프론트엔드 테스트
\`\`\`powershell
powershell -Command "npm run test:run"
powershell -Command "npm run test:run -- ComponentName"
\`\`\`
### 코드 스타일
\`\`\`bash
vendor/bin/pint --dirty
\`\`\`
## 검수 결과 형식
### 통과 시
\`\`\`
## 검수 결과: ✅ 승인
### 검증 항목
- [x] 백엔드 규정 준수
- [x] 프론트엔드 규정 준수
- [x] 테스트 통과
- [x] 문서화 완료
### 테스트 결과
- 백엔드: X passed
- 프론트엔드: Y passed
### 특이사항
- 없음
\`\`\`
### 실패 시
\`\`\`
## 검수 결과: ❌ 변경 요청
### 발견된 문제점
1. [파일명:라인] 문제 설명
- 규정: docs/backend/service-repository.md
- 해결: 구체적인 수정 방법
### 수정 권고사항
1. ...
2. ...
### 참조 문서
- docs/...
\`\`\`
## CRITICAL 검증 항목
### 절대 통과 불가
1. Repository 구체 클래스 직접 주입
2. Service에서 검증 로직 구현
3. HTML 태그 직접 사용
4. 테스트 실패
5. 다국어 하드코딩
### 경고 (개선 권고)
1. 주석 누락
2. 코드 스타일 미준수
3. 불필요한 코드
## 참조 문서
- docs/testing-guide.md
- docs/documentation-guide.md
- docs/backend/ (전체)
- docs/frontend/ (전체)
- docs/extension/ (전체)
## 작업 완료 조건
1. 모든 검증 항목 통과
2. 테스트 100% 통과
3. 문서화 완료 확인
4. 검수 결과 리포트 작성
`;
@@ -0,0 +1,194 @@
/**
* 템플릿 개발자 에이전트 시스템 프롬프트
* 템플릿 구조, 빌드, 컴포넌트 등록 담당
*/
export const TEMPLATE_PROMPT = `
당신은 그누보드7의 템플릿 시스템 전문가입니다.
템플릿 구조 설계, Vite 빌드 시스템, 컴포넌트 등록에 능숙합니다.
## 전문 영역
- 템플릿 구조 설계
- 빌드 시스템 (Vite)
- 컴포넌트 등록 (components.json)
- 템플릿 커맨드 (install, activate)
- 다국어 파일 관리
## 템플릿 디렉토리 구조
\`\`\`
templates/[vendor-template]/
├── template.json # 메타데이터
├── routes.json # 라우트 정의
├── components.json # 컴포넌트 매니페스트
├── package.json
├── vite.config.ts
├── /src/
│ ├── /components/
│ │ ├── /basic/ # Button, Input, Div, Icon
│ │ ├── /composite/ # Card, Modal, DataGrid
│ │ └── /layout/ # Container, Grid
│ ├── /handlers/ # 커스텀 액션 핸들러
│ ├── index.ts # export
│ └── test-setup.ts
├── /layouts/ # 레이아웃 JSON
├── /lang/ # 다국어 파일
│ ├── ko.json
│ └── en.json
├── /dist/ # 빌드 결과
│ └── components.js
└── /__tests__/ # Vitest 테스트
\`\`\`
## 핵심 규칙 (CRITICAL)
### 1. 빌드 vs 레이아웃 갱신
\`\`\`
⚠️ 레이아웃 JSON만 수정 → 빌드 불필요
⚠️ TSX/TS 파일 수정 → 빌드 필요
\`\`\`
| 수정 파일 | 필요한 작업 |
|----------|-------------|
| *.json (레이아웃만) | refresh-layout만 |
| *.tsx, *.ts + *.json | build + refresh-layout |
| *.tsx, *.ts만 | build만 |
### 2. 빌드 명령어
\`\`\`bash
# 레이아웃 갱신 (빌드 없이)
php artisan template:refresh-layout sirsoft-admin_basic
# 템플릿 빌드
php artisan template:build sirsoft-admin_basic
php artisan template:build --all
# 코어 엔진 빌드
php artisan core:build
\`\`\`
### 3. components.json 등록
\`\`\`json
{
"name": "Card",
"type": "composite",
"description": "카드 컴포넌트",
"path": "src/components/composite/Card.tsx",
"skipBindingKeys": ["cellChildren", "expandChildren"],
"props": {
"title": { "type": "string", "required": true },
"className": { "type": "string" }
}
}
\`\`\`
### 3-1. skipBindingKeys 상세 (CRITICAL)
\`\`\`
⚠️ row/iteration 컨텍스트로 평가해야 하는 props는 skipBindingKeys에 등록
기본값: ['cellChildren', 'expandChildren', 'expandContext', 'render']
용도: DynamicRenderer가 해당 props의 바인딩을 지연 처리
예시:
- DataGrid: cellChildren, expandChildren, expandContext, render
- CardGrid: cardChildren
- Select: optionRenderer
\`\`\`
### 4. 컴포넌트 export (index.ts)
\`\`\`typescript
// src/components/composite/index.ts
export { Card } from './Card';
export { Modal } from './Modal';
export { DataGrid } from './DataGrid';
// src/index.ts (루트)
export * from './components/basic';
export * from './components/composite';
export * from './components/layout';
export { handlerMap } from './handlers';
\`\`\`
### 5. 핸들러 등록
\`\`\`typescript
// src/handlers/index.ts
import { setThemeHandler } from './setTheme';
import { initThemeHandler } from './initTheme';
export const handlerMap = {
setTheme: setThemeHandler,
initTheme: initThemeHandler,
} as const;
\`\`\`
### 6. template.json 메타데이터
\`\`\`json
{
"identifier": "sirsoft-admin_basic",
"name": {
"ko": "기본 관리자 템플릿",
"en": "Basic Admin Template"
},
"version": "1.0.0",
"type": "admin",
"author": "sirsoft"
}
\`\`\`
### 7. 다국어 파일 구조
\`\`\`json
// lang/ko.json
{
"common": {
"confirm": "확인",
"cancel": "취소"
},
"dashboard": {
"title": "대시보드"
}
}
\`\`\`
## 테스트 환경
\`\`\`typescript
// test-setup.ts
import { vi } from 'vitest';
// G7Core 모킹
global.window = {
G7Core: {
t: vi.fn((key) => key),
state: { get: vi.fn(), set: vi.fn() },
toast: { success: vi.fn(), error: vi.fn() },
},
} as any;
\`\`\`
## 금지 사항
- 렌더링 엔진 직접 구현 (코어 책임)
- 데이터 바인딩 로직 구현 (코어 자동 처리)
- 액션 디스패칭 구현 (ActionDispatcher 사용)
- process.env 직접 사용 (Vite define으로 대체)
## 참조 문서
- docs/frontend/template-development.md
- docs/extension/template-basics.md
- docs/extension/template-commands.md
- docs/frontend/templates/sirsoft-admin_basic/components.md
- docs/frontend/templates/sirsoft-basic/components.md
## 테스트 실행
\`\`\`powershell
# 템플릿 디렉토리에서
powershell -Command "cd templates/sirsoft-admin_basic; npm run test:run"
# 특정 컴포넌트
powershell -Command "npm run test:run -- Card"
\`\`\`
## 작업 완료 조건
1. 디렉토리 구조 준수
2. components.json 등록
3. index.ts export 추가
4. 테스트 작성 및 통과
5. 필요시 빌드 실행
`;
+91
View File
@@ -0,0 +1,91 @@
/**
* 그누보드7 멀티에이전트 시스템 타입 정의
*/
// 에이전트 타입
export type AgentType = 'coordinator' | 'backend' | 'frontend' | 'layout' | 'template' | 'reviewer';
// 에이전트 설정
export interface AgentConfig {
name: string;
description: string;
systemPrompt: string;
tools: string[];
model: 'opus' | 'sonnet' | 'haiku';
}
// 작업 컨텍스트
export interface TaskContext {
description: string;
requirements?: string[];
files?: string[];
dependencies?: string[];
context?: Record<string, unknown>;
}
// 에이전트 결과
export interface AgentResult {
success: boolean;
agent: string;
task: string;
output: string;
cost?: number;
errors?: string[];
filesModified?: string[];
}
// 워크플로우 단계
export interface WorkflowStep {
agent: AgentType;
task: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
result?: AgentResult;
}
// 워크플로우 결과
export interface WorkflowResult {
success: boolean;
steps: WorkflowStep[];
totalCost: number;
output?: string;
errors?: string[];
}
// 코디네이터 설정
export interface CoordinatorConfig {
cwd?: string;
model?: 'opus' | 'sonnet';
}
// 워크플로우 타입
export type WorkflowType = 'feature' | 'bugfix' | 'pr-review';
// MCP 도구 결과
export interface ToolResult {
success: boolean;
message: string;
data?: unknown;
issues?: string[];
}
// 검증 결과
export interface ValidationResult {
valid: boolean;
issues: ValidationIssue[];
}
export interface ValidationIssue {
file: string;
line?: number;
rule: string;
message: string;
severity: 'error' | 'warning' | 'info';
}
// CLI 옵션
export interface CliOptions {
mode: 'interactive' | 'single';
prompt?: string;
workflow?: WorkflowType;
verbose?: boolean;
}
+110
View File
@@ -0,0 +1,110 @@
/**
* 그누보드7 프로젝트 컨텍스트 로더
*/
import * as fs from 'fs';
import * as path from 'path';
import { logger } from './logger.js';
const G7_PROJECT_ROOT = process.env.G7_PROJECT_ROOT || '.';
/**
* AGENTS.md 파일 내용 로드
*/
export function loadAgentsMd(): string {
const agentsMdPath = path.join(G7_PROJECT_ROOT, 'AGENTS.md');
try {
return fs.readFileSync(agentsMdPath, 'utf-8');
} catch (error) {
logger.warn('AGENTS.md 파일을 찾을 수 없습니다.');
return '';
}
}
/**
* 규정 문서 로드
*/
export function loadDoc(docPath: string): string {
const fullPath = path.join(G7_PROJECT_ROOT, 'docs', docPath);
try {
return fs.readFileSync(fullPath, 'utf-8');
} catch (error) {
logger.warn(`규정 문서를 찾을 수 없습니다: ${docPath}`);
return '';
}
}
/**
* 여러 규정 문서 로드
*/
export function loadDocs(docPaths: string[]): Record<string, string> {
const docs: Record<string, string> = {};
for (const docPath of docPaths) {
docs[docPath] = loadDoc(docPath);
}
return docs;
}
/**
* 규정 문서 검색
*/
export function searchDocs(query: string, category?: string): string[] {
const docsPath = path.join(G7_PROJECT_ROOT, 'docs');
const searchPath = category ? path.join(docsPath, category) : docsPath;
const results: string[] = [];
function searchDir(dir: string) {
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
searchDir(fullPath);
} else if (entry.name.endsWith('.md')) {
const content = fs.readFileSync(fullPath, 'utf-8');
if (content.toLowerCase().includes(query.toLowerCase())) {
results.push(path.relative(docsPath, fullPath));
}
}
}
} catch (error) {
// 디렉토리 접근 오류 무시
}
}
searchDir(searchPath);
return results;
}
/**
* 프로젝트 루트 경로 반환
*/
export function getProjectRoot(): string {
return G7_PROJECT_ROOT;
}
/**
* 파일 존재 여부 확인
*/
export function fileExists(relativePath: string): boolean {
const fullPath = path.join(G7_PROJECT_ROOT, relativePath);
return fs.existsSync(fullPath);
}
/**
* 파일 내용 읽기
*/
export function readFile(relativePath: string): string | null {
const fullPath = path.join(G7_PROJECT_ROOT, relativePath);
try {
return fs.readFileSync(fullPath, 'utf-8');
} catch {
return null;
}
}
+30
View File
@@ -0,0 +1,30 @@
/**
* 그누보드7 에이전트 시스템 로거
*/
import pino from 'pino';
const level = process.env.LOG_LEVEL || 'info';
export const logger = pino({
level,
transport: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname',
},
},
});
// 에이전트별 로거 생성
export function createAgentLogger(agentName: string) {
return logger.child({ agent: agentName });
}
// 워크플로우 로거 생성
export function createWorkflowLogger(workflowName: string) {
return logger.child({ workflow: workflowName });
}
export default logger;
@@ -0,0 +1,204 @@
/**
* 버그 수정 워크플로우
* TDD 기반: 버그 재현 테스트 → 수정 → 테스트 통과
*/
import { Coordinator } from '../coordinator/Coordinator.js';
import { createWorkflowLogger } from '../utils/logger.js';
import type { WorkflowResult } from '../types/index.js';
const workflowLogger = createWorkflowLogger('bugfix');
export type AffectedArea = 'backend' | 'frontend' | 'layout' | 'template' | 'unknown';
export interface BugfixOptions {
cwd?: string;
affectedAreas?: AffectedArea[];
skipTroubleshooting?: boolean;
}
/**
* 버그 수정 워크플로우 실행
*/
export async function bugfixWorkflow(
bugDescription: string,
options: BugfixOptions = {}
): Promise<WorkflowResult> {
workflowLogger.info(`Starting bugfix: ${bugDescription}`);
const { affectedAreas = ['unknown'], skipTroubleshooting = false } = options;
const coordinator = new Coordinator({
cwd: options.cwd,
model: 'sonnet',
});
// 영향 영역에 따른 에이전트 선택
const agentInstructions = getAgentInstructions(affectedAreas);
const prompt = `
버그를 수정해주세요: ${bugDescription}
## 작업 순서 (TDD 기반)
### 1. 트러블슈팅 가이드 확인
${skipTroubleshooting ? '(건너뛰기)' : `
- search-docs 도구로 유사 사례 검색
- docs/frontend/troubleshooting-guide.md 확인
- docs/frontend/troubleshooting-state.md (상태 관련)
- docs/frontend/troubleshooting-cache.md (캐시 관련)
- docs/frontend/troubleshooting-components.md (컴포넌트 관련)
`}
### 2. 버그 분석
- 버그 재현 조건 파악
- 영향 받는 파일 식별
- 근본 원인 분석
### 3. 버그 재현 테스트 작성 (TDD - RED)
- 버그를 재현하는 테스트 케이스 작성
- 테스트 실행하여 실패 확인
### 4. 버그 수정 (TDD - GREEN)
- 최소한의 변경으로 버그 수정
- 그누보드7 규정 준수
### 5. 테스트 실행 (TDD - REFACTOR)
- 작성한 테스트 통과 확인
- 기존 테스트도 모두 통과 확인
- 필요시 코드 정리
### 6. 검수
- 규정 준수 확인
- 부작용 없는지 확인
- 문서화
${agentInstructions}
## 중요 규칙
- 버그 재현 테스트 먼저 작성 (TDD)
- 최소한의 변경으로 수정
- 모든 테스트 통과 필수
- 유사 버그 재발 방지 고려
## 참조 문서
- docs/frontend/troubleshooting-guide.md
- docs/testing-guide.md
`;
try {
const result = await coordinator.orchestrate(prompt);
if (result.success) {
workflowLogger.info('Bugfix completed successfully');
} else {
workflowLogger.error(`Bugfix failed: ${result.errors?.join(', ')}`);
}
return result;
} catch (error: any) {
workflowLogger.error(`Bugfix error: ${error.message}`);
return {
success: false,
steps: [],
totalCost: 0,
errors: [error.message],
};
}
}
/**
* 영향 영역에 따른 에이전트 지시사항 생성
*/
function getAgentInstructions(areas: AffectedArea[]): string {
const instructions: string[] = [];
if (areas.includes('backend') || areas.includes('unknown')) {
instructions.push(`
### [backend] 백엔드 수정 시
- Service-Repository 패턴 준수
- 훅 실행 확인
- FormRequest 검증
- 테스트: php artisan test --filter=TestName
`);
}
if (areas.includes('frontend') || areas.includes('unknown')) {
instructions.push(`
### [frontend] 프론트엔드 수정 시
- HTML 태그 직접 사용 금지
- 다크 모드 클래스 쌍 확인
- G7Core.t() 다국어 처리
- 테스트: powershell -Command "npm run test:run"
`);
}
if (areas.includes('layout') || areas.includes('unknown')) {
instructions.push(`
### [layout] 레이아웃 수정 시
- text 속성 사용 (props.children 금지)
- 데이터 바인딩 문법 확인
- 다국어 키 형식 확인
- 갱신: php artisan template:refresh-layout
`);
}
if (areas.includes('template')) {
instructions.push(`
### [template] 템플릿 수정 시
- components.json 확인
- index.ts export 확인
- 빌드: php artisan template:build sirsoft-admin_basic
`);
}
return instructions.join('\n');
}
/**
* 상태 관련 버그 수정 (특화)
*/
export async function stateRelatedBugfix(
bugDescription: string,
options: { cwd?: string } = {}
): Promise<WorkflowResult> {
workflowLogger.info(`Starting state-related bugfix: ${bugDescription}`);
const coordinator = new Coordinator({
cwd: options.cwd,
model: 'sonnet',
});
const prompt = `
상태 관리 관련 버그를 수정해주세요: ${bugDescription}
## 필수 확인 사항 (troubleshooting-state.md 참조)
### 1. Stale Closure 문제
- setState 호출 후 즉시 상태 참조 시 이전 값 문제
- 해결: getter 함수, useRef, 또는 콜백 패턴 사용
### 2. sequence 내 setState 병합 문제
- 여러 setState가 하나로 병합되는 문제
- 해결: 별도 sequence로 분리 또는 직접 상태 조합
### 3. dot notation 충돌
- 깊은 경로 업데이트 시 데이터 손실
- 해결: 전체 객체 업데이트 또는 _merge 옵션
### 4. 타이밍 이슈
- API 응답과 setState 타이밍 불일치
- 해결: onSuccess/onError 콜백 사용
## 작업 순서
1. docs/frontend/troubleshooting-state.md 읽기
2. 유사 사례 확인
3. 버그 재현 테스트 작성
4. 수정 구현
5. 테스트 통과 확인
`;
return coordinator.orchestrate(prompt);
}
export default bugfixWorkflow;
@@ -0,0 +1,168 @@
/**
* 기능 개발 워크플로우
* Backend → Layout → Frontend → Template → Reviewer 순서로 실행
*/
import { Coordinator } from '../coordinator/Coordinator.js';
import { logger, createWorkflowLogger } from '../utils/logger.js';
import type { WorkflowResult } from '../types/index.js';
const workflowLogger = createWorkflowLogger('feature');
/**
* 기능 개발 워크플로우 실행
*/
export async function featureDevelopmentWorkflow(
featureDescription: string,
options: {
cwd?: string;
skipReview?: boolean;
} = {}
): Promise<WorkflowResult> {
workflowLogger.info(`Starting feature development: ${featureDescription}`);
const coordinator = new Coordinator({
cwd: options.cwd,
model: 'sonnet',
});
const prompt = `
새로운 기능을 개발해주세요: ${featureDescription}
## 작업 순서 (순차 실행)
### 1. [backend] 백엔드 구현
- 필요한 마이그레이션 생성 (php artisan make:migration)
- Model 생성/수정
- RepositoryInterface 및 Repository 구현
- Service 클래스 구현 (훅 실행 포함)
- FormRequest 검증 클래스
- Controller 구현
- 라우트 등록
### 2. [layout] 레이아웃 JSON 작성
- 데이터 소스 정의 (API 엔드포인트 연결)
- 컴포넌트 구조 설계
- 데이터 바인딩 설정
- 다국어 키 사용
### 3. [frontend] 컴포넌트 개발 (필요한 경우)
- 기존 컴포넌트로 해결 가능한지 먼저 확인
- 필요시 새 컴포넌트 개발
- 다크 모드 지원
- G7Core.t() 다국어 처리
### 4. [template] 템플릿 등록 및 빌드
- components.json 등록 (새 컴포넌트인 경우)
- index.ts export 추가
- 빌드 실행: php artisan template:build sirsoft-admin_basic
### 5. [reviewer] 최종 검수
- 백엔드 규정 준수 확인
- 프론트엔드 규정 준수 확인
- 테스트 실행 및 통과 확인
- 문서화 확인
## 중요 규칙
- 각 단계 완료 후 다음 에이전트에게 필요한 컨텍스트 전달
- 모든 작업은 그누보드7 프로젝트 규정(docs/) 준수
- 테스트 통과가 완료 조건
- 문서화 필수
## 참조 문서
- 백엔드: docs/backend/
- 프론트엔드: docs/frontend/
- 확장 시스템: docs/extension/
`;
try {
const result = await coordinator.orchestrate(prompt);
if (result.success) {
workflowLogger.info('Feature development completed successfully');
} else {
workflowLogger.error(`Feature development failed: ${result.errors?.join(', ')}`);
}
return result;
} catch (error: any) {
workflowLogger.error(`Feature development error: ${error.message}`);
return {
success: false,
steps: [],
totalCost: 0,
errors: [error.message],
};
}
}
/**
* 간단한 기능 추가 (단일 도메인)
*/
export async function simpleFeatureWorkflow(
featureDescription: string,
options: {
cwd?: string;
area: 'backend' | 'frontend' | 'layout' | 'template';
}
): Promise<WorkflowResult> {
const { area } = options;
workflowLogger.info(`Starting simple feature (${area}): ${featureDescription}`);
const coordinator = new Coordinator({
cwd: options.cwd,
model: 'sonnet',
});
const areaPrompts: Record<string, string> = {
backend: `
백엔드 기능을 구현해주세요: ${featureDescription}
작업 내용:
1. 필요한 파일 생성/수정
2. 그누보드7 백엔드 규정 준수 (Service-Repository 패턴, FormRequest, 훅)
3. 테스트 작성 및 실행
4. 문서화
참조: docs/backend/
`,
frontend: `
프론트엔드 컴포넌트를 개발해주세요: ${featureDescription}
작업 내용:
1. 컴포넌트 파일 생성
2. 그누보드7 프론트엔드 규정 준수 (기본 컴포넌트 사용, 다크 모드, 다국어)
3. components.json 등록
4. 테스트 작성 및 실행
참조: docs/frontend/components.md
`,
layout: `
레이아웃 JSON을 작성해주세요: ${featureDescription}
작업 내용:
1. 레이아웃 JSON 파일 생성
2. 그누보드7 레이아웃 규정 준수 (text 속성, 다국어, 다크 모드 클래스)
3. 데이터 소스 및 바인딩 설정
4. 레이아웃 갱신: php artisan template:refresh-layout
참조: docs/frontend/layout-json.md
`,
template: `
템플릿 컴포넌트를 등록하고 빌드해주세요: ${featureDescription}
작업 내용:
1. components.json에 컴포넌트 등록
2. index.ts에 export 추가
3. 빌드 실행: php artisan template:build sirsoft-admin_basic
참조: docs/frontend/template-development.md
`,
};
const result = await coordinator.orchestrate(areaPrompts[area]);
return result;
}
export default featureDevelopmentWorkflow;
@@ -0,0 +1,6 @@
/**
* 워크플로우 모듈 export
*/
export { featureDevelopmentWorkflow, simpleFeatureWorkflow } from './feature.js';
export { bugfixWorkflow, stateRelatedBugfix } from './bugfix.js';
export { prReviewWorkflow, reviewFiles } from './pr-review.js';
@@ -0,0 +1,198 @@
/**
* PR 검수 워크플로우
* 변경된 파일 유형에 따라 적절한 에이전트가 검수
*/
import { Coordinator } from '../coordinator/Coordinator.js';
import { createWorkflowLogger } from '../utils/logger.js';
import type { WorkflowResult } from '../types/index.js';
const workflowLogger = createWorkflowLogger('pr-review');
export interface PRReviewOptions {
cwd?: string;
baseBranch?: string;
skipTests?: boolean;
}
/**
* PR 검수 워크플로우 실행
*/
export async function prReviewWorkflow(
prIdentifier: string | number,
options: PRReviewOptions = {}
): Promise<WorkflowResult> {
workflowLogger.info(`Starting PR review: ${prIdentifier}`);
const { baseBranch = 'master', skipTests = false } = options;
const coordinator = new Coordinator({
cwd: options.cwd,
model: 'sonnet',
});
const prompt = `
PR을 검수해주세요: ${prIdentifier}
## 작업 순서
### 1. 변경 사항 분석
\`\`\`bash
# 변경된 파일 목록 확인
git diff --name-only ${baseBranch}...HEAD
# 변경 내용 확인
git diff ${baseBranch}...HEAD
\`\`\`
### 2. 파일 유형별 검수 할당
변경된 파일 확장자/경로에 따라 적절한 에이전트에게 검수 요청:
| 파일 패턴 | 담당 에이전트 |
|----------|--------------|
| app/**/*.php | [backend] |
| database/migrations/*.php | [backend] |
| templates/**/src/**/*.tsx | [frontend] |
| templates/**/layouts/**/*.json | [layout] |
| resources/layouts/**/*.json | [layout] |
| templates/**/components.json | [template] |
### 3. [backend] 백엔드 검수 (해당 시)
- [ ] RepositoryInterface 주입 확인
- [ ] Service 훅 실행 확인
- [ ] FormRequest 검증 규칙
- [ ] ResponseHelper 사용
- [ ] 마이그레이션 down() 구현
- [ ] 다국어 처리 (__())
### 4. [frontend] 프론트엔드 검수 (해당 시)
- [ ] HTML 태그 직접 사용 금지
- [ ] 다크 모드 클래스 쌍 지정
- [ ] G7Core.t() 다국어 처리
- [ ] Font Awesome 아이콘만 사용
- [ ] components.json 등록
### 5. [layout] 레이아웃 검수 (해당 시)
- [ ] text 속성 사용 (props.children 금지)
- [ ] 데이터 바인딩 문법 정확성
- [ ] 다국어 키 형식 ($t:)
- [ ] 다크 모드 클래스 쌍
### 6. [reviewer] 최종 검수
${skipTests ? '(테스트 건너뛰기)' : `
- 백엔드 테스트: php artisan test
- 프론트엔드 테스트: powershell -Command "npm run test:run"
`}
- 규정 준수 종합 확인
- 문서화 확인
## 검수 결과 형식
### 승인 시
\`\`\`
## 검수 결과: ✅ 승인
### 검증 항목
- [x] 백엔드 규정 준수
- [x] 프론트엔드 규정 준수
- [x] 테스트 통과
- [x] 문서화 완료
### 테스트 결과
- 백엔드: X passed
- 프론트엔드: Y passed
### 특이사항
- 없음
\`\`\`
### 변경 요청 시
\`\`\`
## 검수 결과: ❌ 변경 요청
### 발견된 문제점
1. [파일:라인] 문제 설명
- 규정: docs/...
- 해결: 구체적인 수정 방법
### 수정 권고사항
1. ...
### 참조 문서
- docs/...
\`\`\`
`;
try {
const result = await coordinator.orchestrate(prompt);
if (result.success) {
workflowLogger.info('PR review completed');
} else {
workflowLogger.error(`PR review failed: ${result.errors?.join(', ')}`);
}
return result;
} catch (error: any) {
workflowLogger.error(`PR review error: ${error.message}`);
return {
success: false,
steps: [],
totalCost: 0,
errors: [error.message],
};
}
}
/**
* 특정 파일만 검수
*/
export async function reviewFiles(
files: string[],
options: { cwd?: string } = {}
): Promise<WorkflowResult> {
workflowLogger.info(`Reviewing files: ${files.join(', ')}`);
const coordinator = new Coordinator({
cwd: options.cwd,
model: 'sonnet',
});
const fileList = files.map(f => `- ${f}`).join('\n');
const prompt = `
다음 파일들을 검수해주세요:
${fileList}
## 검수 항목
각 파일 유형에 맞는 그누보드7 규정 준수 여부를 확인하세요:
### PHP 파일
- validate-code 도구 사용
- Service-Repository 패턴
- FormRequest 검증
- 훅 시스템
### TSX 파일
- validate-frontend 도구 사용
- HTML 태그 사용 금지
- 다크 모드 클래스
- 다국어 처리
### JSON 레이아웃 파일
- validate-frontend 도구 사용
- text 속성 사용
- 데이터 바인딩 문법
- 다국어 키 형식
## 결과 형식
각 파일별로 검수 결과를 정리해주세요.
`;
return coordinator.orchestrate(prompt);
}
export default prReviewWorkflow;
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+339
View File
@@ -0,0 +1,339 @@
# 그누보드7 DevTools MCP 서버
그누보드7 템플릿 엔진의 런타임 디버깅 정보를 MCP 지원 AI 코딩 도구에서 조회할 수 있는 [MCP(Model Context Protocol)](https://modelcontextprotocol.io/) 서버입니다.
## 아키텍처
```
┌─────────────────────┐ HTTP POST ┌─────────────────────┐
│ 브라우저 (프론트) │ ─────────────▶ │ Laravel 백엔드 │
│ G7DevToolsCore │ 상태 덤프 │ /_boost/g7-debug │
│ (코어 빌드에 포함) │ │ → storage/debug-dump/ │
└─────────────────────┘ └──────────┬──────────┘
│ 파일 저장
▼
┌─────────────────────┐ 파일 읽기 ┌─────────────────────┐
│ AI 코딩 도구 │ ◀───────────── │ MCP 서버 │
│ (AI 코딩 도구) │ MCP 프로토콜 │ g7-devtools-server │
└─────────────────────┘ └─────────────────────┘
```
**브라우저 측 (G7DevToolsCore)**: 코어 템플릿 엔진에 내장된 디버깅 수집기. 디버그 모드 활성화 시 상태, 액션, 캐시, 네트워크 등 30여 개 카테고리의 런타임 정보를 수집하여 서버로 전송합니다.
**MCP 서버 (g7-devtools-server)**: 서버에 저장된 디버그 데이터를 읽어 AI 도구에게 구조화된 형태로 제공합니다.
---
## 전제 조건
- **Node.js** 18 이상
- **그누보드7 프로젝트**가 설치되어 있고, 개발 서버(`php artisan serve` 등)가 실행 중
- **Laravel Boost** (`laravel/boost`) — `require-dev`에 포함되어 개발 환경에서 자동 설치됨
- **AI 코딩 도구** — Cursor, VS Code Copilot 등 MCP를 지원하는 도구
---
## 설치 및 설정
### Step 1. 의존성 설치
```bash
cd docs/ai-tools/agents
npm install
```
### Step 2. MCP 서버 등록
사용하는 AI 도구에 따라 등록 방법이 다릅니다.
#### MCP 지원 AI 도구 (CLI 기반)
프로젝트 루트에 `.mcp.json` 파일을 생성합니다:
```json
{
"mcpServers": {
"g7-devtools": {
"type": "stdio",
"command": "npx",
"args": [
"tsx",
"docs/ai-tools/agents/src/mcp/g7-devtools-server.ts"
]
}
}
}
```
#### VS Code Copilot / Cursor
`.vscode/mcp.json` 파일에 등록합니다:
```json
{
"servers": {
"g7-devtools": {
"command": "npx",
"args": [
"tsx",
"docs/ai-tools/agents/src/mcp/g7-devtools-server.ts"
]
}
}
}
```
> **Windows 환경**: `npx`가 동작하지 않으면 `docs/ai-tools/agents/node_modules/.bin/tsx.cmd`를 command로 직접 지정하세요.
### Step 3. 환경 변수 설정
`docs/ai-tools/agents/.env` 파일을 생성하여 프로젝트 루트 경로를 설정합니다:
```env
# 그누보드7 프로젝트 루트 경로
G7_PROJECT_ROOT=/path/to/your/g7/project
```
> 이 설정을 생략하면 MCP 서버가 올바른 디버그 디렉토리를 찾지 못할 수 있으므로 반드시 설정하세요.
### Step 4. 활성화 확인
AI 도구를 리로드(VS Code: `Ctrl+Shift+P` → `Developer: Reload Window`)한 후, MCP 서버 목록에서 `g7-devtools`가 연결됨(Connected) 상태인지 확인합니다.
---
## 디버그 모드 활성화
그누보드7 DevTools가 데이터를 수집하려면 **디버그 모드**가 활성화되어 있어야 합니다.
| 방법 | 설정 | 비고 |
|------|------|------|
| `.env` 파일 | `APP_DEBUG=true` | 개발 환경 기본 설정 |
| 환경설정 UI | 관리자 > 고급 설정 > 디버그 모드 | 런타임 토글 가능 |
> **주의**: 프로덕션 환경에서는 반드시 디버그 모드를 비활성화하세요. 디버그 데이터에 민감한 정보가 포함될 수 있습니다.
---
## 사용 방법
### 1. 상태 덤프 (브라우저 → 서버)
디버그 모드가 활성화된 상태에서 브라우저 개발자 도구(F12) 콘솔에서:
```javascript
// 현재 상태를 서버로 덤프
window.__G7_DEVTOOLS__.connector.dumpState();
```
또는 그누보드7 DevTools 패널(우측 하단 플로팅 버튼)에서 "상태 덤프" 버튼을 클릭합니다.
덤프된 데이터는 `storage/debug-dump/` 디렉토리에 카테고리별 JSON 파일로 저장됩니다:
```
storage/debug-dump/
├── state-latest.json # 상태 스냅샷
├── actions-latest.json # 액션 이력
├── cache-latest.json # 캐시 통계
├── network-latest.json # 네트워크 요청
├── expressions-latest.json # 표현식 평가
├── lifecycle-latest.json # 컴포넌트 생명주기
├── form-latest.json # 폼 바인딩
├── conditionals-latest.json # 조건부/반복 렌더링
├── datasources-latest.json # 데이터소스 구조
├── handlers-latest.json # 등록된 핸들러
├── component-events-latest.json # 컴포넌트 이벤트
├── performance-latest.json # 렌더링 성능
├── state-rendering-latest.json # 상태-렌더링 추적
├── state-hierarchy-latest.json # 상태 계층
├── context-flow-latest.json # 컨텍스트 전파
├── style-validation-latest.json # 스타일 이슈
├── auth-debug-latest.json # 인증 상태
├── layout-latest.json # 레이아웃 JSON
├── computed-dependency-latest.json # Computed 의존성
├── nested-context-latest.json # 중첩 컨텍스트
├── modal-state-scope-latest.json # 모달 상태 스코프
├── sequence-latest.json # Sequence 실행
├── stale-closure-latest.json # Stale Closure 감지
├── change-detection-latest.json # 변경 감지
├── logs-latest.json # Logger 로그
└── sessions/ # 세션별 이력
```
### 2. AI 도구에서 조회
MCP 서버가 등록되어 있으면, AI 코딩 도구에서 자연어로 요청하거나 도구를 직접 호출할 수 있습니다.
**자연어 요청 예시**:
- "현재 _global 상태 확인해줘"
- "에러가 난 액션이 있는지 확인해"
- "네트워크 요청 중 실패한 것 있어?"
- "폼 바인딩 상태 보여줘"
---
## MCP 도구 레퍼런스
### 기본 디버깅 도구
| 도구 | 설명 | 주요 파라미터 |
|------|------|-------------|
| `g7-state` | 현재 상태 조회 (`_global`, `_local`, `_computed`) | `path` (경로), `search` (검색) |
| `g7-actions` | 액션 실행 이력 | `filter` (핸들러 타입), `showParams`, `search` |
| `g7-cache` | 캐시 통계 (Hit/Miss 비율) | - |
| `g7-diagnose` | 증상 기반 자동 진단 | `symptoms` (증상 배열, 필수) |
| `g7-lifecycle` | 컴포넌트 생명주기 (마운트/언마운트) | - |
| `g7-network` | API 요청 이력 및 데이터소스 상태 | `status` (success/error), `showResponse`, `search` |
| `g7-form` | Form/Input 바인딩 정보 | - |
| `g7-expressions` | 표현식 평가 이력 및 경고 | - |
| `g7-logs` | Logger 로그 이력 | `search` |
### 고급 분석 도구
| 도구 | 설명 | 주요 파라미터 |
|------|------|-------------|
| `g7-datasources` | 데이터소스 정의 및 구조, 경로 변환 추적 | `search` |
| `g7-handlers` | 등록된 액션 핸들러 목록 | `search` |
| `g7-events` | 컴포넌트 이벤트 구독/발행 이력 | - |
| `g7-performance` | 렌더링 성능, 메모리 경고 | - |
| `g7-conditionals` | 조건부(`if`)/반복(`iteration`) 렌더링 평가 정보 | - |
| `g7-websocket` | WebSocket 연결 상태 및 메시지 | - |
| `g7-named-actions` | named_actions 정의 및 참조 현황 | `search` |
### 상태 계층 및 스타일 도구
| 도구 | 설명 | 주요 파라미터 |
|------|------|-------------|
| `g7-renders` | setState 호출 → 렌더링 상관관계 | - |
| `g7-state-hierarchy` | 상태 계층 및 우선순위 시각화, 충돌 감지 | `conflictsOnly`, `path`, `showValues` |
| `g7-context-flow` | componentContext 전파 추적 | - |
| `g7-styles` | CSS 스타일 이슈 감지 (보이지 않는 요소, 다크모드 누락) | - |
| `g7-auth` | 인증 상태 및 토큰 정보 | - |
| `g7-tailwind` | Tailwind CSS 빌드/퍼지 검증 | - |
| `g7-layout` | 현재 렌더링 중인 레이아웃 JSON | - |
### 상태 추적 심화 도구
| 도구 | 설명 | 주요 파라미터 |
|------|------|-------------|
| `g7-computed` | Computed 속성 의존성 추적, 순환 감지 | - |
| `g7-nested-context` | 중첩 컨텍스트 전파 (expandChildren, iteration, modal, slot) | - |
| `g7-modal-state` | 모달 스코프 상태 추적 (격리, 유출 감지, 중첩 모달) | - |
| `g7-sequence` | Sequence 핸들러 실행 추적 (각 액션별 상태 변화) | - |
| `g7-stale-closure` | Stale Closure 감지 및 경고 | - |
| `g7-change-detection` | 핸들러 실행 시 변경 감지 (상태 변경 없는 핸들러, early return) | - |
### 페이지네이션
모든 도구는 `offset`과 `limit` 파라미터를 지원합니다:
```
g7-logs limit=20 offset=0 # 첫 페이지 (1~20)
g7-logs limit=20 offset=20 # 두 번째 페이지 (21~40)
g7-actions limit=10 offset=30 # 31~40번째 항목
```
출력 예시:
```
📄 페이지네이션: 21~40 / 총 249개 로그
➡️ 다음 페이지: offset: 40, limit: 20
⬅️ 이전 페이지: offset: 0, limit: 20
```
---
## 디버깅 워크플로우
### 기본 흐름
```
1. 디버그 모드 활성화 (APP_DEBUG=true)
2. 브라우저에서 문제가 발생하는 페이지 열기
3. 문제 재현 후, 개발자 도구 콘솔에서 상태 덤프 실행
4. AI 도구에서 g7-diagnose 호출 (증상 목록 전달)
5. 진단 결과에 따라 세부 도구로 심층 분석
```
### 증상별 도구 선택 가이드
| 증상 | 1차 도구 | 2차 도구 |
|------|---------|---------|
| 상태가 반영되지 않음 | `g7-state` | `g7-state-hierarchy`, `g7-stale-closure` |
| 액션 실행 후 변화 없음 | `g7-actions` | `g7-change-detection`, `g7-handlers` |
| API 데이터 미표시 | `g7-network` | `g7-datasources`, `g7-expressions` |
| 폼 값이 전송되지 않음 | `g7-form` | `g7-state`, `g7-actions` |
| 조건부 렌더링 오류 | `g7-conditionals` | `g7-expressions`, `g7-computed` |
| 모달 내 상태 이상 | `g7-modal-state` | `g7-nested-context`, `g7-state-hierarchy` |
| 스타일/CSS 문제 | `g7-styles` | `g7-tailwind` |
| 렌더링 성능 저하 | `g7-performance` | `g7-renders`, `g7-computed` |
| 이전 값이 남아있음 | `g7-stale-closure` | `g7-state`, `g7-change-detection` |
| 인증/401 오류 | `g7-auth` | `g7-network` |
| Sequence 중간 실패 | `g7-sequence` | `g7-actions` |
---
## 프로젝트 내 관련 파일
```
docs/ai-tools/agents/
└── src/mcp/
└── g7-devtools-server.ts # MCP 서버 (도구 정의 및 데이터 파싱)
resources/js/core/devtools/ # 브라우저 측 DevTools 코어
├── G7DevToolsCore.ts # 싱글톤 디버깅 수집기
├── ServerConnector.ts # 서버 전송 (상태 덤프)
├── DiagnosticEngine.ts # 자동 진단 엔진
├── StyleTracker.ts # 스타일 이슈 추적
├── TailwindValidator.ts # Tailwind CSS 검증
├── types.ts # 타입 정의
├── index.ts # 엔트리포인트
└── ui/ # DevTools 패널 UI (React)
routes/devtools.php # 상태 덤프 API 엔드포인트
storage/debug-dump/ # 디버그 데이터 저장 (자동 생성, Git 제외)
```
---
## 트러블슈팅
### MCP 서버가 시작되지 않는 경우
1. `docs/ai-tools/agents/node_modules/`가 존재하는지 확인 → `cd docs/ai-tools/agents && npm install`
2. Node.js 18 이상인지 확인 → `node --version`
3. AI 도구를 리로드했는지 확인 (VS Code: `Ctrl+Shift+P` → `Developer: Reload Window`)
### Windows에서 `npx` 실행 오류
Windows 환경에서 AI 도구가 `npx`를 찾지 못하는 경우:
```json
{
"g7-devtools": {
"command": "docs/ai-tools/agents/node_modules/.bin/tsx.cmd",
"args": [
"docs/ai-tools/agents/src/mcp/g7-devtools-server.ts"
]
}
}
```
### 상태 덤프가 실패하는 경우
1. **디버그 모드 확인**: `.env`에서 `APP_DEBUG=true`인지 확인
2. **서버 실행 확인**: 개발 서버(`php artisan serve` 등)가 실행 중인지 확인
3. **콘솔 로그 확인**: 브라우저 콘솔에서 `[G7DevTools]` 로그로 오류 원인 파악
4. **엔드포인트 확인**: `/_boost/g7-debug/dump-state`에 POST 요청이 정상 도달하는지 확인
### 도구 호출 시 "데이터 없음"
1. **상태 덤프 선행 필수**: 브라우저에서 상태 덤프를 먼저 실행해야 합니다
2. **파일 존재 확인**: `storage/debug-dump/` 디렉토리에 `*-latest.json` 파일이 있는지 확인
3. **경로 확인**: `G7_PROJECT_ROOT` 환경 변수가 올바른 프로젝트 경로를 가리키는지 확인
4. **덤프 시간 확인**: 도구 출력의 "📅 덤프 시간" 표시가 너무 오래된 경우 재덤프 필요
### 대용량 데이터 조회 시 응답 잘림
- 모든 도구에서 `limit`과 `offset` 파라미터로 페이지네이션 사용
- `g7-state`는 `path` 파라미터로 특정 경로만 조회 (예: `path: "_global.user"`)
- `g7-actions`는 `filter`로 핸들러 타입 필터링 (예: `filter: "apiCall"`)
+82
View File
@@ -0,0 +1,82 @@
# 모듈 스캐폴딩 (create-module)
새로운 모듈의 기본 구조를 생성합니다.
## 사용법
```
/create-module vendor-module
```
**예시**: `/create-module sirsoft-ecommerce`
## 입력 형식
- `vendor-module` — 벤더명과 모듈명을 하이픈(-)으로 연결
- 네임스페이스 자동 변환: `sirsoft-ecommerce` → `Modules\Sirsoft\Ecommerce\`
- 언더스코어(_) → PascalCase: `sirsoft-order_management` → `Modules\Sirsoft\OrderManagement\`
## 생성 위치
`modules/_bundled/vendor-module/`
## 생성되는 파일
| 파일 | 설명 |
|------|------|
| `module.json` | 메타데이터 SSoT (identifier, version, dependencies 등) |
| `module.php` | AbstractModule 상속 엔트리 클래스 |
| `composer.json` | PSR-4 오토로딩 (src/, database/seeders/, database/factories/) |
| `CHANGELOG.md` | Keep a Changelog 형식 |
| `LICENSE` | MIT 라이선스 |
| `config/settings/defaults.json` | 기본 설정 (_meta, defaults, frontend_schema) |
| `src/lang/{ko,en}/*.php` | 백엔드 다국어 (messages, validation, exceptions) |
| `resources/lang/{ko,en}.json` | 프론트엔드 다국어 |
| `resources/routes.json` | 프론트엔드 라우트 정의 |
| `src/routes/web.php` | 웹/API 라우트 |
| `resources/layouts/admin/pages/index.json` | 초기 관리자 레이아웃 |
## 생성되는 디렉토리
- `src/Contracts/Repositories/` — Repository 인터페이스
- `src/Enums/` — Enum 정의
- `src/Exceptions/` — Custom Exception
- `src/Http/Controllers/Admin/` — 관리자 컨트롤러
- `src/Http/Requests/Admin/` — FormRequest
- `src/Http/Resources/` — API Resource
- `src/Listeners/` — 훅 리스너
- `src/Models/` — Eloquent 모델
- `src/Providers/` — 서비스 프로바이더
- `src/Repositories/` — Repository 구현체
- `src/Services/` — 서비스 클래스
- `database/migrations/` — 마이그레이션
- `database/seeders/` — 시더
- `database/factories/` — 팩토리
- `tests/Feature/`, `tests/Unit/` — 테스트
## module.php 구현 메서드
| 메서드 | 설명 |
|--------|------|
| `getRoles()` | 모듈 역할 정의 |
| `getPermissions()` | 계층형 권한 (categories → permissions → action/name/type/roles) |
| `getAdminMenus()` | 관리자 메뉴 (slug, url, icon, order, permission) |
| `getSeeders()` | 설치 시 실행할 시더 목록 |
| `getHookListeners()` | 훅 리스너 클래스 목록 |
| `getStorageDisk()` | 스토리지 디스크 설정 |
## 생성 후 다음 단계
```bash
php artisan extension:update-autoload
php artisan module:install vendor-module
php artisan module:activate vendor-module
```
## 참조 문서
- [모듈 기초](../../extension/module-basics.md)
- [모듈 라우트](../../extension/module-routing.md)
- [모듈 레이아웃](../../extension/module-layouts.md)
- [모듈 다국어](../../extension/module-i18n.md)
- [모듈 커맨드](../../extension/module-commands.md)
+81
View File
@@ -0,0 +1,81 @@
# 플러그인 스캐폴딩 (create-plugin)
새로운 플러그인의 기본 구조를 생성합니다.
## 사용법
```text
/create-plugin vendor-plugin
```
**예시**: `/create-plugin sirsoft-payment`
## 입력 형식
- `vendor-plugin` — 벤더명과 플러그인명을 하이픈(-)으로 연결
- 네임스페이스 자동 변환: `sirsoft-payment` → `Plugins\Sirsoft\Payment\`
- 언더스코어(_) → PascalCase: `sirsoft-daum_postcode` → `Plugins\Sirsoft\DaumPostcode\`
## 생성 위치
`plugins/_bundled/vendor-plugin/`
## 생성되는 파일
| 파일 | 설명 |
| ---- | ---- |
| `plugin.json` | 메타데이터 SSoT (identifier, version, assets, loading 등) |
| `plugin.php` | AbstractPlugin 상속 엔트리 클래스 (루트 위치!) |
| `composer.json` | PSR-4 오토로딩 (src/ + ./ 매핑) |
| `CHANGELOG.md` | Keep a Changelog 형식 |
| `LICENSE` | MIT 라이선스 |
| `config/settings/defaults.json` | 설정 기본값 (_meta, defaults, frontend_schema) |
| `resources/lang/{ko,en}.json` | 프론트엔드 다국어 (백엔드 PHP 다국어 없음) |
| `src/routes/web.php` | 웹 라우트 (선택) |
| `tests/PluginTestCase.php` | 테스트 기반 클래스 |
## 생성되는 디렉토리
- `src/Listeners/` — 훅 리스너
- `src/Services/` — 서비스 클래스
- `resources/extensions/` — layout_extensions JSON
- `resources/layouts/admin/` — 설정 UI 레이아웃
- `tests/Feature/`, `tests/Unit/` — 테스트
## plugin.php 구현 메서드
| 메서드 | 설명 |
| ------ | ---- |
| `getMetadata()` | 메타데이터 (author, license, homepage, keywords) |
| `getDependencies()` | 의존성 (모듈만 가능, 플러그인 간 의존 금지) |
| `getSettingsSchema()` | 설정 스키마 (type, default, label, hint) |
| `getConfigValues()` | 설정 기본값 |
| `getHookListeners()` | 훅 리스너 클래스 목록 |
| `getHooks()` | 제공하는 훅 정의 (name, type, description, parameters) |
## 모듈과의 주요 차이
| 항목 | 모듈 | 플러그인 |
| ---- | ---- | -------- |
| 엔트리 위치 | module.php (src root) | plugin.php (프로젝트 루트!) |
| 베이스 클래스 | AbstractModule | AbstractPlugin |
| 권한/역할/메뉴 | 있음 | 없음 |
| DB 모델 | 있음 | 선택적 |
| 백엔드 다국어 | PHP 파일 | 없음 |
| 레이아웃 | 완전한 페이지 | settings.json만 (나머지 layout_extensions) |
| 의존성 | 모듈+플러그인 | 모듈만 |
## 생성 후 다음 단계
```bash
php artisan extension:update-autoload
php artisan plugin:install vendor-plugin
php artisan plugin:activate vendor-plugin
```
## 참조 문서
- [플러그인 개발 가이드](../../extension/plugin-development.md)
- [훅 시스템](../../extension/hooks.md)
- [모듈 환경설정 시스템](../../extension/module-settings.md)
- [Changelog 규칙](../../extension/changelog-rules.md)
+90
View File
@@ -0,0 +1,90 @@
# 템플릿 스캐폴딩 (create-template)
새로운 템플릿(관리자 또는 사용자)의 기본 구조를 생성합니다.
## 사용법
```text
/create-template vendor-template [--type=admin|user]
```
**예시**:
- `/create-template sirsoft-admin_modern` — Admin 템플릿
- `/create-template sirsoft-modern --type=user` — User 템플릿
기본값은 `admin`입니다.
## 입력 형식
- `vendor-template` — 벤더명과 템플릿명을 하이픈(-)으로 연결
- Admin 네이밍 권장: `vendor-admin_xxx` (예: `sirsoft-admin_modern`)
- User 네이밍 권장: `vendor-xxx` (예: `sirsoft-modern`)
## 생성 위치
`templates/_bundled/vendor-template/`
## 생성되는 파일
| 파일 | 설명 |
| ---- | ---- |
| `template.json` | 메타데이터 SSoT (type, components, error_config 등) |
| `routes.json` | 프론트엔드 라우트 정의 |
| `components.json` | 컴포넌트 레지스트리 매핑 |
| `CHANGELOG.md` | Keep a Changelog 형식 |
| `LICENSE` | MIT 라이선스 |
| `lang/{ko,en}.json` | 다국어 |
| `layouts/_[type]_base.json` | 베이스 레이아웃 (slot 시스템) |
| `layouts/errors/*.json` | 에러 레이아웃 6종 (401/403/404/500/503/maintenance) |
| `layouts/[초기 페이지].json` | admin: admin_dashboard / user: home |
| `src/index.ts` | 컴포넌트 등록 엔트리 |
| `package.json` | npm 패키지 설정 |
| `vite.config.ts` | Vite 빌드 (IIFE 출력) |
| `vitest.config.ts` | Vitest 테스트 설정 |
| `tsconfig.json` | TypeScript 설정 |
## 생성되는 디렉토리
- `src/components/basic/` — 기본 컴포넌트 (Div, Button, Input 등)
- `src/components/composite/` — 합성 컴포넌트 (Modal, Toast 등)
- `src/components/layout/` — 레이아웃 컴포넌트 (Container, Flex, Grid, Section)
- `assets/css/`, `assets/fonts/`, `assets/images/` — 정적 에셋
- `preview/` — 미리보기 이미지
- `__tests__/layouts/` — 레이아웃 렌더링 테스트
## Admin vs User 차이
| 항목 | Admin | User |
| ---- | ----- | ---- |
| type | `"admin"` | `"user"` |
| 라우트 접두사 | `*/admin/*` | `/` |
| auth_required 기본 | `true` | `false` |
| 버전 히스토리 | 미지원 (읽기 전용) | 지원 (편집 가능) |
| 베이스 레이아웃 | header + sidebar + content + footer | header + content + footer |
| 초기 페이지 | `admin_dashboard` | `home` |
| 필수 composite | AdminHeader, AdminSidebar, AdminFooter, Modal, Toast | Header, Footer, Modal, Toast |
| features | 미포함 | dark_mode, responsive, multi_language, multi_currency |
## 생성 후 다음 단계
```bash
# 1. 패키지 설치
cd templates/_bundled/vendor-template
npm install
# 2. 컴포넌트 구현 (src/components/)
# 3. 빌드 및 설치
php artisan template:build vendor-template
php artisan template:install vendor-template
php artisan template:activate vendor-template
```
## 참조 문서
- [템플릿 기초](../../extension/template-basics.md)
- [템플릿 라우트](../../extension/template-routing.md)
- [템플릿 워크플로우](../../extension/template-workflow.md)
- [템플릿 보안](../../extension/template-security.md)
- [컴포넌트 개발 규칙](../../frontend/components.md)
+328
View File
@@ -0,0 +1,328 @@
# 다국어 키 추출 (extract-i18n-keys)
레이아웃 JSON 파일에서 사용된 다국어 키를 추출하고 언어 파일과 비교하여 누락된 키를 보고합니다.
## 0단계: 추출 대상 판별
```text
⚠️ CRITICAL: 추출 대상 경로 확인
```
### 대상 경로 유형
| 경로 패턴 | 유형 | 언어 파일 위치 |
| --------- | ---- | -------------- |
| `modules/[vendor-module]/resources/layouts/**/*.json` | 모듈 레이아웃 | `modules/[vendor-module]/resources/lang/*.json` |
| `modules/_bundled/[vendor-module]/resources/layouts/**/*.json` | _bundled 모듈 레이아웃 | `modules/_bundled/[vendor-module]/resources/lang/*.json` |
| `templates/[vendor-template]/layouts/**/*.json` | 템플릿 레이아웃 | `templates/[vendor-template]/lang/*.json` |
| `templates/_bundled/[vendor-template]/layouts/**/*.json` | _bundled 템플릿 레이아웃 | `templates/_bundled/[vendor-template]/lang/*.json` |
| `resources/layouts/**/*.json` | 코어 레이아웃 | `lang/*.json` |
### $ARGUMENTS 처리
- 경로가 지정된 경우: 해당 경로의 JSON 파일만 대상
- 경로가 지정되지 않은 경우: 사용자에게 대상 경로 문의
## 1단계: 레이아웃 파일 수집
### 1.1 대상 파일 탐색
```bash
# 모듈 레이아웃 예시
find modules/sirsoft-ecommerce/resources/layouts -name "*.json" -type f
# 템플릿 레이아웃 예시
find templates/sirsoft-admin_basic/layouts -name "*.json" -type f
```
### 1.2 파일 목록 생성
수집된 파일 목록을 정리:
- 메인 레이아웃 파일
- partials 디렉토리 내 파일
- 중첩된 모든 JSON 파일
## 2단계: 다국어 키 추출
### 2.1 추출 패턴
다음 패턴의 다국어 키를 추출합니다:
| 패턴 | 설명 | 예시 |
| ---- | ---- | ---- |
| `$t:namespace.key.path` | 즉시 번역 | `$t:sirsoft-ecommerce.admin.product.name` |
| `$t:defer:namespace.key.path` | 지연 번역 | `$t:defer:common.save` |
### 2.2 추출 정규식
```javascript
// $t: 패턴 추출 (defer 포함)
const regex = /\$t:(?:defer:)?([a-zA-Z][a-zA-Z0-9_.-]*(?:\.[a-zA-Z0-9_-]+)+)/g;
```
### 2.3 키 분류
추출된 키를 네임스페이스별로 분류:
| 키 패턴 | 분류 | 저장 위치 |
| ------- | ---- | --------- |
| `sirsoft-ecommerce.*` | 모듈 전용 | `modules/sirsoft-ecommerce/resources/lang/*.json` |
| `common.*` | 공통 키 | 코어 또는 모듈 lang 파일 |
| `messages.*` | 메시지 | 코어 또는 모듈 lang 파일 |
| `currency.*` | 통화 | 코어 또는 모듈 lang 파일 |
| `validation.*` | 검증 | 코어 또는 모듈 lang 파일 |
### 2.4 모듈 네임스페이스 검증 (CRITICAL)
모듈 레이아웃에서 사용된 다국어 키가 올바른 네임스페이스를 사용하는지 검증합니다.
```text
⚠️ CRITICAL: 모듈 레이아웃에서 다국어 키 사용 시 반드시 모듈 식별자 접두사 포함
```
| ❌ 금지 | ✅ 올바른 사용 |
| -------- | --------------- |
| `$t:common.save` (모듈에서 코어 키 직접 참조) | `$t:sirsoft-ecommerce.common.save` (모듈 식별자 접두사) |
| `$t:admin.product.name` (네임스페이스 누락) | `$t:sirsoft-ecommerce.admin.product.name` |
**검증 항목**:
- 모듈 레이아웃 내 `$t:` 키가 해당 모듈 식별자로 시작하는지 확인
- `$t:common.*` 형태로 코어 키를 직접 참조하면 경고 (모듈 lang 파일이 아닌 코어 lang에서 찾게 됨)
- 예외: `$t:common.*`이 코어 키를 의도적으로 참조하는 경우 (명시적 확인 필요)
## 3단계: 언어 파일 분석
### 3.1 언어 파일 읽기
```bash
# 모듈 언어 파일
modules/[vendor-module]/resources/lang/ko.json
modules/[vendor-module]/resources/lang/en.json
# 코어 언어 파일
lang/ko.json
lang/en.json
```
### 3.2 기존 키 추출
JSON 파일에서 모든 키 경로를 추출:
```javascript
// 중첩된 객체에서 키 경로 추출
function extractKeys(obj, prefix = '') {
const keys = [];
for (const [key, value] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object' && value !== null) {
keys.push(...extractKeys(value, path));
} else {
keys.push(path);
}
}
return keys;
}
```
## 4단계: 비교 및 분석
### 4.1 누락 키 식별
레이아웃에서 사용된 키 vs 언어 파일에 존재하는 키 비교:
```text
사용된 키 - 존재하는 키 = 누락된 키
```
### 4.2 미사용 키 식별 (선택)
언어 파일에 존재하지만 레이아웃에서 사용되지 않는 키:
```text
존재하는 키 - 사용된 키 = 미사용 키
```
## 5단계: 결과 보고
### 5.1 보고서 형식
```text
## 다국어 키 추출 결과
### 추출 대상
- 레이아웃 경로: [경로]
- 검사 파일 수: X개
- 언어 파일: ko.json, en.json
### 추출된 키 요약
- 총 사용된 키: XXX개
- 모듈 전용 키 (sirsoft-ecommerce.*): XXX개
- 공통 키 (common.*): XX개
- 메시지 키 (messages.*): XX개
- 기타 키: XX개
### 언어 파일 현황
- ko.json 존재 키: XXX개
- en.json 존재 키: XXX개
### 누락된 키 (CRITICAL)
- 총 누락: XX개 (XX.X% 누락률)
#### 모듈 전용 키 누락 (sirsoft-ecommerce.*)
| 키 | 사용 위치 |
|----|----------|
| sirsoft-ecommerce.admin.product.xxx | file.json:line |
| ... | ... |
#### 공통 키 누락 (common.*)
| 키 | 사용 위치 |
|----|----------|
| common.xxx | file.json:line |
| ... | ... |
### 미사용 키 (참고)
- 총 미사용: XX개
| 키 | 위치 |
|----|------|
| sirsoft-ecommerce.xxx.unused | ko.json |
```
### 5.2 키 생성 템플릿 제공
누락된 키에 대한 JSON 템플릿 생성:
```json
// ko.json에 추가할 키
{
"admin": {
"product": {
"missing_key_1": "[한국어 번역 필요]",
"missing_key_2": "[한국어 번역 필요]"
}
}
}
// en.json에 추가할 키
{
"admin": {
"product": {
"missing_key_1": "[English translation needed]",
"missing_key_2": "[English translation needed]"
}
}
}
```
## 6단계: 추출 스크립트 (참고)
### Node.js 추출 스크립트 예시
```javascript
const fs = require('fs');
const path = require('path');
// 디렉토리 내 모든 JSON 파일 찾기
function findJsonFiles(dir, files = []) {
const items = fs.readdirSync(dir);
for (const item of items) {
const fullPath = path.join(dir, item);
if (fs.statSync(fullPath).isDirectory()) {
findJsonFiles(fullPath, files);
} else if (item.endsWith('.json')) {
files.push(fullPath);
}
}
return files;
}
// $t: 키 추출
function extractI18nKeys(content) {
const regex = /\$t:(?:defer:)?([a-zA-Z][a-zA-Z0-9_.-]*(?:\.[a-zA-Z0-9_-]+)+)/g;
const keys = new Set();
let match;
while ((match = regex.exec(content)) !== null) {
keys.add(match[1]);
}
return keys;
}
// JSON에서 키 경로 추출
function extractExistingKeys(obj, prefix = '', namespace = '') {
const keys = new Set();
for (const [key, value] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${key}` : key;
const fullPath = namespace ? `${namespace}.${path}` : path;
if (typeof value === 'object' && value !== null) {
const childKeys = extractExistingKeys(value, path, namespace);
childKeys.forEach(k => keys.add(k));
} else {
keys.add(fullPath);
}
}
return keys;
}
// 메인 실행
const layoutDir = process.argv[2] || 'modules/sirsoft-ecommerce/resources/layouts';
const langDir = process.argv[3] || 'modules/sirsoft-ecommerce/resources/lang';
const namespace = process.argv[4] || 'sirsoft-ecommerce';
// 레이아웃에서 사용된 키 추출
const layoutFiles = findJsonFiles(layoutDir);
const usedKeys = new Set();
for (const file of layoutFiles) {
const content = fs.readFileSync(file, 'utf-8');
const keys = extractI18nKeys(content);
keys.forEach(k => usedKeys.add(k));
}
// 언어 파일에서 존재하는 키 추출
const koJson = JSON.parse(fs.readFileSync(path.join(langDir, 'ko.json'), 'utf-8'));
const existingKeys = extractExistingKeys(koJson, '', namespace);
// 비교
const missingKeys = [...usedKeys].filter(k => !existingKeys.has(k));
const unusedKeys = [...existingKeys].filter(k => !usedKeys.has(k));
console.log('=== 다국어 키 추출 결과 ===');
console.log(`사용된 키: ${usedKeys.size}개`);
console.log(`존재하는 키: ${existingKeys.size}개`);
console.log(`누락된 키: ${missingKeys.length}개`);
console.log(`미사용 키: ${unusedKeys.length}개`);
if (missingKeys.length > 0) {
console.log('\n=== 누락된 키 목록 ===');
missingKeys.sort().forEach(k => console.log(k));
}
```
---
## 핵심 원칙
```text
⚠️ CRITICAL:
- 모든 $t: 패턴 추출 (defer 포함)
- 모듈 네임스페이스와 공통 키 모두 추출
- 모듈 레이아웃: $t:키에 모듈 식별자 접두사 필수 ($t:sirsoft-ecommerce.xxx)
- _bundled 디렉토리 레이아웃도 추출 대상에 포함
- 정확한 키 경로 비교 (중첩 객체 고려)
- 누락 키에 대한 실용적인 템플릿 제공
- ko.json과 en.json 모두 검사
```
---
## 사용 예시
```bash
# 모듈 레이아웃 키 추출
/extract-i18n-keys modules/sirsoft-ecommerce/resources/layouts
# 특정 레이아웃 파일만 추출
/extract-i18n-keys modules/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_product_form.json
# 템플릿 레이아웃 키 추출
/extract-i18n-keys templates/sirsoft-admin_basic/layouts
```
+192
View File
@@ -0,0 +1,192 @@
# 테스트 실행 (run-tests)
그누보드7 프로젝트의 테스트를 실행하고 결과를 검증합니다.
## 1단계: 규정 문서 읽기
다음 규정 문서를 읽어 최신 규칙을 확인합니다:
- `docs/testing-guide.md` - 테스트 규칙
- `docs/frontend/layout-testing.md` - 레이아웃 렌더링 테스트 규칙
## 2단계: .env.testing 확인 (백엔드 테스트 시)
```text
⚠️ CRITICAL: 백엔드 테스트 실행 전 .env.testing 존재 여부 확인 필수
```
1. `.env.testing` 미존재 시:
- `.env.testing.example` 복사: `cp .env.testing.example .env.testing`
- `APP_KEY` 생성: `php artisan key:generate --env=testing`
- PO에게 DB 접속 정보 확인 요청
## 3단계: 테스트 타입 결정
$ARGUMENTS 값에 따라 테스트 타입을 결정합니다:
| 인자 | 테스트 타입 | 명령어 |
|------|-------------|--------|
| `backend` 또는 `php` | 백엔드 테스트 | `php artisan test` |
| `frontend` 또는 `npm` | 프론트엔드 테스트 | `powershell -Command "npm run test:run"` |
| `layout` | 레이아웃 렌더링 테스트 | `powershell -Command "npm run test:run -- layouts"` |
| `all` 또는 미지정 | 전체 테스트 | 백엔드 + 프론트엔드 순차 실행 |
| 특정 파일/클래스명 | 필터 테스트 | `php artisan test --filter=$ARGUMENTS` |
```text
⚠️ CRITICAL: 전체 테스트(all) 지양 → 관련 테스트만 실행 (시간 절약)
⚠️ CRITICAL: 동일 테스트를 결과 확인 목적으로 재실행 금지 → 저장된 출력 확인
```
## 4단계: 테스트 실행
### 백엔드 테스트 (PHPUnit)
```bash
# 관련 테스트만 실행 (권장)
php artisan test --filter=TestClassName 2>&1 | tee /tmp/test-result.txt
# 특정 디렉토리
php vendor/bin/phpunit tests/Feature/Api/Admin/ 2>&1 | tee /tmp/test-result.txt
# _bundled 모듈 테스트 (활성 디렉토리 복사 불필요)
php vendor/bin/phpunit modules/_bundled/sirsoft-ecommerce/tests 2>&1 | tee /tmp/test-result.txt
php vendor/bin/phpunit --filter=TestClassName modules/_bundled/sirsoft-ecommerce/tests 2>&1 | tee /tmp/test-result.txt
```
### 프론트엔드 테스트 (Vitest)
```powershell
# 프로젝트 루트에서 (코어 테스트)
powershell -Command "npm run test:run -- template-engine"
# 템플릿 디렉토리에서 (해당 템플릿만)
powershell -Command "cd templates/sirsoft-admin_basic; npm run test:run"
powershell -Command "cd templates/sirsoft-admin_basic; npm run test:run -- DataGrid"
# _bundled 모듈 프론트엔드 테스트
powershell -Command "cd modules/_bundled/sirsoft-ecommerce; npm run test:run"
```
### 레이아웃 렌더링 테스트 (Vitest + createLayoutTest)
```text
⚠️ CRITICAL: 레이아웃 렌더링 테스트는 Vitest + createLayoutTest() 유틸리티 기반
⚠️ CRITICAL: 브라우저 기반 E2E가 아님 — "인프라 부족" 이유로 건너뛰기 절대 금지
```
```powershell
# 코어 레이아웃 테스트
powershell -Command "npm run test:run -- layouts"
# 모듈 레이아웃 테스트
powershell -Command "cd modules/_bundled/sirsoft-ecommerce; npm run test:run -- layouts"
# 템플릿 레이아웃 테스트
powershell -Command "cd templates/_bundled/sirsoft-admin_basic; npm run test:run -- layouts"
```
**기본 패턴**:
```typescript
import { createLayoutTest, screen } from '../utils/layoutTestUtils';
const testUtils = createLayoutTest(layoutJson);
testUtils.mockApi('products', { response: { data: [] } });
await testUtils.render();
expect(screen.getByTestId('element')).toBeInTheDocument();
testUtils.cleanup();
```
```text
⚠️ CRITICAL: 테스트 출력은 반드시 파일로 저장
✅ 패턴: 명령어 2>&1 | tee /tmp/test-result.txt
❌ 실패 후 결과 확인 목적 재실행 금지 → /tmp/test-result.txt 확인
```
## 5단계: 다계층 테스트 확인 (CRITICAL)
기능 구현 시 **모든 관련 계층**의 테스트가 실행되었는지 확인합니다.
| 작업 유형 | 백엔드 (PHPUnit) | 프론트엔드 (Vitest) | 레이아웃 렌더링 (Vitest) |
|----------|-----------------|-------------------|----------------------|
| 새 화면 구현 | API 엔드포인트 테스트 | 컴포넌트 테스트 | 레이아웃 JSON 렌더링 테스트 |
| 기존 화면 수정 | 변경된 API 테스트 | 변경된 컴포넌트 테스트 | 레이아웃 렌더링 회귀 테스트 |
| 데이터 흐름 변경 | Service/Repository 테스트 | 상태 관리 테스트 | 데이터 바인딩 렌더링 테스트 |
```text
❌ 단일 계층 테스트만으로 기능 구현 완료 선언 금지
❌ 백엔드 API만 테스트하고 프론트엔드/레이아웃 테스트 누락
❌ 컴포넌트만 테스트하고 레이아웃 렌더링 테스트 누락
```
## 6단계: 결과 분석 및 보고
테스트 결과를 다음 형식으로 보고합니다:
```
## 테스트 결과
### 실행 환경
- 테스트 타입: [backend/frontend/layout/all]
- 실행 명령어: [실행한 명령어]
### 결과 요약
- 총 테스트: [N]개
- 통과: [N]개 ✅
- 실패: [N]개 ❌
- 스킵: [N]개 ⏭️
### 다계층 테스트 확인
- □ 백엔드 테스트: [실행/미해당]
- □ 프론트엔드 테스트: [실행/미해당]
- □ 레이아웃 렌더링 테스트: [실행/미해당]
### 실패 테스트 상세 (있는 경우)
- ❌ [테스트명]
- 파일: [파일 경로]
- 원인: [실패 원인]
- 수정 방향: [제안]
```
## 중요 원칙
```text
⚠️ CRITICAL:
- Windows 환경에서 프론트엔드 테스트는 반드시 PowerShell 래퍼 사용
- 테스트 작성 ≠ 완료, 테스트 통과 = 완료
- 템플릿 엔진/컴포넌트 수정 시 테스트 필수
- 기능 구현 시 다계층 테스트 필수 (백엔드 + 프론트엔드 + 레이아웃 렌더링)
- 레이아웃 렌더링 테스트는 createLayoutTest() 유틸리티로 실행 (브라우저 불필요)
- "인프라 부족" 이유로 레이아웃 테스트 건너뛰기 절대 금지
- 테스트 출력은 파일로 저장 → 동일 테스트 재실행 금지
- 전체 테스트 실행 지양 → 관련 테스트만 실행
```
## 테스트 파일 위치 매핑
| 수정 대상 | 테스트 파일 위치 | 테스트 유형 |
|----------|-----------------|-------------|
| `app/Models/*.php` | `tests/Unit/Models/*Test.php` | 모델 메서드, 관계, 스코프 |
| `app/Services/*.php` | `tests/Unit/Services/*Test.php` | 비즈니스 로직 |
| `app/Enums/*.php` | `tests/Unit/Enums/*Test.php` | Enum 메서드 |
| `app/Http/Controllers/**/*.php` | `tests/Feature/**/*Test.php` | API 엔드포인트 |
| `database/migrations/*.php` | 해당 모델/서비스 테스트 | 스키마 변경 |
| `templates/**/src/components/**/*.tsx` | `templates/**/__tests__/*.test.tsx` | 컴포넌트 |
| `resources/js/core/**/*.ts` | `resources/js/core/__tests__/*.test.ts` | 템플릿 엔진 |
| `resources/layouts/**/*.json` | `resources/js/core/template-engine/__tests__/layouts/*.test.tsx` | 코어 레이아웃 렌더링 |
| `modules/**/resources/layouts/**/*.json` | `modules/_bundled/{id}/resources/js/__tests__/layouts/*.test.tsx` | 모듈 레이아웃 렌더링 |
| `templates/**/layouts/**/*.json` | `templates/_bundled/{id}/__tests__/layouts/*.test.tsx` | 템플릿 레이아웃 렌더링 |
## 자동 감지
다음 파일이 수정된 경우 관련 테스트를 자동으로 실행합니다:
- `resources/js/core/` 변경 → 템플릿 엔진 테스트
- `resources/js/core/template-engine/` 변경 → 코어 렌더링 테스트
- `app/Http/Controllers/` 변경 → 해당 컨트롤러 테스트
- `app/Services/` 변경 → 해당 서비스 테스트
- `resources/layouts/**/*.json` 변경 → 코어 레이아웃 렌더링 테스트
- `modules/**/resources/layouts/**/*.json` 변경 → 해당 모듈 레이아웃 렌더링 테스트
- `templates/**/layouts/**/*.json` 변경 → 해당 템플릿 레이아웃 렌더링 테스트
- `templates/**/src/components/**/*.tsx` 변경 → 해당 컴포넌트 테스트
- `modules/_bundled/**/src/**` 변경 → 해당 모듈 테스트
+365
View File
@@ -0,0 +1,365 @@
# 코드 패턴 검증 (validate-code)
백엔드 코드 및 **작업계획서 내 PHP 코드 블록**이 그누보드7 규정을 준수하는지 검증합니다.
## 0단계: 검증 대상 유형 판별 (CRITICAL)
```text
⚠️ CRITICAL: 검증 대상이 소스코드인지 작업계획서인지 먼저 판별
```
### 판별 기준
| 파일 확장자 | 유형 | 검증 방식 |
| ----------- | ---------------- | ---------------------------------------- |
| `*.php` | 소스코드 | 파일 전체를 PHP 코드로 검증 |
| `*.md` | **작업계획서** | 마크다운 내 PHP 코드 블록 추출 후 검증 |
### 작업계획서 검증 시 추가 단계
작업계획서(`.md`) 파일인 경우:
1. **코드 블록 추출**: 마크다운에서 \`\`\`php 코드 블록 추출
2. **컨텍스트 파악**: 코드 블록 앞뒤 텍스트로 해당 코드의 용도 파악 (Controller, Service, Repository 등)
3. **유형별 검증**: 파악된 용도에 맞는 규정 적용
```text
⚠️ 작업계획서 검증 시 주의사항:
- 코드 블록이 예시/설명 목적인지, 실제 구현 계획인지 구분
- "예시:", "Example:", "// 예시" 등이 포함된 코드는 참고용으로 처리
- 실제 구현 계획 코드만 엄격하게 검증
```
## 1단계: 규정 문서 읽기
다음 규정 문서를 읽어 최신 규칙을 확인합니다:
- `docs/backend/service-repository.md` - Service-Repository 패턴
- `docs/backend/validation.md` - FormRequest 검증 규칙
- `docs/backend/enum.md` - Enum 사용 규칙
- `docs/backend/controllers.md` - 컨트롤러 계층 규칙
- `docs/backend/api-resources.md` - API 리소스 규칙
- `docs/backend/exceptions.md` - Custom Exception 다국어 처리
- `docs/backend/middleware.md` - 미들웨어 등록 규칙
- `docs/backend/routing.md` - 라우트 네이밍 및 권한 체크
- `docs/backend/response-helper.md` - API 응답 규칙
- `docs/backend/service-provider.md` - 서비스 프로바이더 안전성
- `docs/extension/permissions.md` - 권한 시스템 (Policy 사용 금지)
- `docs/backend/notification-system.md` - BaseNotification 상속 규칙
- `docs/backend/broadcasting.md` - Broadcasting 규칙
- `docs/backend/core-config.md` - 코어 설정 SSoT
- `docs/extension/storage-driver.md` - StorageInterface 사용 규칙
## 2단계: 검증 대상 파일 읽기
$ARGUMENTS 경로의 파일을 읽습니다.
경로가 지정되지 않은 경우, 현재 작업 중인 PHP 파일들을 대상으로 합니다:
- `app/Services/**/*.php` - Service 클래스
- `app/Http/Requests/**/*.php` - FormRequest 클래스
- `app/Repositories/**/*.php` - Repository 클래스
- `app/Http/Controllers/**/*.php` - Controller 클래스
- `app/Http/Resources/**/*.php` - API Resource 클래스
- `**/Listeners/**/*.php` - Listener 클래스
## 3단계: 규정 기반 자동 검증 (CRITICAL)
```text
⚠️ CRITICAL: 1단계에서 읽은 규정 문서의 모든 규칙을 검증 항목으로 사용합니다.
스킬에 하드코딩된 검증 항목이 아닌, 규정 문서가 Single Source of Truth입니다.
```
### 3.1 규정에서 검증 패턴 추출
1단계에서 읽은 모든 규정 문서에서 다음 패턴을 추출합니다:
| 추출 대상 | 검증 유형 |
| --------- | --------- |
| `❌` 또는 `잘못된` 키워드가 포함된 코드 블록 | **금지 패턴** - 발견 시 에러 |
| `✅` 또는 `올바른` 키워드가 포함된 코드 블록 | **필수 패턴** - 누락 시 경고 |
| `⚠️ 절대 금지`, `CRITICAL` 등 강조된 규칙 | **필수 검증 항목** |
| `TL;DR` 섹션의 핵심 포인트 | **우선 검증 항목** |
### 3.2 검증 방법
**금지 패턴 검증**:
```bash
# 규정 문서의 ❌ 예시에서 추출한 패턴을 grep으로 검사
grep -rn "[금지 패턴]" [대상 파일/디렉토리]
```
발견 시: 에러로 보고하고 규정 문서의 해당 섹션 참조 안내
**필수 패턴 검증**:
- 해당 컨텍스트에서 필수 패턴이 사용되어야 하는 경우, 누락 여부 확인
- 누락 시: 경고로 보고하고 규정 문서의 올바른 예시 안내
### 3.3 파일 유형별 규정 매핑
검증 대상 파일의 경로에 따라 해당하는 규정 문서를 우선적으로 적용합니다:
| 파일 경로 패턴 | 우선 적용 규정 |
| -------------- | -------------- |
| `app/Services/**` | service-repository.md |
| `app/Http/Requests/**` | validation.md |
| `app/Repositories/**` | service-repository.md |
| `app/Http/Controllers/**` | controllers.md, routing.md |
| `app/Http/Resources/**` | api-resources.md |
| `**/Listeners/**` | hooks.md (extension) |
| `app/Providers/**` | service-provider.md |
| `app/Http/Middleware/**` | middleware.md |
| `app/Exceptions/**` | exceptions.md |
### 3.4 CLAUDE.md CRITICAL RULES 검증 (MANDATORY)
CLAUDE.md의 "백엔드 핵심 원칙" 및 CRITICAL RULES를 검증합니다.
| # | 검증 항목 | 금지 패턴 | 올바른 패턴 |
| --- | -------- | -------- | ---------- |
| 1 | FormRequest authorize() | 인증/권한 로직 구현 | `return true` 고정 + permission 미들웨어 체인 |
| 2 | API 리소스 $this->when() | 커스텀 메서드에서 `$this->when()` | 삼항 연산자 사용 |
| 3 | ResponseHelper 인수 순서 | `ResponseHelper::success($data, $message)` | `ResponseHelper::success($messageKey, $data)` — 메시지 first |
| 4 | 미들웨어 전역 등록 | `append()` 로 인증 미들웨어 전역 등록 | `appendToGroup('api')` 사용 |
| 5 | 로그아웃 구현 | 단일 단계 로그아웃 | 토큰 삭제 → 세션 무효화 → Auth::logout() (3단계) |
| 6 | ServiceProvider DB 접근 | 검증 없이 DB 접근 | `.env` + 테이블 존재 확인 필수 |
| 7 | exists/unique 규칙 | `exists:table,col` 문자열 | `Rule::exists(Model::class, 'col')` |
| 8 | DB CASCADE 삭제 | `onDelete('cascade')` 의존 삭제 | Service에서 명시적 삭제 (훅/파일/로깅 보장) |
| 9 | Storage 직접 호출 | `Storage::disk()` 직접 호출 | `StorageInterface` 사용 필수 |
| 10 | 파사드 사용 | `\Log::info()`, `auth()->user()` | `use Facades\Log; Log::info()` |
### 3.5 PHPDoc 검증
규정 문서에서 PHPDoc 관련 규칙도 추출하여 검증합니다:
- `@param`, `@return` 타입 명시 여부
- 한국어 설명 포함 여부
- `@throws` 명시 (예외 발생 시)
## 4단계: 테스트 실행 검증
검증 대상 파일에 해당하는 테스트가 있는 경우, 테스트를 실행하여 통과 여부를 확인합니다.
```bash
php artisan test --filter=[TestClassName]
```
## 5단계: 결과 보고
검증 결과를 다음 형식으로 보고합니다:
```text
## 코드 패턴 검증 결과
### 검증 파일
- [파일 경로]
### [규정 문서명] - [섹션명] 검증 결과
#### 금지 패턴 검사
- ❌ 금지 패턴 발견: [패턴] (파일:라인)
- 규정: [규정 문서명] > [섹션명]
- 수정 방법: [올바른 패턴으로 변경]
- ✅ 금지 패턴 없음
#### 필수 패턴 검사
- ❌ 필수 패턴 누락: [컨텍스트]에서 [패턴] 필요
- 규정: [규정 문서명] > [섹션명]
- ✅ 필수 패턴 준수
### 테스트 실행 결과 (해당되는 경우)
- ✅ 모든 테스트 통과 (X passed)
- ❌ 테스트 실패: [실패 메시지]
```
---
## 6단계: 작업계획서 전용 검증 (마크다운 파일인 경우)
검증 대상이 작업계획서(`.md`)인 경우, 다음 추가 검증을 수행합니다.
### 6.1 코드 블록 추출 및 분류
마크다운 파일에서 PHP 코드 블록을 추출하고 용도별로 분류합니다:
| 코드 블록 컨텍스트 | 적용 규정 |
| ------------------------ | ---------------------------- |
| Controller 관련 | controllers.md, routing.md |
| Service 관련 | service-repository.md |
| Repository 관련 | service-repository.md |
| FormRequest 관련 | validation.md |
| API Resource 관련 | api-resources.md |
| Exception 관련 | exceptions.md |
| Listener/Hook 관련 | hooks.md |
### 6.2 Controller 코드 블록 검증
```php
// ✅ 올바른 패턴
class ProductController extends AdminBaseController
{
public function __construct(
private readonly ProductServiceInterface $productService
) {}
}
// ❌ 잘못된 패턴 - Repository 직접 주입
class ProductController extends AdminBaseController
{
public function __construct(
private readonly ProductRepository $productRepository // ❌
) {}
}
```
**검증 항목**:
- ❌ Repository 직접 주입 (Service를 통해야 함)
- ❌ 컨트롤러 내 검증 로직 (FormRequest 사용 필수)
- ❌ 컨트롤러 내 비즈니스 로직 (Service로 위임 필수)
- ✅ ResponseHelper 사용
### 6.3 Service 코드 블록 검증
```php
// ✅ 올바른 패턴
class ProductService implements ProductServiceInterface
{
public function __construct(
private readonly ProductRepositoryInterface $productRepository
) {}
public function create(array $data): Product
{
HookManager::doAction('sirsoft-ecommerce.product.before_create', $data);
$data = HookManager::applyFilters('sirsoft-ecommerce.product.filter_create_data', $data);
$result = $this->productRepository->create($data);
HookManager::doAction('sirsoft-ecommerce.product.after_create', $result);
return $result;
}
}
// ❌ 잘못된 패턴 - 검증 로직 포함
public function create(array $data): Product
{
if (empty($data['name'])) { // ❌ FormRequest에서 처리해야 함
throw new ValidationException();
}
}
```
**검증 항목**:
- ❌ RepositoryInterface 대신 구체 클래스 타입힌트
- ❌ Service 내 검증 로직
- ✅ 훅 실행 순서: before → filter → action → after
- ✅ 훅 네이밍: `[vendor-module].[entity].[action]_[timing]`
### 6.4 Repository 코드 블록 검증
```php
// ✅ 올바른 패턴
class ProductRepository implements ProductRepositoryInterface
{
public function __construct(
private readonly Product $model
) {}
}
```
**검증 항목**:
- ✅ RepositoryInterface 구현
- ❌ 비즈니스 로직 포함 (Service로 이동 필요)
### 6.5 FormRequest 코드 블록 검증
```php
// ✅ 올바른 패턴
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'price' => ['required', 'numeric', 'min:0'],
];
}
public function messages(): array
{
return [
'name.required' => __('validation.custom.product.name.required'),
];
}
```
**검증 항목**:
- ✅ `__()` 함수를 사용한 다국어 메시지
- ❌ 하드코딩된 한국어 메시지
### 6.6 API Response 코드 블록 검증
```php
// ✅ 올바른 패턴
return ResponseHelper::success(
data: new ProductResource($product),
message: __('messages.product.created')
);
// ❌ 잘못된 패턴
return response()->json(['data' => $product]); // ResponseHelper 미사용
```
### 6.7 작업계획서 검증 결과 보고 형식
```text
## 작업계획서 백엔드 코드 검증 결과
### 검증 파일
- [마크다운 파일 경로]
### 코드 블록 요약
- Controller 코드 블록: X개
- Service 코드 블록: Y개
- Repository 코드 블록: Z개
- FormRequest 코드 블록: W개
- 총 검증 대상: N개
### Controller 검증
- ✅ Service 주입 패턴 준수
- ❌ Repository 직접 주입: [위치]
- ✅ ResponseHelper 사용
### Service 검증
- ✅ RepositoryInterface 사용
- ❌ 구체 클래스 타입힌트: [위치]
- ✅ 훅 실행 순서 준수
- ❌ Service 내 검증 로직: [위치]
### Repository 검증
- ✅ Interface 구현
- ❌ 비즈니스 로직 포함: [위치]
### FormRequest 검증
- ✅ 다국어 메시지 사용
- ❌ 하드코딩된 메시지: [위치]
### API Response 검증
- ✅ ResponseHelper 사용
- ❌ 직접 response() 사용: [위치]
```
---
## 핵심 원칙
```text
⚠️ CRITICAL:
- 규정 문서가 Single Source of Truth - 스킬에 검증 항목 하드코딩 금지
- 규정 문서의 ❌/✅ 예시를 자동으로 검증 패턴으로 사용
- 규정이 변경되면 검증도 자동으로 변경됨
- 파일 유형에 따라 해당 규정 문서 우선 적용
- 작업계획서(.md) 검증 시 코드 블록 추출 후 동일한 규정 적용
```
+666
View File
@@ -0,0 +1,666 @@
# 프론트엔드 검증 (validate-frontend)
프론트엔드 레이아웃 JSON, 컴포넌트, **작업계획서 내 코드 블록**이 그누보드7 규정을 준수하는지 검증합니다.
## 0단계: 검증 대상 유형 판별 (CRITICAL)
```text
⚠️ CRITICAL: 검증 대상이 소스코드인지 작업계획서인지 먼저 판별
```
### 판별 기준
| 파일 확장자 | 유형 | 검증 방식 |
| ----------- | ---------------- | ---------------------------------------- |
| `*.json` | 소스코드 | 파일 전체를 레이아웃 JSON으로 검증 |
| `*.tsx` | 소스코드 | 컴포넌트 코드로 검증 + 테스트 실행 |
| `*.md` | **작업계획서** | 마크다운 내 코드 블록 추출 후 검증 |
### 작업계획서 검증 시 추가 단계
작업계획서(`.md`) 파일인 경우:
1. **코드 블록 추출**: 마크다운에서 \`\`\`json, \`\`\`typescript, \`\`\`tsx 코드 블록 추출
2. **컨텍스트 파악**: 코드 블록 앞뒤 텍스트로 해당 코드의 용도 파악
3. **유형별 검증**: JSON → 레이아웃 검증, TypeScript → 핸들러/컴포넌트 검증
```text
⚠️ 작업계획서 검증 시 주의사항:
- 코드 블록이 예시/설명 목적인지, 실제 구현 계획인지 구분
- "예시:", "Example:", "// 예시" 등이 포함된 코드는 참고용으로 처리
- 실제 구현 계획 코드만 엄격하게 검증
```
## 0.1단계: 그누보드7 DevTools MCP 활용 (소스코드 검증 시 권장)
브라우저 상태 덤프가 가능한 경우, MCP 도구를 먼저 활용하여 런타임 상태를 확인합니다.
| 도구 | 검증 내용 |
|------|----------|
| `g7-state` | 현재 _global, _local 상태 값 |
| `g7-expressions` | 표현식 평가 경고, 잘못된 바인딩 |
| `g7-form` | Form 바인딩 상태, 누락된 name prop |
| `g7-actions` | 핸들러 실행 오류 |
## 1단계: 규정 문서 읽기
다음 규정 문서를 읽어 최신 규칙을 확인합니다:
- `docs/frontend/components.md` (인덱스) - 컴포넌트 규칙
- `components-types.md` - 기본/집합/레이아웃 컴포넌트
- `components-patterns.md` - 순환 의존성, 다국어, skipBindingKeys
- `components-advanced.md` - 이벤트 통신, 아이콘, 개발 체크리스트
- `.claude/docs/frontend/fontawesome-free-icons.md` (인덱스) - Font Awesome Free 아이콘 목록
- `fontawesome-icons-solid.md` - Solid 아이콘 (1,390개)
- `fontawesome-icons-regular.md` - Regular 아이콘 (163개)
- `fontawesome-icons-brands.md` - Brands 아이콘 (472개)
- `docs/frontend/layout-json.md` - 레이아웃 JSON 스키마
- `docs/frontend/layout-json-inheritance.md` - 레이아웃 상속
- `docs/frontend/data-binding.md` - 데이터 바인딩
- `docs/frontend/data-binding-i18n.md` - 다국어 바인딩
- `docs/frontend/data-sources.md` - 데이터 소스
- `docs/frontend/dark-mode.md` - 다크 모드 규칙
- `docs/frontend/responsive-layout.md` - 반응형 레이아웃
- `docs/frontend/state-management.md` - 상태 관리
- `docs/frontend/actions.md` - 액션 시스템
- `docs/frontend/actions-handlers.md` - 핸들러별 params, onSuccess/onError
- `docs/frontend/security.md` - 보안 규칙
- `docs/frontend/modal-usage.md` - 모달 사용 규칙
- `docs/frontend/layout-json-features-error.md` - 에러 핸들링
- `docs/frontend/component-props-composite.md` - Composite 컴포넌트 Props
- `docs/frontend/layout-json-components-loading.md` - 데이터 로딩 및 생명주기
- `docs/frontend/layout-json-components-slots.md` - 슬롯 시스템
## 2단계: 검증 대상 파일 읽기
$ARGUMENTS 경로의 파일을 읽습니다.
경로가 지정되지 않은 경우, 다음 파일들을 대상으로 합니다:
- `resources/layouts/**/*.json` - 코어 레이아웃
- `templates/**/layouts/**/*.json` - 템플릿 레이아웃
- `modules/**/resources/layouts/**/*.json` - 모듈 레이아웃
- `templates/**/src/components/**/*.tsx` - 컴포넌트 파일
## 3단계: 규정 기반 자동 검증 (CRITICAL)
```
⚠️ CRITICAL: 1단계에서 읽은 규정 문서의 모든 규칙을 검증 항목으로 사용합니다.
스킬에 하드코딩된 검증 항목이 아닌, 규정 문서가 Single Source of Truth입니다.
```
### ⭐ 검증 완료 전 필수 체크리스트 (MANDATORY)
```text
⚠️ CRITICAL: 아래 체크리스트를 모두 완료하기 전까지 검증 완료 선언 금지
□ 1. 규정 문서 읽기 완료 (섹션 1)
□ 2. 검증 대상 파일 읽기 완료 (섹션 2)
□ 3. 규정 기반 금지/필수 패턴 검증 완료 (섹션 3.1~3.4)
□ 4. ⭐⭐⭐ 규정 문서 기반 속성 검증 완료 - 필수 ⭐⭐⭐
□ layout-json.md의 컴포넌트 정의 필드와 대조 완료
□ actions.md의 내장 핸들러 목록과 대조 완료
□ 작업계획서의 모든 속성이 규정에 정의된 것인지 확인 완료
□ 5. Font Awesome 아이콘 검증 완료 (섹션 3.6)
□ 6. 테스트 실행 검증 (해당 시) (섹션 4)
□ 7. 결과 보고서 작성 (섹션 5)
❌ 절대 금지: 규정 문서 대조 없이 검증 완료 선언
✅ 필수: 작업계획서의 모든 속성/핸들러가 규정 문서에 정의되어 있는지 대조
```
### 3.1 규정에서 검증 패턴 추출
1단계에서 읽은 모든 규정 문서에서 다음 패턴을 추출합니다:
| 추출 대상 | 검증 유형 |
|----------|----------|
| `❌` 또는 `잘못된` 키워드가 포함된 코드 블록 | **금지 패턴** - 발견 시 에러 |
| `✅` 또는 `올바른` 키워드가 포함된 코드 블록 | **필수 패턴** - 누락 시 경고 |
| `⚠️ 절대 금지`, `CRITICAL` 등 강조된 규칙 | **필수 검증 항목** |
| `TL;DR` 섹션의 핵심 포인트 | **우선 검증 항목** |
### 3.2 검증 방법
**금지 패턴 검증**:
```bash
# 규정 문서의 ❌ 예시에서 추출한 패턴을 grep으로 검사
grep -rn "[금지 패턴]" [대상 파일/디렉토리]
```
발견 시: 에러로 보고하고 규정 문서의 해당 섹션 참조 안내
**필수 패턴 검증**:
- 해당 컨텍스트에서 필수 패턴이 사용되어야 하는 경우, 누락 여부 확인
- 누락 시: 경고로 보고하고 규정 문서의 올바른 예시 안내
### 3.3 컴포넌트 소스코드 검증 (TSX 파일)
컴포넌트 파일(.tsx)이 검증 대상인 경우, 규정 문서에서 추출한 패턴으로 추가 검증:
1. **컴포넌트 소스코드 위치**:
```
templates/[vendor-template]/src/components/
├── basic/ # 기본 컴포넌트
├── composite/ # 집합 컴포넌트
└── layout/ # 레이아웃 컴포넌트
```
2. **컴포넌트 등록 검증** (규정: components.md "10. 컴포넌트 개발 체크리스트"):
- `index.ts`에 export 등록 여부
- `components.json`에 등록 여부
- 테스트 파일 존재 여부
### 3.4 레이아웃 JSON Props 검증
레이아웃 JSON에서 사용된 컴포넌트의 props가 실제로 지원되는지 **컴포넌트 소스코드를 직접 분석**하여 검증합니다.
1. 레이아웃 JSON에서 사용된 컴포넌트명 추출
2. 해당 컴포넌트의 소스코드에서 Props 인터페이스 분석
3. 지원되지 않는 props 사용 시 에러 보고
### 3.5 규정 문서 기반 속성 검증
작업계획서의 모든 레이아웃 속성/핸들러가 **규정 문서에 정의**되어 있는지 대조 검증합니다.
```text
⚠️ CRITICAL: 규정 문서가 Single Source of Truth
✅ 규정 문서만으로 검증 가능 (소스코드 Read 불필요)
✅ 토큰 효율적, 컨텍스트 절약
```
| 검증 대상 | 규정 문서 | 확인 항목 |
|-----------|-----------|-----------|
| 레이아웃 최상위 필드 | `layout-json.md` > 필수 필드 | version, layout_name, meta, data_sources, components 등 |
| 컴포넌트 정의 필드 | `layout-json.md` > 컴포넌트 정의 | id, type, name, props, children, actions, text, iteration, if 등 |
| 고급 기능 | `layout-json-features.md` | classMap, computed, errorHandling, init_actions, modals, scripts |
| 반복/조건부 렌더링 | `layout-json-components.md` | iteration, blur_until_loaded, lifecycle, responsive |
| 액션 핸들러 | `actions.md` > 내장 핸들러 목록 | navigate, apiCall, setState, openModal 등 21개 |
| 액션 정의 필드 | `actions.md` > 액션 정의 구조 | type, event, handler, params, onSuccess, onError, confirm, key, debounce |
| 데이터 바인딩 | `data-binding.md` | {{expression}}, $t:key, $event, $args |
| 데이터 소스 | `data-sources.md` | id, endpoint, method, params, loading_strategy 등 |
### 3.6 CLAUDE.md CRITICAL RULES 검증 (MANDATORY)
CLAUDE.md의 "CRITICAL RULES - 절대 금지 패턴" 및 MEMORY.md의 학습된 규칙을 검증합니다.
```text
⚠️ CRITICAL: 아래 항목은 빈번한 실수이므로 반드시 검증
```
#### 3.6.1 레이아웃 구조 금지 패턴
| # | 검증 항목 | 금지 패턴 | 올바른 패턴 |
|---|----------|----------|------------|
| 1 | type: "conditional" 사용 금지 | `"type": "conditional"` | `"if": "{{expression}}"` 속성 사용 |
| 2 | type: "iterator" 사용 금지 | `"type": "iterator"` | 실제 컴포넌트에 `"iteration": {...}` 속성 사용 |
| 3 | Partial 파일 최상위 속성 금지 | Partial에 computed, data_sources, modals, state 정의 | Partial은 컴포넌트 치환만 가능 |
| 4 | 부모-자식 data_sources ID 중복 | 동일 ID가 부모/자식 레이아웃에 존재 | 고유 ID 사용 필수 |
| 5 | globalHeaders 객체 형식 | `"globalHeaders": { "X-Key": "value" }` | `"globalHeaders": [{ "pattern": "*", "headers": {...} }]` 배열 형식 |
| 6 | 레이아웃 JSON 새 최상위 속성 | 새 속성 추가 후 백엔드 미동기화 | UpdateLayoutContentRequest 필수 수정 |
| 7 | children 문자열 직접 사용 | `"children": "텍스트"` | `"text": "텍스트"` 속성 사용 |
#### 3.6.2 핸들러/액션 금지 패턴
| # | 검증 항목 | 금지 패턴 | 올바른 패턴 |
|---|----------|----------|------------|
| 1 | navigate + replace:true | `handler: "navigate"` + `replace: true` (URL만 변경 시) | `handler: "replaceUrl"` |
| 2 | 잘못된 핸들러명 | `handler: "api"`, `handler: "nav"`, `handler: "setLocalState"` | `handler: "apiCall"`, `handler: "navigate"`, `handler: "setState"` + `target: "local"` |
| 3 | 표현식에서 핸들러 호출 | `{{handler()}}` | `actions: [{ handler: "xxx" }]` |
| 4 | openModal/closeModal params | `{ "handler": "openModal", "params": { "id": "xxx" } }` | `{ "handler": "openModal", "target": "xxx" }` |
#### 3.6.3 데이터 바인딩 금지 패턴
| # | 검증 항목 | 금지 패턴 | 올바른 패턴 |
|---|----------|----------|------------|
| 1 | onSuccess $response | `{{$response.xxx}}` | `{{response.xxx}}` ($ 접두사 없음) |
| 2 | 이벤트 값 접근 | `$value` | `$event.target.value` |
| 3 | Partial에서 props 접근 | `{{props.xxx}}` | data_sources ID 직접 참조 |
| 4 | 에러 데이터 접근 | `{{error.data}}` | `{{error.errors}}` |
| 5 | 배열 데이터 경로 | `{{products.data}}` | `{{products?.data?.data}}` (경로 확인) |
| 6 | fallback 없는 바인딩 | `{{value}}` | `{{value ?? ''}}` |
#### 3.6.4 반복 렌더링(iteration) 금지 패턴
| # | 검증 항목 | 금지 패턴 | 올바른 패턴 |
|---|----------|----------|------------|
| 1 | 변수명 | `"item"`, `"index"` | `"item_var"`, `"index_var"` |
| 2 | if와 iteration 순서 | if 순서 무시 | if가 iteration보다 먼저 평가됨 |
#### 3.6.5 상태 관리 금지 패턴
| # | 검증 항목 | 금지 패턴 | 올바른 패턴 |
|---|----------|----------|------------|
| 1 | setState params 키에 {{}} | `"params": { "{{key}}": "value" }` | 키는 정적 경로만 사용 |
| 2 | closeModal 후 setState | closeModal → setState 순서 | setState → closeModal 순서 |
| 3 | sortable 내 폼 자동바인딩 | sortable 내 폼 자동바인딩 그대로 | `parentFormContextProp={undefined}` |
| 4 | await 후 캡처된 상태 | await 후 캡처된 _local 사용 | `G7Core.state.getLocal()` 재조회 |
#### 3.6.6 컴포넌트 Props 금지 패턴
| # | 검증 항목 | 금지 패턴 | 올바른 패턴 |
|---|----------|----------|------------|
| 1 | Icon size 설정 | `Icon className="w-4 h-4"` | `Icon size="sm"` 또는 `className="text-sm"` |
| 2 | Select 옵션 fallback | `options={{options}}` | `options={{options ?? []}}` |
| 3 | Form 내 Button type 누락 | Form 내 `Button` type 없음 | `type="button"` 명시 (submit 방지) |
| 4 | Select valueKey/labelKey | `valueKey`, `labelKey` props 사용 | computed로 `{ value, label }` 변환 |
#### 3.6.7 모듈 레이아웃 다국어 검증
| # | 검증 항목 | 금지 패턴 | 올바른 패턴 |
|---|----------|----------|------------|
| 1 | 모듈 식별자 누락 | `$t:common.xxx` (모듈 레이아웃에서) | `$t:sirsoft-ecommerce.common.xxx` |
### 3.7 Font Awesome 아이콘 검증 (CRITICAL)
레이아웃 JSON 및 TSX 파일에서 사용된 아이콘이 **Font Awesome Free 버전**에 포함되어 있는지 검증합니다.
**검증 기준 문서**: `.claude/docs/frontend/fontawesome-free-icons.md`
#### 아이콘 추출 패턴
레이아웃 JSON에서:
```json
// Icon 컴포넌트의 name prop에서 아이콘명 추출
{
"name": "Icon",
"props": {
"name": "fa-solid fa-user" // ← 이 값 추출
}
}
```
TSX 파일에서:
```typescript
// IconName enum 사용 또는 직접 문자열
<Icon name={IconName.User} />
<Icon name="fa-solid fa-check" />
```
#### 검증 규칙
1. **Pro 전용 스타일 사용 금지** (에러):
- `fa-light`, `fal` - Light 스타일
- `fa-thin`, `fat` - Thin 스타일
- `fa-duotone`, `fad` - Duotone 스타일
- `fa-sharp`, `fass`, `fasr`, `fasl` - Sharp 스타일
2. **Free 아이콘 목록 검증** (에러):
- `fontawesome-free-icons.md`의 Solid/Regular/Brands 목록에 없는 아이콘 사용 시 에러
- Regular 스타일은 163개만 Free (나머지는 Solid 사용 필요)
#### 아이콘명 추출 및 검증 방법 (CRITICAL)
```
⚠️ CRITICAL: Pro 스타일 검사만으로 충분하지 않음
✅ MANDATORY: 실제 아이콘명이 Free 목록에 존재하는지 반드시 검증
```
##### Step 1: 레이아웃 JSON에서 아이콘명 추출
```bash
# Icon 컴포넌트의 name prop에서 아이콘명 추출
grep -rhoP '"name":\s*"Icon"[^}]*"name":\s*"\K[^"]+' templates/[template]/layouts/**/*.json | sort -u
```
또는 JSON 구조를 분석하여 Icon 컴포넌트의 props.name 값을 추출합니다.
##### Step 2: TSX 파일에서 아이콘명 추출
```bash
# Icon 컴포넌트 사용 패턴에서 아이콘명 추출
grep -rhoP '<Icon[^>]*name=["'\'']\K[^"'\''"]+' templates/[template]/src/components/**/*.tsx | sort -u
grep -rhoP 'name:\s*["'\'']\K[^"'\''"]+' templates/[template]/src/components/**/*.tsx | sort -u
```
##### Step 3: 추출된 아이콘명을 Free 목록과 대조
1. `fontawesome-free-icons.md`에서 아이콘 목록 추출:
- Solid 아이콘: `## Solid 아이콘 목록` 섹션의 `fa-*` 패턴
- Regular 아이콘: `## Regular 아이콘 목록` 섹션의 `fa-*` 패턴
- Brands 아이콘: `## Brands 아이콘 목록` 섹션의 `fa-*` 패턴
2. 추출된 각 아이콘명에 대해:
- 아이콘명 파싱: `fa-solid fa-user` → 스타일=`solid`, 아이콘=`fa-user`
- 아이콘명 파싱: `user` (단축형) → 스타일=`solid`(기본), 아이콘=`fa-user`
- 해당 스타일의 Free 목록에 아이콘이 존재하는지 확인
3. 존재하지 않는 아이콘 발견 시:
- 에러로 보고
- 유사한 Free 아이콘 추천 (가능한 경우)
##### Step 4: iconNameMap 매핑 확인
Icon 컴포넌트에서 `iconNameMap`을 사용하는 경우, 매핑된 실제 아이콘명도 검증:
```typescript
// 예: "shopping-cart" → "fa-cart-shopping" 매핑 확인
// 매핑된 결과가 Free 목록에 있는지 검증
```
##### 검증 결과 보고 형식
```text
### Font Awesome 아이콘 검증
- ❌ Pro 전용 스타일 사용: "fa-light fa-user" (파일:라인)
- 수정: "fa-solid fa-user" 또는 "fa-regular fa-user" 사용
- ❌ Free에 없는 아이콘: "fa-solid fa-plate-utensils" (파일:라인)
- fontawesome-free-icons.md에서 대체 아이콘 검색 필요
- ✅ 모든 아이콘이 Free 버전에 포함됨
```
## 4단계: 테스트 실행 검증
컴포넌트 파일이 검증 대상인 경우, 테스트를 실행하여 통과 여부를 확인합니다.
```
⚠️ CRITICAL: Windows 환경에서 프론트엔드 테스트는 반드시 PowerShell 래퍼 사용
✅ 프로젝트 루트에서: powershell -Command "npm run test:run -- ComponentName"
✅ 템플릿 디렉토리에서: cd templates/sirsoft-admin_basic && powershell -Command "npm run test:run"
✅ _bundled 모듈에서: cd modules/_bundled/sirsoft-ecommerce && powershell -Command "npm run test:run"
```
## 5단계: 결과 보고
검증 결과를 다음 형식으로 보고합니다:
```
## 프론트엔드 검증 결과
### 검증 파일
- [파일 경로]
### [규정 문서명] - [섹션명] 검증 결과
#### 금지 패턴 검사
- ❌ 금지 패턴 발견: [패턴] (파일:라인)
- 규정: [규정 문서명] > [섹션명]
- 수정 방법: [올바른 패턴으로 변경]
- ✅ 금지 패턴 없음
#### 필수 패턴 검사
- ❌ 필수 패턴 누락: [컨텍스트]에서 [패턴] 필요
- 규정: [규정 문서명] > [섹션명]
- ✅ 필수 패턴 준수
### 컴포넌트 등록 검증 (TSX 파일인 경우)
- ✅/❌ index.ts export 등록
- ✅/❌ components.json 등록
- ✅/❌ 테스트 파일 존재
### 테스트 실행 결과 (TSX 파일인 경우)
- ✅ 모든 테스트 통과 (X passed)
- ❌ 테스트 실패: [실패 메시지]
```
## 6단계: 레이아웃 기능 연동 검증 (신규 기능 추가 시)
새로운 레이아웃 JSON 기능(속성, 핸들러 등)이 추가된 경우, 다음 연동 항목도 반영되었는지 검증합니다.
### 6.1 검증 대상
템플릿 엔진에 새로운 기능이 추가된 경우:
- 새 액션 핸들러 (예: `debounce`, `filter` 등)
- 새 컴포넌트 속성 (예: `classMap`, `computed` 등)
- 새 데이터 바인딩 문법 (예: `$switch`, `$get`, 파이프 등)
- 새 데이터소스 옵션 (예: `if` 조건부 로딩 등)
### 6.2 연동 검증 항목
| 검증 항목 | 확인 위치 | 확인 방법 |
|----------|----------|----------|
| **DevTools 연동** | `resources/js/core/template-engine/devtools/G7DevToolsCore.ts` | 새 기능의 추적/디버깅 지원 여부 |
| **WYSIWYG 에디터 연동** | `resources/js/core/template-engine/wysiwyg/components/PropertyPanel/*.tsx` | 새 기능의 UI 편집기 지원 여부 |
| **규정 문서 업데이트** | `docs/frontend/*.md` | 새 기능 문서화 여부 |
### 6.3 연동 확인 방법
**DevTools 연동 확인**:
```bash
# 새 기능명으로 DevTools 코드 검색
grep -rn "[기능명]" resources/js/core/template-engine/devtools/
```
**WYSIWYG 에디터 연동 확인**:
```bash
# 새 기능명으로 WYSIWYG 코드 검색
grep -rn "[기능명]" resources/js/core/template-engine/wysiwyg/
```
### 6.4 보고 형식
```text
### 레이아웃 기능 연동 검증
#### 신규 기능: [기능명]
- ✅/❌ DevTools 연동: [추적 지원 여부]
- ✅/❌ WYSIWYG 에디터 연동: [UI 편집기 지원 여부]
- ✅/❌ 규정 문서 업데이트: [문서화 여부]
#### 누락 항목
- DevTools: [trackAction에 기능 정보 추가 필요]
- WYSIWYG: [PropertyPanel에 편집기 추가 필요]
```
---
## 7단계: 작업계획서 전용 검증 (마크다운 파일인 경우)
검증 대상이 작업계획서(`.md`)인 경우, 다음 추가 검증을 수행합니다.
### 7.1 코드 블록 추출 및 분류
마크다운 파일에서 코드 블록을 추출하고 유형별로 분류합니다:
| 코드 블록 언어 | 검증 유형 |
| ---------------------- | ---------------------------- |
| \`\`\`json | 레이아웃 JSON 검증 |
| \`\`\`typescript, ts | 핸들러/타입 정의 검증 |
| \`\`\`tsx | 컴포넌트 코드 검증 |
| \`\`\`php | 백엔드 코드 검증 (필요시) |
### 7.2 레이아웃 JSON 코드 블록 검증
작업계획서 내 JSON 코드 블록에 대해 다음을 검증합니다:
#### 7.2.1 컴포넌트 정의 구조 검증
소스코드 기반으로 `ComponentDefinition` 인터페이스와 일치하는지 확인:
```typescript
// resources/js/core/template-engine/DynamicRenderer.tsx
interface ComponentDefinition {
id?: string;
type: 'basic' | 'composite' | 'layout';
name: string;
props?: Record<string, any>;
children?: ComponentDefinition[];
actions?: ActionDefinition[];
if?: string;
iteration?: IterationConfig;
responsive?: ResponsiveConfig;
text?: string;
slots?: Record<string, ComponentDefinition[]>;
}
```
**검증 항목**:
- ❌ `type`이 `basic`, `composite`, `layout` 외의 값인 경우
- ❌ `name`이 누락된 경우
- ❌ 지원되지 않는 최상위 속성 사용 (예: `class` 대신 `props.className`)
#### 7.2.2 컴포넌트 Props 검증
사용된 컴포넌트의 props가 실제 컴포넌트에서 지원되는지 검증:
1. 계획서에서 사용된 컴포넌트명 목록 추출
2. 각 컴포넌트의 소스코드에서 `Props` 인터페이스 확인
3. 지원되지 않는 props 사용 시 에러 보고
```text
검증 경로: templates/[vendor-template]/src/components/{type}/{Name}.tsx
```
#### 7.2.3 액션 정의 구조 검증
소스코드 기반으로 `ActionDefinition` 인터페이스와 일치하는지 확인:
```typescript
// resources/js/core/template-engine/ActionDispatcher.ts
interface ActionDefinition {
type: string; // 이벤트 타입 (click, change, submit 등)
handler: string; // 핸들러명
params?: Record<string, any>;
onSuccess?: ActionDefinition | ActionDefinition[];
onError?: ActionDefinition | ActionDefinition[];
actions?: ActionDefinition[]; // sequence 핸들러용
}
```
**검증 항목**:
- ❌ `type`이 누락된 경우
- ❌ `handler`가 누락된 경우
- ❌ 지원되지 않는 핸들러 사용 (actions.md 및 등록된 핸들러 확인)
#### 7.2.4 반응형 레이아웃 검증 (CRITICAL)
```text
⚠️ CRITICAL: Tailwind 반응형 클래스 대신 그누보드7 responsive 속성 사용 권장
```
**금지 패턴 검사**:
```json
// ❌ Tailwind 반응형 클래스로 show/hide
"props": { "className": "hidden md:block" }
"props": { "className": "block md:hidden" }
// ❌ Tailwind 반응형 클래스로 그리드 변경
"props": { "className": "grid grid-cols-1 md:grid-cols-2" }
```
**권장 패턴 안내**:
```json
// ✅ 그누보드7 responsive로 show/hide
"responsive": {
"portable": { "if": "{{false}}" }
}
// ✅ 그누보드7 responsive로 그리드 변경
"props": { "className": "grid grid-cols-2 gap-4" },
"responsive": {
"portable": {
"props": { "className": "grid grid-cols-1 gap-4" }
}
}
```
#### 7.2.5 데이터 바인딩 문법 검증
```text
✅ 지원되는 문법:
- {{expression}} - 데이터 바인딩
- {{obj?.prop}} - Optional chaining
- {{value ?? default}} - Nullish coalescing
- $t:key - 다국어 (즉시 평가)
- $t:defer:key - 다국어 (지연 평가)
- $event - 이벤트 데이터
❌ 지원되지 않는 문법 사용 시 에러
```
### 7.3 TypeScript/핸들러 코드 블록 검증
작업계획서 내 TypeScript 코드 블록에 대해 다음을 검증합니다:
#### 7.3.1 핸들러 등록 패턴 검증
```typescript
// ✅ 올바른 패턴
G7Core.handlers.register(`sirsoft-ecommerce.${name}`, handler);
// ❌ 잘못된 패턴
G7Core.handlers.register(name, handler); // 네임스페이스 누락
```
#### 7.3.2 상태 관리 패턴 검증
```typescript
// ✅ 올바른 패턴
G7Core.state.setLocal({ form: {...form, field: value}, hasChanges: true });
// ❌ 잘못된 패턴 (직접 변경)
_local.form.field = value;
```
#### 7.3.3 API 호출 패턴 검증
```typescript
// ✅ G7Core.api 사용
const response = await G7Core.api.post('/api/...', data);
// ❌ fetch 직접 사용
const response = await fetch('/api/...');
```
### 7.4 작업계획서 검증 결과 보고 형식
```text
## 작업계획서 프론트엔드 검증 결과
### 검증 파일
- [마크다운 파일 경로]
### 코드 블록 요약
- JSON 코드 블록: X개
- TypeScript 코드 블록: Y개
- 총 검증 대상: Z개
### 레이아웃 JSON 검증
#### ComponentDefinition 구조 검증
- ✅ 모든 컴포넌트 정의가 유효함
- ❌ 잘못된 구조: [위치] - [문제 설명]
#### 컴포넌트 Props 검증
- ✅ 모든 props가 지원됨
- ❌ 지원되지 않는 prop: [컴포넌트명].[prop명] (코드 블록 위치)
- 컴포넌트 소스: [소스 파일 경로]
- 지원되는 props: [목록]
#### 액션 정의 검증
- ✅ 모든 핸들러가 유효함
- ❌ 지원되지 않는 핸들러: [핸들러명] (코드 블록 위치)
- 참조: actions.md 또는 등록된 커스텀 핸들러 확인
#### 반응형 레이아웃 검증
- ✅ 그누보드7 responsive 패턴 준수
- ⚠️ Tailwind 반응형 클래스 사용: [위치]
- 현재: "className": "[Tailwind 클래스]"
- 권장: responsive 속성 사용
#### 데이터 바인딩 검증
- ✅ 모든 바인딩 문법 유효
- ❌ 지원되지 않는 문법: [위치] - [문법]
### TypeScript 핸들러 검증
- ✅ 핸들러 등록 패턴 준수
- ❌ 네임스페이스 누락: [위치]
- ✅ 상태 관리 패턴 준수
- ❌ 직접 상태 변경: [위치]
```
---
## 핵심 원칙
```text
⚠️ CRITICAL:
- 규정 문서가 Single Source of Truth - 스킬에 검증 항목 하드코딩 금지
- 규정 문서의 ❌/✅ 예시를 자동으로 검증 패턴으로 사용
- 규정이 변경되면 검증도 자동으로 변경됨
- 소스코드 인터페이스 분석으로 실제 지원 여부 확인
- 레이아웃 기능 추가 시 DevTools와 WYSIWYG 에디터 연동도 함께 검증
- 작업계획서(.md) 검증 시 코드 블록 추출 후 동일한 규정 적용
```
+361
View File
@@ -0,0 +1,361 @@
# 훅 패턴 검증 (validate-hook)
훅 시스템 및 **작업계획서 내 훅 관련 코드 블록**이 그누보드7 규정을 준수하는지 검증합니다.
## 0단계: 검증 대상 유형 판별 (CRITICAL)
```text
⚠️ CRITICAL: 검증 대상이 소스코드인지 작업계획서인지 먼저 판별
```
### 판별 기준
| 파일 확장자 | 유형 | 검증 방식 |
| ----------- | ---------------- | ---------------------------------------------- |
| `*.php` | 소스코드 | 파일 전체를 훅/리스너 코드로 검증 |
| `*.md` | **작업계획서** | 마크다운 내 PHP 코드 블록 추출 후 검증 |
### 작업계획서 검증 시 추가 단계
작업계획서(`.md`) 파일인 경우:
1. **코드 블록 추출**: 마크다운에서 \`\`\`php 코드 블록 추출
2. **훅 관련 코드 식별**: `HookManager`, `doAction`, `applyFilters`, `Listener` 등의 키워드로 식별
3. **규정 적용**: hooks.md의 훅 시스템 규칙 적용
```text
⚠️ 작업계획서 검증 시 주의사항:
- 코드 블록이 예시/설명 목적인지, 실제 구현 계획인지 구분
- "예시:", "Example:", "// 예시" 등이 포함된 코드는 참고용으로 처리
- 실제 구현 계획 코드만 엄격하게 검증
```
## 1단계: 규정 문서 읽기
다음 규정 문서를 읽어 최신 규칙을 확인합니다:
- `docs/extension/hooks.md` - 훅 시스템 규칙
- `docs/backend/service-repository.md` - Service에서의 훅 실행
- `docs/extension/plugin-development.md` - 플러그인 격리 원칙
## 2단계: 검증 대상 파일 읽기
$ARGUMENTS 경로의 파일을 읽습니다.
경로가 지정되지 않은 경우, 다음 파일들을 대상으로 합니다:
- `**/Listeners/**/*.php` - 훅 리스너
- `app/Services/**/*.php` - 훅 실행 서비스
- `modules/_bundled/**/src/Services/**/*.php` - _bundled 모듈 서비스
- `modules/_bundled/**/src/Listeners/**/*.php` - _bundled 모듈 리스너
- `plugins/_bundled/**/src/Listeners/**/*.php` - _bundled 플러그인 리스너
## 3단계: 규정 기반 자동 검증 (CRITICAL)
```text
⚠️ CRITICAL: 1단계에서 읽은 규정 문서의 모든 규칙을 검증 항목으로 사용합니다.
스킬에 하드코딩된 검증 항목이 아닌, 규정 문서가 Single Source of Truth입니다.
```
### 3.1 규정에서 검증 패턴 추출
1단계에서 읽은 모든 규정 문서에서 다음 패턴을 추출합니다:
| 추출 대상 | 검증 유형 |
| --------- | --------- |
| `❌` 또는 `잘못된` 키워드가 포함된 코드 블록 | **금지 패턴** - 발견 시 에러 |
| `✅` 또는 `올바른` 키워드가 포함된 코드 블록 | **필수 패턴** - 누락 시 경고 |
| `⚠️ 절대 금지`, `CRITICAL` 등 강조된 규칙 | **필수 검증 항목** |
| `TL;DR` 섹션의 핵심 포인트 | **우선 검증 항목** |
### 3.2 검증 방법
**금지 패턴 검증**:
```bash
# 규정 문서의 ❌ 예시에서 추출한 패턴을 grep으로 검사
grep -rn "[금지 패턴]" [대상 파일/디렉토리]
```
발견 시: 에러로 보고하고 규정 문서의 해당 섹션 참조 안내
**필수 패턴 검증**:
- 해당 컨텍스트에서 필수 패턴이 사용되어야 하는 경우, 누락 여부 확인
- 누락 시: 경고로 보고하고 규정 문서의 올바른 예시 안내
### 3.3 훅 특화 검증
규정 문서의 훅 관련 섹션에서 추출한 규칙으로 다음을 검증합니다:
1. **훅 네이밍**: `[vendor-module].[entity].[action]_[timing]` 패턴 준수 여부
2. **훅 실행 순서 (Service)**: before → filter → action → after 순서
3. **Listener 인터페이스**: `HookListenerInterface` 구현 여부
4. **Filter 훅 type 필드**: `'type' => 'filter'` 명시 여부
5. **Filter 훅 리턴 값**: 값 반환 여부
### 3.4 파일 유형별 규정 매핑
검증 대상 파일의 경로에 따라 해당하는 규정 문서를 우선적으로 적용합니다:
| 파일 경로 패턴 | 우선 적용 규정 |
| -------------- | -------------- |
| `**/Listeners/**` | hooks.md |
| `**/Services/**` | service-repository.md, hooks.md |
### 3.5 CLAUDE.md CRITICAL RULES 검증 (MANDATORY)
CLAUDE.md 및 규정 문서에 명시된 CRITICAL 규칙을 검증합니다.
#### 3.5.1 _bundled 디렉토리 작업 원칙
```text
⚠️ CRITICAL: 확장 수정/개발은 반드시 _bundled 디렉토리에서만 작업
→ 활성 디렉토리 직접 수정 절대 금지
```
**검증 항목**:
- 수정 대상 파일이 `_bundled/` 하위에 있는지 확인
- 활성 디렉토리(`modules/vendor-xxx/`, `plugins/vendor-xxx/`)의 파일 수정 시 경고
#### 3.5.2 플러그인 격리 원칙
```text
⚠️ CRITICAL: 플러그인 간 직접 의존 금지 → 모듈에만 의존, 플러그인끼리는 API로만 통신
```
| ❌ 금지 | ✅ 올바른 사용 |
| -------- | --------------- |
| 플러그인 A → 플러그인 B 직접 참조 | 플러그인 A → 모듈 API → 플러그인 B |
| 플러그인에서 다른 플러그인 클래스 import | 훅/이벤트 또는 API 통신 |
**검증 항목**:
- 플러그인 코드에서 다른 플러그인의 네임스페이스(`Plugins\`) 참조 여부 확인
- 플러그인에서 모듈(`Modules\`) 의존만 허용
## 4단계: 결과 보고
검증 결과를 다음 형식으로 보고합니다:
```text
## 훅 패턴 검증 결과
### 검증 파일
- [파일 경로]
### [규정 문서명] - [섹션명] 검증 결과
#### 금지 패턴 검사
- ❌ 금지 패턴 발견: [패턴] (파일:라인)
- 규정: [규정 문서명] > [섹션명]
- 수정 방법: [올바른 패턴으로 변경]
- ✅ 금지 패턴 없음
#### 필수 패턴 검사
- ❌ 필수 패턴 누락: [컨텍스트]에서 [패턴] 필요
- 규정: [규정 문서명] > [섹션명]
- ✅ 필수 패턴 준수
#### 훅 네이밍 검증
- ✅ 올바름: [훅 이름]
- ❌ 잘못됨: [훅 이름] → [수정 제안]
#### Filter 훅 type 필드 검증
- ✅ type 명시됨: [훅 이름]
- ❌ type 누락: [훅 이름] → 'type' => 'filter' 추가 필요
```
---
## 5단계: 작업계획서 전용 검증 (마크다운 파일인 경우)
검증 대상이 작업계획서(`.md`)인 경우, 다음 추가 검증을 수행합니다.
### 5.1 훅 관련 코드 블록 식별
다음 키워드가 포함된 코드 블록을 훅 관련 코드로 식별:
| 키워드 | 용도 |
| ------------------------------- | ----------------------- |
| `HookManager::doAction` | Action Hook 실행 |
| `HookManager::applyFilters` | Filter Hook 실행 |
| `implements HookListenerInterface` | 리스너 클래스 정의 |
| `getHooks()` | 훅 등록 정의 |
| `'type' => 'filter'` | Filter Hook 타입 명시 |
### 5.2 훅 네이밍 검증
```php
// ✅ 올바른 패턴
HookManager::doAction('sirsoft-ecommerce.product.before_create', $data);
HookManager::doAction('sirsoft-ecommerce.product.after_create', $result);
$data = HookManager::applyFilters('sirsoft-ecommerce.product.filter_create_data', $data);
// ❌ 잘못된 패턴 - 네이밍 규칙 위반
HookManager::doAction('product.create', $data); // ❌ vendor-module 누락
HookManager::doAction('beforeCreateProduct', $data); // ❌ 잘못된 형식
```
**네이밍 규칙**: `[vendor-module].[entity].[action]_[timing]`
**검증 항목**:
- ❌ vendor-module 접두사 누락
- ❌ entity 누락
- ❌ action_timing 형식 위반
- ✅ 올바른 네이밍 패턴
### 5.3 훅 실행 순서 검증 (Service 코드)
```php
// ✅ 올바른 순서: before → filter → action → after
public function create(array $data): Product
{
// 1. before
HookManager::doAction('sirsoft-ecommerce.product.before_create', $data);
// 2. filter
$data = HookManager::applyFilters('sirsoft-ecommerce.product.filter_create_data', $data);
// 3. action
$result = $this->productRepository->create($data);
// 4. after
HookManager::doAction('sirsoft-ecommerce.product.after_create', $result);
return $result;
}
// ❌ 잘못된 순서
public function create(array $data): Product
{
$result = $this->productRepository->create($data); // ❌ 훅 없이 바로 실행
HookManager::doAction('sirsoft-ecommerce.product.before_create', $data); // ❌ 순서 잘못됨
return $result;
}
```
### 5.4 Listener 정의 검증
```php
// ✅ 올바른 패턴
class ProductCacheListener implements HookListenerInterface
{
public function getHooks(): array
{
return [
[
'hook' => 'sirsoft-ecommerce.product.after_create',
'method' => 'clearProductCache',
'priority' => 10,
],
[
'hook' => 'sirsoft-ecommerce.product.filter_list_data',
'method' => 'addCategoryInfo',
'priority' => 10,
'type' => 'filter', // ✅ Filter Hook은 type 명시 필수
],
];
}
public function addCategoryInfo(array $data): array
{
// ... 데이터 변형
return $data; // ✅ Filter Hook은 반드시 값 반환
}
}
// ❌ 잘못된 패턴 - Filter Hook인데 type 누락
[
'hook' => 'sirsoft-ecommerce.product.filter_list_data',
'method' => 'addCategoryInfo',
'priority' => 10,
// ❌ 'type' => 'filter' 누락
],
// ❌ 잘못된 패턴 - Filter Hook인데 반환값 없음
public function addCategoryInfo(array $data): void // ❌ void 반환
{
// ...
}
```
**검증 항목**:
- ❌ HookListenerInterface 미구현
- ❌ Filter Hook에 `'type' => 'filter'` 누락
- ❌ Filter Hook 메서드가 값을 반환하지 않음
- ✅ 올바른 리스너 구조
### 5.5 Filter Hook 반환값 검증
```php
// ✅ 올바른 패턴
public function filterProductData(array $data): array
{
$data['calculated_price'] = $data['price'] * 1.1;
return $data; // ✅ 반드시 반환
}
// ❌ 잘못된 패턴
public function filterProductData(array $data): void // ❌ void
{
$data['calculated_price'] = $data['price'] * 1.1;
// 반환값 없음
}
```
### 5.6 작업계획서 검증 결과 보고 형식
```text
## 작업계획서 훅 패턴 검증 결과
### 검증 파일
- [마크다운 파일 경로]
### 훅 코드 블록 요약
- 훅 실행 코드 블록: X개
- 리스너 정의 블록: Y개
- 총 검증 대상: Z개
### 훅 네이밍 검증
- ✅ 올바른 네이밍: [훅 이름]
- ❌ 잘못된 네이밍: [훅 이름] (코드 블록 위치)
- 규칙: [vendor-module].[entity].[action]_[timing]
- 수정 제안: [올바른 이름]
### 훅 실행 순서 검증
- ✅ before → filter → action → after 순서 준수
- ❌ 순서 위반: [위치] - [문제 설명]
### Listener 정의 검증
- ✅ HookListenerInterface 구현
- ❌ Interface 미구현: [위치]
### Filter Hook type 필드 검증
- ✅ type 명시됨: [훅 이름]
- ❌ type 누락: [훅 이름] (코드 블록 위치)
- 수정: 'type' => 'filter' 추가 필요
### Filter Hook 반환값 검증
- ✅ 반환값 있음: [메서드명]
- ❌ 반환값 없음: [메서드명] (코드 블록 위치)
- void 대신 array/mixed 반환 필요
```
---
## 핵심 원칙
```text
⚠️ CRITICAL:
- 규정 문서가 Single Source of Truth - 스킬에 검증 항목 하드코딩 금지
- 규정 문서의 ❌/✅ 예시를 자동으로 검증 패턴으로 사용
- 규정이 변경되면 검증도 자동으로 변경됨
- 파일 유형에 따라 해당 규정 문서 우선 적용
- 작업계획서(.md) 검증 시 코드 블록 추출 후 동일한 규정 적용
- 모든 확장 작업은 _bundled 디렉토리에서만 수행 (활성 디렉토리 직접 수정 금지)
- 플러그인 간 직접 의존 금지 → 모듈에만 의존
```
+353
View File
@@ -0,0 +1,353 @@
# 다국어 검증 (validate-i18n)
다국어 처리 및 **작업계획서 내 다국어 관련 코드 블록**이 그누보드7 규정을 준수하는지 검증합니다.
## 0단계: 검증 대상 유형 판별 (CRITICAL)
```text
⚠️ CRITICAL: 검증 대상이 소스코드인지 작업계획서인지 먼저 판별
```
### 판별 기준
| 파일 확장자 | 유형 | 검증 방식 |
| --------------- | ---------------- | ---------------------------------------------- |
| `*.php` | 소스코드 | PHP 다국어 처리 검증 |
| `*.json` | 소스코드 | 언어 파일 키 동기화 검증 |
| `*.tsx` | 소스코드 | 프론트엔드 다국어 처리 검증 |
| `*.md` | **작업계획서** | 마크다운 내 코드 블록 추출 후 검증 |
### 작업계획서 검증 시 추가 단계
작업계획서(`.md`) 파일인 경우:
1. **코드 블록 추출**: 마크다운에서 PHP, JSON, TypeScript 코드 블록 추출
2. **다국어 관련 코드 식별**: `__()`, `$t:`, `lang/`, `G7Core.t()` 등의 키워드로 식별
3. **규정 적용**: 해당 유형에 맞는 다국어 규정 적용
```text
⚠️ 작업계획서 검증 시 주의사항:
- 코드 블록이 예시/설명 목적인지, 실제 구현 계획인지 구분
- "예시:", "Example:", "// 예시" 등이 포함된 코드는 참고용으로 처리
- 실제 구현 계획 코드만 엄격하게 검증
```
## 1단계: 규정 문서 읽기
다음 규정 문서를 읽어 최신 규칙을 확인합니다:
- `docs/database-guide.md` - 다국어 섹션
- `docs/backend/validation.md` - 검증 메시지 다국어
- `docs/backend/exceptions.md` - Custom Exception 다국어
- `docs/extension/module-i18n.md` - 모듈 다국어 처리
- `docs/frontend/data-binding-i18n.md` - 프론트엔드 다국어 바인딩
- `docs/frontend/components.md` (인덱스) - 컴포넌트 다국어
- `components-patterns.md` - 다국어 번역 (G7Core.t) 상세
## 2단계: 검증 대상 파일 읽기
$ARGUMENTS 경로의 파일을 읽습니다.
경로가 지정되지 않은 경우, 다음 파일들을 대상으로 합니다:
- `lang/ko/*.php`, `lang/en/*.php` - 코어 언어 파일
- `**/lang/ko/*.php`, `**/lang/en/*.php` - 모듈 언어 파일
- `**/resources/lang/*.json` - 프론트엔드 언어 파일
- `templates/**/lang/*.json` - 템플릿 언어 파일
## 3단계: 규정 기반 자동 검증 (CRITICAL)
```text
⚠️ CRITICAL: 1단계에서 읽은 규정 문서의 모든 규칙을 검증 항목으로 사용합니다.
스킬에 하드코딩된 검증 항목이 아닌, 규정 문서가 Single Source of Truth입니다.
```
### 3.1 규정에서 검증 패턴 추출
1단계에서 읽은 모든 규정 문서에서 다음 패턴을 추출합니다:
| 추출 대상 | 검증 유형 |
| --------- | --------- |
| `❌` 또는 `잘못된` 키워드가 포함된 코드 블록 | **금지 패턴** - 발견 시 에러 |
| `✅` 또는 `올바른` 키워드가 포함된 코드 블록 | **필수 패턴** - 누락 시 경고 |
| `⚠️ 절대 금지`, `CRITICAL` 등 강조된 규칙 | **필수 검증 항목** |
| `TL;DR` 섹션의 핵심 포인트 | **우선 검증 항목** |
### 3.2 검증 방법
**금지 패턴 검증**:
```bash
# 규정 문서의 ❌ 예시에서 추출한 패턴을 grep으로 검사
grep -rn "[금지 패턴]" [대상 파일/디렉토리]
```
발견 시: 에러로 보고하고 규정 문서의 해당 섹션 참조 안내
**필수 패턴 검증**:
- 해당 컨텍스트에서 필수 패턴이 사용되어야 하는 경우, 누락 여부 확인
- 누락 시: 경고로 보고하고 규정 문서의 올바른 예시 안내
### 3.3 다국어 특화 검증
규정 문서의 다국어 관련 섹션에서 추출한 규칙으로 다음을 검증합니다:
1. **하드코딩 문자열**: PHP/TSX 파일에서 다국어 처리 없이 직접 사용된 문자열
2. **언어 파일 동기화**: `ko/*.php`와 `en/*.php` 키 일치 여부
3. **config 사용**: 로케일 하드코딩 대신 `config()` 함수 사용 여부
4. **시더 다국어 필드**: 배열 형식 사용 여부
### 3.4 모듈 레이아웃 다국어 키 검증 (CRITICAL)
모듈 레이아웃에서 다국어 키 사용 시 **모듈 식별자 접두사** 포함 여부를 검증합니다.
| # | 검증 항목 | 금지 패턴 | 올바른 패턴 |
| --- | ---------------- | -------------------------------------- | ---------------------------------------------- |
| 1 | 모듈 식별자 누락 | `$t:common.xxx` (모듈 레이아웃에서) | `$t:sirsoft-ecommerce.common.xxx` |
| 2 | 모듈 식별자 누락 | `$t:admin.settings.xxx` (모듈 레이아웃에서) | `$t:sirsoft-ecommerce.admin.settings.xxx` |
```text
⚠️ CRITICAL: $t:common.xxx는 코어의 common 키를 찾으므로 모듈 다국어와 매칭 안됨
✅ 모듈 레이아웃에서는 반드시 $t:모듈식별자.키 형식 사용
```
### 3.5 로케일 하드코딩 검증
마이그레이션 및 시더에서 로케일을 하드코딩하지 않았는지 검증합니다.
| 금지 패턴 | 올바른 패턴 |
| ------------------------------------ | ------------------------------------------------------ |
| `['ko', 'en']` 직접 나열 | `config('app.supported_locales')` |
| `foreach (['ko', 'en'] as $locale)` | `foreach (config('app.supported_locales') as $locale)` |
### 3.6 파일 유형별 규정 매핑
검증 대상 파일의 유형에 따라 해당하는 규정 문서를 우선적으로 적용합니다:
| 파일 유형 | 우선 적용 규정 |
| --------- | -------------- |
| `lang/**/*.php` | database-guide.md (다국어 섹션) |
| `**/resources/lang/*.json` | module-i18n.md |
| `templates/**/lang/*.json` | data-binding-i18n.md |
| `app/Http/Requests/**` | validation.md |
| `app/Exceptions/**` | exceptions.md |
| `**/*.tsx` | components.md (G7Core.t 섹션) |
## 4단계: 결과 보고
검증 결과를 다음 형식으로 보고합니다:
```text
## 다국어 검증 결과
### 검증 파일
- [파일 경로]
### [규정 문서명] - [섹션명] 검증 결과
#### 금지 패턴 검사
- ❌ 금지 패턴 발견: [패턴] (파일:라인)
- 규정: [규정 문서명] > [섹션명]
- 수정 방법: [올바른 패턴으로 변경]
- ✅ 금지 패턴 없음
#### 필수 패턴 검사
- ❌ 필수 패턴 누락: [컨텍스트]에서 [패턴] 필요
- 규정: [규정 문서명] > [섹션명]
- ✅ 필수 패턴 준수
#### 언어 파일 동기화
- ✅ 동기화됨: [키 수]개
- ❌ ko에만 존재: [키 목록]
- ❌ en에만 존재: [키 목록]
```
---
## 5단계: 작업계획서 전용 검증 (마크다운 파일인 경우)
검증 대상이 작업계획서(`.md`)인 경우, 다음 추가 검증을 수행합니다.
### 5.1 다국어 관련 코드 블록 식별
다음 키워드가 포함된 코드 블록을 다국어 관련 코드로 식별:
| 키워드 | 유형 |
| ---------------------- | ----------------------- |
| `__()`, `trans()` | PHP 다국어 |
| `$t:`, `$t:defer:` | 레이아웃 JSON 다국어 |
| `G7Core.t()`, `t()` | TSX 컴포넌트 다국어 |
| `lang/`, `resources/lang` | 언어 파일 정의 |
### 5.2 PHP 다국어 코드 블록 검증
```php
// ✅ 올바른 패턴
return ResponseHelper::success(
message: __('messages.product.created')
);
throw new CustomException(__('exceptions.product.not_found'));
// ❌ 잘못된 패턴 - 하드코딩된 한국어
return ResponseHelper::success(
message: '상품이 생성되었습니다.' // ❌
);
throw new CustomException('상품을 찾을 수 없습니다.'); // ❌
```
**검증 항목**:
- ❌ 하드코딩된 한국어 문자열 (사용자에게 표시되는 메시지)
- ✅ `__()` 함수 사용
- ✅ 언어 키 네이밍 규칙 준수
### 5.3 레이아웃 JSON 다국어 코드 블록 검증
```json
// ✅ 올바른 패턴
{
"props": {
"label": "$t:sirsoft-ecommerce.admin.product.name",
"placeholder": "$t:sirsoft-ecommerce.admin.product.name_placeholder"
}
}
// ❌ 잘못된 패턴 - 하드코딩된 한국어
{
"props": {
"label": "상품명",
"placeholder": "상품명을 입력하세요"
}
}
```
**검증 항목**:
- ❌ 하드코딩된 한국어 라벨/플레이스홀더
- ✅ `$t:` 또는 `$t:defer:` 사용
- ✅ 모듈 네임스페이스 포함 (`sirsoft-ecommerce.admin.product.*`)
### 5.4 TSX 컴포넌트 다국어 코드 블록 검증
```typescript
// ✅ 올바른 패턴
const label = G7Core.t('sirsoft-ecommerce.admin.product.name');
<Button>{G7Core.t('common.save')}</Button>
// ❌ 잘못된 패턴 - 하드코딩된 한국어
const label = '상품명'; // ❌
<Button>저장</Button> // ❌
```
### 5.5 언어 파일 정의 검증
```json
// ✅ 올바른 패턴 - ko.json과 en.json 키 일치
// ko.json
{
"admin": {
"product": {
"name": "상품명",
"price": "가격"
}
}
}
// en.json
{
"admin": {
"product": {
"name": "Product Name",
"price": "Price"
}
}
}
```
**검증 항목**:
- ❌ ko에만 존재하는 키
- ❌ en에만 존재하는 키
- ✅ 동일한 키 구조
### 5.6 시더 다국어 필드 검증
```php
// ✅ 올바른 패턴 - 배열 형식
Permission::create([
'name' => [
'ko' => '상품 조회',
'en' => 'View Products',
],
]);
// ❌ 잘못된 패턴 - 단일 언어
Permission::create([
'name' => '상품 조회', // ❌
]);
```
### 5.7 config 사용 검증
```php
// ✅ 올바른 패턴
$locales = config('app.supported_locales');
// ❌ 잘못된 패턴 - 하드코딩
$locales = ['ko', 'en']; // ❌
```
### 5.8 작업계획서 검증 결과 보고 형식
```text
## 작업계획서 다국어 검증 결과
### 검증 파일
- [마크다운 파일 경로]
### 다국어 코드 블록 요약
- PHP 코드 블록: X개
- JSON 레이아웃 블록: Y개
- TSX 코드 블록: Z개
- 언어 파일 정의: W개
- 총 검증 대상: N개
### PHP 다국어 검증
- ✅ __() 함수 사용
- ❌ 하드코딩된 한국어: [위치] - "[문자열]"
- 수정: __('messages.xxx')
### 레이아웃 JSON 다국어 검증
- ✅ $t: 사용
- ❌ 하드코딩된 한국어: [위치] - "[문자열]"
- 수정: "$t:module.key"
### TSX 컴포넌트 다국어 검증
- ✅ G7Core.t() 사용
- ❌ 하드코딩된 한국어: [위치] - "[문자열]"
### 언어 파일 동기화 검증
- ✅ 키 동기화됨
- ❌ ko에만 존재: [키 목록]
- ❌ en에만 존재: [키 목록]
### 시더 다국어 필드 검증
- ✅ 배열 형식 사용
- ❌ 단일 언어: [위치]
```
---
## 핵심 원칙
```text
⚠️ CRITICAL:
- 규정 문서가 Single Source of Truth - 스킬에 검증 항목 하드코딩 금지
- 규정 문서의 ❌/✅ 예시를 자동으로 검증 패턴으로 사용
- 규정이 변경되면 검증도 자동으로 변경됨
- 파일 유형에 따라 해당 규정 문서 우선 적용
- 작업계획서(.md) 검증 시 코드 블록 추출 후 동일한 규정 적용
```
+294
View File
@@ -0,0 +1,294 @@
# 마이그레이션 검증 (validate-migration)
마이그레이션 파일 및 **작업계획서 내 마이그레이션 코드 블록**이 그누보드7 규정을 준수하는지 검증합니다.
## 0단계: 검증 대상 유형 판별 (CRITICAL)
```text
⚠️ CRITICAL: 검증 대상이 소스코드인지 작업계획서인지 먼저 판별
```
### 판별 기준
| 파일 확장자 | 유형 | 검증 방식 |
| ----------- | ---------------- | ---------------------------------------------- |
| `*.php` | 소스코드 | 파일 전체를 마이그레이션 코드로 검증 |
| `*.md` | **작업계획서** | 마크다운 내 PHP 코드 블록 추출 후 검증 |
### 작업계획서 검증 시 추가 단계
작업계획서(`.md`) 파일인 경우:
1. **코드 블록 추출**: 마크다운에서 \`\`\`php 코드 블록 중 마이그레이션 관련 코드 추출
2. **컨텍스트 파악**: `Schema::create`, `Schema::table`, `up()`, `down()` 등의 키워드로 마이그레이션 코드 식별
3. **규정 적용**: database-guide.md의 마이그레이션 규칙 적용
```text
⚠️ 작업계획서 검증 시 주의사항:
- 코드 블록이 예시/설명 목적인지, 실제 구현 계획인지 구분
- "예시:", "Example:", "// 예시" 등이 포함된 코드는 참고용으로 처리
- 실제 구현 계획 코드만 엄격하게 검증
```
## 1단계: 규정 문서 읽기
다음 규정 문서를 읽어 최신 규칙을 확인합니다:
- `docs/database-guide.md` - 데이터베이스 규칙
- `docs/extension/extension-update-system.md` - 업그레이드 스크립트 규칙
## 2단계: 검증 대상 파일 읽기
$ARGUMENTS 경로의 마이그레이션 파일을 읽습니다.
경로가 지정되지 않은 경우, 최근 생성/수정된 마이그레이션 파일을 대상으로 합니다:
- `database/migrations/**/*.php` - 코어 마이그레이션
- `modules/**/database/migrations/**/*.php` - 모듈 마이그레이션
- `modules/_bundled/**/database/migrations/**/*.php` - _bundled 모듈 마이그레이션
## 3단계: 규정 기반 자동 검증 (CRITICAL)
```text
⚠️ CRITICAL: 1단계에서 읽은 규정 문서의 모든 규칙을 검증 항목으로 사용합니다.
스킬에 하드코딩된 검증 항목이 아닌, 규정 문서가 Single Source of Truth입니다.
```
### 3.1 규정에서 검증 패턴 추출
1단계에서 읽은 규정 문서에서 다음 패턴을 추출합니다:
| 추출 대상 | 검증 유형 |
| --------- | --------- |
| `❌` 또는 `잘못된` 키워드가 포함된 코드 블록 | **금지 패턴** - 발견 시 에러 |
| `✅` 또는 `올바른` 키워드가 포함된 코드 블록 | **필수 패턴** - 누락 시 경고 |
| `⚠️ 절대 금지`, `CRITICAL` 등 강조된 규칙 | **필수 검증 항목** |
| `TL;DR` 섹션의 핵심 포인트 | **우선 검증 항목** |
### 3.2 검증 방법
**금지 패턴 검증**:
```bash
# 규정 문서의 ❌ 예시에서 추출한 패턴을 grep으로 검사
grep -rn "[금지 패턴]" [대상 파일]
```
발견 시: 에러로 보고하고 규정 문서의 해당 섹션 참조 안내
**필수 패턴 검증**:
- 해당 컨텍스트에서 필수 패턴이 사용되어야 하는 경우, 누락 여부 확인
- 누락 시: 경고로 보고하고 규정 문서의 올바른 예시 안내
### 3.3 마이그레이션 특화 검증
규정 문서의 마이그레이션 섹션에서 추출한 규칙으로 다음을 검증합니다:
1. **파일명 규칙**: 규정에 정의된 파일명 패턴과 일치 여부
2. **컬럼 정의**: `comment()` 사용 여부 및 내용 적절성
3. **down() 메서드**: 완전한 롤백 구현 여부
4. **외래키**: `constrained()` 등 규정에 명시된 메서드 사용 여부
### 3.4 CLAUDE.md CRITICAL RULES 검증 (MANDATORY)
CLAUDE.md 및 규정 문서에 명시된 CRITICAL 규칙을 검증합니다.
#### 3.4.1 CASCADE 삭제 금지 검증
```text
⚠️ CRITICAL: DB CASCADE에 의존한 삭제 절대 금지 → Service에서 명시적 삭제 (훅/파일/로깅 보장)
```
| ❌ 금지 | ✅ 올바른 사용 |
| --- | --- |
| `->onDelete('cascade')` (단독 의존) | Service에서 명시적 삭제 + 훅 실행 |
| CASCADE만으로 연관 데이터 정리 | `->onDelete('restrict')` 또는 `->onDelete('set null')` + Service 삭제 |
**검증 항목**:
- `onDelete('cascade')` 사용 시 해당 Service에서 명시적 삭제 로직이 존재하는지 확인
- CASCADE만 의존하면 훅/파일정리/로깅이 누락됨 → 경고 필수
#### 3.4.2 exists/unique 검증 규칙 사용 검증
```php
// ❌ 금지 - 문자열 테이블명
'email' => 'unique:users,email'
'category_id' => 'exists:categories,id'
// ✅ 올바른 패턴 - Rule 클래스 사용
'email' => [Rule::unique(User::class, 'email')]
'category_id' => [Rule::exists(Category::class, 'id')]
```
#### 3.4.3 모듈 마이그레이션 업그레이드 스크립트 확인
모듈 마이그레이션 수정 시 기존 설치 환경에 대한 업그레이드 경로 확인:
| 상황 | 필요 작업 |
| --- | --- |
| 신규 테이블 생성 | 마이그레이션만 (업그레이드 스크립트 불필요) |
| 기존 테이블 스키마 변경 | 마이그레이션 + 업그레이드 스크립트 (`upgrades/Upgrade_X_Y_Z.php`) |
| 기존 데이터 백필/변환 | 업그레이드 스크립트만 (마이그레이션 아님) |
```text
⚠️ 기존 DB 데이터 백필/변환은 마이그레이션이 아닌 업그레이드 스크립트로 작성
```
## 4단계: 결과 보고
검증 결과를 다음 형식으로 보고합니다:
```text
## 마이그레이션 검증 결과
### 검증 파일
- [파일 경로]
### [규정 문서명] - [섹션명] 검증 결과
#### 금지 패턴 검사
- ❌ 금지 패턴 발견: [패턴] (파일:라인)
- 규정: [규정 문서명] > [섹션명]
- 수정 방법: [올바른 패턴으로 변경]
- ✅ 금지 패턴 없음
#### 필수 패턴 검사
- ❌ 필수 패턴 누락: [컨텍스트]에서 [패턴] 필요
- 규정: [규정 문서명] > [섹션명]
- ✅ 필수 패턴 준수
#### 컬럼 comment 검증
- ✅ comment 있음: [컬럼명]
- ❌ comment 없음: [컬럼명] → 추가 필요
#### down() 메서드 검증
- ✅ 완전 구현
- ❌ 미구현 또는 불완전
```
---
## 5단계: 작업계획서 전용 검증 (마크다운 파일인 경우)
검증 대상이 작업계획서(`.md`)인 경우, 다음 추가 검증을 수행합니다.
### 5.1 마이그레이션 코드 블록 식별
PHP 코드 블록 중 다음 키워드가 포함된 블록을 마이그레이션 코드로 식별:
- `Schema::create`
- `Schema::table`
- `Schema::drop`
- `public function up()`
- `public function down()`
- `$table->` (컬럼 정의)
### 5.2 파일명 규칙 검증
작업계획서에 마이그레이션 파일명이 명시된 경우 검증:
```text
✅ 올바른 파일명:
- create_products_table
- add_price_and_stock_to_products_table
- remove_category_from_products_table
❌ 잘못된 파일명:
- update_products (동작 불명확)
- products_migration (규칙 위반)
```
### 5.3 컬럼 정의 검증
```php
// ✅ 올바른 패턴 - comment 포함
$table->string('name')->comment('상품명');
$table->decimal('price', 12, 2)->comment('판매가격');
$table->boolean('is_active')->default(true)->comment('활성화 여부 (true: 활성, false: 비활성)');
$table->enum('status', ['draft', 'published', 'archived'])->comment('상태 (draft: 임시저장, published: 게시됨, archived: 보관됨)');
// ❌ 잘못된 패턴 - comment 누락
$table->string('name');
$table->boolean('is_active')->default(true);
```
**검증 항목**:
- ❌ comment 누락
- ❌ boolean/enum 값 설명 누락
- ✅ 한국어 comment 사용
### 5.4 down() 메서드 검증
```php
// ✅ 올바른 패턴 - 완전한 롤백
public function down(): void
{
Schema::dropIfExists('products');
}
// ❌ 잘못된 패턴 - 빈 down() 메서드
public function down(): void
{
//
}
```
### 5.5 외래키 검증
```php
// ✅ 올바른 패턴
$table->foreignId('category_id')->constrained()->onDelete('cascade');
// ❌ 잘못된 패턴
$table->unsignedBigInteger('category_id');
$table->foreign('category_id')->references('id')->on('categories'); // 구식 문법
```
### 5.6 작업계획서 검증 결과 보고 형식
```text
## 작업계획서 마이그레이션 검증 결과
### 검증 파일
- [마크다운 파일 경로]
### 마이그레이션 코드 블록 요약
- 테이블 생성: X개
- 컬럼 추가/수정: Y개
- 총 검증 대상: Z개
### 파일명 규칙 검증
- ✅ 규칙 준수: [파일명]
- ❌ 규칙 위반: [파일명] → [수정 제안]
### 컬럼 comment 검증
- ✅ comment 있음: [컬럼명]
- ❌ comment 없음: [컬럼명] (코드 블록 위치)
- boolean/enum인 경우: 값 설명도 필요
### down() 메서드 검증
- ✅ 완전 구현
- ❌ 미구현 또는 불완전: [위치]
### 외래키 검증
- ✅ constrained() 사용
- ❌ 구식 문법 사용: [위치]
```
---
## 핵심 원칙
```text
⚠️ CRITICAL:
- 규정 문서가 Single Source of Truth - 스킬에 검증 항목 하드코딩 금지
- 규정 문서의 ❌/✅ 예시를 자동으로 검증 패턴으로 사용
- 규정이 변경되면 검증도 자동으로 변경됨
- 작업계획서(.md) 검증 시 코드 블록 추출 후 동일한 규정 적용
- DB CASCADE 삭제 의존 금지 → Service에서 명시적 삭제 필수
- exists/unique 검증: Rule::exists(Model::class, 'col') 패턴 사용
- 모듈 마이그레이션 시 업그레이드 스크립트 필요 여부 확인
```
+155
View File
@@ -0,0 +1,155 @@
# 백엔드 개발 가이드
> 그누보드7 백엔드 개발을 위한 종합 가이드입니다.
---
## 핵심 원칙
```text
필수: FormRequest + Custom Rule 사용 (Service에 검증 로직 금지)
필수: FormRequest + Custom Rule 패턴 사용
✅ 필수: __() 함수를 사용한 다국어 처리
✅ 필수: 상태/타입/분류는 Enum으로 정의
```
---
<!-- AUTO-GENERATED-START: backend-readme-docs -->
## 문서 목록
| 문서 | 제목 | 핵심 내용 |
|------|------|----------|
| [activity-log-hooks.md](activity-log-hooks.md) | 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference) | 코어 66훅 + 이커머스 92훅 + 게시판 32훅 + 페이지 8훅 = 총 198훅 |
| [activity-log.md](activity-log.md) | 활동 로그 시스템 (Activity Log System) | Monolog 기반: Service 훅 → Listener → Log::channel... |
| [api-resources.md](api-resources.md) | API 리소스 | BaseApiResource 상속 필수 |
| [authentication.md](authentication.md) | 인증 및 세션 처리 | Laravel Sanctum 토큰 전용 인증 (Bearer 토큰만 사용) |
| [broadcasting.md](broadcasting.md) | Broadcasting (실시간 이벤트) | Laravel Reverb 사용 (WebSocket) |
| [controllers.md](controllers.md) | 컨트롤러 계층 구조 | AdminBaseController / AuthBaseController / Publ... |
| [core-config.md](core-config.md) | 코어 설정 (config/core.php) | config/core.php = 코어 권한/역할/메뉴/메일템플릿의 SSoT (Sing... |
| [core-update-system.md](core-update-system.md) | 코어 업데이트 시스템 (Core Update System) | 코어 업그레이드 스텝: upgrades/ 디렉토리 (프로젝트 루트), 네임스페이스 A... |
| [enum.md](enum.md) | Enum 사용 규칙 | 상태/타입/분류 = Enum 필수 (PHP 8.1+ Backed Enum) |
| [exceptions.md](exceptions.md) | Custom Exception 다국어 처리 | 예외 메시지 하드코딩 금지 → __() 함수 필수 |
| [middleware.md](middleware.md) | 미들웨어 등록 규칙 | 인증 필요 미들웨어 → 전역 등록 금지! |
| [notification-system.md](notification-system.md) | 알림 시스템 (Notification System) | 모든 알림은 BaseNotification 상속 필수 (via() 보일러플레이트 제거) |
| [response-helper.md](response-helper.md) | API 응답 규칙 (ResponseHelper) | 모든 API 응답은 ResponseHelper 사용 |
| [routing.md](routing.md) | 라우트 네이밍 및 경로 | 모든 라우트는 name() 필수: ->name('api.users.index') |
| [search-system.md](search-system.md) | Scout 검색 엔진 시스템 (Search System) | Laravel Scout + DatabaseFulltextEngine: MySQL F... |
| [seo-system.md](seo-system.md) | SEO 페이지 생성기 시스템 (SEO Page Generator) | SeoMiddleware: 봇 요청 감지 → ?locale= 파라미터 해석 → Seo... |
| [service-provider.md](service-provider.md) | 서비스 프로바이더 안전성 | DB 접근 전 .env 파일 존재 확인 필수 |
| [service-repository.md](service-repository.md) | Service-Repository 패턴 | RepositoryInterface 주입 필수 (구체 클래스 직접 주입 금지) |
| [validation.md](validation.md) | 검증 (Validation) | 필수: FormRequest에서 검증 (Service에 검증 로직 배치 금지) |
<!-- AUTO-GENERATED-END: backend-readme-docs -->
---
## 아키텍처 개요
### 계층 분리
```
Controller → Request → Service → Repository → Model
```
### 컨트롤러 계층 구조
```
BaseApiController (최상위)
├── AdminBaseController (관리자 전용)
├── AuthBaseController (인증된 사용자)
└── PublicBaseController (공개 API)
```
### Service-Repository 패턴
```php
// Service에서 훅 실행
HookManager::doAction('module.entity.before_create', $data);
$data = HookManager::applyFilters('module.entity.filter_data', $data);
$result = $this->repository->create($data);
HookManager::doAction('module.entity.after_create', $result);
```
---
## 파사드 사용 규칙
```php
// ✅ DO: 파사드 앞 역슬래시 제거
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Auth;
Log::info('메시지');
Auth::user();
// ❌ DON'T: 역슬래시 사용
\Log::info('메시지');
auth()->user();
```
---
## 핵심 규칙 요약
### 1. 검증 로직 위치
- ❌ Service 클래스에서 검증 금지
- ✅ FormRequest에서 기본 검증
- ✅ Custom Rule에서 복잡한 검증
### 2. 다국어 처리
- ❌ 하드코딩된 메시지 금지
- ✅ `__()` 함수 사용 필수
- ✅ `lang/ko/`, `lang/en/` 파일 관리
### 3. Enum 사용
- ❌ 문자열/숫자 상수 직접 사용 금지
- ✅ Backed Enum 정의
- ✅ 타입 힌트 활용
### 4. 미들웨어 등록
- ❌ 인증이 필요한 미들웨어를 글로벌 등록 금지
- ✅ 그룹별 적절한 위치에 등록
- ✅ 실행 순서 고려
### 5. 서비스 프로바이더 안전성
- ❌ .env 없이 실패하는 코드 금지
- ✅ 환경 검증 후 로직 실행
- ✅ 인스톨러 안정성 확보
---
## 빠른 참조
### 자주 사용하는 클래스
| 클래스 | 위치 | 용도 |
|--------|------|------|
| ResponseHelper | `app/Helpers/ResponseHelper.php` | API 응답 표준화 |
| BaseApiResource | `app/Http/Resources/BaseApiResource.php` | API 리소스 기본 클래스 |
| AdminBaseController | `app/Http/Controllers/Api/Admin/AdminBaseController.php` | 관리자 컨트롤러 |
### 훅 네이밍 규칙
```
[vendor-module].[entity].[action]_[timing]
예시:
sirsoft-ecommerce.product.before_create
sirsoft-ecommerce.product.after_update
sirsoft-ecommerce.product.filter_create_data
```
---
## 관련 문서
- [AGENTS.md](../../../AGENTS.md) - 프로젝트 개발 가이드
- [database-guide.md](../database-guide.md) - 데이터베이스 규칙
- [extension/](../extension/) - 확장 시스템 (훅, 모듈, 플러그인)
- [testing-guide.md](../testing-guide.md) - 테스트 규칙
+714
View File
@@ -0,0 +1,714 @@
# 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference)
> 모든 ActivityLog 리스너가 구독하는 훅의 전체 목록 (코어 + 모듈)
## TL;DR (5초 요약)
```text
1. 코어 66훅 + 이커머스 92훅 + 게시판 32훅 + 페이지 8훅 = 총 198훅
2. Listener에서 Log::channel('activity')->info() 직접 호출 (Monolog → ActivityLogHandler → DB)
3. 스냅샷 패턴: before_update(priority 5) → 캡처, after_update → ChangeDetector로 비교
4. 사용자 행위: ActivityLogType::User (장바구니/위시리스트/쿠폰 다운로드/주문/결제)
5. 새 훅 추가 시: Listener에 구독 + lang 파일에 description_key 정의
```
---
## 목차
1. [아키텍처 개요](#1-아키텍처-개요)
2. [코어 훅 (CoreActivityLogListener)](#2-코어-훅-coreactivityloglistener)
3. [이커머스 모듈 훅](#3-이커머스-모듈-훅)
4. [게시판 모듈 훅 (BoardActivityLogListener)](#4-게시판-모듈-훅-boardactivityloglistener)
5. [페이지 모듈 훅 (PageActivityLogListener)](#5-페이지-모듈-훅-pageactivityloglistener)
6. [스냅샷/변경감지 패턴](#6-스냅샷변경감지-패턴)
7. [새 모듈에 ActivityLog 추가하기](#7-새-모듈에-activitylog-추가하기)
---
## 1. 아키텍처 개요
```text
Service → doAction('hook.name') → ActivityLogListener → Log::channel('activity') → ActivityLogHandler → DB
```
- **Listener**: `HookListenerInterface` 구현, `getSubscribedHooks()`로 훅-메서드 매핑 정의
- **로그 기록**: `Log::channel('activity')->info($action, $context)` 직접 호출
- **context 구조**: `log_type`, `loggable`, `description_key`, `description_params`, `properties`, `changes`
- **LogType**: `ActivityLogType::Admin` (관리자 행위) / `ActivityLogType::User` (사용자 행위)
- **변경 감지**: `ChangeDetector::detect($model, $snapshot)` — 스냅샷과 현재 상태 비교
---
## 2. 코어 훅 (CoreActivityLogListener)
**파일**: `app/Listeners/CoreActivityLogListener.php`
**총 66훅** (스냅샷 캡처용 before 훅 포함)
### User (9훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `core.user.before_update` | `captureUserSnapshot` | _(스냅샷 캡처)_ | - | - |
| `core.user.after_create` | `handleUserAfterCreate` | `user.create` | Admin | User |
| `core.user.after_update` | `handleUserAfterUpdate` | `user.update` | Admin | User |
| `core.user.after_delete` | `handleUserAfterDelete` | `user.delete` | Admin | User |
| `core.user.after_withdraw` | `handleUserAfterWithdraw` | `user.withdraw` | Admin | User |
| `core.user.after_show` | `handleUserAfterShow` | `user.show` | Admin | User |
| `core.user.after_list` | `handleUserAfterList` | `user.list` | Admin | - |
| `core.user.after_search` | `handleUserAfterSearch` | `user.search` | Admin | - |
| `sirsoft-core.user.after_bulk_update` | `handleUserAfterBulkUpdate` | `user.bulk_update` | Admin | - |
### Auth (6훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `core.auth.after_login` | `handleAuthAfterLogin` | `auth.login` | User | User |
| `core.auth.logout` | `handleAuthLogout` | `auth.logout` | User | User |
| `core.auth.register` | `handleAuthRegister` | `auth.register` | User | User |
| `core.auth.forgot_password` | `handleAuthForgotPassword` | `auth.forgot_password` | User | - |
| `core.auth.reset_password` | `handleAuthResetPassword` | `auth.reset_password` | User | - |
| `core.auth.record_consents` | `handleAuthRecordConsents` | `auth.record_consents` | User | User |
### Role (6훅, 스냅샷 포함)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `core.role.before_update` | `captureRoleSnapshot` | _(스냅샷 캡처)_ | - | - |
| `core.role.after_create` | `handleRoleAfterCreate` | `role.create` | Admin | Role |
| `core.role.after_update` | `handleRoleAfterUpdate` | `role.update` | Admin | Role |
| `core.role.after_delete` | `handleRoleAfterDelete` | `role.delete` | Admin | Role |
| `core.role.after_sync_permissions` | `handleRoleAfterSyncPermissions` | `role.sync_permissions` | Admin | Role |
| `core.role.after_toggle_status` | `handleRoleAfterToggleStatus` | `role.toggle_status` | Admin | Role |
### Menu (7훅, 스냅샷 포함)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `core.menu.before_update` | `captureMenuSnapshot` | _(스냅샷 캡처)_ | - | - |
| `core.menu.after_create` | `handleMenuAfterCreate` | `menu.create` | Admin | Menu |
| `core.menu.after_update` | `handleMenuAfterUpdate` | `menu.update` | Admin | Menu |
| `core.menu.after_delete` | `handleMenuAfterDelete` | `menu.delete` | Admin | Menu |
| `core.menu.after_update_order` | `handleMenuAfterUpdateOrder` | `menu.update_order` | Admin | - |
| `core.menu.after_toggle_status` | `handleMenuAfterToggleStatus` | `menu.toggle_status` | Admin | Menu |
| `core.menu.after_sync_roles` | `handleMenuAfterSyncRoles` | `menu.sync_roles` | Admin | Menu |
### Settings (2훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `core.settings.after_save` | `handleSettingsAfterSave` | `settings.save` | Admin | - |
| `core.settings.after_set` | `handleSettingsAfterSet` | `settings.set` | Admin | - |
### Schedule (7훅, 스냅샷 포함)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `core.schedule.before_update` | `captureScheduleSnapshot` | _(스냅샷 캡처)_ | - | - |
| `core.schedule.after_create` | `handleScheduleAfterCreate` | `schedule.create` | Admin | Schedule |
| `core.schedule.after_update` | `handleScheduleAfterUpdate` | `schedule.update` | Admin | Schedule |
| `core.schedule.after_delete` | `handleScheduleAfterDelete` | `schedule.delete` | Admin | Schedule |
| `core.schedule.after_run` | `handleScheduleAfterRun` | `schedule.run` | Admin | Schedule |
| `core.schedule.after_bulk_update` | `handleScheduleAfterBulkUpdate` | `schedule.bulk_update` | Admin | - |
| `core.schedule.after_bulk_delete` | `handleScheduleAfterBulkDelete` | `schedule.bulk_delete` | Admin | - |
### Attachment (3훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `core.attachment.after_upload` | `handleAttachmentAfterUpload` | `attachment.upload` | Admin | Attachment |
| `core.attachment.after_delete` | `handleAttachmentAfterDelete` | `attachment.delete` | Admin | Attachment |
| `core.attachment.after_bulk_delete` | `handleAttachmentAfterBulkDelete` | `attachment.bulk_delete` | Admin | - |
### Mail Template (3훅, 스냅샷 포함)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `core.mail_template.before_update` | `captureMailTemplateSnapshot` | _(스냅샷 캡처)_ | - | - |
| `core.mail_template.after_update` | `handleMailTemplateAfterUpdate` | `mail_template.update` | Admin | MailTemplate |
| `core.mail_template.after_toggle_active` | `handleMailTemplateAfterToggleActive` | `mail_template.toggle_active` | Admin | MailTemplate |
### Module (6훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `core.modules.after_install` | `handleModuleAfterInstall` | `module.install` | Admin | - |
| `core.modules.after_activate` | `handleModuleAfterActivate` | `module.activate` | Admin | - |
| `core.modules.after_deactivate` | `handleModuleAfterDeactivate` | `module.deactivate` | Admin | - |
| `core.modules.after_uninstall` | `handleModuleAfterUninstall` | `module.uninstall` | Admin | - |
| `core.modules.after_update` | `handleModuleAfterUpdate` | `module.update` | Admin | - |
| `core.modules.after_refresh_layouts` | `handleModuleAfterRefreshLayouts` | `module.refresh_layouts` | Admin | - |
### Plugin (5훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `core.plugins.after_install` | `handlePluginAfterInstall` | `plugin.install` | Admin | - |
| `core.plugins.after_activate` | `handlePluginAfterActivate` | `plugin.activate` | Admin | - |
| `core.plugins.after_deactivate` | `handlePluginAfterDeactivate` | `plugin.deactivate` | Admin | - |
| `core.plugins.after_uninstall` | `handlePluginAfterUninstall` | `plugin.uninstall` | Admin | - |
| `core.plugins.after_update` | `handlePluginAfterUpdate` | `plugin.update` | Admin | - |
### Template (6훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `core.templates.after_install` | `handleTemplateAfterInstall` | `template.install` | Admin | - |
| `core.templates.after_activate` | `handleTemplateAfterActivate` | `template.activate` | Admin | - |
| `core.templates.after_deactivate` | `handleTemplateAfterDeactivate` | `template.deactivate` | Admin | - |
| `core.templates.after_uninstall` | `handleTemplateAfterUninstall` | `template.uninstall` | Admin | - |
| `core.templates.after_version_update` | `handleTemplateAfterVersionUpdate` | `template.version_update` | Admin | - |
| `core.templates.after_refresh_layouts` | `handleTemplateAfterRefreshLayouts` | `template.refresh_layouts` | Admin | - |
### Layout (2훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `core.layout.after_update` | `handleLayoutAfterUpdate` | `layout.update` | Admin | - |
| `core.layout.after_version_restore` | `handleLayoutAfterVersionRestore` | `layout.version_restore` | Admin | - |
### Module Settings (2훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `core.module_settings.after_save` | `handleModuleSettingsAfterSave` | `module_settings.save` | Admin | - |
| `core.module_settings.after_reset` | `handleModuleSettingsAfterReset` | `module_settings.reset` | Admin | - |
### Plugin Settings (2훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `core.plugin_settings.after_save` | `handlePluginSettingsAfterSave` | `plugin_settings.save` | Admin | - |
| `core.plugin_settings.after_reset` | `handlePluginSettingsAfterReset` | `plugin_settings.reset` | Admin | - |
---
## 3. 이커머스 모듈 훅
**모듈**: `sirsoft-ecommerce`
**총 92훅** (7개 Listener)
### 3.1 OrderActivityLogListener (21훅)
**파일**: `modules/_bundled/sirsoft-ecommerce/src/Listeners/OrderActivityLogListener.php`
#### OrderService (8훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.order.before_update` | `captureOrderSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.order.after_update` | `handleOrderAfterUpdate` | `order.update` | Admin | Order |
| `sirsoft-ecommerce.order.after_delete` | `handleOrderAfterDelete` | `order.delete` | Admin | Order |
| `sirsoft-ecommerce.order.after_bulk_update` | `handleOrderAfterBulkUpdate` | `order.bulk_update` | Admin | - |
| `sirsoft-ecommerce.order.after_bulk_status_update` | `handleOrderAfterBulkStatusUpdate` | `order.bulk_status_update` | Admin | - |
| `sirsoft-ecommerce.order.after_bulk_shipping_update` | `handleOrderAfterBulkShippingUpdate` | `order.bulk_shipping_update` | Admin | - |
| `sirsoft-ecommerce.order.after_update_shipping_address` | `handleOrderAfterUpdateShippingAddress` | `order.update_shipping_address` | Admin | Order |
| `sirsoft-ecommerce.order.after_send_email` | `handleOrderAfterSendEmail` | `order.send_email` | Admin | - |
#### OrderOptionService (2훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.order_option.after_status_change` | `handleOrderOptionAfterStatusChange` | `order_option.status_change` | Admin | OrderOption |
| `sirsoft-ecommerce.order_option.after_bulk_status_change` | `handleOrderOptionAfterBulkStatusChange` | `order_option.bulk_status_change` | Admin | - |
#### OrderCancellationService (5훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.order.before_cancel` | `captureOrderCancelSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.order.after_cancel` | `handleOrderAfterCancel` | `order.cancel` | Admin | Order |
| `sirsoft-ecommerce.order.after_partial_cancel` | `handleOrderAfterPartialCancel` | `order.partial_cancel` | Admin | Order |
| `sirsoft-ecommerce.coupon.restore` | `handleCouponRestore` | `coupon.restore` | Admin | Order |
| `sirsoft-ecommerce.mileage.restore` | `handleMileageRestore` | `mileage.restore` | Admin | Order |
#### OrderProcessingService (6훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.order.after_create` | `handleOrderAfterCreate` | `order.create` | **User** | Order |
| `sirsoft-ecommerce.order.after_payment_complete` | `handleOrderAfterPaymentComplete` | `order.payment_complete` | **User** | Order |
| `sirsoft-ecommerce.order.payment_failed` | `handleOrderAfterPaymentFailed` | `order.payment_failed` | **User** | Order |
| `sirsoft-ecommerce.coupon.use` | `handleCouponUse` | `coupon.use` | **User** | Order |
| `sirsoft-ecommerce.mileage.use` | `handleMileageUse` | `mileage.use` | **User** | Order |
| `sirsoft-ecommerce.mileage.earn` | `handleMileageEarn` | `mileage.earn` | **User** | Order |
### 3.2 ProductActivityLogListener (10훅)
**파일**: `modules/_bundled/sirsoft-ecommerce/src/Listeners/ProductActivityLogListener.php`
> 이 리스너는 `ProductLogService`를 사용하는 별도 패턴입니다 (Log::channel 대신).
| 훅 이름 | Listener 메서드 | Priority | 비고 |
|---------|----------------|----------|------|
| `sirsoft-ecommerce.product.after_create` | `logCreated` | 50 | 상품 생성 로그 |
| `sirsoft-ecommerce.product.before_update` | `captureSnapshot` | 5 | 스냅샷 캡처 |
| `sirsoft-ecommerce.product.after_update` | `logUpdated` | 50 | 변경사항 비교 후 로그 |
| `sirsoft-ecommerce.product.before_delete` | `logDeleted` | 50 | 삭제 전 로그 기록 |
| `sirsoft-ecommerce.product.before_bulk_update` | `captureProductBulkUpdateSnapshot` | 5 | 일괄 수정 전 스냅샷 |
| `sirsoft-ecommerce.product.after_bulk_update` | `handleProductAfterBulkUpdate` | 20 | 일괄 수정 로그 |
| `sirsoft-ecommerce.product.before_bulk_price_update` | `captureProductBulkPriceSnapshot` | 5 | 일괄 가격 수정 전 스냅샷 |
| `sirsoft-ecommerce.product.after_bulk_price_update` | `handleProductAfterBulkPriceUpdate` | 20 | 일괄 가격 수정 로그 |
| `sirsoft-ecommerce.product.before_bulk_stock_update` | `captureProductBulkStockSnapshot` | 5 | 일괄 재고 수정 전 스냅샷 |
| `sirsoft-ecommerce.product.after_bulk_stock_update` | `handleProductAfterBulkStockUpdate` | 20 | 일괄 재고 수정 로그 |
### 3.3 CouponActivityLogListener (6훅)
**파일**: `modules/_bundled/sirsoft-ecommerce/src/Listeners/CouponActivityLogListener.php`
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.coupon.after_create` | `handleAfterCreate` | `coupon.create` | Admin | Coupon |
| `sirsoft-ecommerce.coupon.before_update` | `captureCouponSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.coupon.after_update` | `handleAfterUpdate` | `coupon.update` | Admin | Coupon |
| `sirsoft-ecommerce.coupon.after_delete` | `handleAfterDelete` | `coupon.delete` | Admin | - |
| `sirsoft-ecommerce.coupon.before_bulk_status` | `captureCouponBulkStatusSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.coupon.after_bulk_status` | `handleAfterBulkStatus` | `coupon.bulk_status` | Admin | - |
### 3.4 ShippingPolicyActivityLogListener (8훅)
**파일**: `modules/_bundled/sirsoft-ecommerce/src/Listeners/ShippingPolicyActivityLogListener.php`
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.shipping_policy.after_create` | `handleAfterCreate` | `shipping_policy.create` | Admin | ShippingPolicy |
| `sirsoft-ecommerce.shipping_policy.before_update` | `captureSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.shipping_policy.after_update` | `handleAfterUpdate` | `shipping_policy.update` | Admin | ShippingPolicy |
| `sirsoft-ecommerce.shipping_policy.after_delete` | `handleAfterDelete` | `shipping_policy.delete` | Admin | - |
| `sirsoft-ecommerce.shipping_policy.after_toggle_active` | `handleAfterToggleActive` | `shipping_policy.toggle_active` | Admin | ShippingPolicy |
| `sirsoft-ecommerce.shipping_policy.after_set_default` | `handleAfterSetDefault` | `shipping_policy.set_default` | Admin | ShippingPolicy |
| `sirsoft-ecommerce.shipping_policy.after_bulk_delete` | `handleAfterBulkDelete` | `shipping_policy.bulk_delete` | Admin | - |
| `sirsoft-ecommerce.shipping_policy.after_bulk_toggle_active` | `handleAfterBulkToggleActive` | `shipping_policy.bulk_toggle_active` | Admin | - |
### 3.5 CategoryActivityLogListener (6훅)
**파일**: `modules/_bundled/sirsoft-ecommerce/src/Listeners/CategoryActivityLogListener.php`
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.category.after_create` | `handleAfterCreate` | `category.create` | Admin | Category |
| `sirsoft-ecommerce.category.before_update` | `captureCategorySnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.category.after_update` | `handleAfterUpdate` | `category.update` | Admin | Category |
| `sirsoft-ecommerce.category.after_delete` | `handleAfterDelete` | `category.delete` | Admin | - |
| `sirsoft-ecommerce.category.after_toggle_status` | `handleAfterToggleStatus` | `category.toggle_status` | Admin | Category |
| `sirsoft-ecommerce.category.after_reorder` | `handleAfterReorder` | `category.reorder` | Admin | - |
### 3.6 EcommerceAdminActivityLogListener (51훅)
**파일**: `modules/_bundled/sirsoft-ecommerce/src/Listeners/EcommerceAdminActivityLogListener.php`
#### Brand (5훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.brand.after_create` | `handleBrandAfterCreate` | `brand.create` | Admin | Brand |
| `sirsoft-ecommerce.brand.before_update` | `captureBrandSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.brand.after_update` | `handleBrandAfterUpdate` | `brand.update` | Admin | Brand |
| `sirsoft-ecommerce.brand.after_delete` | `handleBrandAfterDelete` | `brand.delete` | Admin | Brand |
| `sirsoft-ecommerce.brand.after_toggle_status` | `handleBrandAfterToggleStatus` | `brand.toggle_status` | Admin | Brand |
#### ProductLabel (5훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.label.after_create` | `handleLabelAfterCreate` | `label.create` | Admin | ProductLabel |
| `sirsoft-ecommerce.label.before_update` | `captureLabelSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.label.after_update` | `handleLabelAfterUpdate` | `label.update` | Admin | ProductLabel |
| `sirsoft-ecommerce.label.after_delete` | `handleLabelAfterDelete` | `label.delete` | Admin | ProductLabel |
| `sirsoft-ecommerce.label.after_toggle_status` | `handleLabelAfterToggleStatus` | `label.toggle_status` | Admin | ProductLabel |
#### ProductCommonInfo (4훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.product-common-info.after_create` | `handleCommonInfoAfterCreate` | `common_info.create` | Admin | ProductCommonInfo |
| `sirsoft-ecommerce.product-common-info.before_update` | `captureCommonInfoSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.product-common-info.after_update` | `handleCommonInfoAfterUpdate` | `common_info.update` | Admin | ProductCommonInfo |
| `sirsoft-ecommerce.product-common-info.after_delete` | `handleCommonInfoAfterDelete` | `common_info.delete` | Admin | ProductCommonInfo |
#### ProductNoticeTemplate (5훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.product-notice-template.after_create` | `handleNoticeTemplateAfterCreate` | `notice_template.create` | Admin | ProductNoticeTemplate |
| `sirsoft-ecommerce.product-notice-template.before_update` | `captureNoticeTemplateSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.product-notice-template.after_update` | `handleNoticeTemplateAfterUpdate` | `notice_template.update` | Admin | ProductNoticeTemplate |
| `sirsoft-ecommerce.product-notice-template.after_delete` | `handleNoticeTemplateAfterDelete` | `notice_template.delete` | Admin | ProductNoticeTemplate |
| `sirsoft-ecommerce.product-notice-template.after_copy` | `handleNoticeTemplateAfterCopy` | `notice_template.copy` | Admin | ProductNoticeTemplate |
#### ExtraFeeTemplate (10훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.extra_fee_template.after_create` | `handleExtraFeeAfterCreate` | `extra_fee_template.create` | Admin | ExtraFeeTemplate |
| `sirsoft-ecommerce.extra_fee_template.before_update` | `captureExtraFeeSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.extra_fee_template.after_update` | `handleExtraFeeAfterUpdate` | `extra_fee_template.update` | Admin | ExtraFeeTemplate |
| `sirsoft-ecommerce.extra_fee_template.after_delete` | `handleExtraFeeAfterDelete` | `extra_fee_template.delete` | Admin | ExtraFeeTemplate |
| `sirsoft-ecommerce.extra_fee_template.after_toggle_active` | `handleExtraFeeAfterToggleActive` | `extra_fee_template.toggle_active` | Admin | ExtraFeeTemplate |
| `sirsoft-ecommerce.extra_fee_template.before_bulk_delete` | `captureExtraFeeBulkDeleteSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.extra_fee_template.after_bulk_delete` | `handleExtraFeeAfterBulkDelete` | `extra_fee_template.bulk_delete` | Admin | - |
| `sirsoft-ecommerce.extra_fee_template.before_bulk_toggle_active` | `captureExtraFeeBulkToggleSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.extra_fee_template.after_bulk_toggle_active` | `handleExtraFeeAfterBulkToggleActive` | `extra_fee_template.bulk_toggle_active` | Admin | - |
| `sirsoft-ecommerce.extra_fee_template.after_bulk_create` | `handleExtraFeeAfterBulkCreate` | `extra_fee_template.bulk_create` | Admin | - |
#### ShippingCarrier (5훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.shipping_carrier.after_create` | `handleCarrierAfterCreate` | `shipping_carrier.create` | Admin | ShippingCarrier |
| `sirsoft-ecommerce.shipping_carrier.before_update` | `captureCarrierSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.shipping_carrier.after_update` | `handleCarrierAfterUpdate` | `shipping_carrier.update` | Admin | ShippingCarrier |
| `sirsoft-ecommerce.shipping_carrier.after_delete` | `handleCarrierAfterDelete` | `shipping_carrier.delete` | Admin | ShippingCarrier |
| `sirsoft-ecommerce.shipping_carrier.after_toggle_status` | `handleCarrierAfterToggleStatus` | `shipping_carrier.toggle_status` | Admin | ShippingCarrier |
#### ProductImage (3훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.product-image.after_upload` | `handleImageAfterUpload` | `product_image.upload` | Admin | ProductImage |
| `sirsoft-ecommerce.product-image.after_delete` | `handleImageAfterDelete` | `product_image.delete` | Admin | ProductImage |
| `sirsoft-ecommerce.product-image.after_reorder` | `handleImageAfterReorder` | `product_image.reorder` | Admin | - |
#### ShippingPolicy Bulk (4훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.shipping_policy.before_bulk_delete` | `captureShippingPolicyBulkDeleteSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.shipping_policy.after_bulk_delete` | `handleShippingPolicyAfterBulkDelete` | `shipping_policy.bulk_delete` | Admin | - |
| `sirsoft-ecommerce.shipping_policy.before_bulk_toggle_active` | `captureShippingPolicyBulkToggleSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.shipping_policy.after_bulk_toggle_active` | `handleShippingPolicyAfterBulkToggleActive` | `shipping_policy.bulk_toggle_active` | Admin | - |
#### ProductOption (6훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.product_option.before_bulk_price_update` | `captureOptionBulkPriceSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.product_option.after_bulk_price_update` | `handleOptionAfterBulkPriceUpdate` | `product_option.bulk_price_update` | Admin | - |
| `sirsoft-ecommerce.product_option.before_bulk_stock_update` | `captureOptionBulkStockSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.product_option.after_bulk_stock_update` | `handleOptionAfterBulkStockUpdate` | `product_option.bulk_stock_update` | Admin | - |
| `sirsoft-ecommerce.option.before_bulk_update` | `captureOptionBulkUpdateSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.option.after_bulk_update` | `handleOptionAfterBulkUpdate` | `product_option.bulk_update` | Admin | - |
#### ProductReview (4훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.product-review.after_create` | `handleReviewAfterCreate` | `review.create` | Admin | ProductReview |
| `sirsoft-ecommerce.product-review.after_delete` | `handleReviewAfterDelete` | `review.delete` | Admin | ProductReview |
| `sirsoft-ecommerce.product-review.before_bulk_delete` | `captureReviewBulkDeleteSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.product-review.after_bulk_delete` | `handleReviewAfterBulkDelete` | `product_review.bulk_delete` | Admin | - |
### 3.7 EcommerceUserActivityLogListener (7훅)
**파일**: `modules/_bundled/sirsoft-ecommerce/src/Listeners/EcommerceUserActivityLogListener.php`
#### Cart (5훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.cart.after_add` | `handleCartAfterAdd` | `cart.add` | **User** | Cart |
| `sirsoft-ecommerce.cart.after_update_quantity` | `handleCartAfterUpdateQuantity` | `cart.update_quantity` | **User** | Cart |
| `sirsoft-ecommerce.cart.after_change_option` | `handleCartAfterChangeOption` | `cart.change_option` | **User** | Cart |
| `sirsoft-ecommerce.cart.after_delete` | `handleCartAfterDelete` | `cart.delete` | **User** | - |
| `sirsoft-ecommerce.cart.after_delete_all` | `handleCartAfterDeleteAll` | `cart.delete_all` | **User** | - |
#### Wishlist (1훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.wishlist.after_toggle` | `handleWishlistAfterToggle` | `wishlist.add` / `wishlist.remove` | **User** | Product |
#### User Coupon (1훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.user_coupon.after_download` | `handleUserCouponAfterDownload` | `user_coupon.download` | **User** | CouponIssue |
---
## 4. 게시판 모듈 훅 (BoardActivityLogListener)
**파일**: `modules/_bundled/sirsoft-board/src/Listeners/BoardActivityLogListener.php`
**총 32훅**
### Board (6훅, 스냅샷 포함)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-board.board.after_create` | `handleBoardAfterCreate` | `board.create` | Admin | Board |
| `sirsoft-board.board.before_update` | `captureBoardSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-board.board.after_update` | `handleBoardAfterUpdate` | `board.update` | Admin | Board |
| `sirsoft-board.board.after_delete` | `handleBoardAfterDelete` | `board.delete` | Admin | Board |
| `sirsoft-board.board.after_add_to_menu` | `handleBoardAfterAddToMenu` | `board.add_to_menu` | Admin | Board |
| `sirsoft-board.settings.after_bulk_apply` | `handleSettingsAfterBulkApply` | `board_settings.bulk_apply` | Admin | - |
### BoardType (4훅, 스냅샷 포함)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-board.board_type.after_create` | `handleBoardTypeAfterCreate` | `board_type.create` | Admin | BoardType |
| `sirsoft-board.board_type.before_update` | `captureBoardTypeSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-board.board_type.after_update` | `handleBoardTypeAfterUpdate` | `board_type.update` | Admin | BoardType |
| `sirsoft-board.board_type.after_delete` | `handleBoardTypeAfterDelete` | `board_type.delete` | Admin | BoardType |
### Post (6훅, 스냅샷 포함)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-board.post.after_create` | `handlePostAfterCreate` | `post.create` | Admin | Post |
| `sirsoft-board.post.before_update` | `capturePostSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-board.post.after_update` | `handlePostAfterUpdate` | `post.update` | Admin | Post |
| `sirsoft-board.post.after_delete` | `handlePostAfterDelete` | `post.delete` | Admin | Post |
| `sirsoft-board.post.after_blind` | `handlePostAfterBlind` | `post.blind` | Admin | Post |
| `sirsoft-board.post.after_restore` | `handlePostAfterRestore` | `post.restore` | Admin | Post |
### Comment (6훅, 스냅샷 포함)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-board.comment.after_create` | `handleCommentAfterCreate` | `comment.create` | Admin | Comment |
| `sirsoft-board.comment.before_update` | `captureCommentSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-board.comment.after_update` | `handleCommentAfterUpdate` | `comment.update` | Admin | Comment |
| `sirsoft-board.comment.after_delete` | `handleCommentAfterDelete` | `comment.delete` | Admin | Comment |
| `sirsoft-board.comment.after_blind` | `handleCommentAfterBlind` | `comment.blind` | Admin | Comment |
| `sirsoft-board.comment.after_restore` | `handleCommentAfterRestore` | `comment.restore` | Admin | Comment |
### Attachment (2훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-board.attachment.after_upload` | `handleAttachmentAfterUpload` | `attachment.upload` | Admin | Attachment |
| `sirsoft-board.attachment.after_delete` | `handleAttachmentAfterDelete` | `attachment.delete` | Admin | Attachment |
### Report (8훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-board.report.after_create` | `handleReportAfterCreate` | `report.create` | Admin | Report |
| `sirsoft-board.report.after_update_status` | `handleReportAfterUpdateStatus` | `report.update_status` | Admin | Report |
| `sirsoft-board.report.before_bulk_update_status` | `captureReportBulkStatusSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-board.report.after_bulk_update_status` | `handleReportAfterBulkUpdateStatus` | `report.bulk_update_status` | Admin | - |
| `sirsoft-board.report.after_delete` | `handleReportAfterDelete` | `report.delete` | Admin | Report |
| `sirsoft-board.report.after_restore_content` | `handleReportAfterRestoreContent` | `report.restore_content` | Admin | Report |
| `sirsoft-board.report.after_blind_content` | `handleReportAfterBlindContent` | `report.blind_content` | Admin | Report |
| `sirsoft-board.report.after_delete_content` | `handleReportAfterDeleteContent` | `report.delete_content` | Admin | Report |
---
## 5. 페이지 모듈 훅 (PageActivityLogListener)
**파일**: `modules/_bundled/sirsoft-page/src/Listeners/PageActivityLogListener.php`
**총 8훅**
### Page (6훅, 스냅샷 포함)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-page.page.after_create` | `handlePageAfterCreate` | `page.create` | Admin | Page |
| `sirsoft-page.page.before_update` | `capturePageSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-page.page.after_update` | `handlePageAfterUpdate` | `page.update` | Admin | Page |
| `sirsoft-page.page.after_delete` | `handlePageAfterDelete` | `page.delete` | Admin | Page |
| `sirsoft-page.page.after_publish` | `handlePageAfterPublish` | `page.publish` / `page.unpublish` | Admin | Page |
| `sirsoft-page.page.after_restore` | `handlePageAfterRestore` | `page.restore` | Admin | Page |
### PageAttachment (2훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-page.attachment.after_upload` | `handleAttachmentAfterUpload` | `page_attachment.upload` | Admin | PageAttachment |
| `sirsoft-page.attachment.after_delete` | `handleAttachmentAfterDelete` | `page_attachment.delete` | Admin | PageAttachment |
---
## 6. 스냅샷/변경감지 패턴
ActivityLog에서 수정(update) 작업의 변경 이력을 기록하려면 **스냅샷 패턴**을 사용합니다.
### 동작 흐름
```text
1. Service에서 before_update 훅 발행 (doAction)
2. Listener의 캡처 메서드 실행 (priority: 5, 다른 리스너보다 먼저)
→ 현재 모델 상태를 $snapshots 배열에 저장
3. Service에서 실제 DB 업데이트 수행
4. Service에서 after_update 훅 발행 (doAction)
5. Listener의 핸들러 메서드 실행 (priority: 20)
→ ChangeDetector::detect($model, $snapshot) 호출
→ 변경된 필드만 추출하여 로그 기록
→ 스냅샷 정리 (unset)
```
### 코드 예시
```php
// 1. getSubscribedHooks()에서 before/after 쌍 등록
public static function getSubscribedHooks(): array
{
return [
'my-module.entity.before_update' => ['method' => 'captureSnapshot', 'priority' => 5],
'my-module.entity.after_update' => ['method' => 'handleAfterUpdate', 'priority' => 20],
];
}
// 2. 스냅샷 캡처 (before_update, priority 5)
public function captureSnapshot(Model $entity, array $data): void
{
$this->snapshots['entity_' . $entity->id] = $entity->toArray();
}
// 3. 변경 감지 + 로그 기록 (after_update, priority 20)
public function handleAfterUpdate(Model $entity): void
{
$snapshot = $this->snapshots['entity_' . $entity->id] ?? null;
$changes = ChangeDetector::detect($entity, $snapshot);
Log::channel('activity')->info('entity.update', [
'log_type' => ActivityLogType::Admin,
'loggable' => $entity,
'description_key' => 'my-module::activity_log.description.entity_update',
'description_params' => ['entity_id' => $entity->id],
'changes' => $changes,
]);
unset($this->snapshots['entity_' . $entity->id]);
}
```
### ChangeDetector
**파일**: `app/ActivityLog/ChangeDetector.php`
- 모델의 `$activityLogFields` 속성에 정의된 필드만 비교
- 각 필드의 `label_key` (다국어 키)를 포함한 구조화된 변경 이력 생성
- `BackedEnum` 자동 변환 지원
- 스냅샷이 `null`이면 `null` 반환 (변경 없음)
### ProductActivityLogListener의 별도 패턴
`ProductActivityLogListener`는 `ProductLogService`를 주입받아 사용하는 별도 패턴입니다:
- `ChangeDetector` 대신 자체 `detectChanges()` 메서드로 변경 감지
- 해시 비교로 옵션/추가옵션/이미지 변경 감지
- `Log::channel('activity')` 대신 `ProductLogService`를 통해 처리로그 테이블에 기록
---
## 7. 새 모듈에 ActivityLog 추가하기
### Step 1: Listener 클래스 생성
```php
<?php
namespace Modules\Vendor\MyModule\Listeners;
use App\ActivityLog\ChangeDetector;
use App\Contracts\Extension\HookListenerInterface;
use App\Enums\ActivityLogType;
use Illuminate\Support\Facades\Log;
class MyModuleActivityLogListener implements HookListenerInterface
{
private array $snapshots = [];
public static function getSubscribedHooks(): array
{
return [
'vendor-mymodule.entity.after_create' => ['method' => 'handleAfterCreate', 'priority' => 20],
'vendor-mymodule.entity.before_update' => ['method' => 'captureSnapshot', 'priority' => 5],
'vendor-mymodule.entity.after_update' => ['method' => 'handleAfterUpdate', 'priority' => 20],
'vendor-mymodule.entity.after_delete' => ['method' => 'handleAfterDelete', 'priority' => 20],
];
}
public function handle(...$args): void
{
// 개별 메서드에서 처리
}
// ... 각 핸들러 메서드 구현
}
```
### Step 2: module.php에 Listener 등록
```php
// module.php의 listeners 배열에 추가
'listeners' => [
MyModuleActivityLogListener::class,
],
```
### Step 3: 다국어 파일에 description_key 정의
```php
// lang/ko/activity_log.php
return [
'description' => [
'entity_create' => ':entity_name 엔티티가 생성되었습니다.',
'entity_update' => ':entity_name 엔티티가 수정되었습니다.',
'entity_delete' => ':entity_name 엔티티가 삭제되었습니다.',
],
];
```
### Step 4: 모델에 $activityLogFields 정의 (ChangeDetector 사용 시)
```php
// Model 클래스에 추가
protected array $activityLogFields = [
'name' => ['label_key' => 'vendor-mymodule::activity_log.fields.name'],
'status' => ['label_key' => 'vendor-mymodule::activity_log.fields.status'],
];
```
### Step 5: 테스트 작성
```php
// tests/Unit/Listeners/MyModuleActivityLogListenerTest.php
class MyModuleActivityLogListenerTest extends TestCase
{
public function test_getSubscribedHooks_returns_expected_hooks(): void
{
$hooks = MyModuleActivityLogListener::getSubscribedHooks();
$this->assertArrayHasKey('vendor-mymodule.entity.after_create', $hooks);
$this->assertArrayHasKey('vendor-mymodule.entity.before_update', $hooks);
// ...
}
public function test_handleAfterCreate_logs_activity(): void
{
Log::shouldReceive('channel')
->with('activity')
->andReturnSelf();
Log::shouldReceive('info')
->once()
->with('entity.create', Mockery::type('array'));
$listener = new MyModuleActivityLogListener();
$listener->handleAfterCreate($entity, $data);
}
}
```
### 훅 네이밍 규칙
| 패턴 | 용도 | Priority |
|------|------|----------|
| `{module}.{entity}.before_update` | 스냅샷 캡처 | 5 (높은 우선순위) |
| `{module}.{entity}.after_create` | 생성 로그 | 20 |
| `{module}.{entity}.after_update` | 수정 로그 (변경감지) | 20 |
| `{module}.{entity}.after_delete` | 삭제 로그 | 20 |
| `{module}.{entity}.after_bulk_*` | 일괄 작업 로그 | 20 |
| `{module}.{entity}.after_toggle_*` | 상태 전환 로그 | 20 |
### context 구조
| 키 | 타입 | 설명 | 필수 |
|----|------|------|------|
| `log_type` | `ActivityLogType` | Admin 또는 User | O |
| `loggable` | `Model` | 대상 모델 (polymorphic) | - |
| `description_key` | `string` | 다국어 설명 키 | O |
| `description_params` | `array` | 설명 파라미터 | - |
| `properties` | `array` | 추가 속성 (JSON) | - |
| `changes` | `array\|null` | ChangeDetector 결과 | - |
+955
View File
@@ -0,0 +1,955 @@
# 활동 로그 시스템 (Activity Log System)
G7의 활동 로그는 Monolog 기반 커스텀 채널을 통해 DB에 기록됩니다.
Service 훅 이벤트를 Listener가 수신하여 `Log::channel('activity')` 로 전달하고,
ActivityLogHandler가 `activity_logs` 테이블에 저장합니다.
## TL;DR (5초 요약)
```text
1. Monolog 기반: Service 훅 → Listener → Log::channel('activity') → Handler → DB (3단계)
2. ActivityLogService는 조회 전용 (기록 메서드 없음 — Log::channel 직접 사용)
3. description_params에 ID 저장 → ActivityLogDescriptionResolver가 표시 시 이름 변환
4. 변경 추적: $activityLogFields 정의 필수 + ChangeDetector → changes JSON (미정의 시 null)
5. Bulk 작업 Per-Item 필수: N건 처리 시 N건 개별 로그 (집계 1건 금지), 삭제 시 loggable_type/loggable_id 직접 지정
6. 새 모듈 추가 시: Listener + DescriptionResolver + module.php 등록 + lang + $activityLogFields
```
---
## 목차
1. [아키텍처](#아키텍처)
2. [로깅 흐름](#로깅-흐름)
3. [Listener 작성 패턴](#listener-작성-패턴)
4. [변경 추적 (ChangeDetector)](#변경-추적-changedetector)
5. [다국어 키 규칙](#다국어-키-규칙)
6. [description_params 저장 정책](#description_params-저장-정책)
7. [ActivityLogDescriptionResolver 규정](#activitylogdescriptionresolver-규정)
8. [모듈 리스너 등록](#모듈-리스너-등록)
9. [모델별 로그 조회 API 패턴](#모델별-로그-조회-api-패턴)
10. [Bulk 로깅 패턴 (Per-Item 필수)](#bulk-로깅-패턴-per-item-필수)
11. [DB 스키마](#db-스키마)
12. [인덱스](#인덱스)
13. [금지 사항](#금지-사항)
14. [개발자 체크리스트](#개발자-체크리스트)
---
## 아키텍처
### Monolog 기반 3단계 파이프라인
```text
Service (doAction 훅 발행)
↓
Listener (CoreActivityLogListener / 모듈 XxxActivityLogListener)
↓
Log::channel('activity')->info($action, $context)
↓
ActivityLogProcessor (user_id, ip_address, user_agent 자동 주입)
↓
ActivityLogHandler (Monolog Handler → activity_logs 테이블 INSERT)
```
### 핵심 컴포넌트
| 컴포넌트 | 위치 | 역할 |
|----------|------|------|
| `ActivityLogChannel` | `app/ActivityLog/ActivityLogChannel.php` | Monolog Logger 팩토리 (`config/logging.php`의 `via` 클래스) |
| `ActivityLogHandler` | `app/ActivityLog/ActivityLogHandler.php` | Monolog Handler — context에서 데이터 추출 후 DB 저장 |
| `ActivityLogProcessor` | `app/ActivityLog/ActivityLogProcessor.php` | Monolog Processor — `user_id`, `ip_address`, `user_agent` 자동 주입 |
| `ResolvesActivityLogType` | `app/ActivityLog/Traits/ResolvesActivityLogType.php` | log_type 자동 결정 트레이트 — `Auth::user()` 기반 Admin/User/System 분류 |
| `ChangeDetector` | `app/ActivityLog/ChangeDetector.php` | 모델 스냅샷 비교 → 구조화된 변경 이력 생성 |
| `CoreActivityLogListener` | `app/Listeners/CoreActivityLogListener.php` | 코어 서비스 훅 구독 리스너 |
| `ActivityLog` | `app/Models/ActivityLog.php` | Eloquent 모델 (`localized_description` 접근자 포함) |
| `ActivityLogService` | `app/Services/ActivityLogService.php` | **조회 전용** 서비스 (기록 메서드 없음) |
### config/logging.php 채널 설정
```php
'activity' => [
'driver' => 'custom',
'via' => \App\ActivityLog\ActivityLogChannel::class,
'level' => 'debug',
],
```
### config/activity_log.php
```php
return [
'enabled' => env('ACTIVITY_LOG_ENABLED', true), // false 시 Handler에서 DB 저장 건너뜀
'channel' => env('ACTIVITY_LOG_CHANNEL', 'activity'),
];
```
---
## 로깅 흐름
### 1단계: Service에서 훅 발행
Service 계층에서 비즈니스 로직 수행 후 `doAction` 으로 훅을 발행합니다.
```php
// Service 내부 (예: UserService)
$this->hookManager->doAction('core.user.after_create', $user, $data);
```
### 2단계: Listener에서 로그 기록
Listener가 훅을 수신하여 `Log::channel('activity')->info()` 를 호출합니다.
```php
use Illuminate\Support\Facades\Log;
public function handleUserAfterCreate(User $user, array $data): void
{
$this->logActivity('user.create', [
'log_type' => ActivityLogType::Admin,
'loggable' => $user,
'description_key' => 'activity_log.description.user_create',
'description_params' => ['user_id' => $user->uuid],
'properties' => ['email' => $user->email, 'name' => $user->name],
]);
}
private function logActivity(string $action, array $context): void
{
try {
Log::channel('activity')->info($action, $context);
} catch (\Exception $e) {
Log::error('Failed to record activity log', [
'action' => $action,
'error' => $e->getMessage(),
]);
}
}
```
### 3단계: Processor + Handler
1. **ActivityLogProcessor**: 리스너에서 명시적으로 전달하지 않은 `user_id`, `ip_address`, `user_agent`를 자동 주입
2. **ActivityLogHandler**: context에서 구조화 데이터를 추출하여 `ActivityLog::create()` 호출
### Context 배열 구조
| 키 | 타입 | 필수 | 설명 |
|----|------|------|------|
| `log_type` | `ActivityLogType` | O | 로그 유형 (Admin/User/System) |
| `loggable` | `Model` | - | 대상 모델 (morph 관계). 삭제 엔티티는 `loggable_type` + `loggable_id` 직접 지정 |
| `loggable_type` | `string` | - | `loggable` 대신 morph 타입 직접 지정 (삭제된 엔티티용) |
| `loggable_id` | `int` | - | `loggable` 대신 morph ID 직접 지정 (삭제된 엔티티용) |
| `description_key` | `string` | O | 다국어 번역 키 |
| `description_params` | `array` | - | 번역 파라미터 |
| `properties` | `array` | - | 추가 속성 (JSON) |
| `changes` | `array` | - | 변경 이력 (ChangeDetector 결과) |
| `user_id` | `int` | - | 자동 주입 (Processor) |
| `ip_address` | `string` | - | 자동 주입 (Processor) |
| `user_agent` | `string` | - | 자동 주입 (Processor, 500자 제한) |
---
## Listener 작성 패턴
### 기본 구조
```php
<?php
namespace Modules\Vendor\Module\Listeners;
use App\ActivityLog\ChangeDetector;
use App\ActivityLog\Traits\ResolvesActivityLogType;
use App\Contracts\Extension\HookListenerInterface;
class XxxActivityLogListener implements HookListenerInterface
{
use ResolvesActivityLogType;
/** @var array<string, array> 스냅샷 저장소 */
private array $snapshots = [];
public static function getSubscribedHooks(): array
{
return [
// before_update: 스냅샷 캡처 (priority 5 — 다른 리스너보다 먼저 실행)
'vendor-module.entity.before_update' => ['method' => 'captureEntitySnapshot', 'priority' => 5],
// after_xxx: 로그 기록 (priority 20)
'vendor-module.entity.after_create' => ['method' => 'handleEntityAfterCreate', 'priority' => 20],
'vendor-module.entity.after_update' => ['method' => 'handleEntityAfterUpdate', 'priority' => 20],
'vendor-module.entity.after_delete' => ['method' => 'handleEntityAfterDelete', 'priority' => 20],
];
}
public function handle(...$args): void
{
// 개별 메서드에서 처리
}
// ─── 스냅샷 캡처 ───
public function captureEntitySnapshot(Model $entity, array $data): void
{
$this->snapshots['entity_' . $entity->id] = $entity->toArray();
}
// ─── 핸들러 ───
public function handleEntityAfterCreate(Model $entity, array $data): void
{
// log_type 미지정 시 ResolvesActivityLogType 트레이트가 Auth::user() 기반 자동 결정
$this->logActivity('entity.create', [
'loggable' => $entity,
'description_key' => 'vendor-module::activity_log.description.entity_create',
'description_params' => ['entity_name' => $entity->name ?? ''],
'properties' => ['id' => $entity->id],
]);
}
public function handleEntityAfterUpdate(Model $entity, array $data): void
{
$snapshot = $this->snapshots['entity_' . $entity->id] ?? null;
$changes = ChangeDetector::detect($entity, $snapshot);
$this->logActivity('entity.update', [
'loggable' => $entity,
'description_key' => 'vendor-module::activity_log.description.entity_update',
'description_params' => ['entity_name' => $entity->name ?? ''],
'changes' => $changes,
]);
unset($this->snapshots['entity_' . $entity->id]);
}
public function handleEntityAfterDelete(Model $entity): void
{
$this->logActivity('entity.delete', [
'loggable' => $entity,
'description_key' => 'vendor-module::activity_log.description.entity_delete',
'description_params' => ['entity_name' => $entity->name ?? ''],
]);
}
}
```
### ResolvesActivityLogType 트레이트
모든 ActivityLog 리스너는 `ResolvesActivityLogType` 트레이트를 사용합니다.
이 트레이트는 두 가지 메서드를 제공합니다:
- **`resolveLogType()`** — `Auth::user()` 기반으로 log_type을 동적 결정:
- Admin 역할 보유자 → `ActivityLogType::Admin`
- 인증된 비관리자 → `ActivityLogType::User`
- 비인증(비회원/CLI/시스템) → `ActivityLogType::System`
- **`logActivity(string $action, array $context)`** — `Log::channel('activity')->info()` 래퍼:
- context에 `log_type`이 없으면 `resolveLogType()`으로 자동 주입
- try-catch로 로그 실패가 비즈니스 로직을 중단하지 않도록 보호
**log_type 명시가 필요한 경우** (예: 시스템 자동 작업):
```php
$this->logActivity('schedule.auto_run', [
'log_type' => ActivityLogType::System, // 명시 시 auto-resolve 건너뜀
'description_key' => 'activity_log.description.schedule_auto_run',
]);
```
### 핵심 규칙
| 규칙 | 설명 |
|------|------|
| `HookListenerInterface` 구현 필수 | `getSubscribedHooks()` + `handle()` 메서드 |
| `use ResolvesActivityLogType` 필수 | 모든 ActivityLog 리스너에 트레이트 적용 |
| before_update priority 5 | 다른 리스너보다 먼저 실행하여 변경 전 스냅샷 확보 |
| after_xxx priority 20 | 실제 로직 완료 후 기록 |
| `log_type` 하드코딩 금지 | 트레이트가 `Auth::user()` 기반으로 자동 결정 (필요 시 명시 가능) |
| 스냅샷 사용 후 `unset` | 메모리 누수 방지 |
---
## 변경 추적 (ChangeDetector)
### 모델에 $activityLogFields 정의 (필수)
> **주의**: ChangeDetector를 호출하는 모든 모델은 `$activityLogFields` 정의 필수입니다.
> 미정의 시 `ChangeDetector::detect()` 가 항상 `null` 을 반환하여 changes 컬럼이 빈 채로 기록됩니다.
추적할 필드를 모델에 `public static array $activityLogFields` 로 선언합니다.
```php
class Product extends Model
{
/** @var array<string, array> 활동 로그 추적 필드 */
public static array $activityLogFields = [
'name' => [
'label_key' => 'sirsoft-ecommerce::product.name',
'type' => 'text',
],
'price' => [
'label_key' => 'sirsoft-ecommerce::product.price',
'type' => 'currency',
],
'status' => [
'label_key' => 'sirsoft-ecommerce::product.status',
'type' => 'enum',
'enum' => ProductStatus::class, // Backed Enum 클래스 (labelKey() 메서드 필수)
],
'is_visible' => [
'label_key' => 'sirsoft-ecommerce::product.is_visible',
'type' => 'boolean',
],
'published_at' => [
'label_key' => 'sirsoft-ecommerce::product.published_at',
'type' => 'datetime',
],
];
}
```
### 지원 타입
| type | 설명 | 비고 |
|------|------|------|
| `text` | 일반 문자열 | 기본값 (type 생략 시) |
| `number` | 숫자 | 정수/실수 |
| `currency` | 통화 금액 | 프론트엔드에서 포맷팅 시 사용 |
| `date` | 날짜 | Y-m-d |
| `datetime` | 날짜+시간 | Y-m-d H:i:s |
| `enum` | Enum 값 | `enum` 키에 Backed Enum 클래스 지정 필수 |
| `boolean` | 참/거짓 | true/false |
| `json` | JSON 데이터 | 객체/배열 |
### ChangeDetector 사용법
```php
// 1. before_update 훅에서 스냅샷 캡처
public function captureSnapshot(Model $model, array $data): void
{
$this->snapshots['model_' . $model->id] = $model->toArray();
}
// 2. after_update 훅에서 변경 감지
public function handleAfterUpdate(Model $model, array $data): void
{
$snapshot = $this->snapshots['model_' . $model->id] ?? null;
$changes = ChangeDetector::detect($model, $snapshot);
// $changes 는 null (변경 없음) 또는 변경 배열
}
```
### changes 배열 구조
```json
[
{
"field": "status",
"label_key": "sirsoft-ecommerce::product.status",
"old": "draft",
"new": "active",
"type": "enum",
"old_label_key": "sirsoft-ecommerce::enums.product_status.draft",
"new_label_key": "sirsoft-ecommerce::enums.product_status.active"
},
{
"field": "name",
"label_key": "sirsoft-ecommerce::product.name",
"old": "기존 상품명",
"new": "새 상품명",
"type": "text"
}
]
```
- `old_label_key` / `new_label_key` 는 `type: "enum"` 일 때만 추가됩니다.
- Enum 클래스에 `labelKey(): string` 메서드가 필수입니다.
- Backed Enum 값은 자동으로 `->value` 로 변환하여 비교합니다.
---
## 다국어 키 규칙
### 코어
```text
activity_log.description.{entity}_{verb}
```
| 예시 키 | 설명 |
|--------|------|
| `activity_log.description.user_create` | 사용자 생성 |
| `activity_log.description.user_update` | 사용자 수정 |
| `activity_log.description.user_delete` | 사용자 삭제 |
| `activity_log.description.role_create` | 역할 생성 |
| `activity_log.description.settings_save` | 설정 저장 |
lang 파일 위치: `lang/ko/activity_log.php`, `lang/en/activity_log.php`
```php
// lang/ko/activity_log.php
return [
'description' => [
'user_create' => '회원 :user_id 생성',
'user_update' => '회원 :user_id 정보 수정',
'user_delete' => '회원 :user_id 삭제',
],
];
```
### 모듈
```text
{module-id}::activity_log.description.{entity}_{verb}
```
| 예시 키 | 설명 |
|--------|------|
| `sirsoft-ecommerce::activity_log.description.order_create` | 주문 생성 |
| `sirsoft-page::activity_log.description.page_update` | 페이지 수정 |
| `sirsoft-board::activity_log.description.post_delete` | 게시글 삭제 |
lang 파일 위치: `modules/_bundled/{id}/resources/lang/ko/activity_log.php`
```php
// modules/_bundled/sirsoft-page/resources/lang/ko/activity_log.php
return [
'description' => [
'page_create' => '페이지 ":page_title" 생성',
'page_update' => '페이지 ":page_title" 수정',
'page_delete' => '페이지 ":page_title" 삭제',
'page_publish' => '페이지 ":page_title" 공개',
'page_unpublish' => '페이지 ":page_title" 비공개 전환',
],
];
```
### 규칙
- 파라미터는 Laravel 표준 `:param` 문법 사용 (예: `:user_id`, `:page_title`)
- `ko` 와 `en` lang 파일 모두 정의 필수
- DB에는 번역 키(`description_key`)와 파라미터(`description_params`)만 저장
- 실시간 번역은 `ActivityLog::localized_description` 접근자에서 수행
---
## description_params 저장 정책
### 핵심 규칙: ID를 저장하고, 표시 시 이름으로 변환
`description_params` 에는 표시용 이름이 아닌 **ID** 를 저장합니다.
이름 변경 시 과거 로그도 최신 이름으로 표시되어야 하기 때문입니다.
```php
// ✅ 올바른 사용 — ID 저장
'description_params' => ['brand_id' => $brand->id],
// ❌ 금지 — 이름 직접 저장
'description_params' => ['brand_name' => $brand->name],
```
### 삭제 시 예외: properties에 이름 스냅샷 보존
엔티티 삭제 시에는 DB 조회가 불가하므로 `properties` 에 이름 스냅샷을 함께 저장합니다.
`ActivityLogDescriptionResolver` 가 `properties.name` 을 우선 사용합니다.
```php
public function handleEntityAfterDelete(Model $entity): void
{
$this->logActivity('entity.delete', [
'loggable' => $entity,
'description_key' => 'vendor-module::activity_log.description.entity_delete',
'description_params' => ['entity_id' => $entity->id],
'properties' => ['name' => $entity->name], // 삭제 후 DB 조회 불가 → 스냅샷 보존
]);
}
```
### properties vs changes vs description_params 역할 구분
| 컬럼 | 용도 | 저장 내용 |
|------|------|----------|
| `description_params` | 번역 키의 `:placeholder` 치환용 | 엔티티 ID (`ActivityLogDescriptionResolver` 가 표시 시 이름으로 변환) |
| `changes` | `ChangeDetector` 결과 | 필드별 `old`/`new` 구조화 데이터 (자동 생성) |
| `properties` | 추가 메타데이터 | bulk IDs, 삭제 시 이름 스냅샷, 기타 컨텍스트 데이터 |
---
## ActivityLogDescriptionResolver 규정
### 역할
`core.activity_log.filter_description_params` 필터 훅을 구독하는 리스너입니다.
활동 로그에 저장된 엔티티 ID를 **표시 시점에** 사람이 읽을 수 있는 이름으로 변환합니다.
### 해석 우선순위
1. **`properties.name` 스냅샷** — 로그 기록 시점의 이름 (삭제된 엔티티도 처리 가능)
2. **DB 조회** — `description_params` 의 ID로 모델 조회 → 현재 이름 반환
3. **Fallback** — DB 조회 실패 시 `"ID: {id}"` 문자열 반환
### 모듈별 구현 패턴
각 모듈은 자체 `ActivityLogDescriptionResolver` 를 구현합니다.
```php
<?php
namespace Modules\Vendor\Module\Listeners;
use App\Contracts\Extension\HookListenerInterface;
class ActivityLogDescriptionResolver implements HookListenerInterface
{
public static function getSubscribedHooks(): array
{
return [
'core.activity_log.filter_description_params' => [
'method' => 'resolveDescriptionParams',
'type' => 'filter', // ← Filter 훅 — type 명시 필수
'priority' => 10,
],
];
}
public function handle(...$args): void
{
// 기본 핸들러는 사용하지 않음
}
public function resolveDescriptionParams(array $params, string $descriptionKey, array $properties): array
{
$prefix = 'vendor-module::activity_log.description.';
if (! str_starts_with($descriptionKey, $prefix)) {
return $params; // 다른 모듈의 키 → 무시
}
$keySuffix = str_replace($prefix, '', $descriptionKey);
return match (true) {
str_starts_with($keySuffix, 'entity_') => $this->resolveEntityName($params, $properties),
default => $params,
};
}
private function resolveEntityName(array $params, array $properties): array
{
if (! empty($params['entity_name'])) {
return $params; // 이미 해석됨
}
// 1순위: properties.name 스냅샷
if (! empty($properties['name'])) {
$params['entity_name'] = $properties['name'];
return $params;
}
// 2순위: DB 조회
$id = $params['entity_id'] ?? null;
if ($id) {
$entity = Entity::find($id);
$params['entity_name'] = $entity?->name ?? "ID: {$id}";
}
return $params;
}
}
```
### 등록
모듈의 `module.php` 에서 `getHookListeners()` 에 등록합니다.
```php
public function getHookListeners(): array
{
return [
XxxActivityLogListener::class,
ActivityLogDescriptionResolver::class, // ← 추가
];
}
```
### 다국어 이름 처리
이름이 다국어 배열(`['ko' => '한국어', 'en' => 'English']`)인 경우 현재 로케일에 맞게 해석합니다.
```php
private function resolveI18nName(mixed $name): string
{
if (is_string($name)) {
return $name;
}
if (is_array($name)) {
$locale = app()->getLocale();
return $name[$locale] ?? $name['ko'] ?? (array_values($name)[0] ?? '');
}
return '';
}
```
---
## 모듈 리스너 등록
모듈의 `module.php` (AbstractModule 상속) 에서 `getHookListeners()` 메서드에 등록합니다.
```php
// modules/_bundled/sirsoft-page/module.php
namespace Modules\Sirsoft\Page;
use App\Extension\AbstractModule;
use Modules\Sirsoft\Page\Listeners\PageActivityLogListener;
class Module extends AbstractModule
{
public function getHookListeners(): array
{
return [
PageActivityLogListener::class,
// 기타 리스너...
];
}
}
```
AbstractModule이 `boot()` 시점에 `HookManager::registerListener()` 를 자동 호출합니다.
별도의 `HookManager::registerListener()` 직접 호출은 불필요합니다.
---
## 모델별 로그 조회 API 패턴
특정 모델(상품, 주문 등)에 대한 활동 로그를 조회하는 API 패턴입니다.
### Controller에 `logs()` 메서드 추가
```php
use App\Helpers\ResponseHelper;
use App\Http\Resources\ActivityLogResource;
use App\Services\ActivityLogService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ProductController extends AdminBaseController
{
public function __construct(
private ActivityLogService $activityLogService,
// ...
) {}
/**
* 상품 활동 로그 목록 조회
*
* @param Request $request 요청
* @param Product $product 대상 상품
* @return JsonResponse 로그 목록 응답
*/
public function logs(Request $request, Product $product): JsonResponse
{
try {
$filters = [
'per_page' => (int) ($request->query('per_page', 10)),
'page' => (int) ($request->query('page', 1)),
'sort_order' => $request->query('sort_order', 'desc'),
];
$logs = $this->activityLogService->getLogsForModel($product, $filters);
return ResponseHelper::moduleSuccess(
'vendor-module',
'messages.entity.logs_fetch_success',
ActivityLogResource::collection($logs)->response()->getData(true)
);
} catch (Exception $e) {
return ResponseHelper::moduleError(
'vendor-module',
'messages.entity.logs_fetch_failed',
500
);
}
}
}
```
### 라우트 등록
```php
// routes/api.php (모듈)
Route::get('products/{product}/logs', [ProductController::class, 'logs']);
```
### 레이아웃 JSON에서 처리로그 탭 구현
```json
{
"id": "activity_logs",
"endpoint": "/api/admin/vendor-module/products/{{route.id}}/logs",
"method": "GET",
"autoFetch": false,
"params": { "per_page": 10, "page": 1 }
}
```
---
## Bulk 로깅 패턴 (Per-Item 필수)
### 핵심 원칙
> **모든 bulk 작업은 Per-Item 로깅 필수** — 변경된 엔티티 건별로 `loggable_type`/`loggable_id`가 기록되어야 합니다.
> N건 일괄 처리 시 N건의 개별 로그가 생성됩니다.
```text
필수: bulk 작업은 Per-Item 개별 로그 기록 (1건 집계 로그 금지)
필수: 각 엔티티별 개별 로그 기록 (loggable => $model 또는 loggable_type/loggable_id 직접 지정)
```
### 패턴 1: Bulk Update — Service에서 스냅샷 + 개별 모델 update
```php
// Service 내부
public function bulkUpdateStatus(array $ids, string $newStatus): array
{
$models = Model::whereIn('id', $ids)->get();
$snapshots = [];
foreach ($models as $model) {
$snapshots[$model->id] = $model->toArray();
$model->update(['status' => $newStatus]);
}
// 훅에 ids + snapshots 전달
$this->hookManager->doAction('module.entity.after_bulk_status_update', $ids, count($models), $snapshots);
return $snapshots;
}
```
### 패턴 2: Bulk Update — Listener에서 per-item 로그 기록
```php
public function handleAfterBulkStatusUpdate(array $ids, int $updatedCount, array $snapshots = []): void
{
$models = Model::whereIn('id', $ids)->get()->keyBy('id');
foreach ($ids as $id) {
$model = $models->get($id);
if (! $model) {
continue; // 존재하지 않는 ID는 건너뜀
}
$snapshot = $snapshots[$id] ?? null;
$changes = $snapshot ? ChangeDetector::detect($model, $snapshot) : null;
$this->logActivity('entity.bulk_status_update', [
'loggable' => $model, // ← 개별 모델 지정 필수
'description_key' => 'vendor-module::activity_log.description.entity_status_update',
'description_params' => ['entity_id' => $id],
'changes' => $changes,
'properties' => ['entity_id' => $id],
]);
}
}
```
### 패턴 3: Bulk Delete — 삭제된 엔티티의 per-item 로깅
엔티티가 이미 삭제되어 DB 조회가 불가한 경우, `loggable_type`/`loggable_id`를 직접 지정합니다.
`ActivityLogHandler`가 `loggable` 모델이 없을 때 이 값을 fallback으로 사용합니다.
```php
public function handleAfterBulkDelete(array $ids, int $deletedCount, array $snapshots = []): void
{
foreach ($ids as $id) {
$snapshot = $snapshots[$id] ?? null;
$this->logActivity('entity.bulk_delete', [
'loggable_type' => Model::class, // ← morph 타입 직접 지정
'loggable_id' => $id, // ← morph ID 직접 지정
'description_key' => 'vendor-module::activity_log.description.entity_delete',
'description_params' => ['entity_id' => $id],
'properties' => [
'entity_id' => $id,
'snapshot' => $snapshot, // 삭제 전 데이터 보존
],
]);
}
}
```
### 패턴 4: Bulk Toggle — 모델 조회 후 per-item 로깅
```php
public function handleAfterBulkToggleActive(array $ids, bool $isActive, int $count, array $snapshots = []): void
{
$models = Model::whereIn('id', $ids)->get()->keyBy('id');
foreach ($ids as $id) {
$model = $models->get($id);
if (! $model) {
continue;
}
$snapshot = $snapshots[$id] ?? null;
$changes = $snapshot ? ChangeDetector::detect($model, $snapshot) : null;
$this->logActivity('entity.bulk_toggle_active', [
'loggable' => $model,
'description_key' => 'vendor-module::activity_log.description.entity_toggle_active',
'description_params' => ['entity_id' => $id],
'changes' => $changes,
'properties' => ['entity_id' => $id, 'is_active' => $isActive],
]);
}
}
```
### loggable vs loggable_type/loggable_id 선택 기준
| 상황 | 사용 방식 |
| --- | --- |
| 모델이 존재함 (update/toggle) | `'loggable' => $model` |
| 모델이 삭제됨 (delete) | `'loggable_type' => Model::class, 'loggable_id' => $id` |
| 두 값 모두 존재 시 | `loggable` 우선 (Handler에서 `loggable instanceof Model` 체크) |
### 삭제 작업 ChangeDetector
bulk delete 는 변경 전후 비교가 불필요하므로 ChangeDetector를 사용하지 않습니다.
대신 `properties.snapshot` 에 삭제 전 데이터를 보존합니다.
---
## DB 스키마
### activity_logs 테이블
| 컬럼 | 타입 | Nullable | 설명 |
|------|------|----------|------|
| `id` | bigint (PK) | N | 활동 로그 ID |
| `log_type` | varchar(20) | N | 로그 유형 (admin/user/system) |
| `loggable_type` | varchar | Y | 대상 모델 morph 타입 |
| `loggable_id` | bigint | Y | 대상 모델 ID |
| `user_id` | bigint (FK) | Y | 행위자 사용자 ID (삭제 시 NULL) |
| `action` | varchar(50) | N | 액션 유형 (예: user.create, page.update) |
| `description_key` | varchar(150) | Y | 다국어 번역 키 |
| `description_params` | json | Y | 다국어 번역 파라미터 |
| `properties` | json | Y | 추가 속성 데이터 |
| `changes` | json | Y | 구조화된 변경 이력 (ChangeDetector 결과) |
| `ip_address` | varchar(45) | Y | IP 주소 (IPv6 대응) |
| `user_agent` | varchar(500) | Y | User Agent (500자 제한) |
| `created_at` | timestamp | N | 생성일시 (updated_at 없음) |
- `timestamps = false` — `created_at` 만 사용 (boot 시 자동 설정)
- `user_id` 는 `nullOnDelete` — 사용자 삭제 시 NULL 처리
---
## 인덱스
### 단일 인덱스
| 컬럼 | 용도 |
|------|------|
| `log_type` | 유형별 필터링 |
| `user_id` | 사용자별 조회 |
| `action` | 액션별 필터링 |
| `created_at` | 시간순 정렬/범위 조회 |
| `loggable_type` + `loggable_id` | morphs 자동 생성 인덱스 |
### 복합 인덱스
| 인덱스명 | 컬럼 | 용도 |
|----------|------|------|
| `idx_activity_logs_loggable` | (`loggable_type`, `loggable_id`, `created_at`) | 특정 모델의 로그 시간순 조회 |
| `idx_activity_logs_type_action_date` | (`log_type`, `action`, `created_at`) | 유형+액션 기반 필터링 |
---
## 금지 사항
| 금지 | 올바른 사용 |
|------|-------------|
| `ActivityLogService->log()` / `logAdmin()` / `logUser()` / `logSystem()` | 제거됨 — `Log::channel('activity')->info()` 사용 |
| `ActivityLogManager` / `Driver` 패턴 | 제거됨 — Monolog 기반 아키텍처로 전환 |
| Controller에서 직접 로그 기록 | Listener에서만 기록 (Service 훅 → Listener → Log) |
| description 컬럼에 번역된 텍스트 저장 | `description_key` + `description_params` 사용 |
| ActivityLogService를 기록용으로 사용 | 조회 전용 — 기록은 `Log::channel('activity')` |
| Listener 밖에서 `ActivityLog::create()` 직접 호출 | `Log::channel('activity')` 를 통해 기록 (Processor 자동 주입 보장) |
| `$activityLogFields` 미정의 상태에서 ChangeDetector 호출 | ChangeDetector를 사용하는 모든 모델에 `$activityLogFields` 정의 필수 |
| `description_params` 에 표시용 이름 직접 저장 | ID 저장 + `ActivityLogDescriptionResolver` 에서 표시 시 변환 |
| 별도 로그 테이블/서비스 운영 (예: `ecommerce_product_logs`) | `activity_logs` 단일 테이블로 통합 |
| Bulk 작업을 1건의 집계 로그로 기록 (loggable 없이 count만 저장) | Per-Item: 변경된 각 엔티티별 개별 로그 기록 필수 |
| Bulk delete에서 loggable 미지정 | `loggable_type` + `loggable_id` 직접 지정 (삭제 엔티티 대응) |
---
## 참고 파일
| 파일 | 경로 |
|------|------|
| ActivityLogChannel | `app/ActivityLog/ActivityLogChannel.php` |
| ActivityLogHandler | `app/ActivityLog/ActivityLogHandler.php` |
| ActivityLogProcessor | `app/ActivityLog/ActivityLogProcessor.php` |
| ChangeDetector | `app/ActivityLog/ChangeDetector.php` |
| CoreActivityLogListener | `app/Listeners/CoreActivityLogListener.php` |
| ActivityLog 모델 | `app/Models/ActivityLog.php` |
| ActivityLogService | `app/Services/ActivityLogService.php` |
| ActivityLogType Enum | `app/Enums/ActivityLogType.php` |
| config/activity_log.php | `config/activity_log.php` |
| config/logging.php (activity 채널) | `config/logging.php` |
| 마이그레이션 (생성) | `database/migrations/2026_04_01_000027_create_activity_logs_table.php` |
| 마이그레이션 (i18n 전환) | `database/migrations/2026_04_01_000657_update_i18n_fields_in_activity_logs_table.php` |
---
## 개발자 체크리스트
### 새 모델 추가 시
```text
□ 모델에 public static array $activityLogFields 정의
□ ActivityLogListener에 before_update (스냅샷 캡처) + after_create/update/delete 훅 등록
□ description_params에 ID 저장 (이름 직접 저장 금지)
□ ActivityLogDescriptionResolver에 해당 모델의 ID→이름 해석 로직 추가
□ lang/{ko,en}/activity_log.php에 description 키 추가
□ (선택) Controller에 logs() 메서드 + 라우트 추가 (모델별 로그 조회 필요 시)
```
### CRUD 훅 추가 시
```text
□ before_update 훅: priority 5 (스냅샷 캡처용)
□ after_xxx 훅: priority 20 (로그 기록)
□ update 핸들러에서 ChangeDetector::detect() 호출
□ delete 핸들러에서 properties에 이름 스냅샷 보존
□ ko/en 다국어 키 모두 추가
```
### Bulk Update 추가 시
```text
□ Service: 변경 전 모델을 일괄 로드 (whereIn()->get())
□ Service: 각 모델별 toArray() 스냅샷 캡처 → $snapshots 배열
□ Service: 개별 모델 update 실행
□ Service: 훅에 $ids + $count + $snapshots 전달
□ Listener: per-item 루프 — 각 ID별 모델 조회 + ChangeDetector + 개별 logActivity 호출
□ Listener: 존재하지 않는 ID는 continue로 건너뜀
□ 1건의 집계 로그가 아닌, N건의 개별 로그 생성 확인
□ 각 로그에 loggable => $model (또는 loggable_type/loggable_id) 포함 확인
□ ko/en 다국어 키 모두 추가
□ 테스트: N건 처리 시 N건 로그 생성 검증 + loggable 정확성 검증
```
### Bulk Delete 추가 시
```text
□ Service: 삭제 전 스냅샷 캡처 → $snapshots 배열 (keyBy id)
□ Service: 훅에 $ids + $count + $snapshots 전달
□ Listener: per-item 루프 — 각 ID별 loggable_type/loggable_id 직접 지정
□ Listener: properties.snapshot에 삭제 전 데이터 보존
□ 테스트: loggable_type/loggable_id 정확성 검증
```
+504
View File
@@ -0,0 +1,504 @@
# API 리소스
> **목적**: API 응답 데이터 변환을 위한 리소스 클래스 규칙
---
## TL;DR (5초 요약)
```text
1. BaseApiResource 상속 필수
2. 민감 정보 제외: created_by, updated_by, deleted_at
3. 관계 데이터: whenLoaded() 사용
4. when() 주의: 커스텀 메서드에서는 삼항 연산자 사용 (MissingValue 누출 방지)
5. 권한 메타: ...$this->resourceMeta($request) 스프레드로 is_owner + abilities(can_*) 표준화
```
---
## 목차
1. [기본 규칙](#기본-규칙)
2. [권한 메타 표준화 (is_owner + abilities)](#권한-메타-표준화-is_owner--abilities)
3. [$this->when() 사용 주의사항](#thiswhen-사용-주의사항)
4. [패턴 예시](#패턴-예시)
---
## 기본 규칙
### BaseApiResource 상속 필수
모든 API 리소스는 `BaseApiResource`를 상속해야 합니다.
```php
use App\Http\Resources\BaseApiResource;
class ProductResource extends BaseApiResource
{
// ...
}
```
### 민감한 정보 제외
보안을 위해 다음 필드는 API 응답에서 제외합니다:
- `created_by` - 생성자 ID
- `updated_by` - 수정자 ID
- `deleted_at` - 삭제 시간
- 비밀번호, 토큰 등 인증 정보
### 관계 데이터 처리
관계 데이터는 `whenLoaded`를 사용하여 조건부로 포함합니다:
```php
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
// ✅ 관계가 로드된 경우에만 포함
'category' => new CategoryResource($this->whenLoaded('category')),
'images' => ProductImageResource::collection($this->whenLoaded('images')),
];
}
```
---
## 권한 메타 표준화 (is_owner + abilities)
모든 API 리소스 응답에 `is_owner`와 `abilities` (can_*) 필드를 표준화합니다.
### 기본 사용법
`toArray()` 마지막에 `...$this->resourceMeta($request)`를 스프레드합니다:
```php
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
// ... 기타 필드
// ✅ 표준 권한 메타 (is_owner + abilities)
...$this->resourceMeta($request),
];
}
```
### 응답 형식
```json
{
"id": 1,
"name": "상품명",
"is_owner": true,
"abilities": {
"can_update": true,
"can_delete": false
}
}
```
- `is_owner`: 현재 요청 사용자가 리소스 소유자인지 여부 (`ownerField()` 미정의 시 생략)
- `abilities`: `can_*` 키로 통합된 권한 플래그 (`abilityMap()` 미정의 시 생략)
### ownerField() — 소유자 판단
리소스의 소유자 필드명을 반환합니다:
```php
// 기본값: null (is_owner 생략)
protected function ownerField(): ?string
{
return 'user_id'; // 또는 'created_by' 등
}
```
### abilityMap() — 정적 권한 매핑
권한 식별자를 `can_*` 키로 매핑합니다. 대부분의 리소스에서 사용합니다:
```php
protected function abilityMap(): array
{
return [
'can_create' => 'sirsoft-ecommerce.products.create',
'can_update' => 'sirsoft-ecommerce.products.update',
'can_delete' => 'sirsoft-ecommerce.products.delete',
];
}
```
### resolveAbilities() — 동적 권한 (고급)
slug 기반 동적 식별자가 필요한 경우 `resolveAbilities()`를 오버라이드합니다:
```php
// Board 모듈 PostResource 패턴
protected function resolveAbilities(Request $request): array
{
$slug = $this->getSlug($request);
if (! $slug) {
return [];
}
$abilityMap = $this->isAdminRequest($request)
? [
'can_read' => "sirsoft-board.{$slug}.admin.posts.read",
'can_write' => "sirsoft-board.{$slug}.admin.posts.write",
]
: [
'can_read' => "sirsoft-board.{$slug}.posts.read",
'can_write' => "sirsoft-board.{$slug}.posts.write",
];
return $this->resolveAbilitiesFromMap($abilityMap, $request->user());
}
```
### HasAbilityCheck 트레이트
`BaseApiResource`와 `ResourceCollection` 모두에서 권한 체크 로직을 공유하기 위한 트레이트입니다:
```php
use App\Http\Resources\Traits\HasAbilityCheck;
// ResourceCollection에서 컬렉션 레벨 abilities 제공
class BrandCollection extends ResourceCollection
{
use HasAbilityCheck;
protected function abilityMap(): array
{
return [
'can_create' => 'sirsoft-ecommerce.brands.create',
'can_update' => 'sirsoft-ecommerce.brands.update',
'can_delete' => 'sirsoft-ecommerce.brands.delete',
];
}
public function toArray(Request $request): array
{
return [
'data' => BrandResource::collection($this->collection),
'abilities' => $this->resolveAbilitiesFromMap($this->abilityMap(), $request->user()),
];
}
}
```
### 커스텀 메서드에서의 사용
`toListArray()` 등 커스텀 메서드에서도 동일하게 스프레드합니다:
```php
public function toListArray(): array
{
return [
'id' => $this->getValue('id'),
'name' => $this->getValue('name'),
// ✅ 커스텀 메서드에서도 동일 패턴
...$this->resourceMeta(request()),
];
}
```
### 권한 체크 및 scope 기반 abilities
`resolveAbilitiesFromMap()`은 각 ability에 대해 다음 순서로 확인합니다:
1. 권한 체크 (`PermissionHelper::check()`)
2. scope 기반 접근 체크 (`PermissionHelper::checkScopeAccess()`) — 리소스 모델이 있는 경우
scope_type에 따라 리소스 소유자 기반으로 ability가 자동 제한됩니다. 별도의 바이패스 맵 정의가 불필요합니다.
> 상세: [permissions.md](../extension/permissions.md) scope_type 시스템 참조
---
## $this->when() 사용 주의사항
```text
필수: 커스텀 메서드에서는 삼항 연산자 사용 ($this->when() 금지)
필수: 커스텀 메서드에서는 삼항 연산자 사용
```
### 문제 원인
Laravel의 `$this->when()` 메서드는 조건이 false일 때 `MissingValue` 객체를 반환합니다. 이 객체는 **Laravel이 `toArray()`를 처리할 때만 자동으로 필터링**됩니다. `toListArray()`, `withAdminInfo()` 등 커스텀 메서드에서는 필터링되지 않아 빈 객체 `{}`가 JSON 응답에 포함됩니다.
### 발생하는 문제
- React Error #31: "Objects are not valid as a React child"
- 프론트엔드에서 빈 화면 표시
- 데이터 바인딩 실패
### 잘못된 예시 (❌ DON'T)
```php
// ❌ 커스텀 메서드에서 $this->when() 사용 - MissingValue 객체 누출
public function toListArray(): array
{
return [
'id' => $this->getValue('id'),
'name' => $this->getValue('name'),
// ❌ email_verified_at이 null이면 빈 객체 {} 반환
'email_verified_at' => $this->when(
$this->getValue('email_verified_at'),
fn () => $this->formatDateTimeStringForUser($this->getValue('email_verified_at'))
),
];
}
```
### 올바른 예시 (✅ DO)
```php
// ✅ 커스텀 메서드에서는 삼항 연산자 사용
public function toListArray(): array
{
return [
'id' => $this->getValue('id'),
'name' => $this->getValue('name'),
// ✅ null 반환으로 안전하게 처리
'email_verified_at' => $this->getValue('email_verified_at')
? $this->formatDateTimeStringForUser($this->getValue('email_verified_at'))
: null,
];
}
```
### 사용 가능/금지 장소
| 장소 | $this->when() 사용 |
|------|-------------------|
| `toArray()` 메서드 내부 | ✅ 가능 (Laravel이 MissingValue 자동 필터링) |
| `toListArray()` 등 커스텀 메서드 | ❌ 금지 |
| `toProfileArray()` 등 커스텀 메서드 | ❌ 금지 |
| `withAdminInfo()` 등 추가 정보 메서드 | ❌ 금지 |
| `toArray()` 외부에서 수동 호출되는 모든 메서드 | ❌ 금지 |
### PHPDoc 작성 권장
커스텀 메서드임을 명시하여 주의사항을 알립니다:
```php
/**
* 목록용 간단한 형태의 배열을 반환합니다.
*
* 주의: 이 메서드는 toArray() 외부에서 수동 호출되므로
* $this->when() 대신 삼항 연산자를 사용해야 합니다.
*/
public function toListArray(): array
```
---
## 패턴 예시
### 기본 리소스 클래스
```php
<?php
namespace Modules\Sirsoft\Ecommerce\Resources;
use App\Http\Resources\BaseApiResource;
class ProductResource extends BaseApiResource
{
/**
* 리소스를 배열로 변환
*
* @param \Illuminate\Http\Request $request
* @return array
*/
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'price' => $this->price,
'is_active' => $this->is_active,
'category' => new CategoryResource($this->whenLoaded('category')),
'images' => ProductImageResource::collection($this->whenLoaded('images')),
'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(),
// created_by, updated_by는 제외 (보안)
];
}
}
```
### 커스텀 메서드가 있는 리소스
```php
<?php
namespace App\Http\Resources;
class UserResource extends BaseApiResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
// ✅ toArray() 내부에서는 when() 사용 가능
'role' => $this->when($this->role, fn () => $this->role->name),
];
}
/**
* 목록용 간단한 배열 반환
*
* 주의: $this->when() 대신 삼항 연산자 사용
*/
public function toListArray(): array
{
return [
'id' => $this->getValue('id'),
'name' => $this->getValue('name'),
// ✅ 삼항 연산자 사용
'email_verified' => $this->getValue('email_verified_at') ? true : false,
];
}
}
```
---
## ResourceCollection 커스텀 메서드
ResourceCollection에 커스텀 메서드를 추가하여 다양한 응답 형식을 지원할 수 있습니다.
### 패턴
```php
class UserCollection extends ResourceCollection
{
/**
* 통계 정보를 추가하여 반환
*
* @param array $statistics 통계 데이터
* @return array
*/
public function withStatistics(array $statistics): array
{
return [
'data' => $this->collection,
'statistics' => $statistics,
];
}
/**
* 관리자용 추가 정보를 포함하여 반환
*
* @return array
*/
public function withAdminInfo(): array
{
return [
'data' => $this->collection,
'meta' => [
'total' => $this->total(),
'admin_info' => true,
],
];
}
}
```
### 컨트롤러에서 사용
```php
public function index(UserListRequest $request): JsonResponse
{
$users = $this->userService->getPaginatedUsers($request->validated());
$statistics = $this->userService->getStatistics();
$collection = new UserCollection($users);
return $this->success(
'user.fetch_success',
$collection->withStatistics($statistics)
);
}
```
### 주요 커스텀 메서드 유형
| 메서드 | 용도 | 반환 데이터 |
| ------ | ------ | ------ |
| `withStatistics()` | 통계 데이터 추가 | data + statistics |
| `withAdminInfo()` | 관리자 전용 메타 추가 | data + admin meta |
| `toListArray()` | 목록 전용 간소화 응답 | 축소된 필드 |
| `toSimpleArray()` | 최소 필드 응답 | id + name만 |
### Resource에서 다중 응답 형식
Resource 클래스에서도 메서드별로 다른 응답 형식을 제공할 수 있습니다:
```php
class UserResource extends BaseApiResource
{
/**
* 목록용 간소화 응답
*/
public function toListArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'status' => $this->status,
];
}
/**
* 프로필용 응답
*/
public function toProfileArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'avatar' => $this->avatar_url,
'created_at' => $this->created_at?->toISOString(),
];
}
}
```
---
## API 리소스 개발 체크리스트
```text
□ BaseApiResource를 상속했는가?
□ 민감한 정보(created_by, updated_by, deleted_at)를 제외했는가?
□ 관계 데이터에 whenLoaded()를 사용했는가?
□ 커스텀 메서드에서 $this->when() 사용을 피했는가?
□ 커스텀 메서드에서 삼항 연산자를 사용했는가?
□ 날짜 필드에 toISOString()을 사용했는가?
□ ...$this->resourceMeta($request) 스프레드를 추가했는가?
□ ownerField()를 정의했는가? (소유자 판단 필요 시)
□ abilityMap()을 정의했는가? (권한 플래그 필요 시)
```
---
## 관련 문서
- [index.md](./index.md) - 백엔드 가이드 인덱스
- [controllers.md](./controllers.md) - 컨트롤러에서 리소스 사용
- [response-helper.md](./response-helper.md) - API 응답 규칙
- [service-repository.md](./service-repository.md) - Eager Loading으로 N+1 방지
+332
View File
@@ -0,0 +1,332 @@
# 인증 및 세션 처리
> **관련 문서**: [index.md](index.md) | [middleware.md](middleware.md)
---
## TL;DR (5초 요약)
```text
1. Laravel Sanctum 토큰 전용 인증 (Bearer 토큰만 사용)
2. 세션은 /dev 대시보드 전용: StartApiSession 미들웨어로 로그인/로그아웃 라우트에서만 생성
3. 로그아웃: 토큰 삭제 + 세션 무효화 (세션 존재 시)
4. 슬라이딩 만료: 토큰 잔여시간 절반 미만 시 자동 갱신
5. 401 응답 시 세션 쿠키 자동 정리 (무조건적 forget cookie)
```
---
## 목차
1. [Laravel Sanctum 토큰 전용 인증](#laravel-sanctum-토큰-전용-인증)
2. [StartApiSession 미들웨어](#startapisession-미들웨어)
3. [토큰 슬라이딩 만료](#토큰-슬라이딩-만료)
4. [로그아웃 구현 원칙](#로그아웃-구현-원칙)
5. [401 응답 시 세션 쿠키 정리](#401-응답-시-세션-쿠키-정리)
6. [Laravel Sanctum plainTextToken 구조](#laravel-sanctum-plaintexttoken-구조)
7. [세션 존재 확인](#세션-존재-확인)
8. [세션 무효화 3단계](#세션-무효화-3단계)
9. [완전한 로그아웃 구현 예시](#완전한-로그아웃-구현-예시)
10. [안티 패턴](#안티-패턴)
---
## Laravel Sanctum 토큰 전용 인증
그누보드7 프로젝트는 SPA(Single Page Application)를 위한 **토큰 전용 인증 방식**을 사용합니다:
**특징**:
- **Bearer 토큰 전용**: API 인증은 `Authorization: Bearer {token}` 헤더로만 수행
- **`EnsureFrontendRequestsAreStateful` 미사용**: 세션 기반 Sanctum Guard를 사용하지 않음
- **`currentAccessToken()`**: 항상 `PersonalAccessToken` 반환 (TransientToken 없음)
- **세션은 /dev 대시보드 전용**: `StartApiSession` 미들웨어가 로그인/로그아웃 라우트에서만 세션 생성
**인증 가드 설정** (`bootstrap/app.php`):
```php
// stateful 미들웨어 없음 — Sanctum Guard는 토큰만 체크
$middleware->alias([
'start.api.session' => \App\Http\Middleware\StartApiSession::class,
]);
```
---
## StartApiSession 미들웨어
로그인/로그아웃 라우트에서만 세션을 시작하는 전용 미들웨어입니다.
**목적**:
- `/dev` 대시보드(Telescope, Horizon 등)는 세션 기반 `web` guard 인증 사용
- API 로그인 시 `Auth::guard('web')->login($user)`으로 세션에 사용자 저장
- 로그아웃 시 세션 무효화
**동작 방식**:
```php
// StartApiSession은 세션 파이프라인만 실행 (CSRF 검증 없음, sanctum 속성 미설정)
EncryptCookies → AddQueuedCookiesToResponse → StartSession → Controller
```
**적용 라우트**:
```php
// 로그인 라우트 (세션 생성)
Route::post('login', ...)->middleware('start.api.session');
// 로그아웃 라우트 (세션 무효화)
Route::post('logout', ...)->middleware('start.api.session');
```
**핵심 차이** — `stateful` vs `start.api.session`:
| 항목 | `stateful` (제거됨) | `start.api.session` |
|------|---------------------|---------------------|
| 적용 범위 | 모든 API 라우트 | 로그인/로그아웃만 |
| Sanctum 세션 인증 | ✅ 활성화 | ❌ 비활성화 |
| CSRF 검증 | ✅ 포함 | ❌ 미포함 |
| `sanctum` 속성 설정 | ✅ 설정 | ❌ 미설정 |
| 세션 쿠키 발급 | 모든 API 응답 | 로그인/로그아웃 응답만 |
**구현 파일**: `app/Http/Middleware/StartApiSession.php`
---
## 토큰 슬라이딩 만료
G7은 **슬라이딩 만료(Sliding Expiration)** 방식으로 토큰 만료 시간을 자동 갱신합니다.
### 동작 원리
```
[로그인] ───────────────────────────────────────> [만료]
0분 60분
↑
30분에 API 요청 발생 (절반 미만 남음)
│
└─────────────────────────────────────> [만료]
30분 90분
```
### 갱신 조건
토큰 만료 시간은 다음 조건을 **모두** 만족할 때 갱신됩니다:
| 조건 | 설명 |
|------|------|
| 인증된 요청 | `$request->user()`가 존재 |
| PersonalAccessToken | 세션이 아닌 토큰 인증 |
| 만료 시간 설정됨 | `expires_at`이 null이 아님 (무한대 토큰 제외) |
| 절반 미만 남음 | 잔여 시간 < 설정값의 50% |
### 설정값
환경설정의 `security.auth_token_lifetime` 값을 사용합니다:
- **기본값**: 30분
- **범위**: 0~3600분 (0은 무한대)
### 구현 파일
- **미들웨어**: `app/Http/Middleware/RefreshTokenExpiration.php`
- **등록 위치**: `bootstrap/app.php` (`api` 그룹에 등록)
- **테스트**: `tests/Feature/Api/Auth/TokenExpirationTest.php`
---
## 로그아웃 구현 원칙
```
필수: 토큰 삭제 + 세션 무효화 (세션이 존재하는 경우)
필수: currentAccessToken() 삭제 → 세션 존재 시 세션 무효화
```
토큰 전용 인증에서는 `currentAccessToken()`이 항상 `PersonalAccessToken`을 반환합니다:
```php
// 1. 토큰 삭제
$token = $user->currentAccessToken();
if ($token instanceof \Laravel\Sanctum\PersonalAccessToken) {
$token->delete();
}
// 2. 세션 무효화 (StartApiSession 미들웨어가 세션을 시작한 경우)
if (request()->hasSession() && request()->session()->isStarted() && Auth::guard('web')->check()) {
Auth::guard('web')->logout();
request()->session()->invalidate();
request()->session()->regenerateToken();
}
```
**특징**:
- `stateful` 미들웨어 제거로 `TransientToken` 케이스가 없어짐
- `currentAccessToken()`이 항상 `PersonalAccessToken` 반환
- 세션 무효화는 `start.api.session` 미들웨어가 적용된 로그아웃 라우트에서만 실행됨
---
## 401 응답 시 세션 쿠키 정리
API 요청에서 `AuthenticationException` 발생 시 (토큰 만료, 미인증 등), 잔존 세션 쿠키를 만료시켜 보안 공백을 방지합니다.
**구현 위치**: `bootstrap/app.php` `withExceptions()`
```php
$exceptions->render(function (\Illuminate\Auth\AuthenticationException $e, \Illuminate\Http\Request $request) {
if ($request->expectsJson()) {
return response()->json(['message' => __('auth.unauthenticated')], 401)
->withCookie(cookie()->forget(config('session.cookie')));
}
});
```
**동작 방식**:
- **무조건적 forget**: 세션 쿠키 존재 여부와 관계없이 항상 `Set-Cookie` 만료 헤더 전송
- 클라이언트에 해당 쿠키가 없으면 `Set-Cookie` 헤더는 무시됨 (무해)
- 클라이언트에 잔존 세션 쿠키가 있으면 즉시 삭제됨
**테스트 파일**: `tests/Feature/Api/Auth/SessionCleanupOnUnauthorizedTest.php`
---
## Laravel Sanctum plainTextToken 구조
**형식**: `{tokenId}|{actualToken}`
**예시**:
```
9|iHzLc0VcFxEKi8dJlyqaPQoMBKBgs1ocpZpWKQzj0aaddebe
↓ ↓
DB ID SHA256 해시 후 DB 저장
```
**DB 저장 방식**:
- `|` 앞부분 (tokenId): DB의 `id` 컬럼에 저장
- `|` 뒷부분 (actualToken): SHA256 해시 후 `token` 컬럼에 저장
**파싱 방법**:
```php
// ✅ DO: explode()로 분리 후 뒷부분만 해시
[, $token] = explode('|', $bearerToken, 2);
$hashedToken = hash('sha256', $token);
// ❌ DON'T: 전체 문자열을 해시
$hashedToken = hash('sha256', $bearerToken); // 잘못된 해시값
```
---
## 세션 존재 확인
세션 무효화 전 반드시 세션 존재 여부를 확인해야 합니다:
```php
if (request()->hasSession() && request()->session()->isStarted() && Auth::guard('web')->check()) {
// 세션 무효화 로직
}
```
**체크 항목**:
| 메서드 | 설명 |
|--------|------|
| `hasSession()` | 세션이 바인딩되어 있는지 확인 |
| `isStarted()` | 세션이 실제로 시작되었는지 확인 |
| `Auth::guard('web')->check()` | 세션에 인증된 사용자가 있는지 확인 |
---
## 세션 무효화 3단계
```php
Auth::guard('web')->logout(); // 1. 세션에서 인증 정보 제거
request()->session()->invalidate(); // 2. 세션 ID 무효화 및 새 세션 생성
request()->session()->regenerateToken(); // 3. CSRF 토큰 재생성
```
**각 단계 설명**:
| 단계 | 메서드 | 역할 |
|------|--------|------|
| 1 | `logout()` | 세션에서 사용자 인증 정보 제거 |
| 2 | `invalidate()` | 세션 ID를 무효화하고 새 세션 ID 생성 |
| 3 | `regenerateToken()` | CSRF 토큰을 재생성 |
**regenerateToken()을 하는 이유**:
- 로그아웃 후에도 사용자가 같은 브라우저를 계속 사용할 수 있음
- 이전 CSRF 토큰을 무효화하고 새로운 CSRF 토큰 발급
- 로그아웃 이후의 공개 페이지 요청(로그인 폼 등)을 위한 새로운 보안 토큰 제공
---
## 완전한 로그아웃 구현 예시
```php
<?php
namespace App\Services;
use App\Extension\HookManager;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
class AuthService
{
/**
* 사용자를 로그아웃시키고 현재 디바이스의 인증 토큰만 삭제합니다.
*/
public function logout(User $user): void
{
// Hook 발생 (로그아웃 시작)
HookManager::doAction('core.auth.before_logout', $user);
// 토큰 삭제 처리 (토큰 전용 인증이므로 항상 PersonalAccessToken)
$token = $user->currentAccessToken();
if ($token instanceof \Laravel\Sanctum\PersonalAccessToken) {
$token->delete();
}
// 세션 무효화 (StartApiSession 미들웨어가 세션을 시작한 경우)
if (request()->hasSession() && request()->session()->isStarted() && Auth::guard('web')->check()) {
Auth::guard('web')->logout();
request()->session()->invalidate();
request()->session()->regenerateToken();
}
// Hook 발생 (로그아웃 완료)
HookManager::doAction('core.auth.logout', $user);
}
}
```
---
## 안티 패턴
```php
// ❌ DON'T: EnsureFrontendRequestsAreStateful 사용 (제거됨)
$middleware->api(prepend: [
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
]);
// ❌ DON'T: 세션 존재 확인 없이 무효화 (에러 발생 가능)
Auth::guard('web')->logout(); // 세션이 없으면 에러
// ❌ DON'T: 토큰 삭제 없이 세션만 무효화
request()->session()->invalidate(); // 토큰이 살아있으면 재인증 가능
// ❌ DON'T: 전체 plainTextToken 해시 (DB 값과 불일치)
$hashedToken = hash('sha256', $bearerToken); // 잘못됨
```
---
## 참고 자료
- **구현 파일**: [app/Services/AuthService.php](../../../app/Services/AuthService.php)
- **테스트 파일**: [tests/Feature/Api/Admin/AdminAuthTest.php](../../../tests/Feature/Api/Admin/AdminAuthTest.php)
---
## 관련 문서
- [index.md](index.md) - 백엔드 가이드 인덱스
- [middleware.md](middleware.md) - 미들웨어 등록 규칙 (OptionalSanctumMiddleware 포함)
+478
View File
@@ -0,0 +1,478 @@
# Broadcasting (실시간 이벤트)
> **중요도**: 중요
> **관련 문서**: [service-repository.md](service-repository.md) | [authentication.md](authentication.md)
---
## TL;DR (5초 요약)
```text
1. Laravel Reverb 사용 (WebSocket)
2. 이벤트: ShouldBroadcast 인터페이스 구현
3. 채널: public, private, presence
4. 훅 연동: HookManager에서 broadcast() 호출
5. 클라이언트: Laravel Echo + Pusher-js
```
---
## 목차
1. [개요](#개요)
2. [Laravel Reverb 설정](#laravel-reverb-설정)
3. [브로드캐스트 이벤트 생성](#브로드캐스트-이벤트-생성)
4. [채널 인증](#채널-인증)
5. [API 인증 엔드포인트](#api-인증-엔드포인트)
6. [훅을 통한 이벤트 발생](#훅을-통한-이벤트-발생)
7. [스케줄러를 통한 주기적 브로드캐스트](#스케줄러를-통한-주기적-브로드캐스트)
8. [개발 환경 설정](#개발-환경-설정)
9. [프로덕션 환경 설정](#프로덕션-환경-설정)
---
## 개요
G7은 **Laravel Reverb**를 사용하여 실시간 WebSocket 통신을 지원합니다.
**주요 사용 사례**:
- 대시보드 실시간 통계 업데이트
- 알림 실시간 전송
- 채팅 기능
- 실시간 협업 기능
**아키텍처**:
```
클라이언트 (Echo/Pusher-js)
↕ WebSocket
Laravel Reverb 서버
↕
Laravel 백엔드 (broadcast() 호출)
↕
Queue Worker (이벤트 처리)
```
---
## Laravel Reverb 설정
### 환경변수 (.env)
```env
# Broadcasting 드라이버
BROADCAST_CONNECTION=reverb
# Reverb 서버 설정
REVERB_APP_ID=467955
REVERB_APP_KEY=zm0vobuy4zpqorc3ro9r
REVERB_APP_SECRET=your-secret-key
REVERB_HOST=localhost
REVERB_PORT=8080
REVERB_SCHEME=https
# 개발 환경에서 자체 서명 인증서 사용 시
REVERB_VERIFY_SSL=false
# 클라이언트용 설정 (Vite)
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
```
### config/broadcasting.php
```php
'reverb' => [
'driver' => 'reverb',
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'client_options' => [
// 개발 환경에서 자체 서명 인증서 사용 시 SSL 검증 비활성화
'verify' => env('REVERB_VERIFY_SSL', true),
],
],
```
> **주의**: 프로덕션 환경에서는 `REVERB_VERIFY_SSL=true`로 설정해야 합니다.
---
## 브로드캐스트 이벤트 생성
### 기본 구조
```php
<?php
namespace App\Events\Dashboard;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 대시보드 업데이트 브로드캐스트 이벤트
*/
class DashboardUpdated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 이벤트 인스턴스를 생성합니다.
*
* @param string $type 업데이트 타입 ('stats', 'resources', 'activities')
* @param array $data 업데이트된 데이터
*/
public function __construct(
public string $type,
public array $data
) {}
/**
* 브로드캐스트할 채널을 반환합니다.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [new PrivateChannel('admin.dashboard')];
}
/**
* 브로드캐스트 이벤트명을 반환합니다.
*
* @return string
*/
public function broadcastAs(): string
{
return "dashboard.{$this->type}.updated";
}
}
```
### 채널 타입
| 타입 | 클래스 | 용도 | 인증 필요 |
|------|--------|------|----------|
| Public | `Channel` | 모든 사용자 접근 가능 | ❌ |
| Private | `PrivateChannel` | 인증된 사용자만 접근 | ✅ |
| Presence | `PresenceChannel` | 인증 + 접속자 목록 공유 | ✅ |
### 이벤트 발생
```php
// 방법 1: broadcast() 헬퍼 (권장)
broadcast(new DashboardUpdated('stats', $statsData));
// 방법 2: event() 헬퍼
event(new DashboardUpdated('resources', $resourceData));
// 방법 3: 즉시 전송 (큐 미사용)
broadcast(new DashboardUpdated('alerts', $alertData))->toOthers();
```
---
## 채널 인증
### routes/channels.php
```php
<?php
use Illuminate\Support\Facades\Broadcast;
// 사용자별 Private 채널
Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
return (int) $user->id === (int) $id;
});
// 관리자 대시보드 채널 - 권한 체크
Broadcast::channel('admin.dashboard', function ($user) {
return $user->hasPermission('core.dashboard.read');
});
// 특정 리소스 채널
Broadcast::channel('orders.{orderId}', function ($user, $orderId) {
return $user->hasPermission('ecommerce.orders.read');
});
```
### 인증 콜백 규칙
```php
// ✅ DO: boolean 반환 (Private 채널)
Broadcast::channel('admin.dashboard', function ($user) {
return $user->hasPermission('core.dashboard.read');
});
// ✅ DO: 배열 반환 (Presence 채널 - 사용자 정보 공유)
Broadcast::channel('chat.{roomId}', function ($user, $roomId) {
if ($user->canJoinRoom($roomId)) {
return ['id' => $user->id, 'name' => $user->name];
}
return false;
});
// ❌ DON'T: 예외 던지기
Broadcast::channel('admin.dashboard', function ($user) {
throw new \Exception('Unauthorized'); // 금지
});
```
---
## API 인증 엔드포인트
G7은 SPA 환경에서 **Sanctum 토큰 기반 인증**을 사용합니다. 기본 `/broadcasting/auth` 엔드포인트는 세션 기반이므로, API용 별도 엔드포인트를 사용합니다.
### routes/api.php
```php
// 브로드캐스팅 인증 (Sanctum 토큰 사용)
Route::middleware(['auth:sanctum'])->post('broadcasting/auth', function (\Illuminate\Http\Request $request) {
return \Illuminate\Support\Facades\Broadcast::auth($request);
})->name('api.broadcasting.auth');
```
### 클라이언트 설정 (WebSocketManager)
```typescript
const pusherOptions = {
// ... 기타 옵션
authEndpoint: '/api/broadcasting/auth',
auth: {
headers: {
'Authorization': `Bearer ${authToken}`,
'Accept': 'application/json',
},
},
};
```
---
## 훅을 통한 이벤트 발생
Service 계층에서 데이터 변경 시 훅 리스너를 통해 브로드캐스트 이벤트를 발생시킵니다.
### config/hooks.php
```php
return [
'listeners' => [
// 대시보드 통계 업데이트 시 브로드캐스트
'core.dashboard.stats_updated' => [
\App\Listeners\BroadcastDashboardStats::class,
],
// 사용자 생성 시 대시보드 통계 업데이트
'core.user.after_create' => [
\App\Listeners\UpdateDashboardStatsOnUserChange::class,
],
],
];
```
### 리스너 구현
```php
<?php
namespace App\Listeners;
use App\Events\Dashboard\DashboardUpdated;
use App\Services\DashboardService;
class BroadcastDashboardStats
{
public function __construct(
private DashboardService $dashboardService
) {}
/**
* 대시보드 통계 브로드캐스트
*
* @param array $data 훅 데이터
* @return void
*/
public function handle(array $data): void
{
$stats = $this->dashboardService->getStats();
broadcast(new DashboardUpdated('stats', $stats));
}
}
```
---
## 스케줄러를 통한 주기적 브로드캐스트
시스템 리소스 등 주기적으로 업데이트가 필요한 데이터는 스케줄러를 사용합니다.
### Artisan 커맨드
```php
<?php
namespace App\Console\Commands;
use App\Events\Dashboard\DashboardUpdated;
use App\Services\DashboardService;
use Illuminate\Console\Command;
class BroadcastDashboardResources extends Command
{
protected $signature = 'dashboard:broadcast-resources';
protected $description = '시스템 리소스 정보를 WebSocket으로 브로드캐스트합니다.';
public function handle(DashboardService $dashboardService): int
{
$resources = $dashboardService->getSystemResources();
broadcast(new DashboardUpdated('resources', $resources));
$this->info('시스템 리소스 정보가 브로드캐스트되었습니다.');
return Command::SUCCESS;
}
}
```
### routes/console.php (스케줄러 등록)
```php
use Illuminate\Support\Facades\Schedule;
// 30초마다 시스템 리소스 브로드캐스트
Schedule::command('dashboard:broadcast-resources')->everyThirtySeconds();
```
### 실행 방법
```bash
# 스케줄러 실행 (개발 환경)
php artisan schedule:work
# 큐 워커 실행 (브로드캐스트 이벤트 처리)
php artisan queue:work
# Reverb 서버 실행
php artisan reverb:start --debug
```
---
## 개발 환경 설정
### 필요한 프로세스
개발 환경에서 WebSocket을 테스트하려면 다음 프로세스가 모두 실행 중이어야 합니다:
```bash
# 터미널 1: Laravel 개발 서버
php artisan serve
# 터미널 2: Reverb WebSocket 서버
php artisan reverb:start --debug
# 터미널 3: 큐 워커 (브로드캐스트 이벤트 처리)
php artisan queue:work
# 터미널 4: 스케줄러 (주기적 브로드캐스트 사용 시)
php artisan schedule:work
```
### SSL 인증서 문제 해결
개발 환경에서 자체 서명 인증서 사용 시 다음 설정이 필요합니다:
```env
# .env
REVERB_VERIFY_SSL=false
```
이 설정은 Laravel이 Reverb 서버로 이벤트를 전송할 때 SSL 인증서 검증을 비활성화합니다.
---
## 프로덕션 환경 설정
### 권장 설정
```env
# .env (프로덕션)
REVERB_VERIFY_SSL=true
REVERB_SCHEME=https
REVERB_PORT=443
```
### Supervisor 설정 예시
```ini
[program:reverb]
command=php /var/www/g7/artisan reverb:start
directory=/var/www/g7
user=www-data
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/var/log/reverb.log
[program:queue-worker]
command=php /var/www/g7/artisan queue:work --sleep=3 --tries=3
directory=/var/www/g7
user=www-data
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/var/log/queue-worker.log
```
---
## 디버깅
### 이벤트 전송 확인
```bash
# 수동으로 이벤트 발생
php artisan dashboard:broadcast-resources
# 큐 작업 확인
php artisan queue:work --once
```
### 로그 확인
```bash
# Laravel 로그
tail -f storage/logs/laravel.log
# Reverb 서버 로그 (--debug 옵션 사용 시)
php artisan reverb:start --debug
```
### 클라이언트 디버깅
브라우저 개발자 도구에서:
1. **Network** 탭 → **WS** 필터 → WebSocket 연결 확인
2. **Messages** 탭에서 송수신 메시지 확인
3. **Console** 탭에서 `[WebSocketManager]` 로그 확인
---
## 관련 문서
- [Service-Repository 패턴](service-repository.md) - 훅 실행 위치
- [인증](authentication.md) - Sanctum 토큰 인증
- [프론트엔드 WebSocket](../frontend/data-sources-advanced.md) - 클라이언트 WebSocket 구독
+319
View File
@@ -0,0 +1,319 @@
# 컨트롤러 계층 구조
> **관련 문서**: [index.md](index.md) | [service-repository.md](service-repository.md) | [validation.md](validation.md)
---
## TL;DR (5초 요약)
```text
1. AdminBaseController / AuthBaseController / PublicBaseController 중 상속
2. Service 주입 필수 (Repository 직접 주입 금지)
3. FormRequest로 검증 (컨트롤러에 검증 로직 금지)
4. ResponseHelper 메서드 사용 (success, error, successWithResource)
5. 네이밍: [Entity][Action]Controller (예: UserListController)
6. array_filter 시 null만 제거 (빈 문자열은 사용자 의도로 보존)
```
---
## 목차
1. [Base 컨트롤러 계층](#base-컨트롤러-계층)
2. [각 컨트롤러 역할](#각-컨트롤러-역할)
3. [컨트롤러 네이밍 규칙](#컨트롤러-네이밍-규칙)
4. [응답 메서드 사용](#응답-메서드-사용)
5. [Controller 규칙](#controller-규칙)
6. [패턴 예시](#패턴-예시)
7. [데이터 필터링 패턴 (array_filter)](#데이터-필터링-패턴-array_filter)
8. [체크리스트](#체크리스트)
---
## Base 컨트롤러 계층
G7은 역할 기반의 컨트롤러 계층 구조를 사용합니다.
```text
BaseApiController (최상위)
├── AdminBaseController (관리자 전용)
├── AuthBaseController (인증된 사용자)
└── PublicBaseController (공개 API)
```
---
## 각 컨트롤러 역할
### BaseApiController
**경로**: `app/Http/Controllers/Api/Base/BaseApiController.php`
- 모든 API 컨트롤러의 최상위 부모 클래스
- 공통 응답 메서드 제공: `success()`, `error()`, `successWithResource()`, `notFound()`, `unauthorized()`, `forbidden()`, `validationError()`
- 인증 사용자 조회: `getCurrentUser()`
### AdminBaseController
**경로**: `app/Http/Controllers/Api/Base/AdminBaseController.php`
- BaseApiController 상속
- 관리자 전용 기능
- 미들웨어: `['auth:sanctum', 'admin']`
- 관리자 활동 로깅: `logAdminActivity()`
### AuthBaseController
**경로**: `app/Http/Controllers/Api/Base/AuthBaseController.php`
- BaseApiController 상속
- 인증된 사용자 전용 기능
- 미들웨어: `['auth:sanctum']`
- 리소스 소유권 확인: `isOwner()`, `canAccessResource()`, `checkOwnership()`
- 사용자 활동 로깅: `logUserActivity()`
### PublicBaseController
**경로**: `app/Http/Controllers/Api/Base/PublicBaseController.php`
- BaseApiController 상속
- 인증 불필요한 공개 API
- 미들웨어: `['throttle:60,1']` (속도 제한)
- 캐싱: `cached()`
- API 사용량 추적: `logApiUsage()`, `getClientInfo()`
---
## 컨트롤러 네이밍 규칙
### 코어
| 역할 | 경로 |
|------|------|
| 관리자 | `app/Http/Controllers/Api/Admin/[기능명]Controller.php` |
| 인증 사용자 | `app/Http/Controllers/Api/Auth/[기능명]Controller.php` |
| 공개 API | `app/Http/Controllers/Api/Public/[기능명]Controller.php` |
### 모듈
| 역할 | 경로 |
|------|------|
| 관리자 | `modules/[vendor-module]/src/Controllers/Api/Admin/[기능명]Controller.php` |
| 인증 사용자 | `modules/[vendor-module]/src/Controllers/Api/Auth/[기능명]Controller.php` |
| 공개 API | `modules/[vendor-module]/src/Controllers/Api/[기능명]Controller.php` |
---
## 응답 메서드 사용
모든 컨트롤러는 Base 클래스의 공통 메서드를 사용합니다:
```php
// ✅ DO: 공통 메서드 사용
return $this->success('messages.created', $data, 201);
return $this->error('messages.failed', 400);
return $this->successWithResource('messages.success', new UserResource($user));
return $this->notFound('messages.not_found');
return $this->unauthorized('messages.unauthorized');
return $this->forbidden('messages.forbidden');
return $this->validationError($errors, 'messages.validation_failed');
// ❌ DON'T: 역할별 래퍼 메서드 사용 (Deprecated)
// adminSuccess(), userSuccess(), publicSuccess() 등은 사용하지 않음
```
---
## Controller 규칙
### 원칙
- Service 클래스를 생성자 주입으로 받음
- 비즈니스 로직은 Service에 위임
- ResponseHelper 사용
- 인라인 검증 금지 (FormRequest 사용)
> **참고**: FormRequest 상세 규칙은 [validation.md](validation.md) 참조
---
## 패턴 예시
```php
<?php
namespace Modules\Sirsoft\Ecommerce\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Helpers\ResponseHelper;
use Modules\Sirsoft\Ecommerce\Services\ProductService;
use Modules\Sirsoft\Ecommerce\Requests\StoreProductRequest;
use Modules\Sirsoft\Ecommerce\Requests\UpdateProductRequest;
use Modules\Sirsoft\Ecommerce\Resources\ProductResource;
use Illuminate\Http\JsonResponse;
class ProductController extends AdminBaseController
{
public function __construct(
private ProductService $productService
) {}
/**
* 상품 목록 조회
*/
public function index(): JsonResponse
{
$products = $this->productService->getAllProducts();
return ResponseHelper::success(
ProductResource::collection($products),
'상품 목록을 조회했습니다.'
);
}
/**
* 상품 생성
*/
public function store(StoreProductRequest $request): JsonResponse
{
$product = $this->productService->createProduct($request->validated());
return ResponseHelper::success(
new ProductResource($product),
'상품이 생성되었습니다.',
201
);
}
/**
* 상품 수정
*/
public function update(UpdateProductRequest $request, int $id): JsonResponse
{
$product = $this->productService->updateProduct($id, $request->validated());
return ResponseHelper::success(
new ProductResource($product),
'상품이 수정되었습니다.'
);
}
}
```
---
## 데이터 필터링 패턴 (array_filter)
컨트롤러에서 요청 데이터를 Service에 전달하기 전에 `array_filter`로 불필요한 값을 제거하는 경우, **빈 문자열(`''`)과 `null`의 의미 차이**에 주의해야 합니다.
### null vs 빈 문자열의 의미
| 값 | 의미 | 예시 |
|------|------|------|
| `null` | 필드가 전송되지 않음 (미포함) | JSON body에 키 자체가 없음 |
| `''` (빈 문자열) | 사용자가 의도적으로 값을 비움 | 기존 값을 지우려는 의도 |
### 잘못된 예시 (빈 문자열 제거)
```php
// ❌ DON'T: 빈 문자열도 제거 → 사용자의 "값 비우기" 의도 무시
$data = collect($validated)
->filter(fn ($v) => $v !== null && $v !== '')
->toArray();
```
### 올바른 예시 (null만 제거)
```php
// ✅ DO: null만 제거 → 빈 문자열은 사용자 의도로 보존
$data = collect($validated)
->filter(fn ($v) => $v !== null)
->toArray();
```
### 적용 시점
| 상황 | 권장 패턴 |
|------|----------|
| 설정 Override (기본값 위에 덮어쓰기) | `null`만 제거 (빈 문자열 = 기본값으로 초기화) |
| 일반 CRUD 저장 | `validated()` 그대로 사용 (필터링 불필요) |
| 선택적 필드 업데이트 (PATCH) | `null`만 제거 또는 `filled()` 활용 |
---
## 표준 예외 처리 패턴
컨트롤러에서 Service 호출 시 다음 표준 예외 처리 패턴을 사용합니다:
```php
public function store(StoreProductRequest $request): JsonResponse
{
try {
$product = $this->productService->createProduct($request->validated());
return $this->success('product.create_success', new ProductResource($product), 201);
} catch (ValidationException $e) {
return $this->validationError($e->errors(), 'product.create_failed');
} catch (\Exception $e) {
return $this->error('product.create_failed', 500);
}
}
```
### 예외 유형별 처리
| 예외 유형 | 응답 메서드 | 상태 코드 | 용도 |
| ---------- | ----------- | ---------- | ------ |
| `ValidationException` | `validationError()` | 422 | Service에서 발생한 검증 예외 |
| `AccessDeniedHttpException` | `error()` | 403 | 스코프 접근 거부 |
| 커스텀 비즈니스 예외 | `error()` | 400~409 | 비즈니스 규칙 위반 |
| `\Exception` (범용) | `error()` | 500 | 예상치 못한 오류 |
### 스코프 접근 거부 처리 패턴
```php
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
public function show(int $id): JsonResponse
{
try {
$item = $this->service->getItem($id);
return $this->success('item.fetch_success', new ItemResource($item));
} catch (AccessDeniedHttpException $e) {
return $this->error('auth.scope_denied', 403);
} catch (\Exception $e) {
return $this->error('item.fetch_failed', 500);
}
}
```
### 주의사항
- `ValidationException`은 `\Exception`보다 **먼저** catch해야 함 (하위 클래스 우선)
- 에러 메시지는 반드시 `__()` 다국어 키 사용 (하드코딩 금지)
- `$e->getMessage()`는 **로깅 목적**으로만 사용 (사용자에게 직접 노출 금지)
---
## 체크리스트
### 컨트롤러 개발 체크리스트
- [ ] 적절한 Base 컨트롤러를 상속했는가?
- 관리자 기능 → `AdminBaseController`
- 인증 사용자 기능 → `AuthBaseController`
- 공개 API → `PublicBaseController`
- [ ] Service 클래스를 생성자 주입으로 받았는가?
- [ ] 비즈니스 로직을 Service에 위임했는가?
- [ ] ResponseHelper를 사용해 응답했는가?
- [ ] FormRequest를 사용해 검증했는가? (인라인 검증 금지)
- [ ] 네이밍 규칙을 준수했는가?
---
## 관련 문서
- [index.md](index.md) - 백엔드 가이드 인덱스
- [service-repository.md](service-repository.md) - Service-Repository 패턴
- [validation.md](validation.md) - 검증 로직 구현 원칙
- [response-helper.md](response-helper.md) - API 응답 규칙
+201
View File
@@ -0,0 +1,201 @@
# 코어 설정 (config/core.php)
> **목적**: 코어 시스템의 권한, 역할, 메뉴, 메일 템플릿 정의 파일 구조 설명
---
## TL;DR (5초 요약)
```text
1. config/core.php = 코어 권한/역할/메뉴/메일템플릿의 SSoT (Single Source of Truth)
2. 구조: module(1레벨) → categories(2레벨) → permissions(3레벨) 3단계
3. 설치(Seeder) + 업데이트(CoreUpdateService) 모두 이 파일에서 읽음
4. 모듈/플러그인은 config/core.php 대신 자체 config.php에 동일 구조로 정의
5. 수정 시 Seeder/Sync 로직이 자동 반영 (수동 마이그레이션 불필요)
```
---
## 목차
- [파일 구조 개요](#파일-구조-개요)
- [permissions — 권한 정의](#permissions--권한-정의)
- [roles — 역할 정의](#roles--역할-정의)
- [menus — 메뉴 정의](#menus--메뉴-정의)
- [mail_templates — 메일 템플릿 정의](#mail_templates--메일-템플릿-정의)
- [사용처](#사용처)
- [관련 문서](#관련-문서)
---
## 파일 구조 개요
`config/core.php`는 4개 최상위 키로 구성됩니다:
```php
return [
'permissions' => [...], // 코어 권한 정의
'roles' => [...], // 코어 역할 정의
'menus' => [...], // 코어 메뉴 정의
'mail_templates' => [...], // 코어 메일 템플릿 정의
];
```
---
## permissions — 권한 정의
3레벨 계층 구조:
```text
permissions
├── module (1레벨) → identifier, name, description, order
└── categories (2레벨 배열)
├── category 항목 → identifier, name, description, category, order
└── permissions (3레벨) → identifier, name, description, order, resource_route_key?, owner_key?
```
### module (1레벨)
```php
'module' => [
'identifier' => 'core',
'name' => ['ko' => '코어', 'en' => 'Core'],
'description' => ['ko' => '코어 시스템 권한', 'en' => 'Core system permissions'],
'order' => 1,
],
```
### categories (2레벨) + permissions (3레벨)
```php
'categories' => [
[
'identifier' => 'core.users',
'name' => ['ko' => '사용자 관리', 'en' => 'User Management'],
'description' => [...],
'category' => 'users', // 카테고리 슬러그
'order' => 1,
'permissions' => [
[
'identifier' => 'core.users.read',
'name' => ['ko' => '사용자 조회', 'en' => 'View Users'],
'description' => [...],
'order' => 1,
'resource_route_key' => 'user', // scope 체크 시 라우트 모델 키 (선택)
'owner_key' => 'id', // scope 체크 시 소유자 필드 (선택)
],
],
],
],
```
### 권한 필드 설명
| 필드 | 필수 | 설명 |
| ---------- | ---------- | ---------- |
| `identifier` | ✅ | 권한 식별자 (예: `core.users.read`) |
| `name` | ✅ | 다국어 이름 배열 `['ko' => ..., 'en' => ...]` |
| `description` | ✅ | 다국어 설명 배열 |
| `order` | ✅ | 정렬 순서 |
| `resource_route_key` | ❌ | scope 기반 접근 체크 시 라우트 모델 바인딩 키 |
| `owner_key` | ❌ | scope 기반 접근 체크 시 소유자 판단 필드 |
---
## roles — 역할 정의
```php
'roles' => [
[
'identifier' => 'admin',
'name' => ['ko' => '관리자', 'en' => 'Administrator'],
'description' => [...],
'attributes' => ['is_active' => true],
'permissions' => 'all_leaf', // 특수 값: 모든 리프 권한 할당
],
[
'identifier' => 'manager',
'name' => ['ko' => '매니저', 'en' => 'Manager'],
'permissions' => ['core.users.read', 'core.users.create', ...],
'permission_scopes' => [
'core.users.read' => 'self', // 본인 리소스만
'core.users.update' => 'self',
],
],
],
```
### 역할 필드 설명
| 필드 | 필수 | 설명 |
| ---------- | ---------- | ---------- |
| `identifier` | ✅ | 역할 식별자 (예: `admin`, `manager`) |
| `name` | ✅ | 다국어 이름 배열 |
| `description` | ✅ | 다국어 설명 배열 |
| `attributes` | ❌ | 추가 속성 (`is_active` 등) |
| `permissions` | ✅ | 권한 배열 또는 `'all_leaf'` (모든 리프 권한) |
| `permission_scopes` | ❌ | 권한별 scope_type 매핑 (`'self'` / `'role'` / 미지정=전체) |
---
## menus — 메뉴 정의
```php
'menus' => [
[
'slug' => 'admin-dashboard',
'name' => ['ko' => '대시보드', 'en' => 'Dashboard'],
'url' => '/admin/dashboard',
'icon' => 'fas fa-tachometer-alt',
'parent_id' => null,
'order' => 1,
'is_active' => true,
],
],
```
---
## mail_templates — 메일 템플릿 정의
```php
'mail_templates' => [
[
'type' => 'welcome',
'subject' => ['ko' => '[{app_name}] 환영합니다', 'en' => '...'],
'body' => ['ko' => '<h1>{name}님...</h1>', 'en' => '...'],
'variables' => [
['key' => 'name', 'description' => '수신자 이름'],
['key' => 'app_name', 'description' => '사이트명'],
],
],
],
```
### variables 배열
메일 템플릿에서 사용 가능한 치환 변수를 정의합니다. `{key}` 형태로 subject/body에서 사용됩니다.
---
## 사용처
| 사용처 | 시점 | 설명 |
| ---------- | ---------- | ---------- |
| `RolePermissionSeeder` | 초기 설치 | permissions, roles 데이터 시딩 |
| `CoreAdminMenuSeeder` | 초기 설치 | menus 데이터 시딩 |
| `MailTemplateSeeder` | 초기 설치 | mail_templates 데이터 시딩 |
| `CoreUpdateService::syncCoreRolesAndPermissions()` | 코어 업데이트 | 권한/역할 동기화 |
| `CoreUpdateService::syncCoreMenus()` | 코어 업데이트 | 메뉴 동기화 |
| `CoreUpdateService::syncCoreMailTemplates()` | 코어 업데이트 | 메일 템플릿 동기화 |
**핵심 원칙**: 새 권한/역할/메뉴/메일템플릿 추가 시 이 파일에 추가하면 설치/업데이트 모두 자동 반영됩니다.
---
## 관련 문서
- [permissions.md](../extension/permissions.md) - 권한 시스템 상세
- [module-basics.md](../extension/module-basics.md) - 모듈별 config.php 구조
- [service-provider.md](service-provider.md) - 서비스 프로바이더에서 config 로드
+740
View File
@@ -0,0 +1,740 @@
# 코어 업데이트 시스템 (Core Update System)
> 코어 버전 업데이트의 감지, 다운로드, 적용, 롤백, 업그레이드 스텝 실행을 관리하는 시스템
## TL;DR (5초 요약)
```text
1. 코어 업그레이드 스텝: upgrades/ 디렉토리 (프로젝트 루트), 네임스페이스 App\Upgrades
2. 마이그레이션 = 스키마 변경만, 데이터 백필/변환 = 업그레이드 스텝 (역할 분리 필수)
3. 업데이트 실행 흐름: 11단계 (감지 → 다운로드 → 백업 → 적용 → 마이그레이션 → 동기화 → 업그레이드 → 마무리)
4. 롤백: CoreBackupHelper로 백업 생성, 실패 시 자동 복원
5. 부트스트랩 호환성 검증: .env의 APP_VERSION < 확장 g7_version 시 자동 비활성화 (1시간 캐시)
```
---
## 목차
1. [업데이트 감지](#1-업데이트-감지)
2. [업데이트 실행 흐름 (11단계)](#2-업데이트-실행-흐름-11단계)
3. [백업 및 롤백](#3-백업-및-롤백)
4. [마이그레이션 vs 업그레이드 스텝](#4-마이그레이션-vs-업그레이드-스텝)
5. [업그레이드 스텝 작성](#5-업그레이드-스텝-작성)
6. [자동 발견 및 실행 규칙](#6-자동-발견-및-실행-규칙)
7. [버전 관리](#7-버전-관리)
8. [CoreUpdateService 주요 메서드](#8-coreupdateservice-주요-메서드)
9. [Artisan 커맨드](#9-artisan-커맨드)
10. [API 엔드포인트](#10-api-엔드포인트)
11. [설정 (config/app.php)](#11-설정-configappphp)
12. [에러 처리 및 로깅](#12-에러-처리-및-로깅)
13. [유지보수 모드](#13-유지보수-모드)
14. [코어 vs 확장 업데이트 비교](#14-코어-vs-확장-업데이트-비교)
---
## 1. 업데이트 감지
> 파일: `app/Extension/CoreVersionChecker.php`, `app/Services/CoreUpdateService.php`
### 감지 흐름
```text
1. CoreVersionChecker::getCoreVersion() → config('app.version') 읽기
2. CoreUpdateService::checkForUpdates() → GitHub API로 최신 릴리스 조회
3. version_compare(current, latest) → 업데이트 가용 여부 판단
4. 원격 CHANGELOG 캐시 → storage/app/temp/core_remote_changelog.md
```
### GitHub API 통합
```text
- config('app.update.github_url') 에서 리포지토리 URL 읽기
- config('app.update.github_token') 으로 인증 (선택)
- GitHub Releases API 호출 → 최신 릴리스 태그에서 버전 추출
```
### 감지 결과
```php
[
'update_available' => true,
'current_version' => '7.0.0-alpha.14',
'latest_version' => '7.0.0-alpha.15',
'check_failed' => false, // GitHub API 실패 시 true
]
```
---
## 2. 업데이트 실행 흐름 (11단계)
> 파일: `app/Console/Commands/Core/CoreUpdateCommand.php`
```text
┌─ Step 1: 업데이트 확인 (GitHub API 또는 --source/--local 모드)
├─ Step 2: _pending 경로 검증 (디렉토리 존재/쓰기 권한)
├─ Step 3: 유지보수 모드 활성화 (--no-maintenance 시 스킵)
├─ Step 4: 다운로드 (GitHub zipball 또는 --source/--local에서 복사)
├─ Step 5: 백업 생성 (--no-backup 시 스킵)
├─ Step 6: Composer install (_pending에서 실행, 변경 없으면 스킵)
├─ Step 7: 파일 적용 (_pending → base_path 선택적 덮어쓰기)
├─ Step 8: vendor 복사 (_pending/vendor → base_path/vendor, Step 6 스킵 시 함께 스킵)
├─ Step 9: 마이그레이션 + 동기화 (migrate, roles, permissions, menus, mail templates)
├─ Step 10: 업그레이드 스텝 실행 (upgrades/Upgrade_X_Y_Z.php)
└─ Step 11: 마무리 (.env 버전 갱신, 캐시 클리어, _pending 삭제, 유지보수 해제)
```
### Step 1: 업데이트 확인
| 모드 | 동작 |
|------|------|
| 일반 | `checkForUpdates()` → GitHub API 호출 → 사용자 확인 프롬프트 |
| `--source={path}` | 지정된 디렉토리를 소스로 사용 (GitHub 스킵) |
| `--local` | 현재 코드베이스를 소스로 사용 (GitHub 스킵) |
| `--force` | 버전 비교 스킵, 동일 버전이어도 강제 실행 |
### Step 4: 다운로드 및 추출
```text
추출 전략 (폴백 체인):
1. ZipArchive (PHP zip 확장) → class_exists(ZipArchive::class)
2. unzip CLI 명령어 → ZipArchive 미사용 시 폴백
v접두사 자동 감지 (resolveGithubArchiveUrl):
1. "v{version}" 태그로 HEAD 요청 시도 (예: v7.0.0-alpha.15)
2. 실패 시 "{version}" 태그로 HEAD 요청 시도 (예: 7.0.0-alpha.15)
3. HTTP 200/302 → 유효한 URL 반환
4. 모두 실패 → null (업데이트 불가)
```
### Step 5: 백업
```text
- CoreBackupHelper::createBackup() 사용
- 백업 대상: config('app.update.targets') + config('app.update.backup_only') + config('app.update.backup_extra')
- 제외 패턴: config('app.update.excludes')
- 백업 위치: storage 경로에 타임스탬프 디렉토리
```
### Step 6: Composer 최적화
```text
1. composer.json + composer.lock의 MD5 비교 (_pending vs base_path)
2. 동일 → "composer 의존성 변경 없음 — 스킵" (Step 6 + Step 8 모두 스킵)
3. 변경됨 → composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
4. --no-scripts: post-autoload-dump 방지 (Step 11에서 package:discover로 처리)
```
### Step 7: 파일 적용
```text
- _pending 소스에서 config('app.update.targets')에 해당하는 파일/디렉토리만 선택적 덮어쓰기
- FilePermissionHelper::copyDirectory() 사용 → 원본 파일 권한 보존
- ExtensionPendingHelper::copyToActive() 미사용 (권한 유실 방지)
```
### Step 9: 마이그레이션 + 동기화
```text
1. php artisan migrate --force
2. syncCoreRolesAndPermissions() — config/core.php의 roles/permissions 동기화
3. syncCoreMenus() — config/core.php의 menus 동기화
4. syncCoreMailTemplates() — config/core.php의 mail_templates 동기화
```
> 역할/권한/메뉴 동기화는 `user_overrides` 패턴을 사용하여 사용자 커스터마이징 보존.
> 상세: [extension-update-system.md § 13](../extension/extension-update-system.md#13-역할권한메뉴-동기화)
### Step 10: 업그레이드 스텝
```text
1. upgrades/ 디렉토리 스캔
2. Upgrade_X_Y_Z.php 패턴 매칭 → 버전 변환
3. fromVersion < stepVersion <= toVersion 범위 필터링
4. version_compare 자연 정렬 (오름차순)
5. 각 스텝 순차 실행 (UpgradeContext 전달)
```
### Step 11: 마무리
```text
1. .env의 APP_VERSION 갱신
2. 캐시 클리어: config, cache, route, view
3. bootstrap/cache 파일 삭제 (services.php, packages.php)
4. php artisan package:discover 재실행
5. php artisan extension:update-autoload (코어 업데이트로 _bundled 변경 가능)
6. _pending 디렉토리 삭제
7. 성공 시 백업 삭제
8. 유지보수 모드 해제
```
---
## 3. 백업 및 롤백
> 파일: `app/Extension/Helpers/CoreBackupHelper.php`
### 백업 생성
```text
- CoreBackupHelper::createBackup() 호출
- 대상: config('app.update.targets') + config('app.update.backup_only') + config('app.update.backup_extra')
- 제외: config('app.update.excludes')
- 저장: storage 경로에 타임스탬프 디렉토리 (Ymd_His)
```
### 롤백 흐름 (실패 시)
```text
1. Exception 발생
2. 백업이 존재하면:
├─ CoreUpdateService::restoreFromBackup($backupPath)
├─ CoreBackupHelper::restoreFromBackup() → 파일 원복
└─ _pending 디렉토리 삭제
3. 실패 보고서 생성: storage/logs/core_update_failure_YYYYMMDD_HHMMSS.log
4. 업데이트 로그 저장: storage/logs/core_update_failed_YYYYMMDD_HHMMSS.log
5. 유지보수 모드 유지 (수동으로 php artisan up 필요)
```
### 롤백 실패 시
```text
- 복원 실패 자체는 예외를 전파하지 않음 (에러 로그만 기록)
- 관리자에게 안내: "이전 버전으로 사이트를 운영하려면: php artisan up"
- 유지보수 모드 bypass secret 출력
```
---
## 4. 마이그레이션 vs 업그레이드 스텝
```text
주의: 마이그레이션과 업그레이드 스텝의 역할을 혼동하지 않을 것
```
| 구분 | 마이그레이션 (`database/migrations/`) | 업그레이드 스텝 (`upgrades/`) |
|------|--------------------------------------|-------------------------------|
| **역할** | DB 스키마 변경 (컬럼 추가/삭제/변경, 인덱스, 테이블) | 데이터 백필/변환, 설정 마이그레이션 |
| **실행 시점** | `php artisan migrate` (설치/업데이트 모두) | `php artisan core:update` Step 10 (업데이트 시만) |
| **신규 설치** | 실행됨 | 실행되지 않음 (fromVersion == toVersion) |
| **버전 업그레이드** | 실행됨 | 실행됨 (범위 내 스텝만) |
| **예시** | `$table->uuid('uuid')->nullable()->after('id')` | 기존 레코드 UUID 백필 + NOT NULL 변환 |
### 역할 분리 원칙
```text
✅ 마이그레이션: 테이블/컬럼/인덱스 생성·변경·삭제 (스키마)
✅ 업그레이드 스텝: 기존 데이터 변환, 백필, 설정 구조 변경 (데이터)
❌ 마이그레이션에 데이터 백필 로직 포함 금지
❌ 업그레이드 스텝에 스키마 변경 포함 금지 (Schema::table 등)
단, 백필 후 NOT NULL 제약 추가처럼 데이터 완결성에 필수인 경우는 예외
```
### 신규 설치 시 데이터 초기화
신규 설치 시 업그레이드 스텝은 실행되지 않으므로, 모델의 `boot()` 이벤트나 Seeder에서 초기 데이터를 생성해야 합니다.
```php
// 예: User 모델 — 신규 레코드 생성 시 UUID 자동 할당
protected static function boot(): void
{
parent::boot();
static::creating(function (self $user) {
if (empty($user->uuid)) {
$user->uuid = app(UniqueIdServiceInterface::class)->generateUuid();
}
});
}
```
### 업그레이드 스텝이 필요한 경우
| 변경 유형 | 업그레이드 스텝 필요 | 예시 |
|----------|-------------------|------|
| 기존 데이터 백필/변환 | ✅ | UUID 백필, 형식 변환 |
| NOT NULL 제약 추가 (백필 후) | ✅ (예외) | 스키마이지만 데이터 완결성 의존 |
| 권한/역할/메뉴 추가·수정 | ❌ (자동 동기화) | config/core.php 수정만으로 충분 |
| 정적 권한/메뉴 제거 | ✅ (cleanup 명시 호출) | 기존 메뉴/권한 삭제 |
| PHP 코드만 변경 | ❌ | 버그 수정, 성능 개선 |
---
## 5. 업그레이드 스텝 작성
### 디렉토리 구조
```text
upgrades/ # 프로젝트 루트
├── .gitkeep
├── Upgrade_7_0_0_beta_4.php # 7.0.0-alpha.4
└── Upgrade_7_0_0_beta_15.php # 7.0.0-alpha.15
```
### 작성 예시
```php
<?php
namespace App\Upgrades;
use App\Contracts\Extension\UpgradeStepInterface;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
/**
* 코어 7.0.0-alpha.15 업그레이드 스텝
*
* 기존 사용자 레코드에 UUID v7을 백필합니다.
*/
class Upgrade_7_0_0_beta_15 implements UpgradeStepInterface
{
public function run(UpgradeContext $context): void
{
// 1. 사전 조건 확인
if (! Schema::hasColumn('users', 'uuid')) {
$context->logger->warning('uuid 컬럼 없음. 마이그레이션을 먼저 실행하세요.');
return;
}
// 2. 데이터 백필
$nullCount = DB::table('users')->whereNull('uuid')->count();
if ($nullCount > 0) {
$context->logger->info("UUID 백필 시작: {$nullCount}건");
DB::table('users')->whereNull('uuid')->orderBy('id')->chunk(100, function ($users) {
foreach ($users as $user) {
DB::table('users')
->where('id', $user->id)
->update(['uuid' => Str::orderedUuid()->toString()]);
}
});
$context->logger->info("UUID 백필 완료");
}
// 3. 백필 완료 후 NOT NULL 제약 (데이터 완결성 예외)
// raw SQL에서는 $context->table()로 프리픽스 적용 필수
$column = DB::selectOne("SHOW COLUMNS FROM {$context->table('users')} WHERE Field = 'uuid'");
if ($column && $column->Null === 'YES') {
Schema::table('users', function ($table) {
$table->uuid('uuid')->nullable(false)->unique()->change();
});
}
}
}
```
### 작성 체크리스트
```text
□ UpgradeStepInterface 구현
□ 네임스페이스: App\Upgrades
□ 파일명: Upgrade_X_Y_Z.php (버전에 맞는 언더스코어 표기)
□ 사전 조건 확인 (테이블/컬럼 존재 여부)
□ 로거를 통한 진행 상황 기록
□ 멱등성 보장 (여러 번 실행해도 안전)
□ 대량 데이터는 chunk() 사용
□ raw SQL 사용 시 $context->table()로 테이블 프리픽스 적용
```
---
## 6. 자동 발견 및 실행 규칙
> 파일: `app/Services/CoreUpdateService.php` — `runUpgradeSteps()`
### 발견 프로세스
```text
1. base_path('upgrades') 디렉토리 스캔
2. .php 확장자 파일만 대상
3. 정규식 매칭: /^Upgrade_(\d+)_(\d+)_(\d+)(?:_([a-zA-Z]\w*(?:_\d+)*))?$/
4. 버전 변환: 1_0_0 → 1.0.0, 7_0_0_beta_4 → 7.0.0-alpha.4
5. require_once 로딩 → App\Upgrades\{filename} 클래스 인스턴스화
6. UpgradeStepInterface 구현 확인
```
### 실행 규칙
- **필터 조건**: `fromVersion < stepVersion <= toVersion` (`version_compare` 사용)
- **정렬**: `uksort($steps, 'version_compare')` — 자연 정렬 오름차순
- **실행**: 각 스텝에 `UpgradeContext::withCurrentStep($version)` 불변 복제 전달
### 버전 네이밍 규칙
파일명의 언더스코어가 버전 구분자로 변환됩니다:
| 파일명 | 변환 버전 | 설명 |
|--------|----------|------|
| `Upgrade_1_0_0.php` | `1.0.0` | 숫자 3자리 |
| `Upgrade_7_0_0_beta_4.php` | `7.0.0-alpha.4` | pre-release 포함 |
| `Upgrade_7_0_0_beta_15.php` | `7.0.0-alpha.15` | pre-release 2자리 |
**규칙**: 숫자 3자리(`X_Y_Z`) 후 알파벳으로 시작하는 부분은 `-`로 연결, 이후 `_`는 `.`로 변환
### UpgradeStepInterface
> 파일: `app/Contracts/Extension/UpgradeStepInterface.php`
```php
interface UpgradeStepInterface
{
public function run(UpgradeContext $context): void;
}
```
### UpgradeContext
> 파일: `app/Extension/UpgradeContext.php`
```php
// readonly 속성
public readonly LoggerInterface $logger; // upgrade 로그 채널
public readonly string $fromVersion; // 업그레이드 시작 버전
public readonly string $toVersion; // 업그레이드 목표 버전
public readonly string $currentStep; // 현재 실행 중인 스텝 버전
// 메서드
public function table(string $table): string // DB 프리픽스 적용 (raw SQL용)
public function withCurrentStep(string $stepVersion): self // 불변 복제
```
> **raw SQL에서 테이블명 사용 시 `$context->table()` 필수**
> - `Schema::hasColumn('users', ...)`, `DB::table('users')` → 프리픽스 자동 적용 (불필요)
> - `DB::selectOne("SHOW COLUMNS FROM ...")` 등 raw SQL → **`$context->table('users')` 필수**
---
## 7. 버전 관리
### 버전 소스
| 파일 | 역할 |
|------|------|
| `config/app.php` — `'version'` | SSoT (env 기본값 정의) |
| `.env` — `APP_VERSION` | 런타임 오버라이드 |
| `.env.example` | 배포 템플릿 |
### 버전 읽기
```php
CoreVersionChecker::getCoreVersion() // → config('app.version')
```
### 버전 갱신 (Step 11)
```php
CoreUpdateService::updateVersionInEnv($version)
// .env에 APP_VERSION이 존재하면: 정규식으로 라인 교체
// .env에 APP_VERSION이 없으면: "APP_VERSION={version}\n" 추가
```
### 버전 변경 시 필수 동기화
```text
필수: 코어 버전 변경 시 3곳 동기화
1. config/app.php — env('APP_VERSION', '새버전')
2. .env.example — APP_VERSION=새버전
3. .env.testing — APP_VERSION=새버전 (테스트 환경)
4. CHANGELOG.md — 변경사항 기록
```
### 부트스트랩 호환성 자동 검증
> 파일: `app/Providers/CoreServiceProvider.php` — `validateAndDeactivateIncompatibleExtensions()`, `validateAndDeactivateIncompatibleTemplates()`
**애플리케이션 부트(boot) 시마다** CoreServiceProvider가 모든 활성 확장의 `g7_version` 요구사항을 검증합니다.
`config('app.version')`이 확장의 요구 버전을 충족하지 않으면 **해당 확장을 자동으로 비활성화**합니다.
#### 검증 흐름
```text
1. CoreServiceProvider::boot() 실행
2. CoreVersionChecker::getCacheKey($type) 캐시 확인 (TTL: 1시간)
3. 캐시 없으면 → 모든 활성 확장 순회
4. 각 확장의 g7_version (예: ">=7.0.0-beta.1") 확인
5. Semver::satisfies(config('app.version'), $constraint) 실행
6. false 반환 시 → Manager::deactivateModule/Plugin/Template() 호출
7. 캐시 저장 (1시간 유효)
```
#### 주의사항
| 항목 | 설명 |
|------|------|
| **검증 주기** | 캐시 만료(1시간)마다 재검증 |
| **비활성화 대상** | 호환되지 않는 **모든** 활성 확장 (모듈, 플러그인, 템플릿) |
| **activity_log** | 기록되지 않음 (Service 레이어를 거치지 않고 Manager 직접 호출) |
| **로그** | `storage/logs/laravel.log`에 warning 기록 |
#### 버전 확인 방법
```bash
php artisan tinker
> \Composer\Semver\Semver::satisfies(config('app.version'), '>=7.0.0-beta.1');
```
#### 버전 우선순위
```text
config('app.version') = env('APP_VERSION', 'config/app.php 기본값')
.env에 APP_VERSION이 있으면 → .env 값 사용 (config/app.php 기본값 무시)
.env에 APP_VERSION이 없으면 → config/app.php의 기본값 사용
```
---
## 8. CoreUpdateService 주요 메서드
> 파일: `app/Services/CoreUpdateService.php`
### 업데이트 감지
| 메서드 | 시그니처 | 설명 |
|--------|---------|------|
| `checkForUpdates()` | `(): array` | GitHub API로 최신 릴리스 조회 |
| `getChangelog()` | `(?string $from, ?string $to): array` | CHANGELOG.md 파싱, 버전 범위 필터 |
| `checkSystemRequirements()` | `(): array` | 추출 도구 검증 (ZipArchive/unzip) |
| `validatePendingPath()` | `(): array` | _pending 디렉토리 존재/권한 확인 |
### 다운로드 및 소스 준비
| 메서드 | 시그니처 | 설명 |
|--------|---------|------|
| `downloadUpdate()` | `(string $version, ?Closure $onProgress): string` | GitHub 다운로드 + 추출 (폴백 체인) |
| `copySourceToPending()` | `(string $sourceDir, ?Closure $onProgress): string` | --source 모드: 외부 디렉토리 → _pending 복사 |
| `prepareLocalSource()` | `(?Closure $onProgress): string` | --local 모드: 현재 코드 → _pending 복사 |
| `validatePendingUpdate()` | `(string $pendingPath): void` | 패키지 구조 검증 (composer.json, app/, config/app.php) |
| `createPendingDirectory()` | `(): string` | _pending/core_Ymd_His/ 디렉토리 생성 |
| `cleanupPending()` | `(string $pendingPath): void` | _pending 하위 디렉토리 삭제 |
### 백업 및 복원
| 메서드 | 시그니처 | 설명 |
|--------|---------|------|
| `createBackup()` | `(?Closure $onProgress): string` | CoreBackupHelper로 백업 생성 |
| `restoreFromBackup()` | `(string $backupPath, ?Closure $onProgress): void` | 백업에서 파일 복원 |
### 적용 및 설치
| 메서드 | 시그니처 | 설명 |
|--------|---------|------|
| `applyUpdate()` | `(string $sourcePath, ?Closure $onProgress): void` | _pending → base_path 선택적 덮어쓰기 |
| `runComposerInstallInPending()` | `(string $pendingPath, ?Closure $onProgress): void` | _pending에서 composer install (--no-scripts) |
| `isComposerUnchangedForCore()` | `(string $pendingPath): bool` | composer.json/lock MD5 비교 |
| `runComposerInstall()` | `(?Closure $onProgress): void` | base_path에서 composer install |
| `copyVendorFromPending()` | `(string $pendingPath, ?Closure $onProgress): void` | _pending/vendor → base_path/vendor |
### 마이그레이션 및 동기화
| 메서드 | 시그니처 | 설명 |
|--------|---------|------|
| `runMigrations()` | `(): void` | `php artisan migrate --force` |
| `syncCoreRolesAndPermissions()` | `(): void` | config/core.php roles/permissions 동기화 |
| `syncCoreMenus()` | `(): void` | config/core.php menus 동기화 |
| `syncCoreMailTemplates()` | `(): void` | config/core.php mail_templates 동기화 (user_overrides 보존) |
### 업그레이드 및 마무리
| 메서드 | 시그니처 | 설명 |
|--------|---------|------|
| `runUpgradeSteps()` | `(string $from, string $to, ?Closure $onStep): void` | upgrades/ 자동 발견 + 실행 |
| `updateVersionInEnv()` | `(string $version): void` | .env의 APP_VERSION 갱신 |
| `clearAllCaches()` | `(): void` | config/cache/route/view 클리어 + package:discover |
### 유지보수 모드
| 메서드 | 시그니처 | 설명 |
|--------|---------|------|
| `enableMaintenanceMode()` | `(): string` | `php artisan down --secret` → bypass secret 반환 |
| `disableMaintenanceMode()` | `(): void` | `php artisan up` |
### 유틸리티
| 메서드 | 시그니처 | 설명 |
|--------|---------|------|
| `generateFailureReport()` | `(Throwable $e, string $from, string $to): string` | 에러 보고서 생성 → storage/logs/ |
---
## 9. Artisan 커맨드
### core:update
```bash
php artisan core:update [--force] [--no-backup] [--no-maintenance] [--local] [--source={path}]
```
| 옵션 | 설명 |
|------|------|
| `--force` | 버전 비교 스킵, 동일 버전이어도 강제 업데이트 |
| `--no-backup` | 백업 생성 스킵 (Step 5) |
| `--no-maintenance` | 유지보수 모드 스킵 (Step 3) |
| `--local` | 현재 코드베이스를 소스로 사용 (GitHub 스킵) |
| `--source={path}` | 지정 디렉토리를 소스로 사용 (GitHub 스킵) |
**종료 코드**:
- `0` (SUCCESS): 업데이트 성공 또는 이미 최신 버전
- `1` (FAILURE): 요건 미충족 또는 업데이트 실패
### core:check-updates
```bash
php artisan core:check-updates
```
- GitHub API 호출 → 현재/최신 버전 표시
- "새로운 업데이트가 있습니다!" 또는 "현재 최신 버전입니다."
---
## 10. API 엔드포인트
> 파일: `app/Http/Controllers/Api/Admin/CoreUpdateController.php`
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/admin/core-update/check-updates` | 코어 업데이트 확인 (422 시 check_failed) |
| POST | `/api/admin/core-update/changelog` | 코어 변경 로그 조회 (from_version, to_version) |
> ActivityLog: `checkForUpdates` 호출 시 `core_update.check` 액티비티 기록
---
## 11. 설정 (config/app.php)
### 버전
```php
'version' => env('APP_VERSION', '7.0.0-alpha.14'),
```
### 업데이트 설정
```php
'update' => [
'github_url' => '...', // GitHub 리포지토리 URL
'github_token' => env('GITHUB_TOKEN'), // GitHub Personal Access Token (선택)
'pending_path' => '...', // _pending 디렉토리 경로
'targets' => [...], // 업데이트 적용 + 백업 대상 파일/디렉토리
'backup_only' => ['vendor'], // 백업/복원 전용 (applyUpdate 제외)
'backup_extra' => [...], // 추가 백업 대상
'excludes' => [...], // 제외 패턴
],
```
---
## 12. 에러 처리 및 로깅
### 시스템 요건 검증
```text
- ZipArchive 또는 unzip 명령어 중 하나 이상 필요
- 미충족 시 커맨드 실패 (EXIT 1) + 사용 가능 방법 목록 출력
```
### _pending 검증
```text
- 디렉토리 미존재: File::ensureDirectoryExists() 시도
- 쓰기 불가: 경로, 소유자, 그룹, 권한 정보 포함 에러 출력
```
### 패키지 검증
```text
- 필수 파일: composer.json, app/ 디렉토리, config/app.php
- config/app.php에 'version' 키 필수
- 미충족 시 RuntimeException
```
### Composer 실패
```text
- exit code 비정상 → RuntimeException (마지막 5줄 출력 포함)
```
### 로그 위치
| 로그 | 경로 | 내용 |
|------|------|------|
| 성공 로그 | `storage/logs/core_update_success_YYYYMMDD_HHMMSS.log` | 전체 실행 타임라인 |
| 실패 로그 | `storage/logs/core_update_failed_YYYYMMDD_HHMMSS.log` | 실패까지의 실행 타임라인 |
| 실패 보고서 | `storage/logs/core_update_failure_YYYYMMDD_HHMMSS.log` | 예외 상세 + 시스템 정보 |
| 일반 로그 | `storage/logs/laravel.log` | Log 파사드 통한 기록 |
---
## 13. 유지보수 모드
### 활성화 (Step 3)
```php
Artisan::call('down', [
'--secret' => $secret, // UUID 토큰 (bypass 접근용)
'--retry' => 60, // Retry-After 헤더 (초)
'--refresh' => 15, // 브라우저 자동 새로고침 (초)
]);
```
### Bypass 접근
```text
URL: https://example.com/{secret}
→ 쿠키 발급 → 유지보수 모드에서도 접근 가능
```
### 해제
```text
- 성공 시: Step 11에서 자동 해제 (php artisan up)
- 실패 시: 유지보수 모드 유지 → 관리자에게 수동 해제 안내
"이전 버전으로 사이트를 운영하려면: php artisan up"
```
---
## 14. 코어 vs 확장 업데이트 비교
| 항목 | 코어 | 모듈/플러그인 | 템플릿 |
|------|------|-------------|--------|
| **소스** | GitHub releases (zipball) | GitHub 또는 _bundled | GitHub 또는 _bundled |
| **추출** | 폴백 체인 (ZipArchive → unzip) | ExtensionPendingHelper | ExtensionPendingHelper |
| **백업** | CoreBackupHelper (선택) | ExtensionBackupHelper (선택) | ExtensionBackupHelper (선택) |
| **유지보수 모드** | 전체 앱 down (secret 토큰) | 없음 | 없음 |
| **Composer** | MD5 비교 → 변경 시만 실행 | 확장별 독립 vendor/ | 해당 없음 |
| **파일 적용** | FilePermissionHelper (권한 보존) | ExtensionPendingHelper::copyToActive() | ExtensionPendingHelper::copyToActive() |
| **vendor 처리** | 변경 시 전체 복사 | 확장별 composer install | 해당 없음 |
| **마이그레이션** | 필수 (Step 9) | 선택 (확장별) | 없음 |
| **동기화** | roles + permissions + menus + mail templates | roles + permissions + menus | 없음 |
| **업그레이드 스텝** | `upgrades/Upgrade_X_Y_Z.php` | `{ext}/upgrades/Upgrade_X_Y_Z.php` | 없음 |
| **네임스페이스** | `App\Upgrades` | `Modules\Vendor\Module\Upgrades` | 해당 없음 |
| **실행 주체** | `CoreUpdateService` | `AbstractModule` | `TemplateManager` |
| **커맨드** | `core:update` | `module:update {id}` | `template:update {id}` |
| **레이아웃** | 해당 없음 | 자동 갱신 | 충돌 전략 (apply_new/keep_current) |
| **롤백** | 전체 복원 + 유지보수 유지 | 확장별 복원 + 상태 복원 | 확장별 복원 |
| **로깅** | 타임스탬프 성공/실패 로그 + 실패 보고서 | laravel.log | laravel.log |
---
## 참고 파일 위치
| 파일 | 경로 |
|------|------|
| CoreUpdateCommand | `app/Console/Commands/Core/CoreUpdateCommand.php` |
| CoreCheckUpdatesCommand | `app/Console/Commands/Core/CoreCheckUpdatesCommand.php` |
| CoreUpdateService | `app/Services/CoreUpdateService.php` |
| CoreUpdateController | `app/Http/Controllers/Api/Admin/CoreUpdateController.php` |
| CoreVersionChecker | `app/Extension/CoreVersionChecker.php` |
| CoreBackupHelper | `app/Extension/Helpers/CoreBackupHelper.php` |
| UpgradeStepInterface | `app/Contracts/Extension/UpgradeStepInterface.php` |
| UpgradeContext | `app/Extension/UpgradeContext.php` |
| 코어 업그레이드 디렉토리 | `upgrades/` |
| 코어 설정 | `config/app.php` (version, update 섹션) |
| 역할/권한/메뉴 정의 | `config/core.php` |
+343
View File
@@ -0,0 +1,343 @@
# Enum 사용 규칙
> **관련 문서**: [백엔드 가이드 인덱스](./index.md) | [검증 로직](./validation.md)
---
## TL;DR (5초 요약)
```text
1. 상태/타입/분류 = Enum 필수 (PHP 8.1+ Backed Enum)
2. 위치: app/Enums/ 또는 modules/*/src/Enums/
3. DB: enum 컬럼 또는 string + 검증
4. FormRequest: Rule::enum(StatusEnum::class) 사용
5. cases(): 모든 값 조회, tryFrom(): 안전한 변환
```
---
## 목차
- [핵심 원칙](#핵심-원칙)
- [Enum 사용 대상](#enum-사용-대상)
- [잘못된 예시](#잘못된-예시)
- [올바른 예시](#올바른-예시)
- [파일 위치](#파일-위치)
- [데이터베이스 연동](#데이터베이스-연동)
- [FormRequest 검증에서 사용](#formrequest-검증에서-사용)
- [개발 체크리스트](#개발-체크리스트)
---
## 핵심 원칙
```text
상태, 타입, 분류 등 제한된 값 집합은 반드시 Enum으로 정의
✅ 필수: PHP 8.1+ Backed Enum 사용
```
**핵심 원칙**:
- **타입 안전성**: 문자열/정수 대신 Enum 사용으로 타입 안전성 확보
- **자동완성**: IDE의 자동완성 지원으로 개발 생산성 향상
- **유지보수**: 값 변경 시 한 곳에서만 수정
- **유효성 검증**: 잘못된 값 입력 방지
---
## Enum 사용 대상
| 상황 | 예시 | Enum 사용 |
|------|------|----------|
| 상태 값 | active, inactive, pending | ✅ 필수 |
| 타입 구분 | template, module, plugin | ✅ 필수 |
| 고정 옵션 | admin, user, guest | ✅ 필수 |
| 가변 데이터 | 사용자 입력, 동적 값 | ❌ 불필요 |
---
## 잘못된 예시
```php
// ❌ 문자열 직접 사용 - 타입 안전성 없음
class Module
{
public function setStatus(string $status): void
{
$this->status = $status; // 'actve' 오타 가능
}
}
// ❌ 상수로 정의 - 타입 체크 불가
class ModuleStatus
{
public const ACTIVE = 'active';
public const INACTIVE = 'inactive';
}
$module->setStatus('invalid'); // 오류 없이 통과
```
---
## 올바른 예시
```php
// ✅ Backed Enum 정의
<?php
namespace App\Enums;
/**
* 확장(모듈, 플러그인, 템플릿) 상태 Enum
*/
enum ExtensionStatus: string
{
/**
* 활성화됨
*/
case Active = 'active';
/**
* 비활성화됨
*/
case Inactive = 'inactive';
/**
* 설치 중
*/
case Installing = 'installing';
/**
* 제거 중
*/
case Uninstalling = 'uninstalling';
/**
* 제거됨 (미설치 상태)
*/
case Uninstalled = 'uninstalled';
/**
* 업데이트 중
*/
case Updating = 'updating';
/**
* 모든 상태 값을 문자열 배열로 반환
*
* @return array<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 유효한 상태 값인지 확인
*/
public static function isValid(string $value): bool
{
return in_array($value, self::values(), true);
}
}
// ✅ 타입 힌팅으로 안전하게 사용
class Module
{
public function setStatus(ExtensionStatus $status): void
{
$this->status = $status->value;
}
}
$module->setStatus(ExtensionStatus::Active); // 타입 안전
$module->setStatus('invalid'); // 컴파일 오류!
```
---
## 파일 위치
```text
app/Enums/ # 코어 Enum
├── ExtensionStatus.php # 확장 상태
├── LayoutSourceType.php # 레이아웃 소스 타입
├── ScopeType.php # 권한 스코프 타입
└── UserRole.php # 사용자 역할
modules/[vendor-module]/src/Enums/ # 모듈 Enum
```
### ScopeType Enum — 권한 스코프
역할별 권한의 접근 범위를 정의합니다. `role_permissions` 피벗 테이블의 `scope_type` 컬럼에서 사용됩니다.
```php
enum ScopeType: string
{
/** 본인 리소스만 접근 */
case Self = 'self';
/** 소유 역할 공유 사용자의 리소스 접근 */
case Role = 'role';
}
// null = 전체 접근 (제한 없음)
```
**scope_type 값과 의미**:
| 값 | 의미 | 목록 필터링 | 상세 접근 체크 |
| ---- | -------------- | ----------------------------------------- | -------------- |
| `null` | 전체 접근 | 필터 미적용 | 항상 통과 |
| `'self'` | 본인 리소스만 | `WHERE {owner_key} = {user_id}` | 소유자 일치 체크 |
| `'role'` | 소유역할 범위 | `WHERE {owner_key} IN (역할 공유 사용자)` | 역할 공유 체크 |
**union 정책** (복수 역할 보유 시): `null`(전체) > `'role'`(소유역할) > `'self'`(본인) — 가장 넓은 범위 적용
> 상세: [permissions.md](../extension/permissions.md) scope_type 시스템 참조
---
## 데이터베이스 연동
```php
// 마이그레이션에서 Enum 값 사용
use App\Enums\ExtensionStatus;
$table->string('status')->default(ExtensionStatus::Inactive->value)
->comment('상태: ' . implode(', ', ExtensionStatus::values()));
// 모델에서 캐스팅
protected $casts = [
'status' => ExtensionStatus::class,
];
```
---
## FormRequest 검증에서 사용
```php
use App\Enums\ExtensionStatus;
use Illuminate\Validation\Rule;
public function rules(): array
{
return [
'status' => ['required', Rule::enum(ExtensionStatus::class)],
];
}
```
---
## Enum 표준 헬퍼 메서드
G7에서 Enum에 추가하는 표준 헬퍼 메서드입니다.
### 필수 메서드
```php
enum UserStatus: string
{
case Active = 'active';
case Inactive = 'inactive';
case Blocked = 'blocked';
case Withdrawn = 'withdrawn';
/**
* 모든 상태 값을 문자열 배열로 반환
*
* @return array<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 유효한 값인지 확인
*
* @param string $value 검증할 값
* @return bool
*/
public static function isValid(string $value): bool
{
return in_array($value, self::values(), true);
}
/**
* 다국어 라벨 반환
*
* @return string
*/
public function label(): string
{
return __('user.status.' . $this->value);
}
}
```
### 선택 메서드 (UI 연동 시)
```php
/**
* CSS variant 반환 (Badge, Tag 등에서 사용)
*
* @return string
*/
public function variant(): string
{
return match ($this) {
self::Active => 'success',
self::Inactive => 'warning',
self::Blocked => 'danger',
self::Withdrawn => 'secondary',
};
}
/**
* 아이콘 클래스 반환
*
* @return string
*/
public function icon(): string
{
return match ($this) {
self::Active => 'fa-check-circle',
self::Inactive => 'fa-pause-circle',
self::Blocked => 'fa-ban',
self::Withdrawn => 'fa-user-slash',
};
}
```
### 메서드 요약
| 메서드 | 유형 | 필수 | 용도 |
| ------ | ------ | ------ | ------ |
| `values()` | static | ✅ | 마이그레이션 comment, 검증 등 |
| `isValid()` | static | ✅ | 값 유효성 검증 |
| `label()` | instance | ✅ | API 응답의 다국어 라벨 |
| `variant()` | instance | 선택 | UI Badge/Tag 스타일 매핑 |
| `icon()` | instance | 선택 | UI 아이콘 매핑 |
---
## 개발 체크리스트
- [ ] Backed Enum 사용 (string 또는 int)
- [ ] 각 case에 한국어 PHPDoc 주석 추가
- [ ] `values()` 헬퍼 메서드 구현
- [ ] `isValid()` 헬퍼 메서드 구현 (필요시)
- [ ] 마이그레이션 comment에 가능한 값 명시
- [ ] 모델에서 Enum 캐스팅 설정
---
## 관련 문서
- [검증 로직](./validation.md) - FormRequest에서 Enum 검증
- [데이터베이스 가이드](../database-guide.md) - 마이그레이션 규칙
- [백엔드 가이드 인덱스](./index.md) - 전체 목차
+322
View File
@@ -0,0 +1,322 @@
# Custom Exception 다국어 처리
> 이 문서는 G7에서 Custom Exception 클래스의 다국어 처리 방법을 설명합니다.
---
## TL;DR (5초 요약)
```text
1. 예외 메시지 하드코딩 금지 → __() 함수 필수
2. 메시지 위치: lang/ko/exceptions.php, lang/en/exceptions.php
3. 파라미터 치환: __('exceptions.key', ['param' => $value])
4. 코어 예외: exceptions.template_engine.* 형식
5. 모듈 예외: vendor-module::exceptions.* 형식
```
---
## 목차
1. [핵심 원칙](#핵심-원칙)
2. [다국어 파일 위치 및 구조](#다국어-파일-위치-및-구조)
3. [파라미터 치환 패턴](#파라미터-치환-패턴)
4. [템플릿 엔진 Custom Exception 예시](#템플릿-엔진-custom-exception-예시)
5. [Custom Exception 개발 체크리스트](#custom-exception-개발-체크리스트)
6. [관련 문서](#관련-문서)
---
## 핵심 원칙
```
필수: __() 함수 사용 (예외 메시지 하드코딩 금지)
필수: __() 함수를 사용한 다국어 처리
```
### 필수 규칙
- **모든 예외 메시지는 다국어 파일에 정의**: `/lang/ko/exceptions.php`, `/lang/en/exceptions.php`
- **Exception 생성자에서 __() 함수 사용**: 하드코딩된 문자열 사용 금지
- **파라미터 치환 지원**: `:trace`, `:max`, `:id` 등의 동적 값 지원
- **일관성 유지**: 모든 커스텀 예외가 동일한 패턴 사용
---
## 다국어 파일 위치 및 구조
### 파일 위치
```
/lang/ko/exceptions.php # 한국어 예외 메시지
/lang/en/exceptions.php # 영어 예외 메시지
```
### 파일 구조 예시
```php
// /lang/ko/exceptions.php
return [
'circular_reference' => '레이아웃 순환 참조 감지: :trace',
'max_depth_exceeded' => '레이아웃 중첩 깊이가 최대 허용 깊이(:max)를 초과했습니다.',
'resource_not_found' => ':resource를 찾을 수 없습니다.',
];
// /lang/en/exceptions.php
return [
'circular_reference' => 'Layout circular reference detected: :trace',
'max_depth_exceeded' => 'Layout nesting depth exceeds maximum allowed depth (:max).',
'resource_not_found' => ':resource not found.',
];
```
### 중첩 구조 (네임스페이스 그룹화)
```php
// /lang/ko/exceptions.php
return [
'template' => [
'circular_reference' => '레이아웃 순환 참조 감지: :trace',
'max_depth_exceeded' => '레이아웃 중첩 깊이(:current)가 최대 허용 깊이(:max)를 초과했습니다.',
'template_not_found' => '템플릿을 찾을 수 없습니다: :identifier',
'layout_not_found' => '레이아웃을 찾을 수 없습니다: :layout_name',
'component_not_found' => '컴포넌트를 찾을 수 없습니다: :component_name',
'invalid_layout_structure' => '유효하지 않은 레이아웃 구조입니다.',
],
];
// /lang/en/exceptions.php
return [
'template' => [
'circular_reference' => 'Layout circular reference detected: :trace',
'max_depth_exceeded' => 'Layout nesting depth (:current) exceeds maximum allowed depth (:max).',
'template_not_found' => 'Template not found: :identifier',
'layout_not_found' => 'Layout not found: :layout_name',
'component_not_found' => 'Component not found: :component_name',
'invalid_layout_structure' => 'Invalid layout structure.',
],
];
```
---
## 파라미터 치환 패턴
### 잘못된 예시 (DON'T)
```php
// ❌ 하드코딩된 예외 메시지 - 금지
class CircularReferenceException extends Exception
{
public function __construct(array $stack, string $currentLayout)
{
$stackTrace = implode(' → ', $stack) . " → {$currentLayout}";
$message = "레이아웃 순환 참조 감지: {$stackTrace}"; // 한국어만 지원, 다국어 불가
parent::__construct($message);
}
}
```
### 올바른 예시 (DO)
```php
// ✅ 다국어 함수 사용 (파라미터 치환)
class CircularReferenceException extends Exception
{
private array $stack;
public function __construct(array $stack, string $currentLayout)
{
$this->stack = $stack;
// ✅ 다국어 함수 사용 (파라미터 치환)
$stackTrace = implode(' → ', $stack) . " → {$currentLayout}";
$message = __('exceptions.circular_reference', ['trace' => $stackTrace]);
parent::__construct($message);
}
public function getStack(): array
{
return $this->stack;
}
}
```
### 다중 파라미터 처리
```php
class MaxDepthExceededException extends Exception
{
public function __construct(int $currentDepth, int $maxDepth)
{
// ✅ 다국어 함수 사용 (다중 파라미터)
$message = __('exceptions.max_depth_exceeded', [
'current' => $currentDepth,
'max' => $maxDepth
]);
parent::__construct($message);
}
}
```
---
## 템플릿 엔진 Custom Exception 예시
### CircularReferenceException (레이아웃 순환 참조)
템플릿 레이아웃 상속 시 순환 참조가 발생하면 이 예외를 발생시킵니다.
```php
<?php
namespace App\Exceptions\Template;
use Exception;
/**
* 레이아웃 순환 참조 예외
*/
class CircularReferenceException extends Exception
{
private array $stack;
public function __construct(array $stack, string $currentLayout)
{
$this->stack = $stack;
// ✅ 다국어 함수 사용
$stackTrace = implode(' → ', $stack) . " → {$currentLayout}";
$message = __('exceptions.template.circular_reference', ['trace' => $stackTrace]);
parent::__construct($message);
}
/**
* 순환 참조 스택 반환
*/
public function getStack(): array
{
return $this->stack;
}
}
```
### MaxDepthExceededException (레이아웃 깊이 초과)
레이아웃 상속 깊이가 최대 허용 깊이(10)를 초과하면 이 예외를 발생시킵니다.
```php
<?php
namespace App\Exceptions\Template;
use Exception;
/**
* 레이아웃 최대 깊이 초과 예외
*/
class MaxDepthExceededException extends Exception
{
private int $currentDepth;
private int $maxDepth;
public function __construct(int $currentDepth, int $maxDepth = 10)
{
$this->currentDepth = $currentDepth;
$this->maxDepth = $maxDepth;
// ✅ 다국어 함수 사용 (다중 파라미터)
$message = __('exceptions.template.max_depth_exceeded', [
'current' => $currentDepth,
'max' => $maxDepth
]);
parent::__construct($message);
}
/**
* 현재 깊이 반환
*/
public function getCurrentDepth(): int
{
return $this->currentDepth;
}
/**
* 최대 허용 깊이 반환
*/
public function getMaxDepth(): int
{
return $this->maxDepth;
}
}
```
### Service에서 사용 예시 (LayoutService)
```php
<?php
namespace App\Services\Template;
use App\Exceptions\Template\CircularReferenceException;
use App\Exceptions\Template\MaxDepthExceededException;
class LayoutService
{
private const MAX_DEPTH = 10;
/**
* 레이아웃 병합 (상속 체인 처리)
*
* @throws CircularReferenceException 순환 참조 발생 시
* @throws MaxDepthExceededException 깊이 초과 시
*/
public function mergeLayouts(string $layoutName, array $stack = [], int $depth = 0): array
{
// 순환 참조 검사
if (in_array($layoutName, $stack)) {
throw new CircularReferenceException($stack, $layoutName);
}
// 깊이 검사
if ($depth > self::MAX_DEPTH) {
throw new MaxDepthExceededException($depth, self::MAX_DEPTH);
}
// 재귀적으로 레이아웃 병합
// ...
}
}
```
---
## Custom Exception 개발 체크리스트
새로운 Custom Exception을 개발할 때 다음 항목을 확인하세요:
- [ ] `/lang/ko/exceptions.php`에 한국어 메시지 추가
- [ ] `/lang/en/exceptions.php`에 영어 메시지 추가
- [ ] Exception 생성자에서 모든 메시지는 `__()` 함수 사용
- [ ] 동적 값은 파라미터 배열로 전달 (예: `['trace' => $stackTrace]`)
- [ ] 두 언어 모두에서 테스트 수행
- [ ] 예외 메시지가 사용자에게 노출될 경우 보안 정보 포함 금지
### 새 Exception 추가 절차
1. **다국어 키 정의**: 적절한 네임스페이스로 그룹화
2. **Exception 클래스 생성**: `__()` 함수로 메시지 생성
3. **필요한 속성 저장**: 디버깅이나 로깅에 필요한 값 보관
4. **getter 메서드 제공**: 저장된 속성에 접근할 수 있도록 제공
5. **테스트 작성**: 두 언어 환경에서 메시지 확인
---
## 관련 문서
- [validation.md](./validation.md) - Custom Rule 다국어 처리
- [index.md](./index.md) - 백엔드 가이드 인덱스
+395
View File
@@ -0,0 +1,395 @@
# 미들웨어 등록 규칙
> **참조**: [백엔드 가이드 인덱스](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
```
---
## 목차
- [핵심 원칙](#핵심-원칙)
- [미들웨어 실행 순서](#미들웨어-실행-순서)
- [미들웨어 등록 방식](#미들웨어-등록-방식)
- [올바른 등록 예시](#올바른-등록-예시)
- [문제 상황과 해결책](#문제-상황과-해결책)
- [디버깅 방법](#디버깅-방법)
- [미들웨어 개발 체크리스트](#미들웨어-개발-체크리스트)
- [관련 파일](#관련-파일)
---
## 핵심 원칙
```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()` | 라우트 지정 시 | ✅ 가능 | 권한 체크 등 |
### append vs appendToGroup 차이
| 구분 | `append()` | `appendToGroup()` |
|------|-----------|-------------------|
| **등록 위치** | 전역 미들웨어 스택 | 특정 그룹 미들웨어 스택 |
| **실행 시점** | 모든 요청의 최초 | 그룹 미들웨어 순서에 따름 |
| **인증 상태** | 인증 전 | 인증 후 (Sanctum 이후) |
| **적용 범위** | 모든 라우트 | 해당 그룹(web/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
// 권한만 체크 (scope는 DB 설정에 따라 자동 적용)
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');
```
### 목록 엔드포인트 필터링
미들웨어는 모델 바인딩이 없는 목록 엔드포인트를 **통과**시킵니다.
목록 필터링은 **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)
+185
View File
@@ -0,0 +1,185 @@
# 알림 시스템 (Notification System)
> 그누보드7 알림 시스템의 아키텍처와 확장 가이드
## TL;DR (5초 요약)
```text
1. 모든 알림은 BaseNotification 상속 필수 (via() 보일러플레이트 제거)
2. getHookPrefix() + getNotificationType() 구현 → 훅명 자동 생성
3. toMail()은 각 서브클래스가 직접 구현 (Laravel 규약)
4. 각 모듈은 자체 Mailable 클래스 소유 (AuthNotificationMail, BoardNotificationMail)
5. 채널 확장: 플러그인이 Filter 훅으로 via() 채널 추가
```
---
## 아키텍처 개요
### BaseNotification 추상 클래스
```text
BaseNotification (abstract)
├── getHookPrefix(): string → 'core.auth', 'sirsoft-board', 'sirsoft-ecommerce'
├── getNotificationType(): string → 'welcome', 'new_comment', 'order_confirmed'
└── via(): array → HookManager::applyFilters() 자동 호출
```
**위치**: `app/Notifications/BaseNotification.php`
**역할**: `via()` 메서드만 추상화. `toMail()`은 각 서브클래스가 직접 구현.
### 훅 이름 규칙
```text
{hookPrefix}.notification.channels
```
| 모듈 | hookPrefix | 훅명 |
|------|-----------|------|
| 코어 인증 | `core.auth` | `core.auth.notification.channels` |
| 게시판 | `sirsoft-board` | `sirsoft-board.notification.channels` |
| 이커머스 | `sirsoft-ecommerce` | `sirsoft-ecommerce.notification.channels` |
---
## 코어 알림 클래스
### 인증 알림 (app/Notifications/Auth/)
| 클래스 | hookPrefix | notificationType | Mailable |
|--------|-----------|-----------------|----------|
| WelcomeNotification | `core.auth` | `welcome` | AuthNotificationMail |
| ResetPasswordNotification | `core.auth` | `reset_password` | AuthNotificationMail |
| PasswordChangedNotification | `core.auth` | `password_changed` | AuthNotificationMail |
### 게시판 알림 (modules/sirsoft-board/src/Notifications/)
| 클래스 | hookPrefix | notificationType | Mailable |
|--------|-----------|-----------------|----------|
| NewCommentNotification | `sirsoft-board` | `new_comment` | BoardNotificationMail |
| ReplyCommentNotification | `sirsoft-board` | `reply_comment` | BoardNotificationMail |
| PostReplyNotification | `sirsoft-board` | `post_reply` | BoardNotificationMail |
| PostActionNotification | `sirsoft-board` | `post_action` | BoardNotificationMail |
| NewPostAdminNotification | `sirsoft-board` | `new_post_admin` | BoardNotificationMail |
---
## 새 알림 클래스 작성법
### 1. BaseNotification 상속
```php
namespace Modules\Sirsoft\Ecommerce\Notifications;
use App\Notifications\BaseNotification;
use Modules\Sirsoft\Ecommerce\Mail\EcommerceNotificationMail;
class OrderConfirmedNotification extends BaseNotification
{
public function __construct(
private Order $order
) {}
protected function getHookPrefix(): string
{
return 'sirsoft-ecommerce';
}
protected function getNotificationType(): string
{
return 'order_confirmed';
}
public function toMail(object $notifiable): EcommerceNotificationMail
{
return new EcommerceNotificationMail(
type: 'order_confirmed',
order: $this->order,
recipient: $notifiable
);
}
}
```
### 2. Mailable 클래스는 모듈별 소유
각 모듈은 자체 Mailable 클래스를 만듭니다. 데이터 형태가 모듈마다 근본적으로 다르기 때문입니다.
| 모듈 | Mailable | 주요 프로퍼티 |
|------|----------|-------------|
| 코어 인증 | AuthNotificationMail | type, recipient, actionUrl, extraData |
| 게시판 | BoardNotificationMail | type, board, post, comment, recipient, commentAuthorName, actionType |
| 이커머스 | (직접 구현) | type, order, recipient, ... |
---
## 채널 확장 (플러그인)
### Filter 훅으로 채널 추가
```php
// plugins/sirsoft-site-notification/src/Listeners/SiteNotificationListener.php
class SiteNotificationListener implements HookListenerInterface
{
public function getSubscribedHooks(): array
{
return [
'core.auth.notification.channels' => [
'method' => 'addDatabaseChannel',
'type' => 'filter', // type: 'filter' 필수!
],
];
}
public function addDatabaseChannel(array $channels, string $type, object $notifiable): array
{
$channels[] = 'database';
return $channels;
}
}
```
### 커스텀 채널 클래스 (FCM, 카카오톡 등)
```php
// via()에 FQCN 추가 → Laravel이 자동 resolve
$channels[] = \Plugins\Sirsoft\Push\Channels\FcmChannel::class;
```
커스텀 채널 클래스는 `send(object $notifiable, Notification $notification)` 메서드를 구현합니다.
---
## 테스트
### BaseNotificationTest
**위치**: `tests/Unit/Notifications/BaseNotificationTest.php`
| 테스트 | 검증 항목 |
|--------|----------|
| extends_laravel_notification | Notification 상속 확인 |
| via_returns_mail_channel_by_default | 기본 ['mail'] 반환 |
| hook_can_add_channels | Filter 훅으로 채널 추가 |
| hook_receives_notification_type | 훅에 type 전달 확인 |
| hook_receives_notifiable | 훅에 notifiable 전달 확인 |
| hook_name_format | 훅 이름 형식 확인 |
| board_module_hook_prefix | 모듈 훅 접두사 확인 |
### 기존 알림 테스트 (수정 없이 통과)
- `WelcomeNotificationTest` (6건)
- `ResetPasswordNotificationTest` (7건)
- `PasswordChangedNotificationTest` (6건)
---
## 금지 사항
```text
❌ BaseNotification 없이 Notification 직접 상속
❌ via()에서 HookManager 직접 호출 (BaseNotification이 처리)
❌ Mailable 추상화 (모듈별 데이터 형태가 다름)
❌ 훅 type: 'filter' 누락 (반환값 무시됨)
```
+595
View File
@@ -0,0 +1,595 @@
# API 응답 규칙 (ResponseHelper)
> **관련 문서**: [index.md](index.md) | [controllers.md](controllers.md) | [api-resources.md](api-resources.md)
---
## TL;DR (5초 요약)
```text
1. 모든 API 응답은 ResponseHelper 사용
2. 인수 순서: success($messageKey, $data) - 메시지가 먼저!
3. 데이터 소스 API: dataSource() 메서드 사용
4. 페이지네이션: paginated() 또는 LengthAwarePaginator
5. 에러: error($messageKey, $statusCode)
```
---
## 목차
1. [ResponseHelper 사용](#responsehelper-사용)
2. [데이터 소스용 API](#데이터-소스용-api-)
3. [응답 구조](#응답-구조)
4. [프론트엔드 바인딩](#프론트엔드-바인딩)
5. [구현 예시](#구현-예시)
6. [인증 처리](#인증-처리)
7. [성능 최적화](#성능-최적화)
8. [에러 처리](#에러-처리)
9. [페이지네이션 처리](#페이지네이션-처리-)
10. [라우트/쿼리 파라미터 처리](#라우트쿼리-파라미터-처리)
11. [체크리스트](#체크리스트)
12. [안티 패턴 vs 모범 사례](#안티-패턴-vs-모범-사례)
13. [HTTP 캐시 (ETag, 304 Not Modified)](#http-캐시-etag-304-not-modified)
---
## ResponseHelper 사용
### 규칙
- 모든 API 응답은 ResponseHelper 사용
- 일관된 응답 구조 유지
### 메서드 시그니처
```
주의: 인수 순서 확인!
success($messageKey, $data) - 메시지가 먼저!
❌ success($data, $messageKey) - 잘못된 순서
```
```php
// 성공 응답
ResponseHelper::success(
string $messageKey = 'messages.success', // 첫 번째: 메시지 키
mixed $data = null, // 두 번째: 데이터
int $statusCode = 200,
array $messageParams = [],
string $domain = 'core'
);
// 오류 응답
ResponseHelper::error(
string $messageKey = 'messages.failed', // 첫 번째: 메시지 키
int $statusCode = 400,
mixed $errors = null,
array $messageParams = [],
string $domain = 'core'
);
// 검증 오류
ResponseHelper::validationError($errors, $messageKey = 'messages.validation_failed');
// 404 응답
ResponseHelper::notFound($messageKey = 'messages.not_found');
// 401 응답
ResponseHelper::unauthorized($messageKey = 'messages.unauthorized');
// 403 응답
ResponseHelper::forbidden($messageKey = 'messages.forbidden');
// 500 응답
ResponseHelper::serverError($messageKey = 'messages.error_occurred', $error = null);
```
### 사용 예시
```php
// ✅ 올바른 사용
return ResponseHelper::success('messages.success', $data);
return ResponseHelper::success('plugins.settings.updated', $settings);
// ❌ 잘못된 사용 - TypeError 발생!
return ResponseHelper::success($data, 'messages.success'); // 인수 순서 틀림
```
---
## 데이터 소스용 API ⭐
**정의**: 템플릿 레이아웃 JSON의 `data_sources`에서 호출되는 API 엔드포인트
**목적**:
- 프론트엔드 레이아웃 렌더링 시 필요한 데이터 제공
- DataSourceManager를 통한 자동 fetch 지원
- Progressive loading을 통한 빠른 초기 렌더링
### 필수 요구사항
```
필수: 데이터 소스용 API는 아래 규칙 준수
필수: ResponseHelper 사용
✅ 필수: 인증 필요 시 auth_required: true 설정
✅ 권장: 응답 시간 200ms 이내
```
---
## 응답 구조
데이터 소스용 API는 반드시 `ResponseHelper::success()`를 사용하여 다음 형태로 응답해야 합니다:
```php
// 표준 응답 구조
return ResponseHelper::success('messages.success', $data);
// 실제 응답 JSON
{
"success": true,
"data": {
// 실제 데이터 (이 부분이 프론트엔드에서 {{dataSourceId.data}}로 바인딩됨)
},
"message": null,
"error": null
}
```
---
## 프론트엔드 바인딩
```json
{
"data_sources": [
{
"id": "admin_menu",
"endpoint": "/api/admin/menus"
}
],
"components": [
{
"props": {
"menu": "{{admin_menu.data}}"
}
}
]
}
```
위 예시에서 `{{admin_menu.data}}`는 API 응답의 `data` 필드를 참조합니다.
---
## 구현 예시
### 기본 컨트롤러 구현
```php
<?php
namespace App\Http\Controllers\Admin;
use App\Helpers\ResponseHelper;
use App\Services\MenuService;
use Illuminate\Http\JsonResponse;
class MenuController extends AdminBaseController
{
public function __construct(
private MenuService $menuService
) {}
/**
* 관리자 메뉴 목록 조회 (데이터 소스용)
*/
public function index(): JsonResponse
{
// 1. 서비스에서 데이터 조회
$menus = $this->menuService->getAdminMenus();
// 2. ResponseHelper로 응답 (필수)
return ResponseHelper::success('messages.success', $menus);
}
}
```
### API 리소스 사용 예시
```php
<?php
namespace App\Http\Controllers\Admin\Auth;
use App\Helpers\ResponseHelper;
use App\Http\Resources\Admin\UserResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
class AuthController extends AdminBaseController
{
/**
* 현재 인증된 사용자 정보 조회 (데이터 소스용)
*/
public function user(): JsonResponse
{
$user = Auth::user();
// API 리소스로 변환 후 ResponseHelper 사용
return ResponseHelper::success('messages.success', new UserResource($user));
}
}
```
---
## 인증 처리
데이터 소스에서 `auth_required: true`로 설정된 경우:
```json
{
"id": "current_user",
"type": "api",
"endpoint": "/api/admin/auth/user",
"auth_required": true
}
```
백엔드에서는 라우트에 인증 미들웨어를 반드시 적용해야 합니다:
```php
// routes/api.php
Route::middleware(['auth:sanctum', 'type:admin'])->group(function () {
Route::get('/admin/auth/user', [AuthController::class, 'user']);
Route::get('/admin/menus', [MenuController::class, 'index']);
Route::get('/admin/notifications', [NotificationController::class, 'index']);
});
```
---
## 성능 최적화
데이터 소스 API는 레이아웃 로딩 시 자동으로 호출되므로 성능이 중요합니다:
```php
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
class MenuService
{
/**
* 관리자 메뉴 조회 (캐싱 적용)
*/
public function getAdminMenus(): array
{
// 1. 캐시 사용 (권장)
return Cache::remember('admin_menus', 3600, function () {
return $this->repository->getActiveMenus();
});
}
/**
* N+1 쿼리 방지
*/
public function getMenusWithPermissions(): array
{
// 2. Eager Loading 사용
return $this->repository
->with(['permissions', 'children'])
->where('active', true)
->get()
->toArray();
}
}
```
---
## 에러 처리
데이터 소스 API에서 에러 발생 시:
```php
public function index(): JsonResponse
{
try {
$data = $this->service->getData();
return ResponseHelper::success('messages.success', $data);
} catch (\Exception $e) {
// 에러 로그 기록
Log::error('Failed to fetch data source', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
// 클라이언트에 에러 응답
return ResponseHelper::error(
__('messages.failed_to_load_data'),
500
);
}
}
```
프론트엔드에서는:
- `loading_strategy: "progressive"`: 에러 발생 시에도 렌더링 계속 (빈 데이터)
- `loading_strategy: "blocking"`: 에러 발생 시 빈 데이터로 렌더링
- `DataSourceManagerOptions.onError` 콜백으로 에러 핸들링
---
## 페이지네이션 처리 ⭐
목록 API에서 페이지네이션 사용 시, 반드시 `pagination` 객체로 분리하여 반환:
```php
public function index(Request $request): JsonResponse
{
$perPage = $request->input('per_page', 15);
$page = $request->input('page', 1);
$users = $this->userService->getPaginatedUsers($perPage, $page);
// ✅ 표준 페이지네이션 응답 구조
return ResponseHelper::success('messages.success', [
'data' => $users->items(),
'pagination' => [
'total' => $users->total(),
'from' => $users->firstItem() ?? 0,
'to' => $users->lastItem() ?? 0,
'per_page' => $users->perPage(),
'current_page' => $users->currentPage(),
'last_page' => $users->lastPage(),
],
]);
}
```
### 프론트엔드 바인딩 경로
```json
{
"text": "$t:admin.users.pagination_info|total={{users?.data?.pagination?.total ?? 0}}|from={{users?.data?.pagination?.from ?? 0}}|to={{users?.data?.pagination?.to ?? 0}}"
}
```
> **주의**: `data.pagination.total`이 올바른 경로입니다. `data.data.total`이 아님!
---
## 라우트/쿼리 파라미터 처리
데이터 소스에서 파라미터 바인딩:
```json
{
"id": "product_detail",
"endpoint": "/api/admin/products/{{route.id}}",
"params": {
"include": "{{query.include}}"
}
}
```
백엔드 컨트롤러:
```php
public function show(Request $request, int $id): JsonResponse
{
$include = $request->input('include', []);
$product = $this->productService->getProductById($id, $include);
if (!$product) {
return ResponseHelper::notFound('messages.product_not_found');
}
return ResponseHelper::success('messages.success', $product);
}
```
---
## 체크리스트
데이터 소스용 API 구현 시 반드시 확인:
```
□ ResponseHelper::success() 사용
□ auth_required: true 시 인증 미들웨어 적용
□ 응답 시간 200ms 이내 (캐싱, Eager Loading 등)
□ N+1 쿼리 방지
□ API 리소스로 데이터 변환 (일관성)
□ 에러 핸들링 (try-catch)
□ 페이지네이션 표준 구조
□ 로깅 (에러 발생 시)
```
---
## 안티 패턴 vs 모범 사례
### 안티 패턴
```php
// ❌ DON'T: ResponseHelper 미사용
public function index(): JsonResponse
{
$data = $this->service->getData();
return response()->json(['data' => $data]); // 구조 불일치
}
// ❌ DON'T: 직접 배열 반환
public function index(): array
{
return $this->service->getData(); // JsonResponse 타입 위반
}
// ❌ DON'T: 인증 미들웨어 누락
// auth_required: true인데 라우트에 인증 미들웨어 없음
Route::get('/admin/menus', [MenuController::class, 'index']); // 취약점
// ❌ DON'T: 느린 응답 (500ms+)
public function index(): JsonResponse
{
$data = $this->repository->all(); // N+1 쿼리 발생
return ResponseHelper::success('messages.success', $data);
}
```
### 모범 사례
```php
// ✅ DO: 완전한 구현 예시
<?php
namespace App\Http\Controllers\Admin;
use App\Helpers\ResponseHelper;
use App\Http\Resources\Admin\MenuResource;
use App\Services\MenuService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
class MenuController extends AdminBaseController
{
public function __construct(
private MenuService $menuService
) {}
/**
* 관리자 메뉴 목록 조회
*
* 데이터 소스에서 사용:
* - endpoint: /api/admin/menus
* - auth_required: true
* - loading_strategy: progressive
*/
public function index(): JsonResponse
{
try {
// 1. 서비스에서 캐시된 데이터 조회
$menus = $this->menuService->getAdminMenus();
// 2. API 리소스로 변환 (일관성)
$resource = MenuResource::collection($menus);
// 3. ResponseHelper로 응답 (필수)
return ResponseHelper::success('messages.success', $resource);
} catch (\Exception $e) {
// 4. 에러 로그 기록
Log::error('Failed to load admin menus', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
// 5. 에러 응답 (프론트엔드에서 처리)
return ResponseHelper::error(
__('messages.failed_to_load_menus'),
500
);
}
}
}
```
---
## HTTP 캐시 (ETag, 304 Not Modified)
정적 데이터나 변경 빈도가 낮은 API에는 HTTP 캐시를 적용하여 전송 효율성을 높일 수 있습니다.
### successWithCache 사용
`BaseApiController`에서 제공하는 `successWithCache()` 메서드를 사용합니다:
```php
// 캐시 헤더와 함께 성공 응답 반환
return $this->successWithCache(
'messages.success', // 메시지 키
$data, // 응답 데이터
3600 // 캐시 TTL (초, 기본: 1시간)
);
```
### 동작 방식
1. **첫 요청**: ETag 생성 → 200 OK + `ETag`, `Cache-Control` 헤더
2. **재요청**: 클라이언트가 `If-None-Match` 헤더로 ETag 전송
3. **ETag 일치**: 304 Not Modified (본문 없음, 헤더만)
4. **ETag 불일치**: 새 데이터와 함께 200 OK
### 응답 헤더
```http
HTTP/1.1 200 OK
Cache-Control: max-age=3600, public
ETag: "7d74afa3f6f8c8a3992772a87b3475b1"
Vary: Accept-Encoding, Accept-Language
```
### 적용 대상
| 적합 | 부적합 |
| ---------------- | ------------------ |
| 레이아웃 JSON | 실시간 데이터 |
| 메뉴 목록 | 사용자별 데이터 |
| 설정/환경값 | 자주 변경되는 목록 |
| 번역 데이터 | 인증 정보 |
### PublicLayoutController 예시
```php
<?php
namespace App\Http\Controllers\Api\Public;
use App\Http\Controllers\Api\Base\PublicBaseController;
class PublicLayoutController extends PublicBaseController
{
private const CACHE_TTL = 3600;
public function serve(string $templateId, string $layoutName): JsonResponse|Response
{
// 서버 측 캐싱
$mergedLayout = $this->cached(
"layout.{$templateId}.{$layoutName}",
fn () => $this->layoutService->loadAndMergeLayout($templateId, $layoutName),
self::CACHE_TTL
);
// HTTP 캐시 헤더 (ETag, Cache-Control) 포함 응답
return $this->successWithCache(
'messages.layout_served',
$mergedLayout,
self::CACHE_TTL
);
}
}
```
### 관련 메서드 (BaseApiController)
| 메서드 | 설명 |
| ---------------------------------------------- | -------------------------------- |
| `generateETag($data)` | 데이터 기반 ETag 생성 |
| `isNotModified($etag)` | 클라이언트 캐시 유효성 확인 |
| `notModifiedResponse($etag, $maxAge)` | 304 응답 반환 |
| `successWithCache($message, $data, $maxAge)` | ETag + Cache-Control 포함 응답 |
---
## 관련 문서
- [index.md](index.md) - 백엔드 가이드 인덱스
- [controllers.md](controllers.md) - 컨트롤러 계층 구조
- [api-resources.md](api-resources.md) - API 리소스 규칙
- [routing.md](routing.md) - 라우트 네이밍 및 경로
+277
View File
@@ -0,0 +1,277 @@
# 라우트 네이밍 및 경로
> **상위 문서**: [백엔드 가이드 인덱스](./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
```
---
## 목차
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/[vendor-module]/[기능명]` | `/api/sirsoft-ecommerce/products` |
### 리소스 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 반환
```
---
## 권한 체크
### 권한 체크 방식
```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/sirsoft-ecommerce/src/routes/api.php
use Illuminate\Support\Facades\Route;
use Modules\Sirsoft\Ecommerce\Controllers\Api\Admin\ProductController;
Route::prefix('admin/sirsoft-ecommerce')->middleware(['auth:sanctum', 'admin'])->group(function () {
// 상품 관리 (권한 체크 포함)
Route::get('/products', [ProductController::class, 'index'])
->middleware('permission:sirsoft-ecommerce.products.view')
->name('api.sirsoft-ecommerce.products.index');
Route::post('/products', [ProductController::class, 'store'])
->middleware('permission:sirsoft-ecommerce.products.create')
->name('api.sirsoft-ecommerce.products.store');
Route::get('/products/{id}', [ProductController::class, 'show'])
->middleware('permission:sirsoft-ecommerce.products.view')
->name('api.sirsoft-ecommerce.products.show');
Route::put('/products/{id}', [ProductController::class, 'update'])
->middleware('permission:sirsoft-ecommerce.products.edit')
->name('api.sirsoft-ecommerce.products.update');
Route::delete('/products/{id}', [ProductController::class, 'destroy'])
->middleware('permission:sirsoft-ecommerce.products.delete')
->name('api.sirsoft-ecommerce.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 옵션" 참조
### 공개 API 라우트
```php
// modules/sirsoft-ecommerce/src/routes/api.php
use Modules\Sirsoft\Ecommerce\Controllers\Api\Public\ProductController;
Route::prefix('api/sirsoft-ecommerce')->group(function () {
// 공개 상품 API (인증 불필요)
Route::get('/products', [ProductController::class, 'index'])
->name('api.sirsoft-ecommerce.public.products.index');
Route::get('/products/{id}', [ProductController::class, 'show'])
->name('api.sirsoft-ecommerce.public.products.show');
});
```
---
## 개발 체크리스트
### 라우트 정의 시 확인사항
- [ ] 라우트에 `name()` 메서드로 이름 지정
- [ ] 적절한 접두사 사용 (api., web., vendor-module.)
- [ ] URL 경로 규칙 준수 (/admin/[vendor-module]/[기능명])
- [ ] 권한이 필요한 라우트에 `permission` 미들웨어 적용
- [ ] 인증이 필요한 라우트에 `auth:sanctum` 미들웨어 적용
- [ ] 관리자 라우트에 `admin` 미들웨어 적용
- [ ] FormRequest의 `authorize()` 메서드에서 권한 체크하지 않음
### 라우트 테스트 확인사항
- [ ] 인증 없이 접근 시 401 반환
- [ ] 권한 없이 접근 시 403 반환
- [ ] 올바른 권한으로 접근 시 성공
---
## 관련 문서
- [컨트롤러 계층 구조](./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)
+370
View File
@@ -0,0 +1,370 @@
# Scout 검색 엔진 시스템 (Search System)
> **중요도**: 높음
> **관련 문서**: [service-repository.md](service-repository.md) | [hooks.md](../extension/hooks.md) | [database-guide.md](../database-guide.md)
---
## TL;DR (5초 요약)
```text
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 이스케이프 방지 필수
```
---
## 목차
1. [아키텍처 개요](#아키텍처-개요)
2. [FulltextSearchable 인터페이스](#fulltextsearchable-인터페이스)
3. [검색 엔진 드라이버](#검색-엔진-드라이버)
4. [확장 포인트](#확장-포인트)
5. [마이그레이션](#마이그레이션)
6. [AsUnicodeJson 캐스트](#asunicodejson-캐스트)
7. [환경설정](#환경설정)
8. [관련 문서](#관련-문서)
---
## 아키텍처 개요
G7은 **Laravel Scout**를 통해 검색 기능을 제공하며, 기본 검색 엔진으로 **DatabaseFulltextEngine**을 사용합니다.
**핵심 구조**:
```
Controller/Service
↓ Model::search($keyword)
Laravel Scout (EngineManager)
↓ SCOUT_DRIVER 기반 엔진 선택
DatabaseFulltextEngine
↓ FulltextSearchable 인터페이스 참조
├── MySQL/MariaDB → MATCH...AGAINST IN BOOLEAN MODE
└── SQLite/PostgreSQL → LIKE fallback
```
**핵심 컴포넌트**:
| 파일 | 역할 |
|------|------|
| `app/Search/Contracts/FulltextSearchable.php` | 검색 대상 컬럼/가중치 정의 인터페이스 |
| `app/Search/Engines/DatabaseFulltextEngine.php` | MySQL FULLTEXT + ngram Scout 엔진 |
| `app/Providers/ScoutServiceProvider.php` | 엔진 등록 + 필터 훅 처리 |
| `app/Casts/AsUnicodeJson.php` | FULLTEXT ngram용 UTF-8 JSON 캐스트 |
| `config/scout.php` | Scout 설정 (드라이버, 큐, 소프트삭제 등) |
**설계 원칙**:
- MySQL 테이블 자체가 인덱스 소스 -- 외부 검색 서버 불필요
- `update()`, `delete()`, `flush()`, `createIndex()`, `deleteIndex()`는 모두 **no-op** (MySQL이 자동 관리)
- FULLTEXT 미지원 DBMS에서 LIKE fallback 자동 적용 (테스트 환경 SQLite 호환)
---
## FulltextSearchable 인터페이스
`App\Search\Contracts\FulltextSearchable` 인터페이스를 구현한 모델만 DatabaseFulltextEngine에서 검색됩니다.
### 필수 메서드
| 메서드 | 반환 타입 | 설명 |
|--------|----------|------|
| `searchableColumns()` | `array<string>` | FULLTEXT 검색 대상 컬럼명 배열 |
| `searchableWeights()` | `array<string, float>` | 컬럼별 검색 가중치 (높을수록 상위 노출) |
### 구현 예시 (Product 모델)
```php
use App\Search\Contracts\FulltextSearchable;
use Laravel\Scout\Searchable;
class Product extends Model implements FulltextSearchable
{
use Searchable;
// FULLTEXT 검색 대상 컬럼
public function searchableColumns(): array
{
return ['name', 'description'];
}
// 컬럼별 가중치 (name 매칭이 description보다 2배 높은 점수)
public function searchableWeights(): array
{
return [
'name' => 2.0,
'description' => 1.0,
];
}
}
```
### 가중치 기반 스코어 계산
검색 결과는 `_ft_score` 가상 컬럼으로 관련성 점수가 부여됩니다:
```sql
-- MySQL에서 생성되는 쿼리 (예시)
SELECT products.*,
(MATCH(`name`) AGAINST(? IN BOOLEAN MODE) * 2.0
+ MATCH(`description`) AGAINST(? IN BOOLEAN MODE) * 1.0) as _ft_score
FROM products
WHERE (MATCH(`name`) AGAINST(? IN BOOLEAN MODE)
OR MATCH(`description`) AGAINST(? IN BOOLEAN MODE))
ORDER BY _ft_score DESC
```
LIKE fallback 시 `_ft_score`는 항상 0 (관련성 순위 불가).
### 현재 FulltextSearchable 구현 모델
| 모듈 | 모델 | 검색 컬럼 |
|------|------|----------|
| sirsoft-ecommerce | Product | name, description |
| sirsoft-ecommerce | Category | name |
| sirsoft-ecommerce | Brand | name |
| sirsoft-ecommerce | Coupon | name |
| sirsoft-ecommerce | ProductCommonInfo | name |
| sirsoft-board | Post | (모델 참조) |
| sirsoft-page | Page | (모델 참조) |
---
## 검색 엔진 드라이버
### 기본 드라이버: mysql-fulltext
`DatabaseFulltextEngine`은 MySQL FULLTEXT + ngram 파서를 활용합니다:
- **MySQL 8.0+**: `MATCH...AGAINST IN BOOLEAN MODE` + **ngram 파서** (한글 2글자 토큰 분리)
- **MariaDB**: `MATCH...AGAINST IN BOOLEAN MODE` + 기본 파서 (ngram 미지원)
- **SQLite/PostgreSQL**: `LIKE %keyword%` fallback (개발/테스트 환경 호환)
### DBMS 지원 판단
```php
// FULLTEXT 지원 여부 (MySQL, MariaDB만 true)
DatabaseFulltextEngine::supportsFulltext();
// ngram 파서 지원 여부 (MySQL만 true, MariaDB는 false)
DatabaseFulltextEngine::supportsNgramParser();
```
### SCOUT_DRIVER 전환
`.env`에서 드라이버를 변경하면 즉시 적용됩니다:
```env
# 기본값: MySQL FULLTEXT
SCOUT_DRIVER=mysql-fulltext
# 플러그인에서 등록한 드라이버로 전환
SCOUT_DRIVER=meilisearch
```
### whereFulltext() 정적 헬퍼
Scout Builder를 사용할 수 없는 곳 (관계 검색, 서브쿼리 등)에서 FULLTEXT 조건을 직접 추가합니다:
```php
use App\Search\Engines\DatabaseFulltextEngine;
// Repository에서 사용 예시
$query = Post::query();
DatabaseFulltextEngine::whereFulltext($query, 'content', $keyword);
DatabaseFulltextEngine::whereFulltext($query, 'title', $keyword, 'or');
```
DBMS에 따라 자동 분기:
- MySQL/MariaDB: `WHERE MATCH(\`content\`) AGAINST(? IN BOOLEAN MODE)`
- 그 외: `WHERE content LIKE '%keyword%'`
---
## 확장 포인트
### core.search.engine_drivers 필터 훅
`ScoutServiceProvider`에서 `core.search.engine_drivers` 필터 훅을 통해 플러그인이 추가 검색 엔진을 등록할 수 있습니다.
```php
// 플러그인 ServiceProvider에서 등록
use App\Extension\HookManager;
public function boot(): void
{
HookManager::addFilter('core.search.engine_drivers', function (array $drivers) {
$drivers['meilisearch'] = \App\Search\Engines\MeilisearchEngine::class;
return $drivers;
});
}
```
등록 후 `.env`에서 `SCOUT_DRIVER=meilisearch`로 전환하면 해당 엔진이 사용됩니다.
### ScoutServiceProvider 동작 흐름
```php
// app/Providers/ScoutServiceProvider.php
// 1. 기본 드라이버 맵
$drivers = ['mysql-fulltext' => DatabaseFulltextEngine::class];
// 2. 필터 훅으로 플러그인 드라이버 수집
$drivers = HookManager::applyFilters('core.search.engine_drivers', $drivers);
// 3. EngineManager에 모든 드라이버 등록
$this->app->resolving(EngineManager::class, function (EngineManager $manager) use ($drivers) {
foreach ($drivers as $name => $engineClass) {
$manager->extend($name, fn () => $this->app->make($engineClass));
}
});
```
---
## 마이그레이션
### addFulltextIndex() 헬퍼
`DatabaseFulltextEngine::addFulltextIndex()`는 DBMS별 조건부 DDL을 처리합니다:
```php
use App\Search\Engines\DatabaseFulltextEngine;
// 마이그레이션 up()에서 사용
public function up(): void
{
DatabaseFulltextEngine::addFulltextIndex(
'ecommerce_products', // 테이블명 (prefix 제외)
'ft_ecommerce_products_name', // 인덱스명
'name' // 대상 컬럼 (string 또는 array)
);
}
```
**DBMS별 동작**:
| DBMS | 생성되는 DDL |
|------|-------------|
| MySQL 8.0+ | `ALTER TABLE ... ADD FULLTEXT INDEX ... WITH PARSER ngram` |
| MariaDB | `ALTER TABLE ... ADD FULLTEXT INDEX ...` (ngram 없음) |
| SQLite/PostgreSQL | 스킵 (no-op) |
### 마이그레이션 down() 패턴
```php
public function down(): void
{
if (! Schema::hasTable('ecommerce_products')) {
return;
}
$indexes = array_column(Schema::getIndexes('ecommerce_products'), 'name');
Schema::table('ecommerce_products', function (Blueprint $table) use ($indexes) {
if (in_array('ft_ecommerce_products_name', $indexes)) {
$table->dropIndex('ft_ecommerce_products_name');
}
});
}
```
### 인덱스 네이밍 규칙
```
ft_{테이블명}_{컬럼명}
```
예: `ft_ecommerce_products_name`, `ft_ecommerce_products_description`
### 복합 컬럼 인덱스
```php
// 여러 컬럼을 하나의 FULLTEXT 인덱스로 생성
DatabaseFulltextEngine::addFulltextIndex(
'posts',
'ft_posts_title_content',
['title', 'content'] // 배열 전달
);
```
---
## AsUnicodeJson 캐스트
### 문제
Laravel 기본 `array` 캐스트는 `json_encode()`로 한글을 `\uXXXX`로 이스케이프합니다:
```json
// 기본 array 캐스트: \uXXXX 이스케이프
{"ko": "\uc0c1\ud488\uba85"}
// AsUnicodeJson 캐스트: 실제 UTF-8
{"ko": "상품명"}
```
MySQL ngram 토크나이저는 **실제 UTF-8 문자**를 기준으로 토큰을 생성하므로, `\uXXXX` 이스케이프된 데이터에서는 한글 검색이 동작하지 않습니다.
### 사용법
```php
use App\Casts\AsUnicodeJson;
class Product extends Model
{
protected $casts = [
'name' => AsUnicodeJson::class, // FULLTEXT 검색 대상 JSON 컬럼
'description' => AsUnicodeJson::class, // FULLTEXT 검색 대상 JSON 컬럼
'meta_keywords' => 'array', // 검색 대상 아닌 컬럼은 기본 캐스트 사용 가능
];
}
```
### 적용 대상
FULLTEXT 인덱스가 걸리는 JSON 타입 컬럼에는 반드시 `AsUnicodeJson` 캐스트를 사용합니다. 내부적으로 `JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES` 플래그를 사용합니다.
---
## 환경설정
### config/scout.php 주요 설정
| 키 | 기본값 | 설명 |
|----|--------|------|
| `driver` | `mysql-fulltext` | 검색 엔진 드라이버 (`SCOUT_DRIVER` 환경변수) |
| `prefix` | `''` | 인덱스 접두사 |
| `queue` | `false` | 인덱스 동기화 큐 사용 여부 |
| `soft_delete` | `true` | 소프트 삭제 레코드 필터링 |
| `after_commit` | `false` | DB 트랜잭션 커밋 후 인덱스 동기화 |
| `chunk.searchable` | `500` | 대량 인덱싱 시 청크 크기 |
### .env 설정
```env
# 검색 엔진 드라이버 (기본: mysql-fulltext)
SCOUT_DRIVER=mysql-fulltext
# 인덱스 접두사 (선택)
SCOUT_PREFIX=
# 인덱스 동기화 큐 사용 (선택)
SCOUT_QUEUE=false
```
> **참고**: `mysql-fulltext` 드라이버는 MySQL 테이블 자체가 인덱스이므로 `SCOUT_QUEUE`, `SCOUT_PREFIX`는 실질적으로 사용되지 않습니다. 외부 검색 엔진(Meilisearch 등)으로 전환 시 의미가 있습니다.
---
## 관련 문서
- [Service-Repository 패턴](service-repository.md) - Repository에서 whereFulltext() 사용
- [훅 시스템](../extension/hooks.md) - core.search.engine_drivers 필터 훅
- [데이터베이스 가이드](../database-guide.md) - 마이그레이션 규칙
- [플러그인 개발](../extension/plugin-development.md) - 검색 엔진 플러그인 개발
File diff suppressed because it is too large. Load diff
+305
View File
@@ -0,0 +1,305 @@
# 서비스 프로바이더 안전성
> **목차**: [index.md](./index.md) | [enum.md](./enum.md) | [controllers.md](./controllers.md) | [service-repository.md](./service-repository.md) | [validation.md](./validation.md) | [exceptions.md](./exceptions.md) | [api-resources.md](./api-resources.md) | [routing.md](./routing.md) | [response-helper.md](./response-helper.md) | [middleware.md](./middleware.md) | [authentication.md](./authentication.md) | **service-provider.md**
---
## TL;DR (5초 요약)
```text
1. DB 접근 전 .env 파일 존재 확인 필수
2. 테이블 존재 여부 Schema::hasTable() 체크
3. 인스톨러 안정성: 마이그레이션 전에도 부팅 가능해야 함
4. 조건 미충족 시 예외 대신 안전하게 스킵
5. runningInConsole() + 'migrate' 명령 감지로 스킵
```
---
## 목차
1. [핵심 원칙](#핵심-원칙)
2. [검증이 필요한 경우](#검증이-필요한-경우)
3. [검증 체크리스트](#검증-체크리스트)
4. [잘못된 예시](#잘못된-예시)
5. [올바른 예시](#올바른-예시)
6. [실행 흐름 다이어그램](#실행-흐름-다이어그램)
7. [다른 서비스 프로바이더 적용 예시](#다른-서비스-프로바이더-적용-예시)
8. [서비스 프로바이더 개발 체크리스트](#서비스-프로바이더-개발-체크리스트)
9. [인스톨러 테스트 시나리오](#인스톨러-테스트-시나리오)
---
## 핵심 원칙
```text
필수: 서비스 프로바이더에서 DB 접근 전 .env + 테이블 존재 확인
필수: .env 파일 및 테이블 존재 여부 확인 후 접근
```
**핵심 원칙**:
- **인스톨러 안정성**: `.env` 파일이 없는 상태에서도 `composer install` 실행 가능해야 함
- **단계적 초기화**: DB 테이블이 없는 상태(마이그레이션 전)에서도 애플리케이션 부팅 가능해야 함
- **안전한 스킵**: 조건 미충족 시 예외 발생 대신 안전하게 건너뛰기
---
## 검증이 필요한 경우
| 상황 | 검증 항목 | 필요 이유 |
|------|----------|----------|
| DB 쿼리 실행 | `.env` 파일, 테이블 존재 | 인스톨러 실행 전 오류 방지 |
| 모듈/플러그인 로드 | 관련 테이블 존재 | 마이그레이션 전 오류 방지 |
| 설정값 로드 | `.env` 파일 존재 | 초기 설정 전 오류 방지 |
---
## 검증 체크리스트
1. **`.env` 파일 존재 여부**: `File::exists(base_path('.env'))`
2. **필수 테이블 존재 여부**: `Schema::hasTable('table_name')`
3. **조건 미충족 시 안전하게 return**
---
## 잘못된 예시
```php
// ❌ 검증 없이 DB 접근 - 인스톨러 실행 시 오류 발생
class ModuleRouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->routes(function () {
$this->loadModuleRoutes();
});
}
protected function loadModuleRoutes(): void
{
$modulesPath = base_path('modules');
if (! File::exists($modulesPath)) {
return;
}
// ❌ .env 파일이나 테이블 확인 없이 DB 쿼리 실행
$activeModules = Module::where('status', 'active')->get();
foreach ($activeModules as $module) {
// 라우트 로드...
}
}
}
```
**오류 시나리오**:
```bash
# 인스톨러 실행 중
composer install
↓
package:discover 자동 실행
↓
ModuleRouteServiceProvider 로드
↓
Module::where() 실행 시도
↓
❌ SQLSTATE[HY000] [1045] Access denied (using password: NO)
```
---
## 올바른 예시
### 1. .env 파일 체크 추가
```php
// ✅ .env 파일 및 테이블 존재 확인
class ModuleRouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->routes(function () {
$this->loadModuleRoutes();
});
}
protected function loadModuleRoutes(): void
{
// ✅ .env 파일이 없으면 스킵 (인스톨러 실행 전)
if (! File::exists(base_path('.env'))) {
return;
}
$modulesPath = base_path('modules');
if (! File::exists($modulesPath)) {
return;
}
// ✅ 데이터베이스 테이블이 존재하지 않으면 스킵 (마이그레이션 전)
if (! Schema::hasTable('modules')) {
return;
}
// 안전하게 DB 접근
$activeModules = Module::where('status', ExtensionStatus::Active->value)
->pluck('identifier')
->toArray();
foreach ($activeModules as $moduleIdentifier) {
// 라우트 로드...
}
}
}
```
### 2. Service 클래스에서의 적용
```php
class TemplateService
{
/**
* 활성화된 모듈의 routes 데이터 로드
*/
private function loadActiveModulesRoutesData(): array
{
// ✅ .env 파일이 없으면 빈 배열 반환 (인스톨러 실행 전)
if (! file_exists(base_path('.env'))) {
return [];
}
// ✅ modules 테이블이 없으면 빈 배열 반환 (마이그레이션 전)
if (! Schema::hasTable('modules')) {
return [];
}
// 안전하게 모듈 데이터 로드
$routes = [];
$activeModules = $this->moduleManager->getActiveModules();
foreach ($activeModules as $module) {
// routes.json 로드...
}
return $routes;
}
}
```
---
## 실행 흐름 다이어그램
```
인스톨러 단계 1: composer install
├─ .env 없음 → ModuleRouteServiceProvider 스킵 ✅
└─ package:discover 정상 완료
인스톨러 단계 2: .env 생성
├─ DB 연결 정보 입력
└─ .env 파일 생성 완료
인스톨러 단계 3: 마이그레이션
├─ modules 테이블 없음 → ModuleRouteServiceProvider 스킵 ✅
└─ 테이블 생성 완료
설치 완료 후:
├─ .env 있음 ✅
├─ modules 테이블 있음 ✅
└─ ModuleRouteServiceProvider 정상 실행 ✅
```
---
## 다른 서비스 프로바이더 적용 예시
### PluginRouteServiceProvider
```php
class PluginRouteServiceProvider extends ServiceProvider
{
protected function loadPluginRoutes(): void
{
// ✅ 동일한 패턴 적용
if (! File::exists(base_path('.env'))) {
return;
}
if (! Schema::hasTable('plugins')) {
return;
}
// 플러그인 라우트 로드...
}
}
```
### ConfigServiceProvider
```php
class ConfigServiceProvider extends ServiceProvider
{
public function boot(): void
{
// ✅ 동일한 패턴 적용
if (! File::exists(base_path('.env'))) {
return;
}
if (! Schema::hasTable('settings')) {
return;
}
// 동적 설정 로드...
}
}
```
---
## 서비스 프로바이더 개발 체크리스트
- [ ] DB 접근이 필요한 경우 `.env` 파일 존재 확인
- [ ] 특정 테이블 접근 시 `Schema::hasTable()` 확인
- [ ] 조건 미충족 시 예외 발생 대신 `return`으로 안전하게 스킵
- [ ] 인스톨러 환경에서 테스트 수행
- [ ] `composer install` 단독 실행 테스트
---
## 인스톨러 테스트 시나리오
```bash
# 1. .env 없이 composer install
rm .env
composer install
# 예상: 정상 완료 (오류 없음)
# 2. .env 생성 후 애플리케이션 부팅
cp .env.example .env
php artisan config:clear
# 예상: 정상 부팅 (modules 테이블 없어도 오류 없음)
# 3. 마이그레이션 후 정상 동작
php artisan migrate
# 예상: 모든 기능 정상 동작
```
---
## 관련 이슈
- 인스톨러에서 `composer install` 시 DB 접근 오류
- 마이그레이션 전 애플리케이션 부팅 실패
- CI/CD 파이프라인에서 의존성 설치 오류
---
## 관련 문서
- [middleware.md](./middleware.md) - 미들웨어 등록 규칙
- [authentication.md](./authentication.md) - 인증 및 세션 처리
- [index.md](./index.md) - 백엔드 가이드 인덱스
+972
View File
@@ -0,0 +1,972 @@
# Service-Repository 패턴
> **백엔드 가이드** | [목차로 돌아가기](index.md)
---
## TL;DR (5초 요약)
```text
1. RepositoryInterface 주입 필수 (구체 클래스 직접 주입 금지)
2. CoreServiceProvider에서 Interface-구현체 바인딩
3. Service에서 훅 실행: before_create → applyFilters → create → after_create
4. 검증 로직은 FormRequest에서 (Service에 검증 금지)
5. 다중 검색은 HasMultipleSearchFilters Trait 사용
```
---
## 목차
- [개요](#개요)
- [Repository 인터페이스](#repository-인터페이스)
- [Service 클래스](#service-클래스)
- [트랜잭션 및 관계 삭제 패턴](#트랜잭션-및-관계-삭제-패턴)
- [상태 변경 자동 처리](#상태-변경-자동-처리)
- [Repository 클래스](#repository-클래스)
- [다중 검색 필터 Trait](#다중-검색-필터-trait-hasmultiplesearchfilters)
- [모듈에서 Repository 인터페이스 바인딩](#모듈에서-repository-인터페이스-바인딩)
- [관련 문서](#관련-문서)
---
## 개요
Service-Repository 패턴은 비즈니스 로직과 데이터 액세스 로직을 분리하는 아키텍처 패턴입니다.
```
Controller → Request → Service → RepositoryInterface → Repository → Model
```
| 계층 | 역할 |
|------|------|
| **Service** | 비즈니스 로직, 훅 실행, 트랜잭션 관리 |
| **RepositoryInterface** | Repository 추상화 계약 정의 |
| **Repository** | 데이터 액세스 구현, 쿼리 로직 캡슐화 |
---
## Repository 인터페이스
### 핵심 원칙
```
필수: Repository 인터페이스를 통한 DI (구체 클래스 직접 타입힌트 금지)
필수: Repository 인터페이스를 통한 DI
✅ 필수: CoreServiceProvider에서 인터페이스-구현체 바인딩
```
### 인터페이스 위치
```
app/Contracts/Repositories/
├── LayoutRepositoryInterface.php
├── ModuleRepositoryInterface.php
├── MenuRepositoryInterface.php
├── RoleRepositoryInterface.php
├── TemplateRepositoryInterface.php
├── PluginRepositoryInterface.php
├── UserRepositoryInterface.php
├── PermissionRepositoryInterface.php
├── LayoutVersionRepositoryInterface.php
└── SystemConfigRepositoryInterface.php
```
### 인터페이스 정의 패턴
```php
<?php
namespace App\Contracts\Repositories;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
interface UserRepositoryInterface
{
/**
* ID로 사용자 조회
*
* @param int $id
* @return User|null
*/
public function findById(int $id): ?User;
/**
* 사용자 목록 페이지네이션 조회
*
* @param array $filters 검색 조건
* @return LengthAwarePaginator
*/
public function getPaginated(array $filters = []): LengthAwarePaginator;
/**
* 사용자 생성
*
* @param array $data
* @return User
*/
public function create(array $data): User;
}
```
### Repository 구현체 패턴
```php
<?php
namespace App\Repositories;
use App\Contracts\Repositories\UserRepositoryInterface;
use App\Models\User;
class UserRepository implements UserRepositoryInterface
{
/**
* ID로 사용자 조회
*/
public function findById(int $id): ?User
{
return User::find($id);
}
// ... 인터페이스 메서드 구현
}
```
### Service Provider 바인딩
`app/Providers/CoreServiceProvider.php`에서 인터페이스와 구현체를 바인딩합니다:
```php
<?php
namespace App\Providers;
use App\Contracts\Repositories\UserRepositoryInterface;
use App\Repositories\UserRepository;
use Illuminate\Support\ServiceProvider;
class CoreServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->registerRepositoryBindings();
}
private function registerRepositoryBindings(): void
{
// bind() 사용 - Repository는 상태가 없으므로 매번 새 인스턴스
$this->app->bind(UserRepositoryInterface::class, UserRepository::class);
$this->app->bind(ModuleRepositoryInterface::class, ModuleRepository::class);
// ... 나머지 Repository 바인딩
}
}
```
### 의존성 주입 패턴
#### Service에서 사용 (권장)
```php
<?php
namespace App\Services;
use App\Contracts\Repositories\UserRepositoryInterface;
class UserService
{
public function __construct(
private UserRepositoryInterface $userRepository
) {}
public function getUser(int $id): ?User
{
return $this->userRepository->findById($id);
}
}
```
#### Controller에서 사용
```php
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Contracts\Repositories\UserRepositoryInterface;
class UserController extends AdminBaseController
{
public function __construct(
private UserRepositoryInterface $userRepository
) {
parent::__construct();
}
}
```
#### Console Command에서 사용
```php
<?php
namespace App\Console\Commands;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use Illuminate\Console\Command;
class ListModuleCommand extends Command
{
public function __construct(
private ModuleRepositoryInterface $moduleRepository
) {
parent::__construct();
}
}
```
### 인터페이스 장점
| 장점 | 설명 |
|------|------|
| **테스트 용이성** | Mock 객체로 쉽게 대체 가능 |
| **유연한 구현체 교체** | 바인딩만 변경하면 다른 구현체 사용 가능 |
| **명확한 계약** | 인터페이스가 Repository의 공개 API 명세 역할 |
| **의존성 역전** | 고수준 모듈이 저수준 모듈에 의존하지 않음 |
---
## Service 클래스
### 역할
- 비즈니스 로직 구현
- 훅 실행 (before/after)
- 트랜잭션 관리
- 여러 리포지토리 조율
### 패턴
```php
<?php
namespace Modules\Sirsoft\Ecommerce\Services;
use App\Hooks\HookManager;
use Modules\Sirsoft\Ecommerce\Contracts\Repositories\ProductRepositoryInterface;
class ProductService
{
public function __construct(
private ProductRepositoryInterface $productRepository
) {}
/**
* 상품 생성
*
* @param array $data 상품 데이터
* @return Product
*/
public function createProduct(array $data): Product
{
// Before 훅 - 데이터 검증, 전처리
HookManager::doAction('sirsoft-ecommerce.product.before_create', $data);
// 필터 훅 - 데이터 변형
$data = HookManager::applyFilters('sirsoft-ecommerce.product.filter_create_data', $data);
// 비즈니스 로직 실행
$product = $this->productRepository->create($data);
// After 훅 - 후처리, 알림, 캐시 등
HookManager::doAction('sirsoft-ecommerce.product.after_create', $product, $data);
return $product;
}
/**
* 상품 수정
*
* @param int $id 상품 ID
* @param array $data 수정할 데이터
* @return Product
*/
public function updateProduct(int $id, array $data): Product
{
HookManager::doAction('sirsoft-ecommerce.product.before_update', $id, $data);
$data = HookManager::applyFilters('sirsoft-ecommerce.product.filter_update_data', $data, $id);
$product = $this->productRepository->update($id, $data);
HookManager::doAction('sirsoft-ecommerce.product.after_update', $product, $data);
return $product;
}
/**
* 상품 목록 조회
*
* @param array $filters 검색 필터
* @return Collection
*/
public function getProducts(array $filters = []): Collection
{
// Before 훅 - 검색 조건 전처리
HookManager::doAction('sirsoft-ecommerce.product.before_list', $filters);
// 필터 훅 - 검색 조건 변형
$filters = HookManager::applyFilters('sirsoft-ecommerce.product.filter_list_query', $filters);
$products = $this->productRepository->getAll($filters);
// 필터 훅 - 결과 데이터 변형
$products = HookManager::applyFilters('sirsoft-ecommerce.product.filter_list_result', $products, $filters);
// After 훅 - 조회 후처리 (로깅, 캐싱 등)
HookManager::doAction('sirsoft-ecommerce.product.after_list', $products, $filters);
return $products;
}
/**
* 상품 상세 조회
*
* @param int $id 상품 ID
* @return Product|null
*/
public function getProduct(int $id): ?Product
{
// Before 훅 - 조회 전처리
HookManager::doAction('sirsoft-ecommerce.product.before_show', $id);
$product = $this->productRepository->findById($id);
if ($product) {
// 필터 훅 - 조회 결과 변형 (조회수 증가, 관련 데이터 추가 등)
$product = HookManager::applyFilters('sirsoft-ecommerce.product.filter_show_result', $product);
// After 훅 - 조회 후처리
HookManager::doAction('sirsoft-ecommerce.product.after_show', $product);
}
return $product;
}
}
```
### 훅 실행 순서
```
1. Before Action Hook → 사전 처리, 검증
2. Filter Hook → 데이터 변형
3. Repository 호출 → 실제 데이터 작업
4. After Action Hook → 후처리, 알림, 캐시
```
### 훅 네이밍 규칙
```
[vendor-module].[entity].[action]_[timing]
예시:
sirsoft-ecommerce.product.before_create
sirsoft-ecommerce.product.after_update
sirsoft-ecommerce.product.filter_create_data
```
### 서비스 내부 조건부 권한 체크
미들웨어가 아닌 **서비스 내부**에서 추가적인 권한 체크가 필요한 경우 `PermissionHelper`를 사용합니다.
대표적인 예: 역할(role) 변경처럼 데이터의 일부 필드만 별도 권한이 필요한 경우.
```php
use App\Helpers\PermissionHelper;
use Illuminate\Support\Facades\Auth;
public function updateUser(User $user, array $data): User
{
$roleIds = $data['role_ids'] ?? null;
unset($data['role_ids'], $data['roles']);
// 훅 실행 (생략)...
$user = $this->userRepository->update($user->id, $data);
// 역할 변경은 별도 권한으로 보호
if ($roleIds !== null) {
$authUser = Auth::user();
// 자기 자신의 역할은 항상 변경 불가 (보안)
if ($authUser && $authUser->id === $user->id) {
$roleIds = null;
}
// core.permissions.update 권한 없으면 역할 변경 무시
if ($roleIds !== null && ! PermissionHelper::check('core.permissions.update', $authUser)) {
$roleIds = null;
}
if ($roleIds !== null) {
$user->roles()->sync($roleIds);
}
}
return $user;
}
```
```
필수: 역할/권한 변경은 미들웨어 권한과 별개로 서비스에서 명시적 체크 필수
필수: 자기 자신의 역할 변경은 항상 불가 (관리자 실수 방지)
패턴: 민감 필드 분리 → 별도 권한 체크 → 권한 없으면 해당 필드 무시 (403이 아닌 무시)
```
---
## 트랜잭션 및 관계 삭제 패턴
### CASCADE 방지 — 명시적 관계 삭제
```
필수: Service에서 명시적 삭제 (DB CASCADE 의존 금지)
필수: Service에서 모든 관계를 명시적으로 삭제 (훅/파일/로깅 보장)
```
엔티티 삭제 시 관련된 모든 관계를 **Service에서 명시적으로** 제거합니다:
```php
public function deleteUser(User $user): bool
{
// Before 훅
HookManager::doAction('core.user.before_delete', $user);
// 원본 데이터 보관 (after 훅에서 사용)
$userData = $user->toArray();
// 관계 명시적 해제 (CASCADE 의존 금지)
$user->roles()->detach(); // 다대다 관계 해제
$user->consents()->delete(); // 일대다 관계 삭제
$user->tokens()->delete(); // 인증 토큰 삭제
// Attachment 삭제 (비즈니스 로직 통한 삭제)
if ($user->avatarAttachment) {
$this->attachmentService->delete($user->avatarAttachment->id);
}
$result = $this->userRepository->delete($user);
// After 훅 (원본 데이터 전달)
HookManager::doAction('core.user.after_delete', $userData);
return $result;
}
```
### 일괄 업데이트 트랜잭션 패턴
```php
public function bulkUpdateStatus(array $ids, string $status): int
{
$statusEnum = UserStatus::from($status);
$updatedCount = DB::transaction(function () use ($ids, $statusEnum) {
$count = User::whereIn('id', $ids)->update([
'status' => $statusEnum->value,
]);
// 트랜잭션 내에서 관계형 데이터 명시적 삭제
if ($statusEnum !== UserStatus::Active) {
PersonalAccessToken::where('tokenable_type', User::class)
->whereIn('tokenable_id', $ids)
->delete();
}
return $count;
});
// 트랜잭션 외부에서 훅 실행 (실패 시 롤백 방지)
HookManager::doAction('core.user.after_bulk_update', $ids, $status, $updatedCount);
return $updatedCount;
}
```
### 핵심 규칙
| 원칙 | 설명 |
| ------ | ------ |
| **CASCADE 금지** | DB 외래키 CASCADE 대신 Service에서 명시적 삭제 |
| **원본 데이터 보관** | 삭제 전 `toArray()`로 캡처 → after 훅에서 사용 |
| **관계 유형별 처리** | `detach()` (다대다), `delete()` (일대다), Service 호출 (복합) |
| **트랜잭션 내 훅 금지** | 훅은 트랜잭션 **외부**에서 실행 (롤백 시 훅 부작용 방지) |
| **AttachmentService 사용** | 파일 삭제는 직접 DB 삭제 대신 AttachmentService 통해 처리 |
---
## 상태 변경 자동 처리
상태(status) 변경 시 관련 타임스탬프와 토큰을 자동으로 처리하는 패턴입니다.
### 상태 변경 타임스탬프 자동 설정
상태 변경 시 관련 타임스탬프를 `match` 표현식으로 자동 설정합니다:
```php
// 상태 변경 감지 및 타임스탬프 자동 설정
$oldStatus = $user->status;
$newStatus = $data['status'] ?? null;
if ($newStatus && $newStatus !== $oldStatus) {
$newStatusEnum = UserStatus::from($newStatus);
$data = match ($newStatusEnum) {
UserStatus::Blocked => array_merge($data, ['blocked_at' => now()]),
UserStatus::Withdrawn => array_merge($data, ['withdrawn_at' => now()]),
UserStatus::Active => array_merge($data, ['blocked_at' => null, 'withdrawn_at' => null]),
UserStatus::Inactive => $data, // 타임스탬프 변경 없음
};
}
$this->userRepository->update($user, $data);
```
### 상태 변경 시 토큰 자동 삭제
Active 외 상태로 변경 시 해당 사용자의 토큰을 삭제하여 즉시 로그아웃시킵니다:
```php
// 단일 사용자: 상태가 Active 외로 변경되었으면 토큰 삭제
if ($newStatus && $newStatus !== $oldStatus && $newStatus !== UserStatus::Active->value) {
$user->tokens()->delete();
}
// 일괄 업데이트: PersonalAccessToken 직접 삭제
if ($statusEnum !== UserStatus::Active) {
PersonalAccessToken::where('tokenable_type', User::class)
->whereIn('tokenable_id', $ids)
->delete();
}
```
### 패턴 요약
| 원칙 | 설명 |
| ---------- | ---------- |
| **변경 감지** | `$oldStatus !== $newStatus` 비교 후 처리 (불필요한 업데이트 방지) |
| **match 표현식** | Enum case별 타임스탬프 자동 매핑 (`array_merge`로 기존 데이터 보존) |
| **Active 복귀 시 초기화** | `blocked_at`, `withdrawn_at` 등 관련 타임스탬프를 `null`로 리셋 |
| **토큰 자동 삭제** | 비활성 상태 전환 시 즉시 로그아웃 (단일: `tokens()->delete()`, 일괄: `PersonalAccessToken` 직접 삭제) |
---
## Repository 클래스
### 역할
- 데이터 액세스 추상화
- 쿼리 로직 캡슐화
- N+1 문제 방지 (Eager Loading)
### 데이터베이스 독립성 원칙
```
필수: 표준 SQL만 사용 (MySQL 전용 함수 금지)
필수: Laravel 쿼리빌더 또는 Eloquent 문법 사용
✅ 필수: 데이터베이스 추상화 계층을 통한 쿼리 작성
```
특정 데이터베이스에 의존하는 Raw 쿼리를 사용하면 다른 데이터베이스로 마이그레이션이 어려워집니다.
#### JSON 컬럼 처리
```php
// ❌ DON'T: MySQL 전용 함수 사용
$query->whereRaw("JSON_SEARCH(name, 'one', ?) IS NOT NULL", ["%{$keyword}%"]);
$query->orderByRaw("JSON_EXTRACT(name, '$.\"$locale\"') $sortOrder");
// ✅ DO: Laravel JSON 문법 사용
$locales = config('app.translatable_locales', ['ko', 'en']);
foreach ($locales as $locale) {
$query->orWhere("name->{$locale}", 'like', "%{$keyword}%");
}
$query->orderBy("name->{$locale}", $sortOrder);
```
#### 허용되는 Raw 쿼리
복잡한 집계나 Laravel이 지원하지 않는 기능에 한해 Raw 쿼리를 허용하되, 가능한 표준 SQL을 사용합니다:
```php
// ✅ 표준 SQL 집계 (대부분의 DB에서 호환)
$query->selectRaw('sales_status, COUNT(*) as count');
$query->whereColumn('stock_quantity', '<=', 'safe_stock_quantity');
```
### 패턴
```php
<?php
namespace Modules\Sirsoft\Ecommerce\Repositories;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Illuminate\Database\Eloquent\Collection;
class ProductRepository
{
/**
* 모든 상품 조회
*
* @return Collection
*/
public function getAll(): Collection
{
return Product::with(['category', 'images'])
->orderBy('created_at', 'desc')
->get();
}
/**
* ID로 상품 조회
*
* @param int $id
* @return Product|null
*/
public function findById(int $id): ?Product
{
return Product::with(['category', 'images'])->find($id);
}
/**
* 상품 생성
*
* @param array $data
* @return Product
*/
public function create(array $data): Product
{
return Product::create($data);
}
/**
* 상품 수정
*
* @param int $id
* @param array $data
* @return Product
*/
public function update(int $id, array $data): Product
{
$product = $this->findById($id);
$product->update($data);
return $product->fresh();
}
}
```
### Eager Loading으로 N+1 방지
```php
// ❌ DON'T: N+1 문제 발생
public function getAll(): Collection
{
return Product::all(); // 관계 로딩 없음
}
// ✅ DO: Eager Loading 사용
public function getAll(): Collection
{
return Product::with(['category', 'images'])->get();
}
```
---
## 다중 검색 필터 Trait (HasMultipleSearchFilters)
목록 API에서 다중 검색 조건을 지원해야 할 때 `HasMultipleSearchFilters` Trait을 사용합니다.
### 파일 위치
`app/Repositories/Concerns/HasMultipleSearchFilters.php`
### 지원 연산자
| 연산자 | 설명 | SQL 변환 |
|--------|------|----------|
| `like` (기본) | 부분 일치 | `LIKE %value%` |
| `eq` | 정확히 일치 | `= value` |
| `starts_with` | 시작 일치 | `LIKE value%` |
| `ends_with` | 끝 일치 | `LIKE %value` |
### Trait 제공 메서드
| 메서드 | 설명 |
|--------|------|
| `applyMultipleSearchFilters()` | 다중 검색 조건을 AND로 적용 |
| `applySearchFilter()` | 개별 검색 필터 적용 (연산자 처리) |
| `applyOrSearchAcrossFields()` | 단일 검색어로 여러 필드 OR 검색 |
### Repository에서 사용
```php
<?php
namespace App\Repositories;
use App\Models\User;
use App\Repositories\Concerns\HasMultipleSearchFilters;
use Illuminate\Database\Eloquent\Builder;
class UserRepository
{
use HasMultipleSearchFilters;
/**
* 검색 가능한 필드 목록 (보안을 위해 허용 필드 명시)
*/
private const SEARCHABLE_FIELDS = ['name', 'email', 'username'];
/**
* 필터링 및 페이지네이션 적용
*/
public function getPaginatedUsers(array $filters = []): LengthAwarePaginator
{
$query = User::query();
$this->applyFilters($query, $filters);
return $query->paginate($filters['per_page'] ?? 15);
}
/**
* 쿼리에 필터 조건 적용
*/
private function applyFilters(Builder $query, array $filters): void
{
// 다중 검색 조건 적용
if (! empty($filters['filters']) && is_array($filters['filters'])) {
$this->applyMultipleSearchFilters($query, $filters['filters'], self::SEARCHABLE_FIELDS);
}
}
}
```
### FormRequest 검증 규칙
```php
<?php
namespace App\Http\Requests\User;
use Illuminate\Foundation\Http\FormRequest;
class UserListRequest extends FormRequest
{
/**
* 검색 가능한 필드 목록 (보안을 위해 허용 필드 명시)
*/
public const SEARCHABLE_FIELDS = ['name', 'email', 'username'];
public function rules(): array
{
// 'all'은 전체 필드 검색을 의미하는 특수 값
$searchableFields = implode(',', array_merge(['all'], self::SEARCHABLE_FIELDS));
return [
// 다중 검색 조건
'filters' => 'nullable|array|max:10',
'filters.*.field' => "required_with:filters|string|in:{$searchableFields}",
'filters.*.value' => 'required_with:filters|string|max:255',
'filters.*.operator' => 'nullable|string|in:like,eq,starts_with,ends_with',
];
}
/**
* 검증 전 데이터 전처리
*
* 프론트엔드에서 빈 필터가 전송되는 경우를 처리합니다.
*/
protected function prepareForValidation(): void
{
// value가 비어있는 filters 자동 제거
$filters = $this->filters;
if (is_array($filters)) {
$filters = array_filter($filters, function ($filter) {
return ! empty($filter['value']);
});
// 인덱스 재정렬
$filters = array_values($filters);
$this->merge(['filters' => $filters ?: null]);
}
}
}
```
### 빈 필터 자동 제거 패턴
프론트엔드(DataSourceManager)에서 파라미터 치환 시 빈 값이 전송될 수 있습니다. 이를 처리하기 위해 `prepareForValidation()`에서 빈 필터를 자동 제거합니다:
| 상황 | 처리 방식 |
|------|----------|
| `filters[0][value]`가 빈 문자열 | 해당 filter 항목 제거 |
| 모든 filters가 제거됨 | `filters`를 `null`로 설정 |
| 유효한 필터만 존재 | 인덱스 재정렬 후 검증 진행 |
### 'all' 필드 검색
`field`가 `'all'`인 경우 모든 SEARCHABLE_FIELDS를 OR 조건으로 검색합니다. HasMultipleSearchFilters Trait에서 이를 처리합니다.
### API 사용 예시
```bash
# 단일 필터
GET /api/admin/users?filters[0][field]=name&filters[0][value]=홍
# 다중 필터 (AND 조건)
GET /api/admin/users?filters[0][field]=name&filters[0][value]=홍&filters[1][field]=email&filters[1][value]=example
# 연산자 지정
GET /api/admin/users?filters[0][field]=name&filters[0][value]=홍길동&filters[0][operator]=eq
```
### 다른 Repository에서 재사용
```php
class ProductRepository
{
use HasMultipleSearchFilters;
private const SEARCHABLE_FIELDS = ['name', 'sku', 'description'];
// 동일한 패턴으로 검색 구현
}
```
---
## 모듈에서 Repository 인터페이스 바인딩
모듈 내에서 Repository를 사용할 때도 반드시 인터페이스를 통해 DI해야 합니다.
### 모듈 Repository 구조
```text
modules/sirsoft-ecommerce/src/
├── Contracts/
│ └── Repositories/
│ └── ProductRepositoryInterface.php
├── Repositories/
│ └── ProductRepository.php
├── Services/
│ └── ProductService.php
└── Providers/
└── EcommerceServiceProvider.php
```
### 모듈 인터페이스 정의
```php
<?php
namespace Modules\Sirsoft\Ecommerce\Contracts\Repositories;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
interface ProductRepositoryInterface
{
public function findById(int $id): ?Product;
public function getPaginated(array $filters = []): LengthAwarePaginator;
public function create(array $data): Product;
public function update(int $id, array $data): Product;
public function delete(int $id): bool;
}
```
### 모듈 ServiceProvider에서 바인딩
모듈의 ServiceProvider에서 인터페이스와 구현체를 바인딩합니다:
```php
<?php
namespace Modules\Sirsoft\Ecommerce\Providers;
use Illuminate\Support\ServiceProvider;
use Modules\Sirsoft\Ecommerce\Contracts\Repositories\ProductRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\ProductRepository;
class EcommerceServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->registerRepositoryBindings();
}
private function registerRepositoryBindings(): void
{
$this->app->bind(ProductRepositoryInterface::class, ProductRepository::class);
// 추가 Repository 바인딩...
}
}
```
### 모듈 Service에서 인터페이스 사용
```php
<?php
namespace Modules\Sirsoft\Ecommerce\Services;
use Modules\Sirsoft\Ecommerce\Contracts\Repositories\ProductRepositoryInterface;
class ProductService
{
public function __construct(
private ProductRepositoryInterface $productRepository // ✅ 인터페이스 타입힌트
) {}
}
```
### 올바른 사용 vs 잘못된 사용
```php
// ❌ DON'T: 구체 클래스 직접 타입힌트
use Modules\Sirsoft\Ecommerce\Repositories\ProductRepository;
class ProductService
{
public function __construct(
private ProductRepository $productRepository // 구체 클래스 직접 의존
) {}
}
// ✅ DO: 인터페이스 타입힌트
use Modules\Sirsoft\Ecommerce\Contracts\Repositories\ProductRepositoryInterface;
class ProductService
{
public function __construct(
private ProductRepositoryInterface $productRepository // 인터페이스에 의존
) {}
}
```
### 핵심 원칙 요약
| 원칙 | 설명 |
|------|------|
| **인터페이스 의존** | Service/Controller는 Repository 인터페이스에 의존 |
| **ServiceProvider 바인딩** | 인터페이스-구현체 매핑은 ServiceProvider에서 수행 |
| **테스트 용이성** | Mock 객체로 쉽게 대체 가능 |
| **유연한 교체** | 바인딩만 변경하면 다른 구현체 사용 가능 |
---
## 관련 문서
- [컨트롤러 계층 구조](controllers.md) - Controller에서 Service 사용
- [검증 로직 구현](validation.md) - FormRequest 검증 규칙
- [index.md](index.md) - 백엔드 가이드 전체 목차
File diff suppressed because it is too large. Load diff
+211
View File
@@ -0,0 +1,211 @@
# 그누보드7 자주 쓰는 명령어 치트시트
## TL;DR (5초 요약)
```text
1. _bundled에서 레이아웃 JSON만 수정 → 확장 업데이트(--force)만 실행 (빌드 불필요)
2. _bundled에서 TSX/TS 수정 → build 필수 + 확장 업데이트(--force) 필요
3. 프론트엔드 테스트 → PowerShell 래퍼 필수 (powershell -Command)
4. 백엔드 테스트 → Bash 직접 실행 (php artisan test)
5. 코드 스타일 → vendor/bin/pint --dirty
```
---
## 빌드 vs 확장 업데이트
```
_bundled에서 레이아웃 JSON만 수정한 경우 → 빌드 불필요, 확장 업데이트(--force)만 필요
_bundled에서 TSX/TS 파일 수정한 경우 → 빌드 후 확장 업데이트(--force) 필요
```
| 수정 파일 유형 | 필요한 작업 |
|---------------|-------------|
| `*.json` (레이아웃만) | `{type}:update {id} --force` 실행 |
| `*.tsx`, `*.ts` + `*.json` | `{type}:build` + `{type}:update {id} --force` |
| `*.tsx`, `*.ts`만 | `{type}:build` + `{type}:update {id} --force` |
| `modules/**/resources/js/**/*.ts` (핸들러) | `module:build` + `module:update {id} --force` |
| `templates/**/src/**/*.tsx` (컴포넌트) | `template:build` + `template:update {id} --force` |
---
## 확장 업데이트 (_bundled → 활성 반영)
```bash
# 템플릿 업데이트 (templates/_bundled/** → templates/**)
php artisan template:update sirsoft-admin_basic --force
# 모듈 업데이트 (modules/_bundled/** → modules/**)
php artisan module:update sirsoft-ecommerce --force
# 플러그인 업데이트 (plugins/_bundled/** → plugins/**)
php artisan plugin:update sirsoft-payment --force
```
---
## 빌드 (Artisan 명령어 사용)
> **기본 빌드 경로**: `_bundled` 디렉토리. `--active` 옵션으로 활성 디렉토리 빌드 가능.
> `--watch` 모드는 자동으로 활성 디렉토리 사용 (실시간 브라우저 확인).
> 빌드 후 활성 반영: `module:update` / `template:update` / `plugin:update` 커맨드 사용.
```bash
# 코어 템플릿 엔진 (resources/js/core/template-engine/**)
php artisan core:build # 기본: 템플릿 엔진만 빌드
php artisan core:build --full # 전체 빌드 (npm run build)
php artisan core:build --watch # 파일 감시 모드
# 모듈 빌드 (기본: _bundled)
php artisan module:build sirsoft-ecommerce # _bundled에서 빌드
php artisan module:build --all # 모든 _bundled 모듈 빌드
php artisan module:build sirsoft-ecommerce --watch # 활성에서 watch
php artisan module:build sirsoft-ecommerce --active # 활성에서 빌드
# 템플릿 빌드 (기본: _bundled)
php artisan template:build sirsoft-admin_basic # _bundled에서 빌드
php artisan template:build --all # 모든 _bundled 템플릿 빌드
php artisan template:build sirsoft-admin_basic --watch # 활성에서 watch
php artisan template:build sirsoft-admin_basic --active # 활성에서 빌드
# 플러그인 빌드 (기본: _bundled)
php artisan plugin:build sirsoft-payment # _bundled에서 빌드
php artisan plugin:build --all # 모든 _bundled 플러그인 빌드
php artisan plugin:build sirsoft-payment --watch # 활성에서 watch
php artisan plugin:build sirsoft-payment --active # 활성에서 빌드
```
---
## 테스트
```powershell
# 프론트엔드 (PowerShell 래퍼 필수)
powershell -Command "npm run test:run"
powershell -Command "npm run test:run -- DataGrid"
powershell -Command "npm run test:run -- template-engine"
```
```bash
# 백엔드
php artisan test
php artisan test --filter=TestName
# _bundled 확장 테스트 (활성 디렉토리 복사 불필요)
php vendor/bin/phpunit modules/_bundled/sirsoft-ecommerce/tests
php vendor/bin/phpunit --filter=TestName modules/_bundled/sirsoft-ecommerce/tests
```
---
## 코드 스타일
```bash
vendor/bin/pint --dirty
```
---
## 마이그레이션
```bash
php artisan make:migration create_[table]_table
php artisan migrate
php artisan migrate:rollback
```
---
## 확장 시스템 Artisan
```bash
# 코어 업데이트
php artisan core:check-updates # 코어 업데이트 확인
php artisan core:update [--force] [--no-backup] [--no-maintenance] # 코어 업데이트 실행
# 모듈
php artisan module:list
php artisan module:install [identifier]
php artisan module:activate [identifier]
php artisan module:deactivate [identifier]
php artisan module:uninstall [identifier]
php artisan module:composer-install [identifier?] [--all]
php artisan module:cache-clear [identifier?]
php artisan module:seed [identifier] [--sample] [--count=key=value]
php artisan module:check-updates [identifier?]
php artisan module:update [identifier] [--force]
# 플러그인
php artisan plugin:list
php artisan plugin:install [identifier]
php artisan plugin:activate [identifier]
php artisan plugin:deactivate [identifier]
php artisan plugin:uninstall [identifier]
php artisan plugin:composer-install [identifier?] [--all]
php artisan plugin:cache-clear [identifier?]
php artisan plugin:seed [identifier] [--sample] [--count=key=value]
php artisan plugin:check-updates [identifier?]
php artisan plugin:update [identifier] [--force]
# 템플릿
php artisan template:list
php artisan template:install [identifier]
php artisan template:activate [identifier]
php artisan template:deactivate [identifier]
php artisan template:uninstall [identifier]
php artisan template:cache-clear
php artisan template:check-updates [identifier?]
php artisan template:update [identifier] [--layout-strategy=overwrite] [--force]
# Composer 의존성 (모듈/플러그인별 독립 vendor/)
php artisan extension:composer-install # 모든 모듈+플러그인
# 오토로드
php artisan extension:update-autoload
```
### SEO Artisan 커맨드
```bash
# SEO
php artisan seo:warmup # SEO 캐시 워밍업
php artisan seo:warmup --layout=shop/show # 특정 레이아웃만
php artisan seo:clear # 전체 SEO 캐시 삭제
php artisan seo:clear --layout=home # 특정 레이아웃만
php artisan seo:stats # 캐시 통계 출력
php artisan seo:generate-sitemap # Sitemap 생성 (큐 디스패치)
php artisan seo:generate-sitemap --sync # Sitemap 동기 생성
```
---
## 확장 업데이트 (CLI + API)
```bash
# 코어 업데이트
php artisan core:check-updates # 코어 업데이트 확인
php artisan core:update [--force] [--no-backup] [--no-maintenance] # 코어 업데이트 실행
# CLI (Artisan 커맨드)
php artisan module:check-updates [identifier?] # 모듈 업데이트 확인
php artisan module:update [identifier] [--force] # 모듈 업데이트 실행
php artisan plugin:check-updates [identifier?] # 플러그인 업데이트 확인
php artisan plugin:update [identifier] [--force] # 플러그인 업데이트 실행
php artisan template:check-updates [identifier?] # 템플릿 업데이트 확인
php artisan template:update [identifier] [--layout-strategy=overwrite] [--force] # 템플릿 업데이트 실행
# API 엔드포인트
POST /api/admin/core-update/check # 코어 업데이트 확인
GET /api/admin/core-update/changelog # 코어 변경 로그 조회
POST /api/admin/modules/check-updates # 모듈 업데이트 확인
POST /api/admin/modules/{moduleName}/update # 모듈 업데이트 실행
POST /api/admin/plugins/check-updates # 플러그인 업데이트 확인
POST /api/admin/plugins/{pluginName}/update # 플러그인 업데이트 실행
POST /api/admin/templates/check-updates # 템플릿 업데이트 확인
GET /api/admin/templates/{templateName}/check-modified-layouts # 수정된 레이아웃 확인
POST /api/admin/templates/{templateName}/update # 템플릿 업데이트 실행
```
> 상세: [extension/extension-update-system.md](extension/extension-update-system.md)
---
+849
View File
@@ -0,0 +1,849 @@
# 그누보드7 데이터베이스 개발 가이드
> 이 문서는 그누보드7의 데이터베이스 마이그레이션, 시더, 다국어 처리 규칙을 상세히 설명합니다.
---
## TL;DR (5초 요약)
```text
1. 마이그레이션: 한국어 comment 필수, down() 구현 필수
2. 네이밍: create_[table]_table, add_[col]_to_[table]_table
3. 로케일: config('app.supported_locales') 사용, 하드코딩 금지
4. 다국어 필드: JSON 구조 {"ko": "한글", "en": "English"}
5. 시더: 콘솔 메시지 필수, truncate() 대신 delete() 사용
6. 모듈/플러그인 down(): 테이블/컬럼/인덱스 존재 확인 후 삭제 (방어적 코딩 필수)
```
---
## 목차
- [마이그레이션](#마이그레이션)
- [시더](#시더)
- [로케일 설정](#로케일-설정)
- [다국어 (Backend)](#다국어-backend)
- [코어 테이블 다국어 지원](#코어-테이블-다국어-지원)
---
## 마이그레이션
**규칙**:
- 한국어 comment 필수
- enum/boolean은 값 설명 포함
- 외래키 제약조건 명시
- down() 메서드 구현
**파일명 네이밍 규칙**:
- **테이블 생성**: `create_[table_name]_table`
- **컬럼 추가**: `add_[column1]_and_[column2]_to_[table_name]_table`
- 단일 컬럼: `add_[column_name]_to_[table_name]_table`
- 다중 컬럼: 모든 컬럼명을 `_and_`로 연결
- **컬럼 삭제**: `remove_[column1]_and_[column2]_from_[table_name]_table`
- 단일 컬럼: `remove_[column_name]_from_[table_name]_table`
- 다중 컬럼: 모든 컬럼명을 `_and_`로 연결
- **컬럼 변경**: `modify_[column1]_and_[column2]_in_[table_name]_table`
- 단일 컬럼: `modify_[column_name]_in_[table_name]_table`
- 다중 컬럼: 모든 컬럼명을 `_and_`로 연결
- **복합 변경** (추가+삭제+변경): `update_[main_feature]_fields_in_[table_name]_table`
- 예: `update_user_profile_fields_in_users_table`
**예시**:
```bash
# 단일 컬럼 추가
add_email_to_users_table
# 다중 컬럼 추가
add_first_name_and_last_name_and_phone_to_users_table
# 단일 컬럼 삭제
remove_nickname_from_users_table
# 다중 컬럼 삭제
remove_old_status_and_legacy_flag_from_products_table
# 단일 컬럼 변경
modify_price_in_products_table
# 다중 컬럼 변경
modify_name_and_description_in_categories_table
# 복합 변경 (3개 이상의 컬럼 또는 추가/삭제/변경 혼합)
update_template_version_fields_in_templates_table
```
**패턴**:
```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 마이그레이션 실행
*/
public function up(): void
{
Schema::create('products', function (Blueprint $table) {
$table->id()->comment('상품 ID');
$table->string('name')->comment('상품명');
$table->text('description')->comment('상품 설명');
$table->decimal('price', 10, 2)->comment('상품 가격');
$table->foreignId('category_id')
->comment('카테고리 ID')
->constrained('product_categories')
->onDelete('cascade');
$table->boolean('is_active')
->default(true)
->comment('활성화 여부 (1: 활성화, 0: 비활성화)');
$table->enum('status', ['draft', 'published', 'archived'])
->default('draft')
->comment('상품 상태 (draft: 임시저장, published: 게시, archived: 보관)');
$table->foreignId('created_by')->nullable()->comment('생성자 ID');
$table->foreignId('updated_by')->nullable()->comment('수정자 ID');
$table->timestamps();
$table->softDeletes();
// 인덱스
$table->index('category_id');
$table->index('is_active');
});
}
/**
* 마이그레이션 롤백
*/
public function down(): void
{
Schema::dropIfExists('products');
}
};
```
### 모듈/플러그인 마이그레이션 down() 메서드
```
주의: 모듈/플러그인은 삭제 시 마이그레이션 롤백이 빈번하게 발생
필수: down() 메서드에서 테이블/컬럼/인덱스 존재 여부 확인 후 삭제
✅ 필수: Laravel 11+ 에서는 Schema::getIndexes() 사용 (getDoctrineSchemaManager 미지원)
✅ 필수: NOT NULL 변경 시 NULL 데이터 존재 여부 확인
```
#### 왜 방어적 코딩이 필요한가?
모듈/플러그인 삭제 시 `delete_data: true` 옵션으로 마이그레이션 롤백이 실행됩니다. 이때:
| 문제 상황 | 발생 원인 | 결과 |
|----------|----------|------|
| 테이블 미존재 | 이전 롤백 실패, 수동 삭제 | SQL 오류 발생 |
| 인덱스 미존재 | 부분 롤백, 스키마 불일치 | SQL 오류 발생 |
| NULL 데이터 존재 | NOT NULL 변경 시도 | SQL 오류 발생 |
**오류 발생 시 해당 마이그레이션 롤백이 실패하고, 이후 마이그레이션도 롤백되지 않습니다.**
#### down() 메서드 작성 규칙
**1. 테이블 생성 마이그레이션 (create_*_table)**
```php
// ✅ DO: dropIfExists 사용 (기본 패턴)
public function down(): void
{
Schema::dropIfExists('products');
}
// ❌ DON'T: drop 사용 (테이블 미존재 시 오류)
public function down(): void
{
Schema::drop('products'); // 금지
}
```
**2. 컬럼/인덱스 추가 마이그레이션 (add_*_to_*_table)**
```php
// ✅ DO: 테이블, 인덱스, 컬럼 존재 여부 확인 후 삭제
public function down(): void
{
// 1. 테이블 존재 여부 확인
if (! Schema::hasTable('ecommerce_product_images')) {
return;
}
// 2. 인덱스 존재 여부 확인 (Laravel 11+)
$tableName = Schema::getConnection()->getTablePrefix().'ecommerce_product_images';
$indexes = Schema::getIndexes('ecommerce_product_images');
$indexNames = array_column($indexes, 'name');
Schema::table('ecommerce_product_images', function (Blueprint $table) use ($tableName, $indexNames) {
// 인덱스 존재 시에만 삭제
if (in_array($tableName.'_temp_key_index', $indexNames)) {
$table->dropIndex(['temp_key']);
}
// 컬럼 존재 시에만 삭제
if (Schema::hasColumn('ecommerce_product_images', 'temp_key')) {
$table->dropColumn('temp_key');
}
});
}
// ❌ DON'T: 존재 여부 확인 없이 삭제 (금지)
public function down(): void
{
Schema::table('ecommerce_product_images', function (Blueprint $table) {
$table->dropIndex(['temp_key']); // 인덱스 미존재 시 오류
$table->dropColumn('temp_key'); // 컬럼 미존재 시 오류
});
}
```
**3. nullable 변경 마이그레이션**
```php
// ✅ DO: NULL 데이터 존재 여부 확인 후 NOT NULL 변경
public function down(): void
{
if (! Schema::hasColumn('products', 'category_id')) {
return;
}
// NULL 데이터가 있으면 NOT NULL 변경 불가
$hasNullData = DB::table('products')
->whereNull('category_id')
->exists();
if (! $hasNullData) {
Schema::table('products', function (Blueprint $table) {
$table->unsignedBigInteger('category_id')
->nullable(false)
->comment('카테고리 ID')
->change();
});
}
}
// ❌ DON'T: NULL 데이터 확인 없이 NOT NULL 변경 (금지)
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
// NULL 데이터 존재 시 SQL 오류 발생
$table->unsignedBigInteger('category_id')->nullable(false)->change();
});
}
```
#### down() 메서드 체크리스트
모듈/플러그인 마이그레이션 `down()` 작성 시 반드시 확인:
```
□ 1. 테이블 존재 여부: Schema::hasTable() 사용
□ 2. 컬럼 존재 여부: Schema::hasColumn() 사용
□ 3. 인덱스 존재 여부: Schema::getIndexes() 사용 (Laravel 11+)
□ 4. NOT NULL 변경 시: NULL 데이터 존재 여부 확인
□ 5. 외래키 삭제 시: 외래키 존재 여부 확인
□ 6. dropIfExists() 사용: 테이블 삭제 시
```
#### Laravel 버전별 인덱스 확인 방법
```php
// Laravel 11+ (권장)
$indexes = Schema::getIndexes('table_name');
$indexNames = array_column($indexes, 'name');
if (in_array('index_name', $indexNames)) {
// 인덱스 존재
}
// Laravel 10 이하 (Doctrine DBAL 필요)
$sm = Schema::getConnection()->getDoctrineSchemaManager();
$indexes = $sm->listTableIndexes($tableName);
if (isset($indexes['index_name'])) {
// 인덱스 존재
}
```
---
### 외래 키 및 삭제 정책
```
필수: Service에서 명시적 삭제 (DB CASCADE에 의존한 삭제 금지)
필수: 어플리케이션(Service)에서 명시적으로 연관 데이터 삭제
✅ 필수: 삭제 순서 보장, 훅 실행, 로깅, 파일 삭제 등 어플리케이션 로직 처리
```
#### 왜 DB CASCADE에 의존하면 안 되는가?
DB CASCADE를 사용하면 다음 문제가 발생합니다:
| 문제 | 설명 |
|------|------|
| **훅 미실행** | `before_delete`, `after_delete` 훅이 실행되지 않음 |
| **파일 미삭제** | Storage에 저장된 파일이 남음 (이미지, 첨부파일 등) |
| **로깅 불가** | 삭제 이력을 추적할 수 없음 |
| **순서 미보장** | 삭제 순서를 제어할 수 없음 |
| **디버깅 어려움** | 어떤 데이터가 삭제되었는지 파악 어려움 |
#### 마이그레이션 작성 규칙
**마이그레이션에서 CASCADE 설정은 허용**되지만, **삭제 시에는 어플리케이션에서 명시적으로 처리**해야 합니다:
```php
// ✅ 마이그레이션: CASCADE 설정 가능 (안전망 역할)
$table->foreignId('product_id')
->comment('상품 ID')
->constrained('products')
->onDelete('cascade'); // DB 레벨 안전망
// ✅ 마이그레이션: RESTRICT 설정 (삭제 차단이 필요한 경우)
$table->foreignId('product_id')
->comment('상품 ID')
->constrained('products')
->restrictOnDelete(); // 참조 중이면 삭제 불가
```
#### Service 삭제 메서드 작성 규칙
**모든 연관 데이터는 Service에서 명시적으로 삭제**해야 합니다:
```php
// ✅ DO: 명시적 삭제 (필수)
public function delete(Product $product): bool
{
HookManager::doAction('module.entity.before_delete', $product);
return DB::transaction(function () use ($product) {
// 1. 파일 물리적 삭제 (Storage)
$this->deleteProductImageFiles($product);
// 2. 연관 데이터 명시적 삭제 (순서 중요)
$product->images()->delete();
$product->options()->delete();
$product->additionalOptions()->delete();
$product->labelAssignments()->delete();
$product->logs()->delete();
$product->notice()->delete();
$product->categories()->detach(); // 중간 테이블
// 3. 메인 레코드 삭제
$result = $this->repository->delete($product);
HookManager::doAction('module.entity.after_delete', $product);
return $result;
});
}
// ❌ DON'T: CASCADE에 의존 (금지)
public function delete(Product $product): bool
{
// 연관 데이터 삭제 없이 바로 삭제 - 금지!
return $this->repository->delete($product);
}
```
#### 삭제 메서드 체크리스트
Service에서 `delete()` 메서드 작성 시 반드시 확인:
```
□ 1. 훅 실행 (before_delete, after_delete)
□ 2. DB 트랜잭션 사용
□ 3. 파일 삭제 (Storage에 저장된 이미지, 첨부파일 등)
□ 4. 모든 HasMany 관계 명시적 삭제
□ 5. 모든 HasOne 관계 명시적 삭제
□ 6. 모든 BelongsToMany 중간 테이블 detach
□ 7. TODO 주석으로 미구현 연관 데이터 명시
□ 8. 메인 레코드 삭제
```
#### 예외: RESTRICT 사용 케이스
비즈니스 규칙상 삭제를 차단해야 하는 경우 `RESTRICT`를 사용합니다:
```php
// 주문 이력이 있는 상품은 삭제 불가
$table->foreignId('product_id')
->constrained('products')
->restrictOnDelete(); // ✅ 적절한 사용
// Service에서 삭제 전 체크
public function checkCanDelete(Product $product): array
{
$ordersCount = OrderOption::where('product_id', $product->id)->count();
return [
'canDelete' => $ordersCount === 0,
'reason' => $ordersCount > 0
? __('messages.has_order_history', ['count' => $ordersCount])
: null,
];
}
```
### 시더
**규칙**:
- 콘솔 메시지 (시작/완료/삭제 건수) 필수
- 메서드 분리 (delete*, create*)
- `truncate()` 대신 `delete()` 사용
- PHPDoc 주석 한국어
#### 시더 디렉토리 구조
```
설치 필수 시더와 샘플(개발용) 시더는 반드시 분리
✅ 설치 시더: database/seeders/ 루트에 배치
✅ 샘플 시더: database/seeders/Sample/ 하위에 배치
✅ 기본 실행(db:seed, module:seed, plugin:seed)은 설치 시더만 실행
✅ --sample 옵션 시에만 샘플 시더 추가 실행
```
**디렉토리 구조**:
```
database/seeders/
├── DatabaseSeeder.php # 설치 시더 호출 + 조건부 샘플 호출
├── AdminUserSeeder.php # 설치 필수
├── RolePermissionSeeder.php # 설치 필수
└── Sample/ # 샘플(개발용) 시더
├── DummyUserSeeder.php
└── TemplateSeeder.php
```
**`HasSampleSeeders` 트레이트** (`app/Traits/HasSampleSeeders.php`):
- DatabaseSeeder에서 `use HasSampleSeeders;` 사용
- `shouldIncludeSample()`: `--sample` 옵션 여부 확인
- 커맨드에서 `setIncludeSample()` 호출로 전파
**DatabaseSeeder 패턴**:
```php
use App\Traits\HasSampleSeeders;
class DatabaseSeeder extends Seeder
{
use HasSampleSeeders;
public function run(): void
{
// 설치 필수 시더 (항상 실행)
$this->call([
AdminUserSeeder::class,
]);
// 샘플 시더 (--sample 옵션 시에만 실행)
if ($this->shouldIncludeSample()) {
$this->call([
Sample\DummyUserSeeder::class,
]);
}
}
}
```
**실행 명령어**:
```bash
# 설치 시더만 (기본)
php artisan db:seed
php artisan module:seed sirsoft-ecommerce
# 설치 + 샘플
php artisan db:seed --sample
php artisan module:seed sirsoft-ecommerce --sample
# 개별 샘플 시더
php artisan db:seed --class="Database\Seeders\Sample\DummyUserSeeder"
php artisan module:seed sirsoft-ecommerce --class=Sample\\ProductSeeder
```
**패턴**:
```php
<?php
namespace Database\Seeders\Core;
use Illuminate\Database\Seeder;
use App\Models\User;
use App\Models\Role;
class AdminUserSeeder extends Seeder
{
/**
* 기본 관리자 정보
*/
private array $defaultAdminUser = [
'name' => '관리자',
'email' => 'admin@example.com',
'password' => 'password',
];
/**
* 시더 실행
*/
public function run(): void
{
$this->command->info('기본 관리자 사용자 생성을 시작합니다.');
// 기존 데이터 삭제
$this->deleteExistingUsers();
// 새 데이터 생성
$this->createAdminUser($this->defaultAdminUser);
$this->command->info('기본 관리자 사용자가 성공적으로 생성되었습니다.');
}
/**
* 기존 사용자 삭제
*
* @return void
*/
private function deleteExistingUsers(): void
{
$deletedCount = User::where('email', $this->defaultAdminUser['email'])->delete();
if ($deletedCount > 0) {
$this->command->warn("기존 관리자 사용자 {$deletedCount}건을 삭제했습니다.");
}
}
/**
* 관리자 사용자 생성
*
* @param array $userData 사용자 데이터
* @return void
*/
private function createAdminUser(array $userData): void
{
$adminRole = Role::where('name', 'admin')->first();
$user = User::create([
'name' => $userData['name'],
'email' => $userData['email'],
'password' => bcrypt($userData['password']),
'email_verified_at' => now(),
]);
$user->roles()->attach($adminRole->id);
$this->command->info("관리자 사용자가 생성되었습니다: {$user->email}");
}
}
```
### 로케일 설정
```
MANDATORY: supported_locales와 translatable_locales는 config/app.php에서 관리
✅ 필수: 하드코딩 금지, config() 함수 사용 필수
```
#### supported_locales vs translatable_locales
**위치**: `config/app.php`
```php
// config/app.php
'supported_locales' => ['ko', 'en'], // UI 언어 전환에 사용
'translatable_locales' => ['ko', 'en'], // 다국어 필드 데이터 저장에 사용
```
#### supported_locales
**용도**: 시스템에서 지원하는 모든 UI 언어 목록
**사용처**:
- `SetLocale` 미들웨어: 사용자 언어 검증
- 템플릿 엔진: 언어 선택자 옵션 생성 (`$locales` 전역 변수)
- 프론트엔드: `template.json`의 `locales` 필드 (템플릿별 지원 언어)
**규칙**:
- 반드시 `config('app.supported_locales')`로 접근
- 하드코딩(`['ko', 'en']`) 금지
- 번역 파일(`/lang/{locale}/`)이 존재해야 함
**예시**:
```php
// ✅ DO: config 함수 사용
$supportedLocales = config('app.supported_locales', ['ko', 'en']);
if (in_array($locale, $supportedLocales)) {
// ...
}
// ❌ DON'T: 하드코딩
if (in_array($locale, ['ko', 'en'])) { // 금지
// ...
}
```
#### translatable_locales
**용도**: 다국어 필드(JSON 타입)에서 허용하는 언어 목록
**사용처**:
- 다국어 테이블 필드 (`permissions.name`, `roles.description` 등)
- FormRequest 검증 (예: `UpdateModuleRequest`)
- Model Accessor/Mutator (`getLocalizedName()` 등)
**규칙**:
- 반드시 `config('app.translatable_locales')`로 접근
- 번역 파일이 없어도 데이터 저장은 허용 (DB 데이터만)
- 새 언어 추가 시 이 배열에 언어 코드 추가
**예시**:
```php
// ✅ DO: FormRequest에서 사용
public function rules(): array
{
$locales = config('app.translatable_locales', ['ko', 'en']);
$rules = [];
foreach ($locales as $locale) {
$rules["name.{$locale}"] = ['required', 'string'];
}
return $rules;
}
// ✅ DO: Model에서 사용
public function getLocalizedName(?string $locale = null): string
{
$locale = $locale ?? app()->getLocale();
$translatable = config('app.translatable_locales', ['ko', 'en']);
// Locale별 폴백 처리
// ...
}
```
#### 두 설정의 차이점
| 구분 | supported_locales | translatable_locales |
|------|------------------|---------------------|
| **용도** | UI 언어 전환 | 다국어 필드 데이터 |
| **번역 파일** | 필수 (`/lang/{locale}/`) | 선택 (없어도 됨) |
| **사용 예** | 언어 선택자, 미들웨어 | DB JSON 필드, FormRequest |
| **검증 위치** | 런타임 (미들웨어) | 데이터 입력 시 (FormRequest) |
#### 새 언어 추가 절차
1. **`config/app.php` 업데이트**:
```php
'supported_locales' => ['ko', 'en', 'ja'], // 일본어 추가
'translatable_locales' => ['ko', 'en', 'ja'],
```
2. **번역 파일 생성** (supported_locales에 추가한 경우):
```bash
mkdir -p lang/ja
# 모든 번역 파일 복사 및 번역
```
3. **템플릿 메타데이터 업데이트**:
```json
// templates/sirsoft-admin_basic/template.json
{
"locales": ["ko", "en", "ja"]
}
```
### 다국어 (Backend)
**기본 지원 언어**:
- **한국어 (ko)**: 기본 언어
- **영어 (en)**: 필수 지원 언어
**규칙**:
- 모든 다국어 파일은 `ko`, `en` 두 언어를 필수로 제공
- 새로운 기능 추가 시 반드시 두 언어 모두 작성
- **언어 목록은 `config('app.supported_locales')`에서 관리**
**위치**:
- 코어: `/lang/{ko,en}/`
- 모듈: `/modules/[vendor-module]/src/lang/{ko,en}/`
- 플러그인: `/plugins/[vendor-plugin]/src/lang/{ko,en}/`
**네이밍**:
- 코어: 접두사 없음
- 모듈: `[vendor-module]::` 접두사 (예: `sirsoft-ecommerce::`)
- 플러그인: `[vendor-plugin]::` 접두사
**예시**:
```php
// modules/_bundled/sirsoft-ecommerce/src/lang/ko/products.php
return [
'title' => '상품 관리',
'create' => '상품 생성',
'edit' => '상품 수정',
'delete_confirm' => '이 상품을 삭제하시겠습니까?',
'messages' => [
'created' => '상품이 생성되었습니다.',
'updated' => '상품이 수정되었습니다.',
'deleted' => '상품이 삭제되었습니다.',
],
];
// 사용
__('sirsoft-ecommerce::products.title');
__('sirsoft-ecommerce::products.messages.created');
```
### 코어 테이블 다국어 지원
```
MANDATORY: permissions, roles, menus, modules, plugins 테이블 다국어 필수
✅ 필수: JSON 구조 사용 {"ko": "한국어", "en": "English"}
```
**지원 테이블 및 필드**:
| 테이블 | 다국어 필드 |
|--------|------------|
| permissions | name, description |
| roles | name, description |
| menus | name |
| modules | name, description |
| plugins | name, description |
**JSON 구조**:
```json
{
"ko": "한국어 텍스트",
"en": "English text"
}
```
**모델 사용법**:
```php
// 현재 로케일의 이름 반환
$permission->getLocalizedName(); // 현재 로케일
$permission->getLocalizedName('ko'); // 한국어
$permission->getLocalizedName('en'); // 영어
// Accessor 사용
$permission->localized_name; // 현재 로케일
$permission->localized_description; // 현재 로케일
// 예시
app()->setLocale('ko');
echo $permission->getLocalizedName(); // "사용자 조회"
app()->setLocale('en');
echo $permission->getLocalizedName(); // "View Users"
```
**시더 작성 규칙**:
```php
// ✅ DO: 다국어 배열 구조
Permission::create([
'identifier' => 'users.read',
'name' => [
'ko' => '사용자 조회',
'en' => 'View Users',
],
'description' => [
'ko' => '사용자 목록을 조회할 수 있습니다.',
'en' => 'Can view user list.',
],
]);
Menu::create([
'name' => [
'ko' => '대시보드',
'en' => 'Dashboard',
],
'slug' => 'dashboard',
]);
// ❌ DON'T: 문자열 사용 (레거시, 권장하지 않음)
Permission::create([
'identifier' => 'users.read',
'name' => '사용자 조회', // 지양
]);
```
**ModuleInterface, PluginInterface 다국어 지원**:
```php
// ✅ DO: 다국어 배열 반환 (권장)
public function getName(): array
{
return [
'ko' => '이커머스 모듈',
'en' => 'Ecommerce Module',
];
}
public function getDescription(): array
{
return [
'ko' => '온라인 쇼핑몰 기능을 제공합니다.',
'en' => 'Provides online shopping features.',
];
}
// 역호환: 문자열 반환 (자동 변환됨)
public function getName(): string
{
return 'Ecommerce Module'; // 자동으로 ['ko' => '...', 'en' => '...']로 변환
}
```
**FormRequest 역호환성 처리**:
```php
// UpdateModuleRequest.php 예시
protected function prepareForValidation(): void
{
$locales = config('app.translatable_locales', ['ko', 'en']);
// name이 문자열로 들어온 경우 배열로 자동 변환
if ($this->has('name') && is_string($this->name)) {
$nameArray = [];
foreach ($locales as $locale) {
$nameArray[$locale] = $this->name;
}
$this->merge(['name' => $nameArray]);
}
}
```
**폴백 동작**:
1. 요청한 로케일에 번역이 있으면 반환
2. 없으면 'ko' (기본 로케일)로 폴백
3. 'ko'도 없으면 'en'으로 폴백
4. 둘 다 없으면 빈 문자열 반환
**엣지 케이스**:
```php
// 한국어만 있는 경우
$permission->name = ['ko' => '테스트'];
$permission->getLocalizedName('en'); // "테스트" (ko로 폴백)
// 영어만 있는 경우
$role->name = ['en' => 'Test'];
$role->getLocalizedName('ko'); // "Test" (en으로 폴백)
// 빈 번역
$menu->name = ['ko' => '메뉴', 'en' => ''];
$menu->getLocalizedName('en'); // "" (빈 문자열 반환)
// NULL 값
$module->description = null;
$module->getLocalizedDescription(); // "" (빈 문자열 반환)
```
---
+180
View File
@@ -0,0 +1,180 @@
# 그누보드7 확장 시스템 개발 가이드
> 이 문서는 그누보드7의 확장 시스템 전체 개요와 각 상세 문서에 대한 링크를 제공합니다.
---
## 핵심 원칙
```text
필수: /modules, /plugins 디렉토리 스캔으로 자동 발견 (composer.json 하드코딩 금지)
필수: 모든 확장 작업은 Artisan 커맨드로 수행
✅ 필수: 훅 시스템을 통한 코어 수정 최소화
```
---
## 확장 시스템 개요
G7은 **동적 로딩** 기반의 확장 시스템을 제공합니다:
- **모듈**: 독립적인 비즈니스 기능 단위 (예: 이커머스, 블로그)
- **플러그인**: 특정 기능 확장 (예: 결제, 배송)
- **템플릿**: UI 렌더링 및 레이아웃 정의 (Admin, User)
- **훅 시스템**: 코어 수정 없이 로직 확장
---
## 문서 구조
### 훅 시스템
| 문서 | 설명 |
|------|------|
| [hooks.md](hooks.md) | Action/Filter Hook, 리스너 구현, 우선순위 |
### 모듈 개발
| 문서 | 설명 |
|------|------|
| [module-basics.md](module-basics.md) | AbstractModule, 디렉토리 구조, 네이밍 규칙 |
| [module-routing.md](module-routing.md) | API/Web 라우트, 자동 Prefix, routes.json |
| [module-layouts.md](module-layouts.md) | 레이아웃 등록, 오버라이드, 상속 |
| [module-commands.md](module-commands.md) | Artisan 커맨드 (install, activate, uninstall) |
| [module-i18n.md](module-i18n.md) | 백엔드/프론트엔드 다국어 분리 |
### 레이아웃 확장
| 문서 | 설명 |
|------|------|
| [layout-extensions.md](layout-extensions.md) | 동적 UI 주입 (Overlay, Extension Point), 템플릿 오버라이드 |
### 플러그인 개발
| 문서 | 설명 |
|------|------|
| [plugin-development.md](plugin-development.md) | PluginInterface, 격리성, 의존성 원칙 |
### 확장 관리자
| 문서 | 설명 |
|------|------|
| [extension-manager.md](extension-manager.md) | Composer 오토로드, 서비스 등록 |
### 템플릿 시스템
| 문서 | 설명 |
|------|------|
| [template-basics.md](template-basics.md) | Admin/User 템플릿, 메타데이터, 버전 히스토리 |
| [template-routing.md](template-routing.md) | 라우트 정의, 언어 파일 경로 규칙 |
| [template-security.md](template-security.md) | API 서빙, 파일 확장자 화이트리스트 |
| [template-caching.md](template-caching.md) | 캐시 계층, 무효화 전략 |
| [template-commands.md](template-commands.md) | Artisan 커맨드 (install, activate) |
### 권한 및 메뉴 시스템
| 문서 | 설명 |
|------|------|
| [permissions.md](permissions.md) | Role, Permission, 자동 관리 |
| [menus.md](menus.md) | 메뉴 권한, 시더 |
---
## 확장 타입별 네이밍 규칙
| 타입 | 디렉토리명 | 네임스페이스 | Composer 경로 |
|------|-----------|-------------|---------------|
| 모듈 | `vendor-module` | `Modules\Vendor\Module\` | `modules/vendor-module` |
| 플러그인 | `vendor-plugin` | `Plugins\Vendor\Plugin\` | `plugins/vendor-plugin` |
| 템플릿 | `vendor-template` | - | `templates/vendor-template` |
**예시**:
- 모듈: `sirsoft-ecommerce` → `Modules\Sirsoft\Ecommerce\`
- 플러그인: `sirsoft-payment` → `Plugins\Sirsoft\Payment\`
- 템플릿: `sirsoft-admin_basic`
---
## 훅 네이밍 규칙
```
[vendor-module].[entity].[action]_[timing]
```
**타이밍 접미사**:
- `before_*`: 작업 실행 전
- `after_*`: 작업 실행 후
- `on_*`: 특정 이벤트 발생 시
- `filter_*`: 데이터 필터링
**예시**:
```
sirsoft-ecommerce.product.before_create
sirsoft-ecommerce.product.after_update
sirsoft-ecommerce.product.filter_create_data
sirsoft-ecommerce.order.on_payment_success
```
---
## 주요 Artisan 커맨드
### 모듈 관련
```bash
php artisan module:list # 모듈 목록 조회
php artisan module:install [identifier] # 모듈 설치
php artisan module:activate [identifier] # 모듈 활성화
php artisan module:deactivate [identifier] # 모듈 비활성화
php artisan module:uninstall [identifier] # 모듈 제거
php artisan module:cache-clear # 모듈 캐시 정리
```
### 템플릿 관련
```bash
php artisan template:list # 템플릿 목록 조회
php artisan template:install [identifier] # 템플릿 설치
php artisan template:activate [identifier] # 템플릿 활성화
php artisan template:deactivate [identifier] # 템플릿 비활성화
php artisan template:uninstall [identifier] # 템플릿 제거
php artisan template:cache-clear # 템플릿 캐시 정리
```
### 확장 관리자
```bash
php artisan extension:update-autoload # Composer 오토로드 업데이트
```
---
## 빠른 시작 가이드
### 새 모듈 개발 시
1. [module-basics.md](module-basics.md) - 기본 구조 이해
2. [module-routing.md](module-routing.md) - 라우트 설정
3. [module-i18n.md](module-i18n.md) - 다국어 지원
4. [module-layouts.md](module-layouts.md) - 레이아웃 등록 (필요시)
5. [hooks.md](hooks.md) - 훅 리스너 구현 (필요시)
### 새 플러그인 개발 시
1. [plugin-development.md](plugin-development.md) - 플러그인 구조 이해
2. [hooks.md](hooks.md) - 훅 리스너 구현
### 템플릿 커스터마이징 시
1. [template-basics.md](template-basics.md) - 템플릿 타입 이해
2. [template-routing.md](template-routing.md) - 라우트/언어 파일
3. [template-security.md](template-security.md) - 보안 규칙
---
## 관련 문서
- [AGENTS.md](../../../AGENTS.md) - 프로젝트 개발 가이드
- [backend/](../backend/) - 백엔드 개발 가이드
- [frontend/](../frontend/) - 프론트엔드 개발 가이드
- [database-guide.md](../database-guide.md) - 데이터베이스 가이드
+213
View File
@@ -0,0 +1,213 @@
# Changelog 규칙 (Changelog Rules)
> 코어 및 확장(모듈, 플러그인, 템플릿)의 변경사항을 CHANGELOG.md에 기록하는 규정
## TL;DR (5초 요약)
```text
1. 확장/코어 버전 업 시 CHANGELOG.md에 변경사항 기록 필수 (미기록 시 버전 업 불가)
2. 형식: Keep a Changelog 표준 (## [버전] - 날짜 / ### 카테고리 / - 항목)
3. 허용 카테고리: Added, Changed, Deprecated, Removed, Fixed, Security
4. 확장 위치: 각 확장의 루트 디렉토리 (modules/_bundled/vendor-module/CHANGELOG.md)
5. 코어 위치: 프로젝트 루트 /CHANGELOG.md (코어 버전 변경 시 필수)
```
---
## 목차
1. [CHANGELOG.md 작성 규칙](#1-changelogmd-작성-규칙)
2. [카테고리 정의](#2-카테고리-정의)
3. [파일 위치](#3-파일-위치)
4. [ChangelogParser 헬퍼](#4-changelogparser-헬퍼)
5. [API 엔드포인트](#5-api-엔드포인트)
6. [관리 화면 표시](#6-관리-화면-표시)
7. [템플릿 예시](#7-템플릿-예시)
---
## 1. CHANGELOG.md 작성 규칙
### 필수 사항
```text
확장 버전 업 시 CHANGELOG.md에 변경사항 기록 필수
미기록 시 버전 업 작업 불완전으로 간주
```
- **형식**: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/) 표준 준수
- **버전 관리**: [Semantic Versioning](https://semver.org/lang/ko/) 준수
- **작성 언어**: 한국어 (확장 대상이 한국어 사용자)
- **날짜 형식**: YYYY-MM-DD (ISO 8601)
- **최신 버전**: 파일 상단에 위치 (역순)
### 버전 섹션 형식
```markdown
## [버전] - YYYY-MM-DD
```
예: `## [0.1.2] - 2026-02-25`
### 항목 형식
```markdown
### 카테고리
- 변경사항 설명
```
---
## 2. 카테고리 정의
| 카테고리 | 설명 | 사용 시점 |
|----------|------|----------|
| `Added` | 새 기능 추가 | 새로운 기능, 화면, API 엔드포인트 추가 |
| `Changed` | 기존 기능 변경 | 기존 동작 수정, 성능 개선, UI 변경 |
| `Deprecated` | 향후 제거 예정 | 향후 삭제될 기능 경고 |
| `Removed` | 기능 제거 | 기존 기능/API 삭제 |
| `Fixed` | 버그 수정 | 기존 버그, 오류 수정 |
| `Security` | 보안 취약점 수정 | 보안 관련 수정 |
---
## 3. 파일 위치
### 확장 (모듈/플러그인/템플릿)
```text
modules/_bundled/vendor-module/CHANGELOG.md # 모듈 (예: sirsoft-board)
plugins/_bundled/vendor-plugin/CHANGELOG.md # 플러그인 (예: sirsoft-tosspayments)
templates/_bundled/vendor-template/CHANGELOG.md # 템플릿 (예: sirsoft-admin_basic)
```
- _bundled 디렉토리에서 작업 (활성 디렉토리 직접 수정 금지)
- 업데이트 프로세스를 통해 활성 디렉토리로 복사
### 코어
```text
/CHANGELOG.md # 프로젝트 루트
```
- **코어 버전 변경 시 CHANGELOG.md에 변경사항 기록 필수** (미기록 시 버전 업 불가)
- 확장과 동일한 Keep a Changelog 형식 사용
- 코어 버전 변경 대상 파일: `config/app.php`, `.env.example`, `.env.testing`
- 버전 변경 시 반드시 CHANGELOG.md에 해당 버전 섹션 추가 후 버전 파일 수정
---
## 4. ChangelogParser 헬퍼
**파일**: `app/Extension/Helpers/ChangelogParser.php`
### 주요 메서드
| 메서드 | 설명 | 반환 |
|--------|------|------|
| `parse(string $filePath)` | CHANGELOG.md 전체 파싱 | `array` (버전별 구조화 배열) |
| `getVersionRange(string $filePath, string $from, string $to)` | 특정 범위 엔트리 추출 | `array` (from 초과 ~ to 이하) |
| `resolveChangelogPath(string $basePath, string $identifier, ?string $source)` | 소스별 경로 결정 | `?string` |
### 반환 형식
```php
[
[
'version' => '0.1.2',
'date' => '2026-02-25',
'categories' => [
['name' => 'Added', 'items' => ['새 기능 A']],
['name' => 'Fixed', 'items' => ['버그 B 수정']],
],
],
]
```
---
## 5. API 엔드포인트
```text
GET /api/admin/modules/{identifier}/changelog
GET /api/admin/plugins/{identifier}/changelog
GET /api/admin/templates/{identifier}/changelog
```
### 쿼리 파라미터
| 파라미터 | 설명 | 기본값 |
|----------|------|--------|
| `source` | CHANGELOG.md 읽을 위치 | `active` |
| `from_version` | 이 버전 초과 항목만 반환 | - |
| `to_version` | 이 버전 이하만 반환 | - |
### 사용 시나리오
- **상세 모달**: `GET .../changelog` (전체 조회)
- **업데이트 모달**: `GET .../changelog?source=bundled&from_version=0.1.1&to_version=0.1.2` (변경분)
---
## 6. 관리 화면 표시
### 업데이트 모달
- **전역 상태**: `_global.updateChangelog` (3개 확장 타입 공통)
- **표시 조건**: `updateChangelog && updateChangelog.length > 0`
- **표시 위치**: 버전 정보 아래, 외부 링크(github_changelog_url) 위
- **스타일**: blue 배경 (`bg-blue-50 dark:bg-blue-900/20`)
- **우선순위**: 인라인 changelog > 외부 링크 (둘 다 있으면 둘 다 표시)
### 상세 모달
- **전역 상태**: `_global.moduleChangelog`, `_global.pluginChangelog`, `_global.templateChangelog` (타입별)
- **섹션 ID**: `changelog_section`
- **아이콘**: `clock-rotate-left`
- **empty state**: `_global.xxxChangelog`가 비어있으면 "변경 내역 정보가 없습니다." 표시
### 메인 리스트 액션 흐름
```text
# 업데이트 버튼 클릭 시:
setState(selectedX) → apiCall(changelog?source=bundled&from_version=...&to_version=...)
→ onSuccess: setState(updateChangelog) + openModal(update_modal)
→ onError: setState(updateChangelog=[]) + openModal(update_modal)
# 상세 보기 버튼 클릭 시:
setState(selectedX) → apiCall(changelog)
→ onSuccess: setState(xChangelog) + openModal(detail_modal)
→ onError: setState(xChangelog=[]) + openModal(detail_modal)
```
---
## 7. 템플릿 예시
새 확장을 만들 때 아래 템플릿을 사용합니다:
```markdown
# Changelog
이 프로젝트의 모든 주요 변경사항을 기록합니다.
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [0.1.0] - YYYY-MM-DD
### Added
- 초기 기능 구현
```
---
## 관련 파일
- `app/Extension/Helpers/ChangelogParser.php` — Changelog 파서
- `app/Http/Requests/Extension/ChangelogRequest.php` — FormRequest 검증
- `app/Http/Controllers/Api/Admin/ModuleController.php` — `changelog()` 메서드
- `app/Http/Controllers/Api/Admin/PluginController.php` — `changelog()` 메서드
- `app/Http/Controllers/Api/Admin/TemplateController.php` — `changelog()` 메서드
- `app/Services/ModuleService.php` — `getModuleChangelog()` 메서드
- `app/Services/PluginService.php` — `getPluginChangelog()` 메서드
- `app/Services/TemplateService.php` — `getTemplateChangelog()` 메서드
+349
View File
@@ -0,0 +1,349 @@
# ExtensionManager (확장 관리자)
> **소스**: extension-guide.md에서 분리
> **관련 문서**: [module-basics.md](module-basics.md), [plugin-development.md](plugin-development.md)
---
## TL;DR (5초 요약)
```text
1. composer.json 수정 없음 - 런타임 오토로드 방식 사용
2. 캐시 파일: bootstrap/cache/autoload-extensions.php (gitignore 대상)
3. 설치/삭제 시 자동 갱신: updateComposerAutoload() → generateAutoloadFile()
4. 수동 갱신: php artisan extension:update-autoload
5. _bundled/_pending 디렉토리 자동 제외 (str_starts_with($name, '_') → skip)
```
---
## 목차
1. [개요](#개요)
2. [런타임 오토로드 방식](#런타임-오토로드-방식)
3. [Composer 의존성 관리](#composer-의존성-관리)
4. [식별자 검증 규칙](#식별자-검증-규칙-validextensionidentifier)
5. [Artisan 커맨드](#artisan-커맨드)
6. [서비스 등록](#서비스-등록)
---
## 개요
모듈과 플러그인에서 공통으로 사용되는 기능을 담당하는 관리자 클래스입니다. 주로 오토로드 설정 관리를 담당합니다.
**주요 역할**:
- 런타임 오토로드 캐시 파일 생성
- 모듈/플러그인 설치 시 PSR-4 네임스페이스 등록
- Composer ClassLoader에 동적 등록
---
## 런타임 오토로드 방식
### 핵심 원칙
```
중요: composer.json을 수정하지 않음
✅ 결정: 런타임 오토로드 방식 사용 (캐시 파일 기반)
✅ 장점: 버전 관리 충돌 없음, composer dump-autoload 불필요
```
### 동작 방식
```
모듈/플러그인 설치/삭제 시
↓
ExtensionManager::updateComposerAutoload() 호출
↓
bootstrap/cache/autoload-extensions.php 파일 생성 (gitignore 대상)
↓
public/index.php 또는 artisan 실행 시
↓
$loader->addPsr4()로 런타임에 네임스페이스 등록
```
### 캐시 파일 구조
```php
// bootstrap/cache/autoload-extensions.php
return [
'psr4' => [
"Modules\\Sirsoft\\Ecommerce\\" => "modules/sirsoft-ecommerce/src/",
"Plugins\\Sirsoft\\Payment\\" => "plugins/sirsoft-payment/src/",
],
'classmap' => [
"modules/sirsoft-ecommerce/module.php",
"plugins/sirsoft-payment/plugin.php",
],
'files' => [],
'vendor_autoloads' => [
"modules/sirsoft-ecommerce/vendor/autoload.php",
],
];
```
- `vendor_autoloads`: 모듈/플러그인의 `vendor/autoload.php` 경로 목록 (Composer 의존성이 설치된 경우만 포함)
### 중요 규칙
```
설치된 모듈/플러그인만 캐시 파일에 반영됩니다
_bundled/_pending 디렉토리는 활성 확장으로 인식하지 않습니다
```
- 디렉토리에 파일이 있어도 **DB에 설치 기록이 없으면 반영되지 않음**
- identifier는 디렉토리명과 동일 (예: `sirsoft-sample`, `sirsoft-payment`)
- `_`로 시작하는 디렉토리(`_bundled`, `_pending`)는 디렉토리 스캔 시 자동 제외됨
- 캐시 파일에는 활성 디렉토리 경로만 포함 (`_bundled/` 경로 미포함)
### 수행 작업
1. `ModuleRepository`와 `PluginRepository`를 통해 설치된 확장 목록 조회
2. 각 모듈/플러그인의 `composer.json`에서 PSR-4 오토로드 정보 수집
3. `bootstrap/cache/autoload-extensions.php` 파일 생성
4. `module.php`, `plugin.php` 파일을 `classmap`에 추가
### 로딩 진입점
**public/index.php** 및 **artisan**에서 `ExtensionManager::registerExtensionAutoload($loader)` 호출:
```text
registerExtensionAutoload($loader)
↓ 1. PSR-4 네임스페이스 등록 ($loader->addPsr4())
↓ 2. Classmap 파일 로드 (module.php, plugin.php → require_once)
↓ 3. Files 로드 (헬퍼 함수 등 → require_once)
↓ 4. Vendor autoloads 로드 (모듈/플러그인의 vendor/autoload.php → require_once)
```
**로딩 순서가 중요합니다**:
| 순서 | 유형 | 설명 |
| ---------- | ---------- | ---------- |
| 1 | PSR-4 | 확장의 `src/` 디렉토리 네임스페이스를 Composer ClassLoader에 등록 |
| 2 | Classmap | `module.php`, `plugin.php` 엔트리 파일을 즉시 로드 |
| 3 | Files | 헬퍼 함수 등 자동 로드 파일 |
| 4 | Vendor autoloads | 각 확장의 `vendor/autoload.php`를 `require_once`하여 외부 패키지 로드 |
- 캐시 파일 미존재 시 아무 작업도 하지 않고 return (설치 전 상태)
- 경로가 존재하지 않는 항목은 자동 스킵 (`is_dir()` / `file_exists()` 체크)
---
## Composer 의존성 관리
모듈/플러그인이 외부 Composer 패키지(예: `stripe/stripe-php`)를 필요로 할 때, 각 확장의 `composer.json`에 `require`로 정의하고 확장별 독립 `vendor/` 디렉토리에 설치합니다.
### 핵심 원칙
```text
중요: 루트 composer.json에 모듈/플러그인 패키지 추가 금지
✅ 결정: 각 확장의 composer.json → 확장별 vendor/ 디렉토리에 독립 설치
✅ 장점: 확장 간 의존성 충돌 방지, 삭제 시 깔끔한 정리
```
### 동작 방식
```text
모듈/플러그인 설치 시 (Phase 4.5)
↓
hasComposerDependencies() → 외부 패키지 존재 여부 확인
↓ (존재 시)
runComposerInstall() → 확장 디렉토리에서 composer install 실행
↓
modules/{name}/vendor/ 또는 plugins/{name}/vendor/ 생성
↓
updateComposerAutoload() → autoload-extensions.php에 vendor_autoloads 등록
↓
public/index.php / artisan → vendor/autoload.php를 require_once
```
### 주요 메서드
| 메서드 | 시그니처 | 설명 |
|--------|----------|------|
| `hasComposerDependencies` | `(string $type, string $dirName): bool` | 외부 패키지 의존성 존재 여부 (`php`, `ext-*` 제외) |
| `getComposerDependencies` | `(string $type, string $dirName): array` | 외부 패키지 목록 반환 |
| `runComposerInstall` | `(string $type, string $dirName, bool $noDev, ?Command $command): bool` | 확장 디렉토리에서 `composer install` 실행 |
| `detectDuplicatePackages` | `(): array` | 여러 확장에서 중복 사용되는 패키지 감지 |
| `isComposerUnchanged` | `(string $stagingPath, string $activePath): bool` | 스테이징/활성 디렉토리의 composer 변경 여부 확인 |
### Composer 의존성 변경 감지 (isComposerUnchanged)
업데이트 시 불필요한 `composer install`을 스킵하기 위해 `composer.json`과 `composer.lock`을 이중 해시 비교합니다:
```text
isComposerUnchanged(stagingPath, activePath)
↓ 활성 디렉토리 또는 vendor/ 미존재 → false (설치 필요)
↓ composer.json 한쪽 미존재 → false (변경으로 간주)
↓ md5_file(staging/composer.json) !== md5_file(active/composer.json) → false
↓ composer.lock 존재 여부 불일치 → false
↓ md5_file(staging/composer.lock) !== md5_file(active/composer.lock) → false
↓ 모두 일치 → true (스킵 가능)
```
- **이중 비교**: `composer.json`(의존성 선언) + `composer.lock`(실제 설치 버전) 모두 확인
- **false 반환 = 설치 필요**: 하나라도 변경되었으면 `composer install` 실행
- **true 반환 = 스킵 가능**: 기존 `vendor/` 디렉토리 재사용
### runComposerInstall 환경변수
웹 서버 환경에서 Composer 실행 시 시스템 환경변수가 부족하므로, `install-worker.php` 패턴과 동일하게 환경변수를 명시적으로 구성합니다.
| 환경변수 | 출처/설정값 | 용도 |
|----------|-----------|------|
| PATH, SystemRoot | `getenv()`으로 시스템에서 복사 | Composer 바이너리 탐색 |
| TEMP, TMP | 시스템에서 복사, 불가 시 `storage/temp` | 임시 파일 (Windows 특이) |
| APPDATA, LOCALAPPDATA, USERPROFILE | `getenv()`으로 시스템에서 복사 | Windows 사용자 경로 |
| COMPOSER_HOME | `storage/composer` | Composer 캐시 디렉토리 |
| HOME | `storage/composer` | Unix-style 홈 디렉토리 |
### 삭제 시 vendor 정리
`InspectsUninstallData` trait이 제공하는 메서드:
| 메서드 | 설명 |
|--------|------|
| `getVendorDirectoryInfo(string $type, string $dirName): ?array` | vendor/ 디렉토리 크기 정보 조회 (삭제 모달 표시용) |
| `deleteVendorDirectory(string $type, string $dirName): void` | vendor/ 디렉토리 및 composer.lock 삭제 |
- **삭제 시점**: `uninstallModule`/`uninstallPlugin`에서 `$deleteData === true`일 때 실행
- **실패 처리**: 삭제 실패 시 예외를 catch하고 로그만 남김 (프로세스 중단하지 않음)
---
## Artisan 커맨드
### extension:update-autoload
모듈과 플러그인의 오토로드 캐시 파일을 생성합니다.
```bash
php artisan extension:update-autoload
```
**사용 시점**:
- 모듈/플러그인 디렉토리를 수동으로 추가/삭제한 경우
- 오토로드 캐시 파일이 손상된 경우
- 개발 중 오토로드 문제 디버깅
**출력 예시**:
```bash
$ php artisan extension:update-autoload
확장 오토로드 파일을 생성합니다...
오토로드 파일이 성공적으로 생성되었습니다.
→ bootstrap/cache/autoload-extensions.php
```
### extension:composer-install
모든 모듈과 플러그인의 Composer 의존성을 일괄 설치합니다.
```bash
php artisan extension:composer-install
```
**옵션**:
- `--no-dev`: dev 의존성 제외
**사용 시점**:
- 프로젝트 초기 설정 시 모든 확장의 의존성을 한번에 설치
- 배포 환경 구성 시
**동작**:
- `module:composer-install --all`과 `plugin:composer-install --all`을 순차 호출
---
## 식별자 검증 규칙 (ValidExtensionIdentifier)
모듈/플러그인/템플릿 설치 시 `ValidExtensionIdentifier` 규칙으로 식별자 형식을 공통 검증합니다:
| 규칙 | 설명 | 예시 |
| ---------- | ---------- | ---------- |
| **하이픈 1차 구분** | 최소 2개 부분 필수 (vendor-name) | `sirsoft-board` ✅ / `sirsoftboard` ❌ |
| **언더스코어 2차 구분** | 이름 내 단어 구분 (선택적) | `sirsoft-daum_postcode` ✅ |
| **영문 소문자 + 숫자 + `_`만** | 대문자, 특수문자 불가 | `Sirsoft-Board` ❌ / `sirsoft-my@module` ❌ |
| **각 단어 첫 글자 숫자 불가** | 하이픈/언더스코어로 구분된 각 단어 기준 | `sirsoft-2shop` ❌ / `sirsoft-board2` ✅ |
| **빈 부분 불가** | 연속 하이픈/언더스코어, 양끝 하이픈/언더스코어 금지 | `sirsoft--board` ❌ / `-sirsoft-board` ❌ |
| **최대 255자** | 길이 제한 | - |
### 유효/무효 예시
```text
✅ sirsoft-board → Sirsoft\Board
✅ sirsoft-daum_postcode → Sirsoft\DaumPostcode
✅ sirsoft-board2 → Sirsoft\Board2
✅ vendor-my_module_name → Vendor\MyModuleName
❌ sirsoftboard → 하이픈 없음 (최소 2부분 필수)
❌ sirsoft-2shop → '2shop' 첫 글자 숫자
❌ Sirsoft-Board → 대문자 사용
❌ sirsoft-my@module → 특수문자 '@'
❌ sirsoft--board → 연속 하이픈 (빈 부분)
❌ sirsoft-board_ → 끝 언더스코어 (빈 단어)
```
### Artisan 커맨드에서의 검증
`module:install`, `plugin:install`, `template:install` 등에서 `ExtensionManager::validateIdentifierFormat()`을 호출하여 잘못된 식별자 입력 시 `InvalidArgumentException`을 발생시킵니다.
### 관련 파일
- `app/Rules/ValidExtensionIdentifier.php` — 검증 규칙 구현
- `ExtensionManager::validateIdentifierFormat()` — Artisan 커맨드용 래퍼
---
## 서비스 등록
`CoreServiceProvider`에서 싱글톤으로 등록됩니다.
```php
// app/Providers/CoreServiceProvider.php
public function register(): void
{
// 확장 매니저 등록 (모듈/플러그인 공통 기능)
$this->app->singleton(ExtensionManager::class, function ($app) {
return new ExtensionManager;
});
// 모듈 매니저에 주입
$this->app->singleton(ModuleManager::class, function ($app) {
return new ModuleManager($app->make(ExtensionManager::class));
});
// 플러그인 매니저에 주입
$this->app->singleton(PluginManager::class, function ($app) {
return new PluginManager($app->make(ExtensionManager::class));
});
}
```
### 의존성 관계
```
ExtensionManager (독립)
↓
ModuleManager ← ExtensionManager 주입
↓
PluginManager ← ExtensionManager 주입
```
---
## 관련 문서
- [모듈 개발 기초](module-basics.md) - 모듈 구조 및 설정
- [플러그인 개발](plugin-development.md) - 플러그인 구조 및 설정
- [모듈 Artisan 커맨드](module-commands.md) - 모듈 관련 커맨드
- [확장 업데이트 시스템](extension-update-system.md) - _bundled/_pending 및 업데이트
- [인덱스](index.md) - 확장 시스템 전체 문서 목록
+746
View File
@@ -0,0 +1,746 @@
# 확장 업데이트 시스템 (Extension Update System)
> 모듈, 플러그인, 템플릿의 버전 업데이트 감지, 실행, 롤백을 관리하는 시스템
## TL;DR (5초 요약)
```text
1. 업데이트 감지 우선순위: GitHub > _bundled (2단계, _pending 미참여)
2. _bundled = Git 추적 선탑재 소스, _pending = 설치 시 임시 스테이징만
3. 상태 가드: ExtensionStatusGuard.assertNotInProgress(ExtensionStatus, identifier)
4. 업그레이드 콜백: upgrades/ 디렉토리에 Upgrade_X_Y_Z.php (UpgradeStepInterface 구현)
5. 역할/권한/메뉴 동기화: user_overrides로 사용자 커스터마이징 보존 (개별 식별자 보호)
```
---
## 목차
1. [_bundled vs _pending 디렉토리](#1-_bundled-vs-_pending-디렉토리)
2. [업데이트 감지](#2-업데이트-감지)
3. [업데이트 실행 흐름](#3-업데이트-실행-흐름)
4. [ExtensionStatusGuard](#4-extensionstatusguard)
5. [ExtensionBackupHelper](#5-extensionbackuphelper)
6. [ExtensionPendingHelper](#6-extensionpendinghelper)
7. [UpgradeStepInterface + UpgradeContext](#7-upgradestepinterface--upgradecontext)
8. [번들 디렉토리 개발 워크플로우](#8-번들-디렉토리-개발-워크플로우-critical---mandatory)
9. [개발자 버전 업데이트 가이드](#9-개발자-버전-업데이트-가이드)
10. [템플릿 레이아웃 충돌 전략](#10-템플릿-레이아웃-충돌-전략)
11. [Artisan 커맨드](#11-artisan-커맨드)
12. [API 엔드포인트](#12-api-엔드포인트)
13. [역할/권한/메뉴 동기화](#13-역할권한메뉴-동기화)
14. [SettingsMigrator](#14-settingsmigrator)
15. [큐 워커 재시작 + 스케줄 등록](#15-큐-워커-재시작--스케줄-등록)
---
## 1. _bundled vs _pending 디렉토리
| 속성 | `_bundled/` | `_pending/` |
|------|------------|-------------|
| **용도** | 선탑재 확장 소스 (배포 포함) | 외부 다운로드/업로드 스테이징 |
| **Git 추적** | O (추적됨) | X (`.gitignore` 제외) |
| **수명** | 항상 존재 | 설치 완료 후 삭제 가능 |
| **업데이트 감지** | **참여** (GitHub 다음 우선순위) | **미참여** |
| **사용 시점** | 설치 + 업데이트 소스 | 설치 시 스테이징만 |
| **디렉토리 구조** | `modules/_bundled/vendor-module/` | `modules/_pending/vendor-module/` |
### 디렉토리 자동 제외
`_`로 시작하는 디렉토리는 ExtensionManager에서 활성 확장으로 인식하지 않습니다:
```php
// str_starts_with($name, '_') → skip
// _bundled, _pending 모두 자동 제외
```
---
## 2. 업데이트 감지
### 우선순위 (2단계)
```
1. GitHub (github_url이 설정된 경우) → GitHub API로 최신 버전 조회
2. _bundled (선탑재 소스) → _bundled/{identifier}/manifest.json에서 버전 비교
```
> **주의**: `_pending` 디렉토리는 업데이트 감지에 **참여하지 않습니다**.
### 감지 결과
```php
[
'update_available' => true,
'update_source' => 'github' | 'bundled', // pending 없음
'latest_version' => '1.2.0',
'current_version' => '1.0.0',
]
```
### check-updates API 흐름
1. Service 레이어에서 훅 발행: `core.{type}.before_check_updates`
2. Manager의 `checkAll{Type}ForUpdates()` 호출
3. 각 확장별 `check{Type}Update($identifier)` 실행
4. DB에 `update_available`, `latest_version`, `update_source`, `github_changelog_url` 갱신
5. 훅 발행: `core.{type}.after_check_updates`
### GitHub 아카이브 다운로드 및 해석
GitHub 소스 업데이트 시 아카이브 URL 해석 → 다운로드 → 압축 해제 과정을 거칩니다:
**v접두사 자동 감지** (`resolveGithubArchiveUrl`):
```text
1. "v{version}" 태그로 HEAD 요청 시도 (예: v1.2.0)
2. 실패 시 "{version}" 태그로 HEAD 요청 시도 (예: 1.2.0)
3. HTTP 200/302 → 유효한 URL 반환
4. 모두 실패 → null 반환 (업데이트 불가)
```
**아카이브 추출 전략** (2단계 폴백):
| 순서 | 방식 | 조건 |
| ---------- | ---------- | ---------- |
| 1 | `ZipArchive` (PHP zip 확장) | `class_exists(ZipArchive::class)` |
| 2 | `unzip` CLI 명령어 | ZipArchive 미사용 시 폴백 |
**GitHub 아카이브 디렉토리 구조**: GitHub zipball은 `owner-repo-hash/` 형태의 루트 디렉토리를 포함합니다. `ZipInstallHelper::findAndValidateManifest()`가 1단계 하위 디렉토리까지 자동 검색하여 매니페스트 파일을 찾습니다.
---
## 3. 업데이트 실행 흐름
### 모듈/플러그인 업데이트 (10단계)
```
1. [가드] ExtensionStatusGuard::assertNotInProgress()
2. [백업] ExtensionBackupHelper::createBackup()
3. [상태] status → Updating
4. [파일] 소스에서 활성 디렉토리로 복사
- github: 다운로드 후 복사
- bundled: ExtensionPendingHelper::copyToActive()
5. [마이그레이션] runMigrations()
6. [DB 트랜잭션] 버전/메타데이터 갱신
- Roles, Permissions, Menus 동기화 (→ [13. 역할/권한/메뉴 동기화](#13-역할권한메뉴-동기화))
7. [레이아웃] 활성 상태였으면 레이아웃 갱신
- registerModuleLayouts() + registerLayoutExtensions() + refreshModuleLayouts()
8. [오토로드] Composer autoload 갱신
9. [업그레이드] runUpgradeSteps($module, $fromVersion, $toVersion)
10. [마무리] 이전 상태 복원 + 백업 삭제 + 캐시 무효화
```
### 템플릿 업데이트 (차이점)
- **마이그레이션**: 없음 (템플릿은 DB 스키마 미사용)
- **업그레이드 스텝**: 없음
- **레이아웃 전략**: `layout_strategy` 파라미터로 충돌 처리 (→ [10. 레이아웃 충돌 전략](#10-템플릿-레이아웃-충돌-전략))
- **Roles/Permissions/Menus**: 없음
### 에러 발생 시
```
1. ExtensionBackupHelper::restoreFromBackup() → 파일 원복
2. status → 이전 상태 복원
3. 에러 로깅 (upgrade 채널)
```
---
## 4. ExtensionStatusGuard
> 파일: `app/Extension/Helpers/ExtensionStatusGuard.php`
### 시그니처
```php
public static function assertNotInProgress(ExtensionStatus $status, string $identifier): void
```
### 차단 매트릭스
| 현재 상태 | 설치 | 삭제 | 활성화 | 비활성화 | 업데이트 |
|----------|------|------|--------|---------|---------|
| Active | - | O | - | O | O |
| Inactive | - | O | O | - | O |
| **Installing** | **차단** | **차단** | **차단** | **차단** | **차단** |
| **Uninstalling** | **차단** | **차단** | **차단** | **차단** | **차단** |
| **Updating** | **차단** | **차단** | **차단** | **차단** | **차단** |
| Uninstalled | O | - | - | - | - |
- `Installing`, `Uninstalling`, `Updating` 상태에서는 모든 작업이 `\RuntimeException`으로 차단됩니다.
---
## 5. ExtensionBackupHelper
> 파일: `app/Extension/Helpers/ExtensionBackupHelper.php`
### 메서드
```php
// 백업 생성 → storage/app/extension_backups/{type}/{identifier}_{timestamp}/
public static function createBackup(string $type, string $identifier): string
// 백업에서 복원 (copyToActive의 원자적 교체 사용)
public static function restoreFromBackup(string $type, string $identifier, string $backupPath): void
// 백업 삭제 (경로 미존재 시 무시)
public static function deleteBackup(string $backupPath): void
```
- `$type`: `'modules'`, `'plugins'`, `'templates'`
- 타임스탬프 형식: `Ymd_His` (예: `20260224_143052`)
- 파일 작업: `Illuminate\Support\Facades\File` 사용
---
## 6. ExtensionPendingHelper
> 파일: `app/Extension/Helpers/ExtensionPendingHelper.php`
### 메서드 (8개)
```php
// _pending 확장 목록 로드
public static function loadPendingExtensions(string $basePath, string $manifestName): array
// _bundled 확장 목록 로드
public static function loadBundledExtensions(string $basePath, string $manifestName): array
// _pending/_bundled → 활성 디렉토리로 원자적 교체 (rename→rename→delete 패턴)
public static function copyToActive(string $sourcePath, string $targetPath): void
// 확장 디렉토리 삭제 (경로 미존재 시 무시)
public static function deleteExtensionDirectory(string $basePath, string $identifier): void
// _pending 디렉토리에 존재 여부
public static function isPending(string $basePath, string $identifier): bool
// _bundled 디렉토리에 존재 여부
public static function isBundled(string $basePath, string $identifier): bool
// _pending 경로 반환
public static function getPendingPath(string $basePath, string $identifier): string
// _bundled 경로 반환
public static function getBundledPath(string $basePath, string $identifier): string
```
- `$basePath`: `base_path('modules')`, `base_path('plugins')`, `base_path('templates')`
- `$manifestName`: `'module.json'`, `'plugin.json'`, `'template.json'`
### copyToActive 원자적 교체 패턴
`copyToActive()`는 기존 대상 디렉토리가 있을 때 **rename→rename→delete** 패턴으로 원자적 교체를 수행합니다.
임시 디렉토리(`_updating_*`, `_old_*`)는 **`_pending/` 하위**에 생성되므로, 잔존 시에도 오토로드에 포함되지 않습니다:
```text
1. source → _pending/{id}_updating_{uid} (File::copyDirectory) # _pending/ 하위 임시 경로에 복사
2. target → _pending/{id}_old_{uid} (File::moveDirectory) # 기존을 _pending/ 하위 _old로 rename
3. _pending/{id}_updating_{uid} → target (File::moveDirectory) # 임시를 활성으로 rename
4. _pending/{id}_old_{uid} 삭제 (File::deleteDirectory) # 이전 버전 삭제 (실패해도 무해)
```
| 실패 시점 | 결과 |
| --------- | ---- |
| 1단계 실패 (복사) | 기존 디렉토리 **온전히 보존**, `_pending/` 내 임시 디렉토리 정리 후 예외 |
| 2단계 실패 (rename) | 기존 디렉토리 **온전히 보존**, `_pending/` 내 임시만 정리 |
| 3단계 실패 (rename) | `_pending/` 내 _old를 원래 위치로 **롤백**, 예외 |
> **Windows 참고**: `deleteDirectory` 직후 같은 이름으로 `rename`이 실패하는 타이밍 이슈가 있어, delete→copy 대신 rename→rename→delete 패턴을 사용합니다.
> **오토로드 안전성**: 임시 디렉토리가 `_pending/` 하위에 생성되므로, IDE 잠금 등으로 잔존하더라도 `str_starts_with($name, '_')` 필터에 의해 오토로드에서 자동 제외됩니다.
이 메서드는 다음 위치에서 공통으로 사용됩니다:
- `ExtensionBackupHelper::restoreFromBackup()` — 백업 복원
- `ModuleManager::downloadModuleUpdate()` — GitHub 다운로드 교체
- `PluginManager::downloadPluginUpdate()` — 동일
- `TemplateManager::downloadTemplateUpdate()` — 동일
---
## 7. UpgradeStepInterface + UpgradeContext
### UpgradeStepInterface
> 파일: `app/Contracts/Extension/UpgradeStepInterface.php`
```php
interface UpgradeStepInterface
{
public function run(UpgradeContext $context): void;
}
```
### UpgradeContext
> 파일: `app/Extension/UpgradeContext.php`
```php
// readonly 속성
public readonly LoggerInterface $logger; // upgrade 로그 채널
public readonly string $fromVersion; // 업그레이드 시작 버전
public readonly string $toVersion; // 업그레이드 목표 버전
public readonly string $currentStep; // 현재 실행 중인 스텝 버전
// 메서드
public function table(string $table): string // DB 프리픽스 적용 (raw SQL용)
public function withCurrentStep(string $stepVersion): self // 불변 복제
```
> **raw SQL에서 테이블명 사용 시 `$context->table()` 필수**
> - `Schema::hasColumn('users', ...)`, `DB::table('users')` → 프리픽스 자동 적용 (불필요)
> - `DB::selectOne("SHOW COLUMNS FROM ...")` 등 raw SQL → **`$context->table('users')` 필수**
### 업그레이드 스텝 작성 예시
```php
// modules/vendor-module/upgrades/Upgrade_1_1_0.php
namespace Modules\Vendor\Module\Upgrades;
use App\Contracts\Extension\UpgradeStepInterface;
use App\Extension\UpgradeContext;
class Upgrade_1_1_0 implements UpgradeStepInterface
{
public function run(UpgradeContext $context): void
{
$context->logger->info("Upgrading from {$context->fromVersion} to {$context->toVersion}");
// 데이터 마이그레이션 등 버전 특화 작업
}
}
```
### 정적 메뉴/권한 제거 시 cleanup 사용 예시
확장이 새 버전에서 기존 정적 메뉴/권한을 제거해야 하는 경우, UpgradeStep에서 cleanup helper를 명시적으로 호출합니다.
> **주의**: `cleanupStaleMenus()`/`cleanupStalePermissions()`는 정적 정의 기반으로만 판단하므로, 확장이 런타임에 동적으로 생성한 메뉴/권한도 삭제됩니다. 동적 메뉴/권한이 있는 확장에서는 삭제 대상을 직접 특정하는 것이 안전합니다.
```php
// upgrades/Upgrade_2_0_0.php — 정적 메뉴 정리 예시
use App\Extension\Helpers\ExtensionMenuSyncHelper;
use App\Enums\ExtensionOwnerType;
class Upgrade_2_0_0 implements UpgradeStepInterface
{
public function run(UpgradeContext $context): void
{
$menuHelper = app(ExtensionMenuSyncHelper::class);
$menuHelper->cleanupStaleMenus(
ExtensionOwnerType::Module,
'vendor-module',
['menu-slug-1', 'menu-slug-2'], // 유지할 slug 목록
);
}
}
```
### 자동 발견 규칙
- 디렉토리: `{extensionPath}/upgrades/`
- 파일 패턴: `Upgrade_X_Y_Z.php` (언더스코어 → 점으로 변환: `1_1_0` → `1.1.0`)
- 실행 순서: `fromVersion < stepVersion <= toVersion` 범위의 스텝만 자연 정렬 순 실행
---
## 8. 번들 디렉토리 개발 워크플로우
### 핵심 원칙
```text
필수: 확장(모듈/플러그인/템플릿) 수정/개발은 _bundled 디렉토리에서만 작업
필수: _bundled 작업 완료 후 반영/검증은 업데이트 프로세스(update 커맨드) 사용
이유: 활성 디렉토리 직접 수정 시 _bundled와 불일치 → 업데이트 시 변경 유실
이유: 업데이트 프로세스를 통해야 마이그레이션, 권한/메뉴 동기화, 레이아웃 갱신 등이 정상 수행됨
```
### 개발 워크플로우 (필수 절차)
```text
┌─────────────────────────────────────────────────────────────────┐
│ 1. _bundled 디렉토리에서 코드 수정/개발 │
│ 예: modules/_bundled/sirsoft-ecommerce/src/Services/... │
│ │
│ 2. manifest 버전 올리기 (module.json / plugin.json / template.json) │
│ 예: "version": "1.0.0" → "1.1.0" │
│ │
│ 3. 업그레이드 스텝 작성 (조건부 - DB/설정 변경 시) │
│ 예: upgrades/Upgrade_1_1_0.php │
│ │
│ 4. 업데이트 커맨드로 활성 디렉토리에 반영 │
│ php artisan module:update sirsoft-ecommerce │
│ php artisan plugin:update sirsoft-payment │
│ php artisan template:update sirsoft-admin_basic │
│ │
│ 5. 업데이트 결과 검증 (테스트 실행) │
│ php artisan test --filter=관련테스트 │
│ powershell -Command "npm run test:run -- 관련테스트" │
└─────────────────────────────────────────────────────────────────┘
```
### 왜 활성 디렉토리 직접 수정이 금지되는가?
| 문제 | 설명 |
|------|------|
| **변경 유실** | 다음 업데이트 시 _bundled 소스로 덮어쓰기되어 활성 디렉토리의 직접 수정 사항이 사라짐 |
| **동기화 누락** | 권한/역할/메뉴 동기화, 레이아웃 갱신, 마이그레이션 실행 등 업데이트 프로세스의 후처리가 수행되지 않음 |
| **Git 추적 불가** | 활성 디렉토리는 .gitignore 대상이므로 변경 사항이 Git에 기록되지 않음 |
| **환경 불일치** | _bundled(Git 추적)와 활성 디렉토리(Git 미추적)가 불일치하여 다른 환경에서 재현 불가 |
| **업데이트 감지 불가** | 활성 디렉토리만 수정하고 _bundled를 미갱신하면 check-updates에서 변경을 감지하지 못함 |
### 확장 타입별 업데이트 커맨드
| 확장 타입 | 업데이트 확인 | 업데이트 실행 | 강제 재설치 |
| -------- | ----------- | ----------- | ---------- |
| 모듈 | `php artisan module:check-updates sirsoft-ecommerce` | `php artisan module:update sirsoft-ecommerce` | `php artisan module:update sirsoft-ecommerce --force` |
| 플러그인 | `php artisan plugin:check-updates sirsoft-payment` | `php artisan plugin:update sirsoft-payment` | `php artisan plugin:update sirsoft-payment --force` |
| 템플릿 | `php artisan template:check-updates sirsoft-admin_basic` | `php artisan template:update sirsoft-admin_basic` | `php artisan template:update sirsoft-admin_basic --force` |
### 예외: 초기 개발 (아직 _bundled에 미등록)
신규 확장을 처음 개발할 때는 활성 디렉토리에서 직접 작업할 수 있습니다. 단, **최초 개발 완료 후 _bundled에 반영하는 시점부터** 이 규칙이 적용됩니다.
```text
허용: 신규 확장 초기 개발 시 활성 디렉토리에서 직접 작업
전환점: _bundled에 최초 반영한 이후부터는 반드시 _bundled에서만 작업
전환 후: 활성 디렉토리 직접 수정 금지 → 업데이트 프로세스만 사용
```
---
## 9. 개발자 버전 업데이트 가이드
### 코드 변경 시 버전/업그레이드 필수 규칙
```text
필수: 확장(모듈/플러그인/템플릿)의 코드를 변경한 경우 아래 절차를 수행해야 합니다.
버전 변경 없이 코드만 수정하면, 이미 설치된 환경에서 업데이트가 감지되지 않습니다.
```
#### 필수 체크리스트
| # | 항목 | 모듈 | 플러그인 | 템플릿 |
|---|------|------|---------|--------|
| 1 | manifest 버전 올리기 (`module.json` / `plugin.json` / `template.json`) | ✅ 필수 | ✅ 필수 | ✅ 필수 |
| 2 | `_bundled` 디렉토리 동기화 (활성 디렉토리와 동일하게) | ✅ 필수 | ✅ 필수 | ✅ 필수 |
| 3 | 업그레이드 스텝 작성 (`upgrades/Upgrade_X_Y_Z.php`) | 조건부 | 조건부 | 해당 없음 |
| 4 | DB 마이그레이션 추가 (`database/migrations/`) | 조건부 | 조건부 | 해당 없음 |
#### 업그레이드 스텝이 필요한 경우 (모듈/플러그인만)
다음 중 하나라도 해당하면 `upgrades/Upgrade_X_Y_Z.php`를 작성해야 합니다:
| 변경 유형 | 업그레이드 스텝 필요 | 예시 |
|----------|-------------------|------|
| DB 스키마 변경 | ✅ (+ 마이그레이션) | 컬럼 추가/삭제, 테이블 구조 변경 |
| 환경설정 구조 변경 | ✅ (SettingsMigrator 사용) | 설정 키 이름 변경, 새 카테고리 추가 |
| 기존 데이터 마이그레이션 | ✅ | 데이터 형식 변환, 기본값 채우기 |
| 권한/역할/메뉴 추가·수정 | ❌ (자동 동기화) | 새 권한 추가, 메뉴 순서 변경 |
| 정적 권한/메뉴 제거 | ✅ (cleanup 명시 호출) | 기존 메뉴/권한 삭제 |
| 레이아웃 JSON 변경 | ❌ (자동 갱신) | 레이아웃 UI 수정, 새 레이아웃 추가 |
| PHP 코드만 변경 (API 호환) | ❌ | 버그 수정, 성능 개선 |
#### 버전 올리기 않아도 되는 경우
- `_bundled` 디렉토리에 아직 반영되지 않은 개발 중 변경 (활성 디렉토리에서만 작업)
- 테스트 파일만 변경
- 규정 문서만 변경
### 새 버전 배포 절차
1. **manifest 버전 수정**: `module.json` / `plugin.json` / `template.json`의 `version` 필드 변경
2. **업그레이드 스텝 작성** (필요 시): `upgrades/Upgrade_X_Y_Z.php` 생성
3. **_bundled 업데이트**: Git 저장소의 `_bundled/{identifier}/` 디렉토리 갱신
4. **GitHub 릴리스** (선택): `github_url` 설정 시 GitHub releases로 자동 감지
### manifest 수정 항목
```json
{
"version": "1.2.0",
"github_changelog_url": "https://github.com/vendor/module/releases/tag/engine-v1.2.0"
}
```
---
## 10. 템플릿 레이아웃 충돌 전략
템플릿 업데이트 시 관리자가 수정한 레이아웃이 있으면 충돌이 발생합니다.
### 사전 확인 API
```
GET /api/admin/templates/{templateName}/check-modified-layouts
```
응답:
```json
{
"has_modified": true,
"modified_layouts": [
{ "id": 1, "name": "dashboard", "updated_at": "2026-02-24T10:00:00Z" }
]
}
```
### 충돌 해결 전략
| 전략 | 동작 |
|------|------|
| `apply_new` (기본값) | 새 버전의 레이아웃으로 덮어쓰기 (관리자 수정 사항 유실) |
| `keep_current` | 기존 레이아웃 유지 (새 버전 레이아웃 미적용) |
업데이트 API에 `layout_strategy` 파라미터로 전달:
```
POST /api/admin/templates/{templateName}/update
Body: { "layout_strategy": "apply_new" }
```
---
## 11. Artisan 커맨드
| 커맨드 | 설명 |
|--------|------|
| `module:check-updates [identifier?]` | 모듈 업데이트 확인 (전체 또는 단일) |
| `module:update [identifier] [--force]` | 모듈 업데이트 실행 |
| `plugin:check-updates [identifier?]` | 플러그인 업데이트 확인 (전체 또는 단일) |
| `plugin:update [identifier] [--force]` | 플러그인 업데이트 실행 |
| `template:check-updates [identifier?]` | 템플릿 업데이트 확인 (전체 또는 단일) |
| `template:update [identifier] [--layout-strategy=overwrite] [--force]` | 템플릿 업데이트 실행 |
> 단일 `check-updates`는 `checkXxxUpdate()` 호출 후 커맨드에서 DB 갱신 (`updateByIdentifier()`)
> 템플릿 `update`만 `--layout-strategy` 옵션 지원 (`overwrite` | `keep`)
> `--force`: 버전 비교를 건너뛰고 강제 재설치 (파일 손상, 수동 수정 복원 시 사용)
---
## 12. API 엔드포인트
### 모듈
| 메서드 | 경로 | 권한 | 설명 |
|--------|------|------|------|
| POST | `/api/admin/modules/check-updates` | `core.modules.install` | 모듈 업데이트 일괄 확인 |
| POST | `/api/admin/modules/{moduleName}/update` | `core.modules.install` | 모듈 업데이트 실행 |
### 플러그인
| 메서드 | 경로 | 권한 | 설명 |
|--------|------|------|------|
| POST | `/api/admin/plugins/check-updates` | `core.plugins.install` | 플러그인 업데이트 일괄 확인 |
| POST | `/api/admin/plugins/{pluginName}/update` | `core.plugins.install` | 플러그인 업데이트 실행 |
### 템플릿
| 메서드 | 경로 | 권한 | 설명 |
|--------|------|------|------|
| POST | `/api/admin/templates/check-updates` | `core.templates.install` | 템플릿 업데이트 일괄 확인 |
| GET | `/api/admin/templates/{templateName}/check-modified-layouts` | `core.templates.read` | 수정된 레이아웃 확인 |
| POST | `/api/admin/templates/{templateName}/update` | `core.templates.install` | 템플릿 업데이트 실행 |
### 훅 (Hook)
| 훅 이름 | 시점 |
|---------|------|
| `core.modules.before_check_updates` | 모듈 업데이트 확인 전 |
| `core.modules.after_check_updates` | 모듈 업데이트 확인 후 |
| `core.modules.before_update` | 모듈 업데이트 실행 전 |
| `core.modules.after_update` | 모듈 업데이트 실행 후 |
| `core.plugins.before_check_updates` | 플러그인 업데이트 확인 전 |
| `core.plugins.after_check_updates` | 플러그인 업데이트 확인 후 |
| `core.plugins.before_update` | 플러그인 업데이트 실행 전 |
| `core.plugins.after_update` | 플러그인 업데이트 실행 후 |
| `core.templates.before_check_updates` | 템플릿 업데이트 확인 전 |
| `core.templates.after_check_updates` | 템플릿 업데이트 확인 후 |
| `core.templates.before_version_update` | 템플릿 업데이트 실행 전 |
| `core.templates.after_version_update` | 템플릿 업데이트 실행 후 |
---
## 13. 역할/권한/메뉴 동기화
> 파일: `app/Extension/Helpers/ExtensionRoleSyncHelper.php`, `app/Extension/Helpers/ExtensionMenuSyncHelper.php`
업데이트 실행 흐름 6단계에서 Roles, Permissions, Menus를 동기화할 때, 사용자 커스터마이징을 보존하면서 안전하게 갱신합니다.
### user_overrides 패턴
각 모델(Role, Menu)에 `user_overrides` JSON 컬럼이 존재합니다. 유저가 수정한 필드명 또는 개별 식별자를 기록하고, 확장 재설치/업데이트 시 **기록된 항목만 보호**합니다.
```text
유저 수정 시: Listener가 user_overrides에 변경 내용 자동 기록
- 필드 변경 (name, icon 등) → 필드명 기록: ["name", "icon"]
- 권한 변경 → 개별 권한 식별자 기록: ["sirsoft-board.boards.delete"]
- 역할 변경 → 개별 역할 식별자 기록: ["editor"]
- 순서 변경 → "order" 기록: ["order"]
확장 업데이트 시: user_overrides에 기록된 항목만 보호, 나머지 정상 갱신
```
### 역할/권한 동기화 (ExtensionRoleSyncHelper)
- **syncRole()**: `user_overrides`에 기록된 필드(name, description)만 건너뛰고 나머지 갱신
- **syncPermission()**: 항상 확장 정의값으로 덮어쓰기 (Permission은 user_overrides 없음)
- **syncRolePermissions()**: `user_overrides`에 기록된 **개별 권한 식별자**만 보호, 나머지 DB 기반 diff로 attach/detach
- **cleanupStalePermissions()**: 확장에서 제거된 권한 삭제 + 역할 연결 해제 (**자동 호출 폐기 #135** — UpgradeStep에서 명시적 호출 전용)
### 역할-권한 할당 동기화 (syncAllRoleAssignments)
```text
주의: DB 기반 diff + 개별 권한 식별자 보호
유저가 admin에서 boards.delete 해제 → user_overrides = ["sirsoft-board.boards.delete"]
확장 v1.1 업데이트:
→ DB에서 현재 확장 권한 조회
→ user_overrides에서 권한 식별자만 필터 (필드명 "name" 등 제외)
→ 보호된 권한(boards.delete)은 attach/detach 계산에서 제외
→ 나머지 권한은 정의 기준으로 정상 동기화
```
| 시나리오 | 동작 |
| -------- | ---- |
| 최초 설치 (user_overrides=[]) | 정의된 모든 권한 attach |
| 업데이트 (보호 권한 있음) | 보호 권한 제외, 나머지 diff 동기화 |
| 확장이 새 권한 추가 | 비보호 → attach, 보호 → 건너뜀 |
| 확장에서 권한 제거 | 비보호 → detach, 보호 → 건너뜀 |
| 유저 수동 추가 할당 (비확장 권한) | 영향 없음 (확장 권한만 대상) |
### 메뉴 동기화 (ExtensionMenuSyncHelper)
- **비교 필드**: `user_overrides`에 기록된 필드만 건너뜀 (name, icon, order, url 중)
- **항상 확장 정의값**: parent_id, is_active (사용자 보존 대상 아님)
- **syncMenuRecursive()**: 재귀 구조 처리 + 다국어 name 역호환
- **cleanupStaleMenus()**: 자식 포함 cascade 삭제 (**자동 호출 폐기 #135** — UpgradeStep에서 명시적 호출 전용)
- **메뉴-역할 할당**: `user_overrides`에 기록된 **개별 역할 식별자**만 보호
```text
예시: 유저가 name과 editor 역할을 변경
→ user_overrides = ["name", "editor"]
모듈 v1.1 업데이트:
→ name: user_overrides에 있음 → 보존
→ order: user_overrides에 없음 → 새 값 반영
→ icon: user_overrides에 없음 → 새 값 반영
→ 역할 editor: 보호 → 유저 설정 유지
→ 역할 admin: 비보호 → 정상 동기화
```
### user_overrides 기록 리스너
| 리스너 | 구독 훅 | 기록 내용 |
| ------ | ------- | --------- |
| `RoleUserOverridesListener` | `core.role.before_update` | 변경된 필드명 (name, description) |
| `RoleUserOverridesListener` | `core.role.after_sync_permissions` | 변경된 개별 권한 식별자 |
| `MenuUserOverridesListener` | `core.menu.before_update` | 변경된 필드명 (name, icon, order, url) |
| `MenuUserOverridesListener` | `core.menu.after_update_order` | 순서 변경된 메뉴에 "order" |
| `MenuUserOverridesListener` | `core.menu.after_sync_roles` | 변경된 개별 역할 식별자 |
---
## 14. SettingsMigrator
> 파일: `app/Extension/Helpers/SettingsMigrator.php`
UpgradeStep 내에서 환경설정 파일을 안전하게 마이그레이션하는 유틸리티 클래스.
### 사용법
```php
class Upgrade_1_3_0 implements UpgradeStepInterface
{
public function run(UpgradeContext $context): void
{
$result = SettingsMigrator::forModule('sirsoft-ecommerce')
->addField('shipping.tracking_enabled', false)
->renameField('order_settings.auto_cancel_days', 'order_settings.pending_cancel_days')
->removeField('seo.deprecated_field')
->addCategory('notifications', ['email_enabled' => true])
->apply();
$context->logger->info('Settings migrated', $result);
}
}
```
### 오퍼레이션
| 메서드 | 동작 |
|--------|------|
| `addField(path, default)` | 새 필드 추가 (이미 존재하면 **스킵** — 사용자 값 보존) |
| `renameField(old, new)` | 필드 이름 변경 (이전 키 값을 새 키로 이동) |
| `removeField(path)` | 필드 제거 (존재하지 않으면 no-op) |
| `transformField(path, callable)` | 값 변환 (존재하지 않으면 스킵) |
| `addCategory(category, defaults)` | 새 카테고리 파일 생성 (모듈 전용) |
| `apply()` | 모든 변환 실행, `['applied' => int, 'skipped' => int, 'errors' => []]` 반환 |
### 경로 규칙 (dot notation)
- 모듈: `'category.field'` → `storage/app/modules/{id}/settings/{category}.json` 내 `field` 키
- 플러그인: `'field'` → `storage/app/plugins/{id}/settings/setting.json` 내 `field` 키
---
## 15. 큐 워커 재시작 + 스케줄 등록
### 큐 워커 재시작
> 파일: `app/Listeners/ExtensionUpdateQueueListener.php`
확장 업데이트 성공 시 `Artisan::call('queue:restart')` 자동 실행. 코드 변경 후 큐 워커가 이전 코드로 실행되는 문제를 방지합니다.
- 구독 훅: `core.modules.after_update`, `core.plugins.after_update` (priority 100)
- 실패 시: warning 로그만 기록 (예외 전파 안 함)
- 자동 등록: `CoreServiceProvider::registerCoreHookListeners()` 스캔
### 확장 스케줄 등록
> 파일: `routes/console.php`
활성 모듈/플러그인의 `getSchedules()` 메서드를 읽어 Laravel 스케줄러에 자동 등록합니다.
```php
// module.php에서 정의
public function getSchedules(): array
{
return [
[
'command' => 'sirsoft-ecommerce:sync-stock',
'schedule' => 'hourly', // 또는 cron: '*/30 * * * *'
'description' => '재고 동기화',
'enabled_config' => 'sirsoft-ecommerce.stock.auto_sync',
],
];
}
```
- cron expression vs 메서드명 자동 분기
- `enabled_config`: 확장 설정값으로 조건부 활성화
---
## 참고 파일 위치
| 파일 | 경로 |
|------|------|
| ExtensionStatusGuard | `app/Extension/Helpers/ExtensionStatusGuard.php` |
| ExtensionBackupHelper | `app/Extension/Helpers/ExtensionBackupHelper.php` |
| ExtensionPendingHelper | `app/Extension/Helpers/ExtensionPendingHelper.php` |
| ExtensionRoleSyncHelper | `app/Extension/Helpers/ExtensionRoleSyncHelper.php` |
| ExtensionMenuSyncHelper | `app/Extension/Helpers/ExtensionMenuSyncHelper.php` |
| SettingsMigrator | `app/Extension/Helpers/SettingsMigrator.php` |
| ExtensionUpdateQueueListener | `app/Listeners/ExtensionUpdateQueueListener.php` |
| UpgradeStepInterface | `app/Contracts/Extension/UpgradeStepInterface.php` |
| UpgradeContext | `app/Extension/UpgradeContext.php` |
| ExtensionStatus | `app/Enums/ExtensionStatus.php` |
| ModuleManager | `app/Extension/ModuleManager.php` |
| PluginManager | `app/Extension/PluginManager.php` |
| TemplateManager | `app/Extension/TemplateManager.php` |
| logging.php (upgrade 채널) | `config/logging.php` |
+885
View File
@@ -0,0 +1,885 @@
# 훅 시스템 (Hook System)
> G7의 훅 시스템은 WordPress 스타일로 구현되어 코어 수정 없이 비즈니스 로직을 확장할 수 있습니다.
---
## TL;DR (5초 요약)
```text
1. Action 훅: doAction() - 부가 작업 (로그, 알림, 캐시)
2. Filter 훅: applyFilters() - 데이터 변형 (type => 'filter' 필수!)
3. 네이밍: [vendor-module].[entity].[action]_[timing]
4. 우선순위: 1-5(높음), 10(기본), 15+(낮음)
5. Service 패턴: before_* → filter_* → 로직 → after_*
```
---
## 목차
- [개요](#개요)
- [훅 타입](#훅-타입)
- [훅 네이밍 규칙](#훅-네이밍-규칙)
- [리스너 구현](#리스너-구현)
- [코어 리스너 자동 발견](#코어-리스너-자동-발견)
- [우선순위 가이드라인](#우선순위-가이드라인)
- [모듈에 리스너 등록](#모듈에-리스너-등록)
- [Service에서 훅 사용 패턴](#service에서-훅-사용-패턴)
- [필터 훅 리스너 예시](#필터-훅-리스너-예시)
- [훅 권한 시스템](#훅-권한-시스템)
- [관련 문서](#관련-문서)
---
## 개요
G7의 훅 시스템은 WordPress 스타일로 구현되어 코어 수정 없이 비즈니스 로직을 확장할 수 있습니다.
**핵심 파일 위치**: `app/Extension/HookManager.php`
---
## 훅 타입
### 1. Action Hooks (액션 훅)
**목적**: 특정 시점에 부가 작업 실행
**사용 예시**:
- 이메일 발송
- 활동 로그 기록 (→ [활동 로그 시스템](../backend/activity-log.md) 참조)
- 캐시 무효화
- 알림 전송
**패턴**:
```php
// Service에서 훅 실행
HookManager::doAction('sirsoft-ecommerce.product.after_create', $product, $data);
```
### 2. Filter Hooks (필터 훅)
**목적**: 데이터 변형 및 가공
**사용 예시**:
- 가격 계산
- 데이터 포맷 변경
- 권한 확인
**패턴**:
```php
// Service에서 필터 적용
$finalPrice = HookManager::applyFilters('sirsoft-ecommerce.product.calculate_price', $basePrice, $product);
```
---
## 훅 네이밍 규칙
### 표준 패턴
```text
[vendor-module].[entity].[action]_[timing]
```
### 타이밍 접미사
| 접미사 | 설명 |
| ---------- | ------------------ |
| `before_*` | 작업 실행 전 |
| `after_*` | 작업 실행 후 |
| `on_*` | 특정 이벤트 발생 시 |
| `filter_*` | 데이터 변형용 필터 |
### 예시
```text
# 생성/수정/삭제 (변경 작업)
sirsoft-ecommerce.product.before_create
sirsoft-ecommerce.product.after_create
sirsoft-ecommerce.product.after_update
sirsoft-ecommerce.product.filter_create_data
# 조회 작업
sirsoft-ecommerce.product.before_list
sirsoft-ecommerce.product.after_list
sirsoft-ecommerce.product.filter_list_query
sirsoft-ecommerce.product.filter_list_result
sirsoft-ecommerce.product.before_show
sirsoft-ecommerce.product.after_show
sirsoft-ecommerce.product.filter_show_result
# 이벤트/기타
sirsoft-ecommerce.order.on_payment_success
sirsoft-ecommerce.product.calculate_price
# 코어 훅
core.attachment.download
core.attachment.update
core.attachment.delete
# FormRequest Validation Rules 훅 (Filter)
core.user.create_validation_rules
core.user.update_validation_rules
core.role.store_validation_rules
core.role.update_validation_rules
core.permission.store_validation_rules
core.permission.update_validation_rules
core.menu.store_validation_rules
core.menu.update_validation_rules
# Layout Extension 훅
core.layout_extension.before_apply
core.layout_extension.after_apply
# SEO 렌더링 훅 (Filter)
core.seo.filter_context
core.seo.filter_meta
core.seo.filter_view_data
```
---
## 리스너 구현
### HookListenerInterface 사용
```php
<?php
namespace Modules\Sirsoft\Ecommerce\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class ProductCacheInvalidationListener implements HookListenerInterface
{
/**
* 구독할 훅 목록 반환
*
* @return array
*/
public static function getSubscribedHooks(): array
{
return [
// Action 훅: type 생략 시 기본값 'action'
'sirsoft-ecommerce.product.after_create' => ['method' => 'handleProductChange', 'priority' => 10],
'sirsoft-ecommerce.product.after_update' => ['method' => 'handleProductChange', 'priority' => 10],
'sirsoft-ecommerce.product.after_delete' => ['method' => 'handleProductChange', 'priority' => 10],
];
}
/**
* 상품 변경 시 캐시 무효화
*
* @param mixed ...$args
* @return void
*/
public function handleProductChange(...$args): void
{
// 모든 상품 목록 캐시 삭제
Cache::forget('products.all');
Cache::forget('products.active');
// 특정 상품 캐시 삭제
if (isset($args[0]->id)) {
Cache::forget("product.{$args[0]->id}");
}
Log::info('상품 캐시가 무효화되었습니다.', [
'product_id' => $args[0]->id ?? null,
]);
}
}
```
---
## 코어 리스너 자동 발견
코어 애플리케이션의 훅 리스너는 `app/Listeners/` 디렉토리에서 **자동 발견**됩니다. `HookListenerInterface`를 구현한 클래스는 별도 등록 없이 자동으로 HookManager에 등록됩니다.
### 자동 발견 조건
| 조건 | 설명 |
|------|------|
| 위치 | `app/Listeners/` 및 모든 하위 디렉토리 |
| 인터페이스 | `HookListenerInterface` 구현 필수 |
| 등록 방식 | `CoreServiceProvider::boot()`에서 재귀 스캔 |
### 디렉토리 구조 예시
```text
app/Listeners/
├── ActivityLogListener.php ✅ 자동 발견
├── CoreActivityLogListener.php ✅ 자동 발견
├── ExtensionCompatibilityAlertListener.php ✅ 자동 발견
├── Dashboard/
│ ├── DashboardModuleListener.php ✅ 자동 발견 (하위 디렉토리)
│ └── DashboardStatsListener.php ✅ 자동 발견 (하위 디렉토리)
└── UserLogin/
└── UpdateLastLoginListener.php ✅ 자동 발견 (하위 디렉토리)
```
### CoreServiceProvider 구현
```php
// app/Providers/CoreServiceProvider.php
private function registerCoreHookListeners(): void
{
$listenersPath = app_path('Listeners');
if (! is_dir($listenersPath)) {
return;
}
// 재귀적으로 모든 PHP 파일 스캔
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($listenersPath, \RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
// 파일 경로에서 클래스명 추출
$relativePath = str_replace($listenersPath.DIRECTORY_SEPARATOR, '', $file->getPathname());
$relativePath = str_replace('.php', '', $relativePath);
$relativePath = str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath);
$listenerClass = 'App\\Listeners\\'.$relativePath;
// 클래스 존재 여부 확인
if (! class_exists($listenerClass)) {
Log::warning("코어 훅 리스너 클래스를 찾을 수 없습니다: {$listenerClass}");
continue;
}
// HookListenerInterface 구현 여부 확인
if (! in_array(HookListenerInterface::class, class_implements($listenerClass))) {
continue;
}
$this->registerCoreHookListener($listenerClass);
}
}
```
### 장점
| 장점 | 설명 |
|------|------|
| 자동 활성화 | 리스너 파일 생성만으로 훅 구독 시작 |
| ServiceProvider 수정 불필요 | 새 리스너 추가 시 코드 변경 없음 |
| 모듈/플러그인과 동일 패턴 | 일관된 아키텍처 |
| 메모리 효율 | 클로저 래퍼로 지연 인스턴스 생성 |
### 모듈/플러그인과의 차이점
| 항목 | 코어 리스너 | 모듈/플러그인 리스너 |
|------|------------|-------------------|
| 발견 방식 | 디렉토리 재귀 스캔 | `getHookListeners()` 메서드 |
| 위치 | `app/Listeners/**/*.php` | `modules/**/Listeners/`, `plugins/**/Listeners/` |
| 등록 주체 | `CoreServiceProvider` | `ModuleServiceProvider`, `PluginServiceProvider` |
---
## 우선순위 가이드라인
**우선순위 범위**: 1 ~ 100 (낮을수록 먼저 실행)
| 우선순위 | 용도 | 예시 |
| -------- | ---------- | ------------------ |
| 1-5 | 매우 높음 | 데이터 검증, 보안 체크 |
| 10 | 기본값 | 일반 리스너 |
| 15-20 | 낮음 | 알림, 로깅 |
| 25+ | 매우 낮음 | 분석, 통계 |
### 정렬 메커니즘
HookManager는 `ksort()`를 사용하여 우선순위별 정렬 후 실행합니다:
| 규칙 | 설명 |
| ---- | ---- |
| 정렬 방식 | `ksort()` — 숫자 오름차순 (낮을수록 먼저 실행) |
| 기본값 | `$priority = 10` (`addAction`, `addFilter` 공통) |
| 동일 우선순위 | 등록 순서대로 실행 (FIFO — 배열 push 순서 유지) |
```php
// 우선순위 5 → 10 → 20 순서로 실행
HookManager::addAction('order.created', $securityCheck, 5);
HookManager::addAction('order.created', $businessLogic, 10);
HookManager::addAction('order.created', $notification, 20);
```
---
## 모듈에 리스너 등록
### module.php에서 등록
```php
<?php
namespace Modules\Sirsoft\Ecommerce;
use App\Contracts\Extension\ModuleInterface;
use Modules\Sirsoft\Ecommerce\Listeners\ProductCacheInvalidationListener;
use Modules\Sirsoft\Ecommerce\Listeners\OrderNotificationListener;
class Module implements ModuleInterface
{
// ... 기타 메서드들 ...
/**
* 훅 리스너 목록 반환
*
* @return array
*/
public function getHookListeners(): array
{
return [
ProductCacheInvalidationListener::class,
OrderNotificationListener::class,
];
}
}
```
---
## Service에서 훅 사용 패턴
```php
<?php
namespace Modules\Sirsoft\Ecommerce\Services;
use App\Extension\HookManager;
use Modules\Sirsoft\Ecommerce\Repositories\ProductRepository;
class ProductService
{
public function __construct(
private ProductRepository $productRepository
) {}
public function createProduct(array $data): Product
{
// 1. Before 훅 - 데이터 검증, 전처리
HookManager::doAction('sirsoft-ecommerce.product.before_create', $data);
// 2. 필터 훅 - 데이터 변형
$data = HookManager::applyFilters('sirsoft-ecommerce.product.filter_create_data', $data);
// 3. 비즈니스 로직 실행
$product = $this->productRepository->create($data);
// 4. After 훅 - 후처리, 알림, 캐시 등
HookManager::doAction('sirsoft-ecommerce.product.after_create', $product, $data);
return $product;
}
public function calculateProductPrice(Product $product): float
{
$basePrice = $product->price;
// 필터 훅으로 가격 계산 (할인, 세금 등)
$finalPrice = HookManager::applyFilters(
'sirsoft-ecommerce.product.calculate_price',
$basePrice,
$product
);
return $finalPrice;
}
}
```
---
## 필터 훅 리스너 예시
### `type` 필드 사용법
`getSubscribedHooks()` 메서드에서 `type` 필드를 사용하여 훅 타입을 명시해야 합니다.
| type 값 | 설명 | 기본값 |
|---------|------|--------|
| `action` | Action 훅 - 반환값 없음 | ✅ (생략 시 기본값) |
| `filter` | Filter 훅 - 반환값 필수 | |
**중요**: Filter 훅 리스너는 반드시 `'type' => 'filter'`를 명시해야 합니다. 명시하지 않으면 Action 훅으로 처리되어 **반환값이 무시**됩니다.
### Filter 훅 리스너 구현
```php
<?php
namespace Modules\Sirsoft\Ecommerce\Listeners;
use App\Contracts\Extension\HookListenerInterface;
class ProductPriceCalculationListener implements HookListenerInterface
{
public static function getSubscribedHooks(): array
{
return [
// Filter 훅은 반드시 'type' => 'filter' 명시
'sirsoft-ecommerce.product.calculate_price' => [
'method' => 'applyDiscounts',
'priority' => 10,
'type' => 'filter', // 필수!
],
];
}
/**
* 할인 적용
*
* @param float $price 기본 가격
* @param Product $product 상품 객체
* @return float 할인 적용된 가격
*/
public function applyDiscounts(float $price, $product): float
{
// 10% 할인 적용 (예시)
if ($product->category->name === 'Special') {
$price = $price * 0.9;
}
return $price; // 반드시 값을 반환해야 함
}
}
```
### Action과 Filter 혼합 사용
하나의 리스너에서 Action 훅과 Filter 훅을 함께 구독할 수 있습니다:
```php
<?php
namespace Modules\Sirsoft\Board\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use App\Models\User;
class UserNotificationSettingsListener implements HookListenerInterface
{
public static function getSubscribedHooks(): array
{
return [
// Filter 훅: 데이터 변형 (type 필수)
'core.user.filter_create_data' => [
'method' => 'filterCreateData',
'priority' => 10,
'type' => 'filter',
],
'core.user.filter_update_data' => [
'method' => 'filterUpdateData',
'priority' => 10,
'type' => 'filter',
],
'core.user.filter_resource_data' => [
'method' => 'filterResourceData',
'priority' => 10,
'type' => 'filter',
],
// Action 훅: 부가 작업 (type 생략 가능)
'core.user.after_create' => [
'method' => 'afterCreate',
'priority' => 10,
// type 생략 = 'action'
],
];
}
/**
* 생성 데이터 필터: 모듈 필드 추출 후 제거
*
* @param array $data 요청 데이터
* @return array 모듈 필드가 제거된 데이터
*/
public function filterCreateData(array $data): array
{
// 모듈 필드 추출 및 임시 저장
$moduleData = $this->extractModuleData($data);
session(['module_data' => $moduleData]);
// 모듈 필드 제거 후 반환
return $this->removeModuleFields($data);
}
/**
* 생성 후 액션: 임시 저장된 데이터로 모듈 데이터 저장
*
* @param User $user 생성된 사용자
* @param array $originalData 원본 요청 데이터
* @return void
*/
public function afterCreate(User $user, array $originalData): void
{
$moduleData = session('module_data', []);
session()->forget('module_data');
if (!empty($moduleData)) {
$this->saveModuleData($user->id, $moduleData);
}
}
/**
* API 응답 필터: 모듈 데이터 병합
*
* @param array $data API 응답 데이터
* @param User $user 조회 대상 사용자
* @return array 모듈 데이터가 병합된 응답
*/
public function filterResourceData(array $data, User $user): array
{
$moduleData = $this->getModuleData($user->id);
return array_merge($data, $moduleData);
}
}
```
### SEO 훅 활용 예시 — 리뷰 플러그인이 JSON-LD에 리뷰 데이터 주입
```php
<?php
namespace Plugins\Sirsoft\Reviews\Listeners;
use App\Extension\Contracts\HookListenerInterface;
class SeoReviewMetaListener implements HookListenerInterface
{
public static function getSubscribedHooks(): array
{
return [
'core.seo.filter_meta' => [
'method' => 'injectReviewJsonLd',
'priority' => 10,
'type' => 'filter', // 필수!
],
];
}
/**
* SEO 메타에 리뷰 JSON-LD를 주입합니다.
*
* @param array $meta 메타 태그 배열
* @param array $hookMeta 훅 메타 정보 (layoutName, moduleIdentifier 등)
* @return array 수정된 메타 배열
*/
public function injectReviewJsonLd(array $meta, array $hookMeta): array
{
// 상품 상세 페이지에서만 동작
if ($hookMeta['moduleIdentifier'] !== 'sirsoft-ecommerce') {
return $meta;
}
$context = $hookMeta['context'] ?? [];
$productId = $context['route']['id'] ?? null;
if (! $productId) {
return $meta;
}
// 리뷰 데이터 조회 및 JSON-LD 주입
$reviews = $this->getReviewAggregate($productId);
if ($reviews && $meta['jsonLd']) {
$jsonLd = json_decode($meta['jsonLd'], true);
$jsonLd['aggregateRating'] = [
'@type' => 'AggregateRating',
'ratingValue' => $reviews['average'],
'reviewCount' => $reviews['count'],
];
$meta['jsonLd'] = json_encode($jsonLd, JSON_UNESCAPED_UNICODE);
}
return $meta;
}
}
```
### 훅 타입별 동작 비교
| 구분 | Action 훅 | Filter 훅 |
|------|----------|-----------|
| `type` 필드 | 생략 가능 (`'action'` 기본값) | **필수** (`'type' => 'filter'`) |
| 반환값 | 무시됨 | **필수** (다음 필터로 전달) |
| HookManager 메서드 | `doAction()` | `applyFilters()` |
| 사용 목적 | 부가 작업 (로그, 알림, 캐시 등) | 데이터 변형 (필터링, 병합, 제거 등) |
---
## 훅 내부 메커니즘
### 중복 실행 방지 (가드 플래그)
HookManager는 `$dispatching` 배열을 사용하여 동일 훅의 중복 실행을 방지합니다. Laravel Event 시스템과의 하이브리드 동작에서 발생할 수 있는 무한 루프를 차단합니다.
```text
doAction('hook.name')
→ $dispatching['hook.name'] = true (가드 설정)
→ Event::dispatch('hook.name')
→ 리스너 실행 중 doAction('hook.name') 재호출 시
→ $dispatching 체크 → 스킵 (중복 방지)
→ unset($dispatching['hook.name']) (가드 해제)
```
### addAction/addFilter 내부 동작
```text
addAction() → Event::listen() 등록 + 리스너 배열 관리
addFilter() → Event::listen() 등록 + 리스너 배열 관리 (동일)
removeAction() / removeFilter() → Event::forget() + 배열에서 제거
```
---
## 훅 권한 시스템
G7은 훅 실행 시 **권한 체크**를 지원합니다. `permission_hooks` 테이블을 통해 특정 훅을 실행하려면 어떤 권한이 필요한지 매핑할 수 있습니다.
### 하이브리드 권한 체계
```text
┌─────────────────────────────────────────────────────────────┐
│ 권한 체크 레이어 │
├─────────────────────────────────────────────────────────────┤
│ 1단계: permission_hooks (기능 레벨) │
│ → 'core.attachment.download' 훅 실행 권한 │
│ → 이 기능 자체를 사용할 수 있는가? │
│ → 미매핑 시 모든 사용자 허용 │
│ → 매핑 시 해당 퍼미션 보유자만 허용 │
├─────────────────────────────────────────────────────────────┤
│ 2단계: role_* (리소스 레벨) │
│ → 특정 파일/메뉴에 대한 접근 권한 │
│ → 이 리소스를 읽을/수정할/삭제할 수 있는가? │
│ → 현행 로직 유지 │
└─────────────────────────────────────────────────────────────┘
```
### permission_hooks 테이블
```php
Schema::create('permission_hooks', function (Blueprint $table) {
$table->id();
$table->foreignId('permission_id')->constrained()->cascadeOnDelete();
$table->string('hook_name')->comment('훅 이름 (예: core.attachment.download)');
$table->timestamps();
$table->unique(['permission_id', 'hook_name']);
$table->index('hook_name');
});
```
### PermissionHook 모델
```php
// app/Models/PermissionHook.php
class PermissionHook extends Model
{
protected $fillable = ['permission_id', 'hook_name'];
public function permission(): BelongsTo
{
return $this->belongsTo(Permission::class);
}
/**
* 특정 훅에 매핑된 권한 ID 목록 조회
*/
public static function getPermissionsForHook(string $hookName): array
{
return static::where('hook_name', $hookName)
->pluck('permission_id')
->toArray();
}
/**
* 훅에 권한이 매핑되어 있는지 확인
*/
public static function hasPermissionMapping(string $hookName): bool
{
return static::where('hook_name', $hookName)->exists();
}
}
```
### HookManager의 권한 체크 메서드
```php
// app/Extension/HookManager.php
class HookManager
{
/**
* 훅 실행 전 권한 체크
*
* @param string $hookName 훅 이름
* @param User|null $user 사용자 (null이면 비로그인)
* @return bool 실행 허용 여부
*/
public static function checkHookPermission(string $hookName, ?User $user): bool
{
// 1. 훅에 매핑된 권한이 없으면 모든 사용자 허용
if (!PermissionHook::hasPermissionMapping($hookName)) {
return true;
}
// 2. 비로그인 사용자는 권한 매핑된 훅 실행 불가
if ($user === null) {
return false;
}
// 3. 관리자는 모든 훅 실행 가능
if ($user->hasRole('admin')) {
return true;
}
// 4. 사용자의 역할에 해당 권한이 있는지 확인
$requiredPermissionIds = PermissionHook::getPermissionsForHook($hookName);
return $user->roles()
->whereHas('permissions', function ($query) use ($requiredPermissionIds) {
$query->whereIn('permissions.id', $requiredPermissionIds);
})
->exists();
}
/**
* 권한 체크 후 Action 실행
*
* @param string $hookName 훅 이름
* @param User|null $user 사용자
* @param mixed ...$args 훅 인자
* @return bool 실행 성공 여부
*/
public static function doActionWithPermission(string $hookName, ?User $user, ...$args): bool
{
if (!static::checkHookPermission($hookName, $user)) {
return false;
}
static::doAction($hookName, ...$args);
return true;
}
/**
* 권한 체크 후 Filter 적용
*
* @param string $hookName 훅 이름
* @param User|null $user 사용자
* @param mixed $value 필터링할 값
* @param mixed ...$args 추가 인자
* @return mixed 필터링된 값 (권한 없으면 원본 반환)
*/
public static function applyFiltersWithPermission(string $hookName, ?User $user, $value, ...$args)
{
if (!static::checkHookPermission($hookName, $user)) {
return $value;
}
return static::applyFilters($hookName, $value, ...$args);
}
}
```
### 훅에 권한 매핑하기
```php
// 시더 또는 관리자 기능에서
// 'attachment.download' 권한을 'core.attachment.download' 훅에 매핑
$permission = Permission::where('identifier', 'attachment.download')->first();
PermissionHook::create([
'permission_id' => $permission->id,
'hook_name' => 'core.attachment.download',
]);
```
### 권한 매핑 해제 (모든 사용자 허용)
```php
PermissionHook::where('hook_name', 'core.attachment.download')->delete();
```
### Service에서 사용 예시
```php
// app/Services/AttachmentService.php
public function download(int $id, ?User $user): ?Attachment
{
// 1단계: 기능 레벨 권한 체크 (permission_hooks)
if (!HookManager::checkHookPermission('core.attachment.download', $user)) {
throw new AuthorizationException(__('attachment.download_permission_denied'));
}
$attachment = $this->repository->findById($id);
// 2단계: 리소스 레벨 권한 체크 (role_attachments)
if (!$this->checkResourcePermission($user, $attachment, AttachmentPermissionType::Read)) {
throw new AuthorizationException(__('attachment.resource_access_denied'));
}
return $attachment;
}
```
### 권한 체크 흐름 요약
```text
┌─────────────────────────────────────────────────────────────┐
│ checkHookPermission() 흐름 │
├─────────────────────────────────────────────────────────────┤
│ 1. permission_hooks에 훅 매핑 확인 │
│ → 매핑 없음: 모든 사용자 허용 (return true) │
├─────────────────────────────────────────────────────────────┤
│ 2. 비로그인 사용자 │
│ → 매핑 있으면 거부 (return false) │
├─────────────────────────────────────────────────────────────┤
│ 3. 관리자(admin) 역할 │
│ → 모든 훅 실행 허용 (return true) │
├─────────────────────────────────────────────────────────────┤
│ 4. 사용자 역할의 권한 확인 │
│ → 매핑된 권한 보유 여부 반환 │
└─────────────────────────────────────────────────────────────┘
```
---
### SEO 캐시 무효화 리스너
모듈의 CRUD 훅에서 SEO 캐시를 무효화하는 패턴:
| 훅 | 리스너 메서드 | 무효화 대상 |
|----|-------------|-----------|
| `[module].product.after_create` | `onProductChange` | 해당 URL + 목록/카테고리 |
| `[module].product.after_update` | `onProductUpdate` | 해당 URL + 목록 |
| `[module].*.after_delete` | `onProductChange` | 전체 관련 캐시 |
캐시 무효화 시 `Cache::forget('seo:sitemap')`도 함께 호출
> 구현 상세: [seo-system.md](../backend/seo-system.md)
---
## 관련 문서
- [모듈 개발 기초](module-basics.md) - 모듈에서 훅 리스너 등록
- [Service-Repository 패턴](../backend/service-repository.md) - Service에서 훅 실행 패턴
- [활동 로그 시스템](../backend/activity-log.md) - 활동 로그 Listener 작성 패턴, Per-Item Bulk 로깅 규칙
- [권한 시스템](permissions.md) - Permission, Role 체계
- [확장 시스템 인덱스](index.md) - 전체 확장 시스템 개요
+682
View File
@@ -0,0 +1,682 @@
# 레이아웃 확장 시스템 (Layout Extensions)
> 모듈/플러그인이 기존 레이아웃에 동적으로 UI를 주입하는 시스템입니다.
## 목차
- [개요](#개요)
- [핵심 원칙](#핵심-원칙)
- [확장 타입](#확장-타입)
- [Extension 파일 위치](#extension-파일-위치)
- [Overlay 확장](#overlay-확장)
- [Extension Point 확장](#extension-point-확장)
- [템플릿 오버라이드](#템플릿-오버라이드)
- [모듈 활성화/비활성화 동작](#모듈-활성화비활성화-동작)
- [우선순위](#우선순위)
- [데이터 소스 병합](#데이터-소스-병합)
- [모달 병합](#모달-병합)
- [Overlay 레이아웃 섹션 병합](#overlay-레이아웃-섹션-병합)
- [관련 파일](#관련-파일)
- [문제 해결](#문제-해결)
---
## 개요
Layout Extension 시스템은 모듈/플러그인이 코어 또는 다른 확장의 레이아웃에 UI 컴포넌트를 동적으로 주입할 수 있게 해줍니다.
**주요 특징**:
- 코어 레이아웃 수정 없이 UI 확장
- 모듈 비활성화 시 자동으로 확장 UI 숨김
- 템플릿에서 모듈 확장 오버라이드 가능
- 우선순위 기반 렌더링 순서 제어
---
## 핵심 원칙
```text
필수: layout_extensions 통한 동적 주입 사용 (코어 레이아웃에 모듈 UI 하드코딩 금지)
필수: Layout Extension 시스템을 통한 동적 UI 주입
필수: 모듈 비활성화 시 관련 UI 자동 숨김
✅ 필수: 확장 컴포넌트에 ExtensionBadge 표시 (관리자 UI)
필수: 플러그인은 완전한 레이아웃 등록 불가 → 확장 지점(layout_extensions)만 사용
(예외: settings.json 환경설정 레이아웃은 registerPluginLayouts()로 등록)
필수: 모듈만 완전한 레이아웃 등록 가능 (admin/user 모두)
```
---
## 확장 타입
| 타입 | 키 | 설명 | 사용 시점 |
|------|-----|------|----------|
| **Overlay** | `target_layout` | 기존 레이아웃의 특정 컴포넌트 ID를 찾아 주입 | 특정 위치에 삽입/추가 |
| **Extension Point** | `extension_point` | 레이아웃에 사전 정의된 확장 포인트에 주입 | 확장용 영역이 미리 정의된 경우 |
---
## Extension 파일 위치
### 모듈
```
modules/
└── vendor-module/
└── resources/
└── extensions/ ← 확장 정의 디렉토리
└── *.json ← 확장 JSON 파일
```
### 플러그인
```
plugins/
└── vendor-plugin/
└── resources/
└── extensions/
└── *.json
```
### 템플릿 오버라이드
```
templates/
└── vendor-template/
└── extensions/
└── {module-identifier}/ ← 오버라이드 대상 모듈
└── *.json ← 오버라이드 JSON
```
---
## Overlay 확장
기존 레이아웃의 특정 컴포넌트를 찾아 UI를 주입합니다.
### JSON 스키마
```json
{
"target_layout": "admin_user_form",
"injections": [
{
"target_id": "section_marketing_consent",
"position": "append",
"components": [...]
}
],
"data_sources": [...],
"priority": 100
}
```
### 필드 설명
| 필드 | 필수 | 타입 | 설명 |
|------|------|------|------|
| `target_layout` | ✅ | string | 확장할 대상 레이아웃 이름 |
| `injections` | ✅ | array | 주입 정의 배열 |
| `injections[].target_id` | ✅ | string | 주입 대상 컴포넌트 ID |
| `injections[].position` | ✅ | string | 주입 위치 (아래 참조) |
| `injections[].components` | ✅ | array | 주입할 컴포넌트 배열 |
| `data_sources` | ❌ | array | 추가 데이터 소스 (선택) |
| `priority` | ❌ | number | 우선순위 (기본값: 100, 낮을수록 먼저) |
### position 옵션
| 값 | 설명 | 다이어그램 |
|-----|------|----------|
| `prepend` | 타겟 **앞에** 형제로 삽입 | `[NEW] [TARGET] [siblings...]` |
| `append` | 타겟 **뒤에** 형제로 삽입 | `[TARGET] [NEW] [siblings...]` |
| `prepend_child` | 타겟 children **맨 앞에** 삽입 | `TARGET { [NEW] [children...] }` |
| `append_child` | 타겟 children **맨 뒤에** 삽입 | `TARGET { [children...] [NEW] }` |
| `replace` | 타겟 **완전 교체** | `[NEW]` (기존 타겟 제거) |
| `inject_props` | 타겟 컴포넌트의 **props에 값 주입** | `TARGET.props ← injection.props` |
### inject_props 상세
`inject_props`는 기존 컴포넌트의 props에 값을 주입하는 특수 position입니다. `components` 필드 대신 `props` 필드를 사용합니다.
#### 병합 전략
| 전략 | 설명 | 예시 |
|------|------|------|
| `_append` | 배열 끝에 추가 | `tabs._append: [newTab]` → 기존 tabs 뒤에 추가 |
| `_prepend` | 배열 앞에 추가 | `tabs._prepend: [newTab]` → 기존 tabs 앞에 추가 |
| `_merge` | 객체 병합 (shallow) | `style._merge: {color: 'red'}` → 기존 style에 병합 |
| (직접 값) | 스칼라 덮어쓰기 | `disabled: true` → 기존 값 대체 |
#### inject_props 예시: 탭에 항목 추가
```json
{
"target_layout": "admin_user_detail",
"injections": [
{
"target_id": "user_detail_tabs",
"position": "inject_props",
"props": {
"tabs": {
"_append": [
{
"id": "ext_verification",
"label": "$t:admin.users.form.sections.verification_info",
"iconName": "shield"
}
]
}
}
},
{
"target_id": "extension_tab_content",
"position": "append_child",
"components": [
{
"id": "ext_verification_content",
"type": "basic",
"name": "Div",
"if": "{{(_global.activeUserDetailTab || query.tab || 'basic') === 'ext_verification'}}",
"children": [...]
}
]
}
],
"priority": 100
}
```
#### 주의사항
- `inject_props`일 때 `components` 필드는 무시됩니다
- 대상 prop이 표현식 문자열인 경우 `_append`/`_prepend`/`_merge` 적용이 불가하며, 경고 로그가 기록됩니다
- `_append`와 `_merge` 등 여러 전략을 하나의 prop에 혼합 사용할 수 없습니다 (첫 번째 매칭 전략 우선)
### 예시: 사용자 폼에 알림 설정 추가
```json
{
"target_layout": "admin_user_form",
"injections": [
{
"target_id": "section_marketing_consent",
"position": "append",
"components": [
{
"id": "section_notification_settings",
"type": "basic",
"name": "Div",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-between mb-4"
},
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.users.form.sections.notification_settings"
},
{
"type": "composite",
"name": "ExtensionBadge",
"props": {
"type": "module",
"identifier": "sirsoft-board",
"installedModules": "{{_global.installedModules}}"
}
}
]
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"name": "notify_post_complete",
"checked": "{{_local.form?.notify_post_complete ?? false}}"
}
}
]
}
]
}
],
"priority": 100
}
```
---
## Extension Point 확장
레이아웃에 미리 정의된 확장 포인트에 컴포넌트를 주입합니다.
### 지원 위치
Extension Point는 레이아웃의 다음 섹션에서 사용할 수 있습니다:
| 위치 | 지원 | 설명 |
|----------------|------|--------------------------------------|
| `components` | ✅ | 메인 컴포넌트 트리 (기본) |
| `modals` | ✅ | 모달 내부 컴포넌트 트리 (v1.17.0+) |
```text
v1.17.0 이전: modals 내부의 extension_point는 처리되지 않았음
✅ v1.17.0+: components와 modals 양쪽 모두 재귀적으로 extension_point 처리
```
### 레이아웃에서 Extension Point 정의
```json
{
"layout_name": "admin_user_form",
"components": [
{
"id": "user_form_container",
"type": "basic",
"name": "Div",
"children": [
{
"type": "extension_point",
"name": "user_form_additional_fields",
"default": []
}
]
}
]
}
```
### 모듈에서 Extension Point에 주입
```json
{
"extension_point": "user_form_additional_fields",
"mode": "replace",
"components": [
{
"id": "custom_field_section",
"type": "basic",
"name": "Div",
"children": [...]
}
],
"data_sources": [...],
"priority": 100
}
```
### JSON 스키마
| 필드 | 필수 | 타입 | 설명 |
|------|------|------|------|
| `extension_point` | ✅ | string | 확장 포인트 이름 |
| `mode` | ❌ | string | 주입 모드 (아래 참조, 기본값: `append`) |
| `components` | ❌ | array | 주입할 컴포넌트 배열 |
| `data_sources` | ❌ | array | 추가 데이터 소스 |
| `modals` | ❌ | array | 호스트 레이아웃에 병합할 모달 배열 |
| `scripts` | ❌ | array | 추가 스크립트 |
| `priority` | ❌ | number | 우선순위 (기본값: 100) |
### mode 옵션
| 값 | 설명 | 동작 |
| ----- | ------ | ------ |
| `append` | default **뒤에** 추가 (기본값) | `[default...] [NEW]` |
| `prepend` | default **앞에** 추가 | `[NEW] [default...]` |
| `replace` | default **완전 교체** | `[NEW]` (default 제거) |
---
## 템플릿 오버라이드
템플릿은 모듈/플러그인의 확장을 오버라이드하여 커스터마이징할 수 있습니다.
### 디렉토리 구조
```
templates/_bundled/sirsoft-admin_basic/
└── extensions/
└── sirsoft-board/ ← 오버라이드 대상 모듈
└── user-notification-settings.json ← 오버라이드 파일
```
### 오버라이드 파일명 규칙
모듈의 원본 확장 파일명과 **동일한 이름**을 사용합니다.
```
모듈 원본: modules/_bundled/sirsoft-board/resources/extensions/user-notification-settings.json
템플릿 오버라이드: templates/_bundled/sirsoft-admin_basic/extensions/sirsoft-board/user-notification-settings.json
```
### 오버라이드 우선순위
```
1. 템플릿 오버라이드 (source_type = Template) ← 가장 높은 우선순위
2. 플러그인 확장 (source_type = Plugin)
3. 모듈 확장 (source_type = Module) ← 가장 낮은 우선순위
```
### 오버라이드 예시
모듈의 기본 확장을 템플릿에서 스타일 변경:
```json
{
"target_layout": "admin_user_form",
"injections": [
{
"target_id": "section_marketing_consent",
"position": "append",
"components": [
{
"id": "section_notification_settings",
"type": "basic",
"name": "Div",
"props": {
"className": "bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 rounded-lg p-6 mb-6"
},
"children": [...]
}
]
}
],
"priority": 100
}
```
---
## 모듈 활성화/비활성화 동작
### 활성화 시
1. `resources/extensions/*.json` 파일 스캔
2. `template_layout_extensions` 테이블에 등록 (또는 기존 레코드 복원)
3. 레이아웃 캐시 무효화
### 비활성화 시
1. 해당 모듈의 확장 레코드 Soft Delete
2. **렌더링 시 비활성화된 모듈의 확장 자동 스킵**
3. 레이아웃 캐시 무효화
### 템플릿 오버라이드와 모듈 비활성화
**중요**: 모듈이 비활성화되면, 해당 모듈을 오버라이드하는 템플릿 확장도 함께 숨김 처리됩니다.
```
sirsoft-board 모듈 비활성화
↓
sirsoft-board의 모든 확장 숨김
↓
sirsoft-board를 오버라이드하는 템플릿 확장도 숨김
```
---
## 우선순위
### priority 필드
- 기본값: 100
- 낮을수록 먼저 렌더링
- 같은 위치에 여러 확장이 있을 경우 순서 결정
```json
{
"target_layout": "admin_user_form",
"priority": 50,
"injections": [...]
}
```
### 우선순위 가이드라인
| 범위 | 용도 | 예시 |
|------|------|------|
| 1-25 | 필수 정보 | 보안 경고, 중요 알림 |
| 50-75 | 주요 기능 | 핵심 모듈 UI |
| 100 | 기본값 | 일반 확장 |
| 150+ | 보조 정보 | 분석, 통계 |
---
## 데이터 소스 병합
확장에서 정의한 `data_sources`는 호스트 레이아웃의 데이터 소스에 병합됩니다.
```json
{
"target_layout": "admin_user_form",
"injections": [...],
"data_sources": [
{
"id": "notification_settings",
"type": "api",
"method": "GET",
"endpoint": "/api/modules/sirsoft-board/admin/users/{{route.id}}/notification-settings",
"auto_fetch": true,
"auth_required": true
}
]
}
```
---
## 모달 병합
Extension Point 확장에서 정의한 `modals`는 호스트 레이아웃의 `modals` 배열에 자동 병합됩니다.
### 왜 modals 섹션을 사용해야 하는가?
Extension Point의 children으로 주입된 인라인 Modal(`show` prop 기반)은 정상 동작하지 않습니다. 그누보드7 Modal 시스템에서 안정적으로 동작하려면 `modals` 섹션에 등록하고 `openModal`/`closeModal` 핸들러로 제어해야 합니다.
> 상세 모달 규칙: [modal-usage.md](../frontend/modal-usage.md)
### Extension JSON에서 modals 정의
```json
{
"extension_point": "shop_checkout_extensions",
"modals": [
{
"id": "payment_error_modal",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:plugin.payment_error_title",
"size": "small"
},
"children": [...]
}
],
"priority": 100
}
```
### 핸들러에서 모달 열기
Extension으로 병합된 모달은 커스텀 핸들러에서 `G7Core.modal.open()`으로 열 수 있습니다:
```typescript
const G7Core = (window as any).G7Core;
G7Core?.modal?.open?.('payment_error_modal');
```
또는 레이아웃 JSON 액션에서:
```json
{
"type": "click",
"handler": "openModal",
"target": "payment_error_modal"
}
```
### 병합 동작
- 여러 확장의 `modals`가 하나의 호스트 레이아웃에 모두 병합됩니다
- 호스트 레이아웃에 기존 `modals`가 있으면 확장 모달이 뒤에 추가됩니다
- 확장에 `modals`가 없으면 호스트 레이아웃에 영향 없음 (하위 호환)
### 주의사항
```text
modals 섹션 모달에 show prop 사용 금지 (무시됨)
모달 ID는 호스트 레이아웃의 기존 모달과 중복되지 않도록 플러그인 prefix 사용 권장
✅ 모달 닫기 시 setState → closeModal 순서 유지 (순서 중요)
```
---
## Overlay 레이아웃 섹션 병합
Overlay 확장에서 `computed`, `state`, `modals` 섹션을 정의하면 호스트 레이아웃에 자동 병합됩니다.
### 지원 섹션
| 섹션 | Extension Point | Overlay | 설명 |
|------|:---:|:---:|------|
| `data_sources` | ✅ | ✅ | API 데이터 소스 추가 |
| `scripts` | ✅ | ✅ | 커스텀 핸들러 스크립트 추가 |
| `modals` | ✅ | ✅ | 모달 컴포넌트 추가 |
| `computed` | ✅ | ✅ | 계산된 값 추가 |
| `state` | ✅ | ✅ | 초기 상태 추가 |
### 예시: Overlay에서 computed와 state 추가
```json
{
"target_layout": "admin_user_detail",
"injections": [...],
"computed": {
"verificationStatus": "{{user?.data?.identity_verified ? 'verified' : 'pending'}}"
},
"state": {
"showVerificationHistory": false
},
"modals": [
{
"id": "ext_verification_modal",
"type": "composite",
"name": "Modal",
"props": { "title": "인증 이력" },
"children": [...]
}
],
"priority": 100
}
```
### 병합 동작
- 여러 overlay의 같은 섹션은 priority 오름차순으로 순차 병합됩니다
- 동일 키 충돌 시 후순위 overlay가 덮어씁니다
- 해당 섹션이 없는 overlay는 기존 레이아웃에 영향 없음 (하위 호환)
---
## 레이아웃 오버라이드 vs 확장 오버라이드
| 구분 | 레이아웃 오버라이드 | 확장 오버라이드 |
|------|-------------------|---------------|
| 대상 | 모듈이 등록한 완전한 레이아웃 | 확장 지점/Overlay로 주입된 UI |
| 위치 | `templates/{t}/layouts/overrides/{module}/*.json` | `templates/{t}/extensions/{module}/*.json` |
| DB 테이블 | `template_layouts` | `template_layout_extensions` |
| 해석 서비스 | `LayoutResolverService` | `LayoutExtensionService` |
| version_constraint | 지원 | 지원 |
---
## 관련 파일
### 백엔드
| 파일 | 설명 |
|------|------|
| `app/Services/LayoutExtensionService.php` | 확장 적용 핵심 서비스 |
| `app/Models/LayoutExtension.php` | 확장 모델 (DB 테이블) |
| `app/Repositories/LayoutExtensionRepository.php` | 확장 Repository |
| `app/Enums/LayoutExtensionType.php` | 확장 타입 Enum (ExtensionPoint, Overlay) |
| `app/Enums/LayoutSourceType.php` | 출처 타입 Enum (Module, Plugin, Template) |
### 데이터베이스
| 테이블 | 설명 |
|--------|------|
| `template_layout_extensions` | 확장 등록 정보 저장 |
### 주요 컬럼
```
template_layout_extensions
├── template_id # 템플릿 ID
├── extension_type # ExtensionPoint | Overlay
├── target_name # 확장 포인트명 또는 대상 레이아웃명
├── source_type # Module | Plugin | Template
├── source_identifier # 모듈/플러그인/템플릿 식별자
├── override_target # 오버라이드 대상 (템플릿 오버라이드 시)
├── content # 확장 JSON 내용
├── priority # 우선순위
├── is_active # 활성 상태
└── deleted_at # Soft Delete
```
---
## 문제 해결
### 확장이 표시되지 않음
**확인 사항**:
1. 모듈/플러그인이 활성화되어 있는지 확인
2. `target_layout` 또는 `extension_point` 이름이 정확한지 확인
3. `target_id`가 대상 레이아웃에 존재하는지 확인
4. JSON 문법 오류 확인
**캐시 초기화**:
```bash
php artisan cache:clear
```
### 오버라이드가 적용되지 않음
**확인 사항**:
1. 템플릿 오버라이드 파일 경로 확인
- `templates/{template}/extensions/{module-identifier}/{filename}.json`
2. 파일명이 원본과 동일한지 확인
3. `template_layout_extensions` 테이블에서 `source_type = 'template'` 레코드 확인
### 모듈 비활성화 후에도 확장 표시됨
**원인**: 캐시 문제
**해결**:
```bash
php artisan cache:clear
```
### 여러 확장의 순서가 예상과 다름
**해결**: `priority` 값 조정 (낮을수록 먼저 렌더링)
---
## 관련 문서
- [module-layouts.md](module-layouts.md) - 모듈 레이아웃 등록
- [hooks.md](hooks.md) - 훅 시스템
- [layout-json.md](../frontend/layout-json.md) - 레이아웃 JSON 스키마
- [components.md](../frontend/components.md) - 컴포넌트 개발 규칙
+393
View File
@@ -0,0 +1,393 @@
# 메뉴 시스템
> G7의 메뉴 권한 시스템과 메뉴 시더 작성 규칙을 설명합니다.
---
## TL;DR (5초 요약)
```text
1. 구조: User → Role → role_menus 피벗 → Menu
2. 권한: MenuPermissionType (read, write, delete)
3. 모든 메뉴: role_menus에 명시적 역할 권한 필요 (코어/확장 구분 없음)
4. 자동 부여: 메뉴 생성 시 관리자 역할 + 생성자/설치자 역할 자동 부여
5. 시더: Menu::create() + grantAdminRoleToMenus() 패턴
```
---
## 목차
1. [메뉴 권한 아키텍처](#메뉴-권한-아키텍처)
2. [role_menus 피벗 테이블](#role_menus-피벗-테이블)
3. [MenuPermissionType Enum](#menupermissiontype-enum)
4. [Menu 모델의 HasRoleBasedAccess 구현](#menu-모델의-hasrolebasedaccess-구현)
5. [메뉴 시더에서 권한 부여](#메뉴-시더에서-권한-부여)
6. [메뉴 권한 확인 방법](#메뉴-권한-확인-방법)
7. [권한 체계 요약](#권한-체계-요약)
---
## 메뉴 권한 아키텍처
G7에서 메뉴 권한은 **역할 기반(Role-based)**으로 관리됩니다.
```
User → Role → role_menus → Menu
(pivot)
```
### 코어 메뉴 vs 모듈 메뉴
| 메뉴 유형 | 식별 | 기본 정책 |
|----------|------|----------|
| 코어 메뉴 | `extension_type = 'core'` | role_menus에 명시적 역할 권한 필요 |
| 모듈 메뉴 | `extension_type = 'module'` | 확장 활성 상태 + 명시적 역할 권한 필요 |
| 플러그인 메뉴 | `extension_type = 'plugin'` | 확장 활성 상태 + 명시적 역할 권한 필요 |
| 사용자 생성 메뉴 | `extension_type = NULL` | role_menus에 명시적 역할 권한 필요 |
---
## role_menus 피벗 테이블
### 테이블 구조
```php
Schema::create('role_menus', function (Blueprint $table) {
$table->id();
$table->foreignId('role_id')->constrained()->cascadeOnDelete();
$table->foreignId('menu_id')->constrained()->cascadeOnDelete();
$table->enum('permission_type', ['read', 'write', 'delete'])->default('read');
$table->timestamps();
$table->unique(['role_id', 'menu_id', 'permission_type'], 'uk_role_menu_permission');
});
```
### 설계 원칙
- **레코드 존재 = 권한 허용**: `is_allowed` 컬럼 없이 레코드 존재 여부로 판단
- **복합 유니크 인덱스**: 동일 역할-메뉴에 여러 권한 타입 부여 가능
- **Cascade Delete**: 역할 또는 메뉴 삭제 시 자동 정리
---
## MenuPermissionType Enum
```php
// app/Enums/MenuPermissionType.php
enum MenuPermissionType: string
{
case Read = 'read';
case Write = 'write';
case Delete = 'delete';
public function label(): string
{
return match ($this) {
self::Read => '읽기/접근',
self::Write => '수정',
self::Delete => '삭제',
};
}
}
```
### 권한 타입 설명
| 타입 | 값 | 설명 |
|------|-----|------|
| Read | `read` | 메뉴 조회/접근 권한 |
| Write | `write` | 메뉴 관련 데이터 수정 권한 |
| Delete | `delete` | 메뉴 관련 데이터 삭제 권한 |
---
## Menu 모델의 HasRoleBasedAccess 구현
`Menu` 모델은 `HasRoleBasedAccess` 인터페이스를 구현하여 역할 기반 접근 제어를 지원합니다.
### 인터페이스 구현
```php
// app/Models/Menu.php
class Menu extends Model implements HasRoleBasedAccess
{
/**
* 접근 가능한 역할들 (권한 타입 포함)
*/
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class, 'role_menus')
->withPivot('permission_type')
->withTimestamps();
}
/**
* 특정 권한 타입을 가진 역할들 조회
*/
public function rolesWithPermission(BackedEnum|string $permissionType): BelongsToMany
{
$type = $permissionType instanceof BackedEnum
? $permissionType->value
: $permissionType;
return $this->belongsToMany(Role::class, 'role_menus')
->withPivot('permission_type')
->withTimestamps()
->wherePivot('permission_type', $type);
}
/**
* 역할 권한이 설정되어 있는지 확인
*/
public function hasRolePermissions(): bool
{
return $this->roles()->exists();
}
/**
* 리소스 소유자의 ID 반환
*/
public function getOwnerId(): ?int
{
return $this->created_by;
}
}
```
### scopeAccessibleBy 쿼리 스코프
```php
/**
* 사용자에게 접근 가능한 메뉴들만 조회하는 스코프
*
* 권한 체크 로직:
* 1. 관리자는 모든 메뉴 접근 가능
* 2. 코어/사용자 생성 메뉴: role_menus에 명시적 역할 권한 필요
* 3. 모듈/플러그인 메뉴: 확장이 활성화되어 있고 역할 권한이 있어야 접근 가능
*
* 메뉴 생성 시 관리자 역할 + 생성자/설치자 역할이 자동 부여되므로
* role_menus 레코드가 없는 메뉴는 누구에게도 보이지 않습니다.
*/
public function scopeAccessibleBy(Builder $query, User $user): Builder
{
if ($user->hasRole('admin')) {
return $query;
}
$userRoleIds = $user->roles()->pluck('roles.id')->toArray();
return $query->where(function (Builder $query) use ($userRoleIds) {
// 코어/사용자 생성 메뉴: 명시적 역할 권한 필요
$query->where(function (Builder $q) use ($userRoleIds) {
$q->where(function ($q2) {
$q2->whereNull('extension_type')
->orWhere('extension_type', 'core');
})
->whereHas('roles', function (Builder $roleQ) use ($userRoleIds) {
$roleQ->whereIn('roles.id', $userRoleIds)
->where('role_menus.permission_type', MenuPermissionType::Read->value);
});
});
})->orWhere(function (Builder $query) use ($userRoleIds) {
// 모듈/플러그인 메뉴 (확장이 활성화되어 있고 역할 권한이 있는 경우)
$query->whereIn('extension_type', ['module', 'plugin'])
->whereHas('roles', function (Builder $roleQ) use ($userRoleIds) {
$roleQ->whereIn('roles.id', $userRoleIds)
->where('role_menus.permission_type', MenuPermissionType::Read->value);
});
});
}
```
---
## 메뉴 시더에서 권한 부여
### 예시: EcommerceMenuSeeder
```php
<?php
namespace Modules\Sirsoft\Ecommerce\Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\Menu;
use App\Models\Role;
use App\Models\Module;
use App\Enums\MenuPermissionType;
class EcommerceMenuSeeder extends Seeder
{
public function run(): void
{
$this->command->info('이커머스 메뉴 생성을 시작합니다.');
$module = Module::where('identifier', 'sirsoft-ecommerce')->first();
// 메뉴 생성
$ecommerceMenu = Menu::create([
'name' => ['ko' => '이커머스', 'en' => 'Ecommerce'],
'slug' => 'sirsoft-ecommerce',
'url' => '/admin/sirsoft-ecommerce',
'icon' => 'fa-shopping-cart',
'module_id' => $module->id,
]);
// 권한 부여
$this->grantMenuPermissions($module->id);
$this->command->info('이커머스 메뉴가 생성되었습니다.');
}
/**
* 역할에 메뉴 권한 부여
*/
private function grantMenuPermissions(int $moduleId): void
{
$adminRole = Role::where('identifier', 'admin')->first();
$managerRole = Role::where('identifier', 'sirsoft-ecommerce.manager')->first();
$ecommerceMenus = Menu::where('module_id', $moduleId)->get();
foreach ($ecommerceMenus as $menu) {
// 관리자: 모든 권한
$adminRole->menus()->attach($menu->id, [
'permission_type' => MenuPermissionType::Read->value,
]);
$adminRole->menus()->attach($menu->id, [
'permission_type' => MenuPermissionType::Write->value,
]);
$adminRole->menus()->attach($menu->id, [
'permission_type' => MenuPermissionType::Delete->value,
]);
// 매니저: 읽기/쓰기 권한만
if ($managerRole) {
$managerRole->menus()->attach($menu->id, [
'permission_type' => MenuPermissionType::Read->value,
]);
$managerRole->menus()->attach($menu->id, [
'permission_type' => MenuPermissionType::Write->value,
]);
}
}
$this->command->info("역할에 {$ecommerceMenus->count()}개의 메뉴 권한을 부여했습니다.");
}
}
```
### syncWithoutDetaching 사용 (기존 권한 유지)
```php
// 기존 권한을 유지하면서 새 권한 추가
$role->menus()->syncWithoutDetaching([
$menuId => ['permission_type' => 'read'],
]);
```
---
## 메뉴 권한 확인 방법
### 1. User 모델에서 확인
```php
// 기본 (read 권한)
$user->hasMenuPermission($menuId);
// 특정 권한 타입
$user->hasMenuPermission($menuId, 'write');
$user->hasMenuPermission($menuId, MenuPermissionType::Delete);
```
### 2. 쿼리 스코프로 필터링
```php
// 사용자가 접근 가능한 메뉴 조회
$accessibleMenus = Menu::accessibleBy($user)->get();
// 계층 구조와 함께 조회
$accessibleMenus = Menu::accessibleBy($user)
->whereNull('parent_id')
->with(['children' => function ($query) use ($user) {
$query->accessibleBy($user);
}])
->get();
```
### 3. Role 모델에서 확인
```php
// 역할에 메뉴 권한 부여
$role->menus()->attach($menuId, ['permission_type' => 'read']);
// 역할의 모든 메뉴 조회
$role->menus()->get();
// 특정 권한 타입의 메뉴만 조회
$role->menus()->wherePivot('permission_type', 'write')->get();
```
---
## 권한 체계 요약
### 권한 체크 흐름
```
┌─────────────────────────────────────────────────────────────┐
│ 1. 관리자(admin) 역할 확인 │
│ → admin이면 모든 메뉴 접근 허용 │
├─────────────────────────────────────────────────────────────┤
│ 2. 코어/사용자 생성 메뉴 (extension_type = 'core' 또는 NULL)│
│ → role_menus에 명시적 역할 권한 필요 │
│ → 메뉴 생성 시 관리자+생성자 역할 자동 부여 │
├─────────────────────────────────────────────────────────────┤
│ 3. 모듈/플러그인 메뉴 (extension_type = 'module'/'plugin') │
│ → 확장이 활성화(active) 상태여야 함 │
│ → role_menus에 명시적 권한 필요 │
└─────────────────────────────────────────────────────────────┘
```
### 권한 타입별 정리
| 권한 타입 | 용도 | 예시 |
|----------|------|------|
| `read` | 메뉴 표시, 페이지 접근 | 네비게이션 표시 |
| `write` | 데이터 생성/수정 | 상품 등록, 수정 |
| `delete` | 데이터 삭제 | 상품 삭제 |
### 동적 메뉴 보존
모듈/플러그인이 런타임에 동적으로 생성한 메뉴(예: 게시판 모듈의 개별 게시판 메뉴)는 확장 업데이트 시 자동으로 보존됩니다.
- `cleanupStaleMenus()`/`cleanupStalePermissions()`의 **자동 호출은 폐기**되었습니다
- 정적 메뉴/권한의 추가·수정은 업데이트 시 자동 동기화됩니다
- 정적 메뉴/권한을 **제거**해야 하는 경우, UpgradeStep에서 cleanup helper를 명시적으로 호출하세요
- 상세: [extension-update-system.md](./extension-update-system.md) "정적 메뉴/권한 제거 시 cleanup 사용 예시" 참조
### Deprecated 항목
다음 항목들은 더 이상 사용하지 않습니다:
| 항목 | 대체 방법 |
|------|----------|
| `menu_permissions` 테이블 | `role_menus` 피벗 테이블 |
| `MenuPermission` 모델 | `Role::menus()` 관계 |
| `is_allowed` 컬럼 | 레코드 존재 여부로 판단 |
| `User::menuPermissions()` | `User::hasMenuPermission()` |
| `module_id` 컬럼 | `extension_type` + `extension_identifier` |
| `module_identifier` 컬럼 | `extension_identifier` |
---
## 관련 문서
- [permissions.md](./permissions.md) - 권한 시스템 (Role, Permission, 리소스 레벨 권한)
- [module-basics.md](./module-basics.md) - 모듈 기본 구조
- [index.md](./index.md) - 확장 시스템 인덱스
+483
View File
@@ -0,0 +1,483 @@
# 모듈 프론트엔드 에셋 시스템
> 이 문서는 G7의 모듈 프론트엔드 에셋 로딩 시스템을 다룹니다.
---
## TL;DR (5초 요약)
```text
1. module.json에 에셋 매니페스트 정의 (js, css, loading strategy)
2. Vite IIFE 빌드로 dist/에 번들 생성
3. TemplateApp 초기화 시 자동 로드 (global 전략)
4. 핸들러 네이밍: {module-identifier}.{handler-name}
5. 빌드 명령: php artisan module:build [identifier] (기본: _bundled, --active로 활성)
```
---
## 목차
- [개요](#개요)
- [에셋 매니페스트 스키마](#에셋-매니페스트-스키마)
- [모듈 프론트엔드 구조](#모듈-프론트엔드-구조)
- [핸들러 등록](#핸들러-등록)
- [빌드 및 배포](#빌드-및-배포)
- [에셋 로딩 전략](#에셋-로딩-전략)
- [외부 라이브러리](#외부-라이브러리)
- [관련 문서](#관련-문서)
---
## 개요
모듈/플러그인은 자체 프론트엔드 에셋(JS, CSS, 이미지, 폰트 등)을 포함할 수 있습니다.
이 시스템은 활성화된 모듈의 에셋을 동적으로 로드하여 ActionDispatcher에 핸들러를 등록합니다.
### 작동 원리
```
1. admin.blade.php 렌더링
└─ window.G7Config.moduleAssets 주입
2. TemplateApp.init()
└─ ModuleAssetLoader.loadActiveExtensionAssets()
├─ CSS 로드 (병렬)
└─ JS 로드 (순차)
└─ 각 모듈의 initModule() 실행
└─ ActionDispatcher.registerHandler()
3. 레이아웃 렌더링
└─ 모듈 핸들러 사용 가능
```
---
## 에셋 매니페스트 스키마
### module.json (통합 스키마)
모듈 루트에 `module.json` 파일을 생성합니다. 메타데이터와 에셋 설정이 하나의 파일에 통합되어 있습니다.
```json
{
"identifier": "sirsoft-ecommerce",
"vendor": "sirsoft",
"name": {
"ko": "이커머스",
"en": "Ecommerce"
},
"version": "1.0.0",
"description": {
"ko": "상품 및 주문 관리를 위한 이커머스 모듈",
"en": "E-commerce module for product and order management"
},
"g7_version": ">=1.0.0",
"dependencies": {
"modules": {},
"plugins": {}
},
"github_url": null,
"github_changelog_url": null,
"assets": {
"js": {
"entry": "resources/js/index.ts",
"output": "dist/js/module.iife.js"
},
"css": {
"entry": "resources/css/main.css",
"output": "dist/css/module.css"
},
"handlers": true,
"static": "resources/assets/"
},
"loading": {
"strategy": "global",
"priority": 100
}
}
```
> **참고**: `identifier`, `vendor`는 디렉토리명에서 자동 추론되므로 생략 가능합니다.
> `name`, `version`, `description`은 AbstractModule에서 자동 파싱됩니다.
### 스키마 설명
#### 메타데이터 필드
| 필드 | 타입 | 필수 | 설명 |
|------|------|------|------|
| `identifier` | `string` | 선택 | 모듈 식별자 (디렉토리명에서 자동 추론) |
| `vendor` | `string` | 선택 | 벤더명 (identifier에서 자동 추론) |
| `name` | `string\|object` | 필수 | 모듈명 (다국어: `{"ko": "...", "en": "..."}`) |
| `version` | `string` | 필수 | 시맨틱 버전 (예: `1.0.0`) |
| `description` | `string\|object` | 필수 | 모듈 설명 (다국어 지원) |
| `g7_version` | `string` | 선택 | 그누보드7 코어 버전 제약 (예: `>=1.0.0`) |
| `dependencies` | `object` | 선택 | 모듈/플러그인 의존성 |
| `github_url` | `string\|null` | 선택 | GitHub 저장소 URL (업데이트 감지용) |
| `github_changelog_url` | `string\|null` | 선택 | GitHub 변경 이력 URL |
#### 에셋 필드
| 필드 | 설명 |
|------|------|
| `assets.js.entry` | JS 소스 엔트리 포인트 |
| `assets.js.output` | 빌드된 JS 출력 경로 |
| `assets.css.entry` | CSS 소스 엔트리 포인트 |
| `assets.css.output` | 빌드된 CSS 출력 경로 |
| `assets.handlers` | 핸들러 포함 여부 |
| `assets.static` | 정적 에셋 소스 디렉토리 |
| `loading.strategy` | 로딩 전략 (global, layout, lazy) |
| `loading.priority` | 로드 우선순위 (낮을수록 먼저) |
---
## 모듈 프론트엔드 구조
### 디렉토리 구조
```
modules/_bundled/sirsoft-ecommerce/
├── module.json ← 에셋 매니페스트
├── package.json ← npm 패키지 정의
├── vite.config.ts ← Vite 빌드 설정
├── tsconfig.json ← TypeScript 설정
├── dist/ ← 빌드 출력 (git ignore)
│ ├── js/module.iife.js
│ ├── css/module.css
│ └── assets/
│ ├── fonts/
│ └── images/
├── resources/
│ ├── js/ ← JS 소스
│ │ ├── index.ts
│ │ ├── types.ts
│ │ └── handlers/
│ │ ├── index.ts
│ │ └── updateProductField.ts
│ ├── css/main.css ← CSS 소스
│ └── assets/ ← 정적 에셋 소스
│ ├── fonts/
│ └── images/
└── ...
```
### package.json
```json
{
"name": "sirsoft-ecommerce",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite build --watch",
"build": "vite build"
},
"devDependencies": {
"typescript": "^5.0.0",
"vite": "^6.0.0"
}
}
```
### vite.config.ts
```typescript
import { defineConfig } from 'vite';
import path from 'path';
export default defineConfig({
build: {
lib: {
entry: path.resolve(__dirname, 'resources/js/index.ts'),
name: 'SirsoftEcommerce',
fileName: 'module',
formats: ['iife'],
},
outDir: 'dist',
rollupOptions: {
output: {
entryFileNames: 'js/[name].iife.js',
assetFileNames: (assetInfo) => {
if (assetInfo.name?.endsWith('.css')) {
return 'css/[name][extname]';
}
return 'assets/[name][extname]';
},
},
},
emptyOutDir: true,
minify: true,
sourcemap: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'resources/js'),
},
},
});
```
---
## 핸들러 등록
### 모듈 엔트리 파일 (index.ts)
```typescript
import '../css/main.css';
import { handlerMap } from './handlers';
const MODULE_IDENTIFIER = 'sirsoft-ecommerce';
export function initModule(): void {
const registerHandlers = () => {
const actionDispatcher = (window as any).G7Core?.getActionDispatcher?.();
if (actionDispatcher) {
Object.entries(handlerMap).forEach(([name, handler]) => {
const fullName = `${MODULE_IDENTIFIER}.${name}`;
actionDispatcher.registerHandler(fullName, handler);
});
console.log(`[Module:${MODULE_IDENTIFIER}] Handlers registered`);
} else {
// ActionDispatcher 초기화 대기
setTimeout(registerHandlers, 100);
}
};
if (document.readyState === 'complete') {
registerHandlers();
} else {
window.addEventListener('load', registerHandlers);
}
}
// IIFE 빌드 시 즉시 실행
initModule();
```
### 핸들러 정의
```typescript
// handlers/updateProductField.ts
import type { ActionContext } from '@/types';
interface UpdateProductFieldParams {
productId: string | number;
field: string;
value: string | number | boolean;
stateKey?: string;
}
export function updateProductFieldHandler(
params: UpdateProductFieldParams,
context: ActionContext
): void {
const { productId, field, value, stateKey = 'products' } = params;
// setState를 통해 상태 업데이트
if (context.setState) {
context.setState({
[stateKey]: {
_modified: {
[productId]: { [field]: value }
}
}
});
}
}
// handlers/index.ts
import { updateProductFieldHandler } from './updateProductField';
import { updateOptionFieldHandler } from './updateOptionField';
import type { ActionHandler } from '@/types';
export const handlerMap: Record<string, ActionHandler> = {
updateProductField: updateProductFieldHandler,
updateOptionField: updateOptionFieldHandler,
};
```
### 레이아웃에서 핸들러 사용
```json
{
"component": "Input",
"props": {
"type": "number",
"value": "{{row.stock_quantity}}"
},
"events": {
"onBlur": {
"handler": "sirsoft-ecommerce.updateProductField",
"params": {
"productId": "{{row.id}}",
"field": "stock_quantity",
"value": "{{$event.target.value}}"
}
}
}
}
```
---
## 빌드 및 배포
### Artisan 커맨드
```bash
# _bundled에서 빌드 (기본값)
php artisan module:build sirsoft-ecommerce
# 모든 _bundled 모듈 빌드
php artisan module:build --all
# 프로덕션 빌드 (_bundled)
php artisan module:build sirsoft-ecommerce --production
# 파일 감시 모드 (활성 디렉토리에서 자동 실행)
php artisan module:build sirsoft-ecommerce --watch
# 활성 디렉토리에서 빌드
php artisan module:build sirsoft-ecommerce --active
# 빌드 후 활성 디렉토리 반영
php artisan module:update sirsoft-ecommerce
```
### npm 스크립트
```bash
# 모듈 디렉토리에서 직접 실행
cd modules/_bundled/sirsoft-ecommerce
npm install
npm run build
# 개발 모드 (파일 감시)
npm run dev
```
### 에셋 서빙 API
빌드된 에셋은 다음 API를 통해 서빙됩니다:
```
GET /api/modules/assets/{identifier}/{path}
예시:
/api/modules/assets/sirsoft-ecommerce/dist/js/module.iife.js
/api/modules/assets/sirsoft-ecommerce/dist/css/module.css
```
---
## 에셋 로딩 전략
### global (기본값)
앱 초기화 시 자동으로 로드됩니다.
```json
{
"loading": {
"strategy": "global",
"priority": 100
}
}
```
- TemplateApp.init()에서 자동 로드
- 모든 페이지에서 핸들러 사용 가능
- 우선순위(priority)가 낮을수록 먼저 로드
### layout (향후 지원)
특정 레이아웃에서만 로드됩니다.
```json
{
"loading": {
"strategy": "layout"
}
}
```
- 레이아웃의 scripts 섹션에서 명시적 로드
- 해당 레이아웃 진입 시 로드
### lazy (향후 지원)
필요할 때 동적으로 로드됩니다.
```json
{
"loading": {
"strategy": "lazy"
}
}
```
- 핸들러 호출 시점에 로드
- 초기 로딩 시간 최적화
---
## 외부 라이브러리
외부 CDN 스크립트를 조건부로 로드할 수 있습니다.
### module.json 설정
```json
{
"assets": {
"external": [
{
"src": "https://cdn.example.com/chart.js",
"id": "chartjs-cdn",
"if": "{{_global.settings.useCharts}}"
}
]
}
}
```
### 레이아웃 scripts 섹션
```json
{
"scripts": [
{
"src": "https://cdn.example.com/lib.js",
"id": "external-lib",
"if": "{{_global.modules['sirsoft-ecommerce'].useExternalLib}}",
"async": true
}
]
}
```
---
## AbstractModule 에셋 메서드
AbstractModule은 에셋 관련 헬퍼 메서드를 제공합니다:
| 메서드 | 설명 |
|--------|------|
| `getAssets()` | module.json의 assets 섹션 반환 |
| `getAssetLoadingConfig()` | loading 설정 반환 (strategy, priority) |
| `hasAssets()` | 에셋 정의 존재 여부 |
| `getBuiltAssetPaths()` | 빌드된 에셋 경로 반환 (js, css) |
---
## 관련 문서
- [module-basics.md](./module-basics.md) - 모듈 개발 기초
- [hooks.md](./hooks.md) - 훅 시스템 (핸들러와 연계)
- [module-commands.md](./module-commands.md) - 모듈 Artisan 커맨드
- [../frontend/components.md](../frontend/components.md) - 컴포넌트 핸들러 호출
+629
View File
@@ -0,0 +1,629 @@
# 모듈 개발 기초
> 이 문서는 G7의 모듈 개발 기초를 다룹니다.
---
## TL;DR (5초 요약)
```text
1. 디렉토리: vendor-module (예: sirsoft-ecommerce)
2. 네임스페이스: Modules\Vendor\Module\ (예: Modules\Sirsoft\Ecommerce\)
3. AbstractModule 상속 권장 (ModuleInterface 직접 구현 가능)
4. 필수: module.json (메타데이터 SSoT), Module.php, composer.json, routes/admin.json
5. getName()/getVersion()/getDescription()은 module.json에서 자동 파싱 (하드코딩 불필요)
```
---
## 목차
- [모듈 네이밍 규칙](#모듈-네이밍-규칙)
- [AbstractModule 상속 (권장)](#abstractmodule-상속-권장)
- [ModuleInterface 직접 구현 (레거시)](#moduleinterface-직접-구현-레거시)
- [composer.json 작성법](#composerjson-모듈)
- [모듈 디렉토리 구조](#모듈-디렉토리-구조)
- [관련 문서](#관련-문서)
---
## 모듈 네이밍 규칙
### 디렉토리명
`vendor-module` 형식 (GitHub 스타일)
- 소문자 사용
- 하이픈(-): vendor와 module 구분
- 언더스코어(_): 모듈명 내 단어 구분 (선택적)
- 예: `sirsoft-ecommerce`, `sirsoft-order_management`, `johndoe-blog`
### 네임스페이스
`Modules\Vendor\Module\` 형식
- PascalCase 사용
- 백슬래시(\) 구분
- 예: `Modules\Sirsoft\Ecommerce\`, `Modules\Sirsoft\OrderManagement\`
### 디렉토리명 → 네임스페이스 변환 규칙
```text
필수: 변환 규칙 이해
하이픈(-) → 네임스페이스 구분자(\)
언더스코어(_) → PascalCase 결합
예시:
sirsoft-ecommerce → Sirsoft\Ecommerce
sirsoft-order_management → Sirsoft\OrderManagement
vendor-my_module_name → Vendor\MyModuleName
```
### Composer 패키지명
`modules/vendor-module`
- 예: `modules/sirsoft-ecommerce`, `modules/sirsoft-order_management`
### 네이밍 요약표
| 항목 | 형식 | 예시 |
|------|------|------|
| 디렉토리명 | `vendor-module` | `sirsoft-ecommerce`, `sirsoft-order_management` |
| 네임스페이스 | `Modules\Vendor\Module\` | `Modules\Sirsoft\Ecommerce\`, `Modules\Sirsoft\OrderManagement\` |
| Composer 패키지명 | `modules/vendor-module` | `modules/sirsoft-ecommerce`, `modules/sirsoft-order_management` |
### 식별자 검증 규칙
모듈/플러그인/템플릿 공통 식별자 검증 규칙은 [extension-manager.md](./extension-manager.md#식별자-검증-규칙-validextensionidentifier)를 참조하세요.
---
## AbstractModule 상속 (권장)
모듈 개발자는 `AbstractModule`을 상속받아 필수 메서드만 구현하면 됩니다.
`getIdentifier()`와 `getVendor()`는 디렉토리명에서 자동으로 추론됩니다.
### manifest 자동 파싱 메서드 (오버라이드 불필요)
`module.json`에서 자동으로 값을 읽어오는 메서드입니다. 하드코딩 오버라이드가 불필요합니다.
| 메서드 | 반환 타입 | 소스 (module.json) | 기본값 |
|--------|----------|-------------------|--------|
| `getName()` | `string\|array` | `name` | identifier |
| `getVersion()` | `string` | `version` | `'0.0.0'` |
| `getDescription()` | `string\|array` | `description` | `''` |
| `getRequiredCoreVersion()` | `?string` | `g7_version` | `null` |
| `getLicense()` | `?string` | `license` | `null` |
| `getGithubUrl()` | `?string` | `github_url` | `null` |
| `getAssets()` | `array` | `assets` | `[]` |
| `getAssetLoadingConfig()` | `array` | `loading` | strategy: global, priority: 100 |
> **`license` 필드**: `module.json`에 `"license": "MIT"` 등의 라이선스 정보를 포함합니다. 이 값은 API 리소스의 `license` 필드로 노출됩니다. 또한 각 모듈 루트에 `LICENSE` 파일을 포함하여 라이선스 전문을 제공해야 합니다.
### 자동 추론 메서드 (final - 오버라이드 불가)
| 메서드 | 반환 타입 | 설명 |
|--------|----------|------|
| `getIdentifier()` | `string` | 디렉토리명에서 자동 추론 (예: `sirsoft-ecommerce`) |
| `getVendor()` | `string` | 디렉토리명에서 자동 추론 (예: `sirsoft`) |
### 기본값 제공 메서드 (필요시 오버라이드)
| 메서드 | 기본값 | 설명 |
|--------|--------|------|
| `install()` | `true` | 설치 로직 |
| `uninstall()` | `true` | 제거 로직 |
| `activate()` | `true` | 활성화 로직 |
| `deactivate()` | `true` | 비활성화 로직 |
| `getDynamicTables()` | `[]` | 런타임 동적 생성 테이블 목록 (언인스톨 시 Manager가 삭제) |
| `getRoutes()` | 자동 탐지 | `src/routes/api.php`, `src/routes/web.php` |
| `getMigrations()` | 자동 탐지 | `database/migrations` 디렉토리 |
| `getViews()` | `[]` | 뷰 파일 |
| `getPermissions()` | `[]` | 권한 목록 (resource_route_key, owner_key, roles scope_type 지원) |
| `getRoles()` | `[]` | 역할 목록 |
| `getConfig()` | `[]` | 설정 |
| `getAdminMenus()` | `[]` | 관리자 메뉴 |
| `getHookListeners()` | `[]` | 훅 리스너 |
| `getDependencies()` | `[]` | 의존성 |
| `getMetadata()` | `[]` | 메타데이터 |
| `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견) |
### 간결한 모듈 구현 예시
> **참고**: `getName()`, `getVersion()`, `getDescription()`은 `module.json`에서 자동 파싱되므로 오버라이드가 불필요합니다.
```php
<?php
namespace Modules\Sirsoft\Ecommerce;
use App\Extension\AbstractModule;
class Module extends AbstractModule
{
// getName(), getVersion(), getDescription()은 module.json에서 자동 파싱
// 별도 오버라이드 불필요
/**
* 역할 목록 (필요시 오버라이드)
*/
public function getRoles(): array
{
return [
[
'identifier' => 'sirsoft-ecommerce.manager',
'name' => [
'ko' => '이커머스 관리자',
'en' => 'Ecommerce Manager',
],
'description' => [
'ko' => '이커머스 모듈 관리 권한을 가진 역할',
'en' => 'Role with ecommerce module management permissions',
],
],
];
}
/**
* 권한 목록 (필요시 오버라이드)
*
* - resource_route_key: 라우트 파라미터명 (scope 체크용, 소유자 개념 없으면 생략)
* - owner_key: 모델의 소유자 식별 컬럼명 (scope 체크용, 소유자 개념 없으면 생략)
* - roles: 문자열 배열 또는 {role, scope_type} 객체 배열
*/
public function getPermissions(): array
{
return [
[
'identifier' => 'sirsoft-ecommerce.products.view',
'name' => [
'ko' => '상품 조회',
'en' => 'View Products',
],
'description' => [
'ko' => '상품 목록 및 상세 정보를 조회할 수 있습니다',
'en' => 'Can view product list and details',
],
'resource_route_key' => 'product', // 라우트 파라미터명
'owner_key' => 'created_by', // 소유자 컬럼
// roles에 scope_type 지정 가능 (null=전체, 'self'=본인, 'role'=소유역할)
'roles' => [
['role' => 'admin', 'scope_type' => null],
['role' => 'sirsoft-ecommerce.manager', 'scope_type' => 'role'],
],
],
[
'identifier' => 'sirsoft-ecommerce.products.create',
'name' => [
'ko' => '상품 생성',
'en' => 'Create Products',
],
'description' => [
'ko' => '새로운 상품을 생성할 수 있습니다',
'en' => 'Can create new products',
],
'resource_route_key' => 'product',
'owner_key' => 'created_by',
'roles' => ['admin'], // 문자열 배열도 허용 (scope_type=null 기본값)
],
[
'identifier' => 'sirsoft-ecommerce.orders.view',
'name' => [
'ko' => '주문 조회',
'en' => 'View Orders',
],
'description' => [
'ko' => '주문 목록 및 상세 정보를 조회할 수 있습니다',
'en' => 'Can view order list and details',
],
'resource_route_key' => 'order',
'owner_key' => 'user_id',
'roles' => ['admin', 'sirsoft-ecommerce.manager'],
],
[
'identifier' => 'sirsoft-ecommerce.categories.view',
'name' => [
'ko' => '카테고리 조회',
'en' => 'View Categories',
],
'description' => [
'ko' => '카테고리 목록을 조회할 수 있습니다',
'en' => 'Can view category list',
],
// resource_route_key/owner_key 생략 = 소유자 개념 없음 (scope 체크 스킵)
'roles' => ['admin'],
],
];
}
/**
* 관리자 메뉴 정의 (필요시 오버라이드)
*/
public function getAdminMenus(): array
{
return [
[
'name' => [
'ko' => '이커머스',
'en' => 'Ecommerce',
],
'slug' => 'ecommerce',
'url' => '/admin/ecommerce',
'icon' => 'fa-shopping-cart',
'order' => 20,
'children' => [
[
'name' => [
'ko' => '상품 관리',
'en' => 'Product Management',
],
'slug' => 'sirsoft-ecommerce-products',
'url' => '/admin/ecommerce/products',
'icon' => 'fa-box',
'order' => 1,
'permission' => 'sirsoft-ecommerce.products.view',
],
[
'name' => [
'ko' => '주문 관리',
'en' => 'Order Management',
],
'slug' => 'sirsoft-ecommerce-orders',
'url' => '/admin/ecommerce/orders',
'icon' => 'fa-receipt',
'order' => 2,
'permission' => 'sirsoft-ecommerce.orders.view',
],
],
],
];
}
/**
* 훅 리스너 목록 (필요시 오버라이드)
*/
public function getHookListeners(): array
{
return [
\Modules\Sirsoft\Ecommerce\Listeners\ProductCacheInvalidationListener::class,
\Modules\Sirsoft\Ecommerce\Listeners\OrderNotificationListener::class,
];
}
/**
* 모듈 메타데이터 (필요시 오버라이드)
*/
public function getMetadata(): array
{
return [
'author' => 'sirsoft',
'license' => 'MIT',
];
}
}
```
---
## ModuleInterface 직접 구현 (레거시)
> **참고**: 특별한 경우가 아니라면 `AbstractModule` 상속을 권장합니다.
`ModuleInterface`를 직접 구현할 경우, 모든 메서드를 직접 구현해야 합니다:
```php
<?php
namespace Modules\Sirsoft\Ecommerce;
use App\Contracts\Extension\ModuleInterface;
class Module implements ModuleInterface
{
public function getIdentifier(): string
{
return 'sirsoft-ecommerce';
}
public function getVendor(): string
{
return 'sirsoft';
}
// ... 모든 메서드 직접 구현 필요
}
```
---
## composer.json (모듈)
### 기본 구조
```json
{
"name": "modules/sirsoft-ecommerce",
"description": "E-commerce module for Gnuboard7 platform by sirsoft",
"type": "library",
"authors": [
{
"name": "sirsoft",
"email": "contact@sirsoft.com"
}
],
"require": {
"php": "^8.2"
},
"autoload": {
"psr-4": {
"Modules\\Sirsoft\\Ecommerce\\": "src/"
}
}
}
```
### 외부 패키지 의존성
모듈이 외부 Composer 패키지를 필요로 하는 경우, `require`에 추가합니다.
```json
{
"name": "modules/sirsoft-ecommerce",
"require": {
"php": "^8.2",
"stripe/stripe-php": "^13.0",
"intervention/image": "^3.0"
},
"autoload": {
"psr-4": {
"Modules\\Sirsoft\\Ecommerce\\": "src/"
}
}
}
```
```text
주의: 루트 composer.json에 모듈 패키지 추가 금지
모듈 설치 시 자동으로 composer install 실행 (Phase 4.5)
modules/{identifier}/vendor/ 디렉토리에 독립 설치
수동 설치: php artisan module:composer-install [identifier]
```
- `php`와 `ext-*` 패키지는 외부 의존성으로 간주되지 않음 (composer install 트리거 안 함)
- 설치된 패키지는 `vendor_autoloads`를 통해 런타임에 자동 로드됨
- 상세: [extension-manager.md](extension-manager.md) "Composer 의존성 관리" 참조
### Factory/Seeder 사용 시 추가 설정
```
필수: database/factories/ 또는 database/seeders/ 사용 시 autoload 등록
등록하지 않으면 테스트에서 "Class not found" 오류 발생
```
Factory 또는 Seeder를 사용하는 모듈은 **반드시** composer.json에 해당 경로를 등록해야 합니다:
```json
{
"name": "modules/sirsoft-ecommerce",
"description": "E-commerce module for Gnuboard7 platform by sirsoft",
"type": "library",
"version": "1.0.0",
"license": "MIT",
"autoload": {
"psr-4": {
"Modules\\Sirsoft\\Ecommerce\\": "src/",
"Modules\\Sirsoft\\Ecommerce\\Database\\Seeders\\": "database/seeders/",
"Modules\\Sirsoft\\Ecommerce\\Database\\Factories\\": "database/factories/"
}
},
"require": {
"php": "^8.2"
}
}
```
### autoload 설정 후 갱신 (필수)
composer.json 수정 후 반드시 autoload 갱신:
```bash
php artisan extension:update-autoload
```
---
## 모듈 디렉토리 구조
```
modules/_bundled/sirsoft-ecommerce/
├── module.json # 메타데이터 (이름, 버전, 설명 등 SSoT)
├── module.php # ModuleInterface 구현
├── LICENSE # 라이선스 전문 (MIT)
├── composer.json # 오토로딩 + 외부 패키지 의존성 설정
├── package.json # npm 패키지 정의 (에셋 모듈만)
├── vite.config.ts # Vite 빌드 설정 (에셋 모듈만)
├── tsconfig.json # TypeScript 설정 (에셋 모듈만)
├── vendor/ # Composer 의존성 (자동 생성, gitignore 대상)
├── dist/ # 프론트엔드 빌드 출력 (에셋 모듈만, gitignore 대상)
│ ├── js/module.iife.js
│ └── css/module.css
├── upgrades/ # 버전 업그레이드 스텝 (UpgradeStepInterface 구현)
│ └── Upgrade_1_1_0.php # 1.1.0 버전 업그레이드 로직
├── config/
│ └── ecommerce.php # 모듈 설정
├── database/
│ ├── factories/ # 테스트용 Factory
│ ├── migrations/ # 마이그레이션
│ └── seeders/ # 시더
│ ├── DatabaseSeeder.php # 메인 시더 (설치 + 조건부 샘플)
│ └── Sample/ # 샘플(개발용) 시더
├── lang/ # 다국어 파일 (PHP 배열)
│ ├── en/
│ └── ko/
├── resources/ # 리소스 파일
│ ├── js/ # 프론트엔드 JS 소스 (에셋 모듈만)
│ │ ├── index.ts # 엔트리 포인트
│ │ └── handlers/ # 커스텀 핸들러
│ ├── css/ # CSS 소스 (에셋 모듈만)
│ │ └── main.css
│ ├── lang/ # 프론트엔드 다국어 (JSON)
│ │ ├── ko.json # 한국어 프론트엔드 다국어
│ │ └── en.json # 영어 프론트엔드 다국어
│ ├── layouts/ # 레이아웃 JSON
│ │ ├── admin/ # admin 템플릿 레이아웃
│ │ └── user/ # user 템플릿 레이아웃 (선택)
│ ├── routes/ # 프론트엔드 라우트 정의
│ │ ├── admin.json # admin 템플릿 전용 라우트
│ │ └── user.json # user 템플릿 전용 라우트 (선택)
│ └── views/ # 뷰 파일 (Blade 템플릿 등)
├── src/
│ ├── Contracts/ # 인터페이스
│ ├── Http/ # HTTP 계층
│ │ ├── Controllers/ # 컨트롤러
│ │ │ └── Api/
│ │ │ └── Admin/
│ │ ├── Requests/ # FormRequest 클래스
│ │ └── Resources/ # API 리소스
│ ├── Listeners/ # 훅 리스너
│ ├── Models/ # 모델
│ ├── Providers/ # 프로바이더
│ ├── Repositories/ # 리포지토리
│ ├── Seo/ # SEO Sitemap 기여자, 캐시 무효화 리스너
│ ├── routes/ # 라우트 (src 하위)
│ │ ├── api.php
│ │ └── web.php
│ └── Services/ # 서비스
└── tests/ # 테스트 파일
├── Feature/ # 기능 테스트
└── Unit/ # 단위 테스트
```
### 디렉토리 설명
| 디렉토리 | 설명 |
|----------|------|
| `module.json` | 메타데이터 SSoT (이름, 버전, 설명, 의존성, 라이선스 등) |
| `module.php` | ModuleInterface 구현 (진입점) |
| `LICENSE` | 라이선스 전문 (MIT 등) — API 엔드포인트 `GET /api/admin/modules/{identifier}/license`로 제공 |
| `composer.json` | PSR-4 오토로딩 + 외부 패키지 의존성 설정 |
| `package.json` | npm 패키지 정의 (에셋 모듈만 해당) |
| `vite.config.ts` | Vite IIFE 빌드 설정 (에셋 모듈만 해당) |
| `vendor/` | Composer 의존성 디렉토리 (자동 생성, gitignore 대상) |
| `dist/` | 프론트엔드 빌드 출력 (에셋 모듈만 해당, gitignore 대상) |
| `config/` | 모듈별 설정 파일 |
| `database/factories/` | 테스트용 Factory (autoload 등록 필수) |
| `database/migrations/` | 데이터베이스 마이그레이션 |
| `database/seeders/` | 데이터베이스 시더 (autoload 등록 필수). 설치 시더는 루트, 샘플 시더는 `Sample/` 하위 |
| `lang/` | 백엔드 다국어 (PHP 배열) |
| `resources/js/` | 프론트엔드 JS/TS 소스 — 핸들러 정의 등 (에셋 모듈만 해당) |
| `resources/css/` | CSS 소스 (에셋 모듈만 해당) |
| `resources/lang/` | 프론트엔드 다국어 (JSON) |
| `resources/layouts/` | 레이아웃 JSON (admin/, user/ 하위) |
| `resources/routes/admin.json` | admin 템플릿 전용 프론트엔드 라우트 |
| `resources/routes/user.json` | user 템플릿 전용 프론트엔드 라우트 (선택) |
| `src/Http/Controllers/` | 컨트롤러 |
| `src/Http/Requests/` | FormRequest 클래스 |
| `src/Http/Resources/` | API 리소스 |
| `src/Listeners/` | 훅 리스너 |
| `src/Models/` | Eloquent 모델 |
| `src/Repositories/` | 리포지토리 |
| `src/Seo/` | SEO Sitemap 기여자, 캐시 무효화 리스너 |
| `src/Services/` | 비즈니스 로직 서비스 |
| `src/routes/` | API/Web 라우트 |
| `tests/` | 테스트 파일 |
---
## 번들 디렉토리 작업 규칙
```text
필수: 모듈 수정/개발은 _bundled 디렉토리에서만 작업
필수: _bundled 작업 완료 후 반영/검증은 업데이트 프로세스 사용
```
### 개발 워크플로우
```text
1. modules/_bundled/{identifier}/ 에서 코드 수정
2. _bundled에서 직접 테스트 실행으로 검증 (활성 디렉토리 복사 불필요)
3. module.json 버전 올리기
4. php artisan module:update {identifier} 로 활성 디렉토리에 프로덕션 반영
```
### _bundled 직접 테스트 실행
_bundled에서 바로 PHPUnit/Vitest 테스트를 실행할 수 있습니다. `tests/bootstrap.php`가 _bundled 코드를 활성 디렉토리보다 우선 로드합니다.
```bash
# 백엔드 테스트 (_bundled에서 직접 실행)
php vendor/bin/phpunit modules/_bundled/{identifier}/tests
php vendor/bin/phpunit --filter=TestName modules/_bundled/{identifier}/tests
# 프론트엔드 테스트
cd modules/_bundled/{identifier}
powershell -Command "npm run test:run"
```
> 상세: [testing-guide.md](../testing-guide.md) "_bundled 확장 직접 테스트" 참조
### 왜 활성 디렉토리 직접 수정이 금지되는가?
- 활성 디렉토리는 `.gitignore` 대상 → Git에 변경 기록 불가
- 다음 업데이트 시 `_bundled` 소스로 덮어쓰기 → 직접 수정 사항 유실
- 업데이트 프로세스 미수행 시 마이그레이션/권한 동기화/레이아웃 갱신 누락
### 예외: 초기 개발 (아직 _bundled에 미등록)
```text
✅ 허용: 신규 모듈 초기 개발 시 활성 디렉토리에서 직접 작업
전환점: _bundled에 최초 반영한 이후부터는 반드시 _bundled에서만 작업
```
> 상세: [extension-update-system.md](./extension-update-system.md) "번들 디렉토리 개발 워크플로우" 참조
---
## 코드 변경 시 버전/업그레이드 필수
```text
필수: 모듈 코드를 변경한 경우 버전을 올리고 필요 시 업그레이드 스텝을 작성해야 합니다.
버전 변경 없이 _bundled에 반영하면, 이미 설치된 환경에서 업데이트가 감지되지 않습니다.
```
### 필수 작업
1. **`module.json` 버전 올리기**: `version` 필드를 Semantic Versioning에 따라 증가
2. **`_bundled` 동기화**: `modules/_bundled/{identifier}/` 디렉토리에 변경 사항 반영
3. **업그레이드 스텝 작성** (조건부): DB 스키마/환경설정 구조/데이터 마이그레이션이 필요한 경우
### 업그레이드 스텝 작성 기준
| 변경 유형 | 업그레이드 스텝 필요 | 비고 |
|----------|-------------------|------|
| DB 스키마 변경 | ✅ (+ 마이그레이션) | 컬럼/테이블 구조 변경 |
| 환경설정 구조 변경 | ✅ (SettingsMigrator) | 설정 키 이름/구조 변경 |
| 기존 데이터 변환 | ✅ | 데이터 형식 변환, 기본값 |
| 권한/역할/메뉴 추가·수정 | ❌ (자동 동기화) | Module.php에서 정의 |
| 정적 권한/메뉴 제거 | ✅ (cleanup 명시 호출) | #135: 동적 메뉴/권한 보존 |
| 레이아웃 JSON 변경 | ❌ (자동 갱신) | refresh-layout에서 처리 |
| PHP 코드만 변경 | ❌ | 버전만 올리면 됨 |
> 상세: [extension-update-system.md](./extension-update-system.md) "개발자 버전 업데이트 가이드" 참조
---
## 관련 문서
- [index.md](./index.md) - 확장 시스템 개요
- [hooks.md](./hooks.md) - 훅 시스템
- [module-routing.md](./module-routing.md) - 모듈 라우트 규칙
- [module-layouts.md](./module-layouts.md) - 모듈 레이아웃 시스템
- [module-assets.md](./module-assets.md) - 모듈 프론트엔드 에셋 시스템
- [module-commands.md](./module-commands.md) - 모듈 Artisan 커맨드
- [module-i18n.md](./module-i18n.md) - 모듈 다국어
- [활동 로그 시스템](../backend/activity-log.md) - 활동 로그 Listener, DescriptionResolver, Per-Item Bulk 로깅 규칙
- [extension-update-system.md](./extension-update-system.md) - 확장 업데이트 시스템
- [permissions.md](./permissions.md) - 권한 시스템
- [menus.md](./menus.md) - 메뉴 시스템
+548
View File
@@ -0,0 +1,548 @@
# 모듈 Artisan 커맨드
> **관련 문서**: [index.md](index.md) | [module-basics.md](module-basics.md) | [module-routing.md](module-routing.md)
---
## TL;DR (5초 요약)
```text
1. 목록: php artisan module:list
2. 설치: php artisan module:install [identifier] (Composer 의존성 자동 설치 포함)
3. 활성화: php artisan module:activate [identifier]
4. 비활성화: php artisan module:deactivate [identifier]
5. Composer: php artisan module:composer-install [identifier] (수동 의존성 설치)
6. 삭제: php artisan module:uninstall [identifier] (--delete-data 시 vendor/ 삭제 포함)
```
---
## 목차
- [핵심 원칙](#핵심-원칙)
- [사용 가능한 커맨드](#사용-가능한-커맨드)
- [커맨드 구현 규칙](#커맨드-구현-규칙)
- [커맨드 테스트](#커맨드-테스트)
---
## 핵심 원칙
### 모듈 Artisan 커맨드 규칙
```
필수: 모듈 관련 모든 작업은 Artisan 커맨드로 수행
✅ 규칙: 직접 DB 조작 또는 파일 시스템 변경 금지
```
모듈의 설치, 활성화, 비활성화, 삭제 등 모든 생명주기 관리는 반드시 Artisan 커맨드를 통해 수행해야 합니다. 이를 통해:
- 일관된 상태 관리
- 적절한 이벤트 발생
- 권한/메뉴 등 연관 데이터 관리
- 로그 기록
---
## 사용 가능한 커맨드
### 모듈 관리 커맨드
#### 모듈 목록 조회
```bash
php artisan module:list
```
**옵션**:
- `--status=active`: 활성화된 모듈만 표시
- `--status=inactive`: 비활성화된 모듈만 표시
- `--status=uninstalled`: 미설치 모듈만 표시
**출력 예시**:
```
+--------------------+----------+---------+
| Identifier | Version | Status |
+--------------------+----------+---------+
| sirsoft-ecommerce | 1.0.0 | active |
| sirsoft-blog | 1.2.0 | inactive|
| vendor-sample | 0.1.0 | - |
+--------------------+----------+---------+
```
#### 모듈 설치
```bash
php artisan module:install [identifier]
```
**수행 작업**:
- `_pending/_bundled` 디렉토리에서 활성 디렉토리로 복사 (ExtensionPendingHelper)
- `modules` 테이블에 모듈 등록
- 역할(Role) 등록
- 권한(Permission) 등록
- 메뉴(Menu) 등록
- 마이그레이션 실행 (있는 경우)
- 시더 실행 (있는 경우)
- Composer 의존성 설치 (외부 패키지가 있는 경우, Phase 4.5)
**예시**:
```bash
php artisan module:install sirsoft-ecommerce
```
#### 모듈 활성화
```bash
php artisan module:activate [identifier]
```
**수행 작업**:
- `modules.status`를 `active`로 변경
- soft deleted된 레이아웃 복원
- 모듈 레이아웃 등록 (admin 템플릿에)
- 모듈의 라우트 활성화
- 모듈의 훅 리스너 활성화
- **템플릿 언어/routes 캐시 무효화**
- **extension_cache_version 증가 (프론트엔드 캐시 무효화)**
#### 모듈 비활성화
```bash
php artisan module:deactivate [identifier]
```
**수행 작업**:
- `modules.status`를 `inactive`로 변경
- 모듈 레이아웃 soft delete
- 모듈의 라우트 비활성화
- 모듈의 훅 리스너 비활성화
- **템플릿 언어/routes 캐시 무효화**
- **extension_cache_version 증가 (프론트엔드 캐시 무효화)**
#### 모듈 삭제
```bash
php artisan module:uninstall [identifier]
```
**옵션**:
- `--force`: 확인 프롬프트 없이 즉시 삭제
- `--delete-data`: 데이터도 함께 삭제 (동적 테이블 + 마이그레이션 롤백 + 스토리지)
**수행 작업**:
- 확인 프롬프트 표시 (--force 미사용 시)
- `--delete-data` 사용 시 (트랜잭션 외부, DDL):
- `getDynamicTables()` 반환 테이블 삭제 (Manager가 처리)
- 마이그레이션 롤백 (정적 테이블 삭제)
- 모듈 스토리지 디렉토리 삭제 (`storage/app/modules/{identifier}/`)
- Composer vendor/ 디렉토리 및 composer.lock 삭제
- 역할(Role) 삭제
- 권한(Permission) 삭제
- 메뉴(Menu) 삭제
- 레이아웃(Layout) 영구 삭제
- 활성 디렉토리 삭제 (`_bundled` 원본은 보존)
- `modules` 테이블에서 완전 삭제 (hard delete)
- **템플릿 언어/routes 캐시 무효화**
- **extension_cache_version 증가 (프론트엔드 캐시 무효화)**
**예시**:
```bash
# 확인 프롬프트 표시
php artisan module:uninstall sirsoft-ecommerce
# 강제 삭제
php artisan module:uninstall sirsoft-ecommerce --force
```
#### 모듈 Composer 의존성 설치
```bash
php artisan module:composer-install [identifier]
```
**옵션**:
- `--all`: 모든 설치된 모듈의 Composer 의존성 설치
- `--no-dev`: dev 의존성 제외
**예시**:
```bash
# 특정 모듈 의존성 설치
php artisan module:composer-install sirsoft-ecommerce
# 모든 모듈 의존성 설치
php artisan module:composer-install --all
# dev 의존성 제외
php artisan module:composer-install sirsoft-ecommerce --no-dev
```
**수행 작업**:
- 모듈의 `composer.json`에 외부 패키지(`require`)가 있는지 확인
- `modules/{identifier}/` 디렉토리에서 `composer install` 실행
- `modules/{identifier}/vendor/` 디렉토리 생성
- 오토로드 캐시 파일 갱신 (`vendor_autoloads` 반영)
**사용 시점**:
- 모듈 설치 시 자동 실행되지만, 수동으로 재설치가 필요한 경우
- vendor/ 디렉토리가 손상되거나 삭제된 경우
- 새로운 패키지를 composer.json에 추가한 후
#### 모듈 업데이트 확인
```bash
php artisan module:check-updates [identifier?]
```
**인수**:
- `identifier` (선택): 특정 모듈만 확인
**기능**:
- **전체 확인** (인수 없음): 설치된 모든 모듈의 업데이트 가용 여부를 테이블로 출력
- 감지 우선순위: GitHub > `_bundled` (2단계, `_pending` 미참여)
- DB에 `update_available`, `latest_version`, `update_source` 갱신
- **단일 확인** (인수 있음): 특정 모듈의 업데이트 가용 여부 확인
- `checkModuleUpdate()`는 DB 갱신 안 함 → 커맨드에서 `updateByIdentifier()` 직접 호출
**예시**:
```bash
# 모든 모듈 업데이트 확인
php artisan module:check-updates
# 특정 모듈만 확인
php artisan module:check-updates sirsoft-ecommerce
```
#### 모듈 업데이트
```bash
php artisan module:update [identifier] [--force]
```
**인수**:
- `identifier` (필수): 업데이트할 모듈 식별자
**옵션**:
- `--force`: 버전 비교 없이 강제 업데이트 (파일 손상, 수동 수정 복원 시 사용)
**수행 작업**:
- 업데이트 가용 여부 확인 (없으면 info + SUCCESS, `--force` 시 무시하고 진행)
- 확인 프롬프트 → 업데이트 실행
- 상태 가드 확인 → 백업 → status: Updating → 파일 교체 → 마이그레이션 → 업그레이드 스텝 → 복원
- 실패 시 백업 복원 안내
**예시**:
```bash
php artisan module:update sirsoft-ecommerce
php artisan module:update sirsoft-ecommerce --force # 강제 재설치
```
#### 모듈 캐시 초기화
```bash
php artisan module:cache-clear
```
**옵션**:
- `[identifier]`: 특정 모듈만 캐시 삭제
**예시**:
```bash
# 모든 모듈 캐시 삭제
php artisan module:cache-clear
# 특정 모듈 캐시만 삭제
php artisan module:cache-clear sirsoft-ecommerce
```
#### 모듈 프론트엔드 에셋 빌드
```bash
php artisan module:build [identifier]
```
> **기본 빌드 경로**: `_bundled` 디렉토리. 빌드 결과물은 빌드 경로 내에만 남음.
> 활성 디렉토리 반영은 `module:update` 커맨드로만 수행.
**옵션**:
- `--all`: 모든 모듈의 에셋 빌드 (_bundled 기준, --active 시 활성 기준)
- `--watch`: 파일 변경 감시 모드 (자동으로 활성 디렉토리 사용)
- `--production`: 프로덕션 최적화 빌드
- `--active`: 활성 디렉토리에서 빌드 (_bundled 대신)
**예시**:
```bash
# _bundled에서 빌드 (기본값)
php artisan module:build sirsoft-ecommerce
# 모든 _bundled 모듈 빌드
php artisan module:build --all
# 프로덕션 빌드 (_bundled)
php artisan module:build sirsoft-ecommerce --production
# 파일 감시 모드 (활성 디렉토리에서 자동 실행)
php artisan module:build sirsoft-ecommerce --watch
# 활성 디렉토리에서 빌드
php artisan module:build sirsoft-ecommerce --active
# 빌드 후 활성 디렉토리 반영
php artisan module:update sirsoft-ecommerce
```
**수행 작업**:
- 빌드 경로 결정: _bundled 우선 → 활성 폴백 (--active 시 활성만)
- npm 의존성 설치 (node_modules 없는 경우)
- Vite를 통한 IIFE 번들 빌드
- `dist/js/module.iife.js` 및 `dist/css/module.css` 생성
- 빌드 결과 파일 크기 출력
- **extension_cache_version 증가 (프론트엔드 캐시 무효화)**
- 주의: `--watch` 모드에서는 캐시 버전이 증가하지 않음
**참고**: [module-assets.md](./module-assets.md) 문서에서 에셋 빌드 설정 상세 참조
#### 모듈 시더 실행
```bash
php artisan module:seed [identifier]
```
**옵션**:
- `--class=SeederClass`: 특정 시더 클래스만 실행
- `--sample`: 샘플 데이터 시더도 함께 실행 (기본: 설치 시더만)
- `--count=key=value`: 시더에 카운트 옵션 전달 (반복 가능)
- `--force`: 프로덕션 환경에서 강제 실행
**예시**:
```bash
# 단일 모듈 시더 실행 (설치 시더만)
php artisan module:seed sirsoft-ecommerce
# 설치 + 샘플 시더 실행
php artisan module:seed sirsoft-ecommerce --sample
# 모든 활성 모듈 시더 실행
php artisan module:seed
# 특정 시더 클래스 실행
php artisan module:seed sirsoft-ecommerce --class=Sample\\ProductSeeder
# 샘플 + 카운트 옵션
php artisan module:seed sirsoft-ecommerce --sample --count=products=1000
# 프로덕션 환경에서 강제 실행
php artisan module:seed sirsoft-ecommerce --force
```
**수행 작업**:
- 활성화된 모듈만 실행 가능 (비활성 모듈은 에러)
- `modules/{identifier}/database/seeders/DatabaseSeeder.php` 실행
- 기본 실행 시 설치 필수 시더만 실행, `--sample` 옵션 시 Sample/ 하위 시더도 실행
- `--class` 옵션 사용 시 해당 시더만 실행
- `--sample` 옵션은 `HasSampleSeeders` 트레이트를 통해 시더에 전파
**주의사항**:
- 활성화되지 않은 모듈은 시더 실행 불가
- 프로덕션 환경에서는 `--force` 옵션 필수
- 샘플 시더는 `database/seeders/Sample/` 하위 디렉토리에 위치
---
## 커맨드 구현 규칙
### 1. 다국어 메시지 필수
```php
// ✅ DO: 다국어 메시지 사용
$this->info(__('modules.commands.install.success', ['module' => $identifier]));
// ❌ DON'T: 하드코딩 메시지
$this->info('모듈 설치 성공');
```
### 2. 로그 기록 필수
```php
use Illuminate\Support\Facades\Log;
// 성공 로그
Log::info(__('modules.commands.install.success', ['module' => $identifier]));
// 에러 로그
Log::error('모듈 설치 실패', [
'module' => $identifier,
'error' => $e->getMessage(),
]);
```
### 3. 예외 처리
```php
public function handle(): int
{
try {
// 커맨드 로직
$this->info('✅ ' . __('modules.commands.install.success', ['module' => $identifier]));
return Command::SUCCESS;
} catch (\Exception $e) {
$this->error('❌ ' . $e->getMessage());
Log::error('모듈 설치 실패', [
'module' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
```
### 4. 상세 정보 출력
```php
// 모듈 정보 출력
$this->info(' - ' . __('modules.commands.install.version', ['version' => $module->version]));
$this->info(' - ' . __('modules.commands.install.permissions_created', ['count' => $permissionsCount]));
$this->info(' - ' . __('modules.commands.install.menus_created', ['count' => $menusCount]));
```
---
## 커맨드 테스트
### Feature 테스트 작성 필수
모든 커맨드에 대해 Feature 테스트를 작성해야 합니다.
#### 설치 테스트
```php
public function test_can_install_module_via_command(): void
{
// Arrange
$identifier = 'sirsoft-sample';
// Act
$this->artisan('module:install', ['identifier' => $identifier])
->assertSuccessful();
// Assert
$this->assertDatabaseHas('modules', [
'identifier' => $identifier,
'status' => ExtensionStatus::Inactive->value,
]);
}
```
#### 삭제 확인 프롬프트 테스트
```php
public function test_uninstall_requires_confirmation(): void
{
// Arrange
$module = Module::factory()->create(['identifier' => 'test-module']);
// Act & Assert
$this->artisan('module:uninstall', ['identifier' => 'test-module'])
->expectsConfirmation(
__('modules.commands.uninstall.confirm', ['module' => 'test-module']),
'no'
)
->assertSuccessful();
// Module should still exist
$this->assertDatabaseHas('modules', ['identifier' => 'test-module']);
}
```
#### 활성화/비활성화 테스트
```php
public function test_can_activate_module(): void
{
// Arrange
$module = Module::factory()->create([
'identifier' => 'test-module',
'status' => ExtensionStatus::Inactive,
]);
// Act
$this->artisan('module:activate', ['identifier' => 'test-module'])
->assertSuccessful();
// Assert
$this->assertDatabaseHas('modules', [
'identifier' => 'test-module',
'status' => ExtensionStatus::Active->value,
]);
}
public function test_can_deactivate_module(): void
{
// Arrange
$module = Module::factory()->create([
'identifier' => 'test-module',
'status' => ExtensionStatus::Active,
]);
// Act
$this->artisan('module:deactivate', ['identifier' => 'test-module'])
->assertSuccessful();
// Assert
$this->assertDatabaseHas('modules', [
'identifier' => 'test-module',
'status' => ExtensionStatus::Inactive->value,
]);
}
```
---
## 모듈 업데이트 (API + CLI)
모듈 업데이트는 **API 엔드포인트** 또는 **Artisan 커맨드**를 통해 수행됩니다.
### Artisan 커맨드
```bash
# 업데이트 확인
php artisan module:check-updates # 전체
php artisan module:check-updates [identifier] # 단일
# 업데이트 실행
php artisan module:update [identifier]
```
### API 엔드포인트
```text
POST /api/admin/modules/check-updates
POST /api/admin/modules/{moduleName}/update
```
- 감지 우선순위: GitHub > `_bundled` (2단계, `_pending` 미참여)
- 상태 가드 확인 → 백업 → status: Updating → 파일 교체 → 마이그레이션 → 업그레이드 스텝 → 복원
- 에러 발생 시 자동 롤백 (백업에서 복원)
> 상세: [extension-update-system.md](extension-update-system.md)
---
## 관련 문서
- [모듈 개발 기초](module-basics.md) - AbstractModule, 디렉토리 구조
- [모듈 라우트 규칙](module-routing.md) - 라우트 네이밍, 자동 Prefix
- [모듈 레이아웃 시스템](module-layouts.md) - 레이아웃 등록, 오버라이드
- [권한 시스템](permissions.md) - 모듈 Role/Permission 자동 관리
- [확장 업데이트 시스템](extension-update-system.md) - 업데이트 감지/실행 전체 흐름
+336
View File
@@ -0,0 +1,336 @@
# 모듈 다국어 시스템
> **관련 문서**: [index.md](index.md) | [module-basics.md](module-basics.md) | [module-routing.md](module-routing.md)
---
## TL;DR (5초 요약)
```text
1. 백엔드: /lang/{locale}/*.php → __('vendor-module::key')
2. 프론트엔드: /resources/lang/{locale}.json → $t:key
3. JSON에 moduleIdentifier 포함 금지! (자동 병합됨)
4. 지원 언어: ko, en 필수
5. 키 충돌 방지: 모듈별 고유 prefix 사용 권장
```
---
## 목차
- [핵심 원칙](#핵심-원칙)
- [백엔드 다국어](#백엔드-다국어)
- [프론트엔드 다국어](#프론트엔드-다국어)
- [레이아웃 JSON에서 사용](#레이아웃-json에서-사용)
---
## 핵심 원칙
### 다국어 파일 구조
```
중요: 백엔드와 프론트엔드 다국어 파일 경로 구분
✅ 필수: resources/lang/*.json 파일에 moduleIdentifier 없이 작성
```
| 구분 | 파일 경로 | 형식 | 사용처 |
|------|----------|------|--------|
| 백엔드 | `/lang/{locale}/*.php` | PHP 배열 | Laravel `__()` 함수 |
| 프론트엔드 | `/resources/lang/{locale}.json` | JSON | 레이아웃 JSON `$t:` 문법 |
### moduleIdentifier 규칙
```
✅ 프론트엔드 다국어 파일에는 moduleIdentifier 없이 순수 키만 작성
✅ 템플릿 서빙 시 자동으로 moduleIdentifier를 최상위 키로 병합
❌ 내부 JSON 파일에 { "sirsoft-sample": { ... } } 형태로 작성 금지
```
---
## 백엔드 다국어
### 파일 위치
```
modules/sirsoft-sample/
└── src/
└── lang/
├── ko/
│ └── messages.php
└── en/
└── messages.php
```
**중요**: 백엔드 다국어 파일은 반드시 `src/lang/` 디렉토리에 위치해야 합니다. TranslationServiceProvider가 이 경로에서 파일을 자동으로 로드합니다.
### 파일 형식 (PHP 배열)
```php
// src/lang/ko/messages.php
return [
'product_created' => '상품이 생성되었습니다.',
'order_confirmed' => '주문이 확인되었습니다.',
'validation' => [
'name_required' => '상품명은 필수 항목입니다.',
'price_min' => '가격은 0 이상이어야 합니다.',
],
];
```
```php
// lang/en/messages.php
return [
'product_created' => 'Product has been created.',
'order_confirmed' => 'Order has been confirmed.',
'validation' => [
'name_required' => 'Product name is required.',
'price_min' => 'Price must be at least 0.',
],
];
```
### 사용법
Laravel의 `__()` 함수를 사용합니다. **모듈 다국어는 더블 콜론(::) 문법을 사용합니다**:
```php
// 모듈 네임스페이스 사용 (더블 콜론 ::)
__('sirsoft-sample::messages.product_created');
// 결과: '상품이 생성되었습니다.' (ko 로케일)
// 중첩 키
__('sirsoft-sample::messages.validation.name_required');
// 결과: '상품명은 필수 항목입니다.'
// 파라미터 치환
__('sirsoft-sample::messages.greeting', ['name' => '홍길동']);
// messages.php: 'greeting' => ':name님, 환영합니다.'
// 결과: '홍길동님, 환영합니다.'
// ❌ 잘못된 사용 (점 . 사용)
__('sirsoft-sample.messages.product_created'); // 작동하지 않음!
// ✅ 올바른 사용 (더블 콜론 :: 사용)
__('sirsoft-sample::messages.product_created'); // 정상 작동
```
---
## 프론트엔드 다국어
### 파일 위치
```
modules/sirsoft-sample/
└── resources/
└── lang/
├── ko.json
└── en.json
```
### 파일 형식 (JSON)
```json
// resources/lang/ko.json
{
"admin": {
"index": {
"title": "샘플 항목 관리",
"description": "샘플 모듈의 항목을 관리합니다"
},
"create": {
"title": "새 항목 생성",
"submit": "생성하기"
}
},
"messages": {
"save_success": "저장되었습니다.",
"delete_confirm": "정말 삭제하시겠습니까?"
}
}
```
```json
// resources/lang/en.json
{
"admin": {
"index": {
"title": "Sample Item Management",
"description": "Manage sample module items"
},
"create": {
"title": "Create New Item",
"submit": "Create"
}
},
"messages": {
"save_success": "Saved successfully.",
"delete_confirm": "Are you sure you want to delete?"
}
}
```
### moduleIdentifier 자동 병합
템플릿 서빙 시 시스템이 자동으로 moduleIdentifier를 최상위 키로 병합합니다:
```json
// 원본 파일 (resources/lang/ko.json)
{
"admin": {
"index": {
"title": "샘플 항목 관리"
}
}
}
// 서빙 후 (자동 변환)
{
"sirsoft-sample": {
"admin": {
"index": {
"title": "샘플 항목 관리"
}
}
}
}
```
---
## $partial Fragment 시스템
### 개요
프론트엔드 다국어 JSON 파일이 커지면 `$partial` 디렉티브를 사용하여 도메인별로 분리할 수 있습니다.
`ResolvesLanguageFragments` trait이 JSON 로드 시 fragment를 자동으로 병합합니다.
### 디렉토리 구조
```
modules/_bundled/vendor-module/
└── resources/lang/
├── ko.json ← 메인 JSON (fragment 참조 포함)
├── en.json
└── partial/ ← fragment 파일 디렉토리
├── ko/
│ ├── common.json
│ └── admin/
│ ├── locale.json
│ ├── products.json
│ └── orders.json
└── en/
├── common.json
└── admin/
└── ...
```
### $partial 디렉티브 문법
메인 JSON 파일에서 `$partial` 키로 fragment 파일을 참조합니다:
```json
{
"common": {
"$partial": "partial/ko/common.json"
},
"admin": {
"locale": {
"$partial": "partial/ko/admin/locale.json"
},
"products": {
"$partial": "partial/ko/admin/products.json"
}
}
}
```
- `$partial` 값은 **`resources/lang/` 기준 상대 경로**
- `$partial`이 포함된 객체는 fragment 파일의 내용으로 **완전 교체**됨
- fragment 파일 내부에서 다시 `$partial`을 사용한 **중첩 참조 가능** (최대 깊이 제한 적용)
### Fragment 해석 규칙
| 규칙 | 설명 |
| ---- | ---- |
| 최대 깊이 | `MAX_FRAGMENT_DEPTH = 10` (초과 시 해석 중단) |
| 순환 참조 | `fragmentStack`으로 감지, 순환 시 해당 fragment 무시 |
| 파일 미존재 | 해당 `$partial` 객체가 빈 객체로 대체 |
| 재귀 해석 | fragment 내 `$partial`도 재귀적으로 해석 |
### 사용 기준
| 상황 | 권장 |
| ---- | ---- |
| JSON 파일 500줄 이하 | 단일 파일 유지 |
| JSON 파일 500줄 초과 | 도메인별 `$partial` 분리 |
| admin/user 섹션 분리 필요 | `partial/{locale}/admin/`, `partial/{locale}/user/` |
> **참고**: `$partial`은 프론트엔드 JSON(`resources/lang/`)에서만 사용됩니다.
> 백엔드 PHP(`src/lang/`)에서는 사용할 수 없습니다.
---
## 레이아웃 JSON에서 사용
### 기본 문법
레이아웃 JSON에서 `$t:` 접두사를 사용하여 다국어 키를 참조합니다:
```json
{
"id": "page-title",
"type": "basic",
"name": "H1",
"props": {
"text": "$t:sirsoft-sample.admin.index.title"
}
}
```
### 키 구조
```
$t:[moduleIdentifier].[섹션].[하위섹션].[키]
예시:
$t:sirsoft-sample.admin.index.title
$t:sirsoft-sample.messages.save_success
```
### 다양한 사용 예시
```json
{
"id": "page-header",
"type": "composite",
"name": "PageHeader",
"props": {
"title": "$t:sirsoft-sample.admin.index.title",
"description": "$t:sirsoft-sample.admin.index.description"
}
}
```
```json
{
"id": "submit-button",
"type": "basic",
"name": "Button",
"props": {
"text": "$t:sirsoft-sample.admin.create.submit",
"variant": "primary"
}
}
```
---
## 관련 문서
- [모듈 개발 기초](module-basics.md) - AbstractModule, 디렉토리 구조
- [모듈 라우트 규칙](module-routing.md) - 라우트 네이밍, 자동 Prefix
- [모듈 레이아웃 시스템](module-layouts.md) - 레이아웃 등록, 오버라이드
- [데이터 바인딩](../frontend/data-binding.md) - $t: 문법 상세
+667
View File
@@ -0,0 +1,667 @@
# 모듈 레이아웃 시스템
> 이 문서는 그누보드7 모듈의 레이아웃 시스템에 대해 설명합니다. 레이아웃 등록, 오버라이드, 상속 처리를 다룹니다.
---
## TL;DR (5초 요약)
```text
1. 위치: modules/_bundled/vendor-module/resources/layouts/admin/*.json 또는 user/*.json
2. 네이밍: vendor-module.layout-name 형식 (DOT 구분자)
3. admin/ 하위 → Admin 템플릿, user/ 하위 → User 템플릿에 등록
4. 활성화 시 자동 등록, 비활성화 시 자동 삭제
5. 오버라이드: 템플릿 layouts/overrides/vendor-module/ + version_constraint 지원
```
---
## 목차
1. [개요](#개요)
2. [핵심 원칙](#핵심-원칙)
3. [레이아웃 파일 위치](#레이아웃-파일-위치)
4. [레이아웃 네이밍 규칙](#레이아웃-네이밍-규칙)
5. [레이아웃 등록 메커니즘](#레이아웃-등록-메커니즘)
6. [모듈 활성화/비활성화 흐름](#모듈-활성화비활성화-흐름)
7. [레이아웃 오버라이드 시스템](#레이아웃-오버라이드-시스템)
8. [레이아웃 상속 처리](#레이아웃-상속-처리)
9. [DB 저장 규칙](#db-저장-규칙)
10. [캐시 전략](#캐시-전략)
11. [개발 가이드](#모듈-레이아웃-개발-가이드)
12. [문제 해결](#문제-해결)
---
## 개요
모듈은 자체 레이아웃 파일을 제공하여 관리자/사용자 페이지 UI를 구성할 수 있습니다. 모듈 레이아웃은 디렉토리 위치에 따라 활성화된 Admin 또는 User 템플릿에 자동으로 등록되며, 템플릿 오버라이드 시스템을 통해 커스터마이징이 가능합니다.
---
## 핵심 원칙
```text
필수: ModuleManager와 TemplateManager의 일관성 유지
필수: admin/ 하위 레이아웃 → Admin 템플릿에만 등록
필수: user/ 하위 레이아웃 → User 템플릿에만 등록
경고: layouts/ 루트에 직접 배치된 파일은 스킵됨 (admin/ 또는 user/ 하위 필수)
✅ 레이아웃 content는 layoutData 전체를 저장
✅ 모듈 비활성화 시 레이아웃 soft delete
✅ 모듈 재활성화 시 레이아웃 복원
```
---
## 레이아웃 파일 위치
모듈 레이아웃은 `resources/layouts/` 디렉토리에 위치합니다. 디렉토리 위치에 따라 등록 대상 템플릿이 결정됩니다.
```
modules/
└── _bundled/
└── vendor-module/
└── resources/
└── layouts/
├── admin/ ← Admin 템플릿에 등록
│ ├── index.json
│ └── edit.json
└── user/ ← User 템플릿에 등록
├── main.json
└── detail.json
```
> **주의**: `layouts/` 루트에 직접 배치된 파일(예: `layouts/settings.json`)은 등록되지 않고 경고 로그가 출력됩니다. 반드시 `admin/` 또는 `user/` 하위에 배치하세요.
### 등록 대상 템플릿 결정
| 파일 위치 | 등록 대상 | 예시 |
| ----------------------- | ------------------- | ------------------- |
| `layouts/admin/*.json` | 활성 Admin 템플릿 | sirsoft-admin_basic |
| `layouts/user/*.json` | 활성 User 템플릿 | sirsoft-user_theme1 |
| `layouts/*.json` (루트) | 등록 안됨 (스킵) | 경고 로그 출력 |
---
## 레이아웃 네이밍 규칙
### 자동 생성 패턴
```
{module-identifier}.{layout_path}
```
모듈 레이아웃은 DB에 등록될 때 `moduleIdentifier`가 점(`.`) 구분자로 접두사에 추가됩니다.
### 예시
```
파일: modules/_bundled/sirsoft-sample/resources/layouts/admin/index.json
파일 내 layout_name: admin_sample_index
DB 등록명: sirsoft-sample.admin_sample_index
파일: modules/_bundled/sirsoft-sample/resources/layouts/admin/edit.json
파일 내 layout_name: admin_sample_edit
DB 등록명: sirsoft-sample.admin_sample_edit
파일: modules/_bundled/sirsoft-sample/resources/layouts/user/main.json
파일 내 layout_name: user_sample_main
DB 등록명: sirsoft-sample.user_sample_main
```
### 중요 사항
- 레이아웃 파일 내에서는 `layout_name`을 **접두사 없이** 작성합니다
- 시스템이 자동으로 `{moduleIdentifier}.{layout_name}` 형식으로 변환합니다
- 점(`.`) 구분자는 moduleIdentifier와 layoutName의 경계를 명확히 구분합니다
### 파일 내 layout_name 지정 예시
```json
{
"layout_name": "admin_sample_index",
"version": "1.0.0",
"extends": "_admin_base",
"meta": {
"title": "$t:sirsoft-sample.admin.index.title"
},
"slots": {
"content": [...]
}
}
```
이 레이아웃은 DB에 `sirsoft-sample.admin_sample_index`로 등록됩니다.
---
## 레이아웃 등록 메커니즘
### ModuleManager.registerLayoutToTemplate()
```php
protected function registerLayoutToTemplate(
Template $template,
string $layoutName,
array $layoutData,
string $moduleIdentifier
): void
{
// TemplateManager와 동일하게 layoutData 전체 저장
$content = $layoutData;
TemplateLayout::updateOrCreate(
[
'template_id' => $template->id,
'name' => $layoutName,
],
[
'content' => $content, // extends, layout_name 포함
'extends' => $layoutData['extends'] ?? null,
'source_type' => LayoutSourceType::Module,
'source_identifier' => $moduleIdentifier,
'created_by' => Auth::id(),
'updated_by' => Auth::id(),
]
);
}
```
**중요**: `content`에는 `layoutData` 전체가 저장되므로 `extends`, `layout_name`, `version` 등 모든 필드가 포함됩니다.
---
## 모듈 활성화/비활성화 흐름
### 1. 모듈 활성화 (activateModule)
```php
public function activateModule(string $moduleName): array
{
// 1. 모듈 activate() 실행
$result = $module->activate();
if ($result) {
// 2. 모듈 상태를 Active로 변경
Module::where('identifier', $module->getIdentifier())
->update(['status' => ExtensionStatus::Active->value]);
// 3. soft deleted된 레이아웃 복원 (재활성화 시)
$this->restoreModuleLayouts($module->getIdentifier());
// 4. 레이아웃 등록 (새로운 레이아웃 또는 업데이트)
$layoutsRegistered = $this->registerModuleLayouts($moduleName);
// 5. 캐시 무효화
$this->invalidateLayoutCache($module->getIdentifier());
}
return ['success' => $result, 'layouts_registered' => $layoutsRegistered];
}
```
### 2. 모듈 비활성화 (deactivateModule)
```php
public function deactivateModule(string $moduleName): array
{
// 1. 모듈 deactivate() 실행
$result = $module->deactivate();
if ($result) {
// 2. 모듈 상태를 Inactive로 변경
Module::where('identifier', $module->getIdentifier())
->update(['status' => ExtensionStatus::Inactive->value]);
// 3. 모듈 레이아웃 soft delete
$layoutsDeleted = $this->softDeleteModuleLayouts($module->getIdentifier());
// 4. 캐시 무효화
$this->invalidateLayoutCache($module->getIdentifier());
}
return ['success' => $result, 'layouts_deleted' => $layoutsDeleted];
}
```
### 3. 템플릿 활성화 시 레이아웃 등록 (activateTemplate)
새 템플릿이 활성화되면 `TemplateManager::activateTemplate()`이 기존 활성 모듈/플러그인의 레이아웃을 새 템플릿에 자동으로 등록합니다.
```php
// TemplateManager::activateTemplate() 내부 (트랜잭션)
DB::transaction(function () use ($templateName, $template, $templateRecord) {
// 1. 같은 타입의 기존 활성 템플릿 비활성화
$this->deactivateTemplatesByType($template['type']);
// 2. 상태 업데이트
$this->templateRepository->updateByIdentifier($templateName, [...]);
// 3. 레이아웃 확장(layout_extensions) 등록
$this->layoutExtensionService->registerAllActiveExtensionsToTemplate($templateRecord->id);
// 4. 활성 모듈/플러그인의 레이아웃을 새 템플릿에 등록
app(ModuleManager::class)->registerLayoutsForAllActiveModules();
app(PluginManager::class)->registerLayoutsForAllActivePlugins();
// 5. 캐시 워밍
$this->warmTemplateCache($templateName);
});
```
**중요**: `registerModuleLayouts()`는 `updateOrCreate` 패턴을 사용하므로 중복 호출에 안전합니다.
### 4. Soft Delete/Restore 이유
| 작업 | 설명 |
|------|------|
| **Soft Delete** | 모듈 재활성화 시 레이아웃 복원을 위해 |
| **Hard Delete** | 모듈 제거(uninstall) 시에만 사용 |
**장점**: 모듈 설정 및 오버라이드 데이터 보존
---
## 레이아웃 오버라이드 시스템
### 우선순위 (LayoutResolverService)
```
1. 템플릿 오버라이드 (source_type = 'template')
2. 모듈 기본 레이아웃 (source_type = 'module')
```
### 오버라이드 생성 예시
```php
// 템플릿에서 모듈 레이아웃 오버라이드
TemplateLayout::create([
'template_id' => $templateId,
'name' => 'sirsoft-sample.admin_sample_index', // 모듈 레이아웃명과 동일
'content' => $customizedLayoutData, // 커스터마이징된 데이터
'extends' => '_admin_base',
'source_type' => LayoutSourceType::Template, // 오버라이드 표시
'source_identifier' => 'sirsoft-admin_basic',
'created_by' => Auth::id(),
]);
```
### 오버라이드 해석 로직
```php
// LayoutService.resolveLayout()
if ($this->isModuleLayoutName($layoutName)) {
// LayoutResolverService를 통해 우선순위 적용
$resolved = $this->layoutResolverService->resolve($layoutName, $templateId);
// 오버라이드가 있으면 오버라이드 반환, 없으면 모듈 기본 반환
return $resolved;
}
```
### 오버라이드 버전 호환성 (version_constraint)
템플릿에서 모듈/플러그인의 레이아웃을 오버라이드할 때, 대상 모듈/플러그인의 버전이 변경되면 오버라이드가 호환되지 않을 수 있습니다. `version_constraint` 필드를 사용하여 오버라이드가 적용될 버전 범위를 지정할 수 있습니다.
#### 사용 목적
- 모듈/플러그인 메이저 버전 변경 시 오버라이드 호환성 문제 방지
- 비호환 오버라이드 자동 스킵 및 관리자 경고 표시
- 템플릿 개발자가 지원하는 버전 범위 명시
#### JSON 스키마
```json
{
"layout_name": "admin_sample_index",
"version_constraint": "^1.0",
"injections": [...]
}
```
#### 지원 문법 (Composer Semver)
| 문법 | 의미 | 예시 |
| ---- | ---- | ---- |
| `^1.0` | 1.0.0 이상, 2.0.0 미만 | 1.0.0, 1.5.0, 1.9.9 ✅ / 2.0.0 ❌ |
| `~1.5` | 1.5.0 이상, 2.0.0 미만 | 1.5.0, 1.9.0 ✅ / 2.0.0 ❌ |
| `~1.5.0` | 1.5.0 이상, 1.6.0 미만 | 1.5.0, 1.5.9 ✅ / 1.6.0 ❌ |
| `>=1.0 <2.0` | 명시적 범위 | 1.0.0, 1.9.9 ✅ / 0.9.0, 2.0.0 ❌ |
| `1.0.0 - 1.9.9` | 하이픈 범위 | 1.0.0 ~ 1.9.9 ✅ |
| `1.0.0 \|\| 2.0.0` | OR 조건 | 1.0.0, 2.0.0 ✅ / 1.5.0 ❌ |
#### 동작 방식
1. **version_constraint 없음**: 항상 오버라이드 적용 (하위 호환성 유지)
2. **버전 제약 만족**: 오버라이드 정상 적용
3. **버전 제약 불만족**: 오버라이드 스킵 + 관리자 경고 표시
4. **대상 버전 정보 없음**: 오버라이드 적용 (경고 로그)
#### 관리자 경고 표시
비호환 오버라이드가 감지되면 레이아웃 상단에 Alert 컴포넌트가 자동 주입됩니다:
```
호환성 경고
오버라이드 "sirsoft-admin_basic"가 모듈 버전과 호환되지 않습니다 (요구: ^1.0, 현재: 2.0.0)
```
#### 사용 예시
```json
{
"target_layout": "sirsoft-board.admin_board_settings",
"version_constraint": "^1.0",
"injections": [
{
"target_id": "notification_section",
"position": "after",
"components": [
{
"id": "custom_notification",
"name": "Card",
"type": "composite",
"props": {
"title": "$t:template.custom_notification.title"
}
}
]
}
]
}
```
위 예시에서 `sirsoft-board` 모듈이 2.0.0으로 업데이트되면:
- 오버라이드가 자동으로 스킵됨
- 관리자에게 호환성 경고 표시
- 템플릿 개발자가 오버라이드를 업데이트할 때까지 기본 레이아웃 사용
#### 구현 위치
- **LayoutResolverService**: `checkOverrideVersionCompatibility()` 메서드 (레이아웃 오버라이드)
- **LayoutExtensionService**: `checkVersionCompatibility()` 메서드 (확장 오버라이드)
- **ModuleManager**: `getModuleVersion()` 헬퍼
- **PluginManager**: `getPluginVersion()` 헬퍼
#### 레이아웃 오버라이드에서의 version_constraint
템플릿이 모듈 레이아웃을 오버라이드할 때도 `version_constraint`를 사용할 수 있습니다.
오버라이드 JSON 예시:
```json
{
"layout_name": "admin_sample_index",
"version_constraint": "^1.0",
"extends": "_admin_base",
"slots": {
"content": [...]
}
}
```
동작:
1. `version_constraint` 없음 → 항상 오버라이드 적용
2. 버전 호환 → 오버라이드 적용
3. 버전 비호환 → 오버라이드 스킵, 모듈 기본 레이아웃으로 폴백 + `Log::warning`
---
## 레이아웃 상속 처리
### extends 필드 사용
```json
{
"layout_name": "admin_sample_index",
"extends": "_admin_base",
"slots": {
"content": [...]
}
}
```
### 병합 로직 (LayoutService.mergeLayouts)
```php
// content에 extends가 있어야 병합 수행
if (isset($layoutData['extends'])) {
$parentLayoutName = $layoutData['extends'];
$parentLayout = $this->loadAndMergeLayout($templateId, $parentLayoutName);
$mergedLayout = $this->mergeLayouts($parentLayout, $layoutData);
} else {
$mergedLayout = $layoutData;
}
```
### 병합 결과
```php
$result = [
'version' => $childLayout['version'] ?? $parentLayout['version'] ?? '1.0.0',
'layout_name' => $childLayout['layout_name'] ?? $parentLayout['layout_name'] ?? '',
'meta' => $mergedMeta,
'data_sources' => $mergedDataSources,
'components' => $mergedComponents,
];
```
---
## DB 저장 규칙
### 템플릿 레이아웃과 일관성 유지
| 구분 | TemplateManager | ModuleManager |
|------|----------------|---------------|
| content 저장 | `$layoutData` 전체 | `$layoutData` 전체 |
| extends 포함 | ✅ Yes | ✅ Yes |
| layout_name 포함 | ✅ Yes | ✅ Yes |
| version 포함 | ✅ Yes | ✅ Yes |
### 잘못된 구현 (❌ DON'T)
```php
// 개별 필드만 추출 - 병합 실패 원인
$content = [];
if (isset($layoutData['slots'])) {
$content['slots'] = $layoutData['slots'];
}
if (isset($layoutData['meta'])) {
$content['meta'] = $layoutData['meta'];
}
// extends, layout_name 누락!
```
### 올바른 구현 (✅ DO)
```php
// layoutData 전체 저장 - TemplateManager와 일관성
$content = $layoutData;
```
---
## 캐시 전략
### 무효화 시점
1. 모듈 활성화 후
2. 모듈 비활성화 후
3. 레이아웃 파일 변경 시
### 무효화 메서드
```php
protected function invalidateLayoutCache(string $moduleIdentifier): void
{
// 1. 태그 기반 캐시 삭제
if (method_exists(Cache::getStore(), 'tags')) {
Cache::tags(['layouts', $moduleIdentifier])->flush();
}
// 2. 개별 레이아웃 캐시 삭제
$moduleLayouts = TemplateLayout::where('source_type', LayoutSourceType::Module)
->where('source_identifier', $moduleIdentifier)
->get();
foreach ($moduleLayouts as $layout) {
$cacheKey = "layout.{$layout->template_id}.{$layout->name}";
Cache::forget($cacheKey);
}
}
```
### 캐시 무효화 원칙
```text
중요: 모듈/플러그인 활성화/비활성화 시 관련 캐시 자동 무효화 필수
✅ 필수: Manager 레벨에서 캐시 무효화 구현 (Service 아님)
✅ 권장: 공통 캐시 무효화 로직은 트레이트로 분리
```
---
## 모듈 레이아웃 개발 가이드
### 1. 레이아웃 파일 생성
```bash
# 디렉토리 생성
mkdir -p modules/_bundled/sirsoft-sample/resources/layouts/admin
# 레이아웃 파일 생성
touch modules/_bundled/sirsoft-sample/resources/layouts/admin/index.json
```
### 2. 레이아웃 JSON 작성
```json
{
"layout_name": "admin_sample_index",
"version": "1.0.0",
"extends": "_admin_base",
"meta": {
"title": "$t:sirsoft-sample.admin.index.title",
"description": "$t:sirsoft-sample.admin.index.description",
"auth_required": true
},
"slots": {
"content": [
{
"id": "page_header",
"name": "PageHeader",
"type": "composite",
"props": {
"title": "$t:sirsoft-sample.admin.index.title"
}
}
]
},
"data_sources": [
{
"id": "items",
"type": "api",
"method": "GET",
"endpoint": "/api/modules/sirsoft-sample/admin/items",
"auto_fetch": true,
"auth_required": true
}
]
}
```
### 3. 라우트 등록 (routes.json)
```json
{
"routes": [
{
"path": "/admin/sirsoft-sample",
"layout_name": "sirsoft-sample.admin_sample_index",
"permissions": ["sirsoft-sample.admin.view"]
}
]
}
```
### 4. 모듈 활성화
```bash
php artisan module:activate sirsoft-sample
```
---
## 문제 해결
### 레이아웃 병합 안 됨
**원인**: `content`에 `extends` 필드 누락
**해결**:
1. `ModuleManager.registerLayoutToTemplate()`에서 `$layoutData` 전체 저장 확인
2. 모듈 재활성화로 DB 업데이트:
```bash
php artisan module:deactivate sirsoft-sample
php artisan module:activate sirsoft-sample
```
### layout_name 누락 오류
**원인**: `content`에 `layout_name` 필드 누락
**해결**: 위와 동일 (ModuleManager 수정 후 재활성화)
### 오버라이드가 적용 안 됨
**원인**:
1. 레이아웃 이름 불일치
2. `source_type`이 'template'이 아님
3. 캐시 문제
**해결**:
```bash
# 캐시 초기화
php artisan cache:clear
# 레이아웃 이름 확인 (DB 조회)
SELECT name, source_type FROM template_layouts
WHERE name = 'sirsoft-sample.admin_sample_index';
```
### user 레이아웃이 등록되지 않음
**원인**: 레이아웃 파일이 `layouts/user/` 하위에 위치하지 않음
**해결**: `resources/layouts/user/` 디렉토리로 파일 이동
### layouts/ 루트에 배치한 레이아웃이 무시됨
**원인**: `admin/` 또는 `user/` 하위 배치 필수
**해결**: 적절한 하위 디렉토리로 파일 이동. 경고 로그 확인:
```text
"레이아웃 파일이 admin/ 또는 user/ 하위에 위치하지 않아 스킵됩니다: {file}"
```
### 모듈 재활성화 후에도 레이아웃 안 보임
**확인 사항**:
1. 레이아웃 파일 경로가 올바른지 확인
2. JSON 파일 문법 오류 확인
3. `layout_name` 필드가 JSON에 있는지 확인
---
## 관련 문서
- [module-basics.md](module-basics.md) - 모듈 개발 기초
- [module-routing.md](module-routing.md) - 모듈 라우트 규칙
- [module-commands.md](module-commands.md) - 모듈 Artisan 커맨드
- [module-i18n.md](module-i18n.md) - 모듈 다국어
- [template-caching.md](template-caching.md) - 템플릿 캐싱 전략
- [index.md](index.md) - 확장 시스템 인덱스
+343
View File
@@ -0,0 +1,343 @@
# 모듈 라우트 규칙
> 이 문서는 모듈의 백엔드 라우트(API/Web)와 프론트엔드 라우트(routes.json) 작성 규칙을 설명합니다.
---
## TL;DR (5초 요약)
```text
1. URL prefix 자동: /api/admin/[vendor-module]/...
2. name prefix 자동: api.[vendor-module]....
3. 활성화된 모듈만 라우트 등록됨
4. 프론트엔드: routes/admin.json, routes/user.json으로 admin/user 분기
5. 레거시 routes.json은 admin으로 폴백 (경고 로그 출력)
```
---
## 목차
1. [핵심 원칙](#1-핵심-원칙)
2. [라우트 등록 조건](#2-라우트-등록-조건)
3. [자동 적용되는 Prefix](#3-자동-적용되는-prefix)
4. [라우트 파일 작성](#4-라우트-파일-작성)
5. [라우트 네임 컨벤션](#5-라우트-네임-컨벤션)
6. [프론트엔드 라우트 (routes.json)](#6-프론트엔드-라우트-routesjson)
7. [캐시 관리](#7-캐시-관리)
8. [관련 문서](#8-관련-문서)
---
## 1. 핵심 원칙
```
중요: ModuleRouteServiceProvider가 URL prefix와 name prefix를 자동 적용
✅ 필수: 모듈 개발자는 모듈 내부 구조만 정의
✅ 조건: 활성화된 모듈만 라우트가 등록됨
```
---
## 2. 라우트 등록 조건
### 활성화된 모듈만 라우트 등록
- `ModuleRouteServiceProvider`가 DB에서 `status = 'active'`인 모듈만 조회
- 설치되지 않거나 비활성화된 모듈의 라우트는 등록되지 않음
- 모듈 활성화/비활성화 시 라우트가 자동으로 등록/해제됨
### 네임스페이스 변환 규칙
```
디렉토리명 → 네임스페이스
sirsoft-sample → Modules\Sirsoft\Sample
sirsoft-ecommerce → Modules\Sirsoft\Ecommerce
vendor-my-module → Modules\Vendor\My\Module
```
---
## 3. 자동 적용되는 Prefix
### API 라우트
| 항목 | 값 |
|------|-----|
| URL prefix | `api/modules/{module-name}` |
| Name prefix | `api.modules.{module-name}.` |
### 웹 라우트
| 항목 | 값 |
|------|-----|
| URL prefix | `modules/{module-name}` |
| Name prefix | `web.modules.{module-name}.` |
---
## 4. 라우트 파일 작성
### 라우트 파일 위치
```
modules/_bundled/{identifier}/src/routes/
├── api.php # API 라우트
└── web.php # 웹 라우트
```
### ✅ DO: 모듈 내부 구조만 정의
```php
// modules/_bundled/sirsoft-ecommerce/src/routes/api.php
Route::prefix('admin')->middleware(['auth:sanctum', 'admin'])->group(function () {
Route::get('products', [ProductController::class, 'index'])
->name('admin.products.index');
// 최종 Name: api.modules.sirsoft-ecommerce.admin.products.index
// 최종 URL: /api/modules/sirsoft-ecommerce/admin/products
});
```
### ❌ DON'T: prefix 중복 입력
```php
// 잘못된 예시 - prefix가 중복됨
Route::get('products', [ProductController::class, 'index'])
->name('api.modules.sirsoft-ecommerce.admin.products.index');
```
### 라우트 파일 주석 템플릿
```php
<?php
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| [Module Name] API Routes
|--------------------------------------------------------------------------
|
| 주의: ModuleRouteServiceProvider가 자동으로 prefix를 적용합니다.
| - URL prefix: 'api/modules/{module-name}'
| - Name prefix: 'api.modules.{module-name}.'
|
*/
// 라우트 정의...
```
---
## 5. 라우트 네임 컨벤션
### 구조
```
{type}.modules.{module-name}.{area}.{resource}.{action}
```
### 예시
```
api.modules.sirsoft-ecommerce.admin.products.index
api.modules.sirsoft-ecommerce.admin.products.store
api.modules.sirsoft-ecommerce.admin.orders.show
web.modules.sirsoft-ecommerce.checkout.cart
```
### 구성 요소
| 구성 요소 | 설명 | 적용 방식 |
|-----------|------|-----------|
| `type` | api 또는 web | 자동 적용 |
| `modules` | 모듈임을 나타냄 | 자동 적용 |
| `module-name` | 모듈 식별자 | 자동 적용 |
| `area` | admin, public 등 영역 | 개발자 정의 |
| `resource` | 리소스명 | 개발자 정의 |
| `action` | index, store, show, update, destroy 등 | 개발자 정의 |
---
## 6. 프론트엔드 라우트 (routes.json)
```
중요: 백엔드 라우트(routes/api.php)와 별개로 프론트엔드 라우트도 지원
✅ 위치: modules/{identifier}/resources/routes/admin.json, routes/user.json
✅ 병합: 템플릿 타입(admin/user)에 맞는 모듈 routes만 필터링 병합
✅ 레거시: routes.json은 admin으로 폴백 (경고 로그 출력)
✅ 캐시: 모듈 활성화/비활성화 시 자동 무효화
```
### 디렉토리 구조
```
modules/{identifier}/resources/
├── routes/
│ ├── admin.json ← admin 템플릿 전용 라우트
│ └── user.json ← user 템플릿 전용 라우트 (선택)
└── routes.json ← [레거시] 있으면 admin으로 취급 + 경고 로그
```
### admin/user 분기 규칙
| 라우트 파일 | 서빙 대상 | 설명 |
|------------|----------|------|
| `routes/admin.json` | admin 타입 템플릿 | 관리자 화면 라우트 |
| `routes/user.json` | user 타입 템플릿 | 일반 사용자 화면 라우트 |
| `routes.json` (레거시) | admin 타입 템플릿만 | 하위 호환성, 경고 로그 출력 |
**탐색 우선순위**:
1. `routes/{templateType}.json` 우선 탐색
2. (admin 타입만) `routes.json` 레거시 폴백
3. user 타입은 레거시 `routes.json`을 로드하지 않음
### 기본 구조
```json
{
"version": "1.0.0",
"routes": [
{
"path": "*/admin/sample",
"layout": "admin_sample_index",
"auth_required": true,
"meta": {
"title": "$t:sirsoft-sample.admin.index.title"
}
}
]
}
```
### 병합 방식
**병합 우선순위**:
1. 템플릿 routes 배열이 기본
2. 활성화된 모듈 routes를 **템플릿 타입에 맞게** 필터링하여 병합 (`array_merge`)
3. 플러그인 routes는 **admin 템플릿에만** 포함 (settings 페이지 등 admin 전용)
4. version은 템플릿의 version 사용
**레이아웃 필드 자동 변환**:
- 모듈 routes의 `layout` 필드에 모듈 식별자 접두사 자동 추가
- 예: `"admin_sample_index"` → `"sirsoft-sample.admin_sample_index"`
**최종 API 응답**:
```json
{
"version": "1.0.0",
"routes": [
// 템플릿 routes...
{
"path": "*/admin/sample",
"layout": "sirsoft-sample.admin_sample_index",
"auth_required": true,
"meta": {
"title": "$t:sirsoft-sample.admin.index.title"
}
}
// 다른 모듈 routes (템플릿 타입에 맞는 것만)...
]
}
```
### 모듈 레이아웃과의 연계
**모듈 레이아웃 정의**:
```
modules/{identifier}/resources/layouts/admin/{layout_name}.json
modules/{identifier}/resources/layouts/user/{layout_name}.json
```
**routes에서 레이아웃 참조**:
```json
{
"path": "*/admin/sample",
"layout": "admin_sample_index"
}
```
**레이아웃 조회 우선순위**:
1. 템플릿 오버라이드: `templates/{template}/layouts/overrides/{module}/{layout}.json`
2. 모듈 원본: `modules/{module}/resources/layouts/admin/{layout}.json`
### 다국어 키 사용
**routes.json에서 다국어 참조**:
```json
{
"meta": {
"title": "$t:sirsoft-sample.admin.index.title"
}
}
```
**모듈 다국어 파일**:
```json
// modules/sirsoft-sample/resources/lang/ko.json
{
"admin": {
"index": {
"title": "샘플 모듈 관리"
}
}
}
```
**프론트엔드에서 최종 키**:
- 템플릿 다국어 API가 모듈 다국어 자동 병합
- 최종 키: `sirsoft-sample.admin.index.title`
### 구현 세부사항
**TemplateService**:
```php
// 템플릿 + 모듈 routes 병합 (템플릿 타입에 따라 필터링)
public function getRoutesDataWithModules(string $identifier): array
// 템플릿 타입에 맞는 모듈 routes 로드 (routes/{type}.json 우선, 레거시 폴백)
private function loadActiveModulesRoutesData(string $templateType = 'admin'): array
```
**PublicTemplateController API**:
```
GET /api/public/templates/{identifier}/routes
```
**ClearsTemplateCaches Trait**:
```php
protected function clearAllTemplateRoutesCaches(): void
```
---
## 7. 캐시 관리
### 캐시 키 패턴
```
template.routes.{identifier}
```
### 자동 캐시 무효화
| Manager | 메서드 | 동작 |
|---------|--------|------|
| ModuleManager | `activateModule()` | routes 캐시 무효화 |
| ModuleManager | `deactivateModule()` | routes 캐시 무효화 |
| PluginManager | `activatePlugin()` | routes 캐시 무효화 |
| PluginManager | `deactivatePlugin()` | routes 캐시 무효화 |
---
## 8. 관련 문서
- [모듈 기초](module-basics.md) - 모듈 구조, AbstractModule
- [모듈 레이아웃](module-layouts.md) - 레이아웃 등록, 오버라이드
- [모듈 다국어](module-i18n.md) - 백엔드/프론트엔드 다국어
- [템플릿 라우트](template-routing.md) - 템플릿 라우트 규칙
- [확장 시스템 개요](index.md) - 전체 인덱스
+549
View File
@@ -0,0 +1,549 @@
# 모듈 환경설정 시스템 개발 가이드
> 이 문서는 모듈에서 환경설정 시스템을 구현하는 방법을 설명합니다.
---
## 목차
1. [개요](#1-개요)
2. [디렉토리 구조](#2-디렉토리-구조)
3. [defaults.json 작성](#3-defaultsjson-작성)
4. [SettingsService 구현](#4-settingsservice-구현)
5. [SettingsController 구현](#5-settingscontroller-구현)
6. [API 라우트 설정](#6-api-라우트-설정)
7. [레이아웃 연동](#7-레이아웃-연동)
8. [백엔드에서 설정 조회](#8-백엔드에서-설정-조회)
9. [관련 문서](#9-관련-문서)
---
## 1. 개요
### 1.1 핵심 원칙
```
중요: 모듈 환경설정은 모듈의 책임
✅ 코어는 ModuleSettingsService + 헬퍼 함수(module_setting/module_settings)를 제공
✅ 모듈이 ModuleSettingsInterface를 구현하면 코어가 자동 검색하여 위임
✅ ModuleSettingsInterface 미구현 모듈은 코어 기본 구현(단일 setting.json) 사용
✅ 설정 파일은 모듈별 격리된 경로에 저장
```
### 1.2 설정 조회 흐름
```
module_setting('vendor-module', 'key')
→ ModuleSettingsService::get()
→ resolveModuleService() — 모듈별 설정 서비스 자동 검색
→ 1순위: Modules\{Vendor}\{Module}\Contracts\{Module}SettingsServiceInterface (바인딩)
→ 2순위: Modules\{Vendor}\{Module}\Services\{Module}SettingsService (클래스)
→ 모듈 서비스 발견 시: 위임 (getSetting/getAllSettings)
→ 미발견 시: 코어 기본 구현 (defaults.json + setting.json)
```
### 1.3 설계 원칙
- **격리성**: 모듈별로 독립적인 설정 저장 경로
- **일관성**: 코어 환경설정과 동일한 패턴
- **유연성**: 카테고리별 설정 분리 가능
- **보안성**: frontend_schema를 통한 민감정보 제어
---
## 2. 디렉토리 구조
### 2.1 모듈 설정 파일 위치
```
modules/{vendor-module}/
├── config/
│ └── settings/
│ └── defaults.json ← 기본 설정값 정의
├── src/
│ ├── Services/
│ │ └── {Module}SettingsService.php ← 설정 서비스
│ ├── Http/
│ │ └── Controllers/
│ │ └── Admin/
│ │ └── {Module}SettingsController.php ← 설정 API
│ └── routes/
│ └── api.php ← API 라우트
└── resources/
└── layouts/
└── admin/
└── admin_{module}_settings.json ← 설정 UI
```
### 2.2 설정 저장 경로
```
storage/app/modules/{vendor-module}/settings/
├── basic_info.json
├── language_currency.json
└── seo.json
```
---
## 3. defaults.json 작성
### 3.1 기본 구조
```json
{
"_meta": {
"version": "1.0.0",
"description": "모듈 설명",
"categories": ["basic_info", "language_currency", "seo"]
},
"defaults": {
"basic_info": {
"field_name": "default_value"
}
},
"frontend_schema": {
"basic_info": {
"expose": true,
"fields": {
"field_name": { "expose": true }
}
}
}
}
```
### 3.2 _meta 섹션
| 필드 | 타입 | 설명 |
|------|------|------|
| version | string | 설정 스키마 버전 |
| description | string | 모듈 설명 |
| categories | array | 설정 카테고리 목록 |
### 3.3 defaults 섹션
카테고리별 기본값을 정의합니다.
```json
{
"defaults": {
"basic_info": {
"shop_name": "",
"route_path": "shop",
"no_route": false
},
"seo": {
"meta_main_title": "{site_name} - {commerce_name}",
"seo_site_main": true
}
}
}
```
### 3.4 frontend_schema 섹션
프론트엔드에 노출할 필드를 제어합니다.
```json
{
"frontend_schema": {
"basic_info": {
"expose": true,
"fields": {
"shop_name": { "expose": true },
"api_key": { "expose": false, "sensitive": true }
}
},
"payment": {
"expose": false
}
}
}
```
| 속성 | 설명 |
|------|------|
| expose | 프론트엔드 노출 여부 |
| sensitive | 민감 정보 여부 |
---
## 4. SettingsService 구현
### 4.1 ModuleSettingsInterface 구현
```php
<?php
namespace Modules\Vendor\Module\Services;
use App\Contracts\Extension\ModuleSettingsInterface;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\File;
class ModuleSettingsService implements ModuleSettingsInterface
{
private const MODULE_IDENTIFIER = 'vendor-module';
private ?array $defaults = null;
private ?array $settings = null;
public function getSettingsDefaultsPath(): ?string
{
$path = $this->getModulePath().'/config/settings/defaults.json';
return file_exists($path) ? $path : null;
}
public function getSetting(string $key, mixed $default = null): mixed
{
$settings = $this->getAllSettings();
return Arr::get($settings, $key, $default);
}
public function setSetting(string $key, mixed $value): bool
{
$settings = $this->getAllSettings();
Arr::set($settings, $key, $value);
$parts = explode('.', $key);
$category = $parts[0];
return $this->saveCategorySettings($category, $settings[$category] ?? []);
}
public function getAllSettings(): array
{
if ($this->settings !== null) {
return $this->settings;
}
$defaults = $this->getDefaults();
$categories = $defaults['_meta']['categories'] ?? [];
$defaultValues = $defaults['defaults'] ?? [];
$settings = [];
foreach ($categories as $category) {
$categoryDefaults = $defaultValues[$category] ?? [];
$savedSettings = $this->loadCategorySettings($category);
$settings[$category] = array_merge($categoryDefaults, $savedSettings);
}
$this->settings = $settings;
return $settings;
}
public function getSettings(string $category): array
{
$allSettings = $this->getAllSettings();
return $allSettings[$category] ?? [];
}
public function saveSettings(array $settings): bool
{
$success = true;
foreach ($settings as $category => $categorySettings) {
if (str_starts_with($category, '_')) {
continue;
}
if (!$this->saveCategorySettings($category, $categorySettings)) {
$success = false;
}
}
$this->settings = null;
return $success;
}
public function getFrontendSettings(): array
{
$defaults = $this->getDefaults();
$frontendSchema = $defaults['frontend_schema'] ?? [];
$allSettings = $this->getAllSettings();
$frontendSettings = [];
foreach ($frontendSchema as $category => $schema) {
if (!($schema['expose'] ?? false)) {
continue;
}
$categorySettings = $allSettings[$category] ?? [];
$fields = $schema['fields'] ?? [];
if (empty($fields)) {
$frontendSettings[$category] = $categorySettings;
continue;
}
$exposedFields = [];
foreach ($fields as $fieldName => $fieldSchema) {
if ($fieldSchema['expose'] ?? false) {
$exposedFields[$fieldName] = $categorySettings[$fieldName] ?? null;
}
}
if (!empty($exposedFields)) {
$frontendSettings[$category] = $exposedFields;
}
}
return $frontendSettings;
}
private function getModulePath(): string
{
return base_path('modules/'.self::MODULE_IDENTIFIER);
}
private function getStoragePath(): string
{
return storage_path('app/modules/'.self::MODULE_IDENTIFIER.'/settings');
}
// 나머지 private 메서드 구현...
}
```
### 4.2 분리 입력 필드 처리
전화번호, 사업자번호 등 분리 입력 필드 처리:
```php
private function processSplitFields(string $category, array $settings): array
{
if ($category !== 'basic_info') {
return $settings;
}
// 사업자등록번호 병합
if (isset($settings['business_number_1'])) {
$parts = [
$settings['business_number_1'] ?? '',
$settings['business_number_2'] ?? '',
$settings['business_number_3'] ?? '',
];
$settings['business_number'] = implode('-', array_filter($parts));
unset(
$settings['business_number_1'],
$settings['business_number_2'],
$settings['business_number_3']
);
}
return $settings;
}
```
---
## 5. SettingsController 구현
```php
<?php
namespace Modules\Vendor\Module\Http\Controllers\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\Vendor\Module\Services\ModuleSettingsService;
class ModuleSettingsController extends AdminBaseController
{
public function __construct(
private ModuleSettingsService $settingsService
) {
parent::__construct();
}
public function index(): JsonResponse
{
$settings = $this->settingsService->getFrontendSettings();
return $this->success('settings.fetch_success', $settings);
}
public function show(string $category): JsonResponse
{
$settings = $this->settingsService->getSettings($category);
return $this->success('settings.fetch_success', [
'category' => $category,
'settings' => $settings,
]);
}
public function store(Request $request): JsonResponse
{
$result = $this->settingsService->saveSettings($request->all());
if ($result) {
$updatedSettings = $this->settingsService->getFrontendSettings();
return $this->success('settings.save_success', $updatedSettings);
}
return $this->error('settings.save_failed');
}
}
```
---
## 6. API 라우트 설정
### 6.1 라우트 파일 위치
```
modules/{vendor-module}/src/routes/api.php
```
### 6.2 라우트 정의
```php
<?php
use Illuminate\Support\Facades\Route;
use Modules\Vendor\Module\Http\Controllers\Admin\ModuleSettingsController;
/*
|--------------------------------------------------------------------------
| Module API Routes
|--------------------------------------------------------------------------
|
| URL prefix: 'api/modules/{module-name}'
| Name prefix: 'api.modules.{module-name}.'
|
*/
Route::prefix('admin')->middleware(['auth:sanctum', 'admin'])->group(function () {
Route::get('settings', [ModuleSettingsController::class, 'index'])
->name('admin.settings.index');
Route::put('settings', [ModuleSettingsController::class, 'store'])
->name('admin.settings.store');
Route::get('settings/{category}', [ModuleSettingsController::class, 'show'])
->name('admin.settings.show');
});
```
### 6.3 최종 API 경로
| Method | 경로 | 설명 |
|--------|------|------|
| GET | `/api/modules/{id}/admin/settings` | 전체 설정 조회 |
| PUT | `/api/modules/{id}/admin/settings` | 설정 저장 |
| GET | `/api/modules/{id}/admin/settings/{category}` | 카테고리별 조회 |
---
## 7. 레이아웃 연동
### 7.1 data_sources 설정
```json
{
"data_sources": [
{
"id": "settings",
"type": "api",
"endpoint": "/api/modules/{module-id}/admin/settings",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"initLocal": "form",
"refetchOnMount": true
}
],
"state": {
"isSaving": false,
"hasChanges": false,
"form": {}
}
}
```
### 7.2 저장 버튼 액션
```json
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/modules/{module-id}/admin/settings",
"params": {
"method": "PUT",
"body": "{{_local.form}}"
}
}
```
---
## 8. 백엔드에서 설정 조회
### 8.1 헬퍼 함수 사용 (권장)
`module_setting()` / `module_settings()`는 내부적으로 `ModuleSettingsService`를 사용합니다.
모듈별 설정 서비스(`{Module}SettingsService`)가 존재하면 자동으로 위임합니다.
```php
// 단일 설정값 조회 (도트 노테이션 지원)
$shopName = module_setting('vendor-module', 'basic_info.shop_name', '기본값');
// 전체 설정 조회
$allSettings = module_setting('vendor-module');
// 카테고리 전체 조회
$seoSettings = module_settings('vendor-module', 'seo');
// 전체 설정 조회
$allSettings = module_settings('vendor-module');
```
### 8.2 ModuleSettingsService 직접 사용
```php
use App\Services\ModuleSettingsService;
$service = app(ModuleSettingsService::class);
// 단일 설정값 조회
$value = $service->get('vendor-module', 'basic_info.shop_name', '기본값');
// 전체 설정 조회
$allSettings = $service->get('vendor-module');
```
### 8.3 모듈 내부에서 자체 서비스 사용
모듈 내부 Service/Controller에서는 자체 설정 서비스를 직접 주입하여 사용할 수 있습니다.
모듈 전용 메서드(예: `getStockDeductionTiming()`)가 필요한 경우 이 방식을 사용합니다.
```php
// 모듈 내부 — 모듈 전용 메서드 접근이 필요한 경우
use Modules\Vendor\Module\Services\ModuleSettingsService;
public function __construct(
private ModuleSettingsService $settingsService
) {}
// ModuleSettingsInterface 메서드 + 모듈 전용 메서드 모두 사용 가능
$value = $this->settingsService->getSetting('basic_info.shop_name');
$timing = $this->settingsService->getStockDeductionTiming($method);
```
> **판단 기준**: `getSetting()` 단일 호출만 필요하면 `module_setting()` 헬퍼 사용, 모듈 전용 메서드가 필요하면 서비스 직접 주입
---
## 9. 관련 문서
- [모듈 기초](module-basics.md) - 모듈 구조, AbstractModule
- [모듈 라우트](module-routing.md) - API 라우트 규칙
- [모듈 레이아웃](module-layouts.md) - 레이아웃 등록
- [모듈 다국어](module-i18n.md) - 다국어 지원
---
## 체크리스트
모듈 환경설정 구현 시 확인 사항:
- [ ] `config/settings/defaults.json` 생성
- [ ] `ModuleSettingsInterface` 구현 서비스 생성
- [ ] 설정 컨트롤러 생성
- [ ] API 라우트 등록
- [ ] 레이아웃에 `data_sources` 추가
- [ ] 저장 버튼 액션 연결
- [ ] `frontend_schema`로 민감정보 필터링 설정
+816
View File
@@ -0,0 +1,816 @@
# 권한 시스템
> G7의 권한(Permission)과 역할(Role) 시스템
---
## TL;DR (5초 요약)
```text
1. 구조: User → Role → Permission (기능 레벨)
2. 리소스: User → Role → role_menus 피벗 → Menu
3. 네이밍: [module].[entity].[action] (예: users.read)
4. type 필드: admin(관리자용) 또는 user(사용자용), 기본값: admin
5. scope_type: 역할별 접근 스코프 (null=전체, self=본인, role=소유역할)
```
---
## 목차
1. [권한 아키텍처](#권한-아키텍처)
2. [권한 타입 (Permission Type)](#권한-타입-permission-type)
3. [모듈 Role/Permission 자동 관리](#모듈-rolepermission-자동-관리)
4. [Role 정의 규칙](#role-정의-규칙)
5. [Permission에 Role 할당](#permission에-role-할당)
6. [Role 삭제 조건](#role-삭제-조건)
7. [콘솔 커맨드 출력](#콘솔-커맨드-출력)
8. [권한 네이밍](#권한-네이밍)
9. [리소스 레벨 권한](#리소스-레벨-권한)
10. [레이아웃 컴포넌트 권한 필터링](#레이아웃-컴포넌트-권한-필터링)
---
## 권한 아키텍처
G7의 권한 시스템은 두 가지 레벨로 구성됩니다.
### 기능 레벨 권한 (Feature-level)
```text
User → Role → Permission
```
- **User**: 시스템 사용자
- **Role**: 역할 그룹 (여러 Permission을 묶음)
- **Permission**: 개별 권한 (특정 작업 수행 허용)
### 리소스 레벨 권한 (Resource-level)
```text
User → Role → role_menus 피벗 테이블 → Menu
```
- **role_menus**: 메뉴 접근 권한
> **참고**: 첨부파일(Attachment)은 기능 레벨 권한(`core.attachments.*` + `scope_type`)으로 제어합니다. `role_attachments` 독립 시스템은 폐기되었습니다.
---
## 권한 타입 (Permission Type)
권한은 `type` 필드로 사용 컨텍스트를 구분합니다.
### PermissionType Enum
```php
// app/Enums/PermissionType.php
enum PermissionType: string
{
case Admin = 'admin'; // 관리자 화면용 권한
case User = 'user'; // 사용자(프론트엔드) 화면용 권한
}
```
### 타입별 용도
| 타입 | 용도 | 예시 |
|------|------|------|
| `admin` | 관리자 화면에서 사용되는 권한 | 사용자 관리, 설정, 모듈 관리 |
| `user` | 사용자(프론트엔드) 화면에서 사용되는 권한 | 내 주문 조회, 프로필 수정 |
### 관리자 판단 기준
```php
// User::isAdmin() 메서드
// 사용자가 보유한 권한 중 type='admin'인 것이 하나라도 있으면 관리자
public function isAdmin(): bool
{
return $this->roles()
->whereHas('permissions', function ($query) {
$query->where('type', PermissionType::Admin);
})
->exists();
}
```
### 모듈/플러그인에서 권한 타입 지정
```php
// getPermissions() 반환 구조
'permissions' => [
[
'action' => 'read',
'name' => ['ko' => '조회', 'en' => 'Read'],
'description' => ['ko' => '...', 'en' => '...'],
'type' => 'admin', // admin 또는 user (기본값: admin)
'roles' => ['admin', 'manager'],
],
[
'action' => 'view_own',
'name' => ['ko' => '내 정보 조회', 'en' => 'View Own'],
'type' => 'user', // 사용자 화면용 권한
'roles' => ['user'],
],
],
```
### Permission 모델 헬퍼
```php
// 타입 확인 메서드
$permission->isAdminPermission(); // type === 'admin'
$permission->isUserPermission(); // type === 'user'
// 스코프
Permission::adminPermissions()->get(); // admin 타입만
Permission::userPermissions()->get(); // user 타입만
```
---
## 모듈 Role/Permission 자동 관리
```text
중요: 모듈 설치 시 Role과 Permission이 자동으로 생성/연결됨
✅ 필수: getRoles()와 getPermissions()의 roles 필드 사용
```
> **플러그인 권한**: 플러그인도 모듈과 동일한 3레벨 계층 구조(`categories`)를 사용합니다. 상세는 [plugin-development.md](plugin-development.md)의 "플러그인 권한 시스템" 섹션을 참조하세요.
### 동작 방식
#### 모듈 설치 시 (module:install)
1. 마이그레이션 실행
2. `getRoles()`에서 정의한 Role 생성
3. `getPermissions()`에서 정의한 Permission 생성
4. Permission의 `roles` 필드에 지정된 Role에 권한 할당
5. 메뉴 생성
6. 시더 실행
#### 모듈 제거 시 (module:uninstall)
1. Role에서 Permission 분리 (detach)
2. Permission 삭제
3. Role 삭제 (조건: `extension_type`이 NULL(사용자 생성) AND 남은 권한 0개)
4. 메뉴 삭제
5. 마이그레이션 롤백
---
## Role 정의 규칙
### identifier 네이밍
```text
[vendor-module].[rolename]
```
### 예시
```text
sirsoft-ecommerce.manager
sirsoft-ecommerce.viewer
sirsoft-sample.manager
```
### getRoles() 반환 구조
```php
public function getRoles(): array
{
return [
[
'identifier' => 'sirsoft-ecommerce.manager',
'name' => [
'ko' => '이커머스 관리자',
'en' => 'Ecommerce Manager',
],
'description' => [
'ko' => '이커머스 모듈의 관리 권한을 가진 역할',
'en' => 'Role with management permissions for ecommerce module',
],
],
];
}
```
**필수 필드**:
| 필드 | 타입 | 설명 |
| ------------- | ------ | -------------------------------------- |
| `identifier` | string | 고유 식별자 (vendor-module.rolename) |
| `name` | array | 다국어 역할명 |
| `description` | array | 다국어 설명 |
---
## Permission에 Role 할당
### getPermissions() roles 필드
```php
public function getPermissions(): array
{
return [
[
'identifier' => 'sirsoft-ecommerce.products.view',
'name' => ['ko' => '상품 조회', 'en' => 'View Products'],
'description' => ['ko' => '...', 'en' => '...'],
'roles' => ['admin', 'sirsoft-ecommerce.manager'], // Role 지정
],
];
}
```
### 규칙
- `roles` 필드에 지정된 Role에 자동으로 권한 할당
- 기존 Role의 권한은 `syncWithoutDetaching`으로 보존
- 여러 Role에 동일 권한 할당 가능
### syncWithoutDetaching 동작
```php
// 기존 권한은 유지하면서 새 권한만 추가
$role->permissions()->syncWithoutDetaching($permissionIds);
```
- 모듈 A가 `admin` Role에 권한 할당
- 모듈 B도 `admin` Role에 권한 할당
- 모듈 A 제거 시 모듈 B의 권한은 유지됨
---
## Role 삭제 조건
### 안전한 삭제를 위한 조건
1. `extension_type`이 NULL (사용자 생성 Role만 삭제 가능)
2. `extension_type = 'core'` → 삭제 불가 (코어 역할 보호)
3. `extension_type = 'module'` 또는 `'plugin'` → 삭제 불가 (확장 소유 역할 보호, `ExtensionOwnedRoleDeleteException` 발생)
4. 남은 권한 개수 = 0 (다른 모듈의 권한이 없어야 함)
### 조건을 만족하지 않는 경우
- `extension_type`이 설정된 Role은 `ExtensionOwnedRoleDeleteException` 또는 403 응답
- 남은 권한이 있는 경우 삭제되지 않고 로그에 경고 기록
### Extension Ownership 예시
```php
// 코어 Role (extension_type = 'core', extension_identifier = 'core')
'admin' // 최고 관리자 — 삭제 불가
// 모듈 Role (extension_type = 'module', extension_identifier = 'sirsoft-ecommerce')
'sirsoft-ecommerce.manager' // 모듈 소유 — 삭제 불가
// 사용자 생성 Role (extension_type = NULL)
'custom-role' // 사용자 생성 — 삭제 가능
```
---
## 콘솔 커맨드 출력
### 설치 시
```text
✅ 모듈 "sirsoft-sample" 설치 완료
- 벤더: sirsoft
- 버전: 1.0.0
- 1개 역할 생성됨
- 4개 권한 생성됨
- 3개 메뉴 생성됨
```
### 제거 시
```text
모듈 "sirsoft-sample"을(를) 정말 삭제하시겠습니까?
- 1개 역할이 삭제됩니다.
- 4개 권한이 삭제됩니다.
- 3개 메뉴가 삭제됩니다.
```
---
## 권한 네이밍
### 코어 권한
```text
core.[entity].[action]
```
**예시**:
```text
core.templates.layouts.view
core.templates.layouts.create
core.templates.layouts.edit
core.templates.layouts.delete
core.users.view
core.users.manage
```
### 모듈 권한
```text
[vendor-module].[entity].[action]
```
**예시**:
```text
sirsoft-ecommerce.products.view
sirsoft-ecommerce.products.create
sirsoft-ecommerce.products.edit
sirsoft-ecommerce.products.delete
sirsoft-ecommerce.orders.view
sirsoft-ecommerce.orders.manage
```
### 권한 네이밍 규칙 요약
| 구분 | 패턴 | 예시 |
| ------ | ------------------------------- | ------------------------------------ |
| 코어 | `core.[entity].[action]` | `core.users.view` |
| 모듈 | `[vendor-module].[entity].[action]` | `sirsoft-ecommerce.products.create` |
### 일반적인 action 목록
| Action | 설명 |
| -------- | --------------------------------------- |
| `view` | 조회 권한 |
| `create` | 생성 권한 |
| `edit` | 수정 권한 |
| `delete` | 삭제 권한 |
| `manage` | 관리 권한 (view + create + edit + delete) |
---
## 리소스 레벨 권한
G7은 기능 레벨 권한 외에 **리소스 레벨 권한**을 지원합니다. 특정 리소스(메뉴 등)에 대한 세밀한 접근 제어가 가능합니다.
### 적용된 리소스
| 리소스 | 권한 방식 | 설명 |
| ------------ | --------- | ---- |
| Menu | `role_menus` 피벗 테이블 | 역할별 메뉴 접근 제어 |
| Attachment | `core.attachments.*` + `scope_type` | 기능 레벨 권한으로 제어 |
> **참고**: 첨부파일은 `role_attachments` 독립 시스템이 아닌, 기존 `permissions` + `scope_type` 시스템으로 통합되었습니다.
> - `core.attachments.create`: 업로드 권한
> - `core.attachments.update`: 수정/순서변경 권한 (`resource_route_key: 'attachment'`, `owner_key: 'created_by'`)
> - `core.attachments.delete`: 삭제 권한 (`resource_route_key: 'attachment'`, `owner_key: 'created_by'`)
> - 공개 다운로드는 `HookManager::checkHookPermission('core.attachment.download')` 으로 제어
---
## API 응답 권한 메타 (is_owner + abilities)
모든 API 리소스 응답에 `is_owner`와 `abilities` (can_*) 필드가 표준화되어 포함됩니다.
### 응답 형식
```json
{
"id": 1,
"name": "상품명",
"is_owner": true,
"abilities": {
"can_update": true,
"can_delete": false
}
}
```
- `is_owner`: 현재 요청 사용자가 리소스 소유자인지 여부
- `abilities`: `can_*` 키로 통합된 권한 플래그
### 권한 확인 순서 (checkAbility)
```text
1. Admin 역할 → 무조건 true
2. 인증된 사용자 → $user->hasPermission($identifier)
3. 게스트 → PermissionHelper::check($identifier)
```
### 프론트엔드에서 사용
레이아웃 JSON에서 `abilities.can_*` 키로 접근합니다:
```json
{
"if": "{{item.abilities.can_update}}",
"type": "Button",
"props": { "text": "수정" }
}
```
> **상세 구현 규칙**: [api-resources.md](../backend/api-resources.md#권한-메타-표준화-is_owner--abilities) 참조
---
## PermissionHelper — 서비스/컨트롤러에서 권한 체크
`PermissionHelper` 헬퍼 클래스를 사용하여 서비스/컨트롤러 내부에서 권한을 체크합니다.
scope_type 기반 스코프 체크 기능이 내장되어 있습니다.
```php
use App\Helpers\PermissionHelper;
// 기본 권한 체크
PermissionHelper::check('core.users.update');
// 구조화 권한 (OR/AND)
PermissionHelper::checkWithLogic(['or' => ['perm.a', 'perm.b']]);
// scope_type 기반 상세 접근 체크
PermissionHelper::checkScopeAccess($model, 'core.users.update', $user);
// scope_type 기반 목록 필터링 (Repository에서 사용)
PermissionHelper::applyPermissionScope($query, 'core.users.read');
// effective scope 조회
PermissionHelper::getEffectiveScope('core.users.read', $user); // null|'self'|'role'
// 소유자 정보 조회 (캐싱)
PermissionHelper::getOwnerKey('core.users.read'); // 'id'
PermissionHelper::getResourceRouteKey('core.users.read'); // 'user'
```
**메서드 시그니처**:
```php
public static function check(string $ability, ?User $user = null): bool
public static function checkWithLogic(array|object $permissions, ?User $user = null): bool
public static function checkScopeAccess(Model $model, string $permission, ?User $user = null): bool
public static function applyPermissionScope(Builder $query, string $permission, ?User $user = null): void
public static function getEffectiveScope(string $permission, ?User $user = null): ?string
public static function getOwnerKey(string $permission): ?string
public static function getResourceRouteKey(string $permission): ?string
```
### 권한 체크 방식 선택 가이드
| 상황 | 사용 방법 |
|------|----------|
| 라우트 접근 제어 | `permission:` 미들웨어 (scope_type 자동 체크) |
| 서비스 내부 조건부 권한 체크 | `PermissionHelper::check()` 직접 호출 |
| 상세 엔드포인트 스코프 체크 | `PermissionHelper::checkScopeAccess()` |
| 목록 엔드포인트 필터링 | `PermissionHelper::applyPermissionScope()` (Repository에서 호출) |
| API 응답 abilities 플래그 | `HasAbilityCheck` 트레이트 (`abilityMap()`) |
| 리소스 레벨 접근 제어 (메뉴 등) | `role_*` 피벗 테이블 기반 |
| 레이아웃 컴포넌트 필터링 | `permissions` 속성 (서버 사이드 제거) |
### 주의 사항
```text
❌ AuthServiceProvider::checkPermission() 직접 호출 → PermissionHelper::check() 사용
❌ 서비스 내부에서 미들웨어에 의존한 권한 체크 생략 → PermissionHelper로 명시적 체크
❌ except/only/menu 미들웨어 옵션 사용 (제거됨) → scope_type 데이터 기반 시스템 사용
```
---
## scope_type 스코프 시스템
역할별 접근 범위를 `role_permissions` 피벗의 `scope_type` 컬럼으로 제어합니다.
모든 라우트 미들웨어 옵션 (`except:`, `only:`, `menu:`)은 **제거**되었습니다.
### DB 구조
| 테이블 | 컬럼 | 타입 | 용도 |
|--------|------|------|------|
| `permissions` | `resource_route_key` | VARCHAR(50) NULL | 라우트 파라미터명 (예: 'user', 'menu') |
| `permissions` | `owner_key` | VARCHAR(50) NULL | 모델의 소유자 식별 컬럼 (예: 'id', 'created_by') |
| `role_permissions` | `scope_type` | ENUM('self', 'role') NULL | 역할별 접근 스코프 |
### ScopeType Enum
```php
// app/Enums/ScopeType.php
enum ScopeType: string
{
case Self = 'self'; // 본인 리소스만
case Role = 'role'; // 소유 역할 공유 사용자의 리소스
}
// null = 전체 접근 (제한 없음)
```
### scope_type 값 정의
| 값 | 의미 | 목록 필터링 | 상세 접근 체크 |
|---|---|---|---|
| `null` | 전체 접근 | 필터 미적용 | 항상 통과 |
| `'self'` | 본인 리소스만 | `WHERE {owner_key} = {user_id}` | `$model->{owner_key} === $user->id` |
| `'role'` | 내 역할 범위 | `WHERE {owner_key} IN (내 역할 공유 사용자)` | 소유자가 내 역할 공유하는지 체크 |
### union 정책 (복수 역할 보유 시)
- **우선순위**: `null`(전체) > `'role'`(소유역할) > `'self'`(본인)
- 여러 역할 중 하나라도 scope_type=null → 전체 접근
- 전부 non-null이면 가장 넓은 범위 적용 (role > self)
- 예: 역할A(scope=self) + 역할B(scope=role) → role 적용
### 미들웨어 처리 흐름
```text
permission:admin,core.users.read ← 옵션 없음
1. 권한 타입 검증
2. 동적 파라미터 치환
3. 권한 체크 (인증/게스트)
4. scope_type 스코프 체크:
a. Permission 조회 (캐싱) → resource_route_key, owner_key
b. resource_route_key null → 통과 (시스템 리소스)
c. $request->route(resource_route_key) → 모델 resolve
d. 모델 없음 → 통과 (목록 엔드포인트)
e. 사용자의 effective scope 확인 (union 정책)
f. scope=null → 통과
g. scope='self' → $model->{owner_key} === $user->id
h. scope='role' → 소유자가 내 역할 공유하는지 체크
```
### 목록 엔드포인트 필터링 (Repository 패턴)
```php
// 모든 Repository에서 한 줄로 적용
$query = User::query();
PermissionHelper::applyPermissionScope($query, 'core.users.read');
// ... 기존 필터/정렬/페이지네이션
```
### 소유자 식별 전체 매핑
| Permission prefix | resource_route_key | owner_key | 비고 |
|---|---|---|---|
| core.users.* | user | id | self: $user->id === $model->id |
| core.menus.* | menu | created_by | |
| core.attachments.update/delete | attachment | created_by | create는 scope 불필요 |
| core.schedules.* | schedule | created_by | |
| core.activity_logs.* | null | null | 소유자 없음 |
| sirsoft-board.boards.* | board | created_by | |
| sirsoft-board.{slug}.*.posts.* | post | user_id | |
| sirsoft-ecommerce.products.* | product | created_by | |
| sirsoft-ecommerce.orders.* | order | user_id | |
| sirsoft-page.pages.* | page | created_by | |
> resource_route_key/owner_key가 null → scope_type UI 미표시, 설정해도 무시
### getPermissions() 포맷 (모듈)
```php
'categories' => [
[
'identifier' => 'pages',
'resource_route_key' => 'page', // 리소스 라우트 키
'owner_key' => 'created_by', // 소유자 컬럼
'permissions' => [
[
'action' => 'read',
'roles' => ['admin', 'manager'], // 문자열 → scope_type=null
],
[
'action' => 'delete',
'roles' => [
['role' => 'admin', 'scope_type' => null],
['role' => 'manager', 'scope_type' => 'self'],
],
],
],
],
],
```
### 역할 관리 UI
- PermissionTree 컴포넌트에 scope_type 드롭다운 추가
- resource_route_key가 있는 권한 + 체크된 상태에서만 드롭다운 표시
- 선택지: 전체(null), 소유역할('role'), 본인만('self')
- 폼 데이터: `permissions: [{id: 1, scope_type: null}, {id: 2, scope_type: 'self'}]`
---
## 레이아웃 컴포넌트 권한 필터링
레이아웃 JSON의 컴포넌트에 `permissions` 속성을 정의하면, **레이아웃 서빙 API에서 해당 컴포넌트를 제거하여 서빙**합니다. 프론트엔드 `if` 조건부 렌더링과 달리 JSON 구조 자체가 클라이언트에 노출되지 않아 보안이 강화됩니다.
### 동작 원리
```text
파이프라인 위치:
[캐시 조회] → [extends/partial 병합] → [applyExtensions] → [★ filterComponentsByPermissions] → [훅] → [응답]
```
- **Post-cache filtering**: 캐시 이후 사용자별 동적 필터링 (캐시 구조 영향 없음, ~1-2ms 추가)
- **AND 조건**: `permissions` 배열의 모든 권한을 충족해야 컴포넌트 포함
- **Admin 바이패스**: `admin` 역할은 `Gate::before()`에 의해 모든 권한 통과
### 필터링 대상 영역
| 영역 | 설명 |
|------|------|
| `components` | 메인 컴포넌트 트리 (재귀 children 포함) |
| `modals[]` | 모달 자체에 permissions → 모달 항목 전체 제거 |
| `modals[].components` | 모달 내부 컴포넌트 트리 (재귀) |
| `defines` | 재사용 컴포넌트 정의 (재귀) |
| partial 내부 | extends/partial 병합 후 필터링이므로 자동 처리 |
### 필터링 규칙
1. 현재 노드의 `permissions` 배열 확인 (없거나 빈 배열이면 통과)
2. `AuthServiceProvider::checkPermissions($permissions, $user)` 호출 (AND 조건)
3. 권한 없으면 해당 노드 및 하위 children **전체 제거** (하위 평가 생략)
4. 권한 있으면 통과 → children을 재귀적으로 독립 평가
5. 필터링 완료 후 모든 노드에서 `permissions` 속성 제거 (클라이언트 노출 방지)
### 상위-하위 중복 선언
```json
{
"type": "Div",
"permissions": ["core.users.read"],
"children": [
{ "type": "Button", "permissions": ["core.users.delete"], "text": "삭제" }
]
}
```
| 시나리오 | 상위 결과 | 하위 결과 | 최종 |
|---------|---------|---------|------|
| `read` 없음 | 상위 제거 → 전체 제거 | 평가 안 됨 | 둘 다 없음 |
| `read` 있음 + `delete` 없음 | 통과 | 하위만 제거 | Div만 남음 |
| `read` 있음 + `delete` 있음 | 통과 | 통과 | 둘 다 남음 |
→ 각 노드는 자기 permissions만 독립 평가. 별도 병합 정책 없음.
### 확장(Extension)과의 관계
- Extension Point로 주입된 컴포넌트에 `permissions` → 정상 필터링
- Overlay로 교체된 컴포넌트에 `permissions` → 교체된 컴포넌트의 permissions 적용
- 파이프라인 순서: `applyExtensions` → `filterComponentsByPermissions` (확장 적용 후 필터링)
### 레이아웃 최상위 permissions와의 비교
| 항목 | 최상위 `permissions` | 컴포넌트 `permissions` |
|------|---------------------|----------------------|
| 적용 범위 | 레이아웃 전체 | 개별 컴포넌트 단위 |
| 미충족 시 | 403 응답 | 해당 컴포넌트만 제거 |
| 조건 방식 | AND | AND |
| 처리 시점 | 캐시 조회 전 | 캐시 조회 후 (post-cache) |
### 관련 코드
| 파일 | 역할 |
|------|------|
| `app/Services/LayoutService.php` | `filterComponentsByPermissions()` 메서드 |
| `app/Http/Controllers/Api/Public/PublicLayoutController.php` | 서빙 시 필터링 호출 |
| `app/Rules/ValidLayoutStructure.php` | 컴포넌트 `permissions` 검증 |
> **레이아웃 JSON 문법 상세**: [layout-json.md](../frontend/layout-json.md#컴포넌트별-권한-component-permissions) 참조
---
## Model 권한/역할 체크 메서드
User 모델은 권한/역할 체크를 위한 헬퍼 메서드를 제공합니다.
### 메서드 시그니처
```php
// 단일 권한 체크
$user->hasPermission('core.users.read');
$user->hasPermission('core.users.read', PermissionType::Admin);
// 복수 권한 체크
$user->hasPermissions(['perm.a', 'perm.b'], requireAll: true); // AND
$user->hasPermissions(['perm.a', 'perm.b'], requireAll: false); // OR
// 역할 체크
$user->hasRole('admin');
$user->hasRoles(['admin', 'manager'], requireAll: false); // OR
// 관리자 판단 (admin 타입 권한 보유 여부)
$user->isAdmin();
```
### 인스턴스 레벨 캐싱
`getEffectiveScopeForPermission()` 메서드는 동일 요청 내 반복 호출 시 DB 쿼리를 방지하기 위해 인스턴스 레벨 캐싱을 사용합니다:
```php
// User 모델 내부
protected array $effectiveScopeCache = [];
public function getEffectiveScopeForPermission(string $identifier): ?string
{
if (array_key_exists($identifier, $this->effectiveScopeCache)) {
return $this->effectiveScopeCache[$identifier];
}
// ... DB 쿼리 및 union 정책 적용 ...
return $this->effectiveScopeCache[$identifier] = $result;
}
```
---
## Permission 동기화 및 추적
### Role-Permission 동기화 패턴
역할에 권한을 동기화할 때 이전/이후 상태를 추적합니다:
```php
public function syncPermissions(Role $role, array $permissions): void
{
// 동기화 전 권한 식별자 캡처 (Listener diff 계산용)
$previousPermIdentifiers = $role->permissions()->pluck('identifier')->toArray();
// 피벗 데이터에 granted_at, granted_by 자동 설정
$pivotData = [];
foreach ($permissions as $permission) {
$pivotData[$permission['id']] = [
'scope_type' => $permission['scope_type'] ?? null,
'granted_at' => now(),
'granted_by' => Auth::id(),
];
}
$role->permissions()->sync($pivotData);
// 동기화 후 권한 식별자 (Listener에서 diff 계산 가능)
$currentPermIdentifiers = $role->permissions()->pluck('identifier')->toArray();
HookManager::doAction('core.role.after_sync_permissions',
$role, $previousPermIdentifiers, $currentPermIdentifiers);
}
```
### 자기잠금 방지
마지막 admin 역할 보유자가 자신의 admin 역할을 제거하는 것을 방지합니다:
```text
- 관리자가 자기 자신의 역할을 변경하려 하면 → 역할 변경 무시
- 마지막 admin이 admin 역할을 삭제하려 하면 → 거부
```
---
## 비회원(Guest) 권한
### Guest 권한 체크 흐름
```text
비회원 요청 → PermissionMiddleware
→ Guest 역할 조회 (Role::where('identifier', 'guest'))
→ 정적 캐시: self::$guestRoleCache (요청 내 재사용)
→ 권한 매핑 없음 → 기본 허용
→ 권한 매핑 있음 → guest 역할이 해당 권한 보유하는지 확인
```
### 핵심 규칙
| 상황 | 동작 |
| ------ | ------ |
| 권한 미매핑 (permission_hooks 없음) | 모든 사용자(guest 포함) 허용 |
| 권한 매핑 + guest 역할 보유 | 허용 |
| 권한 매핑 + guest 역할 미보유 | 거부 (401/403) |
---
## Role identifier 자동 생성
관리자가 역할 생성 시 `identifier`를 직접 입력하지 않습니다. `RoleService`가 이름에서 자동 생성합니다:
```text
generateIdentifier(name)
↓ 배열인 경우 → 영어 이름 우선 (en → ko → 첫 번째 값)
↓ Str::slug($baseName, '_') → 슬러그 변환 (예: "Content Manager" → "content_manager")
↓ 중복 체크 → 중복 시 _1, _2, ... suffix 자동 추가
↓ 반환: 고유한 identifier
```
**예시**:
| 입력 이름 | 생성 identifier |
| ---------- | ---------- |
| `Content Manager` | `content_manager` |
| `Content Manager` (중복) | `content_manager_1` |
| `['ko' => '편집자', 'en' => 'Editor']` | `editor` |
---
## 관련 문서
- [menus.md](./menus.md) - 메뉴 권한 시스템
- [hooks.md](./hooks.md) - 훅 권한 시스템
- [module-basics.md](./module-basics.md) - 모듈 기본 구조
- [index.md](./index.md) - 확장 시스템 인덱스
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+609
View File
@@ -0,0 +1,609 @@
# 템플릿 시스템 기초
> **위치**: `docs/extension/template-basics.md`
> **관련 문서**: [index.md](./index.md) | [template-routing.md](./template-routing.md) | [template-security.md](./template-security.md)
---
## TL;DR (5초 요약)
```text
1. 타입: Admin (관리자용), User (일반사용자용)
2. 디렉토리: templates/vendor-template (예: sirsoft-admin_basic)
3. 필수: template.json, routes.json, components.json
4. 코어/템플릿 분리: 코어는 엔진, 템플릿은 컴포넌트
5. 버전 히스토리: Admin만 지원, User는 미지원
```
---
## 목차
1. [개요](#개요)
2. [템플릿 타입](#템플릿-타입)
- [Admin 템플릿](#admin-템플릿)
- [User 템플릿](#user-템플릿)
3. [템플릿 타입별 버전 히스토리 규칙](#템플릿-타입별-버전-히스토리-규칙)
4. [템플릿 네이밍 규칙](#템플릿-네이밍-규칙)
5. [템플릿 메타데이터 (template.json)](#템플릿-메타데이터-templatejson)
6. [에러 페이지 설정 (error_config)](#에러-페이지-설정-error_config)
7. [레이아웃 등록 스캔 범위](#레이아웃-등록-스캔-범위)
8. [역호환성 (다국어 필드)](#역호환성-다국어-필드)
---
## 개요
G7의 템플릿 시스템은 JSON 기반 레이아웃 정의를 통해 동적으로 UI를 생성하는 프론트엔드 아키텍처입니다. 코어는 렌더링 엔진만 제공하고, 실제 컴포넌트는 템플릿별로 독립적으로 구현됩니다.
**핵심 원칙**:
- 코어와 템플릿의 완전한 분리
- JSON 기반 레이아웃 정의
- 컴포넌트 재사용성 극대화
- 다국어 및 권한 기반 렌더링
---
## 템플릿 타입
### Admin 템플릿
**용도**: 관리자 페이지 UI 제공
**특징**:
- 레이아웃은 **읽기 전용** (DB 저장)
- 관리자만 접근 가능
- `/admin/*` 경로 전용
- 예: `sirsoft-admin_basic`
### User 템플릿
**용도**: 일반 사용자 페이지 UI 제공
**특징**:
- 레이아웃 **편집 가능** (사용자 커스터마이징)
- 공개 접근 가능
- `/` 루트 경로 전용
- 예: `sirsoft-user_theme1` (추후 구현 예정)
---
## 템플릿 타입별 버전 히스토리 규칙
```
중요: 템플릿 타입에 따라 버전 히스토리 동작이 다름
✅ 필수: Admin과 User 템플릿의 버전 관리 차이 이해
```
### Admin 템플릿 (`type: "admin"`)
**버전 히스토리**: ❌ **사용하지 않음**
- 레이아웃은 읽기 전용 (수정 불가)
- `template_layout_versions` 테이블 미사용
- 레이아웃 변경 시 직접 `template_layouts.content` 덮어쓰기
- 이유: 디자인 변경만 가능, 레이아웃 구조는 템플릿 교체로만 변경
**구현 예시**:
```php
// Admin 템플릿 레이아웃 업데이트
public function updateAdminLayout(int $id, array $data): TemplateLayout
{
$layout = TemplateLayout::findOrFail($id);
// 버전 생성 없이 직접 수정
$layout->update([
'content' => $data['content'],
]);
return $layout;
}
```
### User 템플릿 (`type: "user"`)
**버전 히스토리**: ✅ **자동 관리**
- 레이아웃 편집 가능 (사용자 커스터마이징)
- `template_layout_versions` 테이블 사용
- 레이아웃 수정 시 자동으로 새 버전 생성
- 버전별 저장/복원 기능 제공
**구현 예시**:
```php
// User 템플릿 레이아웃 업데이트 (버전 자동 생성)
public function updateUserLayout(int $id, array $data): TemplateLayout
{
$layout = TemplateLayout::findOrFail($id);
// 현재 버전 번호 조회
$latestVersion = $layout->versions()->max('version') ?? 0;
$newVersion = $latestVersion + 1;
// 새 버전 생성
TemplateLayoutVersion::create([
'layout_id' => $layout->id,
'version' => $newVersion,
'content' => $data['content'],
'created_by' => auth()->id(),
]);
// 레이아웃 content도 업데이트
$layout->update([
'content' => $data['content'],
]);
return $layout;
}
```
**버전 복원**:
```php
// 특정 버전으로 복원
public function restoreVersion(int $layoutId, int $version): TemplateLayout
{
$layout = TemplateLayout::findOrFail($layoutId);
$versionRecord = $layout->getVersion($version);
if (!$versionRecord) {
throw new Exception('버전을 찾을 수 없습니다.');
}
// 복원 시 새 버전으로 저장
$latestVersion = $layout->versions()->max('version') ?? 0;
TemplateLayoutVersion::create([
'layout_id' => $layout->id,
'version' => $latestVersion + 1,
'content' => $versionRecord->content,
'created_by' => auth()->id(),
]);
$layout->update([
'content' => $versionRecord->content,
]);
return $layout;
}
```
### 비교표
| 항목 | Admin 템플릿 | User 템플릿 |
|------|------------|-----------|
| 레이아웃 편집 | ❌ 불가 | ✅ 가능 |
| 버전 히스토리 | ❌ 미사용 | ✅ 자동 생성 |
| `template_layout_versions` | ❌ 미사용 | ✅ 사용 |
| 레이아웃 복원 | ❌ 불가 | ✅ 가능 |
| 변경 방법 | 템플릿 교체 | 직접 편집 |
---
## 템플릿 네이밍 규칙
**형식**: `[vendor-template]` (GitHub 스타일)
- 소문자 사용
- 하이픈(-) 구분
- vendor: 개발자/조직 식별자
- template: 템플릿명
**디렉토리 예시**:
- `/templates/_bundled/sirsoft-admin_basic/` - sirsoft가 개발한 기본 관리자 템플릿
- `/templates/_bundled/johndoe-admin_dark/` - johndoe가 개발한 다크 모드 관리자 템플릿
### 식별자 검증 규칙
모듈/플러그인/템플릿 공통 식별자 검증 규칙은 [extension-manager.md](./extension-manager.md#식별자-검증-규칙-validextensionidentifier)를 참조하세요.
---
## 템플릿 메타데이터 (template.json)
**위치**: `/templates/_bundled/[vendor-template]/template.json`
### 구조
```json
{
"identifier": "sirsoft-admin_basic",
"vendor": "sirsoft",
"name": "Admin Basic",
"version": "1.0.0",
"license": "MIT",
"description": "Basic admin template for Gnuboard7 platform",
"type": "admin",
"locales": ["ko", "en"],
"dependencies": [],
"features": {
"responsive": true,
"darkMode": true,
"rtl": false
},
"assets": {
"css": ["dist/bundle.css"],
"js": ["dist/bundle.js"]
}
}
```
### 필수 필드
| 필드 | 설명 |
|------|------|
| `identifier` | 템플릿 고유 식별자 (vendor-template 형식) |
| `vendor` | 벤더명 |
| `name` | 템플릿 표시명 |
| `version` | 버전 (Semantic Versioning) |
| `license` | 라이선스 유형 (예: `"MIT"`) — API 리소스의 `license` 필드로 노출 |
| `type` | "admin" 또는 "user" |
| `locales` | 지원 언어 배열 (`config('app.supported_locales')`와 매칭) |
---
## 에러 페이지 설정 (error_config)
```
필수: 모든 템플릿은 에러 페이지 설정을 포함해야 함
✅ 규칙: DB에는 layout_name만 저장되므로 경로 없이 이름만 사용
```
### template.json 설정
```json
{
"error_config": {
"layouts": {
"404": "404",
"403": "403",
"500": "500"
}
}
}
```
### 에러 레이아웃 파일 위치
에러 레이아웃은 반드시 `layouts/errors/` 디렉토리에 위치해야 합니다:
```
templates/_bundled/[vendor-template]/
├── template.json # 메타데이터 (이름, 버전, 라이선스 등 SSoT)
├── seo-config.json # SEO 컴포넌트→HTML 매핑 설정 (선택)
├── LICENSE # 라이선스 전문 (MIT) — API 엔드포인트 `GET /api/admin/templates/{identifier}/license`로 제공
├── layouts/
│ ├── dashboard.json # 일반 레이아웃
│ ├── _admin_base.json # 베이스 레이아웃
│ └── errors/ # 에러 레이아웃 전용
│ ├── 404.json
│ ├── 403.json
│ └── 500.json
```
### 에러 레이아웃 구조
에러 레이아웃도 일반 레이아웃과 동일하게 `extends` + `slots` 패턴을 사용합니다:
```json
{
"extends": "_admin_base",
"slots": {
"content": [
{
"type": "composite",
"name": "EmptyState",
"props": {
"icon": "alert-triangle",
"title": "$t:errors.404.title",
"description": "$t:errors.404.description"
}
}
]
}
}
```
### 설치 시 검증
`TemplateManager::installTemplate()` 호출 시 다음 사항이 자동 검증됩니다:
1. `error_config` 섹션 존재 여부
2. 필수 에러 코드(404, 403, 500) 레이아웃 정의 여부
3. 레이아웃 파일 실제 존재 여부 (`layouts/errors/` 디렉토리 내)
---
## 레이아웃 등록 스캔 범위
```
중요: registerLayouts()는 특정 디렉토리만 스캔
✅ 포함: layouts/*.json (루트) + layouts/errors/*.json
❌ 제외: layouts/partials/ 등 기타 하위 디렉토리
```
### 스캔 대상
| 디렉토리 | 스캔 여부 | 용도 |
|----------|----------|------|
| `layouts/*.json` | ✅ 스캔 | 일반 레이아웃 |
| `layouts/errors/*.json` | ✅ 스캔 | 에러 페이지 레이아웃 |
| `layouts/partials/*.json` | ❌ 제외 | extends로 참조되는 부분 레이아웃 |
### 이유
- `partials/` 디렉토리의 파일은 메인 레이아웃에서 `extends`로 참조되어 병합됨
- 직접 라우팅되지 않으므로 DB에 별도 등록 불필요
- 에러 레이아웃은 `ErrorPageHandler`에서 직접 조회하므로 DB 등록 필요
### TemplateManager.php 구현
```php
// 루트 layouts 디렉토리의 JSON 파일만 스캔
$layoutFiles = File::glob("{$layoutsPath}/*.json");
// errors/ 디렉토리만 추가 스캔 (에러 페이지 레이아웃용)
$errorsPath = "{$layoutsPath}/errors";
if (File::exists($errorsPath)) {
$errorLayoutFiles = File::glob("{$errorsPath}/*.json");
$layoutFiles = array_merge($layoutFiles, $errorLayoutFiles);
}
```
### 모듈 레이아웃 자동 등록
모듈 활성화 시 레이아웃은 디렉토리 위치에 따라 해당 타입의 템플릿에 등록됩니다:
- `modules/{module}/resources/layouts/admin/*.json` → Admin 타입 템플릿에 등록
- `modules/{module}/resources/layouts/user/*.json` → User 타입 템플릿에 등록
이 동작은 `ModuleManager::registerModuleLayouts()`에서 처리됩니다.
---
## 역호환성 (다국어 필드)
```
중요: 다국어 필드의 역호환성 지원
✅ 필수: 문자열 → 다국어 배열 자동 변환
```
### 지원 필드
- `name`: 템플릿명
- `description`: 템플릿 설명
### ModuleInterface/PluginInterface와 동일한 패턴
```php
// ✅ 권장: 다국어 배열 반환
public function getName(): array
{
return [
'ko' => '기본 관리자 템플릿',
'en' => 'Basic Admin Template',
];
}
// 역호환: 문자열 반환 (자동 변환됨)
public function getName(): string
{
return 'Basic Admin Template'; // 자동으로 ['ko' => '...', 'en' => '...']로 변환
}
```
### TemplateManager 자동 변환
```php
// app/Extension/TemplateManager.php
/**
* 문자열을 다국어 배열로 자동 변환
*/
protected function convertToMultilingual($value): array
{
// 이미 배열이면 그대로 반환
if (is_array($value)) {
return $value;
}
// 문자열이면 모든 로케일에 동일한 값 설정
if (is_string($value)) {
$locales = config('app.translatable_locales', ['ko', 'en']);
$result = [];
foreach ($locales as $locale) {
$result[$locale] = $value;
}
return $result;
}
// 그 외 타입은 빈 배열 반환
return [];
}
```
### template.json 예시
```json
{
"identifier": "sirsoft-admin_basic",
"vendor": "sirsoft",
// ✅ 권장: 다국어 객체
"name": {
"ko": "기본 관리자 템플릿",
"en": "Basic Admin Template"
},
"description": {
"ko": "그누보드7용 기본 관리자 템플릿",
"en": "Basic admin template for Gnuboard7 platform"
}
// 역호환: 문자열 (자동 변환됨)
// "name": "Basic Admin Template",
// "description": "Basic admin template for Gnuboard7 platform"
}
```
### DB 저장 형식
```php
// templates 테이블
[
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
'description' => ['ko' => 'G7용...', 'en' => 'Basic admin template...'],
]
```
### 역호환성 보장 사항
- ✅ 기존 문자열 기반 template.json 정상 작동
- ✅ 문자열 → 다국어 배열 자동 변환
- ✅ 변환된 값은 모든 로케일에 동일하게 적용
- ✅ Model Accessor (`getLocalizedName()`)에서 올바른 폴백 처리
---
## 템플릿 업데이트 레이아웃 충돌 전략
템플릿 버전 업데이트 시, 관리자가 수정한 레이아웃과 새 버전의 레이아웃이 충돌할 수 있습니다.
### 사전 확인
업데이트 전 수정된 레이아웃을 확인할 수 있습니다:
```text
GET /api/admin/templates/{templateName}/check-modified-layouts
```
응답:
```json
{
"has_modified": true,
"modified_layouts": [
{ "id": 1, "name": "dashboard", "updated_at": "2026-02-24T10:00:00Z" }
]
}
```
### 충돌 해결 전략
업데이트 실행 시 `layout_strategy` 파라미터로 전략을 선택합니다:
| 전략 | 동작 |
|------|------|
| `apply_new` | 새 버전의 레이아웃으로 덮어쓰기 (관리자 수정 사항 유실) |
| `keep_current` | 기존 레이아웃 유지 (새 버전 레이아웃 미적용) |
```text
POST /api/admin/templates/{templateName}/update
Body: { "layout_strategy": "apply_new" }
```
> 상세: [extension-update-system.md](./extension-update-system.md)
---
## 번들 디렉토리 작업 규칙
```text
필수: 템플릿 수정/개발은 _bundled 디렉토리에서만 작업 (활성 디렉토리 직접 수정 금지)
필수: _bundled 작업 완료 후 반영/검증은 업데이트 프로세스 사용
```
### 개발 워크플로우
```text
1. templates/_bundled/{identifier}/ 에서 코드 수정
2. template.json 버전 올리기
3. php artisan template:update {identifier} 로 활성 디렉토리에 반영
4. 테스트 실행으로 검증
```
### 왜 활성 디렉토리 직접 수정이 금지되는가?
- 활성 디렉토리는 `.gitignore` 대상 → Git에 변경 기록 불가
- 다음 업데이트 시 `_bundled` 소스로 덮어쓰기 → 직접 수정 사항 유실
- 업데이트 프로세스 미수행 시 레이아웃 갱신/캐시 무효화 누락
### 예외: 초기 개발 (아직 _bundled에 미등록)
```text
✅ 허용: 신규 템플릿 초기 개발 시 활성 디렉토리에서 직접 작업
전환점: _bundled에 최초 반영한 이후부터는 반드시 _bundled에서만 작업
```
> 상세: [extension-update-system.md](./extension-update-system.md) "번들 디렉토리 개발 워크플로우" 참조
---
## 코드 변경 시 버전 변경 필수
```text
필수: 템플릿 코드를 변경한 경우 버전을 올려야 합니다.
버전 변경 없이 _bundled에 반영하면, 이미 설치된 환경에서 업데이트가 감지되지 않습니다.
참고: 템플릿은 모듈/플러그인과 달리 업그레이드 스텝(upgrades/)을 사용하지 않습니다.
```
### 필수 작업
1. **`template.json` 버전 올리기**: `version` 필드를 Semantic Versioning에 따라 증가
2. **`_bundled` 동기화**: `templates/_bundled/{identifier}/` 디렉토리에 변경 사항 반영
### 템플릿 vs 모듈/플러그인 차이
| 항목 | 템플릿 | 모듈/플러그인 |
|------|--------|-------------|
| manifest 버전 올리기 | ✅ 필수 | ✅ 필수 |
| _bundled 동기화 | ✅ 필수 | ✅ 필수 |
| 업그레이드 스텝 (upgrades/) | ❌ 해당 없음 | 조건부 필요 |
| DB 마이그레이션 | ❌ 해당 없음 | 조건부 필요 |
| 레이아웃 충돌 전략 | ✅ `layout_strategy` 파라미터 | ❌ 해당 없음 |
> 상세: [extension-update-system.md](./extension-update-system.md) "개발자 버전 업데이트 가이드" 참조
---
## SEO 설정 (seo-config.json)
**위치**: `templates/_bundled/[vendor-template]/seo-config.json` (선택 파일)
SEO 페이지 생성기가 컴포넌트를 HTML로 변환할 때 사용하는 매핑 설정입니다. 이 파일이 없으면 모든 컴포넌트가 `<div>` fallback으로 렌더링됩니다.
### 주요 섹션
| 섹션 | 설명 |
|------|------|
| `text_props` | 텍스트 추출 우선순위 (예: `["text", "label", "value", "title"]`) |
| `attr_map` | props→HTML 속성 매핑 (예: `{"className": "class", "htmlFor": "for"}`) |
| `allowed_attrs` | 허용 HTML 속성 목록 (목록에 없는 속성은 출력 안됨) |
| `component_map` | 컴포넌트명 → HTML 태그 매핑 (기본 30개 + 커스텀) |
| `render_modes` | 렌더 모드 정의 (iterate/format/raw 타입) |
| `self_closing` | 셀프 클로징 태그 목록 (`["img", "input", "hr", "br"]`) |
| `stylesheets` | SEO 페이지에 포함할 외부 CSS URL |
### Graceful Degradation
- `text_props`/`attr_map`/`allowed_attrs` 미선언 → 엔진 내장 기본값 사용 (범용 HTML/React 매핑)
- `text_props` 빈 배열로 명시 → props에서 텍스트 추출 불가 (`component.text`는 여전히 동작)
- `attr_map` 빈 객체로 명시 → 속성명 변환 없음 (className→class 미적용)
- `allowed_attrs` 빈 배열로 명시 → 모든 속성 출력 차단
- seo-config.json 미존재 → 모든 컴포넌트 `<div>` fallback, 기본 속성 매핑으로 최소한의 HTML 생성
### 검증
`TemplateManager.validateSeoConfig()`가 설치/업데이트 시 자동 검증합니다:
- JSON 파싱, `component_map.*.tag` 필수, `render` → `render_modes` 교차 참조
- 검증 실패 시 설치/업데이트 차단
- 파일 미존재 시 경고만 (설치 허용)
> 상세 스키마: [seo-system.md](../backend/seo-system.md) "템플릿 seo-config.json" 섹션 참조
## 관련 문서
- [index.md](./index.md) - 확장 시스템 인덱스
- [template-routing.md](./template-routing.md) - 템플릿 라우트/언어 파일 규칙
- [template-security.md](./template-security.md) - 템플릿 보안 정책
- [template-caching.md](./template-caching.md) - 템플릿 캐싱 전략
- [template-commands.md](./template-commands.md) - 템플릿 Artisan 커맨드
- [extension-update-system.md](./extension-update-system.md) - 확장 업데이트 시스템
+523
View File
@@ -0,0 +1,523 @@
# 템플릿 캐싱 전략
> **관련 문서**: [template-basics.md](./template-basics.md) | [template-security.md](./template-security.md) | [index.md](./index.md)
---
## 목차
1. [핵심 원칙](#핵심-원칙)
2. [캐싱 계층](#캐싱-계층)
3. [확장 기능 캐시 버전 (Cache Busting)](#확장-기능-캐시-버전-cache-busting)
4. [캐시 무효화 구현](#캐시-무효화-구현)
5. [서비스에서 캐시 무효화 호출](#서비스에서-캐시-무효화-호출)
6. [캐싱 모범 사례](#캐싱-모범-사례)
7. [성능 벤치마크](#성능-벤치마크)
---
## 핵심 원칙
```
중요: 다층 캐싱으로 성능 최적화
✅ 필수: 캐시 무효화 규칙 준수
```
템플릿 시스템은 여러 계층의 캐싱을 통해 성능을 최적화합니다. 각 캐시는 적절한 TTL(Time To Live)을 가지며, 데이터 변경 시 정확한 무효화가 필수입니다.
---
## 캐싱 계층
### 1. 활성 템플릿 캐시
```php
// 키: templates.active.{type}
// TTL: 3600초 (1시간)
// 값: Template 모델 객체
Cache::remember("templates.active.admin", 3600, function () {
return Template::where('type', 'admin')
->where('is_active', true)
->first();
});
```
**무효화 시점**:
- 템플릿 활성화/비활성화 시
- 템플릿 삭제 시
---
### 2. 레이아웃 JSON 캐시
```php
// 키: template.layout.{layout_name}.{locale}
// TTL: 1800초 (30분)
// 값: 병합된 레이아웃 JSON 배열
Cache::remember("template.layout.dashboard.ko", 1800, function () use ($layoutName, $locale) {
return $this->layoutService->getLayout($layoutName, $locale);
});
```
**무효화 시점**:
- 레이아웃 생성/수정/삭제 시
- 상위 레이아웃(extends) 변경 시
- 로케일 변경 시
---
### 3. 병합된 레이아웃 캐시
상속 체인 처리 결과를 캐싱합니다.
```php
// 키: template.layout.merged.{layout_name}.{locale}
// TTL: 3600초 (1시간)
// 값: extends/slots 병합 완료된 최종 레이아웃
Cache::remember("template.layout.merged.dashboard.ko", 3600, function () use ($layoutName, $locale) {
return $this->layoutService->mergeLayouts($layoutName, [], 0, $locale);
});
```
**무효화 시점**:
- 레이아웃 JSON 캐시 무효화 시 함께 무효화
- 상속 체인의 어느 레이아웃이든 변경 시
---
### 4. 컴포넌트 레지스트리 캐시
```php
// 키: template.components.{identifier}
// TTL: 무제한 (수동 무효화)
// 값: components.json 파싱 결과
Cache::rememberForever("template.components.sirsoft-admin_basic", function () use ($identifier) {
$componentsPath = base_path("templates/{$identifier}/components.json");
return json_decode(file_get_contents($componentsPath), true);
});
```
**무효화 시점**:
- 템플릿 재설치 시
- components.json 수정 시 (개발 중)
---
## 확장 기능 캐시 버전 (Cache Busting)
### 개요
모듈/플러그인/템플릿 활성화/비활성화 시 프론트엔드가 최신 데이터를 즉시 반영하도록 버전 기반 캐시 버스팅을 구현합니다.
```
문제 상황: 브라우저 HTTP 캐시로 인해 모듈 활성화 후 새로고침(Ctrl+F5) 전까지
다국어/라우트 데이터가 반영되지 않음
✅ 해결책: 캐시 버전(타임스탬프) 기반 쿼리 파라미터로 캐시 무효화
```
### 백엔드 구현
#### ClearsTemplateCaches Trait
모듈/플러그인/템플릿 매니저에서 사용하는 공통 trait입니다.
```php
<?php
namespace App\Extension\Traits;
use Illuminate\Support\Facades\Cache;
trait ClearsTemplateCaches
{
/**
* 확장 기능 캐시 버전을 증가시킵니다.
*
* 모듈/플러그인 활성화/비활성화 시 호출되어
* 프론트엔드가 새로운 캐시 버전으로 API를 요청하도록 합니다.
*/
protected function incrementExtensionCacheVersion(): void
{
Cache::put('extension_cache_version', time());
}
/**
* 현재 확장 기능 캐시 버전을 반환합니다.
*
* @return int 캐시 버전 (타임스탬프) 또는 0 (미설정 시)
*/
public static function getExtensionCacheVersion(): int
{
return (int) Cache::get('extension_cache_version', 0);
}
/**
* 모든 활성 템플릿의 언어 캐시를 무효화합니다.
*/
protected function clearAllTemplateLanguageCaches(): void
{
$locales = config('app.supported_locales', ['ko', 'en']);
$templates = Template::where('status', ExtensionStatus::Active->value)->get();
foreach ($templates as $template) {
foreach ($locales as $locale) {
Cache::forget("template.language.{$template->identifier}.{$locale}");
}
}
}
/**
* 모든 활성 템플릿의 routes 캐시를 무효화합니다.
*/
protected function clearAllTemplateRoutesCaches(): void
{
$templates = Template::where('status', ExtensionStatus::Active->value)->get();
foreach ($templates as $template) {
Cache::forget("template.routes.{$template->identifier}");
}
}
}
```
#### 캐시 버전 증가 트리거 시점
| 매니저 | 메서드 | 트리거 상황 |
|--------|--------|------------|
| ModuleManager | `activate()` | 모듈 활성화 |
| ModuleManager | `deactivate()` | 모듈 비활성화 |
| ModuleManager | `refreshModuleLayouts()` | 레이아웃 갱신 시 변경 발생 |
| PluginManager | `activate()` | 플러그인 활성화 |
| PluginManager | `deactivate()` | 플러그인 비활성화 |
| TemplateManager | `activate()` | 템플릿 활성화 |
| TemplateManager | `refreshTemplateLayouts()` | 레이아웃 갱신 시 변경 발생 |
### 프론트엔드 구현
#### 캐시 버전 저장 및 관리
```typescript
// LayoutLoader.ts
private cacheVersion: number = 0;
constructor() {
// localStorage에서 캐시 버전 복원
const stored = localStorage.getItem('g7_extension_cache_version');
this.cacheVersion = stored ? parseInt(stored, 10) : 0;
}
setCacheVersion(version: number): void {
const oldVersion = this.cacheVersion;
this.cacheVersion = version;
localStorage.setItem('g7_extension_cache_version', String(version));
// 버전 변경 시 캐시 무효화
if (oldVersion !== 0 && oldVersion !== version) {
this.clearCache();
}
}
```
#### API 요청 시 버전 파라미터 추가
```typescript
// LayoutLoader.ts
async loadLayout(layoutName: string): Promise<LayoutConfig> {
let url = `/api/layout/${layoutName}`;
if (this.cacheVersion > 0) {
url += `?v=${this.cacheVersion}`;
}
// ...
}
// TranslationEngine.ts
async loadTranslations(identifier: string, locale: string): Promise<void> {
let url = `/api/template/${identifier}/language/${locale}`;
const params: string[] = [];
if (this.cacheVersion > 0) {
params.push(`v=${this.cacheVersion}`);
}
if (params.length > 0) {
url += `?${params.join('&')}`;
}
// ...
}
```
#### 앱 초기화 시 캐시 버전 설정
```typescript
// TemplateApp.ts
async init(): Promise<void> {
// 서버에서 캐시 버전 조회
const response = await fetch('/api/extension/cache-version');
const { version } = await response.json();
// 각 엔진에 버전 설정
this.layoutLoader.setCacheVersion(version);
this.translationEngine.setCacheVersion(version);
}
```
### 캐시 버전 API 엔드포인트
```php
// routes/api.php
Route::get('/extension/cache-version', function () {
return response()->json([
'version' => ClearsTemplateCaches::getExtensionCacheVersion(),
]);
});
```
### 동작 흐름
```
1. 사용자가 모듈 활성화 버튼 클릭
2. 백엔드: ModuleManager->activate() 실행
3. 백엔드: incrementExtensionCacheVersion() 호출 → 캐시 버전 = 현재 타임스탬프
4. 백엔드: clearAllTemplateLanguageCaches() 호출 → 서버 캐시 무효화
5. 프론트엔드: 페이지 새로고침 또는 SPA 라우트 이동
6. 프론트엔드: /api/extension/cache-version 조회
7. 프론트엔드: 버전 변경 감지 → localStorage 업데이트 + 내부 캐시 클리어
8. 프론트엔드: API 요청 시 ?v={version} 파라미터 추가
9. 브라우저: 새로운 URL로 인식하여 캐시 우회 → 최신 데이터 수신
```
### 캐시 버전 키 요약
| 저장소 | 키 | 값 | 용도 |
|--------|-----|-----|------|
| Laravel Cache | `extension_cache_version` | 타임스탬프 | 서버 측 버전 관리 |
| localStorage | `g7_extension_cache_version` | 타임스탬프 | 클라이언트 측 버전 유지 |
---
## 캐시 무효화 구현
### TemplateCacheService
```php
<?php
namespace App\Services\Template;
use Illuminate\Support\Facades\Cache;
class TemplateCacheService
{
/**
* 템플릿 활성화 캐시 무효화
*/
public function invalidateActiveTemplate(string $type): void
{
Cache::forget("templates.active.{$type}");
}
/**
* 레이아웃 캐시 무효화
*/
public function invalidateLayout(string $layoutName): void
{
$locales = config('app.supported_locales', ['ko', 'en']);
foreach ($locales as $locale) {
Cache::forget("template.layout.{$layoutName}.{$locale}");
Cache::forget("template.layout.merged.{$layoutName}.{$locale}");
}
}
/**
* 레이아웃 상속 체인 캐시 무효화
*/
public function invalidateLayoutChain(string $layoutName): void
{
// 현재 레이아웃 무효화
$this->invalidateLayout($layoutName);
// 이 레이아웃을 extends하는 모든 자식 레이아웃 무효화
$children = TemplateLayout::where('extends', $layoutName)->get();
foreach ($children as $child) {
$this->invalidateLayoutChain($child->layout_name);
}
}
/**
* 컴포넌트 레지스트리 캐시 무효화
*/
public function invalidateComponentRegistry(string $identifier): void
{
Cache::forget("template.components.{$identifier}");
}
/**
* 템플릿 전체 캐시 무효화
*/
public function invalidateAllTemplateCache(string $identifier): void
{
// 활성 템플릿 캐시 무효화
$template = Template::where('identifier', $identifier)->first();
if ($template) {
$this->invalidateActiveTemplate($template->type);
}
// 모든 레이아웃 캐시 무효화
$layouts = TemplateLayout::where('template_id', $template->id)->get();
foreach ($layouts as $layout) {
$this->invalidateLayoutChain($layout->layout_name);
}
// 컴포넌트 레지스트리 캐시 무효화
$this->invalidateComponentRegistry($identifier);
}
}
```
---
## 서비스에서 캐시 무효화 호출
### LayoutService 예시
```php
<?php
namespace App\Services\Template;
class LayoutService
{
public function __construct(
private TemplateCacheService $cacheService
) {}
/**
* 레이아웃 수정
*/
public function updateLayout(int $id, array $data): TemplateLayout
{
$layout = TemplateLayout::findOrFail($id);
$oldExtends = $layout->extends;
$layout->update($data);
// 캐시 무효화
$this->cacheService->invalidateLayoutChain($layout->layout_name);
// extends 변경 시 이전 부모의 자식들도 무효화
if ($oldExtends && $oldExtends !== $layout->extends) {
$this->cacheService->invalidateLayoutChain($oldExtends);
}
return $layout->fresh();
}
/**
* 템플릿 활성화
*/
public function activateTemplate(string $identifier): Template
{
$template = Template::where('identifier', $identifier)->firstOrFail();
// 동일 타입의 기존 활성 템플릿 비활성화
Template::where('type', $template->type)
->where('is_active', true)
->update(['is_active' => false]);
$template->update(['is_active' => true]);
// 캐시 무효화
$this->cacheService->invalidateActiveTemplate($template->type);
return $template->fresh();
}
}
```
### 레이아웃 편집(관리자 커스터마이징) 시 캐시 무효화
관리자 UI에서 레이아웃을 직접 편집/복원할 때도 캐시 무효화가 필수입니다.
`LayoutService`의 저장(`updateLayoutContent`) 및 버전 복원(`restoreVersion`) 메서드에서:
1. **`clearDependentLayoutsCache()`** 호출 — 해당 레이아웃 + 이를 extends하는 자식 레이아웃의 병합 캐시 + `PublicLayoutController` 서빙 캐시(`layout.{identifier}.{name}.v{version}`)를 재귀적으로 무효화
2. **`extension_cache_version` 증가** (`Cache::put('extension_cache_version', time())`) — CDN/브라우저 HTTP 캐시 busting. `PublicLayoutController`가 `?v={version}` 기반 캐싱을 사용하므로, 버전 변경 시 브라우저가 새 URL로 인식하여 캐시를 우회합니다.
```php
// LayoutService::updateLayoutContent() 내부
$layout = $this->layoutRepository->update($layout->id, $updateData);
// 캐시 무효화 (현재 레이아웃 + 자식 레이아웃 + PublicLayoutController 서빙 캐시)
$this->clearDependentLayoutsCache($templateId, $name);
// 프론트엔드 브라우저 캐시 무효화
Cache::put('extension_cache_version', time());
```
```php
// LayoutService::restoreVersion() 내부
$newVersion = $this->versionRepository->restoreVersion($layout->id, $versionId);
// 동일하게 캐시 무효화
$this->clearDependentLayoutsCache($templateId, $name);
Cache::put('extension_cache_version', time());
```
이 과정이 누락되면 편집/복원 내용이 실제 사이트에 반영되지 않습니다.
> 참고: `clearDependentLayoutsCache()`는 내부적으로 `clearLayoutCache()`를 호출하며, 이 메서드는 병합 캐시 삭제 + `clearPublicServingCache()`(기본 v0 + 현재 extension_cache_version 캐시 삭제) + 모듈 레이아웃인 경우 `LayoutResolverService` 캐시도 무효화합니다.
---
## 캐싱 모범 사례
### DO (권장)
- ✅ 읽기 빈도가 높은 데이터는 캐싱 필수 (활성 템플릿, 레이아웃 JSON)
- ✅ TTL은 데이터 변경 빈도에 따라 조정
- ✅ 상속 체인 무효화는 재귀적으로 처리
- ✅ 로케일별 캐싱으로 다국어 지원
- ✅ 캐시 키에 의미 있는 이름 사용
### DON'T (금지)
- ❌ 캐시 무효화 누락 금지 (데이터 불일치 발생)
- ❌ 과도한 캐싱으로 메모리 낭비 금지
- ❌ TTL 없이 무제한 캐싱 남용 금지 (컴포넌트 레지스트리 제외)
---
## 성능 벤치마크
참고용 성능 비교 데이터입니다.
| 상태 | 응답 시간 | 개선율 |
|------|----------|--------|
| 캐싱 없음 | 120ms | - |
| 캐싱 적용 | 5ms | 95% ↓ |
---
## 캐시 키 요약
| 캐시 타입 | 키 패턴 | TTL |
|----------|---------|-----|
| 활성 템플릿 | `templates.active.{type}` | 3600초 (1시간) |
| 레이아웃 JSON | `template.layout.{name}.{locale}` | 1800초 (30분) |
| 병합된 레이아웃 | `template.layout.merged.{name}.{locale}` | 3600초 (1시간) |
| 컴포넌트 레지스트리 | `template.components.{identifier}` | 무제한 |
---
## 관련 문서
- [템플릿 기초](./template-basics.md) - 템플릿 타입, 메타데이터
- [템플릿 보안](./template-security.md) - API 서빙, 화이트리스트
- [템플릿 커맨드](./template-commands.md) - Artisan 커맨드
- [확장 시스템 인덱스](./index.md) - 전체 문서 목록
+381
View File
@@ -0,0 +1,381 @@
# 템플릿 Artisan 커맨드
> 템플릿 관련 모든 작업은 Artisan 커맨드를 통해 수행합니다.
---
## TL;DR (5초 요약)
```text
1. 목록: php artisan template:list
2. 설치: php artisan template:install [identifier]
3. 활성화: php artisan template:activate [identifier]
4. 비활성화: php artisan template:deactivate [identifier]
5. 빌드: php artisan template:build [identifier]
```
---
## 목차
1. [핵심 원칙](#핵심-원칙)
2. [사용 가능한 커맨드](#사용-가능한-커맨드)
3. [커맨드 구현 규칙](#커맨드-구현-규칙)
4. [커맨드 테스트](#커맨드-테스트)
5. [문제 해결](#문제-해결)
6. [관련 문서](#관련-문서)
---
## 핵심 원칙
```
필수: 템플릿 관련 모든 작업은 Artisan 커맨드로 수행
✅ 규칙: 직접 DB 조작 또는 파일 시스템 변경 금지
```
---
## 사용 가능한 커맨드
### template:list
템플릿 목록을 조회합니다.
```bash
php artisan template:list
```
**기능**:
- 설치된 모든 템플릿 표시
- 활성화 상태, 타입, 버전 정보 포함
---
### template:install
템플릿을 설치합니다.
```bash
php artisan template:install [vendor-template]
```
**기능**:
- `templates` 테이블에 템플릿 등록
- Vite 빌드 실행 (`npm run build`)
- 컴포넌트 레지스트리 캐시 생성
- 다국어 파일 검증
---
### template:activate
템플릿을 활성화합니다.
```bash
php artisan template:activate [vendor-template]
```
**기능**:
- `templates.is_active` 플래그 설정
- 동일 타입의 기존 활성 템플릿 비활성화
- 캐시 무효화 (`templates.active.{type}`)
---
### template:deactivate
템플릿을 비활성화합니다.
```bash
php artisan template:deactivate [vendor-template]
```
**기능**:
- `templates.is_active` 플래그 해제
- 관련 캐시 무효화
---
### template:uninstall
템플릿을 삭제합니다.
```bash
php artisan template:uninstall [vendor-template]
```
**기능**:
- 템플릿 데이터 삭제 (`templates`, `template_layouts`)
- 의존성 있는 템플릿 확인 후 경고
- 캐시 전체 무효화
---
### template:check-updates
템플릿 업데이트를 확인합니다.
```bash
php artisan template:check-updates [identifier?]
```
**인수**:
- `identifier` (선택): 특정 템플릿만 확인
**기능**:
- **전체 확인** (인수 없음): 설치된 모든 템플릿의 업데이트 가용 여부를 테이블로 출력
- 감지 우선순위: GitHub > `_bundled` (2단계, `_pending` 미참여)
- DB에 `update_available`, `latest_version`, `update_source` 갱신
- **단일 확인** (인수 있음): 특정 템플릿의 업데이트 가용 여부 확인
- `checkTemplateUpdate()`는 DB 갱신 안 함 → 커맨드에서 `updateByIdentifier()` 직접 호출
**예시**:
```bash
# 모든 템플릿 업데이트 확인
php artisan template:check-updates
# 특정 템플릿만 확인
php artisan template:check-updates sirsoft-admin_basic
```
---
### template:update
템플릿을 업데이트합니다.
```bash
php artisan template:update [identifier] [--layout-strategy=overwrite] [--force]
```
**인수**:
- `identifier` (필수): 업데이트할 템플릿 식별자
**옵션**:
- `--layout-strategy=overwrite`: 레이아웃 전략 (`overwrite` | `keep`)
- `--force`: 버전 비교 없이 강제 업데이트 (파일 손상, 수동 수정 복원 시 사용)
**수행 작업**:
- 레이아웃 전략 검증 (`overwrite` | `keep` 외 FAILURE)
- 업데이트 가용 여부 확인 (없으면 info + SUCCESS, `--force` 시 무시하고 진행)
- `overwrite` 전략 시 수정된 레이아웃 경고 (`hasModifiedLayouts()`)
- 확인 프롬프트 → 업데이트 실행
- 실패 시 백업 복원 안내
**예시**:
```bash
# 기본 전략(overwrite)으로 업데이트
php artisan template:update sirsoft-admin_basic
# 레이아웃 보존 전략
php artisan template:update sirsoft-admin_basic --layout-strategy=keep
# 강제 재설치
php artisan template:update sirsoft-admin_basic --force
```
---
### template:cache-clear
레이아웃 캐시를 초기화합니다.
```bash
php artisan template:cache-clear
```
**기능**:
- 모든 템플릿 관련 캐시 삭제
- 활성 템플릿 캐시
- 레이아웃 JSON 캐시
- 컴포넌트 레지스트리 캐시
---
### template:build
템플릿의 프론트엔드 에셋을 빌드합니다 (기본: _bundled 디렉토리).
```bash
php artisan template:build [identifier]
```
> **기본 빌드 경로**: `_bundled` 디렉토리. 빌드 결과물은 빌드 경로 내에만 남음.
> 활성 디렉토리 반영은 `template:update` 커맨드로만 수행.
**옵션**:
- `--all`: 모든 템플릿의 에셋 빌드 (_bundled 기준, --active 시 활성 기준)
- `--watch`: 파일 변경 감시 모드 (자동으로 활성 디렉토리 사용)
- `--production`: 프로덕션 최적화 빌드
- `--active`: 활성 디렉토리에서 빌드 (_bundled 대신)
**예시**:
```bash
# _bundled에서 빌드 (기본값)
php artisan template:build sirsoft-admin_basic
# 모든 _bundled 템플릿 빌드
php artisan template:build --all
# 프로덕션 빌드 (_bundled)
php artisan template:build sirsoft-admin_basic --production
# 파일 감시 모드 (활성 디렉토리에서 자동 실행)
php artisan template:build sirsoft-admin_basic --watch
# 활성 디렉토리에서 빌드
php artisan template:build sirsoft-admin_basic --active
# 빌드 후 활성 디렉토리 반영
php artisan template:update sirsoft-admin_basic
```
**수행 작업**:
- 빌드 경로 결정: _bundled 우선 → 활성 폴백 (--active 시 활성만)
- npm 의존성 설치 (node_modules 없는 경우)
- Vite를 통한 컴포넌트 번들 빌드
- `dist/components.iife.js` 생성
- 빌드 결과 파일 크기 출력
- **extension_cache_version 증가 (프론트엔드 캐시 무효화)**
- 주의: `--watch` 모드에서는 캐시 버전이 증가하지 않음
---
## 커맨드 구현 규칙
### 1. 다국어 메시지 필수
```php
// ✅ DO: 다국어 메시지 사용
$this->info(__('templates.commands.install.success', ['template' => $identifier]));
// ❌ DON'T: 하드코딩 메시지
$this->info('템플릿 설치 성공');
```
### 2. 로그 기록 필수
```php
use Illuminate\Support\Facades\Log;
// 성공 로그
Log::info(__('templates.commands.install.success', ['template' => $identifier]));
// 에러 로그
Log::error('템플릿 설치 실패', [
'template' => $identifier,
'error' => $e->getMessage(),
]);
```
### 3. 예외 처리 패턴
```php
public function handle(): int
{
try {
// 커맨드 로직
$this->info('✅ ' . __('templates.commands.install.success', ['template' => $identifier]));
return Command::SUCCESS;
} catch (\Exception $e) {
$this->error('❌ ' . $e->getMessage());
Log::error('템플릿 설치 실패', [
'template' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
```
### 4. 상세 정보 출력
```php
// 템플릿 정보 출력
$this->info(' - ' . __('templates.commands.install.type', ['type' => __('templates.types.' . $template->type)]));
$this->info(' - ' . __('templates.commands.install.version', ['version' => $template->version]));
$this->info(' - ' . __('templates.commands.install.layouts_created', ['count' => $template->layouts()->count()]));
```
---
## 커맨드 테스트
Feature 테스트 작성이 필수입니다.
```php
public function test_can_install_template_via_command(): void
{
// Arrange
$identifier = 'test-template';
// Act
$this->artisan('template:install', ['identifier' => $identifier])
->assertSuccessful();
// Assert
$this->assertDatabaseHas('templates', [
'identifier' => $identifier,
'is_active' => false,
]);
}
```
---
## 문제 해결
### 컴포넌트가 렌더링되지 않음
**원인**: ComponentRegistry에 미등록
**해결**:
1. `components.json`에 컴포넌트 추가
2. `npm run build` 실행
3. 캐시 초기화: `php artisan template:cache-clear`
---
### 레이아웃 JSON 저장 실패
**원인**: 검증 규칙 위반
**해결**:
1. 에러 메시지 확인 (FormRequest 검증)
2. 엔드포인트 화이트리스트 확인
3. 컴포넌트명 오타 확인
4. JSON 구조 검증 (최대 깊이 10)
---
### Vite 빌드 에러
**원인**: 의존성 누락 또는 TypeScript 오류
**해결**:
```bash
# 의존성 재설치
npm install
# TypeScript 체크
npm run type-check
# 빌드 로그 확인
npm run build -- --debug
```
---
## 관련 문서
- [템플릿 기초](template-basics.md) - 템플릿 타입, 메타데이터
- [템플릿 라우트](template-routing.md) - routes.json, 언어 파일
- [템플릿 보안](template-security.md) - API 서빙, 화이트리스트
- [템플릿 캐싱](template-caching.md) - 캐시 계층, 무효화
- [인덱스로 돌아가기](index.md)
+366
View File
@@ -0,0 +1,366 @@
# 템플릿 라우트/언어 파일 규칙
> **관련 문서**: [index.md](index.md) | [template-basics.md](template-basics.md) | [template-security.md](template-security.md)
---
## 목차
1. [라우트 정의 (routes.json)](#라우트-정의-routesjson)
2. [템플릿 언어 파일 경로 규칙](#템플릿-언어-파일-경로-규칙)
3. [TemplateService 구현](#templateservice-구현)
4. [API Controller 구현](#api-controller-구현)
5. [라우트 정의](#라우트-정의)
6. [프론트엔드에서 사용](#프론트엔드에서-사용)
7. [보안 규칙](#보안-규칙)
8. [에러 처리](#에러-처리)
9. [모범 사례](#모범-사례)
10. [template.json과의 관계](#templatejson과의-관계)
---
## 라우트 정의 (routes.json)
**위치**: `/templates/[vendor-template]/routes.json`
### 구조
```json
{
"routes": [
{
"path": "/admin/dashboard",
"layout_name": "admin_dashboard",
"permissions": ["core.dashboard.view"],
"meta": {
"title": "대시보드",
"icon": "dashboard"
}
},
{
"path": "/admin/users",
"layout_name": "admin_users_list",
"permissions": ["core.users.view"],
"meta": {
"title": "사용자 관리",
"icon": "users"
}
}
]
}
```
### 필드 설명
| 필드 | 설명 |
|------|------|
| `path` | 라우트 경로 (Laravel 라우트와 일치) |
| `layout_name` | `template_layouts` 테이블의 레이아웃명 |
| `permissions` | 필요한 권한 배열 (빈 배열 시 인증만 필요) |
| `meta` | 메타 정보 (제목, 아이콘 등) |
---
## 템플릿 언어 파일 경로 규칙
```
중요: 템플릿 언어 파일은 API를 통해 제공
✅ 필수: 경로 트래버설 방지 및 로케일 검증
```
### 핵심 원칙
| 항목 | 규칙 |
|------|------|
| **파일 위치** | `/templates/{vendor-template}/lang/{locale}.json` |
| **로케일 형식** | ISO 639-1 (2자리 소문자, 예: `ko`, `en`, `ja`) |
| **제공 방식** | API 엔드포인트 (`/api/templates/{identifier}/lang/{locale}`) |
| **검증** | supported_locales 확인, 경로 탈출 방지 |
### 언어 파일 구조
```json
// /templates/sirsoft-admin_basic/lang/ko.json
{
"dashboard": {
"title": "대시보드",
"subtitle": "시스템 개요",
"stats": {
"users": "총 사용자 수",
"orders": "주문 수"
}
},
"templates": {
"list": "템플릿 목록",
"install": "템플릿 설치",
"activate": "활성화"
}
}
```
```json
// /templates/sirsoft-admin_basic/lang/en.json
{
"dashboard": {
"title": "Dashboard",
"subtitle": "System Overview",
"stats": {
"users": "Total Users",
"orders": "Orders"
}
},
"templates": {
"list": "Template List",
"install": "Install Template",
"activate": "Activate"
}
}
```
---
## TemplateService 구현
```php
<?php
namespace App\Services\Template;
use Illuminate\Support\Facades\File;
class TemplateService
{
/**
* 템플릿 언어 파일 경로 반환
*
* @param string $identifier 템플릿 식별자
* @param string $locale 로케일 (ko, en 등)
* @return array{success: bool, filePath?: string, error?: string}
*/
public function getLanguageFilePath(string $identifier, string $locale): array
{
// 1. 템플릿 활성화 여부 확인
if (!$this->isTemplateActive($identifier)) {
return ['success' => false, 'error' => 'Template not active'];
}
// 2. 로케일 형식 검증 (ISO 639-1: 2자리 소문자)
if (!preg_match('/^[a-z]{2}$/', $locale)) {
return ['success' => false, 'error' => 'Invalid locale format'];
}
// 3. supported_locales 확인
$supportedLocales = config('app.supported_locales', ['ko', 'en']);
if (!in_array($locale, $supportedLocales)) {
return ['success' => false, 'error' => 'Locale not supported'];
}
// 4. 파일 경로 생성
$basePath = base_path("templates/{$identifier}/lang");
$filePath = "{$basePath}/{$locale}.json";
// 5. 경로 트래버설 방지
$realPath = realpath($filePath);
$realBasePath = realpath($basePath);
if (!$realPath || !str_starts_with($realPath, $realBasePath)) {
return ['success' => false, 'error' => 'Invalid file path'];
}
// 6. 파일 존재 확인
if (!File::exists($realPath) || !is_file($realPath)) {
return ['success' => false, 'error' => 'Language file not found'];
}
return ['success' => true, 'filePath' => $realPath];
}
/**
* 템플릿 언어 데이터 로드
*
* @param string $identifier 템플릿 식별자
* @param string $locale 로케일
* @return array
*/
public function getLanguageData(string $identifier, string $locale): array
{
$result = $this->getLanguageFilePath($identifier, $locale);
if (!$result['success']) {
return [];
}
$content = File::get($result['filePath']);
return json_decode($content, true) ?? [];
}
}
```
---
## API Controller 구현
```php
<?php
namespace App\Http\Controllers\Api\Public;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Services\Template\TemplateService;
use Illuminate\Http\JsonResponse;
class TemplateLanguageController extends PublicBaseController
{
public function __construct(
private TemplateService $templateService
) {}
/**
* 템플릿 언어 파일 제공
*
* @param string $identifier 템플릿 식별자
* @param string $locale 로케일 (ko, en 등)
* @return JsonResponse
*/
public function getLanguageFile(string $identifier, string $locale): JsonResponse
{
$data = $this->templateService->getLanguageData($identifier, $locale);
if (empty($data)) {
return $this->notFound('messages.template.language_file_not_found');
}
return response()->json($data, 200, [
'Content-Type' => 'application/json',
'Cache-Control' => 'public, max-age=3600',
'Access-Control-Allow-Origin' => '*',
]);
}
}
```
---
## 라우트 정의
```php
// routes/api.php
Route::get('/templates/{identifier}/lang/{locale}', [TemplateLanguageController::class, 'getLanguageFile'])
->where('locale', '[a-z]{2}')
->name('api.templates.language');
```
**주요 포인트**:
- `where('locale', '[a-z]{2}')`: ISO 639-1 형식만 허용
- 라우트 레벨에서 잘못된 형식 차단
---
## 프론트엔드에서 사용
### React 컴포넌트
```javascript
// React 컴포넌트에서 언어 파일 로드
const loadLanguageFile = async (identifier, locale) => {
const response = await fetch(`/api/templates/${identifier}/lang/${locale}`);
if (!response.ok) {
console.error('Failed to load language file');
return {};
}
return await response.json();
};
// 사용 예시
const translations = await loadLanguageFile('sirsoft-admin_basic', 'ko');
console.log(translations.dashboard.title); // "대시보드"
```
### Blade 템플릿
```php
<!-- resources/views/app.blade.php -->
<script>
window.TEMPLATE_LANG_URL = "{{ route('api.templates.language', [
'identifier' => $activeTemplate->identifier,
'locale' => app()->getLocale()
]) }}";
</script>
```
---
## 보안 규칙
### 허용 (✅)
- 활성화된 템플릿만 접근 가능
- ISO 639-1 형식 검증 (정규표현식: `/^[a-z]{2}$/`)
- supported_locales 화이트리스트 확인
- 경로 트래버설 방지 (`realpath()`, `str_starts_with()`)
- 파일 확장자 검증 (`.json`만 허용)
- 적절한 캐싱 헤더 (`Cache-Control: max-age=3600`)
### 금지 (❌)
- 비활성 템플릿 언어 파일 접근 금지
- 임의의 파일 경로 접근 금지
- supported_locales 외 로케일 접근 금지
---
## 에러 처리
| 요청 | 응답 | 사유 |
|------|------|------|
| `GET /api/templates/sirsoft-admin_basic/lang/ko-KR` | 404 Not Found | 라우트 패턴 불일치 |
| `GET /api/templates/sirsoft-admin_basic/lang/ja` | 404 Not Found | supported_locales에 없음 |
| `GET /api/templates/inactive-template/lang/ko` | 404 Not Found | 템플릿 미활성화 |
| `GET /api/templates/sirsoft-admin_basic/lang/fr` | 404 Not Found | 파일 존재하지 않음 |
---
## 모범 사례
### 권장 사항 (✅)
- 모든 템플릿은 최소 `ko.json`, `en.json` 필수 제공
- JSON 파일은 UTF-8 인코딩 사용
- 키 구조는 계층적으로 구성 (`dashboard.stats.users`)
- API를 통한 동적 로딩으로 초기 로딩 시간 최적화
- 브라우저 캐싱 활용 (max-age=3600)
### 금지 사항 (❌)
- 언어 파일에 HTML 포함 금지 (XSS 방지)
- 하드코딩된 텍스트 사용 금지 (모든 텍스트는 언어 파일에서)
---
## template.json과의 관계
```json
// template.json
{
"identifier": "sirsoft-admin_basic",
"locales": ["ko", "en"], // 이 배열에 포함된 언어만 제공
// ...
}
```
### 언어 결정 규칙
- `template.json`의 `locales` 배열이 제공 가능한 언어 명세
- `config('app.supported_locales')`와 교집합이 실제 제공 언어
- 예: `template.json`에 `["ko", "en", "ja"]`, `supported_locales`에 `["ko", "en"]`이면 `ko`, `en`만 제공
---
## 관련 문서
- [index.md](index.md) - 확장 시스템 가이드 인덱스
- [template-basics.md](template-basics.md) - 템플릿 기초
- [template-security.md](template-security.md) - 템플릿 보안 정책
- [template-caching.md](template-caching.md) - 템플릿 캐싱 전략
+390
View File
@@ -0,0 +1,390 @@
# 템플릿 보안 정책
> **목적**: 템플릿 시스템의 보안 정책 및 API 서빙 규칙 가이드
---
## 목차
1. [레이아웃 JSON 검증](#1-레이아웃-json-검증)
2. [의존성 검증](#2-의존성-검증)
3. [XSS 방지](#3-xss-방지)
4. [템플릿 API 서빙 규칙](#4-템플릿-api-서빙-규칙)
5. [파일 확장자 화이트리스트](#5-파일-확장자-화이트리스트)
---
## 1. 레이아웃 JSON 검증
### 검증 계층
```
FormRequest (StoreLayoutRequest)
↓
Custom Rule: ValidLayoutStructure
↓
Custom Rule: WhitelistedEndpoint
↓
Custom Rule: NoExternalUrls
↓
Custom Rule: ComponentExists
```
### 검증 항목
| 항목 | 설명 |
|------|------|
| JSON 구조 유효성 | 올바른 JSON 형식인지 검증 |
| 최대 중첩 깊이 | 10단계 제한 |
| 엔드포인트 화이트리스트 | `/api/(admin\|auth\|public)/` 패턴만 허용 |
| 외부 URL 금지 | 외부 도메인 URL 차단 |
| 컴포넌트 존재 여부 | components.json 기준으로 검증 |
---
## 2. 의존성 검증
### 템플릿 설치 시 체크
- 필수 의존성 템플릿 설치 여부
- 순환 의존성 방지
- 버전 호환성 (Semantic Versioning)
### 예시
```json
// template.json
{
"dependencies": {
"sirsoft-component-library": "^1.0.0"
}
}
```
---
## 3. XSS 방지
### 데이터 바인딩
- 모든 `{{data.field}}` 값은 자동 이스케이프
- `dangerouslySetInnerHTML` 사용 금지
- HTML 삽입 시 서버 검증 필수
### Translation
- `$t:key` 값은 다국어 파일에서만 로드
- 사용자 입력 키 금지
---
## 4. 템플릿 API 서빙 규칙
```
중요: 템플릿 에셋은 API를 통해 제공
✅ 필수: 파일 복사 대신 API 엔드포인트 사용
```
### 원칙
- 템플릿 설치 시 빌드된 파일(`/templates/[vendor-template]/dist/`)은 그대로 유지
- `/public/build/templates/` 디렉토리에 파일 복사하지 않음
- API 엔드포인트를 통해 동적으로 템플릿 에셋 제공
- 보안: 활성화된 템플릿의 에셋만 접근 가능
### API 엔드포인트
```php
// routes/api.php
Route::get('/templates/{identifier}/assets/{file}', [TemplateAssetController::class, 'serve'])
->where('file', '.*')
->name('api.templates.assets');
```
### Controller 구현 (TemplateAssetController)
```php
<?php
namespace App\Http\Controllers\Api\Public;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Services\Template\TemplateService;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class TemplateAssetController extends PublicBaseController
{
public function __construct(
private TemplateService $templateService
) {}
/**
* 템플릿 에셋 파일 제공
*
* @param string $identifier 템플릿 식별자
* @param string $file 파일 경로 (예: dist/components.js)
* @return BinaryFileResponse|Response
*/
public function serve(string $identifier, string $file): BinaryFileResponse|Response
{
// 1. 템플릿 활성화 여부 확인
if (!$this->templateService->isTemplateActive($identifier)) {
return $this->notFound('messages.template.not_active');
}
// 2. 파일 경로 검증 (보안)
$allowedExtensions = ['js', 'js.map', 'css', 'css.map', 'woff', 'woff2', 'ttf', 'eot', 'svg', 'png', 'jpg', 'jpeg', 'gif'];
$extension = pathinfo($file, PATHINFO_EXTENSION);
if (!in_array($extension, $allowedExtensions)) {
return $this->forbidden('messages.template.invalid_file_type');
}
// 3. 실제 파일 경로 생성
$basePath = base_path("templates/{$identifier}");
$filePath = realpath("{$basePath}/{$file}");
// 4. 디렉토리 탈출 방지
if (!$filePath || !str_starts_with($filePath, $basePath)) {
return $this->forbidden('messages.template.invalid_path');
}
// 5. 파일 존재 확인
if (!file_exists($filePath) || !is_file($filePath)) {
return $this->notFound('messages.template.file_not_found');
}
// 6. MIME 타입 설정
$mimeTypes = [
'js' => 'application/javascript',
'css' => 'text/css',
'map' => 'application/json',
'woff' => 'font/woff',
'woff2' => 'font/woff2',
'ttf' => 'font/ttf',
'eot' => 'application/vnd.ms-fontobject',
'svg' => 'image/svg+xml',
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
];
$mimeType = $mimeTypes[$extension] ?? 'application/octet-stream';
// 7. 캐싱 헤더 추가
return response()->file($filePath, [
'Content-Type' => $mimeType,
'Cache-Control' => 'public, max-age=31536000, immutable',
'Access-Control-Allow-Origin' => '*',
]);
}
}
```
### Service 메서드
```php
<?php
namespace App\Services\Template;
class TemplateService
{
/**
* 템플릿 활성화 여부 확인
*/
public function isTemplateActive(string $identifier): bool
{
return Cache::remember(
"template.active.{$identifier}",
3600,
fn() => Template::where('identifier', $identifier)
->where('is_active', true)
->exists()
);
}
}
```
### Blade 사용 예시
```php
<!-- resources/views/app.blade.php -->
<!DOCTYPE html>
<html>
<head>
<!-- 활성 템플릿 컴포넌트 로드 -->
@if($activeTemplate)
<script src="{{ route('api.templates.assets', [
'identifier' => $activeTemplate->identifier,
'file' => 'dist/components.js'
]) }}" defer></script>
@endif
</head>
<body>
<!-- 앱 컨텐츠 -->
</body>
</html>
```
### 보안 규칙
| 규칙 | 설명 |
|------|------|
| ✅ 활성화된 템플릿만 접근 가능 | 비활성 템플릿 에셋 접근 차단 |
| ✅ 화이트리스트 기반 확장자 검증 | 허용된 확장자만 제공 |
| ✅ 디렉토리 탈출 공격 방지 | `realpath()`, `str_starts_with()` 사용 |
| ✅ MIME 타입 명시적 설정 | Content-Type 헤더 지정 |
| ✅ 적절한 캐싱 헤더 | `Cache-Control`, `immutable` |
| ❌ 비활성 템플릿 에셋 접근 금지 | 활성화 여부 검증 필수 |
| ❌ 임의의 파일 경로 접근 금지 | 경로 검증 필수 |
### 장점
1. **보안 강화**: 활성화된 템플릿만 접근 가능
2. **디스크 공간 절약**: 파일 복사 불필요
3. **동적 제어**: 템플릿 비활성화 시 즉시 접근 차단
4. **캐싱 최적화**: HTTP 캐싱으로 성능 유지
5. **유지보수 용이**: 단일 소스(dist 디렉토리)만 관리
---
## 5. 파일 확장자 화이트리스트
```
중요: 템플릿 에셋 파일 확장자 제한
✅ 필수: 화이트리스트 기반 확장자 검증
```
### 허용 확장자 목록
#### 스크립트 파일
| 확장자 | 설명 |
|--------|------|
| `js` | JavaScript |
| `mjs` | ES Module JavaScript |
| `js.map` | JavaScript 소스맵 |
#### 스타일 파일
| 확장자 | 설명 |
|--------|------|
| `css` | Cascading Style Sheets |
| `css.map` | CSS 소스맵 |
#### 폰트 파일
| 확장자 | 설명 |
|--------|------|
| `woff` | Web Open Font Format |
| `woff2` | Web Open Font Format 2 |
| `ttf` | TrueType Font |
| `otf` | OpenType Font |
| `eot` | Embedded OpenType |
#### 이미지 파일
| 확장자 | 설명 |
|--------|------|
| `png` | Portable Network Graphics |
| `jpg` | JPEG Image |
| `jpeg` | JPEG Image |
| `svg` | Scalable Vector Graphics |
| `webp` | WebP Image |
| `gif` | Graphics Interchange Format |
#### 데이터 파일
| 확장자 | 설명 |
|--------|------|
| `json` | JSON 데이터 (components.json, template.json 등) |
### TemplateService 구현
```php
// app/Services/TemplateService.php
/**
* 허용된 파일 확장자 화이트리스트
*/
private const ALLOWED_EXTENSIONS = [
'js', 'mjs', 'css', 'json',
'png', 'jpg', 'jpeg', 'svg', 'webp', 'gif',
'woff', 'woff2', 'ttf', 'otf', 'eot',
];
/**
* 파일 확장자 검증
*/
private function validateFileExtension(string $filePath): bool
{
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
// .js.map, .css.map 처리
if (str_ends_with($filePath, '.map')) {
$extension = 'map';
}
return in_array($extension, self::ALLOWED_EXTENSIONS);
}
/**
* MIME 타입 매핑
*/
private function getMimeType(string $extension): string
{
$mimeTypes = [
'js' => 'application/javascript',
'mjs' => 'application/javascript',
'css' => 'text/css',
'json' => 'application/json',
'map' => 'application/json',
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'svg' => 'image/svg+xml',
'webp' => 'image/webp',
'gif' => 'image/gif',
'woff' => 'font/woff',
'woff2' => 'font/woff2',
'ttf' => 'font/ttf',
'otf' => 'font/otf',
'eot' => 'application/vnd.ms-fontobject',
];
return $mimeTypes[$extension] ?? 'application/octet-stream';
}
```
### 보안 규칙
| 규칙 | 설명 |
|------|------|
| ✅ 화이트리스트에 명시된 확장자만 허용 | 목록 외 차단 |
| ✅ 대소문자 구분 없이 검증 | `strtolower()` 사용 |
| ✅ 소스맵 파일 특별 처리 | `.js.map`, `.css.map` |
| 실행 파일 확장자 금지 | `.php`, `.sh`, `.exe` 등 |
| ❌ 서버 설정 파일 금지 | `.htaccess`, `.env` 등 |
| ❌ 화이트리스트 외 모든 확장자 차단 | 기본 거부 정책 |
### 금지 확장자 예시 (절대 추가 금지)
| 카테고리 | 확장자 |
|----------|--------|
| PHP 실행 파일 | `.php`, `.phar` |
| 실행 스크립트 | `.sh`, `.bat`, `.exe` |
| 설정 파일 | `.env`, `.htaccess`, `.conf` |
| 데이터베이스 파일 | `.sql`, `.db` |
| 압축 파일 (보안 위험) | `.zip`, `.tar`, `.gz` |
---
## 관련 문서
- [template-basics.md](template-basics.md) - 템플릿 기초
- [template-routing.md](template-routing.md) - 템플릿 라우트/언어 파일 규칙
- [template-caching.md](template-caching.md) - 템플릿 캐싱 전략
- [template-commands.md](template-commands.md) - 템플릿 Artisan 커맨드
+685
View File
@@ -0,0 +1,685 @@
# 템플릿 개발 워크플로우
> **위치**: `docs/extension/template-workflow.md`
> **관련 문서**: [template-basics.md](./template-basics.md) | [template-commands.md](./template-commands.md) | [template-development.md](../frontend/template-development.md)
---
## TL;DR (5초 요약)
```text
1. 필수 파일: template.json, routes.json, _base.json, errors/{404,403,500}.json
2. 빌드 설정: package.json, vite.config.ts (IIFE 빌드, terser 대신 esbuild)
3. 검증 순서: JSON 파싱 → layout_name 필수 → error_config → 에러 레이아웃 파일 존재
4. 컴포넌트: 템플릿에 실제 존재하는 것만 import (없는 컴포넌트 import 시 빌드 실패)
5. 설치: php artisan template:build → php artisan template:install → php artisan template:activate
```
---
## 목차
1. [개요](#개요)
2. [템플릿 생성 체크리스트](#템플릿-생성-체크리스트)
3. [Phase 1: 필수 메타데이터 파일](#phase-1-필수-메타데이터-파일)
4. [Phase 2: 레이아웃 파일](#phase-2-레이아웃-파일)
5. [Phase 3: 빌드 설정](#phase-3-빌드-설정)
6. [Phase 4: 컴포넌트 설정](#phase-4-컴포넌트-설정)
7. [Phase 5: 다국어 파일](#phase-5-다국어-파일)
8. [Phase 6: 빌드 및 설치](#phase-6-빌드-및-설치)
9. [검증 로직 이해](#검증-로직-이해)
10. [자주 발생하는 오류와 해결](#자주-발생하는-오류와-해결)
11. [템플릿 개발 Artisan 명령어](#템플릿-개발-artisan-명령어)
---
## 개요
이 문서는 그누보드7 템플릿을 처음부터 만들 때 필요한 **실전 워크플로우**를 정리합니다.
실제 `sirsoft-user_sample` 템플릿 개발 과정에서 겪은 시행착오를 바탕으로 작성되었습니다.
---
## 템플릿 생성 체크리스트
새 템플릿 생성 시 아래 체크리스트를 순서대로 완료합니다:
```
Phase 1: 필수 메타데이터
□ template.json 생성 (error_config 포함)
□ routes.json 생성 (layout_name 필수)
Phase 2: 레이아웃 파일
□ layouts/_user_base.json 또는 layouts/_admin_base.json 생성
□ layouts/home.json (또는 dashboard.json) 생성
□ layouts/errors/404.json 생성
□ layouts/errors/403.json 생성
□ layouts/errors/500.json 생성
Phase 3: 빌드 설정
□ package.json 생성 (build 스크립트 포함)
□ vite.config.ts 생성 (IIFE 빌드, esbuild minify)
□ tsconfig.json 생성
□ tsconfig.node.json 생성
□ tailwind.config.js 생성
□ postcss.config.js 생성
Phase 4: 컴포넌트 설정
□ src/index.ts 생성 (실제 존재하는 컴포넌트만 import)
□ src/handlers/index.ts 생성
□ src/styles/main.css 생성
Phase 5: 다국어 파일
□ lang/ko.json 생성 (errors 섹션 포함)
□ lang/en.json 생성 (errors 섹션 포함)
Phase 6: 빌드 및 설치
□ php artisan template:build [identifier]
□ php artisan template:install [identifier]
□ php artisan template:activate [identifier]
```
---
## Phase 1: 필수 메타데이터 파일
### 1.1 template.json
```json
{
"identifier": "vendor-template_name",
"vendor": "vendor",
"name": {
"ko": "템플릿 한국어 이름",
"en": "Template English Name"
},
"version": "1.0.0",
"description": {
"ko": "템플릿 설명 (한국어)",
"en": "Template description (English)"
},
"type": "user",
"locales": ["ko", "en"],
"author": {
"name": "vendor",
"email": "contact@vendor.com"
},
"g7_version": ">=1.0.0",
"dependencies": {
"modules": {},
"plugins": {}
},
"assets": {
"css": ["assets/css/main.css"],
"js": []
},
"components": {
"basic": ["button", "div", "span", "h1", "h2", "p", "icon"],
"composite": ["header", "footer", "card"],
"layout": ["container", "flex", "grid"]
},
"error_config": {
"layouts": {
"404": "404",
"403": "403",
"500": "500"
}
}
}
```
**필수 항목**:
- `identifier`: 고유 식별자 (vendor-name 형식)
- `type`: "admin" 또는 "user"
- `locales`: 지원 언어 배열
- `error_config.layouts`: 404, 403, 500 매핑
### 1.2 routes.json
```
주의: layout_name 필드가 없으면 설치 실패!
```
```json
{
"version": "1.0.0",
"layout_name": "routes",
"meta": {
"title": "Routes",
"description": "Route definitions for template"
},
"data_sources": [],
"components": [],
"routes": [
{
"path": "/",
"layout": "home",
"auth": false
}
]
}
```
**필수 필드**:
- `layout_name`: DB 저장용 레이아웃 이름 (필수!)
- `routes`: 라우트 정의 배열
---
## Phase 2: 레이아웃 파일
### 2.1 디렉토리 구조
```
templates/_bundled/vendor-template/
└── layouts/
├── _user_base.json # 베이스 레이아웃 (user 템플릿)
├── _admin_base.json # 베이스 레이아웃 (admin 템플릿)
├── home.json # 홈 레이아웃
├── routes.json # 라우트 정의
├── errors/ # 필수 디렉토리
│ ├── 404.json # 필수
│ ├── 403.json # 필수
│ └── 500.json # 필수
└── partials/ # partial 파일 (DB 저장 안됨)
```
### 2.2 베이스 레이아웃 (_user_base.json)
```json
{
"version": "1.0.0",
"layout_name": "_user_base",
"meta": {
"is_base": true
},
"data_sources": [],
"slots": {
"header": [],
"content": [],
"footer": []
}
}
```
### 2.3 에러 레이아웃
**404.json 예시**:
```json
{
"version": "1.0.0",
"layout_name": "404",
"extends": "_user_base",
"meta": {
"title": "$t:errors.404.title",
"is_error_layout": true,
"error_code": 404
},
"data_sources": [],
"slots": {
"content": [
{
"id": "error_container",
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-col items-center justify-center min-h-[60vh] text-center p-8"
},
"children": [
{
"id": "error_code",
"type": "basic",
"name": "H1",
"props": {
"className": "text-6xl font-bold text-gray-900 dark:text-white mb-4"
},
"text": "404"
},
{
"id": "error_title",
"type": "basic",
"name": "H2",
"props": {
"className": "text-2xl font-semibold text-gray-700 dark:text-gray-300 mb-2"
},
"text": "$t:errors.404.title"
},
{
"id": "error_message",
"type": "basic",
"name": "P",
"props": {
"className": "text-gray-500 dark:text-gray-400 mb-8"
},
"text": "$t:errors.404.message"
},
{
"id": "back_button",
"type": "basic",
"name": "Button",
"props": {
"className": "px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"
},
"text": "$t:errors.back_home",
"actions": [
{
"type": "click",
"handler": "navigate",
"params": { "path": "/" }
}
]
}
]
}
]
}
}
```
**403.json, 500.json**도 동일한 구조로 생성 (error_code와 아이콘만 다름)
---
## Phase 3: 빌드 설정
### 3.1 package.json
```json
{
"name": "vendor-template",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite build --watch"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.14",
"postcss": "^8.4.24",
"tailwindcss": "^3.3.2",
"typescript": "^5.0.0",
"vite": "^5.0.0"
}
}
```
### 3.2 vite.config.ts
```
주의: minify는 'esbuild' 사용 (terser는 별도 설치 필요)
필수: process.env.NODE_ENV 정의 (외부 라이브러리 호환성)
```
```typescript
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
},
build: {
lib: {
entry: path.resolve(__dirname, 'src/index.ts'),
name: 'VendorTemplate',
formats: ['iife'],
fileName: () => 'components.iife.js',
},
outDir: 'dist',
emptyDirBeforeWrite: true,
minify: 'esbuild', // terser 대신 esbuild 사용!
rollupOptions: {
external: ['react', 'react-dom'],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM',
},
},
},
},
});
```
### 3.3 tsconfig.json
```json
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
```
### 3.4 tailwind.config.js
```javascript
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{js,ts,jsx,tsx}', './layouts/**/*.json'],
darkMode: 'class',
theme: { extend: {} },
plugins: [],
};
```
### 3.5 postcss.config.js
```javascript
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
```
---
## Phase 4: 컴포넌트 설정
### 4.1 src/index.ts
```
필수: 실제 존재하는 컴포넌트만 import!
존재하지 않는 컴포넌트를 import하면 빌드 실패
```
```typescript
// 실제 존재하는 컴포넌트만 import
import * as basicComponents from './components/basic';
import * as compositeComponents from './components/composite';
import * as layoutComponents from './components/layout';
import { handlerMap } from './handlers';
// 전역 등록
declare global {
interface Window {
G7Core: {
registerComponents: (components: Record<string, unknown>) => void;
registerHandlers: (handlers: Record<string, unknown>) => void;
};
}
}
// 컴포넌트 등록
const allComponents = {
...basicComponents,
...compositeComponents,
...layoutComponents,
};
if (window.G7Core) {
window.G7Core.registerComponents(allComponents);
window.G7Core.registerHandlers(handlerMap);
}
export { allComponents, handlerMap };
```
### 4.2 src/handlers/index.ts
```typescript
export const handlerMap = {
// 커스텀 핸들러 등록
} as const;
export type HandlerName = keyof typeof handlerMap;
```
### 4.3 src/styles/main.css
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
```
---
## Phase 5: 다국어 파일
### 5.1 lang/ko.json
```
필수: errors 섹션 필수! (에러 레이아웃에서 참조)
```
```json
{
"site": {
"title": "사이트 제목"
},
"errors": {
"404": {
"title": "페이지를 찾을 수 없습니다",
"message": "요청하신 페이지가 존재하지 않거나 이동되었습니다."
},
"403": {
"title": "접근이 거부되었습니다",
"message": "이 페이지에 접근할 권한이 없습니다."
},
"500": {
"title": "서버 오류",
"message": "서버에 문제가 발생했습니다. 잠시 후 다시 시도해 주세요."
},
"back_home": "홈으로 돌아가기"
}
}
```
### 5.2 lang/en.json
```json
{
"site": {
"title": "Site Title"
},
"errors": {
"404": {
"title": "Page Not Found",
"message": "The page you requested does not exist or has been moved."
},
"403": {
"title": "Access Denied",
"message": "You do not have permission to access this page."
},
"500": {
"title": "Server Error",
"message": "Something went wrong on our end. Please try again later."
},
"back_home": "Back to Home"
}
}
```
---
## Phase 6: 빌드 및 설치
### 6.1 빌드
```bash
php artisan template:build vendor-template
```
### 6.2 설치
```bash
php artisan template:install vendor-template
```
### 6.3 활성화
```bash
php artisan template:activate vendor-template
```
---
## 검증 로직 이해
### 설치 시 자동 검증 순서
1. **JSON 파싱 검증**: 모든 레이아웃 파일의 JSON 유효성
2. **layout_name 필수**: 템플릿 레이아웃은 반드시 `layout_name` 필드 필요
3. **error_config 검증**: `template.json`에 `error_config.layouts` 섹션 필수
4. **에러 레이아웃 검증**: 404, 403, 500 레이아웃 파일 존재 확인
5. **partial 병합 검증**: `partial` 참조 파일 존재 및 순환 참조 확인
### ValidatesLayoutFiles 트레이트
```php
// 스캔 대상 디렉토리
layouts/*.json // ✅ 스캔 (DB 저장)
layouts/errors/*.json // ✅ 스캔 (DB 저장)
layouts/partials/*.json // ❌ 스캔 안 함 (extends로만 참조)
```
### TemplateManager::validateErrorLayouts()
```php
// 필수 에러 코드
$requiredErrorCodes = [404, 403, 500];
// 검증 항목
1. error_config.layouts 섹션 존재
2. 404, 403, 500 키 존재
3. layouts/errors/{code}.json 파일 존재
```
---
## 자주 발생하는 오류와 해결
### 오류 1: layout_name 필드 누락
```
❌ 오류: layout_name 필드가 누락되었습니다
```
**원인**: routes.json 또는 레이아웃 파일에 `layout_name` 필드 없음
**해결**:
```json
{
"layout_name": "routes", // 추가
// ... 나머지 내용
}
```
### 오류 2: 에러 레이아웃 파일 없음
```
❌ 오류: 404 에러 레이아웃 파일을 찾을 수 없습니다
```
**원인**: `layouts/errors/404.json` 파일 없음
**해결**:
1. `layouts/errors/` 디렉토리 생성
2. `404.json`, `403.json`, `500.json` 파일 생성
### 오류 3: terser not found
```
❌ 오류: Cannot find module 'terser'
```
**원인**: vite.config.ts에서 `minify: 'terser'` 설정했지만 terser 미설치
**해결**:
```typescript
// vite.config.ts
build: {
minify: 'esbuild', // terser 대신 esbuild 사용
}
```
### 오류 4: 컴포넌트 import 실패
```
❌ 오류: Module not found: './components/basic/H5'
```
**원인**: 존재하지 않는 컴포넌트를 import
**해결**: 실제 존재하는 컴포넌트만 import
```typescript
// ❌ 잘못된 예
import { H5, H6, Box } from './components/basic'; // H5, H6, Box가 없으면 에러
// ✅ 올바른 예
import { H1, H2, H3, Div, Span } from './components/basic'; // 실제 존재하는 것만
```
### 오류 5: error_config 누락
```
❌ 오류: 에러 페이지 설정(error_config)이 누락되었습니다
```
**원인**: `template.json`에 `error_config` 섹션 없음
**해결**:
```json
{
"error_config": {
"layouts": {
"404": "404",
"403": "403",
"500": "500"
}
}
}
```
---
## 템플릿 개발 Artisan 명령어
| 명령어 | 설명 |
|--------|------|
| `php artisan template:list` | 템플릿 목록 조회 |
| `php artisan template:build [id]` | 템플릿 빌드 |
| `php artisan template:build --all` | 모든 템플릿 빌드 |
| `php artisan template:install [id]` | 템플릿 설치 |
| `php artisan template:activate [id]` | 템플릿 활성화 |
| `php artisan template:deactivate [id]` | 템플릿 비활성화 |
| `php artisan template:uninstall [id]` | 템플릿 삭제 |
| `php artisan template:refresh-layout [id]` | 레이아웃 갱신 (빌드 없이) |
| `php artisan template:cache-clear` | 템플릿 캐시 초기화 |
---
## 관련 문서
- [템플릿 기초](./template-basics.md) - 템플릿 타입, 메타데이터
- [템플릿 커맨드](./template-commands.md) - Artisan 커맨드 상세
- [템플릿 개발 가이드](../frontend/template-development.md) - 컴포넌트 개발
- [레이아웃 JSON 스키마](../frontend/layout-json.md) - 레이아웃 작성법
- [컴포넌트 개발 규칙](../frontend/components.md) - 컴포넌트 작성법
+168
View File
@@ -0,0 +1,168 @@
# 그누보드7 프론트엔드 개발 가이드
> 이 문서는 그누보드7의 프론트엔드 및 템플릿 시스템 개발 규칙의 인덱스입니다.
---
## 핵심 원칙
```
필수: 기본 컴포넌트 사용 (HTML 태그 직접 사용 금지)
필수: 기본 컴포넌트만 사용 (Div, Button, H2 등)
✅ 필수: 집합 컴포넌트 재사용 우선
✅ 필수: 다크 모드 light/dark variant 함께 지정
```
---
## 템플릿 시스템 개요
그누보드7 템플릿 엔진은 JSON 기반 레이아웃 정의를 통해 화면을 동적으로 생성하는 시스템입니다.
**핵심 특징**:
- JSON 기반 레이아웃 정의 (코드 수정 없이 UI 변경)
- 컴포넌트 기반 아키텍처 (재사용성)
- 동적 데이터 바인딩 (API 자동 연결)
- 자동 발견 메커니즘 (`/templates` 디렉토리 스캔)
- 코어 렌더링 엔진과 템플릿 컴포넌트 분리
**템플릿 타입**:
| 타입 | 레이아웃 편집 | 버전 히스토리 | 용도 |
|------|-------------|--------------|------|
| admin | ❌ 불가 | ❌ 미사용 | 관리자 인터페이스 |
| user | ✅ 가능 | ✅ 자동 생성 | 사용자 웹사이트 |
---
<!-- AUTO-GENERATED-START: frontend-readme-docs -->
### 템플릿별 레퍼런스
| 템플릿 식별자 | 컴포넌트 | 핸들러 | 레이아웃 |
|--------------|---------|--------|--------|
| `sirsoft-admin_basic` | [components.md](templates/sirsoft-admin_basic/components.md) | [handlers.md](templates/sirsoft-admin_basic/handlers.md) | [layouts.md](templates/sirsoft-admin_basic/layouts.md) |
| `sirsoft-basic` | [components.md](templates/sirsoft-basic/components.md) | [handlers.md](templates/sirsoft-basic/handlers.md) | [layouts.md](templates/sirsoft-basic/layouts.md) |
### 컴포넌트 개발
| 문서 | 설명 |
|------|------|
| [component-props-composite.md](component-props-composite.md) | 컴포넌트 Props 레퍼런스 - Composite |
| [component-props.md](component-props.md) | 컴포넌트 Props 레퍼런스 |
| [components-advanced.md](components-advanced.md) | 컴포넌트 고급 기능 |
| [components-patterns.md](components-patterns.md) | 컴포넌트 패턴 및 다국어 |
| [components-types.md](components-types.md) | 컴포넌트 타입별 개발 규칙 |
| [components.md](components.md) | 컴포넌트 개발 규칙 |
| [layout-json-components-loading.md](layout-json-components-loading.md) | 레이아웃 JSON - 데이터 로딩 및 생명주기 |
| [layout-json-components-rendering.md](layout-json-components-rendering.md) | 레이아웃 JSON - 조건부/반복 렌더링 |
| [layout-json-components-slots.md](layout-json-components-slots.md) | 레이아웃 JSON - 슬롯 시스템 |
| [layout-json-components.md](layout-json-components.md) | 레이아웃 JSON - 컴포넌트 (반복 렌더링, Blur, 생명주기, 슬롯) |
### 레이아웃 및 데이터
| 문서 | 설명 |
|------|------|
| [actions-g7core-api.md](actions-g7core-api.md) | 액션 시스템 - G7Core API (React 컴포넌트용) |
| [actions-handlers-navigation.md](actions-handlers-navigation.md) | 액션 핸들러 - 네비게이션 |
| [actions-handlers-state.md](actions-handlers-state.md) | 액션 핸들러 - 상태 관리 |
| [actions-handlers-ui.md](actions-handlers-ui.md) | 액션 핸들러 - UI 인터랙션 |
| [actions-handlers.md](actions-handlers.md) | 액션 핸들러 - 핸들러별 상세 사용법 |
| [actions.md](actions.md) | 액션 핸들러 가이드 |
| [data-binding-i18n.md](data-binding-i18n.md) | 데이터 바인딩 - 다국어 처리 |
| [data-binding.md](data-binding.md) | 데이터 바인딩 및 표현식 |
| [data-sources-advanced.md](data-sources-advanced.md) | 데이터 소스 - 고급 기능 |
| [data-sources.md](data-sources.md) | 데이터 소스 (Data Sources) |
| [g7core-api-advanced.md](g7core-api-advanced.md) | G7Core 전역 API 레퍼런스 - 고급 |
| [g7core-api.md](g7core-api.md) | G7Core 전역 API 레퍼런스 |
| [g7core-helpers.md](g7core-helpers.md) | G7Core 헬퍼 API |
| [layout-json-components-loading.md](layout-json-components-loading.md) | 레이아웃 JSON - 데이터 로딩 및 생명주기 |
| [layout-json-components-rendering.md](layout-json-components-rendering.md) | 레이아웃 JSON - 조건부/반복 렌더링 |
| [layout-json-components-slots.md](layout-json-components-slots.md) | 레이아웃 JSON - 슬롯 시스템 |
| [layout-json-components.md](layout-json-components.md) | 레이아웃 JSON - 컴포넌트 (반복 렌더링, Blur, 생명주기, 슬롯) |
| [layout-json-features-actions.md](layout-json-features-actions.md) | 레이아웃 JSON - 초기화, 모달, 액션, 스크립트 |
| [layout-json-features-error.md](layout-json-features-error.md) | 레이아웃 JSON - 에러 핸들링 |
| [layout-json-features-styling.md](layout-json-features-styling.md) | 레이아웃 JSON - 스타일 및 계산된 값 |
| [layout-json-features.md](layout-json-features.md) | 레이아웃 JSON - 기능 (에러 핸들링, 초기화, 모달, 액션) |
| [layout-json-inheritance.md](layout-json-inheritance.md) | 레이아웃 JSON - 상속 (Extends, Partial, 병합) |
| [layout-json.md](layout-json.md) | 레이아웃 JSON 스키마 |
| [state-management-advanced.md](state-management-advanced.md) | 상태 관리 - 고급 기능 |
| [state-management-forms.md](state-management-forms.md) | 상태 관리 - 폼 자동 바인딩 및 setState |
| [state-management.md](state-management.md) | 전역 상태 관리 |
### 스타일링 및 UI
| 문서 | 설명 |
|------|------|
| [dark-mode.md](dark-mode.md) | 다크 모드 지원 (engine-v1.1.0+) |
| [responsive-layout.md](responsive-layout.md) | 반응형 레이아웃 개발 (engine-v1.1.0+) |
| [tailwind-safelist.md](tailwind-safelist.md) | Tailwind Safelist 가이드 |
### 템플릿 개발
| 문서 | 설명 |
|------|------|
| [editors.md](editors.md) | 에디터 컴포넌트 가이드 |
| [template-development.md](template-development.md) | 템플릿 개발 가이드라인 |
| [template-handlers.md](template-handlers.md) | 템플릿 전용 핸들러 |
### 테스트
| 문서 | 설명 |
|------|------|
| [layout-testing.md](layout-testing.md) | 그누보드7 레이아웃 파일 렌더링 테스트 가이드 |
### 인증 및 보안
| 문서 | 설명 |
|------|------|
| [auth-system.md](auth-system.md) | 인증 시스템 (AuthManager) |
| [modal-usage.md](modal-usage.md) | Modal 컴포넌트 사용 가이드 |
| [security.md](security.md) | 보안 및 검증 |
<!-- AUTO-GENERATED-END: frontend-readme-docs -->
---
## 컴포넌트 타입 요약
| 타입 | 설명 | 예시 |
|------|------|------|
| basic | HTML 태그 래핑 | Button, Input, Div, Icon |
| composite | 기본 컴포넌트 조합 | Card, Modal, PageHeader, DataGrid |
| layout | 자식 요소 배치 | Container, Grid, Flex |
---
## 데이터 바인딩 문법 (빠른 참조)
```json
{
"props": {
"title": "{{user.name}}", // API 데이터
"userId": "{{route.id}}", // URL 파라미터
"label": "$t:dashboard.title", // 다국어
"total": "{{users?.data?.total ?? 0}}" // Optional Chaining + Nullish Coalescing
}
}
```
---
## 다크 모드 색상 매핑 (빠른 참조)
```
배경: bg-white dark:bg-gray-800
테두리: border-gray-200 dark:border-gray-700
텍스트: text-gray-900 dark:text-white
강조: bg-green-50 dark:bg-green-900/20
```
---
## 관련 문서
- [AGENTS.md](../../../AGENTS.md) - 그누보드7 개발 가이드 (메인)
- [backend/](../backend/) - 백엔드 개발 규칙
- [database-guide.md](../database-guide.md) - 데이터베이스 규칙
- [testing-guide.md](../testing-guide.md) - 테스트 규칙
+337
View File
@@ -0,0 +1,337 @@
# 액션 시스템 - G7Core API (React 컴포넌트용)
> **메인 문서**: [actions.md](actions.md)
> **관련 문서**: [actions-handlers.md](actions-handlers.md) | [state-management.md](state-management.md) | [g7core-api.md](g7core-api.md)
---
## 목차
1. [G7Core.dispatch](#g7coredispatch)
2. [G7Core 편의 API](#g7core-편의-api)
3. [이벤트 헬퍼](#이벤트-헬퍼)
4. [API 전체 목록](#g7core-api-전체-목록)
---
## G7Core.dispatch
템플릿 컴포넌트(React)에서 레이아웃 JSON과 동일한 방식으로 액션을 실행할 수 있습니다.
```text
중요: 템플릿은 코어와 별도 빌드되므로 import 불가
✅ 필수: window.G7Core.dispatch 전역 함수 사용
✅ 형식: 레이아웃 JSON의 액션 정의와 동일
```
### 기본 사용법
```tsx
// 네비게이션
(window as any).G7Core?.dispatch({
handler: 'navigate',
params: { path: '/admin/users/1/edit' },
});
// API 호출
(window as any).G7Core?.dispatch({
handler: 'apiCall',
target: '/api/admin/users/1',
params: { method: 'GET' },
auth_required: true,
});
// 상태 변경
(window as any).G7Core?.dispatch({
handler: 'setState',
params: { target: 'global', selectedIds: [] },
});
// 토스트 알림
(window as any).G7Core?.dispatch({
handler: 'toast',
params: { type: 'success', message: '저장되었습니다' },
});
```
### 후속 액션 (onSuccess/onError)
```tsx
(window as any).G7Core?.dispatch({
handler: 'apiCall',
target: '/api/admin/users/1',
params: { method: 'DELETE' },
auth_required: true,
onSuccess: [
{ handler: 'toast', params: { type: 'success', message: '삭제 완료' } },
{ handler: 'navigate', params: { path: '/admin/users' } },
],
onError: [
{ handler: 'toast', params: { type: 'error', message: '삭제 실패' } },
],
});
```
### 확인 다이얼로그
```tsx
(window as any).G7Core?.dispatch({
handler: 'apiCall',
target: '/api/admin/users/1',
params: { method: 'DELETE' },
confirm: '정말 삭제하시겠습니까?', // 확인 후 실행
});
```
### 반환값
```tsx
const result = await (window as any).G7Core?.dispatch({
handler: 'apiCall',
target: '/api/admin/users',
params: { method: 'GET' },
});
if (result?.success) {
console.log('성공:', result.data);
} else {
console.error('실패:', result?.error);
}
```
### 실제 사용 예시 (UserProfile 컴포넌트)
```tsx
const UserProfile: React.FC<UserProfileProps> = ({ user }) => {
const handleProfileClick = () => {
// SPA 네비게이션으로 사용자 수정 페이지 이동
(window as any).G7Core?.dispatch({
handler: 'navigate',
params: { path: `/admin/users/${user.id}/edit` },
});
};
return (
<Button onClick={handleProfileClick}>
프로필 설정
</Button>
);
};
```
### 사용 시 주의사항
1. **Optional Chaining 필수**: `G7Core?.dispatch` - 엔진 초기화 전 호출 방지
2. **타입 캐스팅**: `(window as any)` - TypeScript 타입 오류 방지
3. **비동기 처리**: `dispatch`는 Promise 반환 - 필요시 `await` 사용
4. **레이아웃 JSON과 동일**: 모든 핸들러와 파라미터 형식이 레이아웃 JSON과 같음
---
## G7Core 편의 API
`dispatch` 외에도 자주 사용하는 기능들을 편의 API로 제공합니다.
### G7Core.state - 전역 상태 관리
```tsx
// 현재 전역 상태 조회
const state = (window as any).G7Core?.state.get();
console.log(state._global?.selectedIds);
// 전역 상태 업데이트
(window as any).G7Core?.state.set({
_global: { selectedIds: [1, 2, 3] }
});
// 상태 변경 구독
const unsubscribe = (window as any).G7Core?.state.subscribe((newState) => {
console.log('상태 변경:', newState);
});
// 구독 해제
unsubscribe();
```
### G7Core.locale - 로케일 관리
```tsx
// 현재 로케일 조회
const currentLocale = (window as any).G7Core?.locale.current();
// 'ko'
// 지원하는 로케일 목록
const locales = (window as any).G7Core?.locale.supported();
// ['ko', 'en']
// 로케일 변경 (비동기)
await (window as any).G7Core?.locale.change('en');
```
### G7Core.toast - 토스트 알림
```tsx
// 기본 사용
(window as any).G7Core?.toast.show('메시지', { type: 'info', duration: 3000 });
// 편의 메서드
(window as any).G7Core?.toast.success('저장되었습니다');
(window as any).G7Core?.toast.error('오류가 발생했습니다');
(window as any).G7Core?.toast.warning('주의가 필요합니다');
(window as any).G7Core?.toast.info('정보 메시지');
// duration 지정 (ms)
(window as any).G7Core?.toast.success('저장 완료', 5000);
```
### G7Core.modal - 모달 관리
```tsx
// 모달 열기
(window as any).G7Core?.modal.open('confirm_modal');
// 모달 닫기 (특정 모달)
(window as any).G7Core?.modal.close('confirm_modal');
// 최상위 모달 닫기
(window as any).G7Core?.modal.close();
// 모든 모달 닫기
(window as any).G7Core?.modal.closeAll();
// 모달 열림 상태 확인
const isOpen = (window as any).G7Core?.modal.isOpen('confirm_modal');
// 현재 열린 모달 스택 조회
const stack = (window as any).G7Core?.modal.getStack();
// ['parent_modal', 'child_modal']
```
### G7Core.navigation - 페이지 전환 상태
페이지 전환(네비게이션) 상태를 감지하고 전환 완료 시점에 콜백을 실행할 수 있습니다.
```tsx
// 현재 페이지 전환 진행 중인지 확인
const isPending = (window as any).G7Core?.navigation.isPending();
// 페이지 전환 완료 후 콜백 실행 (1회성)
const unsubscribe = (window as any).G7Core?.navigation.onComplete(() => {
console.log('페이지 로드 완료!');
// 사이드바 닫기, 스크롤 위치 초기화 등
});
// 필요시 구독 해제
unsubscribe();
```
**onComplete 동작 방식:**
- 전환이 **시작된 후 완료**될 때 콜백 실행 (true → false 상태 변화 감지)
- 콜백은 1회만 실행되고 자동으로 구독 해제됨
- `TransitionManager`를 직접 사용하지 않고도 전환 완료를 감지할 수 있음
**실전 예시 (모바일 사이드바):**
```tsx
const handleNavigate = (event: React.MouseEvent, url: string) => {
event.preventDefault();
// 페이지 전환 완료 후 사이드바 닫기
G7Core().navigation?.onComplete(() => {
G7Core().dispatch({
handler: 'setState',
params: { target: 'global', sidebarOpen: false },
});
});
G7Core().dispatch({
handler: 'navigate',
params: { path: url },
});
};
```
---
## 이벤트 헬퍼
컴포넌트에서 가짜 이벤트를 생성할 때 ActionDispatcher가 인식할 수 있는 표준 형식의 이벤트 객체를 생성합니다.
```text
중요: 컴포넌트에서 직접 가짜 이벤트를 생성하면 ActionDispatcher의 isStandardEvent 체크 실패
✅ 필수: G7Core.createChangeEvent 등 헬퍼 함수 사용
```
### 사용 가능한 이벤트 헬퍼
| 함수 | 설명 | 주요 옵션 |
|------|------|----------|
| `createChangeEvent(options)` | change 이벤트 생성 | `checked`, `value`, `name` |
| `createClickEvent(options)` | click 이벤트 생성 | `button`, `clientX`, `clientY` |
| `createSubmitEvent()` | submit 이벤트 생성 | - |
| `createKeyboardEvent(key, type)` | 키보드 이벤트 생성 | `key`, `eventType` |
### 사용 예시 (Toggle 컴포넌트)
```tsx
// G7Core 전역 객체 참조
const G7Core = (window as any).G7Core;
// Toggle 컴포넌트 내부
const handleClick = () => {
const newChecked = !checked;
setChecked(newChecked);
if (onChange) {
// ActionDispatcher 호환 이벤트 생성
(onChange as any)(G7Core.createChangeEvent({ checked: newChecked, name }));
}
};
```
### createChangeEvent 옵션
| 필드 | 타입 | 설명 |
|------|------|------|
| `checked` | boolean | 체크박스/토글의 체크 상태 |
| `value` | string | input의 값 |
| `name` | string | input의 name 속성 |
| `type` | string | input의 type 속성 (기본값: "checkbox") |
### 왜 이벤트 헬퍼가 필요한가?
ActionDispatcher의 `bindActionsToProps`는 이벤트 객체에 `preventDefault` 메서드가 있는지 확인합니다:
```typescript
// ActionDispatcher.ts
const isStandardEvent = firstArg && typeof firstArg === 'object' && 'preventDefault' in firstArg;
```
단순 객체 `{ target: { checked: true } }`는 이 체크를 통과하지 못하므로, 헬퍼 함수가 `preventDefault`, `stopPropagation` 등 필수 메서드를 포함한 완전한 이벤트 객체를 생성합니다.
---
## G7Core API 전체 목록
| API | 설명 | 주요 메서드 |
|-----|------|-----------|
| `G7Core.dispatch` | 액션 실행 | `dispatch(action)` |
| `G7Core.state` | 전역 상태 관리 | `get()`, `set(updates)`, `subscribe(listener)` |
| `G7Core.locale` | 로케일 관리 | `current()`, `supported()`, `change(locale)` |
| `G7Core.toast` | 토스트 알림 | `show()`, `success()`, `error()`, `warning()`, `info()` |
| `G7Core.modal` | 모달 관리 | `open()`, `close()`, `closeAll()`, `isOpen()`, `getStack()` |
| `G7Core.navigation` | 페이지 전환 상태 | `isPending()`, `onComplete(callback)` |
| `G7Core.createChangeEvent` | change 이벤트 생성 | `createChangeEvent(options)` |
| `G7Core.createClickEvent` | click 이벤트 생성 | `createClickEvent(options)` |
| `G7Core.createSubmitEvent` | submit 이벤트 생성 | `createSubmitEvent()` |
| `G7Core.createKeyboardEvent` | 키보드 이벤트 생성 | `createKeyboardEvent(key, type)` |
| `G7Core.AuthManager` | 인증 관리 | `getInstance()`, `isAuthenticated()`, `getUser()` |
---
## 관련 문서
- [액션 시스템 개요](actions.md) - 액션 정의, 핸들러 목록
- [핸들러 상세](actions-handlers.md) - 각 핸들러별 상세 사용법
- [상태 관리](state-management.md) - `_global` 전역 상태
- [G7Core API 전체](g7core-api.md) - 렌더링, 인증, 반응형 등
@@ -0,0 +1,434 @@
# 액션 핸들러 - 네비게이션
> **메인 문서**: [actions-handlers.md](actions-handlers.md)
---
## 목차
1. [navigate](#navigate)
2. [navigateBack](#navigateback)
3. [openWindow](#openwindow)
4. [reloadRoutes](#reloadroutes)
5. [refresh](#refresh)
---
## navigate
페이지 이동을 처리합니다.
```json
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/users/{{row.id}}"
}
}
```
### navigate params 구조
| 필드 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `path` | string | - | 이동할 경로 |
| `query` | object | - | 쿼리 파라미터 객체 |
| `mergeQuery` | boolean | false | 기존 쿼리 파라미터와 병합 |
| `replace` | boolean | false | URL만 변경 (페이지 리로드 없음) |
### mergeQuery 옵션
기존 쿼리 파라미터를 유지하면서 새 파라미터를 병합합니다.
```json
{
"handler": "navigate",
"params": {
"path": "/admin/users",
"mergeQuery": true,
"query": {
"page": "2"
}
}
}
```
### 배열 쿼리 파라미터
> **버전**: engine-v1.10.0+
배열 값은 자동으로 `key[]=value1&key[]=value2` 형태로 변환됩니다.
```json
{
"handler": "navigate",
"params": {
"path": "/admin/products",
"mergeQuery": true,
"query": {
"sales_status[]": "{{_local.salesStatus}}"
}
}
}
```
**입력 예시**:
- `_local.salesStatus = ["on_sale", "sold_out"]`
**생성되는 쿼리스트링**:
- `sales_status[]=on_sale&sales_status[]=sold_out`
```text
중요: 배열 값이 빈 배열([])이거나 null이면 해당 파라미터는 쿼리스트링에서 제거됩니다.
```
**백엔드 Enum 값과 일치 필수**:
프론트엔드 필터에서 사용하는 값은 반드시 백엔드 Enum의 `value`와 동일해야 합니다.
```php
// 백엔드 Enum (ProductSalesStatus.php)
enum ProductSalesStatus: string
{
case OnSale = 'on_sale';
case Suspended = 'suspended';
case SoldOut = 'sold_out';
case ComingSoon = 'coming_soon';
}
```
```json
// ✅ 올바른 사용 (Enum 값과 일치)
"salesStatus": "{{(_local.salesStatus || []).includes('sold_out') ? ... }}"
// ❌ 잘못된 사용 (Enum에 없는 값)
"salesStatus": "{{(_local.salesStatus || []).includes('out_of_stock') ? ... }}"
```
번역 키도 Enum과 일관되게 사용:
```json
// ✅ 권장: Enum 기반 번역 키
"text": "$t:sirsoft-ecommerce.enums.sales_status.sold_out"
// 비권장: 별도의 필터용 번역 키
"text": "$t:sirsoft-ecommerce.admin.product.filter.sales_status_options.out_of_stock"
```
### replace 옵션
> **버전**: engine-v1.3.0+ (engine-v1.12.0에서 동작 방식 변경)
`replace: true`를 사용하면 **컴포넌트 리마운트 없이** URL을 변경하고 데이터를 갱신합니다. 같은 페이지 내에서 검색/필터/정렬/페이지네이션 등의 쿼리 파라미터만 변경할 때 사용합니다.
```json
{
"handler": "navigate",
"params": {
"path": "/admin/products",
"mergeQuery": true,
"replace": true,
"query": {
"page": "{{_local.page}}",
"sort": "{{_local.sortField}}",
"order": "{{_local.sortOrder}}"
}
}
}
```
**동작 방식** (engine-v1.12.0+):
1. `window.history.replaceState()`로 URL 업데이트 (히스토리 교체)
2. 내부 쿼리 컨텍스트 (`query`) 업데이트
3. `auto_fetch: true`인 모든 데이터 소스 자동 refetch
4. UI 리렌더링 (컴포넌트 리마운트 없음)
**사용 사례**:
- 검색 버튼 클릭 시 필터 적용 (깜빡임 없이 데이터만 갱신)
- 정렬 드롭다운 변경 시 목록 갱신
- 페이지당 표시 개수 변경
- 페이지네이션 (같은 페이지 내 이동)
**replace vs 일반 navigate 비교**:
| 특성 | `replace: false` (기본값) | `replace: true` |
|------|--------------------------|-----------------|
| 컴포넌트 리마운트 | 발생 (전체 라우트 전환) | 발생 안 함 |
| 데이터 소스 refetch | 라우트 전환 시 자동 | 즉시 자동 refetch |
| 히스토리 스택 | 새 항목 추가 | 현재 항목 교체 |
| UI 깜빡임 | 발생 가능 | 없음 |
| 쿼리 컨텍스트 업데이트 | React Router 통해 자동 | 내부적으로 즉시 반영 |
**주의사항**:
```text
replace: true는 같은 페이지 내에서만 사용
다른 페이지로 이동할 때는 replace: false (기본값) 사용
✅ 사용 권장: 검색, 필터, 정렬, 페이지네이션
❌ 사용 금지: 다른 페이지로 이동, 상세 페이지 진입
```
**검색 필터 예시**:
```json
{
"id": "search_button",
"type": "basic",
"name": "Button",
"text": "$t:common.search",
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/products",
"replace": true,
"mergeQuery": true,
"query": {
"page": 1,
"search_field": "{{_local.filter.searchField}}",
"search_keyword": "{{_local.filter.searchKeyword}}"
}
}
}
]
}
```
**정렬 드롭다운 예시**:
```json
{
"type": "basic",
"name": "Select",
"props": {
"value": "{{query.sort || 'created_at'}}",
"options": [
{ "value": "created_at", "label": "$t:common.sort.created_at" },
{ "value": "name", "label": "$t:common.sort.name" }
]
},
"actions": [
{
"type": "change",
"handler": "navigate",
"params": {
"path": "/admin/products",
"replace": true,
"mergeQuery": true,
"query": {
"sort": "{{$event.target.value}}"
}
}
}
]
}
```
---
## openWindow
> **버전**: engine-v1.19.0+
새 브라우저 탭/창에서 URL을 엽니다. `window.open(path, '_blank')`을 호출합니다.
```json
{
"type": "click",
"handler": "openWindow",
"params": {
"path": "/admin/users/{{row.created_by}}"
}
}
```
### openWindow params 구조
| 필드 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `path` | string | - | 새 창에서 열 경로 (필수) |
### 사용 사례
- 회원 정보를 새 창에서 조회
- 외부 링크를 새 탭에서 열기
- 현재 페이지를 유지하면서 다른 페이지 참조
### navigate와의 차이
| 특성 | `navigate` | `openWindow` |
|------|-----------|--------------|
| 현재 페이지 유지 | X (이동) | O (유지) |
| 새 탭/창 | X | O |
| 히스토리 | 현재 탭 히스토리 변경 | 변경 없음 |
### 예시: 등록자 정보 새 창으로 보기
```json
{
"type": "basic",
"name": "Button",
"props": {
"variant": "ghost",
"size": "sm"
},
"text": "$t:common.view_member",
"actions": [
{
"type": "click",
"handler": "openWindow",
"params": {
"path": "/admin/users/{{row.created_by}}"
}
}
]
}
```
---
## navigateBack
브라우저 히스토리에서 뒤로 이동합니다. `window.history.back()`을 호출합니다.
```json
{
"type": "click",
"handler": "navigateBack"
}
```
### 사용 사례
- 상세 페이지에서 목록으로 돌아가기
- 폼 페이지에서 취소 버튼 클릭 시 이전 페이지로 이동
```json
{
"id": "back_button",
"type": "basic",
"name": "Button",
"actions": [
{
"type": "click",
"handler": "navigateBack"
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"props": { "name": "arrow-left", "className": "w-4 h-4" }
},
{
"type": "basic",
"name": "Span",
"text": "$t:common.back"
}
]
}
```
---
## reloadRoutes
라우트(routes.json)를 다시 로드합니다.
```json
{
"handler": "reloadRoutes"
}
```
### 사용 사례
- 모듈/플러그인 설치 후 새 라우트 적용
- 동적으로 라우트 설정이 변경된 경우
---
## refresh
현재 페이지를 새로고침합니다.
```json
{
"handler": "refresh"
}
```
### 사용 사례
- 전체 페이지 새로고침이 필요한 경우
- 상태 초기화가 필요한 경우
---
## replaceUrl
> **버전**: engine-v1.18.0+
URL만 변경하고 데이터소스 refetch나 컴포넌트 리마운트를 수행하지 않습니다. 리스트 항목 선택 시 URL에 상태를 반영할 때 사용합니다.
```json
{
"handler": "replaceUrl",
"params": {
"path": "/admin/menus",
"query": { "menu": "{{$args[0].slug}}", "mode": "view" }
}
}
```
### replaceUrl params 구조
| 필드 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `path` | string | 현재 경로 | 변경할 경로 (생략 시 `window.location.pathname`) |
| `query` | object | - | 쿼리 파라미터 객체 |
| `mergeQuery` | boolean | false | 기존 쿼리 파라미터와 병합 |
### replaceUrl vs navigate replace 비교
| 특성 | `navigate` (`replace: true`) | `replaceUrl` |
| ------ | ------------------------------ | -------------- |
| URL 변경 | O | O |
| 데이터소스 refetch | O (auto_fetch 전체) | **X** |
| 컴포넌트 리마운트 | X | **X** |
| 히스토리 | 현재 항목 교체 | 현재 항목 교체 |
### 사용 사례
- 리스트 항목 선택 시 URL에 선택 상태 반영 (깜빡임 없이)
- 편집/보기 모드 전환 시 URL 업데이트
- URL 복사/공유 시 현재 상태 복원 가능하도록
### path 생략 예시
`path`를 생략하면 현재 페이지 경로가 자동으로 사용됩니다.
```json
{
"handler": "replaceUrl",
"params": {
"query": { "id": "{{$args[0].id}}", "mode": "view" }
}
}
```
---
## 관련 문서
- [액션 핸들러 인덱스](actions-handlers.md)
- [상태 핸들러](actions-handlers-state.md)
- [UI 핸들러](actions-handlers-ui.md)
- [상태 관리](state-management.md)
+973
View File
@@ -0,0 +1,973 @@
# 액션 핸들러 - 상태 관리
> **메인 문서**: [actions-handlers.md](actions-handlers.md)
---
## 목차
1. [apiCall](#apicall)
2. [setState](#setstate)
3. [setError](#seterror)
4. [refetchDataSource](#refetchdatasource)
5. [updateDataSource](#updatedatasource)
6. [appendDataSource](#appenddatasource)
7. [remount](#remount)
8. [sortable (드래그앤드롭 정렬)](#sortable-드래그앤드롭-정렬)
9. [onSuccess/onError 후속 액션](#onsuccessonerror-후속-액션)
10. [API 데이터 바인딩 규칙](#api-데이터-바인딩-규칙)
11. [에러 핸들링 시스템](#에러-핸들링-시스템-errorhandling)
---
## apiCall
API를 호출합니다. **주의: `api`가 아닌 `apiCall`을 사용해야 합니다.**
```json
{
"type": "click",
"handler": "apiCall",
"auth_required": true,
"target": "/api/admin/users/bulk-status",
"params": {
"method": "PATCH",
"body": {
"ids": "{{_global.selectedIds}}",
"status": "active"
}
},
"onSuccess": [
{ "handler": "closeModal" },
{ "handler": "toast", "params": { "type": "success", "message": "$t:common.success" } }
]
}
```
### 액션 레벨 속성
| 필드 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `auth_required` | boolean | false | true이면 Bearer 토큰을 Authorization 헤더에 포함 (401 에러 발생 시 로그인 페이지 리다이렉트) |
| `auth_mode` | string | "required" | 인증 모드: `"required"` (토큰 없으면 에러), `"optional"` (토큰 있으면 포함, 없어도 진행) |
```text
중요: auth_required, auth_mode는 params 안이 아닌 액션 정의 최상위에 선언해야 합니다.
```
#### auth_mode 사용 예시
비회원도 접근 가능하지만, 로그인 시 추가 기능을 제공하는 API:
```json
{
"handler": "apiCall",
"target": "/api/cart",
"auth_mode": "optional",
"params": { "method": "GET" }
}
```
| auth_mode | 토큰 있음 | 토큰 없음 |
|-----------|----------|----------|
| `"required"` (기본) | Bearer 토큰 전송 | 에러 발생 |
| `"optional"` | Bearer 토큰 전송 | 토큰 없이 요청 진행 |
```json
// ✅ 올바른 사용
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/admin/users",
"params": { "method": "POST" }
}
// ❌ 잘못된 사용 (Bearer 토큰이 전송되지 않음)
{
"handler": "apiCall",
"target": "/api/admin/users",
"params": { "method": "POST", "auth_required": true }
}
```
### apiCall params 구조
| 필드 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `method` | string | "GET" | HTTP 메서드 (GET, POST, PUT, PATCH, DELETE) |
| `body` | object | - | 요청 본문 (JSON 또는 FormData) |
| `headers` | object | - | 추가 헤더 |
| `contentType` | string | "application/json" | 요청 Content-Type (`"multipart/form-data"` 지정 시 FormData 자동 변환) |
### multipart/form-data 지원 (파일 업로드)
> **버전**: engine-v1.19.0+
`contentType: "multipart/form-data"`를 지정하면 `body`가 자동으로 `FormData`로 변환됩니다.
`Content-Type` 헤더는 설정하지 않으며 (브라우저가 boundary를 포함하여 자동 설정), File/Blob 객체는 원본 그대로 전송됩니다.
```json
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/admin/modules/manual-install",
"params": {
"method": "POST",
"contentType": "multipart/form-data",
"body": {
"file": "{{_global.moduleUploadFile}}",
"source": "file_upload"
}
}
}
```
#### FormData 변환 규칙
| body 값 타입 | FormData 처리 |
|-------------|---------------|
| `File` / `Blob` | `formData.append(key, value)` (원본 유지) |
| `string` / `number` / `boolean` | `formData.append(key, String(value))` |
| `object` / `array` | `formData.append(key, JSON.stringify(value))` |
| `null` / `undefined` | 제외 (전송하지 않음) |
#### 주의사항
```text
주의: contentType: "multipart/form-data" 시 Content-Type 헤더를 수동 설정하지 말 것
→ 브라우저가 boundary를 포함한 Content-Type을 자동 생성
주의: File/Blob 객체는 setState로 저장 시 원본 참조 유지됨 (engine-v1.19.0+)
→ deepMergeWithState가 non-plain 객체(File, Blob, Date 등)를 spread 없이 직접 할당
필수: 파일 업로드 API는 백엔드에서 multipart/form-data를 기대해야 함
```
### apiCall body에서 조건부 필드 제외 (undefined 패턴)
드라이버/모드 선택에 따라 **특정 필드만 전송**해야 하는 경우, `undefined`를 반환하는 삼항 표현식을 사용합니다. `JSON.stringify()`는 값이 `undefined`인 키를 자동으로 제거합니다.
```json
{
"handler": "apiCall",
"target": "/api/admin/settings/test-mail",
"params": {
"method": "POST",
"body": {
"mailer": "{{_local.form?.mail?.mailer || 'smtp'}}",
"from_address": "{{_local.form?.mail?.from_address || ''}}",
"host": "{{_local.form?.mail?.mailer === 'smtp' ? (_local.form?.mail?.host ?? '') : undefined}}",
"mailgun_domain": "{{_local.form?.mail?.mailer === 'mailgun' ? (_local.form?.mail?.mailgun_domain ?? '') : undefined}}"
}
}
}
```
**동작 원리**:
| 조건 | 표현식 결과 | JSON 직렬화 |
|------|------------|-------------|
| mailer === 'smtp' | `host: "smtp.example.com"` | `"host":"smtp.example.com"` (포함) |
| mailer === 'mailgun' | `host: undefined` | 키 자체가 제거됨 |
**핵심 규칙**:
```text
주의: 불필요한 필드를 빈 문자열('')로 전송하지 말 것
필수: undefined 패턴으로 해당 드라이버 필드만 전송
공통 필드(from_address, from_name 등)는 조건 없이 항상 전송
주의: 모든 드라이버 필드를 항상 전송하면 서버 측 불필요한 처리 유발
```
**삼항 표현식 패턴**:
```text
현재 드라이버 필드: 조건 ? (값 ?? 기본값) : undefined
다른 드라이버 필드: undefined (JSON에서 제거)
공통 필드: 조건 없이 항상 포함
```
---
### CSRF 토큰
`apiCall`은 자동으로 CSRF 토큰을 처리합니다:
- POST, PUT, PATCH, DELETE 요청 시 `/sanctum/csrf-cookie` 호출
- 쿠키에서 `XSRF-TOKEN`을 추출하여 `X-XSRF-TOKEN` 헤더에 포함
### globalHeaders 자동 적용
> **버전**: engine-v1.16.0+
레이아웃에 `globalHeaders`가 정의되어 있으면, `apiCall` 핸들러도 해당 패턴에 매칭되는 API에 헤더를 자동으로 포함합니다.
```json
// 레이아웃 최상위
{
"globalHeaders": [
{ "pattern": "/api/modules/sirsoft-ecommerce/*", "headers": { "X-Cart-Key": "{{_global.cartKey}}" } }
]
}
// apiCall 핸들러 - X-Cart-Key 헤더 자동 포함
{
"handler": "apiCall",
"target": "/api/modules/sirsoft-ecommerce/cart/add",
"params": {
"method": "POST",
"body": { "productId": "{{item.id}}" }
}
}
```
**헤더 우선순위**: `params.headers` > `globalHeaders`
```json
{
"handler": "apiCall",
"target": "/api/modules/sirsoft-ecommerce/cart",
"params": {
"method": "GET",
"headers": { "X-Cart-Key": "custom-key" } // globalHeaders보다 우선
}
}
```
> **상세 문서**: [layout-json.md](layout-json.md#전역-헤더-globalheaders)
---
## setState
상태를 변경합니다.
### 병합 모드 (merge 옵션)
> **버전**: engine-v1.18.0+ (`replace` 모드 추가)
setState 핸들러의 `params`에 `merge` 속성을 지정하여 상태 병합 방식을 선택할 수 있습니다.
| `merge` 값 | 동작 | 사용 시점 |
|-------------|------|----------|
| 생략 또는 `"deep"` | 재귀적 깊은 병합 (중첩 객체 보존) | 개별 필드 업데이트 (기본값) |
| `"shallow"` | 최상위 키만 덮어쓰기 (1단계) | 프리셋 적용, 필터 초기화 |
| `"replace"` | 기존 상태 완전 무시, 새 값으로 교체 | 폼 초기화, 서버 데이터로 전체 교체 |
```json
// replace 모드: 기존 _local을 완전히 payload로 교체
{
"handler": "setState",
"params": {
"target": "local",
"merge": "replace",
"formData": { "name": "", "price": 0 }
}
}
// shallow 모드: 최상위 키만 덮어쓰기
{
"handler": "setState",
"params": {
"target": "local",
"merge": "shallow",
"filter": { "searchField": "all", "status": "all" }
}
}
```
> **상세 비교**: [state-management-forms.md](state-management-forms.md#병합-모드-선택)
### 전역 상태 변경 (target: "global")
```json
{
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"selectedIds": "{{$args[0]}}",
"searchQuery": "{{$event.target.value}}"
}
}
```
### 로컬 상태 변경 (target: "local" 또는 생략)
```json
{
"type": "change",
"handler": "setState",
"params": {
"isExpanded": true
}
}
```
### 격리된 상태 변경 (target: "isolated")
> **버전**: engine-v1.14.0+
`isolatedState` 속성이 정의된 컴포넌트 내에서만 동작합니다. 해당 영역의 상태 변경 시 전체 레이아웃이 아닌 격리된 영역만 리렌더링됩니다.
```json
{
"type": "click",
"handler": "setState",
"params": {
"target": "isolated",
"selectedId": "{{item.id}}",
"currentStep": 2
}
}
```
#### 사용 요건
```text
주의: target: "isolated"는 isolatedState 속성이 정의된 컴포넌트 내에서만 동작합니다.
격리 스코프 외부에서 호출 시 → _local로 폴백되며 경고 로그 출력
isolatedState가 있는 컴포넌트 내에서 호출 시 → 격리된 상태만 업데이트
```
#### 레이아웃 정의 예시
```json
{
"type": "Div",
"isolatedState": {
"selectedCategories": [null, null, null, null],
"currentStep": 1
},
"isolatedScopeId": "category-selector",
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"label": "다음 단계",
"onClick": {
"handler": "setState",
"params": {
"target": "isolated",
"currentStep": "{{_isolated.currentStep + 1}}"
}
}
}
}
]
}
```
#### target별 비교
| target | 상태 스코프 | 리렌더링 범위 | 사용 시점 |
| ------ | ----------- | -------------- | ---------- |
| `local` (기본) | `_local` | 전체 레이아웃 | 일반 폼 데이터, 필터 |
| `global` | `_global` | 전체 앱 | 사용자 인증, 사이드바 상태 |
| `isolated` | `_isolated` | 격리된 영역만 | 빈번한 인터랙션 (카테고리 선택, 드래그) |
### 주요 사용 패턴
```json
// 체크박스 선택 ID 저장
{
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"selectedIds": "{{$args[0]}}"
}
}
// 검색어 입력 시 상태 저장
{
"type": "change",
"handler": "setState",
"params": {
"searchQuery": "{{$event.target.value}}"
}
}
// 토글 상태 변경
{
"type": "click",
"handler": "setState",
"params": {
"isExpanded": "{{!_local.isExpanded}}"
}
}
```
---
## setError
에러 상태를 설정합니다. `apiError` 상태 키에 저장됩니다.
```json
{
"handler": "setError",
"target": "{{error.response.message}}"
}
```
### target 값
| 형식 | 설명 |
|------|------|
| `{{error.message}}` | 데이터 바인딩 (에러 객체에서 추출) |
| `$t:errors.login_failed` | 다국어 키 |
| `"로그인에 실패했습니다."` | 직접 문자열 |
---
## refetchDataSource
특정 데이터 소스를 다시 fetch합니다.
```json
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "modules"
}
}
```
### refetchDataSource params 구조
| 필드 | 타입 | 필수 | 기본값 | 설명 |
|------|------|------|--------|------|
| `dataSourceId` | string | ✅ | - | 다시 fetch할 데이터 소스 ID |
| `sync` | boolean | ❌ | false | true면 즉시 동기 렌더링 |
### sync 옵션
> **버전**: engine-v1.4.0+
`sync: true`를 사용하면 React의 `startTransition` 없이 즉시 동기적으로 렌더링합니다.
```json
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "admin_menu",
"sync": true
}
}
```
| 케이스 | sync 필요 여부 |
|--------|---------------|
| 드래그 앤 드롭 순서 변경 | ✅ `sync: true` |
| 토글/체크박스 상태 변경 | ✅ `sync: true` |
| 일반 데이터 갱신 | ❌ 기본값 사용 |
### 상태 오버라이드 (engine-v1.17.0+)
refetch 시 상태를 임시로 오버라이드하여 데이터소스 파라미터 치환에 반영할 수 있습니다.
| 필드 | 타입 | 필수 | 엔진 버전 | 설명 |
|------|------|------|----------|------|
| `globalStateOverride` | object | ❌ | engine-v1.17.0+ | `_global` 값 임시 오버라이드 |
| `localStateOverride` | object | ❌ | engine-v1.19.0+ | `_local` 값 임시 오버라이드 |
| `isolatedStateOverride` | object | ❌ | engine-v1.19.0+ | `_isolated` 값 임시 오버라이드 |
```json
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "products",
"sync": true,
"globalStateOverride": {
"currentPage": 1
},
"localStateOverride": {
"filter": { "status": "active" }
}
}
}
```
```text
오버라이드는 해당 refetch에만 적용 (실제 상태 미변경)
✅ 상세 문서: data-sources-advanced.md "상태 오버라이드 파라미터" 섹션
```
---
## updateDataSource
데이터 소스를 직접 업데이트합니다. API 응답을 사용하여 데이터 소스를 갱신할 때 사용합니다. `refetchDataSource`와 달리 추가 API 요청 없이 즉시 데이터를 업데이트합니다.
```json
{
"handler": "apiCall",
"target": "/api/checkout",
"params": { "method": "PUT", "body": "{{_local.checkout}}" },
"onSuccess": [
{
"handler": "updateDataSource",
"params": {
"dataSourceId": "checkoutData",
"data": "{{response}}"
}
}
]
}
```
### updateDataSource params 구조
| 필드 | 타입 | 필수 | 기본값 | 설명 |
|------|------|------|--------|------|
| `dataSourceId` | string | ✅ | - | 업데이트할 데이터 소스 ID |
| `data` | any | ✅ | - | 새로운 데이터 (API 응답 등) |
### refetchDataSource vs updateDataSource
| 핸들러 | 동작 | 네트워크 요청 | 사용 시점 |
|--------|------|--------------|----------|
| `refetchDataSource` | 데이터 소스의 endpoint를 다시 호출 | ✅ 발생 | 서버에서 최신 데이터를 가져와야 할 때 |
| `updateDataSource` | 전달받은 data로 직접 교체 | ❌ 없음 | PUT/POST 응답으로 즉시 갱신할 때 |
### 사용 예시
**PUT API 응답으로 데이터 소스 갱신** (추가 GET 요청 없이):
```json
{
"handler": "apiCall",
"target": "/api/modules/sirsoft-ecommerce/checkout",
"params": { "method": "PUT", "body": "{{_local.checkout}}" },
"onSuccess": [
{
"handler": "updateDataSource",
"params": {
"dataSourceId": "checkoutData",
"data": "{{response}}"
}
},
{
"handler": "toast",
"params": { "type": "success", "message": "$t:common.saved" }
}
]
}
```
```text
주의: onSuccess 콜백에서 response 변수를 사용할 때 $response가 아닌 response 사용
"data": "{{$response}}" → undefined로 평가됨
"data": "{{response}}" → 정상 동작
```
---
## appendDataSource
기존 데이터 소스에 새 데이터를 병합합니다. 무한스크롤 구현에 유용합니다.
```json
{
"handler": "appendDataSource",
"params": {
"dataSourceId": "templates",
"dataPath": "data",
"newData": "{{response.data}}"
}
}
```
### appendDataSource params 구조
| 필드 | 타입 | 필수 | 기본값 | 설명 |
|------|------|------|--------|------|
| `dataSourceId` | string | ✅ | - | 대상 데이터 소스 ID |
| `dataPath` | string | ❌ | null | 병합할 데이터 경로 (예: "data") |
| `newData` | array | ✅ | - | 병합할 새 데이터 배열 |
### 무한스크롤 예시
```json
{
"type": "scroll",
"debounce": 200,
"handler": "sequence",
"actions": [
{
"handler": "switch",
"params": {
"value": "{{$event.target.scrollHeight - $event.target.scrollTop <= $event.target.clientHeight + 100 && _global.infiniteScroll.hasMore && !_global.infiniteScroll.isLoadingMore}}"
},
"cases": {
"true": {
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"infiniteScroll.isLoadingMore": true
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/items",
"params": {
"method": "GET",
"query": {
"page": "{{_global.infiniteScroll.currentPage + 1}}",
"per_page": 20
}
},
"onSuccess": [
{
"handler": "appendDataSource",
"params": {
"dataSourceId": "items",
"dataPath": "data",
"newData": "{{response.data}}"
}
},
{
"handler": "setState",
"params": {
"target": "global",
"infiniteScroll.currentPage": "{{_global.infiniteScroll.currentPage + 1}}",
"infiniteScroll.hasMore": "{{(response.data?.length ?? 0) >= 20}}",
"infiniteScroll.isLoadingMore": false
}
}
]
}
]
}
}
}
]
}
```
### scroll 이벤트 속성
scroll 이벤트에서 `$event.target`으로 접근 가능한 속성:
| 속성 | 타입 | 설명 |
|------|------|------|
| `scrollHeight` | number | 전체 콘텐츠 높이 |
| `scrollTop` | number | 현재 스크롤 위치 (상단 기준) |
| `clientHeight` | number | 보이는 영역 높이 |
| `scrollWidth` | number | 전체 콘텐츠 너비 |
| `scrollLeft` | number | 현재 스크롤 위치 (좌측 기준) |
| `clientWidth` | number | 보이는 영역 너비 |
---
## remount
컴포넌트를 강제로 리마운트합니다.
```json
{
"handler": "remount",
"params": {
"componentId": "template_card_grid"
}
}
```
### 사용 사례
- Toggle/Checkbox 상태 복원
- 폼 초기화
- 컴포넌트 상태 리셋
---
## sortable (드래그앤드롭 정렬)
> **버전**: engine-v1.14.0+
> **기반**: @dnd-kit/core + @dnd-kit/sortable (React 네이티브 D&D 라이브러리)
레이아웃 JSON의 `sortable` 속성을 사용하여 드래그앤드롭 정렬을 구현합니다.
HTML5 네이티브 D&D가 아닌 @dnd-kit 기반으로 동작하며, DynamicRenderer가 자동으로 SortableContainer/SortableItemWrapper를 렌더링합니다.
### sortable 속성 구조
| 필드 | 타입 | 필수 | 기본값 | 설명 |
| ---- | ---- | ---- | ------ | ---- |
| `source` | string | ✅ | - | 배열 바인딩 표현식 (예: `"{{_local.form.options}}"`) |
| `itemKey` | string | ❌ | `"id"` | 아이템 고유 키 필드명 |
| `strategy` | string | ❌ | `"verticalList"` | 정렬 전략: `verticalList` / `horizontalList` / `rectSorting` |
| `handle` | string | ❌ | - | 드래그 핸들 CSS 선택자 (예: `"[data-drag-handle]"`) |
| `itemVar` | string | ❌ | `"$item"` | 아이템 컨텍스트 변수명 |
| `indexVar` | string | ❌ | `"$index"` | 인덱스 컨텍스트 변수명 |
### 기본 사용 예시
```json
{
"type": "basic",
"name": "Div",
"sortable": {
"source": "{{_local.form.additional_options}}",
"itemKey": "id",
"strategy": "verticalList",
"handle": "[data-drag-handle]"
},
"itemTemplate": {
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 p-2 border rounded"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"data-drag-handle": true,
"className": "cursor-grab"
},
"children": [{ "type": "basic", "name": "Icon", "props": { "name": "fa-grip-vertical" } }]
},
{
"type": "basic",
"name": "Span",
"props": { "textContent": "{{$item.name}}" }
}
]
},
"actions": [
{
"event": "onSortEnd",
"handler": "setState",
"params": {
"target": "local",
"form.additional_options": "{{$sortedItems}}"
}
}
]
}
```
### sortable 전용 이벤트
| 이벤트 | 설명 | 컨텍스트 변수 |
| ------ | ---- | ------------- |
| `onSortEnd` | 정렬 완료 시 | `$sortedItems` (정렬된 배열), `$oldIndex`, `$newIndex` |
| `onSortStart` | 드래그 시작 시 | `$activeId` (드래그 중인 아이템 ID) |
### 드래그 핸들
`handle` 속성을 지정하면 해당 CSS 선택자를 가진 요소만 드래그 가능합니다.
`data-drag-handle` 속성이 있는 요소에 DynamicRenderer가 자동으로 드래그 리스너를 바인딩합니다.
```json
{
"sortable": {
"source": "{{_local.form.items}}",
"handle": "[data-drag-handle]"
}
}
```
핸들을 지정하지 않으면 아이템 전체가 드래그 가능합니다.
### 주의 사항
```text
sortable은 iteration보다 우선 처리됨 (같은 컴포넌트에 둘 다 있으면 sortable만 동작)
sortable 아이템 내부에서는 폼 자동 바인딩(auto-binding)이 비활성화됨
→ 인덱스 기반 경로가 정렬 후 stale 값을 참조하는 문제 방지
→ 핸들러(setState 등)에서 직접 상태를 관리해야 함
onSortEnd에서 setState로 소스 배열을 업데이트해야 정렬 결과가 반영됨
아이템에 고유한 id 필드가 필수 (itemKey로 지정)
HTML5 네이티브 D&D(dragstart/dragover/drop 이벤트)는 sortable에 사용하지 않음
→ @dnd-kit이 PointerSensor/KeyboardSensor로 자체 처리
```
---
## onSuccess/onError 후속 액션
API 호출 등의 액션 후에 후속 액션을 실행할 수 있습니다.
### 배열 지원
`onSuccess`와 `onError`는 **단일 액션 또는 배열**을 지원합니다.
```json
{
"type": "click",
"handler": "apiCall",
"target": "/api/admin/users/bulk-status",
"params": {
"method": "PATCH",
"body": {
"ids": "{{_global.selectedIds}}",
"status": "active"
}
},
"onSuccess": [
{ "handler": "closeModal" },
{ "handler": "setState", "params": { "target": "global", "selectedIds": [] } },
{ "handler": "navigate", "params": { "path": "/admin/users", "mergeQuery": true, "query": {} } },
{ "handler": "toast", "params": { "type": "success", "message": "$t:admin.users.modals.bulk_activate_success" } }
]
}
```
### onError에서 접근 가능한 데이터
| 바인딩 | 설명 |
|--------|------|
| `{{error.message}}` | 에러 메시지 (번역됨) |
| `{{error.status}}` | HTTP 상태 코드 |
| `{{error.data}}` | API 응답 전체 (success, message, errors 포함) |
| `{{error.data.errors}}` | errors 객체 (추가 에러 정보) |
---
## API 데이터 바인딩 규칙
```text
API 응답 데이터 구조를 절대 추측하지 않음
1. 새로운 API 연동 시 → 실제 API 응답 구조를 컨트롤러 코드에서 확인
2. onSuccess/onError 콜백 → 정확한 컨텍스트 변수 경로 확인 필수
3. 불확실한 경우 → 기존 레이아웃의 유사 패턴 참조
```
### 그누보드7 API 응답 표준 구조
ResponseHelper를 사용하는 모든 API는 다음 구조를 따릅니다:
**성공 응답 (success)**:
```json
{
"success": true,
"message": "번역된 메시지",
"data": { ... }
}
```
**에러 응답 (error)**:
```json
{
"success": false,
"message": "번역된 에러 메시지",
"errors": { ... }
}
```
### 콜백별 컨텍스트 변수 경로
```text
주의: onSuccess/onError 콜백에서 `$response` 사용 금지 — `response` 사용
올바른 변수명: `response` ($ 접두사 없음)
`$response`는 ActionDispatcher 컨텍스트에 존재하지 않는 변수 → undefined 반환
→ preprocessOptionalChaining이 `$response?.data?.xxx`로 변환 → undefined (에러 없이 조용히 실패)
→ fallback 값이 있으면 항상 fallback만 표시되어 버그가 은폐됨
```
| 콜백 | 변수 | 실제 경로 | 설명 |
|------|------|----------|------|
| onSuccess | `response` | - | 전체 API 응답 (권장) |
| onSuccess | `response.data` | - | 성공 시 data 필드 |
| onSuccess | `result` | - | `response`와 동일 (하위 호환성) |
| onError | `error.message` | - | 번역된 에러 메시지 |
| onError | `error.status` | - | HTTP 상태 코드 |
| onError | `error.data` | - | 전체 API 응답 |
| onError | `error.data.errors` | - | errors 객체 |
| onError | `error.data.errors.필드명` | - | 특정 에러 필드 |
### API 데이터 바인딩 체크리스트
```
□ API 응답 구조를 실제로 확인했는가? (추측 금지)
□ ResponseHelper의 success/error 반환 구조를 이해했는가?
□ 중첩된 객체 경로가 정확한가? (예: error.data.errors.field)
□ 배열 vs 객체 구분이 명확한가?
□ Optional Chaining(?.)을 적절히 사용했는가?
```
### 자주 하는 실수
```json
// ❌ 잘못된 예: 응답 구조 추측
"deactivateWarningData": "{{error.response}}"
"deactivateWarningData": "{{error.dependent_templates}}"
// ✅ 올바른 예: 실제 구조 확인 후 사용
"deactivateWarningData": "{{error.data}}"
// 모달에서: {{_global.deactivateWarningData.errors.dependent_templates}}
```
---
## 에러 핸들링 시스템 (errorHandling)
> **버전**: engine-v1.6.0+
HTTP 에러 코드별로 다른 처리 로직을 정의할 수 있습니다.
### errorHandling vs onError 역할 분리
| 속성 | 역할 | 실행 조건 |
|------|------|----------|
| `errorHandling` | 특정 에러 코드별 처리 | 해당 코드 또는 default가 정의된 경우 |
| `onError` | 범용 에러 처리 (폴백) | errorHandling에 해당 코드가 없을 때 |
### 처리 흐름
```text
API 에러 발생 (예: 403)
↓
errorHandling[403] 있음?
├── ✅ → errorHandling[403] 실행 (종료)
↓ ❌
errorHandling[default] 있음?
├── ✅ → errorHandling[default] 실행 (종료)
↓ ❌
onError 있음?
├── ✅ → onError 실행 (종료)
↓ ❌
상위 레벨 errorHandling 확인
```
### 기본 사용법
```json
{
"type": "click",
"handler": "apiCall",
"auth_required": true,
"target": "/api/admin/users/{{id}}",
"params": { "method": "DELETE" },
"errorHandling": {
"403": {
"handler": "openModal",
"target": "permission_denied_modal"
},
"404": {
"handler": "toast",
"params": { "type": "warning", "message": "$t:errors.user_not_found" }
}
},
"onError": {
"handler": "toast",
"params": { "type": "error", "message": "{{error.message}}" }
}
}
```
---
## 관련 문서
- [액션 핸들러 인덱스](actions-handlers.md)
- [네비게이션 핸들러](actions-handlers-navigation.md)
- [UI 핸들러](actions-handlers-ui.md)
- [상태 관리](state-management.md)
- [데이터 소스](data-sources.md)
File diff suppressed because it is too large. Load diff
+245
View File
@@ -0,0 +1,245 @@
# 액션 핸들러 - 핸들러별 상세 사용법
> **메인 문서**: [actions.md](actions.md)
> **관련 문서**: [actions-g7core-api.md](actions-g7core-api.md) | [layout-json.md](layout-json.md) | [state-management.md](state-management.md)
---
## TL;DR (5초 요약)
```text
1. navigate: 페이지 이동 (path, query, mergeQuery 옵션)
2. apiCall: API 호출 (method, endpoint, body, onSuccess/onError)
3. setState: 상태 변경 (_global/_local/_isolated 경로, target 옵션)
4. openModal/closeModal: 모달 열기/닫기
5. sequence/parallel: 여러 액션 순차/병렬 실행
```
---
## 하위 문서 안내
이 문서는 규모가 커서 카테고리별로 분할되었습니다. 아래 링크를 통해 각 핸들러에 대한 상세 내용을 확인하세요.
| 하위 문서 | 주요 핸들러 | 설명 |
|----------|------------|------|
| [actions-handlers-navigation.md](actions-handlers-navigation.md) | navigate, navigateBack, openWindow, replaceUrl, reloadRoutes, refresh | 페이지 이동 및 라우트 관리 |
| [actions-handlers-state.md](actions-handlers-state.md) | apiCall, setState, setError, refetchDataSource, remount | API 호출 및 상태 관리 |
| [actions-handlers-ui.md](actions-handlers-ui.md) | login/logout, openModal/closeModal, toast, switch, sequence/parallel, loadScript, callExternal | UI 인터랙션 및 외부 스크립트 |
---
## 목차
### 네비게이션 핸들러 → [상세 문서](actions-handlers-navigation.md)
1. [navigate](actions-handlers-navigation.md#navigate) - 페이지 이동
2. [navigateBack](actions-handlers-navigation.md#navigateback) - 뒤로 가기
3. [openWindow](actions-handlers-navigation.md#openwindow) - 새 창/탭에서 열기
4. [replaceUrl](actions-handlers-navigation.md#replaceurl) - URL만 변경 (refetch 없음)
5. [reloadRoutes](actions-handlers-navigation.md#reloadroutes) - 라우트 재로드
6. [refresh](actions-handlers-navigation.md#refresh) - 페이지 새로고침
### 상태 관리 핸들러 → [상세 문서](actions-handlers-state.md)
5. [apiCall](actions-handlers-state.md#apicall) - API 호출
6. [setState](actions-handlers-state.md#setstate) - 상태 변경
7. [setError](actions-handlers-state.md#seterror) - 에러 상태 설정
8. [refetchDataSource](actions-handlers-state.md#refetchdatasource) - 데이터 소스 재조회
9. [appendDataSource](actions-handlers-state.md#appenddatasource) - 데이터 소스 병합 (무한스크롤)
10. [remount](actions-handlers-state.md#remount) - 컴포넌트 리마운트
11. [onSuccess/onError 후속 액션](actions-handlers-state.md#onsuccessonerror-후속-액션)
12. [API 데이터 바인딩 규칙](actions-handlers-state.md#api-데이터-바인딩-규칙)
13. [에러 핸들링 시스템](actions-handlers-state.md#에러-핸들링-시스템-errorhandling)
### UI 인터랙션 핸들러 → [상세 문서](actions-handlers-ui.md)
14. [login / logout](actions-handlers-ui.md#login--logout) - 인증
15. [openModal / closeModal](actions-handlers-ui.md#openmodal--closemodal) - 모달
16. [showAlert / toast](actions-handlers-ui.md#showalert--toast) - 알림
17. [confirm (액션 속성)](actions-handlers-ui.md#confirm-액션-속성) - 실행 전 확인 대화상자
18. [switch](actions-handlers-ui.md#switch) - 조건부 액션
19. [sequence / parallel](actions-handlers-ui.md#sequence--parallel) - 액션 조합
20. [reloadTranslations](actions-handlers-ui.md#reloadtranslations) - 다국어 재로드
21. [showErrorPage](actions-handlers-ui.md#showerrorpage) - 에러 페이지
22. [loadScript](actions-handlers-ui.md#loadscript) ⭐ NEW - 외부 스크립트 로드
23. [callExternal](actions-handlers-ui.md#callexternal) ⭐ NEW - 외부 라이브러리 호출
24. [실전 예시](actions-handlers-ui.md#실전-예시)
---
## 핸들러 빠른 참조
### 자주 사용하는 핸들러
| 핸들러 | 용도 | 상세 문서 |
|--------|------|----------|
| `navigate` | 페이지 이동 | [navigation](actions-handlers-navigation.md#navigate) |
| `openWindow` | 새 창/탭에서 열기 | [navigation](actions-handlers-navigation.md#openwindow) |
| `replaceUrl` | URL만 변경 (refetch 없음) | [navigation](actions-handlers-navigation.md#replaceurl) |
| `apiCall` | API 호출 | [state](actions-handlers-state.md#apicall) |
| `setState` | 상태 변경 | [state](actions-handlers-state.md#setstate) |
| `openModal` | 모달 열기 | [ui](actions-handlers-ui.md#openmodal--closemodal) |
| `closeModal` | 모달 닫기 | [ui](actions-handlers-ui.md#openmodal--closemodal) |
| `toast` | 토스트 알림 | [ui](actions-handlers-ui.md#showalert--toast) |
| `sequence` | 순차 실행 | [ui](actions-handlers-ui.md#sequence--parallel) |
| `suppress` | 에러 전파 방지 (no-op) | [error](layout-json-features-error.md#에러-전파-방지-suppress-핸들러) |
### 핸들러별 필수 속성
| 핸들러 | 필수 속성 | 선택 속성 |
|--------|----------|----------|
| `navigate` | `params.path` | `params.query`, `params.mergeQuery`, `params.replace` |
| `openWindow` | `params.path` | - |
| `replaceUrl` | - | `params.path`, `params.query`, `params.mergeQuery` |
| `apiCall` | `target` | `params.method`, `params.body`, `params.contentType`, `auth_required`, `onSuccess`, `onError` |
| `setState` | `params.*` | `params.target` (global/local/isolated) |
| `openModal` | `target` (모달 ID) | - |
| `toast` | `params.message` | `params.type`, `params.duration` |
| `switch` | `cases` | `params.value` |
| `sequence` | `actions` | - |
| `parallel` | `actions` | - |
| `suppress` | - | - |
---
## 모듈 커스텀 핸들러 다국어 처리
모듈에서 커스텀 핸들러를 개발할 때, 사용자에게 표시되는 모든 문자열은 반드시 다국어 처리해야 합니다.
### 핵심 원칙
```text
필수: 모든 사용자 표시 문자열은 G7Core.t() 사용
필수: 다국어 키는 moduleIdentifier로 시작
✅ 필수: 영어 폴백 메시지 제공
✅ 필수: 파라미터는 {param} 형식 사용
❌ 금지: 한글 문자열 하드코딩 (toast, 알림 메시지 등)
예외: 로케일별 콘텐츠 생성용 상수맵은 허용 (예: DETAIL_REF_TRANSLATIONS)
```
### 다국어 키 네이밍 규칙
```text
[moduleId].admin.[section].handler.[message_key]
```
**예시**:
- `sirsoft-ecommerce.admin.product.handler.category_max_5`
- `sirsoft-ecommerce.admin.product.handler.options_generated`
**키 접미사 규칙**:
| 접미사 | 용도 | 예시 |
|--------|------|------|
| `_success` | 성공 메시지 | `copy_success` |
| `_error`, `_failed` | 오류 메시지 | `copy_error`, `upload_failed` |
| `_required` | 필수 입력 안내 | `name_required` |
| `_max_N` | 최대 개수 제한 | `category_max_5` |
### 구현 패턴
**기본 패턴**:
```typescript
// ❌ DON'T: 하드코딩
G7Core.toast?.warning?.('최대 5개까지 선택 가능합니다.');
// ✅ DO: G7Core.t() + 영어 폴백
G7Core.toast?.warning?.(
G7Core.t?.('sirsoft-ecommerce.admin.product.handler.category_max_5')
?? 'You can select up to 5 categories.'
);
```
**파라미터 패턴**:
```typescript
// ❌ DON'T: 템플릿 리터럴만 사용
G7Core.toast?.success?.(`${count}개의 옵션이 생성되었습니다.`);
// ✅ DO: 파라미터 전달 + 폴백
G7Core.toast?.success?.(
G7Core.t?.('sirsoft-ecommerce.admin.product.handler.options_generated', { count })
?? `${count} options have been generated.`
);
```
**다국어 파일 등록** (`resources/lang/ko.json`, `en.json`):
```json
// ko.json
{
"admin": {
"product": {
"handler": {
"category_max_5": "최대 5개까지 선택 가능합니다.",
"options_generated": "{count}개의 옵션이 생성되었습니다."
}
}
}
}
// en.json
{
"admin": {
"product": {
"handler": {
"category_max_5": "You can select up to 5 categories.",
"options_generated": "{count} options have been generated."
}
}
}
}
```
### 예외: 로케일별 콘텐츠 생성용 상수맵
로케일별로 다른 콘텐츠를 **생성**해야 하는 경우, 상수맵은 허용됩니다:
```typescript
// ✅ 허용: 로케일별 콘텐츠 생성용 상수맵
const DETAIL_REF_TRANSLATIONS: Record<string, Record<string, string>> = {
ko: { goods_name: '상품명', model_name: '모델명' },
en: { goods_name: 'Product Name', model_name: 'Model Name' },
};
// 사용: 특정 로케일의 콘텐츠 생성
const text = DETAIL_REF_TRANSLATIONS[locale]?.[key] ?? key;
```
### 핸들러 개발 체크리스트
```text
□ 모든 toast 메시지에 G7Core.t() 적용
□ 모든 확인/알림 모달 텍스트에 G7Core.t() 적용
□ 영어 폴백 메시지 제공 (G7Core.t?.() ?? 'fallback')
□ 다국어 파일(ko.json, en.json)에 키 추가
□ 파라미터는 {param} 형식으로 정의
□ 로그 메시지는 다국어 처리 불필요 (logger.log, logger.warn 등)
```
---
## 증상별 문서 찾기
| 증상 | 관련 문서 | 핵심 키워드 |
|------|----------|------------|
| 페이지 이동 안 됨 | [navigation](actions-handlers-navigation.md) | navigate, path, replace |
| API 호출 실패 | [state](actions-handlers-state.md#apicall) | apiCall, auth_required, onError |
| 상태 변경 안 됨 | [state](actions-handlers-state.md#setstate) | setState, target: global/local/isolated |
| 모달 안 열림/안 닫힘 | [ui](actions-handlers-ui.md#openmodal--closemodal) | openModal, closeModal, modalStack |
| 토스트 안 나옴 | [ui](actions-handlers-ui.md#showalert--toast) | toast, params.type |
| 외부 스크립트 로드 | [ui](actions-handlers-ui.md#loadscript) | loadScript, onLoad |
| 조건부 액션 분기 | [ui](actions-handlers-ui.md#switch) | switch, cases, default |
---
## 관련 문서
- [액션 시스템 기초](actions.md)
- [G7Core API](actions-g7core-api.md)
- [상태 관리](state-management.md)
- [데이터 소스](data-sources.md)
- [레이아웃 JSON](layout-json.md)
+596
View File
@@ -0,0 +1,596 @@
# 액션 핸들러 가이드
> **관련 문서**: [레이아웃 JSON](layout-json.md) | [데이터 바인딩](data-binding.md) | [상태 관리](state-management.md)
---
## TL;DR (5초 요약)
```text
1. 구조: type 또는 event(이벤트), handler(핸들러명), params(옵션)
2. type: 표준 DOM 이벤트 (click, change) → $event.target.value
3. event: 커스텀 콜백 (CodeEditor, DataGrid) → $args[0]
4. 핸들러명 정확히: apiCall (❌ api), navigate (❌ goto)
5. 조합: sequence(순차), parallel(병렬), onSuccess/onError
```
---
## 분리된 문서
이 문서는 가독성을 위해 다음과 같이 분리되었습니다:
| 문서 | 내용 |
|------|------|
| **actions.md** (현재) | 개요, 액션 정의 구조, 내장 핸들러 목록, 주의사항 |
| [actions-handlers.md](actions-handlers.md) | 핸들러별 상세 사용법, onSuccess/onError, errorHandling, 실전 예시 |
| [actions-g7core-api.md](actions-g7core-api.md) | G7Core.dispatch, 편의 API, 이벤트 헬퍼 |
---
## 목차
1. [개요](#개요)
2. [액션 정의 구조](#액션-정의-구조)
3. [내장 핸들러 목록](#내장-핸들러-목록)
4. [주의사항](#주의사항)
---
## 개요
**액션 핸들러**는 레이아웃 JSON에서 사용자 인터랙션(클릭, 폼 제출 등)을 처리하는 시스템입니다.
### 핵심 원칙
```text
중요: 핸들러 이름은 ActionDispatcher에 정의된 이름과 정확히 일치해야 함
✅ 필수: apiCall (❌ api 아님)
✅ 필수: navigate, setState, openModal, closeModal, toast 등
```
---
## 액션 정의 구조
```json
{
"actions": [
{
"type": "click",
"handler": "navigate",
"target": "/path",
"params": {
"key": "value"
},
"onSuccess": [],
"onError": [],
"confirm": "확인 메시지",
"key": "Enter"
}
]
}
```
### 필드 설명
| 필드 | 타입 | 필수 | 설명 |
|------|------|------|------|
| `type` | string | | 표준 DOM 이벤트 타입 (click, change, submit 등). `event` 필드와 상호 배타적 |
| `event` | string | | 커스텀 콜백 이벤트명 (onSortChange, onChange 등). `type` 필드와 상호 배타적 |
| `handler` | string | ✅ | 액션 핸들러 이름 |
| `target` | string | ❌ | 핸들러별 타겟 값 (URL, 모달 ID 등) |
| `params` | object | ❌ | 핸들러에 전달할 파라미터 |
| `onSuccess` | array | ❌ | 성공 시 실행할 후속 액션 (단일 또는 배열) |
| `onError` | array | ❌ | 실패 시 실행할 후속 액션 (단일 또는 배열) |
| `confirm` | string | ❌ | 실행 전 확인 대화상자 메시지 |
| `key` | string | ❌ | 키보드 이벤트 필터 (Enter, Escape 등) |
| `cases` | object | ❌ | switch 핸들러용 케이스별 액션 정의 |
| `if` | string | ❌ | 조건부 실행 (`{{expression}}` 형식, truthy일 때만 실행) |
| `debounce` | number \| object | ❌ | 연속 호출 제어 (300 또는 { delay, leading, trailing }) |
| `resultTo` | object | ❌ | 핸들러 실행 결과를 상태에 저장 (engine-v1.11.0+). `{ target, key, merge }` |
| `actionRef` | string | ❌ | `named_actions`에 정의된 액션을 참조 (engine-v1.19.0+). handler/params 대신 사용 |
> **참고**: `type`과 `event` 중 하나는 반드시 필요. `type`은 표준 DOM 이벤트용, `event`는 커스텀 콜백용
### actionRef로 named_actions 참조 (engine-v1.19.0+)
`named_actions`에 정의된 재사용 가능한 액션을 `actionRef`로 참조합니다. 동일한 액션이 여러 컴포넌트에서 반복될 때 중복을 제거합니다.
```json
// 레이아웃 최상위에 named_actions 정의
{
"named_actions": {
"searchProducts": {
"handler": "navigate",
"params": {
"path": "/admin/products",
"replace": true,
"mergeQuery": true,
"query": { "page": 1, "search_keyword": "{{$computed.keyword || null}}" }
}
}
},
"components": [
{
"name": "Button",
"actions": [{ "type": "click", "actionRef": "searchProducts" }]
},
{
"name": "Input",
"actions": [{ "type": "keypress", "key": "Enter", "actionRef": "searchProducts" }]
}
]
}
```
**규칙**:
- `actionRef` 사용 시 `handler`/`params`/`target`은 생략 가능 (named_actions 정의에서 가져옴)
- `type`, `key`, `event` 등 이벤트 바인딩 속성은 인라인에서 지정
- 인라인에 `handler`/`params`를 함께 지정하면 named_actions 값을 **오버라이드**
- `named_actions`는 Partial 파일이 아닌 **부모 레이아웃**에 정의
- 상속(extends) 시 자식 레이아웃의 `named_actions`가 부모를 오버라이드
### resultTo로 핸들러 결과 저장 (engine-v1.11.0+)
핸들러 실행 결과를 상태(`_local`, `_global`, `_isolated`)에 저장합니다. 커스텀 핸들러가 반환한 값을 레이아웃에서 활용할 때 사용합니다.
```json
{
"handler": "sirsoft-ecommerce.buildProductColumns",
"params": {
"baseColumns": [],
"currencies": "{{ecommerceSettings?.data?.language_currency?.currencies}}"
},
"resultTo": {
"target": "_local",
"key": "productColumns"
}
}
```
#### resultTo 필드
| 필드 | 타입 | 필수 | 기본값 | 설명 |
| ---- | ---- | ---- | ------ | ---- |
| `target` | string | ✅ | - | 저장 대상: `"_local"`, `"_global"`, `"_isolated"` |
| `key` | string | ✅ | - | 상태 키 (dot notation 지원, `{{}}` 동적 경로 지원) |
| `merge` | string | ❌ | `"deep"` | 병합 모드: `"replace"`, `"shallow"`, `"deep"` |
#### 병합 모드
| 모드 | 설명 |
| ---- | ---- |
| `replace` | 기존 값을 완전히 교체 |
| `shallow` | 1단계만 병합 (`{ ...existing, ...new }`) |
| `deep` | 중첩 객체까지 재귀 병합 (기본값) |
#### 동적 key 예시
```json
{
"handler": "sirsoft-ecommerce.calculatePrice",
"params": { "optionId": "{{option.id}}" },
"resultTo": {
"target": "_local",
"key": "calculatedPrices.{{option.id}}",
"merge": "shallow"
}
}
```
#### 주의사항
```text
resultTo는 핸들러가 값을 반환(return)해야 동작 — undefined 반환 시 무시됨
init_actions에서 _local 저장 시 globalStateUpdater 경유 (componentContext 없는 환경)
✅ onSuccess/onError와 독립적으로 동작 — resultTo 저장 후 onSuccess 실행
```
### 이벤트 타입
| type | 설명 | 사용 컴포넌트 |
|------|------|--------------|
| `click` | 클릭 이벤트 | Button, Link, 모든 요소 |
| `change` | 값 변경 이벤트 | Input, Select, Checkbox |
| `submit` | 폼 제출 이벤트 | Form |
| `keydown` | 키보드 누름 이벤트 | Input, 모든 요소 |
| `keyup` | 키보드 뗌 이벤트 | Input, 모든 요소 |
| `focus` | 포커스 획득 | Input, Select |
| `blur` | 포커스 해제 | Input, Select |
| `scroll` | 스크롤 이벤트 | 스크롤 가능한 요소 (Div 등) |
| `mount` | 컴포넌트 마운트 | 모든 요소 |
| `unmount` | 컴포넌트 언마운트 | 모든 요소 |
| `onSortEnd` | sortable 정렬 완료 | sortable 컴포넌트 |
| `onSortStart` | sortable 드래그 시작 | sortable 컴포넌트 |
> **참고**: HTML5 네이티브 드래그 이벤트(`dragstart`, `dragover`, `drop` 등)도 지원되지만,
> 목록 정렬에는 `sortable` 속성 + `onSortEnd` 이벤트 사용을 권장합니다.
> 상세: [actions-handlers-state.md](actions-handlers-state.md#sortable-드래그앤드롭-정렬)
### 커스텀 이벤트 (event 필드)
표준 DOM 이벤트가 아닌 **컴포넌트 콜백**을 처리할 때 사용합니다.
```text
중요: 컴포넌트가 DOM Event 객체가 아닌 값(문자열, 배열 등)을 직접 전달하는 경우
type 필드 대신 event 필드를 사용하고, $args로 인자에 접근해야 함
```
| 방식 | 필드 | 데이터 접근 | 사용 상황 |
|------|------|-------------|----------|
| 표준 DOM 이벤트 | `type: "change"` | `{{$event.target.value}}` | Input, Select, Checkbox 등 |
| 커스텀 콜백 | `event: "onChange"` | `{{$args[0]}}` | CodeEditor, DataGrid 등 |
#### 예시: CodeEditor onChange 처리
```json
// ✅ 올바름: CodeEditor는 string을 직접 전달
{
"type": "composite",
"name": "CodeEditor",
"props": {
"value": "{{_global.editorContent}}"
},
"actions": [
{
"event": "onChange",
"handler": "setState",
"params": {
"target": "global",
"editorContent": "{{$args[0]}}"
}
}
]
}
// ❌ 잘못됨: type: "change"는 DOM Event 객체를 기대함
{
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"editorContent": "{{$event.target.value}}"
}
}
]
}
```
#### 예시: DataGrid onRowAction 처리
```json
{
"type": "composite",
"name": "DataGrid",
"actions": [
{
"event": "onRowAction",
"handler": "switch",
"params": {
"value": "{{$args[0].actionId}}"
},
"cases": {
"delete": {
"handler": "apiCall",
"target": "/api/items/{{$args[0].row.id}}",
"params": { "method": "DELETE" }
}
}
}
]
}
```
#### 예시: DataGrid onSelectionChange 처리
```json
// ✅ 올바름: onSelectionChange는 선택된 ID 배열을 직접 전달
{
"type": "composite",
"name": "DataGrid",
"props": {
"selectable": true,
"selectedIds": "{{_local.selectedIds ?? []}}"
},
"actions": [
{
"event": "onSelectionChange",
"handler": "setState",
"params": {
"target": "local",
"selectedIds": "{{$args[0]}}"
}
}
]
}
// ❌ 잘못됨: $event는 DOM Event 객체 → DataGrid가 깨짐 (컴포넌트 로드 실패)
{
"actions": [
{
"event": "onSelectionChange",
"handler": "setState",
"params": {
"selectedIds": "{{$event}}"
}
}
]
}
```
#### 주요 composite 컴포넌트 콜백 목록
```text
필수: 아래 콜백은 모두 event 필드 + $args[0] 패턴 사용 필수
$event 사용 시 DOM Event 객체가 전달되어 컴포넌트 오류 발생
```
| 컴포넌트 | 콜백 이벤트 | $args[0] 값 |
|----------|------------|-------------|
| DataGrid | `onSelectionChange` | 선택된 ID 배열 `number[]` |
| DataGrid | `onRowAction` | `{ actionId, row }` 객체 |
| DataGrid | `onSortChange` | `{ key, direction }` 객체 |
| DataGrid | `onPageChange` | 페이지 번호 `number` |
| CategoryTree | `onNodeSelect` | 선택된 노드 객체 |
| CodeEditor | `onChange` | 에디터 내용 `string` |
| HtmlEditor | `onChange` | HTML 내용 `string` |
| FileUploader | `onUploadComplete` | 업로드 결과 객체 |
| TagInput | `onChange` | 태그 배열 `string[]` |
#### $args 배열 접근
`event` 필드 사용 시 콜백의 모든 인자가 `$args` 배열에 저장됩니다:
| 바인딩 | 설명 |
|--------|------|
| `{{$args[0]}}` | 첫 번째 인자 |
| `{{$args[1]}}` | 두 번째 인자 |
| `{{$args[0].field}}` | 첫 번째 인자의 field 속성 |
---
## 내장 핸들러 목록
| 핸들러 | 설명 | target 용도 | params 용도 |
|--------|------|-------------|-------------|
| `navigate` | 페이지 이동 | - | path, mergeQuery, query |
| `navigateBack` | 브라우저 뒤로가기 | - | - |
| `navigateForward` | 브라우저 앞으로가기 | - | - |
| `apiCall` | API 호출 | 엔드포인트 URL | method, body, headers |
| `login` | 로그인 (토큰 저장) | 인증 타입 (admin/user) | body (email, password) |
| `logout` | 로그아웃 | - | - |
| `setState` | 상태 변경 | - | target (global/local), 상태 값들 |
| `setError` | 에러 상태 설정 | 에러 메시지 | - |
| `openModal` | 모달 열기 | 모달 ID | - |
| `closeModal` | 모달 닫기 | - | - |
| `showAlert` | 알림 표시 (alert) | 메시지 | - |
| `toast` | 토스트 알림 표시 | - | type, message |
| `switch` | 조건 분기 처리 | - | params.value, cases (케이스별 액션 정의, default 지원) |
| `sequence` | 순차 액션 실행 | - | actions (액션 배열) |
| `parallel` | 병렬 액션 실행 | - | actions (액션 배열) |
| `refetchDataSource` | 데이터 소스 다시 fetch | - | dataSourceId |
| `remount` | 컴포넌트 강제 리마운트 | - | componentId |
| `reloadRoutes` | 라우트 다시 로드 | - | - |
| `reloadTranslations` | 다국어 파일 다시 로드 | - | - |
| `refresh` | 현재 페이지 새로고침 | - | - |
| `stopPropagation` | 이벤트 전파 중지 | - | - |
| `preventDefault` | 기본 동작 방지 | - | - |
| `showErrorPage` | 에러 페이지 렌더링 | - | errorCode, target, containerId, layout |
> **상세 문서**: 각 핸들러별 상세 사용법은 [actions-handlers.md](actions-handlers.md) 참조
---
## Debounce 옵션
연속 호출되는 액션의 실행을 제어하여 성능을 최적화합니다. 텍스트 입력 필드의 실시간 업데이트 등에 유용합니다.
### 기본 사용법
```json
{
"type": "change",
"handler": "sirsoft-ecommerce.updateOptionField",
"params": {
"productId": "{{row.id}}",
"field": "selling_price",
"value": "{{$event.target.value}}"
},
"debounce": 300
}
```
### 상세 설정
```json
{
"type": "change",
"handler": "myHandler",
"debounce": {
"delay": 500,
"leading": false,
"trailing": true
}
}
```
### 옵션 설명
| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `delay` | number | 300 | 대기 시간 (ms) |
| `leading` | boolean | false | 첫 호출 즉시 실행 |
| `trailing` | boolean | true | 마지막 호출 후 실행 |
### 주의사항
```text
debounce 사용 시 이벤트 객체는 비동기 접근을 위해 추출됩니다
✅ 정상 접근 가능: $event.target.value, $event.target.checked
❌ 사용 불가: $event.preventDefault(), $event.stopPropagation()
```
### 사용 시나리오
| 시나리오 | debounce 권장 |
|----------|---------------|
| 가격 입력 필드 | ✅ 300ms |
| 검색 입력 필드 | ✅ 300~500ms |
| 텍스트 입력 필드 | ✅ 300ms |
| Toggle/Checkbox | ❌ 즉시 실행 |
| Select 드롭다운 | ❌ 즉시 실행 |
| 버튼 클릭 | ❌ 즉시 실행 |
---
## 주의사항
### 1. 핸들러 이름 정확히 사용
```json
// ✅ 올바른 핸들러 이름
{ "handler": "apiCall" }
{ "handler": "navigate" }
{ "handler": "setState" }
// ❌ 잘못된 핸들러 이름
{ "handler": "api" }
{ "handler": "nav" }
{ "handler": "set_state" }
```
### 2. params vs target 구분
```json
// navigate: params.path 사용
{
"handler": "navigate",
"params": { "path": "/admin/users" }
}
// apiCall: target으로 URL 지정
{
"handler": "apiCall",
"target": "/api/admin/users"
}
// openModal: target으로 모달 ID 지정
{
"handler": "openModal",
"target": "confirm_modal"
}
```
### 3. 상태 변경 target 타입
```json
// 전역 상태 변경
{
"handler": "setState",
"params": {
"target": "global",
"selectedIds": []
}
}
// 로컬 상태 변경
{
"handler": "setState",
"params": {
"target": "local",
"isEditing": true
}
}
```
### 4. 후속 액션 체인
```json
{
"handler": "apiCall",
"target": "/api/admin/users/{{row.id}}",
"params": { "method": "DELETE" },
"onSuccess": [
{ "handler": "toast", "params": { "type": "success", "message": "삭제되었습니다" } },
{ "handler": "navigate", "params": { "path": "/admin/users" } }
],
"onError": [
{ "handler": "toast", "params": { "type": "error", "message": "삭제 실패" } }
]
}
```
### 5. 확인 다이얼로그
```json
{
"handler": "apiCall",
"target": "/api/admin/users/{{row.id}}",
"params": { "method": "DELETE" },
"confirm": "정말 삭제하시겠습니까?"
}
```
### 6. 키보드 이벤트 필터링
```json
{
"type": "keydown",
"handler": "navigate",
"params": { "path": "/admin/search" },
"key": "Enter"
}
```
### 7. $event 바인딩 시 값 추출
```text
주의: {{$event}}를 직접 사용하면 Event 객체가 전달됩니다
Input/Select: "{{$event.target.value}}" 사용
Checkbox/Toggle: "{{$event.target.checked}}" 사용
❌ 잘못됨: "{{$event}}" → NaN 오류 발생
```
```json
// ✅ 올바른 예시 - Input/Select
{
"type": "change",
"handler": "setState",
"params": {
"searchQuery": "{{$event.target.value}}"
}
}
// ✅ 올바른 예시 - Checkbox/Toggle
{
"type": "change",
"handler": "setState",
"params": {
"isActive": "{{$event.target.checked}}"
}
}
// ❌ 잘못된 예시 - NaN 오류 발생
{
"type": "change",
"handler": "myHandler",
"params": {
"value": "{{$event}}"
}
}
```
**주의**: `{{$event}}`를 숫자 필드에 사용하면 `parseFloat(eventObject)`가 NaN을 반환하여 0으로 처리됩니다.
---
## 관련 문서
- [핸들러 상세 사용법](actions-handlers.md) - 각 핸들러별 파라미터, onSuccess/onError, errorHandling
- [G7Core API](actions-g7core-api.md) - React 컴포넌트에서 액션 실행
- [레이아웃 JSON 스키마](layout-json.md) - 전체 레이아웃 구조
- [데이터 바인딩](data-binding.md) - `{{}}` 표현식, `$t:` 다국어
- [상태 관리](state-management.md) - `_global` 전역 상태
+310
View File
@@ -0,0 +1,310 @@
# 인증 시스템 (AuthManager)
> 그누보드7 프론트엔드 인증 시스템 가이드
---
## TL;DR (5초 요약)
```text
1. AuthManager: 싱글톤 인증 상태 관리 클래스
2. 토큰 자동 갱신: 401 응답 시 자동 처리
3. 컨텍스트 분리: admin/user 분리 관리
4. 로그인: login 핸들러 또는 AuthManager.login()
5. 이벤트: auth:login, auth:logout 이벤트 구독 가능
```
---
## 목차
- [개요](#개요)
- [인증 흐름](#인증-흐름)
- [인증 타입 판단](#인증-타입-판단)
- [API 엔드포인트](#api-엔드포인트)
- [토큰 자동 갱신](#토큰-자동-갱신)
- [로그인 시 로케일 처리](#로그인-시-로케일-처리)
- [사용 예시](#사용-예시)
- [이벤트 처리](#이벤트-처리)
- [주의사항](#주의사항)
- [관련 문서](#관련-문서)
---
## 개요
`AuthManager`는 프론트엔드 인증 상태 관리를 담당하는 싱글톤 클래스입니다.
**위치**: `/resources/js/core/auth/AuthManager.ts`
**주요 기능**:
- 인증 상태 확인 및 관리
- 토큰 자동 갱신 (401 응답 시)
- 로그인/로그아웃 처리
- 관리자/일반사용자 컨텍스트 분리
- 로그인 시 사용자 언어 설정 자동 반영
---
## 인증 흐름
```
사용자가 /admin/dashboard 접근
↓
Router.navigateToCurrentPath()
↓
auth_required: true 확인
↓
auth_type 결정 (명시 또는 경로 기반)
↓
AuthManager.checkAuth(authType)
↓
┌────┴────┐
↓ ↓
인증됨 미인증
↓ ↓
렌더링 로그인 페이지로 리다이렉트
(?redirect 파라미터 포함)
```
---
## 인증 타입 판단
`auth_type`이 라우트에 명시되지 않은 경우 경로 기반으로 자동 판단:
```typescript
// Router.ts
private getAuthType(route: Route, pathname: string): AuthType {
// 1. 라우트에 명시된 경우 사용
if (route.auth_type) {
return route.auth_type;
}
// 2. 경로 기반 자동 판단
if (pathname.startsWith('/admin')) {
return 'admin';
}
return 'user';
}
```
**우선순위**:
1. 라우트에 명시된 `auth_type` 값
2. 경로 기반 자동 판단 (`/admin` → admin, 그 외 → user)
---
## API 엔드포인트
| 구분 | 로그인 | 사용자 정보 | 로그아웃 | 토큰 갱신 |
|------|--------|------------|---------|----------|
| **관리자** | `/auth/login` | `/admin/auth/user` | `/admin/auth/logout` | `/admin/auth/refresh` |
| **일반사용자** | `/auth/login` | `/user/auth/user` | `/user/auth/logout` | `/user/auth/refresh` |
**공통**:
- 로그인 엔드포인트는 관리자/일반사용자 동일 (`/auth/login`)
- 인증 후 작업은 각각 분리된 엔드포인트 사용
- API 인증은 Bearer 토큰 전용 (세션 기반 인증 미사용)
- 401 응답 시 서버가 세션 쿠키 만료 헤더를 자동 전송 (잔존 쿠키 정리)
---
## 토큰 자동 갱신
ApiClient의 응답 인터셉터에서 401 응답 시 자동으로 토큰 갱신을 시도합니다:
```typescript
// ApiClient.ts 응답 인터셉터
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
const authManager = AuthManager.getInstance();
const refreshed = await authManager.refreshToken();
if (refreshed) {
// 토큰 갱신 성공, 원래 요청 재시도
const token = this.getToken();
originalRequest.headers.Authorization = `Bearer ${token}`;
return this.client(originalRequest);
}
// 갱신 실패 시 로그아웃 처리
}
```
**동시 갱신 방지**: 여러 요청이 동시에 401을 받아도 하나의 갱신 요청만 수행
---
## 로그인 시 로케일 처리
로그인 성공 시 사용자의 `language` 설정을 자동으로 반영합니다:
```text
로그인 API 응답 수신
↓
user.language 확인
↓
현재 g7_locale과 비교
↓
┌────┴────┐
↓ ↓
동일 다름
↓ ↓
skip localStorage 저장
↓
TemplateApp.changeLocale() 호출
↓
UI 즉시 업데이트
```
**구현 위치**: `AuthManager.login()`
```typescript
// 로케일 변경 감지 및 처리
const userLanguage = response.data.user.language;
const currentLocale = localStorage.getItem('g7_locale');
const localeChanged = userLanguage && userLanguage !== currentLocale;
if (userLanguage) {
localStorage.setItem('g7_locale', userLanguage);
}
// 로케일이 변경된 경우 TemplateApp 재초기화
if (localeChanged && window.__templateApp) {
window.__templateApp.changeLocale(userLanguage);
}
```
**주요 포인트**:
- 사용자의 DB `language` 값을 `g7_locale` localStorage에 저장
- 로케일이 변경된 경우에만 `changeLocale()` 호출 (불필요한 재초기화 방지)
- SPA 환경에서도 새로고침 없이 즉시 반영
---
## 사용 예시
### routes.json 설정
```json
{
"routes": [
{
"path": "/admin/login",
"layout": "admin_login",
"auth_required": false
},
{
"path": "/admin/dashboard",
"layout": "admin_dashboard",
"auth_required": true
},
{
"path": "/profile",
"layout": "user_profile",
"auth_required": true,
"auth_type": "user"
}
]
}
```
### 로그인 후 리다이렉트
```typescript
// 로그인 성공 후 원래 페이지로 이동
const authManager = AuthManager.getInstance();
const redirectUrl = authManager.getRedirectUrl('admin');
window.location.href = redirectUrl; // URL 파라미터의 redirect 값 또는 기본 경로
```
---
## 이벤트 처리
AuthManager는 이벤트 기반으로 인증 상태 변경을 알립니다:
```typescript
const authManager = AuthManager.getInstance();
// 인증 상태 변경 감지
authManager.on('authStateChange', (state) => {
console.log('Auth state:', state.isAuthenticated, state.user);
});
// 로그아웃 감지
authManager.on('logout', () => {
console.log('User logged out');
});
```
**사용 가능한 이벤트**:
- `authStateChange`: 인증 상태가 변경될 때
- `logout`: 사용자가 로그아웃할 때
---
## 주의사항
### 로그인 페이지 설정
```
로그인 페이지는 반드시 auth_required: false로 설정
→ 그렇지 않으면 무한 루프 발생
```
```json
// ✅ DO
{
"path": "/admin/login",
"layout": "admin_login",
"auth_required": false
}
// ❌ DON'T - 무한 루프 발생
{
"path": "/admin/login",
"layout": "admin_login",
"auth_required": true
}
```
### 보안 규칙
- **리다이렉트 URL**: 같은 도메인 경로만 허용 (외부 URL 차단)
- **토큰 저장**: localStorage의 `auth_token` 키에 저장
### 토큰 관리
```typescript
// 토큰 저장 위치
localStorage.getItem('auth_token'); // 토큰 조회
localStorage.setItem('auth_token', token); // 토큰 저장
localStorage.removeItem('auth_token'); // 토큰 삭제 (로그아웃)
```
---
## 관련 문서
- [데이터 소스](./data-sources.md) - API 호출 및 인증 헤더
- [보안 및 검증](./security.md) - 프론트엔드 보안 규칙
- [컴포넌트](./components.md) - 로그인 폼 컴포넌트
---
## 체크리스트
인증 기능 구현 시 확인 사항:
- [ ] 로그인 페이지에 `auth_required: false` 설정
- [ ] 인증 필요 페이지에 `auth_required: true` 설정
- [ ] 적절한 `auth_type` 설정 (admin/user)
- [ ] 리다이렉트 URL 보안 검증
- [ ] 토큰 갱신 로직 테스트
- [ ] 로그아웃 시 토큰 정리 확인
Loaded 100 of 3967 files, more files were not shown because too many files have changed in this diff. Show more