chore(payment): NHN KCP와 NICE 결제 플러그인 개발 브랜치 분리
정식 develop에서는 기본 번들 정책에 따라 제거된 결제 플러그인을 별도 개발 브랜치에서 계속 유지보수할 수 있도록 복원한다.
This commit is contained in:
1 parent
49b21e788c
commit
657f546efc
651 files changed
+49118
-20476
No files matched your search
@@ -111,8 +111,7 @@
|
||||
├─ Step 4: 다운로드 (GitHub zipball 또는 --source/--local에서 복사)
|
||||
├─ Step 5: 백업 생성 (--no-backup 시 스킵)
|
||||
├─ Step 6: Composer install (_pending에서 실행, 변경 없으면 스킵)
|
||||
├─ Step 6.5: 신규 파일 manifest 생성 + 증분 적용 대상(3-way) 산출 (백업 있을 때)
|
||||
├─ Step 7: 파일 적용 (_pending → base_path, 기본=코어 변경분만 / --prune=전체 덮어쓰기)
|
||||
├─ 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)
|
||||
@@ -179,49 +178,16 @@ v접두사 자동 감지 (resolveGithubArchiveUrl):
|
||||
|
||||
> Vendor 번들 시스템 상세: [docs/extension/vendor-bundle.md](../extension/vendor-bundle.md)
|
||||
|
||||
### Step 6.5: 신규 파일 manifest + 증분 적용 대상 산출
|
||||
|
||||
```text
|
||||
백업이 있을 때(--no-backup 아님)만 수행:
|
||||
|
||||
1. writeNewFilesManifest() — 자동 롤백용 `_new_files_manifest.json` 기록
|
||||
(base=백업 vs theirs=_pending: 신 버전이 추가한 파일/디렉토리 목록)
|
||||
|
||||
2. computeApplyList() — 기본(증분) 모드의 3-way 적용 대상 산출 (--prune 시 스킵)
|
||||
· base = 구버전 원본 = 백업 스냅샷
|
||||
· theirs = 신 버전 = _pending
|
||||
· base 없음 → added / size·md5 다름 → changed / 동일 → 제외(스킵)
|
||||
· size 선필터 후 size 동일할 때만 md5 (mtime 비교 안 함 — _pending 은 추출 시각)
|
||||
· symlink / excludes / protected_paths 하위 → 목록 제외
|
||||
· 단, targets 에 더 구체적으로 명시된 경로는 상위 protected 를 오버라이드(아래 주석)
|
||||
```
|
||||
|
||||
> **protected_paths 오버라이드 (공개 #64 / 내부 #452)**: `protected_paths` 에는 확장 부모(`modules`·`plugins`·`templates`·`lang-packs`)가 포함되지만, `targets` 에는 `{domain}/_bundled` 가 명시된다. 3-way 산출(`computeApplyList`)과 신규 파일 manifest(`writeNewFilesManifest`)는 "targets 에 더 구체적(하위)으로 명시된 경로가 상위 protected 를 오버라이드"하도록 판정한다. 이로써 코어 배포본에 포함된 번들 확장의 갱신 파일(`_bundled/{id}/composer.json`·`vendor-bundle.json` 등)이 코어 업데이트로 정상 반영된다. 오버라이드는 target 이 protected 보다 **더 깊을 때만** 적용되므로, `storage`(target) == `storage`(protected) 같은 동일 경로는 여전히 제외된다. 확장 부모를 protected 에 둔 원래 의도(자동 발견 폴백이 활성 서브디렉토리 `modules/sirsoft-*` 를 삭제하는 #347 방어)는 그대로 유지된다 — 자동 발견 폴백은 targets 순회가 아니므로 오버라이드 영향을 받지 않는다.
|
||||
|
||||
### Step 7: 파일 적용
|
||||
|
||||
```text
|
||||
기본(증분) 모드 — --prune 미지정 + 백업 있음:
|
||||
- Step 6.5 의 applyList(코어가 실제 변경/추가한 파일)에 있는 파일만 적용
|
||||
- 코어가 건드리지 않은 파일은 복사·chmod·chown·mtime 갱신을 전부 스킵 → 현재 디스크
|
||||
상태(사용자 수정 포함 가능)를 그대로 보존
|
||||
- orphan(소스에 없는 대상 파일) 삭제 안 함 → 사용자가 추가한 신규 파일 보존
|
||||
|
||||
--prune 모드 (또는 백업 부재 fallback):
|
||||
- targets 전체 무조건 덮어쓰기 + orphan 삭제 (기존 동작)
|
||||
- 백업 부재 시 base 가 없어 3-way 불가 → 안전하게 전체 덮어쓰기로 회귀 + 안내 출력
|
||||
|
||||
공통:
|
||||
- _pending 소스에서 config('app.update.targets') 에 해당하는 파일/디렉토리 선택적 덮어쓰기
|
||||
- 자동 발견 폴백: targets 에 미등재된 source 최상위 항목도 스캔하여 적용
|
||||
(config('app.update.protected_paths') 와 config('app.update.excludes') 매치 시 스킵)
|
||||
- FilePermissionHelper::copyDirectory() 사용 → 원본 파일 권한 보존
|
||||
- ExtensionPendingHelper::copyToActive() 미사용 (권한 유실 방지)
|
||||
```
|
||||
|
||||
> **기본 동작 변경 배경 (공개 #64)**: 이전에는 Step 7 이 targets 전체를 무조건 재복사하고 orphan 을 삭제하여, 사용자가 수정한 `public/.htaccess` 커스텀 블록이나 `_bundled/` 아래 커스텀 확장이 소실되는 사고가 반복 제보되었다. 기본 동작을 "코어가 실제로 변경/추가한 파일만 적용(3-way)"으로 전환해 발생 표면을 제거했다. 전체 덮어쓰기 + 정리를 원하면 `--prune` 을 지정한다. "코어도 바꾸고 사용자도 바꾼" 파일은 코어 버전으로 갱신되지만 백업에 원본이 보존되어 복구 가능하다.
|
||||
|
||||
> **증분 모드 잔존 stale 파일 정리**: 기본(증분) 모드는 orphan 을 삭제하지 않으므로, 신 버전에서 제거된 파일이 활성 디렉토리에 잔존할 수 있다. 완료 요약이 잔존을 안내하며, 정리하려면 다음 업데이트를 `--prune` 으로 실행하거나 단발성 정리 도구 `php artisan hotfix:rollback-stale-files --prune` 을 사용한다 (진단 모드 기본, `--prune` 시 확인 프롬프트 후 정리, symlink/protected_paths 가드 적용). 상세 사용법: [docs/cheatsheet.md](../cheatsheet.md) "단발성 결함 보정 (hotfix)".
|
||||
|
||||
> **자동 발견 폴백의 배경 (engine-v / beta.4 이후)**: Step 7 은 부모 프로세스의 `config('app.update.targets')` 를 사용한다. 부모는 업그레이드 *직전* 의 코드/메모리 상태이므로 신버전이 도입한 신규 최상위 디렉토리(예: beta.4 의 `lang-packs/`) 가 부모의 stale targets 에서 누락된다. 폴백은 이 결함을 안전망으로 차단하며, `config/app.php` 의 `update.protected_paths` 가 런타임 데이터(`storage`)·로컬 환경(`.env*`)·별도 파이프라인 산출물(`vendor`)·개발 메타(`.git`/`.claude`/`.serena` 등) 의 의도치 않은 덮어쓰기를 방지한다.
|
||||
|
||||
### Step 9: 마이그레이션 + 동기화
|
||||
@@ -251,21 +217,6 @@ v접두사 자동 감지 (resolveGithubArchiveUrl):
|
||||
|
||||
> **단독 실행 안전성 (beta.6 이후)**: `core:execute-upgrade-steps` 는 HANDOFF 안내 또는 수동 복구 목적으로 운영자가 직접 호출되는 경로가 있다. 단독 실행 시 자식은 기본값으로 부모 Step 9 (`runMigrations` + `reloadCoreConfigAndResync`), Step 11 (`updateVersionInEnv` + `clearAllCaches`), Step 12 (번들 확장 일괄 업데이트) 를 자체적으로 수행해 단일 명령으로 업그레이드를 완결한다. 부모 `CoreUpdateCommand::spawnUpgradeStepsProcess()` 는 자식 명령 라인에 `--skip-migrations`, `--skip-resync`, `--skip-version-env`, `--skip-cache-clear`, `--skip-bundled-updates` 5개를 무조건 추가해 중복 회피한다 — 부모가 자식 종료 후 동일 단계를 직접 수행하기 때문이다.
|
||||
|
||||
#### 재실행 안내의 권한 분기 (핸드오프 catch)
|
||||
|
||||
spawn 자식이 실패(`proc_open` 미지원 · 비정상 종료 · silent skip)하고 `spawn_failure_mode=abort`(기본값) 이면, 파일·버전은 이미 `toVersion` 으로 반영되지만 업그레이드 스텝이 미실행 상태로 남아 운영자에게 `core:execute-upgrade-steps` 재실행을 안내한다. 이때 **sudo(root) 로 `core:update` 를 실행한 경우**, 안내받은 명령을 root 로 그대로 재실행하면 스텝이 만드는 파일·캐시가 root 소유로 생성되어 이후 웹서버(php-fpm www-data 등) 요청이 그 경로에 쓰기 실패한다.
|
||||
|
||||
`CoreUpdateCommand::surfaceResumeCommandWithPermissionGuidance()` 는 실행 환경을 4가지로 분류(`classifyResumeExecutionContext()`)하여 안내를 분기한다:
|
||||
|
||||
| 모드 | 조건 | 안내 |
|
||||
|------|------|------|
|
||||
| `non_root` | root 아님(일반 SSH 사용자 = 파일 소유자) / posix 미지원(Windows) / 공유 호스팅(웹서버·PHP·실행 유저 동일) | 명령만 그대로 출력 |
|
||||
| `root_web_known` | root 실행 + 웹서버 계정 식별 가능 + 실행 사용자와 다름 | `sudo -u {계정} {명령}` + 계정명 명시 경고 |
|
||||
| `root_web_symmetric` | root 실행 + 웹서버 계정이 root 로 추정 (root 서비스 구성) | 명령만 그대로 출력 |
|
||||
| `root_web_unknown` | root 실행 + 웹서버 계정 추정 실패 | `sudo -u <웹서버계정>` placeholder + 계정 확인 안내 |
|
||||
|
||||
웹서버 계정은 `FilePermissionHelper::inferWebServerOwnership()` 이 `storage/*`·`bootstrap/cache` 쓰기 영역 소유자로 추정한다.
|
||||
|
||||
### Step 11: 마무리
|
||||
|
||||
```text
|
||||
@@ -665,13 +616,12 @@ config('app.version') = env('APP_VERSION', 'config/app.php 기본값')
|
||||
|--------|---------|------|
|
||||
| `createBackup()` | `(?Closure $onProgress): string` | CoreBackupHelper로 백업 생성 |
|
||||
| `restoreFromBackup()` | `(string $backupPath, ?Closure $onProgress): void` | 백업에서 파일 복원 |
|
||||
| `CoreBackupHelper::computeApplyList()` | `(string $backupPath, string $sourcePath, array $targets, array $protectedPaths, array $excludes): array` | 3-way 판정으로 증분 적용 대상(added/changed) 산출 (`apply`, `added_count`, `changed_count`, `has_symlink`). targets 에 명시된 `{domain}/_bundled` 는 상위 protected(`modules` 등)를 오버라이드해 목록에 포함 (공개 #64 / 내부 #452) |
|
||||
|
||||
### 적용 및 설치
|
||||
|
||||
| 메서드 | 시그니처 | 설명 |
|
||||
|--------|---------|------|
|
||||
| `applyUpdate()` | `(string $sourcePath, ?Closure $onProgress, bool $prune = false, ?array $applyList = null): void` | _pending → base_path 적용. `$applyList` 지정 + `!$prune` 이면 증분(코어 변경분만), 그 외 전체 덮어쓰기 + orphan 삭제 |
|
||||
| `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 |
|
||||
@@ -713,14 +663,13 @@ config('app.version') = env('APP_VERSION', 'config/app.php 기본값')
|
||||
### core:update
|
||||
|
||||
```bash
|
||||
php artisan core:update [--force] [--no-backup] [--prune] [--no-maintenance] [--local] [--source={path}]
|
||||
php artisan core:update [--force] [--no-backup] [--no-maintenance] [--local] [--source={path}]
|
||||
```
|
||||
|
||||
| 옵션 | 설명 |
|
||||
|------|------|
|
||||
| `--force` | 버전 비교 스킵, 동일 버전이어도 강제 업데이트 |
|
||||
| `--no-backup` | 백업 생성 스킵 (Step 5). 증분 적용 불가 → 전체 덮어쓰기로 회귀 |
|
||||
| `--prune` | 코어가 제거한 파일 정리 + targets 전체 덮어쓰기(기존 방식). 미지정 시 코어가 실제 변경/추가한 파일만 적용(3-way)하고 나머지는 보존 |
|
||||
| `--no-backup` | 백업 생성 스킵 (Step 5) |
|
||||
| `--no-maintenance` | 유지보수 모드 스킵 (Step 3) |
|
||||
| `--local` | 현재 코드베이스를 소스로 사용 (GitHub 스킵) |
|
||||
| `--source={path}` | 지정 디렉토리를 소스로 사용 (GitHub 스킵) |
|
||||
|
||||
@@ -587,27 +587,6 @@ class PublicLayoutController extends PublicBaseController
|
||||
|
||||
---
|
||||
|
||||
## JSON 인코딩 옵션 (JSON_UNESCAPED_UNICODE)
|
||||
|
||||
모든 `response()->json()` 호출은 `ResponseHelper::JSON_ENCODE_OPTIONS`(= `JSON_UNESCAPED_UNICODE`)를 인코딩 옵션으로 전달한다. 한글 등 멀티바이트 문자를 `\uXXXX`(6바이트)로 이스케이프하지 않고 raw UTF-8(한글 3바이트)로 직렬화하여 응답 전송 크기를 줄인다. 다국어 팩(lang) 응답처럼 한글 비중이 큰 페이로드에서 절감 효과가 크다.
|
||||
|
||||
```php
|
||||
// ✅ 올바른 사용 — 네 번째 인자로 옵션 전달
|
||||
return response()->json($payload, $status, [], ResponseHelper::JSON_ENCODE_OPTIONS);
|
||||
|
||||
// 헤더 배열이 필요한 경우 (세 번째 인자)
|
||||
return response()->json($payload, $status, ['Cache-Control' => '...'], ResponseHelper::JSON_ENCODE_OPTIONS);
|
||||
|
||||
// ❌ 옵션 누락 — 한글이 \uXXXX 로 팽창
|
||||
return response()->json($payload, $status);
|
||||
```
|
||||
|
||||
`ResponseHelper` 의 모든 메서드(`success`/`error`/`validationError`/… )와 `BaseApiController::successWithCache`/`cachedJsonResponse` 는 이미 이 옵션을 적용한다. 컨트롤러/서비스는 `ResponseHelper` 를 사용하면 자동으로 적용되므로 별도 조치가 불필요하다. `response()->json()` 을 직접 호출하는 신규 코드에서만 옵션 전달을 잊지 않는다.
|
||||
|
||||
> 슬래시 언이스케이프(`JSON_UNESCAPED_SLASHES`)는 적용하지 않는다 — endpoint 경로의 `\/` 는 크기 영향이 미미하고, 기존 응답 본문 정확 매칭 테스트의 안전성을 우선한다.
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [index.md](index.md) - 백엔드 가이드 인덱스
|
||||
|
||||
@@ -346,7 +346,7 @@ protected function loadModuleRoutes(): void
|
||||
- **`hasTable` 폴백 경로는 반드시 유지** — `installer_completed=false` 환경에서도 안전하게 부팅되어야 함
|
||||
- **try/catch 도 그대로 유지** — DB 연결 실패 시 안전한 스킵 계약 준수
|
||||
- 가드는 hasTable 체크 **전체 블록을 `if (! config('app.installer_completed'))` 로 래핑** 하는 형태
|
||||
- `config:cache` 는 config 소스를 변경하는 라이프사이클에서 `App\Support\ConfigCacheHelper::rebuild()` 로 자동 재생성된다 (아래 "config 캐시 자동 재생성" 참조). `.env` 를 직접 편집한 경우처럼 헬퍼를 거치지 않는 변경은 여전히 `php artisan config:clear && php artisan config:cache` 를 수동 실행한다
|
||||
- `config:cache` 를 사용하는 환경에서는 `.env` 값 변경 후 반드시 `php artisan config:clear && php artisan config:cache` 를 실행해야 함
|
||||
|
||||
### 확장 Trait 패턴
|
||||
|
||||
@@ -369,28 +369,6 @@ private static function isExtensionTableReady(string $table): bool
|
||||
}
|
||||
```
|
||||
|
||||
### 부팅 경로 Listener / Repository 적용
|
||||
|
||||
ServiceProvider 와 Trait 외에도, `CoreServiceProvider::boot()` 말미(동적 훅 등록 / IDV 정책 동기화)에서 매 요청 실행되는 다음 지점도 동일 가드를 적용합니다:
|
||||
|
||||
- `NotificationHookListener::registerDynamicHooks()` — `notification_definitions` 존재 확인 (단일 최대 비용)
|
||||
- `IdentityPolicyRepository::listHookTargets()` — `identity_policies` 존재 확인
|
||||
- `IdentityPolicyRepository::activeExtensionIdentifiers()` — `modules` / `plugins` 존재 확인 (테이블 부재 시 `null` 반환으로 필터 미적용 계약 보존)
|
||||
|
||||
```php
|
||||
// hasTable 을 단독 조건이 아니라 installer_completed 와 && 로 단락.
|
||||
// 설치 완료 시 hasTable 호출 자체를 건너뛰고 후속 로직으로 진행한다.
|
||||
if (! config('app.installer_completed') && ! Schema::hasTable('notification_definitions')) {
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
`try/catch` 블록 안에 있는 Repository 조회는 catch 계약(테이블 부재/DB 오류 시 `[]` 또는 `null` 반환)을 그대로 보존한 채 조건만 확장한다.
|
||||
|
||||
### 부팅 경로 정적 훅 등록 캐시
|
||||
|
||||
`installer_completed` 가드가 부팅 시 DB 조회를 줄이는 것과 별개로, `CoreServiceProvider::boot()` 의 정적 훅 리스너 등록(`app/Listeners` 재귀 스캔 + 모듈/플러그인 `getHookListeners()` 클래스 로딩)은 `bootstrap/cache/hooks.php` 캐시로 매 요청 스캔·리플렉션을 제거한다. 캐시 부재/테스트 환경에서는 기존 스캔 경로로 안전 폴백하며, 등록 결과는 스캔 경로와 바이트 동일하다. 상세: [extension/hooks.md "정적 훅 매핑 캐시"](../extension/hooks.md).
|
||||
|
||||
### 주의사항
|
||||
|
||||
- **인스톨러 설치 완료 시점에 `INSTALLER_COMPLETED=true` 가 `.env` 에 기록되어야 함** — G7 인스톨러(`public/install/includes/task-runner.php`)는 이를 이미 수행
|
||||
@@ -430,38 +408,6 @@ php artisan migrate
|
||||
|
||||
---
|
||||
|
||||
## config 캐시 자동 재생성 (ConfigCacheHelper)
|
||||
|
||||
config 소스(`config/*.php`, `.env`, `storage/app/settings/*.json`, 활성 확장 목록)를 변경하는 라이프사이클은 `App\Support\ConfigCacheHelper::rebuild()` 를 호출해 config 캐시를 재빌드한다.
|
||||
|
||||
### 배경
|
||||
|
||||
과거 설정 변경/코어 업데이트/APP_KEY 재생성 등은 `config:clear` 만 하고 `config:cache` 를 재생성하지 않았다. 그 결과 관리자가 "시스템 최적화"(`optimizeSystem`)로 캐시를 만들어도, 이후 설정을 한 번 저장하면 캐시가 비워진 채 재생성되지 않아 이후 모든 요청이 config 파일을 재파싱했다(성능 손실). `rebuild()` 는 clear 직후 재생성까지 수행해 캐시가 비활성 상태로 잔존하지 않게 한다.
|
||||
|
||||
### 정책
|
||||
|
||||
- **환경 무관 항상 재생성** — config:cache 는 그 자체로 부팅 비용을 줄이고, G7 설정은 config 캐시에 박제되지 않고 매 요청 런타임 `Config::set()` 으로 재주입되므로(설정 stale 없음) 항상 켜두는 것이 이득이다.
|
||||
- **testing 환경**: `config:clear` 는 수행(값 반영 계약 유지)하되 `config:cache` 생성은 스킵(캐시된 config 가 다음 테스트로 누출되는 격리 파괴 방지).
|
||||
- **설치 미완료 상태**: 불완전 config 박제를 피하려 재생성을 스킵하고 clear 만 한다.
|
||||
- **local 개발 주의**: config:cache 가 켜지면 `config/*.php` 를 직접 수정해도 다음 요청에 반영되지 않는다. 이는 개발자가 `php artisan config:clear` 로 대응하는 개발자 책임 영역이다.
|
||||
|
||||
### 적용 지점
|
||||
|
||||
| 라이프사이클 | 재생성 위치 |
|
||||
| --- | --- |
|
||||
| 코어 설치 완료 | 인스톨러 `complete_flag` 직후 `config:cache` task |
|
||||
| 코어 업데이트 | `CoreUpdateCommand` Step 11 완료 지점 |
|
||||
| 코어 업그레이드 스텝(단독 실행) | `ExecuteUpgradeStepsCommand` 캐시 정리 블록 (spawn 자식은 부모가 처리) |
|
||||
| 코어 관리자 설정 저장 / 캐시 정리 / GeoIP / APP_KEY | `SettingsService` / `GeoIpDatabaseService` 의 각 지점 |
|
||||
| 확장 설치/삭제/업데이트 | `ExtensionManager::updateComposerAutoload()` (오토로드·훅 캐시와 동일 생명주기) |
|
||||
| 확장 활성화/비활성화 | `ExtensionConfigCacheListener` (`core.*.activated` / `core.*.after_deactivate` 훅 구독) |
|
||||
|
||||
`ExtensionConfigCacheListener` 의 구독 훅은 `getSubscribedHooks()` 에서 `'sync' => true` 로 선언한다. 훅 Action 리스너의 기본 등록 정책은 큐 디스패치(`HookListenerRegistrar`)인데, config 캐시 재생성을 큐로 미루면 (1) 워커 미가동 환경에서 영영 재생성되지 않고, (2) 확장 토글을 수행하는 CLI 커맨드(`plugin:deactivate` 등)는 실행 후 프로세스가 종료되어 큐 작업을 처리할 주체가 없다. config 캐시 재생성은 인프라 부수효과이므로 반드시 동기 실행한다. 회귀 방지 테스트: `ExtensionConfigCacheListenerTest::test_all_hooks_are_synchronous`.
|
||||
|
||||
**확장별 개별 환경설정 저장(board/ecommerce/plugin SettingsService)은 재생성 대상이 아니다.** 확장 설정은 `config/*.php` 가 아니라 settings JSON 에 저장되고, 매 요청 `CoreServiceProvider::boot` 의 `loadModule/PluginSettingsToConfig` 가 런타임 `Config::set('g7_settings.*', 최신값)` 으로 config 캐시에 박제된 값을 덮어쓴다. 따라서 값 stale 이 없고(즉시 반영), config 캐시를 clear 하지도 않으므로(성능 손실 없음) 재생성이 실효가 없다.
|
||||
|
||||
---
|
||||
|
||||
## 관련 이슈
|
||||
|
||||
- 인스톨러에서 `composer install` 시 DB 접근 오류
|
||||
|
||||
+1
-9
@@ -27,8 +27,6 @@ _bundled에서 TSX/TS 파일 수정한 경우 → 빌드 후 확장 업데이
|
||||
| `modules/**/resources/js/**/*.ts` (핸들러) | `module:build` + `module:update {id} --force` |
|
||||
| `templates/**/src/**/*.tsx` (컴포넌트) | `template:build` + `template:update {id} --force` |
|
||||
|
||||
> **확장 프론트엔드 번들**: 활성 모듈/플러그인 IIFE 는 서버측에서 종류별 1개 번들로 병합 서빙된다(`/api/{modules,plugins}/bundle.{js,css}`). `{type}:update` 후 확장 캐시 버전이 bump 되면 번들이 자동 재생성된다(prod 캐시, dev 매 요청 concat). 구버전 번들 파일은 `ext-bundles:cleanup` 또는 `{type}:cache-clear` 로 정리. 상세: [module-assets.md](extension/module-assets.md#서버측-번들-병합).
|
||||
|
||||
---
|
||||
|
||||
## 확장 업데이트 (_bundled → 활성 반영)
|
||||
@@ -174,15 +172,9 @@ php artisan language-pack:update [identifier] [--force] [--source=auto|bundled|g
|
||||
php artisan extension:composer-install # 모든 모듈+플러그인
|
||||
|
||||
# 오토로드
|
||||
php artisan extension:update-autoload # 오토로드 캐시 + 정적 훅 매핑 캐시 함께 재생성
|
||||
|
||||
# 정적 훅 매핑 캐시 (부팅 비용 절감 — route:cache 동형)
|
||||
php artisan hooks:cache # bootstrap/cache/hooks.php 생성
|
||||
php artisan hooks:clear # 캐시 삭제 (삭제 후 스캔 폴백 — 항상 안전)
|
||||
php artisan extension:update-autoload
|
||||
```
|
||||
|
||||
> 훅 캐시는 확장 install/update 및 코어 업데이트(`clearAllCaches` → `extension:update-autoload`) 시 자동 재생성됩니다. 코어 리스너 코드 배포 시에만 `hooks:cache` 수동 실행. 상세: [extension/hooks.md "정적 훅 매핑 캐시"](extension/hooks.md).
|
||||
|
||||
### 단발성 결함 보정 (hotfix)
|
||||
|
||||
`hotfix:*` prefix 는 특정 버전의 결함 회복을 위해 신설되는 단발성 도구를 위한 표준 prefix 다. `core:*` (영구 운영 도구) 와 명확히 구분되며 dev-dashboard 자동 노출 면제 대상이다.
|
||||
|
||||
@@ -136,7 +136,7 @@ editor-spec.json 이 커지면(코어 admin 템플릿은 단일 파일 18,000줄
|
||||
|
||||
### actionRecipes — 친화 명칭 → 핸들러 JSON
|
||||
|
||||
`params`(친화 입력란) + `build`(핸들러 JSON 생성 틀)로 구성한다. `build` 값의 `{{paramKey}}`(중괄호 2개)가 입력값으로 치환된다. `onSuccess`/`onError` 가 `{{key}}` 이고 그 파라미터가 `action-list` 면 치환 결과는 중첩 액션 **배열**이 된다. 핸들러명/파라미터 키는 사용자에게 노출하지 않는다 — 사용자는 `label` 친화 명칭만 본다. `build.handler` 는 코어 핸들러 규칙의 올바른 핸들러(`navigate`/`apiCall`/`setState`/...)를 쓴다. `comment` 키는 레시피가 아닌 작성자 메모다.
|
||||
`params`(친화 입력란) + `build`(핸들러 JSON 생성 틀)로 구성한다. `build` 값의 `{{paramKey}}`(중괄호 2개)가 입력값으로 치환된다. `onSuccess`/`onError` 가 `{{key}}` 이고 그 파라미터가 `action-list` 면 치환 결과는 중첩 액션 **배열**이 된다. 핸들러명/파라미터 키는 사용자에게 노출하지 않는다 — 사용자는 `label` 친화 명칭만 본다. `build.handler` 는 CLAUDE.md CRITICAL RULES 의 올바른 핸들러(`navigate`/`apiCall`/`setState`/...)를 쓴다. `comment` 키는 레시피가 아닌 작성자 메모다.
|
||||
|
||||
`build.handler` 는 리터럴 핸들러명 대신 `{{paramKey}}` **placeholder** 도 가능하다 — 호출할 핸들러를 응답값 등 데이터로 결정하는 provider-agnostic 액션(예: ecommerce 모듈 `requestPgPayment` "결제 진입" — 핸들러를 `{{response.data.pg_payment_handler}}` 데이터 칩으로 연결)에 쓴다. 결제 같은 **도메인 동작은 코어가 아니라 그 도메인 확장(모듈/플러그인/템플릿)이 자기 editor-spec `actionRecipes` 에 소유**한다 — 코어는 PG/도메인을 모른다. 확장 레시피는 로더가 `__source` 메타를 붙여 〔확장식별자〕 배지·'extension' 그룹으로 자동 분류한다. 라벨은 그 확장의 lang 네임스페이스를 명시한다(예: `$t:sirsoft-ecommerce.editor.action.request_pg_payment.label`).
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
3. 설치/삭제 시 자동 갱신: updateComposerAutoload() → generateAutoloadFile()
|
||||
4. 수동 갱신: php artisan extension:update-autoload
|
||||
5. _bundled/_pending 디렉토리 자동 제외 (str_starts_with($name, '_') → skip)
|
||||
6. src_classmap: 확장 소스 FQCN→경로 사전 계산 → findFile 파일시스템 stat 제거 (지연 로드·vendor 격리 유지)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -80,10 +79,6 @@ return [
|
||||
"modules/sirsoft-ecommerce/module.php",
|
||||
"plugins/sirsoft-payment/plugin.php",
|
||||
],
|
||||
'src_classmap' => [
|
||||
"Modules\\Sirsoft\\Ecommerce\\Services\\OrderService" => "modules/sirsoft-ecommerce/src/Services/OrderService.php",
|
||||
"Plugins\\Sirsoft\\Payment\\Providers\\PaymentServiceProvider" => "plugins/sirsoft-payment/src/Providers/PaymentServiceProvider.php",
|
||||
],
|
||||
'files' => [],
|
||||
'vendor_autoloads' => [
|
||||
"modules/sirsoft-ecommerce/vendor/autoload.php",
|
||||
@@ -91,19 +86,8 @@ return [
|
||||
];
|
||||
```
|
||||
|
||||
- `classmap`: `module.php` / `plugin.php` 등 부트스트랩 파일 목록 — 진입점에서 `require_once` (즉시 로드)
|
||||
- `src_classmap`: 확장 **소스** 클래스의 `FQCN → 상대경로` 맵 — 진입점에서 `$loader->addClassMap()` (지연 로드)
|
||||
- `vendor_autoloads`: 모듈/플러그인의 `vendor/autoload.php` 경로 목록 (Composer 의존성이 설치된 경우만 포함)
|
||||
|
||||
#### src_classmap — findFile 파일시스템 스캔 제거 (성능)
|
||||
|
||||
확장 소스 클래스는 `addPsr4()` 로만 등록되면 Composer `findFile()` 이 클래스마다 파일시스템을 스캔(`is_dir`/`file_exists` stat)한다. OPcache 가 cold 이거나 파일시스템이 느린 환경에서는 이 stat 비용이 요청 시간의 큰 비중을 차지할 수 있다. `src_classmap` 은 `generateAutoloadFile()` 이 각 PSR-4 디렉토리를 스캔해 `FQCN → 경로` 를 사전 계산하고, 진입점(`public/index.php` / `CoreServiceProvider` / `ExtensionManager::registerExtensionAutoload`)이 `ClassLoader::addClassMap()` 으로 등록한다. `findFile()` 은 classMap 을 **최우선 조회**하므로 확장 클래스는 파일시스템 접근 0 으로 즉시 경로를 얻는다.
|
||||
|
||||
- **지연 로드 유지**: classmap 은 경로만 제공하며 `include` 는 실제 사용 시점에. 매 요청 전량 로드가 아니다.
|
||||
- **PSR-4 폴백 보존**: classmap 에 없는 클래스(신규 추가 후 미재생성 등)는 기존 PSR-4 경로로 폴백 — 안전망.
|
||||
- **vendor 격리 불변**: `src_classmap` 은 확장 **소스**(`Modules\*`/`Plugins\*`)만 담는다. 확장별 독립 서드파티 vendor 는 각 확장 `vendor/autoload.php`(별도 ClassLoader) 가 담당하므로 여기 포함되지 않는다.
|
||||
- **재생성**: `psr4` 와 동일 생명주기 — install / update / uninstall 시 `updateComposerAutoload()`, 코어 업데이트 시 `extension:update-autoload` 가 함께 재생성.
|
||||
|
||||
### 중요 규칙
|
||||
|
||||
```
|
||||
|
||||
@@ -484,44 +484,6 @@ private function registerCoreHookListeners(): void
|
||||
| 위치 | `app/Listeners/**/*.php` | `modules/**/Listeners/`, `plugins/**/Listeners/` |
|
||||
| 등록 주체 | `CoreServiceProvider` | `ModuleServiceProvider`, `PluginServiceProvider` |
|
||||
|
||||
---
|
||||
|
||||
## 정적 훅 매핑 캐시 (Static Hook Cache)
|
||||
|
||||
매 요청 부팅 시 코어(`app/Listeners` 재귀 스캔) + 모듈/플러그인(`getHookListeners()`)의 정적 훅 리스너를 발견·리플렉션·`getSubscribedHooks()` 클래스 로딩하는 비용을 제거하기 위해, 사전 계산한 훅 매핑을 `bootstrap/cache/hooks.php` 에 캐시합니다 (오토로드 캐시 `autoload-extensions.php` 와 동일 위치·생명주기).
|
||||
|
||||
캐시는 "무엇을 등록할지 목록" 만 제공하며 **등록 자체는 여전히 부팅에서 수행** 되므로 등록↔발화 순서 계약은 불변입니다. 캐시 경로 등록 결과는 스캔 경로와 **훅 매핑이 바이트 동일** 합니다 (`HookListenerRegistrar::registerFromCache()` 가 `register()` 와 동일한 `applySubscribedHooks()` 에 위임).
|
||||
|
||||
### 동작
|
||||
|
||||
| 상태 | 부팅 시 동작 |
|
||||
|------|-------------|
|
||||
| 캐시 존재 (`bootstrap/cache/hooks.php`) | 스캔·리플렉션·클래스 로딩 없이 캐시 매핑으로 등록 |
|
||||
| 캐시 부재 / 손상 / 구조 불일치 | 기존 스캔 경로로 **안전 폴백** (항상 동작) |
|
||||
| 테스트 환경 (`APP_ENV=testing`) | 캐시 미사용 — 매 setUp 스캔이 정확·격리 우선 |
|
||||
|
||||
- 동적 훅(알림 등 `registerDynamicHooks()`)은 캐시 대상이 아니며 코드 변경 없이 그대로 동작합니다. 캐시에는 각 리스너의 `dynamic` 플래그만 저장하여, 동적 훅 보유 코어 리스너의 boot 후반부 지연 실행 순서를 스캔 경로와 동일하게 유지합니다.
|
||||
- 동적 훅의 DB 조회(`NotificationDefinitionService::getAllActive()`)는 이미 `['notification']` 태그로 캐시되어 있으므로 첫 요청(캐시 워밍) 이후 DB 조회가 발생하지 않습니다.
|
||||
|
||||
### 재생성 (무효화 = 재생성)
|
||||
|
||||
정적 훅 매핑은 **확장 변경 또는 코어 리스너 코드 배포 시에만** 바뀝니다. 해당 시점에 자동/수동 재생성됩니다:
|
||||
|
||||
| 사건 | 재생성 경로 |
|
||||
|------|-----------|
|
||||
| 확장 install / activate / deactivate / uninstall / update | `ExtensionManager::updateComposerAutoload()` 가 오토로드 캐시와 나란히 재생성 (자동) |
|
||||
| 코어 업데이트 (`core:update`) | `clearAllCaches()` → `extension:update-autoload` 가 오토로드 + 훅 캐시 함께 재생성 (자동). 코어 리스너 추가/변경/삭제 반영 |
|
||||
| 코어 리스너 코드 배포 | `php artisan hooks:cache` (배포 파이프라인 — `route:cache` 동형) |
|
||||
|
||||
```bash
|
||||
php artisan hooks:cache # 정적 훅 매핑 캐시 생성 (bootstrap/cache/hooks.php)
|
||||
php artisan hooks:clear # 캐시 삭제 (삭제 후 스캔 폴백 — 항상 안전)
|
||||
```
|
||||
|
||||
캐시 파일은 Git 미추적(`bootstrap/cache/*`) 이며 배포 환경마다 생성됩니다. 캐시가 없어도 스캔 폴백으로 정상 동작하므로 필수는 아니지만, 프로덕션 배포 시 부팅 비용 절감을 위해 `config:cache`/`route:cache` 와 함께 실행하는 것을 권장합니다.
|
||||
|
||||
**핵심 파일**: `app/Extension/HookCacheManager.php`, `app/Extension/HookListenerRegistrar.php` (`registerFromCache`)
|
||||
|
||||
### 동적 훅 리스너 (DB 기반)
|
||||
|
||||
DB 설정에 따라 훅 구독 대상이 동적으로 변하는 경우, 리스너에 `registerDynamicHooks()` 메서드를 구현합니다.
|
||||
|
||||
@@ -381,72 +381,6 @@ GET /api/modules/assets/{identifier}/{path}
|
||||
|
||||
---
|
||||
|
||||
## 서버측 번들 병합 (Server-side Bundle)
|
||||
|
||||
활성 모듈/플러그인이 늘어날수록 개별 IIFE JS/CSS 요청이 선형 증가한다. 이를 줄이기 위해 코어는 타입별(모듈/플러그인)로 활성 `global` 에셋을 서버에서 하나의 번들로 병합해 서빙한다. 각 확장 IIFE 는 자체 클로저에서 자가등록(레지스트리 + 핸들러/리스너)을 수행하므로, priority 순으로 이어붙여 단일 `<script>` 로 실행해도 등록 동작은 동일하다.
|
||||
|
||||
### 서빙 엔드포인트
|
||||
|
||||
```
|
||||
GET /api/modules/bundle.js?v={version}
|
||||
GET /api/modules/bundle.css?v={version}
|
||||
GET /api/plugins/bundle.js?v={version}
|
||||
GET /api/plugins/bundle.css?v={version}
|
||||
```
|
||||
|
||||
`{version}` = 확장 캐시 버전(`ClearsTemplateCaches::getExtensionCacheVersion()`). 활성 조합이 바뀌면 install/activate/deactivate/update 라이프사이클에서 version 이 bump 되어 새 URL → 새 캐시 파일명으로 자동 무효화된다.
|
||||
|
||||
### 동작 흐름
|
||||
|
||||
```
|
||||
1. blade → window.G7Config.bundleUrls 주입 (활성 에셋 없는 타입은 null)
|
||||
2. TemplateApp.loadExtensionAssets()
|
||||
└─ ModuleAssetLoader.loadBundle('module', ...) → loadBundle('plugin', ...)
|
||||
├─ 단일 <script async=false> + 단일 <link> append
|
||||
└─ 번들 내부 물리 순서(=priority 정렬)로 IIFE 자가등록 실행
|
||||
```
|
||||
|
||||
`bundleUrls` 가 없으면(구버전 blade) `ModuleAssetLoader.loadActiveExtensionAssets` 개별 로딩으로 폴백한다.
|
||||
|
||||
### 병합 규율
|
||||
|
||||
| 규율 | 내용 | audit 룰 |
|
||||
|------|------|---------|
|
||||
| priority 순서 | manifest `loading.priority` 오름차순만. 확장 이름 하드코딩 금지 | (선언형) |
|
||||
| `\n;\n` 구분자 | IIFE 사이는 `\n;\n`(JS)/`\n`(CSS). 미사용 시 ASI 붕괴 → 전체 파싱 에러 | `extension-bundle-concat-separator` |
|
||||
| 소스맵 | prod strip, dev 는 개별 에셋 서빙 절대 URL 로 rewrite | - |
|
||||
| same-origin | 번들 URL 은 `/api/...` 만 (CDN 금지 — gdpr preblocker 자기차단 방지) | `extension-bundle-url-same-origin` |
|
||||
| 절대경로 게터 | `getBuiltAssetAbsolutePaths()` 사용. `base_path("modules"\|"plugins")` 직접 조립 금지 | `extension-bundle-asset-path-getter` |
|
||||
| 확장별 try/catch | 파일 읽기 실패 시 해당 확장만 skip, 나머지 병합 지속 | (메모리+회귀테스트) |
|
||||
| CSS url() | 상대경로 url() 을 가진 CSS 는 번들 제외(개별 폴백) | - |
|
||||
|
||||
### 캐시 stale 관리
|
||||
|
||||
번들 파일(`storage/app/ext-bundles/{type}.{version}.{js,css}`)은 Laravel 캐시 스토어 밖 파일시스템이라 version bump/`cache:clear` 가 구파일을 지우지 않는다. 정리 경로:
|
||||
|
||||
```bash
|
||||
php artisan ext-bundles:cleanup # 현재 version 외 구파일 삭제
|
||||
php artisan module:cache-clear # 모듈 번들 파일 정리 포함
|
||||
php artisan plugin:cache-clear # 플러그인 번들 파일 정리 포함
|
||||
php artisan template:cache-clear # 전체 번들 파일 정리 포함
|
||||
```
|
||||
|
||||
프로덕션은 version-in-path 디스크 캐시, 비프로덕션(dev/watch)은 캐시 없이 매 요청 concat(rebuild 즉시 반영). `_bundled` 수정 후에는 `{type}:update {id} --force` 로 활성 반영 후 version bump 로 번들이 재생성된다.
|
||||
|
||||
> 개별 에셋 서빙 라우트(`/api/{type}/assets/...`, `*.map` 포함)는 소스맵·static 참조를 위해 존치한다.
|
||||
|
||||
### 전송 압축 (gzip)
|
||||
|
||||
번들 JS/CSS 는 `fileResponse()`(= `response()->file()` → `BinaryFileResponse`)로 서빙되며, `GzipEncodeResponse` 미들웨어가 gzip 압축을 적용한다. `BinaryFileResponse` 는 `getContent()` 가 `false` 를 반환하므로, 미들웨어는 파일 경로(`getFile()->getPathname()`)에서 본문을 읽어 압축한 뒤 헤더(Content-Type/ETag/Cache-Control)를 승계한 일반 `Response` 로 치환한다.
|
||||
|
||||
- 1KB 미만 번들(예: 빈 CSS)은 `MIN_COMPRESS_SIZE` 가드로 압축 생략.
|
||||
- `Accept-Encoding: gzip` 미포함 요청, 이미 `Content-Encoding` 이 있는 응답, 304 응답은 압축 대상에서 제외.
|
||||
- 회귀 테스트: `tests/Feature/Middleware/GzipEncodeResponseTest.php` (BinaryFileResponse 압축/헤더 승계/소용량 skip).
|
||||
|
||||
> `BinaryFileResponse` 를 압축 대상에 포함하지 않으면 번들이 비압축 전송되는 사각지대가 생긴다(모듈/플러그인 번들은 크기가 커 압축 이득이 특히 크다).
|
||||
|
||||
---
|
||||
|
||||
## 에셋 로딩 전략
|
||||
|
||||
### global (기본값)
|
||||
|
||||
@@ -606,7 +606,7 @@ iteration 내부에서 상태를 관리하는 컴포넌트(Input, Select, TagInp
|
||||
- id가 없으면 동적으로 행 추가/삭제 시 이전 인스턴스의 내부 상태가 새 인스턴스에 표시될 수 있음
|
||||
- 특히 내부 상태를 관리하는 컴포넌트(TagInput, Select, Input 등)에서 중요
|
||||
|
||||
> **iteration id 표현식 보간** (engine-v1.50.0+): 노드의 최상위 `id` 필드에 쓴 표현식(`id: "item_{{$idx}}"`, `id: "row_{{item.id}}"` 등)은 일반 렌더 경로(DynamicRenderer)에서 iteration 컨텍스트로 보간되어 행마다 고유 DOM id 가 된다. `index_var`(예 `"index_var": "$idx"`) 또는 `item_var` 의 필드를 활용한다. 정적 id 를 iteration 안에 그대로 두면 모든 행이 같은 HTML id 를 갖게 되어 W3C id 유일성을 위반하므로(렌더 누락까지 유발), 반복 항목 노드와 그 자식의 id 는 반드시 동적화한다. 내부 식별(slot 등록·React key)에는 원본 id 가 쓰이므로 보간은 DOM 출력에만 적용된다.
|
||||
> **iteration id 표현식 보간** (engine-v1.52.4+): 노드의 최상위 `id` 필드에 쓴 표현식(`id: "item_{{$idx}}"`, `id: "row_{{item.id}}"` 등)은 일반 렌더 경로(DynamicRenderer)에서 iteration 컨텍스트로 보간되어 행마다 고유 DOM id 가 된다. `index_var`(예 `"index_var": "$idx"`) 또는 `item_var` 의 필드를 활용한다. 정적 id 를 iteration 안에 그대로 두면 모든 행이 같은 HTML id 를 갖게 되어 W3C id 유일성을 위반하므로(렌더 누락까지 유발), 반복 항목 노드와 그 자식의 id 는 반드시 동적화한다. 내부 식별(slot 등록·React key)에는 원본 id 가 쓰이므로 보간은 DOM 출력에만 적용된다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -554,7 +554,7 @@ Laravel의 `FormRequest::validated()` 메서드는 `rules()`에 정의된 필드
|
||||
| `id` | string | ✅ | 컴포넌트 고유 ID |
|
||||
| `type` | string | ✅ | 컴포넌트 타입 (basic/composite/layout) |
|
||||
| `name` | string | ✅ | 컴포넌트 이름 (components.json에 등록된 이름) |
|
||||
| `comment` | string | ✅ | 시안 추적 코드 및 역할 설명 (예: "F-002: 상품명 검색 필드"). 공개 서빙 응답에서는 제거됨 (아래 참조) |
|
||||
| `comment` | string | ✅ | 시안 추적 코드 및 역할 설명 (예: "F-002: 상품명 검색 필드") |
|
||||
| `props` | object | ❌ | 컴포넌트에 전달할 props |
|
||||
| `text` | string | ❌ | 텍스트 콘텐츠 (최우선 렌더링) |
|
||||
| `children` | array | ❌ | 자식 컴포넌트 배열 (레이아웃/집합 컴포넌트에서 사용) |
|
||||
@@ -575,13 +575,6 @@ Laravel의 `FormRequest::validated()` 메서드는 `rules()`에 정의된 필드
|
||||
> - `actions`: [layout-json-features.md](layout-json-features.md#액션-시스템-actions)
|
||||
> - `isolatedState`: [아래 섹션](#격리된-상태-isolatedstate)
|
||||
|
||||
### `comment` / `_comment` — 공개 서빙 응답에서 제거
|
||||
|
||||
`comment` / `_comment` 는 편집기·개발자용 설명 주석이며 런타임 렌더러는 참조하지 않는다. 공개 레이아웃 서빙(`GET /api/layouts/{template}/{layout}.json`) 응답에서는 전송 크기 절감을 위해 이 두 키가 노드 트리 전체에서 재귀적으로 제거된다(`LayoutService::stripDeveloperComments`).
|
||||
|
||||
- 편집 모드 서빙(`?with_source_meta=1`, 편집 권한 필요)에서는 편집기가 `comment` 를 편집 가능한 속성으로 노출하므로 **보존**된다. 공개/편집 응답은 별도 캐시 키로 분리되어 있어 조건부 제거가 안전하다.
|
||||
- 따라서 `comment` 는 편집기·저장본에서는 유지되고, 일반 사용자에게 서빙되는 응답에서만 사라진다. 레이아웃 로직이 `comment` 값에 의존해서는 안 된다.
|
||||
|
||||
---
|
||||
|
||||
## 격리된 상태 (isolatedState)
|
||||
|
||||
@@ -219,7 +219,7 @@ test('unauthenticated_401 × no_token', async ({ page }) => {
|
||||
|
||||
`audit-branch.cjs` 의 `test-scenario-coverage` 룰이 매니페스트 axes ↔ docblock 매칭을 자동 검증.
|
||||
|
||||
## §8. 회귀 테스트 4단계
|
||||
## §8. 회귀 테스트 4단계 (CLAUDE.md 의무 절차 준수)
|
||||
|
||||
버그 수정 시 다음 4단계를 스킵 불가:
|
||||
|
||||
@@ -236,7 +236,7 @@ E2E spec 작성 중 발견한 무관 에러는 같은 세션에서 처리:
|
||||
|
||||
- **테스트 stale** (logic 정상, 테스트가 오래됨) → 테스트 수정
|
||||
- **로직 회귀** (테스트 정상, 로직이 의도와 어긋남) → 코드 수정
|
||||
- **데이터 구조 불일치** (양쪽 모두 오래됨) → 보고 + 보류
|
||||
- **데이터 구조 불일치** (양쪽 모두 오래됨) → PO 보고 + 보류
|
||||
|
||||
상세: `testing-guide.md` "무관 에러 처리 분기".
|
||||
|
||||
|
||||
Reference in new issue
Block a user