# 뷰티샵 및 다형성(Polymorphic) 이미지 관리 아키텍처 개편 작업 정리 보고서

## 1. 아키텍처 개요 및 방향성
- **하드코딩 경로 탈피**: `shops` 테이블의 `logo_path`, `banner_path` 등 단일 경로 컬럼에 직접 의존하던 방식을 라라벨 다형성 다대다(`imageables` 피벗) 아키텍처로 전면 개편.
- **범용 슬롯 키(Slot Key) 추상화**:
  - `type` 컬럼을 `logo`, `banner` 등 비즈니스 도메인 명칭에 고정 결합하지 않고, 프론트엔드/블레이드 폼에서 임의로 부여할 수 있는 '슬롯 식별자'로 추상화.
  - 서비스 엔진은 도메인 명칭을 알 필요 없이 오직 **싱글형(1:1 매핑/교체/삭제 - `syncSingle`)**과 **멀티형(1:N 정렬/동기화 - `syncMulti`)** 두 가지 규격으로만 동작하도록 설계.
- **슬롯 격리 및 다중 공유 보장**:
  - 동일한 `image_id`가 같은 뷰티샵의 로고(`logo`), 배너(`banner`), 마케팅(`marketing`) 슬롯에 동시에 등록될 수 있도록 허용.
  - 마케팅 갤러리에서 특정 이미지를 삭제하더라도 로고 슬롯의 동일 이미지는 절대 영향을 받지 않도록 슬롯별 정밀 타겟팅 분리.

---

## 2. 데이터베이스 스키마 설계 (`imageables`)

### 2.1 마이그레이션 변경 사항
- **`type` 컬럼 추가**: 슬롯 식별자 (예: `logo`, `banner`, `og_image`, `marketing`, `editor`, `general` 등).
- **복합 유니크 제약 (`imageables_unique_slot_image`)**:
  - `['image_id', 'imageable_type', 'imageable_id', 'type']` 4개 복합 컬럼을 유니크로 묶어 슬롯 간 동일 이미지 공유는 허용하되, 동일 슬롯 내 중복 등록은 차단.
- **검색 최적화 복합 인덱스 (`imageables_morph_type_idx`)**:
  - `['imageable_type', 'imageable_id', 'type']` 조합으로 특정 엔티티의 특정 슬롯 이미지를 메모리 캐시급 속도로 초고속 탐색.

### 2.2 최종 마이그레이션 코드

    <?php

    use Illuminate\Database\Migrations\Migration;
    use Illuminate\Database\Schema\Blueprint;
    use Illuminate\Support\Facades\Schema;

    return new class extends Migration
    {
        public function up(): void
        {
            Schema::create('imageables', function (Blueprint $table) {
                $table->id()->comment('이미지 다형성 연계 고유 식별 번호');
                $table->foreignId('image_id')
                    ->constrained('images')
                    ->cascadeOnDelete()
                    ->comment('연계 대상 이미지 식별 번호 (images.id 참조)');

                // morphs: imageable_type, imageable_id
                $table->morphs('imageable');

                // 슬롯 식별 키
                $table->string('type', 30)
                    ->default('general')
                    ->comment('슬롯 키 (logo, banner, og_image, marketing, editor 등)');

                $table->integer('sort_order')
                    ->default(0)
                    ->comment('노출 순서 정렬 (오름차순)');

                $table->timestamps();

                // 1) 동일 엔티티 동일 슬롯 내 중복 차단 & 슬롯 간 공유 허용 복합 유니크
                $table->unique(
                    ['image_id', 'imageable_type', 'imageable_id', 'type'],
                    'imageables_unique_slot_image'
                );

                // 2) 초고속 슬롯 조회 복합 인덱스
                $table->index(
                    ['imageable_type', 'imageable_id', 'type'],
                    'imageables_morph_type_idx'
                );
            });
        }

        public function down(): void
        {
            Schema::dropIfExists('imageables');
        }
    };

---

## 3. 핵심 서비스 레이어 구현 (`ImageService.php`)

### 3.1 핵심 엔진 구성
1. **`promote()`**: 임시 폴더(`temp/`)의 이미지를 정식 엔티티 저장소(`shops/{id}/`)로 물리 이동(Zero Duplication 유지).
2. **`syncSingle()`**: 임의의 슬롯 키에 대한 1:1 이미지 매핑. 기존 이미지 교체 시 해당 슬롯의 피벗 레코드만 안전하게 해제(`detach`). 구버전 컬럼 데이터 대비 Fallback 지원.
3. **`syncMulti()`**: 임의의 멀티 슬롯 키에 대한 1:N 배열 목록 및 순서(`sort_order`) 동기화. 해당 슬롯 이외의 타 슬롯 피벗은 일절 건드리지 않는 Diff 알고리즘 적용.
4. **`syncEditorContent()`**: TinyMCE 본문 `<img>` 태그를 정규식(`preg_replace_callback`)으로 역추적하여 파일명 단위로 최신 엔티티 경로 치환 + `type='editor'` 피벗 연동.

