11 KiB
데이터 동기화 Helper (Data Sync Helpers)
배경: 코어/확장의 업그레이드·시더·설치 경로에서 config/seeder ↔ DB 를 완전 동기화 (upsert + stale cleanup) 하기 위한 5종 helper 의 사용 가이드입니다.
TL;DR (5초 요약)
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 단계로 들어가기 전에 다음 가드를 적용한다:
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
주요 메서드
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 말미)
$currentSlugs = $menuSyncHelper->collectSlugsRecursive($coreMenus);
$deleted = $menuSyncHelper->cleanupStaleMenus(
ExtensionOwnerType::Core, 'core', $currentSlugs
);
확장 모듈 업데이트에서도 동일 패턴으로 cleanupStaleMenus 호출.
4. ExtensionRoleSyncHelper
파일: app/Extension/Helpers/ExtensionRoleSyncHelper.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
주요 메서드
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 흐름으로 동기화합니다.
$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
주요 메서드
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 과 인스톨러 일치):
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 로 충분.
주요 메서드
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 — 스코프 없음
$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 별)
$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 도입 예정)
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. 새 엔티티 도입 시 체크리스트
□ 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. 절대 금지 패턴
❌ Seeder 에서 직접 `Model::create()` / `Model::updateOrCreate()` 만 호출 (cleanup 누락)
❌ Seeder 에서 `deleteExistingTypes()` 후 `create()` 재생성 패턴 (사용자 수정 전량 손실)
❌ 데이터 정합성 로직을 Service/Controller 에 분산 (helper 로 집중)
❌ HasUserOverrides 미적용 모델에 업그레이드 시더를 연결 (보존 불가)
10. 참고
HasUserOverridestrait 가이드: user-overrides.md- 완전 동기화 원칙: core-config.md
- 업그레이드 스텝: extension/upgrade-step-guide.md