315 lines
11 KiB
Markdown
315 lines
11 KiB
Markdown
# 데이터 동기화 Helper (Data Sync Helpers)
|
|
|
|
> **배경**: 코어/확장의 업그레이드·시더·설치 경로에서 **config/seeder ↔ DB** 를 완전 동기화 (upsert + stale cleanup) 하기 위한 5종 helper 의 사용 가이드입니다.
|
|
|
|
## TL;DR (5초 요약)
|
|
|
|
```text
|
|
1. 모든 데이터 동기화는 Service/Seeder 가 Helper 를 호출해 수행 (직접 Model 조작 금지)
|
|
2. 4종 전용 helper (Menu/Role/Notification/FilePermission) + 1종 범용 helper (Generic)
|
|
3. 모든 helper 는 upsert + stale cleanup 4단계 패턴 (완전 동기화 원칙)
|
|
4. user_overrides 보존은 helper 내부 HasUserOverrides trait 위임으로 자동 처리
|
|
5. 새 엔티티 도입 시 대부분 GenericEntitySyncHelper 로 충분 — 전용 helper 는 도메인 로직이 필요할 때만
|
|
```
|
|
|
|
## 1. 완전 동기화 원칙 (4단계)
|
|
|
|
코어/확장의 sync 메서드는 다음 4단계를 반드시 수행합니다:
|
|
|
|
| 단계 | 설명 | 책임 |
|
|
|---|---|---|
|
|
| 1. **Upsert** | config → DB (신규 생성 or user_overrides 보존 업데이트) | `Helper::sync*()` |
|
|
| 2. **Orphan Delete** | config 에 없는 DB row 삭제 (user_overrides 무관) | `Helper::cleanupStale*()` |
|
|
| 3. **Mapping Diff** | 관계 테이블(role_permissions 등) 재정렬 | Role Helper (해당 시) |
|
|
| 4. **Dependent Cleanup** | 삭제된 상위 엔티티의 하위 정리 (FK cascade 또는 명시적) | Helper (해당 시) |
|
|
|
|
이 원칙은 "config 기반 모든 정의 데이터" 에 적용됩니다. 중요한 의미 구분:
|
|
|
|
- **row 존재 여부**: config 기준으로 결정 (user_overrides 무관)
|
|
- **필드 값 (유지 row)**: user_overrides 에 등록된 필드만 보존
|
|
|
|
### 1.1. cleanup 가드 — declaration 빈 배열 안전 차단
|
|
|
|
sync 메서드는 declaration getter (예: `Module::getIdentityPolicies()`) 의 결과를 받아 cleanup 단계로 들어가기 전에 다음 가드를 적용한다:
|
|
|
|
```text
|
|
declaration 빈 배열 + DB 에 기존 row 존재 → cleanup 차단 + warning 로그
|
|
declaration 빈 배열 + DB 도 비어있음 → 정상 skip
|
|
declaration 정상 채워짐 → 정상 cleanup (현재 선언과 일치하지 않는 row 삭제)
|
|
```
|
|
|
|
배경: declaration 메서드는 모듈/플러그인 코드 계약. 빈 배열 = "이 영역을 사용하지 않음" 의 의미라면 첫 install 시점부터 DB row 가 없을 것이라는 전제가 성립한다. **DB 에 기존 row 가 존재한다는 사실은 declaration 이 정상 채워진 상태가 있었음을 입증** — 따라서 빈 반환은 정상 의도가 아닌 환경 결함(spawn 자식 PSR-4 stale, fresh-load 의존성 누락, trait 부분 로드 등) 으로 인한 부분 결과일 가능성이 높다. silent 데이터 손실 차단을 위해 cleanup 호출 자체를 건너뛴다.
|
|
|
|
운영자가 진짜 모듈 declaration 을 비웠다면 admin UI 의 명시적 삭제 경로를 사용한다 — silent declaration 흐름과 명시적 삭제는 의미가 다르다.
|
|
|
|
신규 sync 메서드 작성 시 본 가드를 반드시 적용 (`hasExisting*` 체크 helper 패턴 참조: `ModuleManager::hasExistingIdentityPolicies` 등).
|
|
|
|
## 2. Helper 5종 개요
|
|
|
|
| Helper | 역할 | 대상 테이블 | 주요 도메인 로직 |
|
|
|---|---|---|---|
|
|
| `ExtensionMenuSyncHelper` | 메뉴 동기화 | `menus` | 재귀 슬러그 수집, parent/child 처리 |
|
|
| `ExtensionRoleSyncHelper` | 역할·권한 동기화 | `roles`, `permissions`, `role_permissions` | 역할-권한 매핑 diff, `user_roles` 피벗 참조 차단 |
|
|
| `NotificationSyncHelper` | 알림 동기화 | `notification_definitions`, `notification_templates` | Definition-Template 계층, FK cascade |
|
|
| `FilePermissionHelper` | 파일 소유권/권한 복원 | 파일시스템 | 인스톨러 SSoT 경로 순회 |
|
|
| `GenericEntitySyncHelper` | **범용** 단일 테이블 동기화 | 단일 unique 키 모델 | 단순 upsert + scope 필터 cleanup |
|
|
|
|
---
|
|
|
|
## 3. ExtensionMenuSyncHelper
|
|
|
|
**파일**: `app/Extension/Helpers/ExtensionMenuSyncHelper.php`
|
|
|
|
### 주요 메서드
|
|
|
|
```php
|
|
public function syncMenu(
|
|
string $slug,
|
|
ExtensionOwnerType $extensionType,
|
|
string $extensionIdentifier,
|
|
array $newAttributes,
|
|
?int $parentId = null,
|
|
): Menu;
|
|
|
|
public function cleanupStaleMenus(
|
|
ExtensionOwnerType $extensionType,
|
|
string $extensionIdentifier,
|
|
array $currentSlugs,
|
|
): int;
|
|
|
|
public function collectSlugsRecursive(array $menus): array;
|
|
```
|
|
|
|
### 사용 패턴 (코어 `CoreUpdateService::syncCoreMenus` 말미)
|
|
|
|
```php
|
|
$currentSlugs = $menuSyncHelper->collectSlugsRecursive($coreMenus);
|
|
$deleted = $menuSyncHelper->cleanupStaleMenus(
|
|
ExtensionOwnerType::Core, 'core', $currentSlugs
|
|
);
|
|
```
|
|
|
|
확장 모듈 업데이트에서도 동일 패턴으로 `cleanupStaleMenus` 호출.
|
|
|
|
---
|
|
|
|
## 4. ExtensionRoleSyncHelper
|
|
|
|
**파일**: `app/Extension/Helpers/ExtensionRoleSyncHelper.php`
|
|
|
|
### 주요 메서드
|
|
|
|
```php
|
|
public function syncRole(...): Role;
|
|
|
|
public function syncPermission(...): Permission;
|
|
|
|
public function cleanupStaleRoles(
|
|
ExtensionOwnerType $extensionType,
|
|
string $extensionIdentifier,
|
|
array $currentIdentifiers,
|
|
): int;
|
|
|
|
public function cleanupStalePermissions(
|
|
ExtensionOwnerType $extensionType,
|
|
string $extensionIdentifier,
|
|
array $currentIdentifiers,
|
|
): int;
|
|
|
|
public function syncAllRoleAssignments(array $permissionRoleMap, array $allExtensionPermIdentifiers): void;
|
|
```
|
|
|
|
### 중요 정책
|
|
|
|
- `cleanupStaleRoles`: **삭제 대상 역할을 참조하는 `user_roles` 피벗 레코드가 있으면 삭제 차단 + 경고 로그**. 사용자 수동 재배정 유도 (안전장치)
|
|
- `cleanupStalePermissions`: 순수 diff 삭제 (권한은 사용자 수정 불가 설계)
|
|
- `syncAllRoleAssignments`: 코어는 `core/core` 소유 권한 전체를 diff 기준으로 사용 (이관된 구 식별자도 detach)
|
|
|
|
---
|
|
|
|
## 5. NotificationSyncHelper
|
|
|
|
**파일**: `app/Extension/Helpers/NotificationSyncHelper.php`
|
|
|
|
### 주요 메서드
|
|
|
|
```php
|
|
public function syncDefinition(array $data): NotificationDefinition;
|
|
|
|
public function syncTemplate(int $definitionId, array $data): NotificationTemplate;
|
|
|
|
public function cleanupStaleDefinitions(
|
|
string $extensionType,
|
|
string $extensionIdentifier,
|
|
array $currentTypes,
|
|
): int;
|
|
|
|
public function cleanupStaleTemplates(int $definitionId, array $currentChannels): int;
|
|
```
|
|
|
|
### 사용 패턴
|
|
|
|
SSoT 위치(코어 config 또는 모듈/플러그인 getter)에서 정의 배열을 얻은 뒤 동일한 helper 흐름으로 동기화합니다.
|
|
|
|
```php
|
|
$helper = app(NotificationSyncHelper::class);
|
|
$definedTypes = [];
|
|
|
|
foreach ($definitions as $data) {
|
|
$definition = $helper->syncDefinition($data);
|
|
$definedTypes[] = $definition->type;
|
|
|
|
$definedChannels = [];
|
|
foreach ($data['templates'] ?? [] as $template) {
|
|
$helper->syncTemplate($definition->id, $template);
|
|
$definedChannels[] = $template['channel'];
|
|
}
|
|
$helper->cleanupStaleTemplates($definition->id, $definedChannels);
|
|
}
|
|
|
|
$helper->cleanupStaleDefinitions($extensionType, $extensionIdentifier, $definedTypes);
|
|
```
|
|
|
|
**호출처**:
|
|
|
|
- 코어: `NotificationDefinitionSeeder` (`config('core.notification_definitions')` 직독)
|
|
- 모듈/플러그인: `ModuleManager::syncModuleNotificationDefinitions()` / `PluginManager::syncPluginNotificationDefinitions()` (각 확장의 `getNotificationDefinitions()` getter 호출 — 별도 Seeder 작성 금지)
|
|
|
|
---
|
|
|
|
## 6. FilePermissionHelper
|
|
|
|
**파일**: `app/Extension/Helpers/FilePermissionHelper.php`
|
|
|
|
### 주요 메서드
|
|
|
|
```php
|
|
public function chownRecursive(string $path, string $owner, string $group): void;
|
|
|
|
public function chmodRecursive(string $path, int $mode): void;
|
|
```
|
|
|
|
### 사용
|
|
|
|
인스톨러 SSoT 경로 전체에 대한 소유권 복원. 실패 시 경로당 1건 로그 + 종료 시 요약 카운트.
|
|
|
|
대상 경로 (SSoT — `config/app.php restore_ownership` 과 인스톨러 일치):
|
|
|
|
```text
|
|
storage, bootstrap/cache, vendor, modules, modules/_pending,
|
|
plugins, plugins/_pending, templates, templates/_pending, storage/app/core_pending
|
|
```
|
|
|
|
---
|
|
|
|
## 7. GenericEntitySyncHelper
|
|
|
|
**파일**: `app/Extension/Helpers/GenericEntitySyncHelper.php`
|
|
|
|
### 역할 구분 — 언제 사용하나?
|
|
|
|
| 쓰임 | 전용 Helper | GenericEntitySyncHelper |
|
|
|---|---|---|
|
|
| 다중 모델 관계 (Definition-Template, Role-Permission) | ✅ | ❌ |
|
|
| 피벗 참조 차단 / 매핑 diff | ✅ | ❌ |
|
|
| 단일 테이블, 단일 unique 키 | ❌ | ✅ |
|
|
| 선택적 scope 필터 (type별, extension별) | ❌ | ✅ |
|
|
|
|
대부분의 모듈 내부 도메인 엔티티 (ShippingType, BoardType, ClaimReason, 향후 Schedule 등) 는 GenericEntitySyncHelper 로 충분.
|
|
|
|
### 주요 메서드
|
|
|
|
```php
|
|
public function sync(
|
|
string $modelClass,
|
|
array $finder,
|
|
array $attributes,
|
|
): Model;
|
|
|
|
public function cleanupStale(
|
|
string $modelClass,
|
|
array $scopeFilter, // ['extension_identifier' => 'x', 'type' => 'refund']
|
|
string $keyField, // 'code', 'slug', 'name'
|
|
array $currentKeys,
|
|
): int;
|
|
```
|
|
|
|
### 사용 예시 1 — 스코프 없음
|
|
|
|
```php
|
|
$helper = app(GenericEntitySyncHelper::class);
|
|
$definedSlugs = [];
|
|
|
|
foreach ($boardTypes as $data) {
|
|
$helper->sync(BoardType::class, ['slug' => $data['slug']], $data);
|
|
$definedSlugs[] = $data['slug'];
|
|
}
|
|
|
|
$helper->cleanupStale(BoardType::class, [], 'slug', $definedSlugs);
|
|
```
|
|
|
|
### 사용 예시 2 — scope 필터 (type 별)
|
|
|
|
```php
|
|
$definedByType = [];
|
|
foreach ($reasons as $data) {
|
|
$helper->sync(ClaimReason::class, ['type' => $data['type'], 'code' => $data['code']], $data);
|
|
$definedByType[$data['type']][] = $data['code'];
|
|
}
|
|
|
|
foreach ($definedByType as $type => $codes) {
|
|
$helper->cleanupStale(ClaimReason::class, ['type' => $type], 'code', $codes);
|
|
}
|
|
```
|
|
|
|
### 사용 예시 3 — 확장 scope (Schedule 도입 예정)
|
|
|
|
```php
|
|
foreach ($extensionSchedules as $data) {
|
|
$helper->sync(Schedule::class, ['name' => $data['name']], $data);
|
|
$definedNames[] = $data['name'];
|
|
}
|
|
|
|
$helper->cleanupStale(
|
|
Schedule::class,
|
|
['extension_type' => 'module', 'extension_identifier' => 'sirsoft-ecommerce'],
|
|
'name',
|
|
$definedNames,
|
|
);
|
|
```
|
|
|
|
### 모델 전제 조건
|
|
|
|
대상 모델은 **`HasUserOverrides` trait 이 적용** 되어 있어야 합니다. (`sync()` 내부가 `syncOrCreateFromUpgrade` trait 메서드를 호출)
|
|
|
|
---
|
|
|
|
## 8. 새 엔티티 도입 시 체크리스트
|
|
|
|
```text
|
|
□ 1. 모델에 `use HasUserOverrides;` + `$trackableFields` 선언
|
|
□ 2. 테이블에 `user_overrides` (text/json, nullable) 컬럼 추가 마이그레이션
|
|
□ 3. fillable + casts 에 'user_overrides' 추가
|
|
□ 4. Seeder 작성 시 GenericEntitySyncHelper 사용 (단순한 경우)
|
|
□ 5. Seeder 는 반드시 sync + cleanupStale 둘 다 호출 (완전 동기화)
|
|
□ 6. 복잡한 관계 (1:N, 매핑) 가 있다면 전용 helper 고려
|
|
□ 7. 테스트 작성: upsert 동작, user_overrides 보존, stale cleanup
|
|
```
|
|
|
|
---
|
|
|
|
## 9. 절대 금지 패턴
|
|
|
|
```text
|
|
❌ Seeder 에서 직접 `Model::create()` / `Model::updateOrCreate()` 만 호출 (cleanup 누락)
|
|
❌ Seeder 에서 `deleteExistingTypes()` 후 `create()` 재생성 패턴 (사용자 수정 전량 손실)
|
|
❌ 데이터 정합성 로직을 Service/Controller 에 분산 (helper 로 집중)
|
|
❌ HasUserOverrides 미적용 모델에 업그레이드 시더를 연결 (보존 불가)
|
|
```
|
|
|
|
## 10. 참고
|
|
|
|
- `HasUserOverrides` trait 가이드: [user-overrides.md](user-overrides.md)
|
|
- 완전 동기화 원칙: [core-config.md](core-config.md#완전-동기화-원칙)
|
|
- 업그레이드 스텝: [extension/upgrade-step-guide.md](../extension/upgrade-step-guide.md)
|