### 3.2 `app/Services/ImageService.php` 전체 소스

    <?php

    namespace App\Services;

    use App\Models\Image;
    use Illuminate\Database\Eloquent\Model;
    use Illuminate\Http\UploadedFile;
    use Illuminate\Support\Facades\Storage;
    use Illuminate\Support\Facades\DB;
    use Illuminate\Support\Str;

    class ImageService
    {
        protected string $disk = 'public';

        public function resolveEntityDirectory(Model $model): string
        {
            $folderName = $model->getTable();
            if (empty($folderName)) {
                $folderName = Str::snake(Str::pluralStudly(class_basename($model)));
            }
            return "{$folderName}/{$model->getKey()}";
        }

        public function uploadToTemp(UploadedFile $file, string $slotKey = 'general'): array
        {
            $hash = hash_file('sha256', $file->getRealPath());

            $existingImage = Image::where('hash', $hash)->first();
            if ($existingImage) {
                return [
                    'image'     => $existingImage,
                    'is_shared' => true,
                ];
            }

            $dimensions = @getimagesize($file->getRealPath());
            $extension  = $file->getClientOriginalExtension() ?: 'webp';
            $filename   = "{$slotKey}_" . substr($hash, 0, 16) . ".{$extension}";
            $tempPath   = "temp/{$filename}";

            if (!Storage::disk($this->disk)->exists($tempPath)) {
                $file->storeAs('temp', $filename, $this->disk);
            }

            $image = Image::create([
                'folder_id'     => null,
                'shop_id'       => null,
                'uploaded_by'   => auth()->id(),
                'visibility'    => 'public',
                'disk'          => $this->disk,
                'path'          => $tempPath,
                'filename'      => $filename,
                'original_name' => $file->getClientOriginalName(),
                'mime_type'     => $file->getClientMimeType(),
                'size'          => $file->getSize(),
                'width'         => $dimensions ? $dimensions[0] : null,
                'height'        => $dimensions ? $dimensions[1] : null,
                'hash'          => $hash,
                'is_temp'       => true,
                'usage_count'   => 0,
            ]);

            return [
                'image'     => $image,
                'is_shared' => false,
            ];
        }

        public function promote(array $imageIds, Model $targetEntity, ?int $ownerShopId = null): array
        {
            $urlReplacements = [];
            $destinationDir  = $this->resolveEntityDirectory($targetEntity);

            $effectiveShopId = $ownerShopId 
                ?? ($targetEntity instanceof \App\Models\Shop ? $targetEntity->getKey() : ($targetEntity->shop_id ?? null));

            foreach (array_filter($imageIds) as $imageId) {
                $image = Image::find($imageId);
                if (!$image) continue;

                $newPath = "{$destinationDir}/{$image->filename}";
                $oldPath = $image->path;

                if (($image->is_temp || str_starts_with($image->path, 'temp/')) && Storage::disk($this->disk)->exists($oldPath)) {
                    if ($oldPath !== $newPath) {
                        if (Storage::disk($this->disk)->exists($newPath)) {
                            Storage::disk($this->disk)->delete($oldPath);
                        } else {
                            Storage::disk($this->disk)->move($oldPath, $newPath);
                        }

                        $oldStorageUrl = '/storage/' . ltrim($oldPath, '/');
                        $newStorageUrl = '/storage/' . ltrim($newPath, '/');
                        $urlReplacements[$oldStorageUrl] = $newStorageUrl;
                        $urlReplacements[Storage::disk($this->disk)->url($oldPath)] = Storage::disk($this->disk)->url($newPath);

                        $image->update([
                            'shop_id' => $effectiveShopId,
                            'path'    => $newPath,
                            'is_temp' => false,
                        ]);
                    }
                }
            }

            return $urlReplacements;
        }

        public function syncSingle(Model $targetEntity, string $slotKey, ?int $newImageId, ?string $entityPathColumn = null): void
        {
            if (!method_exists($targetEntity, 'images')) {
                return;
            }

            $existingPivot = $targetEntity->images()
                ->wherePivot('type', $slotKey)
                ->first();

            $oldImageId = $existingPivot?->id;

            if (!$oldImageId && $entityPathColumn && !empty($targetEntity->{$entityPathColumn})) {
                $fallbackImg = Image::where('path', $targetEntity->{$entityPathColumn})->first();
                $oldImageId = $fallbackImg?->id;
            }

            if (empty($newImageId)) {
                if ($oldImageId) {
                    $targetEntity->images()
                        ->wherePivot('type', $slotKey)
                        ->detach($oldImageId);
                }
                if ($entityPathColumn && $targetEntity->{$entityPathColumn} !== null) {
                    $targetEntity->update([$entityPathColumn => null]);
                }
                return;
            }

            if ($oldImageId && (int)$oldImageId === (int)$newImageId) {
                return;
            }

            if ($oldImageId) {
                $targetEntity->images()
                    ->wherePivot('type', $slotKey)
                    ->detach($oldImageId);
            }

            $this->promote([(int)$newImageId], $targetEntity);

            $targetEntity->images()->attach($newImageId, [
                'type'       => $slotKey,
                'sort_order' => 0,
            ]);

            if ($entityPathColumn) {
                $newImg = Image::find($newImageId);
                if ($newImg) {
                    $targetEntity->update([$entityPathColumn => $newImg->path]);
                }
            }
        }

        public function syncMulti(
            Model $targetEntity,
            string $slotKey,
            array $submittedImageIds,
            array $imageSorts = []
        ): void {
            if (!method_exists($targetEntity, 'images')) {
                return;
            }

            $submittedImageIds = array_values(array_unique(array_map('intval', array_filter($submittedImageIds))));

            if (!empty($submittedImageIds)) {
                $this->promote($submittedImageIds, $targetEntity);
            }

            $currentSlotImageIds = $targetEntity->images()
                ->wherePivot('type', $slotKey)
                ->pluck('images.id')
                ->toArray();

            $toDetach = array_diff($currentSlotImageIds, $submittedImageIds);
            if (!empty($toDetach)) {
                $targetEntity->images()
                    ->wherePivot('type', $slotKey)
                    ->detach($toDetach);
            }

            foreach ($submittedImageIds as $imageId) {
                $sortOrder = $imageSorts[$imageId] ?? 0;

                if (in_array($imageId, $currentSlotImageIds)) {
                    $targetEntity->images()
                        ->wherePivot('type', $slotKey)
                        ->updateExistingPivot($imageId, [
                            'sort_order' => $sortOrder,
                        ]);
                } else {
                    $targetEntity->images()->attach($imageId, [
                        'type'       => $slotKey,
                        'sort_order' => $sortOrder,
                    ]);
                }
            }
        }

        public function syncEditorContent(?string $htmlContent, Model $targetEntity, ?int $ownerShopId = null): ?string
        {
            if (empty($htmlContent)) {
                return $htmlContent;
            }

            $destinationDir  = $this->resolveEntityDirectory($targetEntity);
            $effectiveShopId = $ownerShopId 
                ?? ($targetEntity instanceof \App\Models\Shop ? $targetEntity->getKey() : ($targetEntity->shop_id ?? null));

            $allEditorImageIds = [];

            $updatedHtml = preg_replace_callback(
                '/(<img[^>]+src=["\'])([^"\']+)(["\'][^>]*>)/i',
                function ($matches) use ($targetEntity, $destinationDir, $effectiveShopId, &$allEditorImageIds) {
                    $prefix   = $matches[1];
                    $src      = $matches[2];
                    $suffix   = $matches[3];

                    $parsedPath = parse_url($src, PHP_URL_PATH);
                    $filename   = basename($parsedPath);

                    if (empty($filename)) {
                        return $matches[0];
                    }

                    $image = Image::where('filename', $filename)->first();
                    if (!$image) {
                        return $matches[0];
                    }

                    $allEditorImageIds[] = $image->id;

                    if ($image->is_temp || str_starts_with($image->path, 'temp/')) {
                        $oldPath = $image->path;
                        $newPath = "{$destinationDir}/{$image->filename}";

                        if (Storage::disk($this->disk)->exists($oldPath)) {
                            if ($oldPath !== $newPath) {
                                if (Storage::disk($this->disk)->exists($newPath)) {
                                    Storage::disk($this->disk)->delete($oldPath);
                                } else {
                                    Storage::disk($this->disk)->move($oldPath, $newPath);
                                }
                            }
                        }

                        $image->update([
                            'shop_id' => $effectiveShopId,
                            'path'    => $newPath,
                            'is_temp' => false,
                        ]);
                    }

                    $newUrl = '/storage/' . ltrim($image->path, '/');
                    return $prefix . $newUrl . $suffix;
                },
                $htmlContent
            );

            if (!empty($allEditorImageIds) && method_exists($targetEntity, 'images')) {
                $uniqueEditorIds  = array_values(array_unique($allEditorImageIds));
                $currentEditorIds = $targetEntity->images()
                    ->wherePivot('type', 'editor')
                    ->pluck('images.id')
                    ->toArray();

                $newToAttach = array_diff($uniqueEditorIds, $currentEditorIds);

                foreach ($newToAttach as $imgId) {
                    $targetEntity->images()->attach($imgId, [
                        'type'       => 'editor',
                        'sort_order' => 0,
                    ]);
                }
            }

            return $updatedHtml;
        }

        public function deleteOrphans(array $imageIds): int
        {
            $images = Image::whereIn('id', $imageIds)
                ->doesntHave('imageables')
                ->get();

            $deletedCount = 0;

            DB::transaction(function () use ($images, &$deletedCount) {
                foreach ($images as $image) {
                    if (Storage::disk($image->disk)->exists($image->path)) {
                        Storage::disk($image->disk)->delete($image->path);

                        $dir = dirname($image->path);
                        if ($dir && !in_array($dir, ['.', '/', 'temp'])) {
                            if (empty(Storage::disk($image->disk)->allFiles($dir))) {
                                Storage::disk($image->disk)->deleteDirectory($dir);
                            }
                        }
                    }
                    $image->delete();
                    $deletedCount++;
                }
            });

            return $deletedCount;
        }
    }

