Files
Gnuboard7/docs/backend/data-sync-helpers.md
T
2026-05-11 11:29:41 +09:00

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. 참고