8.7 KiB
사용자 수정 보존 (HasUserOverrides Trait)
배경: 그누보드7 은 업그레이드/시더 재실행 시 사용자가 UI/API 로 수정한 필드를 자동 보존하는 시스템을 제공합니다. 본 문서는
HasUserOverridestrait 의 사용법과 보장 범위를 정의합니다.
TL;DR (5초 요약)
1. 모델에 `use HasUserOverrides;` + `protected array $trackableFields = [...]` 선언
2. 다국어 JSON 컬럼은 `protected array $translatableTrackableFields = ['name']` 추가 — sub-key dot-path 단위 보존
3. `user_overrides` (JSON/array) 컬럼 마이그레이션 필수
4. 사용자가 trackable 필드를 수정 → user_overrides 자동 누적 기록 (다국어는 'name.ko' 형태)
5. 시더가 `syncOrCreateFromUpgrade()` 호출 → 기록된 키만 보존, 나머지 locale 은 자동 동기화
6. mass update `Model::where(...)->update(...)` 도 **투명하게 자동 추적**
7. 언어팩 활성/비활성 시 `RunSeedersOnLanguagePackLifecycle` 가 entity 시더 자동 재실행 (scope 별 라우팅)
1. 기본 사용법
1.1 모델 선언
use App\Models\Concerns\HasUserOverrides;
use Illuminate\Database\Eloquent\Model;
class ShippingType extends Model
{
use HasUserOverrides;
/**
* 사용자 수정 보존 대상 필드 (전체 컬럼 단위).
*
* @var array<int, string>
*/
protected array $trackableFields = ['name', 'category', 'is_active', 'sort_order'];
/**
* 다국어 JSON 컬럼 — sub-key dot-path 단위로 user_overrides 보존.
*
* 사용자가 ko 라벨만 수정 → user_overrides=['name.ko'] 로 기록되어
* 시더 재실행/언어팩 활성 시 ja/en 키는 자동 동기화 가능.
*
* 정책: trackableFields 의 부분집합. 일반 scalar 필드(category 등)는 등록 금지.
*
* @var array<int, string>
*/
protected array $translatableTrackableFields = ['name'];
protected $fillable = [
'code', 'name', 'category', 'is_active', 'sort_order',
'user_overrides', // 필수
];
protected $casts = [
'name' => 'array',
'is_active' => 'boolean',
'sort_order' => 'integer',
'user_overrides' => 'array', // 필수 (JSON ↔ array 자동 변환)
];
}
1.2 다국어 JSON 컬럼 정책 (translatableTrackableFields)
7.0.0-beta.4 이후 다국어 JSON 컬럼은 sub-key dot-path 단위로 user_overrides 가 기록됩니다.
| 시나리오 | user_overrides 기록 형식 |
|---|---|
사용자가 name.ko 만 수정 |
['name.ko'] |
사용자가 name.ko, name.en 동시 수정 |
['name.ko', 'name.en'] |
사용자가 scalar 필드 is_active 수정 |
['is_active'] (컬럼명 단위) |
| 혼재 수정 | ['name.ko', 'is_active'] |
시더가 재실행될 때:
| 상황 | 동작 |
|---|---|
user_overrides=['name.ko'] 인 row + 시더가 {ko, en, ja} 신규 데이터 제공 |
ko 보존, en/ja 신규 값으로 갱신 |
user_overrides=['is_active'] 인 row + 시더가 is_active=true 제공 |
갱신 SKIP (기존 동작) |
user_overrides=['name'] legacy 컬럼명 형식 |
컬럼 전체 보존 (역호환 — beta.4 upgrade step 이 dot-path 로 자동 변환) |
1.2 마이그레이션
Schema::table('ecommerce_shipping_types', function (Blueprint $table) {
$table->text('user_overrides')->nullable()
->comment('유저가 수정한 필드명 목록 (예: ["name", "category"])');
});
1.3 Seeder (업그레이드 경로)
public function run(): void
{
$helper = app(\App\Extension\Helpers\GenericEntitySyncHelper::class);
$definedCodes = [];
foreach ($types as $data) {
$helper->sync(ShippingType::class, ['code' => $data['code']], $data);
$definedCodes[] = $data['code'];
}
// 완전 동기화: seeder 에 없는 row 삭제 (user_overrides 무관)
$helper->cleanupStale(ShippingType::class, [], 'code', $definedCodes);
}
2. 동작 메커니즘
2.1 사용자 수정 기록 (자동)
사용자가 수정할 때 trait 의 updating 이벤트 핸들러가 자동으로 변경된 trackable 필드를 user_overrides 배열에 추가합니다.
$shippingType = ShippingType::find(1);
$shippingType->name = ['ko' => '사용자 수정'];
$shippingType->save();
// → user_overrides = ['name'] 자동 기록
2.2 시더 재실행 시 보존
// 시더/업그레이드에서 호출
$helper->sync(ShippingType::class, ['code' => 'parcel'], [
'name' => ['ko' => '택배'], // 사용자가 수정했으므로 건너뜀
'sort_order' => 1, // user_overrides 에 없으므로 갱신
]);
syncOrCreateFromUpgrade() 내부는:
- 기존 row 가 있으면
user_overrides에 등록된 trait 필드는 갱신 건너뜀 - 나머지 필드는 갱신
- 시더 컨텍스트(
user_overrides.seeding플래그) 에서는updating이벤트가 자동 bypass 되어 user_overrides 가 추가 기록되지 않음
3. Mass Update 투명 추적
3.1 지원 경로
다음 수정 경로 모두에서 user_overrides 가 자동 기록됩니다:
| 경로 | 예시 | 기록 여부 |
|---|---|---|
| 인스턴스 update | $model->update([...]) |
✅ 기록 |
| 인스턴스 save | $model->name = 'x'; $model->save() |
✅ 기록 |
| mass update | Model::where(...)->update([...]) |
✅ 기록 |
whereIn mass update |
Model::whereIn('id', [...])->update([...]) |
✅ 기록 (행별 계산) |
| Query Builder 우회 | DB::table('...')->update(...) |
❌ 기록 안 됨 (escape hatch) |
3.2 내부 구현
HasUserOverrides trait 이 커스텀 UserOverridesAwareBuilder 를 반환하여 mass update 를 가로챕니다:
- 시더 컨텍스트면 기본 경로 유지
- 입력
$values에 trackable 필드가 없으면 기본 경로 유지 (성능 최적화) - 그 외: 대상 행 preload → 행별
calculateUserOverridesFor($values)계산 → user_overrides 포함한 per-row UPDATE
3.3 성능 고려
| 상황 | 쿼리 수 |
|---|---|
| trackable 필드 미포함 update | 1 UPDATE (기존) |
| trackable 필드 포함 mass update | 1 SELECT + N UPDATE |
| 대량 처리 필요 (수천~수만 행) | DB::table(...)->update(...) 사용 권장 (Eloquent 우회) |
4. 공개 API
4.1 calculateUserOverridesFor(array $incoming): array
현재 모델 상태와 신규 입력을 비교하여 user_overrides 배열을 계산합니다.
$model = ShippingType::find(1);
$overrides = $model->calculateUserOverridesFor(['name' => ['ko' => '새 이름']]);
// 결과: ['name'] (현재 값과 다른 trackable 필드만 포함)
주로 UserOverridesAwareBuilder 가 내부적으로 사용하며, 복잡한 커스텀 sync 로직에서도 재사용 가능합니다.
4.2 syncOrCreateFromUpgrade(array $finder, array $attributes): self
시더/업그레이드에서 직접 호출하거나, helper 에 위임합니다.
ShippingType::syncOrCreateFromUpgrade(
['code' => 'parcel'],
['name' => ['ko' => '택배'], 'category' => 'domestic']
);
4.3 getTrackableFields(): array
$trackableFields 프로퍼티 반환. 커스텀 로직에서 참조.
5. 정책과 원칙
5.1 row 존재 여부 vs 필드 값
| 구분 | 정책 |
|---|---|
| row 존재 | 오직 config/seeder 정의 기준 (user_overrides 무관) — stale cleanup 은 무조건 삭제 |
| 필드 값 (유지되는 row) | user_overrides 에 등록된 trackable 필드는 보존, 나머지는 갱신 |
즉 "사용자가 수정한 row 이니 보존" 이 아니라 "config 에 있으면 유지, 있으면서 사용자가 수정한 필드는 유지" 입니다.
5.2 Trait 적용 시점의 한계
Trait 이 적용되기 이전에 사용자가 수정한 row 는 user_overrides 가 비어있어 추적 불가. Trait 적용 이후 수정부터 보존 효과 발생.
해결: 마이그레이션 + trait 적용을 포함한 업그레이드 후 사용자에게 설정 재확인을 안내.
6. 적용 현황
| 모델 | 트랙 필드 |
|---|---|
Menu |
name, icon, order, url |
Role |
name, description |
NotificationDefinition |
name, is_active |
NotificationTemplate |
subject, body, click_url, recipients, is_active |
Schedule |
expression, command, timeout, is_active |
BoardType |
name |
ClaimReason |
name, sort_order, is_active |
ShippingType |
name, category, is_active, sort_order |
7. 참고
- 완전 동기화 원칙: core-config.md
- Helper 5종 사용 가이드: data-sync-helpers.md
- 업그레이드 스텝: extension/upgrade-step-guide.md