---

## 4. 모델 레이어 구현 (`Shop.php`)

### 4.1 주요 변경 포인트
- **`withPivot(['type', 'sort_order'])` 필수 선언**: 피벗의 슬롯 식별자 조회 보장.
- **슬롯 전용 범용 헬퍼**: `$shop->getSlotImage($key)`, `$shop->getSlotImages($key)` 구현.
- **블레이드 폼 바인딩 및 Fallback**: `logo_image_id`, `banner_image_id`, `og_image_id` 접근자에서 피벗에 데이터가 없는 과거 데이터일 경우 `logo_path` 등을 역추적해 ID 자동 연결.
- **갤러리 분리**: `$shop->marketing_images` 접근자를 통해 본문 에디터(`type='editor'`) 이미지와의 노출 혼선 차단.

### 4.2 `app/Models/Shop.php` 전체 소스

    <?php

    namespace App\Models;

    use Illuminate\Database\Eloquent\Model;
    use Illuminate\Database\Eloquent\Relations\BelongsTo;
    use Illuminate\Database\Eloquent\Relations\BelongsToMany;
    use Illuminate\Database\Eloquent\Relations\MorphToMany;
    use Illuminate\Database\Eloquent\SoftDeletes;

    class Shop extends Model
    {
        use SoftDeletes;

        protected $guarded = [];

        protected function casts(): array
        {
            return [
                'tags'                    => 'array',
                'holiday_configs'         => 'array',
                'is_reservation_enabled'  => 'boolean',
                'is_verified'             => 'boolean',
                'is_phone_verified'       => 'boolean',
                'is_pinned'               => 'boolean',
                'is_main_displayed'       => 'boolean',
                'is_active'               => 'boolean',
                'booking_unit_minutes'    => 'integer',
                'sort_order'              => 'integer',
                'latitude'                => 'decimal:8',
                'longitude'               => 'decimal:8',
                'verified_at'             => 'datetime',
                'verification_expires_at' => 'datetime',
                'approved_at'             => 'datetime',
                'rejected_at'             => 'datetime',
                'pinned_at'               => 'datetime',
            ];
        }

        protected static function booted()
        {
            static::creating(function ($shop) {
                if (empty($shop->code)) {
                    do {
                        $code = 'C' . mt_rand(1000000, 9999999);
                    } while (self::where('code', $code)->exists());

                    $shop->code = $code;
                }
            });

            static::saving(function ($shop) {
                if ($shop->isDirty('operating_status')) {
                    $shop->is_active = ($shop->operating_status === 'active');
                }

                if (!$shop->is_active) {
                    $shop->is_pinned = false;
                    $shop->is_main_displayed = false;
                }
            });

            static::deleted(function ($shop) {
                $shop->operating_status = 'closed';
                $shop->is_active = false;
                $shop->is_pinned = false;
                $shop->is_main_displayed = false;
                $shop->saveQuietly();

                if (method_exists($shop, 'programs')) {
                    $shop->programs()->detach();
                }
            });
        }

        public function isOwnershipLocked(): bool
        {
            return !empty($this->user_id)
                && !empty($this->ceo_name)
                && (bool)$this->is_verified
                && $this->status === 'approved'
                && $this->operating_status === 'active';
        }

        public function user(): BelongsTo
        {
            return $this->belongsTo(User::class);
        }

        public function center(): BelongsTo
        {
            return $this->belongsTo(Center::class);
        }

        public function programs(): BelongsToMany
        {
            return $this->belongsToMany(Program::class, 'shop_program', 'shop_id', 'program_id')
                ->withPivot(['is_active', 'sort_order'])
                ->withTimestamps();
        }

        public function categories(): BelongsToMany
        {
            return $this->belongsToMany(Category::class, 'category_shop', 'shop_id', 'category_id')
                ->withTimestamps();
        }

        public function images(): MorphToMany
        {
            return $this->morphToMany(Image::class, 'imageable', 'imageables')
                ->withPivot(['type', 'sort_order'])
                ->withTimestamps()
                ->orderByRaw('CASE WHEN imageables.sort_order = 0 THEN 1 ELSE 0 END ASC')
                ->orderBy('imageables.sort_order', 'asc')
                ->orderBy('imageables.id', 'desc');
        }

        public function getSlotImage(string $slotKey): ?Image
        {
            return $this->images->firstWhere('pivot.type', $slotKey);
        }

        public function getSlotImages(string $slotKey)
        {
            return $this->images->where('pivot.type', $slotKey)->values();
        }

        public function getLogoImageAttribute(): ?Image
        {
            return $this->getSlotImage('logo');
        }

        public function getBannerImageAttribute(): ?Image
        {
            return $this->getSlotImage('banner');
        }

        public function getOgImageAttribute(): ?Image
        {
            return $this->getSlotImage('og_image');
        }

        public function getMarketingImagesAttribute()
        {
            return $this->getSlotImages('marketing');
        }

        public function getLogoImageIdAttribute(): ?int
        {
            $img = $this->getSlotImage('logo');
            if ($img) {
                return $img->id;
            }

            return !empty($this->logo_path)
                ? Image::where('path', $this->logo_path)->value('id')
                : null;
        }

        public function getBannerImageIdAttribute(): ?int
        {
            $img = $this->getSlotImage('banner');
            if ($img) {
                return $img->id;
            }

            return !empty($this->banner_path)
                ? Image::where('path', $this->banner_path)->value('id')
                : null;
        }

        public function getOgImageIdAttribute(): ?int
        {
            $img = $this->getSlotImage('og_image');
            if ($img) {
                return $img->id;
            }

            return !empty($this->og_image_path)
                ? Image::where('path', $this->og_image_path)->value('id')
                : null;
        }
    }

---

## 5. 컨트롤러 레이어 구현 (`ShopController.php`)

### 5.1 주요 변경 포인트
- `store()` 및 `update()` 메서드에서 트랜잭션 내 일관된 슬롯 동기화 호출.
- 싱글 슬롯 매핑 루프 (`logo`, `banner`, `og_image`를 `syncSingle()`로 처리).
- 마케팅 갤러리 슬롯을 `syncMulti('marketing', ...)`로 명시 호출.
- 본문 에디터 경로 치환 결과(`$updatedContent`)를 DB `description` 컬럼에 최종 영속화.

### 5.2 `app/Http/Controllers/Admin/ShopController.php` 전체 소스

    <?php

    namespace App\Http\Controllers\Admin;

    use App\Http\Controllers\Controller;
    use App\Models\Shop;
    use App\Models\User;
    use App\Models\Center;
    use App\Services\ImageService;
    use App\Services\NotificationService;
    use Illuminate\Http\Request;
    use Illuminate\Support\Facades\DB;

    class ShopController extends Controller
    {
        protected ImageService $imageService;

        public function __construct(ImageService $imageService)
        {
            $this->imageService = $imageService;
        }

        public function index(Request $request)
        {
            $query = Shop::with(['user', 'center', 'images'])
                ->where('status', '!=', 'pending');

            if ($request->filled('keyword')) {
                $keyword = $request->keyword;
                $query->where(function ($q) use ($keyword) {
                    $q->where('name', 'like', "%{$keyword}%")
                        ->orWhere('ceo_name', 'like', "%{$keyword}%")
                        ->orWhere('address1', 'like', "%{$keyword}%");
                });
            }

            if ($request->filled('status')) {
                $status = $request->status;
                if ($status === 'approved') {
                    $query->where('operating_status', 'active');
                } elseif ($status === 'pending') {
                    $query->where('operating_status', 'temp_closed');
                } elseif ($status === 'rejected') {
                    $query->whereIn('operating_status', ['suspended', 'closed']);
                }
            }

            $query->orderBy('is_active', 'desc')
                ->orderBy('is_pinned', 'desc')
                ->orderByRaw('CASE WHEN sort_order = 0 THEN 1 ELSE 0 END')
                ->orderBy('sort_order', 'asc')
                ->latest();

            $perPage = $request->input('per_page', 15);
            $shops = $query->paginate($perPage);

            if ($request->ajax()) {
                return view('admin.shops.partials.list_items', compact('shops'))->render();
            }
            return view('admin.shops.index', compact('shops'));
        }

        public function create()
        {
            $users = User::role(['shop', 'user'])->orderBy('name')->get();
            $centers = Center::orderBy('name')->get();

            return view('admin.shops.create', compact('users', 'centers'));
        }

        public function store(Request $request)
        {
            $request->merge([
                'user_id'         => trim($request->user_id) ?: null,
                'business_number' => trim($request->business_number) ?: null,
            ]);

            $validated = $request->validate([
                'name'             => 'required|string|max:255',
                'user_id'          => [
                    'nullable',
                    'exists:users,id',
                    function ($attribute, $value, $fail) {
                        if (!empty($value) && Shop::where('user_id', $value)->exists()) {
                            $fail('이미 다른 샵에 연동된 원장 계정입니다.');
                        }
                    },
                ],
                'center_id'        => 'nullable|exists:centers,id',
                'business_number'  => [
                    'nullable',
                    'string',
                    'max:50',
                    function ($attribute, $value, $fail) {
                        if (!empty($value) && Shop::where('business_number', $value)->exists()) {
                            $fail('이미 등록되어 있는 사업자 등록번호입니다.');
                        }
                    },
                ],
                'ceo_name'         => 'required|string|max:100',
                'ceo_phone'        => 'nullable|string|max:50',
                'ceo_email'        => 'nullable|email|max:255',
                'tel'              => 'nullable|string|max:50',
                'fax'              => 'nullable|string|max:50',
                'zipcode'          => 'nullable|string|max:20',
                'address1'         => 'nullable|string|max:255',
                'address2'         => 'nullable|string|max:255',
                'region_sido'      => 'required|string|max:30',
                'region_sigungu'   => 'required|string|max:30',
                'region_category'  => 'required|string|max:50',
                'latitude'         => 'nullable|numeric|between:-90,90',
                'longitude'        => 'nullable|numeric|between:-180,180',
                'location_coords'  => 'nullable|string|max:100',
                'bank_name'        => 'nullable|string|max:50',
                'bank_account'     => 'nullable|string|max:100',
                'bank_holder'      => 'nullable|string|max:100',
                'logo_image_id'    => 'nullable|exists:images,id',
                'banner_image_id'  => 'nullable|exists:images,id',
                'og_image_id'      => 'nullable|exists:images,id',
                'naver_place_url'  => 'nullable|url|max:255',
                'youtube_id'       => 'nullable|string|max:100',
                'introduction'     => 'nullable|string|max:255',
                'description'      => 'nullable|string',
                'operating_status' => 'required|in:active,temp_closed,suspended,closed',
                'sort_order'       => 'required|integer|min:0',
                'admin_memo'       => 'nullable|string',
                'category_ids'     => 'nullable|array',
                'category_ids.*'   => 'exists:categories,id',
                'marketing_image_ids'   => 'nullable|array',
                'marketing_image_ids.*' => 'exists:images,id',
                'image_sorts'           => 'nullable|array',
            ]);

            $validated['is_verified']       = $request->has('is_verified') ? 1 : 0;
            $validated['is_phone_verified'] = $request->has('is_phone_verified') ? 1 : 0;
            $validated['is_pinned']         = $request->has('is_pinned') ? 1 : 0;
            $validated['status']            = 'approved';
            $validated['is_active']         = true;

            DB::beginTransaction();
            try {
                $shop = Shop::create(collect($validated)->except([
                    'category_ids', 'marketing_image_ids', 'image_sorts', 'logo_image_id', 'banner_image_id', 'og_image_id'
                ])->toArray());

                if ($request->filled('category_ids')) {
                    $shop->categories()->sync($request->input('category_ids'));
                }

                $singleSlots = [
                    'logo_image_id'   => ['slot' => 'logo',     'column' => 'logo_path'],
                    'banner_image_id' => ['slot' => 'banner',   'column' => 'banner_path'],
                    'og_image_id'     => ['slot' => 'og_image', 'column' => 'og_image_path'],
                ];

                foreach ($singleSlots as $inputKey => $cfg) {
                    if ($request->filled($inputKey)) {
                        $this->imageService->syncSingle(
                            $shop,
                            $cfg['slot'],
                            (int)$request->input($inputKey),
                            $cfg['column']
                        );
                    }
                }

                $this->imageService->syncMulti(
                    $shop,
                    'marketing',
                    $request->input('marketing_image_ids', []),
                    $request->input('image_sorts', [])
                );

                if ($request->filled('description')) {
                    $updatedContent = $this->imageService->syncEditorContent(
                        $request->input('description'),
                        $shop
                    );
                    $shop->update(['description' => $updatedContent]);
                }

                DB::commit();
                return redirect()->route('admin.shops.index')->with('success', '신규 뷰티샵이 성공적으로 등록되었습니다.');

            } catch (\Exception $e) {
                DB::rollBack();
                return back()->withInput()->with('error', '뷰티샵 등록 중 오류가 발생했습니다: ' . $e->getMessage());
            }
        }

        public function edit(Shop $shop)
        {
            $shop->load(['categories.parent', 'images']);
            $users = User::role(['shop', 'user'])->orderBy('name')->get();
            $centers = Center::orderBy('name')->get();

            return view('admin.shops.edit', compact('shop', 'users', 'centers'));
        }

        public function update(Request $request, Shop $shop)
        {
            $request->merge([
                'user_id'         => trim($request->user_id) ?: null,
                'business_number' => trim($request->business_number) ?: null,
            ]);

            $validated = $request->validate([
                'name'             => 'required|string|max:255',
                'user_id'          => [
                    'nullable',
                    'exists:users,id',
                    function ($attribute, $value, $fail) use ($shop) {
                        if (!empty($value) && Shop::where('user_id', $value)->where('id', '!=', $shop->id)->exists()) {
                            $fail('이미 다른 샵에 연동된 원장 계정입니다.');
                        }
                    },
                ],
                'center_id'        => 'nullable|exists:centers,id',
                'business_number'  => [
                    'nullable',
                    'string',
                    'max:50',
                    function ($attribute, $value, $fail) use ($shop) {
                        if (!empty($value) && Shop::where('business_number', $value)->where('id', '!=', $shop->id)->exists()) {
                            $fail('이미 등록되어 있는 사업자 등록번호입니다.');
                        }
                    },
                ],
                'ceo_name'         => 'required|string|max:100',
                'ceo_phone'        => 'nullable|string|max:50',
                'ceo_email'        => 'nullable|email|max:255',
                'tel'              => 'nullable|string|max:50',
                'fax'              => 'nullable|string|max:50',
                'zipcode'          => 'nullable|string|max:20',
                'address1'         => 'nullable|string|max:255',
                'address2'         => 'nullable|string|max:255',
                'region_sido'      => 'required|string|max:30',
                'region_sigungu'   => 'required|string|max:30',
                'region_category'  => 'required|string|max:50',
                'latitude'         => 'nullable|numeric|between:-90,90',
                'longitude'        => 'nullable|numeric|between:-180,180',
                'location_coords'  => 'nullable|string|max:100',
                'bank_name'        => 'nullable|string|max:50',
                'bank_account'     => 'nullable|string|max:100',
                'bank_holder'      => 'nullable|string|max:100',
                'logo_image_id'    => 'nullable|exists:images,id',
                'banner_image_id'  => 'nullable|exists:images,id',
                'og_image_id'      => 'nullable|exists:images,id',
                'naver_place_url'  => 'nullable|url|max:255',
                'youtube_id'       => 'nullable|string|max:100',
                'introduction'     => 'nullable|string|max:255',
                'description'      => 'nullable|string',
                'operating_status' => 'required|in:active,temp_closed,suspended,closed',
                'sort_order'       => 'required|integer|min:0',
                'admin_memo'       => 'nullable|string',
                'category_ids'     => 'nullable|array',
                'category_ids.*'   => 'exists:categories,id',
                'marketing_image_ids'   => 'nullable|array',
                'marketing_image_ids.*' => 'exists:images,id',
                'image_sorts'           => 'nullable|array',
            ]);

            $validated['is_verified']       =$request->has('is_verified') ? 1 : 0;
            $validated['is_phone_verified'] =$request->has('is_phone_verified') ? 1 : 0;
            $validated['is_pinned']         =$request->has('is_pinned') ? 1 : 0;

            DB::beginTransaction();
            try {
                $shop->update(collect($validated)->except([
                    'category_ids', 'marketing_image_ids', 'image_sorts', 'logo_image_id', 'banner_image_id', 'og_image_id'
                ])->toArray());

                $shop->categories()->sync($request->input('category_ids', []));

                $singleSlots = [
                    'logo_image_id'   => ['slot' => 'logo',     'column' => 'logo_path'],
                    'banner_image_id' => ['slot' => 'banner',   'column' => 'banner_path'],
                    'og_image_id'     => ['slot' => 'og_image', 'column' => 'og_image_path'],
                ];

                foreach ($singleSlots as $inputKey =>$cfg) {
                    if ($request->exists($inputKey)) {
                        $newId =$request->input($inputKey);$this->imageService->syncSingle(
                            $shop,$cfg['slot'], 
                            !empty($newId) ? (int)$newId : null,$cfg['column']
                        );
                    }
                }

                $this->imageService->syncMulti($shop,
                    'marketing',
                    $request->input('marketing_image_ids', []),$request->input('image_sorts', [])
                );

                if ($request->has('description')) {
                    $updatedContent =$this->imageService->syncEditorContent(
                        $request->input('description'),$shop
                    );
                    $shop->update(['description' =>$updatedContent]);
                }

                DB::commit();
                return redirect()->route('admin.shops.index')->with('success', '뷰티샵 정보가 성공적으로 수정되었습니다.');

            } catch (\Exception $e) {
                DB::rollBack();
                return back()->withInput()->with('error', '수정 처리 중 오류가 발생했습니다: ' . $e->getMessage());
            }
        }

        public function destroy(Shop $shop)
        {
            try {
                $shop->operating_status = 'closed';
                $shop->save();$shop->delete();

                return redirect()->route('admin.shops.index')->with('success', '뷰티샵이 폐점(삭제) 처리되었습니다.');
            } catch (\Exception $e) {
                return back()->with('error', '삭제 처리 중 오류가 발생했습니다: ' . $e->getMessage());
            }
        }

        public function updateInline(Request $request, Shop$shop)
        {
            $validated =$request->validate([
                'is_pinned'         => 'sometimes|boolean',
                'is_main_displayed' => 'sometimes|boolean',
                'sort_order'        => 'sometimes|integer|min:0',
                'operating_status'  => 'sometimes|in:active,temp_closed,suspended,closed',
            ]);

            $targetActive = isset($validated['operating_status'])
                ? ($validated['operating_status'] === 'active')
                : $shop->is_active;

            if (!$targetActive) {
                if (isset($validated['is_pinned']) &&$validated['is_pinned'] == 1) {
                    return response()->json([
                        'success' => false,
                        'message' => '영업 중이 아닌 뷰티샵은 상단고정을 할 수 없습니다.'
                    ], 422);
                }
                if (isset($validated['is_main_displayed']) &&$validated['is_main_displayed'] == 1) {
                    return response()->json([
                        'success' => false,
                        'message' => '영업 중이 아닌 뷰티샵은 메인노출을 할 수 없습니다.'
                    ], 422);
                }
            }

            if (isset($validated['is_pinned']) &&$validated['is_pinned'] == 1) {
                if (!$shop->is_pinned) {$pinnedCount = Shop::where('is_pinned', true)->count();
                    if ($pinnedCount >= 3) {
                        return response()->json([
                            'success' => false,
                            'message' => '상단 노출은 최대 3개까지만 가능합니다.'
                        ], 422);
                    }
                }
            }

            $shop->update($validated);

            return response()->json([
                'success' => true,
                'message' => '성공적으로 반영되었습니다.'
            ]);
        }

        public function pendingList(Request $request)
        {
            $query = Shop::with(['user', 'center'])->whereIn('status', ['pending', 'rejected']);

            if ($request->filled('keyword')) {
                $keyword =$request->keyword;
                $query->where(function ($q) use ($keyword) {
                    $q->where('name', 'like', "%{$keyword}%")
                        ->orWhere('ceo_name', 'like', "%{$keyword}%")
                        ->orWhere('address1', 'like', "%{$keyword}%");
                });
            }

            $shops =$query->latest()->paginate(15);
            return view('admin.shops.pending', compact('shops'));
        }

        public function pendingShow($id)
        {
            $shop = Shop::with(['user', 'center'])->findOrFail($id);
            return view('admin.shops.pending_show', compact('shop'));
        }

        public function approve($id)
        {
            $shop = Shop::findOrFail($id);
            $user =$shop->user;

            if (!$user) {
                return back()->with('error', '해당 뷰티샵에 연동된 신청인(회원) 정보를 찾을 수 없습니다.');
            }

            if ($user->status !== 'active') {
                return back()->with('error', "신청인의 계정 상태가 정상(active)이 아니므로 입점을 승인할 수 없습니다. (현재 상태: {$user->status})");
            }

            $shop->update([
                'status'           => 'approved',
                'operating_status' => 'temp_closed',
                'is_active'        => true,
                'approved_at'      => now(),
                'rejected_at'      => null,
                'rejection_reason' => null,
            ]);

            if (!$user->hasRole('shop')) {$user->assignRole('shop');
            }

            $notificationService = app(NotificationService::class);
            $applicantPhone =$user->phone ?? null;

            if ($applicantPhone) {
                $notificationService->send(
                    templateCode: 'SHOP_APPROVE_SHOP',
                    receiver: $applicantPhone,
                    variables: [
                        'shop_name'      => $shop->name,
                        'applicant_name' => $user->name ?? $shop->ceo_name,
                    ],
                    userId: $user->id
                );
            }

            return back()->with('success', "뷰티샵 '{$shop->name}'의 입점이 승인되었습니다.");
        }

        public function reject(Request $request,$id)
        {
            $request->validate([
                'rejection_reason' => 'required|string|max:500'
            ], [
                'rejection_reason.required' => '반려 사유를 반드시 입력해주세요.'
            ]);

            $shop = Shop::findOrFail($id);

            $shop->update([
                'status'           => 'rejected',
                'operating_status' => 'suspended',
                'is_active'        => false,
                'rejected_at'      => now(),
                'rejection_reason' => $request->rejection_reason,
            ]);

            return back()->with('success', "뷰티샵 '{$shop->name}'의 입점이 반려되었습니다.");
        }

        public function searchAjax(Request $request)
        {
            $keyword =$request->input('keyword');
            $excludeIds =$request->input('exclude');

            $query = Shop::where('status', 'approved');

            if (!empty($excludeIds)) {
                $excludeArray = explode(',',$excludeIds);
                $query->whereNotIn('id',$excludeArray);
            }

            if (!empty($keyword)) {$query->where(function ($q) use ($keyword) {
                    $q->where('name', 'like', "%{$keyword}%")
                        ->orWhere('ceo_name', 'like', "%{$keyword}%")
                        ->orWhere('address1', 'like', "%{$keyword}%");
                });
            }

            $shops =$query->orderBy('name', 'asc')
                ->limit(30)
                ->get(['id', 'name', 'ceo_name', 'address1', 'created_at']);

            return response()->json(['shops' => $shops]);
        }
    }

---

## 6. 블레이드 뷰 템플릿 가이드라인

### 6.1 싱글 이미지 히든 인풋 설정
모델에 구현된 Fallback 접근자를 사용하여 기존 데이터와 신규 데이터 모두 완벽하게 지원:

    <input type="hidden" name="logo_image_id" id="logo_image_id" value="{{ old('logo_image_id', $shop->logo_image_id) }}">
    <input type="hidden" name="banner_image_id" id="banner_image_id" value="{{ old('banner_image_id', $shop->banner_image_id) }}">
    <input type="hidden" name="og_image_id" id="og_image_id" value="{{ old('og_image_id', $shop->og_image_id) }}">

### 6.2 마케팅 갤러리 렌더링 분리
에디터 본문 이미지(`type='editor'`)가 섞여 나오지 않도록 `$shop->marketing_images` 사용:

    <div id="marketing-gallery-preview" class="row g-2 mt-2">
        @if(isset($shop))
            @foreach($shop->marketing_images as$img)
                <div class="col-6 col-md-3 col-xxl-2 text-center position-relative multi-preview-item marketing-preview-col mb-2" data-id="{{ $img->id }}">
                    <div class="card border shadow-sm h-100 position-relative bg-white rounded overflow-hidden p-0">
                        <button type="button" class="position-absolute top-0 end-0 btn btn-dark btn-sm p-0 d-flex align-items-center justify-content-center shadow-sm" 
                                style="width: 20px; height: 20px; border-radius: 50%; z-index: 5; margin: 5px; font-size: 0.65rem;"
                                onclick="removeMultiMediaItem(this, {{ $img->id }}, 'marketing')" title="이미지 제거">
                            <i class="fa-solid fa-xmark"></i>
                        </button>
                        <div class="card-img-wrapper position-relative d-flex align-items-center justify-content-center overflow-hidden" 
                             style="aspect-ratio: 1 / 1; width: 100%; background-color: #f8f9fa;">
                            <img src="{{ asset('storage/' . $img->path) }}" alt="{{ $img->original_name }}" style="max-width: 100%; max-height: 100%; object-fit: contain;">
                        </div>
                        <div class="card-body p-2 bg-light d-flex flex-column gap-1">
                            <div class="input-group input-group-sm">
                                <span class="input-group-text bg-white small px-1 text-muted" style="font-size: 0.75rem;">순서</span>
                                <input type="number" name="image_sorts[{{ $img->id }}]" value="{{ $img->pivot->sort_order ?? 0 }}" class="form-control text-center text-dark fw-bold px-1 py-0" min="0">
                            </div>
                            <small class="text-dark text-truncate d-block fw-semibold px-1" style="font-size: 0.7rem;">{{ $img->original_name }}</small>
                        </div>
                    </div>
                </div>
            @endforeach
        @endif
    </div>

    <div id="marketing-hidden-inputs">
        @if(isset($shop))
            @foreach($shop->marketing_images as$img)
                <input type="hidden" name="marketing_image_ids[]" value="{{ $img->id }}" data-id="{{ $img->id }}">
            @endforeach
        @endif
    </div>

---

## 7. 향후 타 엔티티(Program 등) 확장 지침
- `Program` 모델 및 컨트롤러 구현 시에도 동일하게 `ImageService`의 `syncSingle`과 `syncMulti`를 호출.
- 예시: 프로그램 대표 썸네일은 `syncSingle($program, 'thumbnail', $id)`, 커리큘럼 소개 갤러리는 `syncMulti($program, 'curriculum_gallery', $ids, $sorts)` 형태로 전달만 하면 추가 스키마 작업 없이 완벽 대응 가능.