# GLOBEAU 플랫폼 중앙 집중형 미디어 라이브러리(DAM) 시스템 통합 운영 및 개발 매뉴얼

---

## 1. 아키텍처 개요 및 핵심 설계 원칙

### 1.1 도입 배경 및 목표
GLOBEAU 플랫폼은 가상서버호스팅(VPS) 환경에서 디스크 스토리지 용량 한계와 네트워크 트래픽을 효율적으로 관리하고, 다중 뷰티샵(Multi-tenant SaaS) 구조에서의 데이터 프라이버시 및 미용업법 규제(과대광고, 미용 양속 저해 방지)를 준수하기 위해 중앙 집중형 미디어 자산 관리(DAM) 시스템을 구축했습니다.
기존의 개별 도메인별 파편화된 업로드 방식을 탈피하여, 단일 진입점인 `ImageService`와 영역별 통제 컨트롤러를 통해 바이너리 해시 기반 중복 차단, 가상 폴더 N:M 피벗 격리, 다형성(Polymorphic) 다대다 공유, 그리고 데스크톱 탐색기 수준의 고도화된 UI/UX를 제공합니다.

### 1.2 핵심 설계 철학
1. **원장님 친화적 UX 최우선**:
   - 뷰티샵 실무 환경에서는 한글, 공백, 괄호가 포함된 파일명(`로고.png`, `매장배너(1).png`)을 자연스럽게 사용합니다.
   - 화면상 표시되는 파일명은 자유롭게 허용하며, 특수문자나 한글로 인한 업로드 제한 스트레스를 배제합니다.
2. **바이너리 디듀플리케이션 (Zero Physical Duplication)**:
   - 파일명 중복은 허용하되, 파일의 실제 바이너리 내용에 대한 `SHA-256` 해시를 대조합니다.
   - 완전히 동일한 이미지인 경우 물리 저장을 차단하고 기존 레코드를 재사용(Share)합니다.
3. **폴더-이미지 N:M 피벗 분리 (`image_folder_pivot`)**:
   - `images` 테이블에 고정 `folder_id`를 두던 1:N 종속 구조를 탈피하고, 가상 폴더와의 연계를 `image_folder_pivot` N:M 피벗으로 완전 분리했습니다.
   - 하나의 이미지가 필요에 따라 여러 가상 카테고리/폴더에 동시에 매핑될 수 있으며, 폴더를 삭제하더라도 실물 이미지는 '전체 이미지'에 안전하게 보존됩니다.
4. **M:N 다형성(Polymorphic) 슬롯 격리 (`imageables`)**:
   - `shops`의 단일 컬럼(`logo_path`, `banner_path`) 의존을 탈피하고, 다형성 피벗(`imageables`)에 슬롯 키(`type`)를 추상화하여 관리합니다.
   - 단일 이미지가 여러 엔티티나 여러 슬롯(로고, 배너, 마케팅)에 동시 연결될 수 있으며, 한 슬롯에서 제거되어도 타 슬롯의 참조는 안전하게 유지됩니다.
5. **리눅스 OS 인코딩 및 퍼미션 보호**:
   - 업로드된 파일은 서버 디스크에 해시 기반 난수 문자열로 저장되어 리눅스 자모 분리 현상(NFC/NFD 충돌)이나 브라우저 URL 인코딩 깨짐을 원천 방어합니다.
   - 공용 스토리지의 디렉터리(`0777`/`2777`) 및 파일(`0666`) 권한을 표준화하여 웹서버(`www-data`)와 백그라운드 큐 워커 간의 쓰기 권한 충돌을 차단합니다.
6. **네이티브 데스크톱 탐색기 인터랙션**:
   - 50개 단위 무한 스크롤, 4단 독립 정렬, 마우스 영역 드래그(Marquee) 선택, OS 파일/폴더 드래그 앤 드롭 및 스포트라이트 드롭존을 완비했습니다.

---

## 2. 데이터베이스 스키마 및 마이그레이션 명세 (최종 확정본)

데이터베이스는 계층형 가상 폴더(`image_folders`), 중앙 미디어 메타데이터(`images`), 폴더 연계 피벗(`image_folder_pivot`), 그리고 도메인 다형성 연계 피벗(`imageables`)의 4대 핵심 테이블로 구성됩니다.

### 2.1 `images` (중앙 집중형 미디어 이미지 테이블)
물리 파일 메타데이터, 다중 포맷 경로(WebP/AVIF/Thumb), 해시 중복 차단값, 보호 잠금, SEO 식별자(`seo_slug`), 저작권(`credit`), 실사용 카운트를 영속화합니다. (기존 종속 컬럼인 `folder_id`는 제거되고 `image_folder_pivot`으로 분리되었습니다.)

| 번호 | PK/FK | 컬럼명 | 타입 | Null | 기본값 | 설명 및 상태값 |
| :---: | :---: | :--- | :--- | :---: | :---: | :--- |
| 1 | PK | `id` | Bigint (unsigned) | N | Auto | 이미지 고유 식별 번호 |
| 2 | FK | `shop_id` | Bigint (unsigned) | Y | null | 소유 뷰티샵 ID (`shops.id` 참조, null=최고관리자 공용) |
| 3 | FK | `uploaded_by` | Bigint (unsigned) | Y | null | 업로더 유저 ID (`users.id` 참조, NullOnDelete) |
| 4 | - | `visibility` | String(20) | N | public | 공개 범위 (`public`, `private`) |
| 5 | - | `disk` | String(50) | N | public | 파일 저장소 드라이버 (`public`, `s3` 등) |
| 6 | - | `path` | String(255) | N | - | 스토리지 내 상대 경로 (`shops/{id}/...` 등) |
| 7 | - | `avif_path` | String(255) | Y | null | AVIF 변환 스토리지 상대 경로 (최고 압축률 차세대 포맷) |
| 8 | - | `webp_path` | String(255) | Y | null | WebP 변환 스토리지 상대 경로 (구글 모바일 SEO 최적화) |
| 9 | - | `thumbnail_path` | String(255) | Y | null | 검색 봇 및 목록 썸네일 경로 (300px 전후) |
| 10 | - | `filename` | String(255) | N | - | 디스크에 저장된 물리적 파일명 |
| 11 | - | `original_name` | String(255) | N | - | 사용자가 업로드한 원본 파일명 (한글/공백 허용) |
| 12 | - | `mime_type` | String(50) | Y | null | 파일 바이너리 MIME 타입 |
| 13 | - | `size` | Bigint (unsigned) | N | - | 파일 용량 (Byte 단위) |
| 14 | - | `width` | Integer (unsigned) | Y | null | 가로 해상도(px) |
| 15 | - | `height` | Integer (unsigned) | Y | null | 세로 해상도(px) |
| 16 | - | `hash` | String(64) | N | - | SHA-256 고유 해시값 (Zero Duplication, Unique Index) |
| 17 | - | `is_temp` | Boolean | N | false | 임시 업로드 여부 (인덱스) |
| 18 | - | `is_protected` | Boolean | N | false | 관리자 수동 삭제 방지 잠금(🔒, 인덱스) |
| 19 | - | `seo_slug` | String(255) | Y | null | 검색 친화적 영문/한글 URL 식별자 (인덱스) |
| 20 | - | `title` | String(255) | Y | null | 관리자용 이미지 제목 |
| 21 | - | `alt_text` | String(255) | Y | null | SEO 대체 텍스트 |
| 22 | - | `description` | Text | Y | null | 상세 설명 및 캡션 |
| 23 | - | `tags` | Json | Y | null | 검색 키워드 태그 (JSON) |
| 24 | - | `credit` | String(255) | Y | null | 저작권자/출처 표기 (Schema.org author/copyrightHolder) |
| 25 | - | `usage_count` | Integer (unsigned) | N | 0 | 연결된 도메인 엔티티 수 캐시 (인덱스) |
| 26 | - | `created_at` | Timestamp | Y | null | 등록 일시 |
| 27 | - | `updated_at` | Timestamp | Y | null | 수정 일시 |

#### 마이그레이션 완성 코드: `database/migrations/2026_01_01_000001_create_images_table.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('images', function (Blueprint $table) {
                $table->id();

                // 1. 소유 및 권한 (folder_id 제거됨 -> 별도 N:M 피벗으로 분리)
                $table->foreignId('shop_id')->nullable()->constrained('shops')->nullOnDelete()->comment('소유 뷰티샵 ID (null: 최고관리자 공용)');
                $table->foreignId('uploaded_by')->nullable()->constrained('users')->nullOnDelete()->comment('업로더 유저 ID');
                $table->string('visibility', 20)->default('public')->comment('공개 범위 (public, private)');

                // 2. 파일 시스템 정보
                $table->string('disk', 50)->default('public')->comment('파일 저장소 드라이버');
                $table->string('path')->comment('스토리지 내 상대 경로 (shops/{id}/... 등)');
                $table->string('avif_path')->nullable()->comment('AVIF 변환 스토리지 상대 경로 (최고 압축률 차세대 포맷)');
                $table->string('webp_path')->nullable()->comment('WebP 변환 스토리지 상대 경로 (구글 모바일 SEO 최적화)');
                $table->string('thumbnail_path')->nullable()->comment('검색 봇 및 목록 썸네일 경로 (300px 전후)');
                $table->string('filename')->comment('디스크에 저장된 물리적 파일명');
                $table->string('original_name')->comment('사용자가 업로드한 원본 파일명');
                $table->string('mime_type', 50)->nullable()->comment('MIME 타입');
                $table->unsignedBigInteger('size')->comment('파일 용량 (Byte)');
                $table->unsignedInteger('width')->nullable()->comment('가로 해상도(px)');
                $table->unsignedInteger('height')->nullable()->comment('세로 해상도(px)');

                // 3. 무결성 및 중복 차단
                $table->string('hash', 64)->unique()->comment('SHA-256 고유 해시값 (Zero Duplication)');

                // 4. 수동 보호 플래그 (중요 보관용 잠금 기능)
                $table->boolean('is_temp')->default(false)->index()->comment('임시 업로드 여부');
                $table->boolean('is_protected')->default(false)->index()->comment('관리자 수동 삭제 방지 잠금(🔒)');

                // 5. SEO 및 메타데이터
                $table->string('seo_slug')->nullable()->index()->comment('검색 친화적 영문/한글 URL 식별자');
                $table->string('title')->nullable()->comment('관리자용 이미지 제목');
                $table->string('alt_text')->nullable()->comment('SEO 대체 텍스트');
                $table->text('description')->nullable()->comment('상세 설명 및 캡션');
                $table->json('tags')->nullable()->comment('검색 키워드 태그 (JSON)');
                $table->string('credit')->nullable()->comment('저작권자/출처 표기');

                // 6. 사용량 캐시
                $table->unsignedInteger('usage_count')->default(0)->index()->comment('연결된 도메인 엔티티 수');

                $table->timestamps();
            });
        }

        public function down(): void
        {
            Schema::dropIfExists('images');
        }
    };

---

### 2.2 `imageables` (다형성 연계 피벗 테이블)
Shop, Program, Post 등 모든 도메인 모델과 `images` 테이블 간 다대다(M:N) 매핑을 지원하며, 슬롯 식별자(`type`)와 정렬 순서(`sort_order`)를 격리 보장합니다.

| 번호 | PK/FK | 컬럼명 | 타입 | Null | 기본값 | 설명 및 상태값 |
| :---: | :---: | :--- | :--- | :---: | :---: | :--- |
| 1 | PK | `id` | Bigint (unsigned) | N | Auto | 이미지 다형성 연계 고유 식별 번호 |
| 2 | FK | `image_id` | Bigint (unsigned) | N | - | 대상 이미지 식별 번호 (`images.id` 참조, Cascade) |
| 3 | - | `imageable_type` | String(255) | N | - | 연계 모델 클래스명 (예: `App\Models\Shop`) |
| 4 | - | `imageable_id` | Bigint (unsigned) | N | - | 연계 엔티티 기본키 (PK) |
| 5 | - | `type` | String(30) | N | general | 슬롯 키 (`logo`, `banner`, `og_image`, `marketing`, `editor` 등) |
| 6 | - | `sort_order` | Integer | N | 0 | 갤러리 노출 순서 정렬 (오름차순) |
| 7 | - | `created_at` | Timestamp | Y | null | 매핑 생성 일시 |
| 8 | - | `updated_at` | Timestamp | Y | null | 매핑 수정 일시 |

- **복합 유니크 제약**: `['image_id', 'imageable_type', 'imageable_id', 'type']` (`imageables_unique_slot_image`) -> 동일 엔티티 동일 슬롯 내 중복 등록은 차단하되, 슬롯 간 공유는 허용.
- **초고속 복합 인덱스**: `['imageable_type', 'imageable_id', 'type']` (`imageables_morph_type_idx`).

#### 마이그레이션 완성 코드: `database/migrations/2026_01_01_000002_create_imageables_table.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('연계 대상 이미지 식별 번호');
                $table->morphs('imageable');
                $table->string('type', 30)->default('general')->comment('슬롯 키 (logo, banner, marketing 등)');
                $table->integer('sort_order')->default(0)->comment('노출 순서 정렬 (오름차순)');
                $table->timestamps();

                // 4개 컬럼 복합 유니크 제약 (동일 슬롯 중복 방지 & 슬롯 간 공유 허용)
                $table->unique(['image_id', 'imageable_type', 'imageable_id', 'type'], 'imageables_unique_slot_image');

                // 검색 최적화 인덱스
                $table->index(['imageable_type', 'imageable_id', 'type'], 'imageables_morph_type_idx');
            });
        }

        public function down(): void
        {
            Schema::dropIfExists('imageables');
        }
    };

---

### 2.3 `image_folders` (가상 이미지 폴더 분류 테이블)
미디어 라이브러리 내 계층형(Parent-Child) 디렉터리 트리 구조를 관리합니다.

| 번호 | PK/FK | 컬럼명 | 타입 | Null | 기본값 | 설명 및 상태값 |
| :---: | :---: | :--- | :--- | :---: | :---: | :--- |
| 1 | PK | `id` | Bigint (unsigned) | N | Auto | 폴더 고유 식별 번호 |
| 2 | - | `name` | String(100) | N | - | 폴더명 (예: 매장 전경, 시술 전후, 제품 등) |
| 3 | FK | `parent_id` | Bigint (unsigned) | Y | null | 상위 폴더 ID (`image_folders.id` 참조, Cascade) |
| 4 | FK | `created_by` | Bigint (unsigned) | Y | null | 폴더 생성 관리자 (`users.id` 참조, NullOnDelete) |
| 5 | - | `sort_order` | Integer | N | 0 | 표시 순서 |
| 6 | - | `created_at` | Timestamp | Y | null | 생성 일시 |
| 7 | - | `updated_at` | Timestamp | Y | null | 수정 일시 |

#### 마이그레이션 완성 코드: `database/migrations/2026_01_01_000003_create_image_folders_table.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('image_folders', function (Blueprint $table) {
                $table->id();
                $table->string('name', 100)->comment('폴더명 (예: 매장 전경, 시술 전후, 제품 등)');
                $table->foreignId('parent_id')->nullable()->constrained('image_folders')->cascadeOnDelete()->comment('상위 폴더 ID');
                $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('폴더 생성 관리자');
                $table->integer('sort_order')->default(0)->comment('표시 순서');
                $table->timestamps();
            });
        }

        public function down(): void
        {
            Schema::dropIfExists('image_folders');
        }
    };

---

### 2.4 `image_folder_pivot` (이미지-폴더 N:M 피벗 테이블)
가상 폴더와 이미지 간의 N:M 매핑을 지원하여 물리 파일과 폴더 간의 결합도를 낮추고 데이터 무결성을 보장합니다.

| 번호 | PK/FK | 컬럼명 | 타입 | Null | 기본값 | 설명 및 상태값 |
| :---: | :---: | :--- | :--- | :---: | :---: | :--- |
| 1 | PK | `id` | Bigint (unsigned) | N | Auto | 피벗 고유 식별 번호 |
| 2 | FK | `folder_id` | Bigint (unsigned) | N | - | 폴더 ID (`image_folders.id` 참조, Cascade) |
| 3 | FK | `image_id` | Bigint (unsigned) | N | - | 이미지 ID (`images.id` 참조, Cascade) |
| 4 | - | `created_at` | Timestamp | Y | null | 매핑 생성 일시 |
| 5 | - | `updated_at` | Timestamp | Y | null | 매핑 수정 일시 |

- **복합 유니크 제약**: `['folder_id', 'image_id']` (`image_folder_unique`) -> 동일 폴더 내 동일 이미지의 중복 매핑을 차단.

#### 마이그레이션 완성 코드: `database/migrations/2026_01_01_000004_create_image_folder_pivot_table.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('image_folder_pivot', function (Blueprint $table) {
                $table->id();
                $table->foreignId('folder_id')->constrained('image_folders')->cascadeOnDelete()->comment('폴더 ID');
                $table->foreignId('image_id')->constrained('images')->cascadeOnDelete()->comment('이미지 ID');
                $table->timestamps();

                // 동일 폴더 내 동일 이미지 중복 매핑 방지
                $table->unique(['folder_id', 'image_id'], 'image_folder_unique');
            });
        }

        public function down(): void
        {
            Schema::dropIfExists('image_folder_pivot');
        }
    };

---

## 3. 백엔드 비즈니스 로직 및 서비스 레이어

### 3.1 `ImageService.php` (핵심 처리 엔진)
임시 업로드 가공, 정식 엔티티 디렉터리 승격(`promote`), 단일/다중 슬롯 동기화(`syncSingle`, `syncMulti`), 본문 에디터 역추적(`syncEditorContent`), 고아 파일 영구 정리(`deleteOrphans`)를 수행합니다.

    namespace App\Services;

    use App\Models\Image;
    use App\Jobs\OptimizeImageJob;
    use Illuminate\Database\Eloquent\Model;
    use Illuminate\Http\UploadedFile;
    use Illuminate\Support\Facades\Storage;
    use Illuminate\Support\Facades\DB;
    use Illuminate\Support\Str;
    use Intervention\Image\ImageManager;
    use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
    use Intervention\Image\Drivers\Gd\Driver as GdDriver;

    class ImageService
    {
        protected string $disk = 'public';

        protected array $formatColumns = [
            'path',
            'avif_path',
            'webp_path',
            'thumbnail_path',
        ];

        public function resolveEntityDirectory(Model $model): string
        {
            $folderName = $model->getTable();
            if (empty($folderName)) {
                $folderName = Str::snake(Str::pluralStudly(class_basename($model)));
            }
            return "{$folderName}/{$model->getKey()}";
        }

        protected function ensureDirectory(string $relDir): void
        {
            $absDir = Storage::disk($this->disk)->path($relDir);
            if (!is_dir($absDir)) {
                $oldMask = umask(0);
                @mkdir($absDir, 0777, true);
                umask($oldMask);
            }
            @chmod($absDir, 0777);
        }

        public function uploadToTemp(UploadedFile $file, string $slotKey = 'general'): array
        {
            $hash = hash_file('sha256', $file->getRealPath());

            // 1. 동일 바이너리 해시 검증 (디듀플리케이션)
            $existingImage = Image::where('hash', $hash)->first();
            if ($existingImage) {
                return [
                    'image'     => $existingImage,
                    'is_shared' => true,
                ];
            }

            $this->ensureDirectory('temp');

            $storageDisk = Storage::disk($this->disk);
            $baseName    = $hash;
            $origExt     = strtolower($file->getClientOriginalExtension() ?: 'jpg');

            $tempOriginPath = "temp/{$baseName}.{$origExt}";
            $tempThumbPath  = "temp/{$baseName}_thumb.webp";
            $tempWebpPath   = "temp/{$baseName}.webp";

            $createdPhysicalFiles = [];

            try {
                $filePath = $file->getRealPath();
                $driver = (class_exists(ImagickDriver::class) && extension_loaded('imagick'))
                    ? new ImagickDriver()
                    : new GdDriver();

                $manager = new ImageManager($driver);
                $imageInstance = $manager->decodePath($filePath);

                $width  = $imageInstance->width();
                $height = $imageInstance->height();

                $encodeFile = function ($img, string $ext) {
                    if (method_exists($img, 'encodeUsingFileExtension')) {
                        return (string) $img->encodeUsingFileExtension($ext);
                    }
                    return (string) $img->encode($ext);
                };

                // 원본 저장
                $storageDisk->put($tempOriginPath, file_get_contents($file));
                @chmod($storageDisk->path($tempOriginPath), 0666);
                $createdPhysicalFiles[] = $tempOriginPath;

                // 400px 초경량 WebP 썸네일 생성
                $thumbInstance = clone $imageInstance;
                if (method_exists($thumbInstance, 'scaleDown')) {
                    $thumbInstance->scaleDown(width: 400);
                } elseif (method_exists($thumbInstance, 'resizeDown')) {
                    $thumbInstance->resizeDown(width: 400);
                }
                $thumbBinary = $encodeFile($thumbInstance, 'webp');
                $storageDisk->put($tempThumbPath, $thumbBinary);
                @chmod($storageDisk->path($tempThumbPath), 0666);
                $createdPhysicalFiles[] = $tempThumbPath;
                unset($thumbInstance, $thumbBinary);

                // 본문용 고화질 WebP 생성
                $webpBinary = $encodeFile(clone $imageInstance, 'webp');
                $storageDisk->put($tempWebpPath, $webpBinary);
                @chmod($storageDisk->path($tempWebpPath), 0666);
                $createdPhysicalFiles[] = $tempWebpPath;
                unset($webpBinary, $imageInstance);
                gc_collect_cycles();

                $clientName = $file->getClientOriginalName();
                $titleOnly  = pathinfo($clientName, PATHINFO_FILENAME);

                // DB 레코드 생성 (images 테이블 스키마 준수)
                $image = DB::transaction(function () use (
                    $tempOriginPath, $tempThumbPath, $tempWebpPath,
                    $baseName, $origExt, $clientName, $file, $width, $height, $hash, $titleOnly
                ) {
                    return Image::create([
                        'shop_id'        => null,
                        'uploaded_by'    => auth()->id(),
                        'visibility'     => 'public',
                        'disk'           => $this->disk,
                        'path'           => $tempOriginPath,
                        'avif_path'      => null,
                        'webp_path'      => $tempWebpPath,
                        'thumbnail_path' => $tempThumbPath,
                        'filename'       => "{$baseName}.{$origExt}",
                        'original_name'  => $clientName,
                        'mime_type'      => $file->getClientMimeType(),
                        'size'           => $file->getSize(),
                        'width'          => $width,
                        'height'         => $height,
                        'hash'           => $hash,
                        'is_temp'        => true,
                        'is_protected'   => false,
                        'title'          => $titleOnly,
                        'alt_text'       => $titleOnly,
                        'usage_count'    => 0,
                    ]);
                });

                // AVIF 비동기 큐 작업 디스패치
                OptimizeImageJob::dispatch($image->id);

                return [
                    'image'     => $image,
                    'is_shared' => false,
                ];

            } catch (\Throwable $e) {
                foreach ($createdPhysicalFiles as $path) {
                    if ($storageDisk->exists($path)) {
                        $storageDisk->delete($path);
                    }
                }
                report($e);
                throw new \RuntimeException('이미지 업로드 가공 중 오류 발생: ' . $e->getMessage(), 0, $e);
            }
        }

        public function promote(array $imageIds, Model $targetEntity, ?int $ownerShopId = null): array
        {
            $urlReplacements = [];
            $destinationDir  = $this->resolveEntityDirectory($targetEntity);
            $storageDisk     = Storage::disk($this->disk);

            $this->ensureDirectory($destinationDir);

            $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;

                if (!$image->is_temp && !str_starts_with($image->path, 'temp/')) {
                    continue;
                }

                $updates = [
                    'shop_id' => $effectiveShopId,
                    'is_temp' => false,
                ];

                foreach ($this->formatColumns as $column) {
                    $oldPath = $image->{$column};
                    if ($oldPath && $storageDisk->exists($oldPath)) {
                        $fileName = basename($oldPath);
                        $newPath  = "{$destinationDir}/{$fileName}";

                        if ($oldPath !== $newPath) {
                            if ($storageDisk->exists($newPath)) {
                                $storageDisk->delete($oldPath);
                            } else {
                                $storageDisk->move($oldPath, $newPath);
                                @chmod($storageDisk->path($newPath), 0666);
                            }

                            $updates[$column] = $newPath;
                            $oldUrl = '/storage/' . ltrim($oldPath, '/');
                            $newUrl = '/storage/' . ltrim($newPath, '/');
                            $urlReplacements[$oldUrl] = $newUrl;
                            $urlReplacements[$storageDisk->url($oldPath)] = $storageDisk->url($newPath);
                        }
                    }
                }

                $image->update($updates);
            }

            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);
                    Image::where('id', $oldImageId)->decrement('usage_count');
                }
                if ($entityPathColumn && $targetEntity->{$entityPathColumn} !== null) {
                    $targetEntity->update([$entityPathColumn => null]);
                }
                return;
            }

            if ($oldImageId && (int)$oldImageId === (int)$newImageId) {
                $this->promote([(int)$newImageId], $targetEntity);
                return;
            }

            if ($oldImageId) {
                $targetEntity->images()->wherePivot('type', $slotKey)->detach($oldImageId);
                Image::where('id', $oldImageId)->decrement('usage_count');
            }

            $this->promote([(int)$newImageId], $targetEntity);

            $targetEntity->images()->attach($newImageId, [
                'type'       => $slotKey,
                'sort_order' => 0,
            ]);
            Image::where('id', $newImageId)->increment('usage_count');

            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);
                Image::whereIn('id', $toDetach)->decrement('usage_count');
            }

            $toAttach = array_diff($submittedImageIds, $currentSlotImageIds);

            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,
                    ]);
                }
            }

            if (!empty($toAttach)) {
                Image::whereIn('id', $toAttach)->increment('usage_count');
            }
        }

        public function syncEditorContent(
            ?string $htmlContent,
            Model $targetEntity,
            string $slotKey = 'description',
            ?int $ownerShopId = null
        ): ?string {
            if (empty($htmlContent)) {
                if (method_exists($targetEntity, 'images')) {
                    $existingIds = $targetEntity->images()
                        ->wherePivot('type', $slotKey)
                        ->pluck('images.id')
                        ->toArray();

                    if (!empty($existingIds)) {
                        $targetEntity->images()->wherePivot('type', $slotKey)->detach($existingIds);
                        Image::whereIn('id', $existingIds)->decrement('usage_count');
                    }
                }
                return $htmlContent;
            }

            $allEditorImageIds = [];
            preg_match_all('/data-image-id=[\'"](\d+)[\'"]/i', $htmlContent, $idMatches);
            if (!empty($idMatches[1])) {
                $allEditorImageIds = array_map('intval', $idMatches[1]);
            }

            $updatedHtml = preg_replace_callback(
                '/(<img[^>]+src=["\'])([^"\']+)(["\'][^>]*>)/i',
                function ($matches) use ($targetEntity, &$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];

                    $baseNameWithoutExt = pathinfo($filename, PATHINFO_FILENAME);
                    $cleanHash = str_replace('_thumb', '', $baseNameWithoutExt);

                    $image = Image::where('filename', $filename)
                        ->orWhere('hash', $cleanHash)
                        ->orWhere('path', 'like', "%{$filename}")
                        ->first();

                    if (!$image) return $matches[0];

                    $allEditorImageIds[] = $image->id;

                    if ($image->is_temp || str_starts_with($image->path, 'temp/')) {
                        $this->promote([$image->id], $targetEntity);
                        $image->refresh();
                    }

                    $targetPath = $image->webp_path ?: $image->path;
                    $newUrl = '/storage/' . ltrim($targetPath, '/');

                    return $prefix . $newUrl . $suffix;
                },
                $htmlContent
            );

            if (method_exists($targetEntity, 'images')) {
                $uniqueEditorIds = array_values(array_unique($allEditorImageIds));
                $currentEditorIds = $targetEntity->images()
                    ->wherePivot('type', $slotKey)
                    ->pluck('images.id')
                    ->toArray();

                $toAttach = array_diff($uniqueEditorIds, $currentEditorIds);
                $toDetach = array_diff($currentEditorIds, $uniqueEditorIds);

                if (!empty($toDetach)) {
                    $targetEntity->images()->wherePivot('type', $slotKey)->detach($toDetach);
                    Image::whereIn('id', $toDetach)->decrement('usage_count');
                }

                if (!empty($toAttach)) {
                    foreach ($toAttach as $idx => $imgId) {
                        $targetEntity->images()->attach($imgId, [
                            'type'       => $slotKey,
                            'sort_order' => $idx,
                        ]);
                    }
                    Image::whereIn('id', $toAttach)->increment('usage_count');
                }
            }

            return $updatedHtml;
        }

        public function deleteOrphans(array $imageIds): int
        {
            $images = Image::whereIn('id', $imageIds)
                ->doesntHave('imageables')
                ->where('is_protected', false)
                ->get();

            $deletedCount = 0;
            $storageDisk  = Storage::disk($this->disk);

            DB::transaction(function () use ($images, $storageDisk, &$deletedCount) {
                foreach ($images as $image) {
                    foreach ($this->formatColumns as $col) {
                        $filePath = $image->{$col};
                        if ($filePath && $storageDisk->exists($filePath)) {
                            $storageDisk->delete($filePath);
                        }
                    }

                    $dir = dirname($image->path);
                    if ($dir && !in_array($dir, ['.', '/', 'temp'])) {
                        if (empty($storageDisk->allFiles($dir))) {
                            $storageDisk->deleteDirectory($dir);
                        }
                    }

                    // 연결된 가상 폴더 피벗도 안전 제거
                    DB::table('image_folder_pivot')->where('image_id', $image->id)->delete();

                    $image->delete();
                    $deletedCount++;
                }
            });

            return $deletedCount;
        }
    }

---

### 3.2 `ImageController.php` (최고관리자 미디어 제어)
다중 컬럼 통합 검색, `image_folder_pivot` 기반 가상 폴더 필터링, OS 폴더 드롭 자동 생성 및 매핑을 전담합니다.

    namespace App\Http\Controllers\Admin;

    use App\Http\Controllers\Controller;
    use Illuminate\Http\Request;
    use App\Models\Image;
    use App\Services\ImageService;
    use Illuminate\Support\Facades\Storage;
    use Illuminate\Support\Facades\DB;

    class ImageController extends Controller
    {
        protected ImageService $imageService;

        public function __construct(ImageService $imageService)
        {
            $this->imageService = $imageService;
        }

        public function index(Request $request)
        {
            try {
                $query = Image::query();

                // 1. 다중 컬럼 통합 키워드 검색
                if ($request->filled('keyword')) {
                    $keyword = trim($request->input('keyword'));
                    $query->where(function ($q) use ($keyword) {
                        $q->where('original_name', 'like', "%{$keyword}%")
                          ->orWhere('title', 'like', "%{$keyword}%")
                          ->orWhere('alt_text', 'like', "%{$keyword}%")
                          ->orWhere('seo_slug', 'like', "%{$keyword}%")
                          ->orWhere('description', 'like', "%{$keyword}%")
                          ->orWhereJsonContains('tags', $keyword)
                          ->orWhere('tags', 'like', "%{$keyword}%");
                    });
                }

                // 2. 가상 폴더 필터링 (image_folder_pivot 피벗 테이블 기준)
                if ($request->filled('folder_id')) {
                    $folderId = $request->input('folder_id');
                    if ($folderId === 'orphans') {
                        $usedIds = DB::table('imageables')->pluck('image_id')->toArray();
                        $query->whereNotIn('id', $usedIds);
                    } elseif (!in_array($folderId, ['all', 'null'], true)) {
                        $folderImageIds = DB::table('image_folder_pivot')
                            ->where('folder_id', (int)$folderId)
                            ->pluck('image_id')
                            ->toArray();
                        $query->whereIn('id', $folderImageIds);
                    }
                }

                // 3. 4단 정렬
                $sortBy = $request->input('sort_by', 'date');
                $sortDirection = strtolower($request->input('sort_direction', 'desc')) === 'asc' ? 'asc' : 'desc';

                if ($sortBy === 'name') {
                    $query->orderBy('original_name', $sortDirection);
                } elseif ($sortBy === 'size') {
                    $query->orderBy('size', $sortDirection);
                } elseif ($sortBy === 'extension' || $sortBy === 'type') {
                    $query->orderByRaw("SUBSTRING_INDEX(original_name, '.', -1) {$sortDirection}");
                } else {
                    $query->orderBy('created_at', $sortDirection);
                }

                $perPage = (int)$request->input('per_page', 50);
                $paginator = $query->paginate($perPage);

                // 4. 실사용(is_used) 여부 판별 (imageables 매핑)
                $pageIds = $paginator->pluck('id')->toArray();
                $usedIdMap = [];
                if (!empty($pageIds)) {
                    $usedIdMap = DB::table('imageables')
                        ->whereIn('image_id', $pageIds)
                        ->pluck('image_id')
                        ->flip()
                        ->toArray();
                }

                // 5. 프론트엔드 안전 규격 포맷팅
                $transformedItems = [];
                foreach ($paginator->items() as $item) {
                    $rawSize = (float)($item->size ?? 0);
                    $sizeText = '-';
                    if ($rawSize > 0) {
                        $units = ['B', 'KB', 'MB', 'GB'];
                        $pow = min((int)floor(log($rawSize, 1024)), count($units) - 1);
                        $pow = max(0, $pow);
                        $sizeText = round($rawSize / pow(1024, $pow), 1) . ' ' . $units[$pow];
                    }

                    $itemDisk = Storage::disk($item->disk ?? 'public');
                    $thumbUrl = !empty($item->thumbnail_path) 
                        ? $itemDisk->url($item->thumbnail_path) 
                        : (!empty($item->webp_path) ? $itemDisk->url($item->webp_path) : $itemDisk->url($item->path));

                    $transformedItems[] = [
                        'id'             => (int)$item->id,
                        'path'           => (string)$item->path,
                        'original_name'  => (string)($item->original_name ?? 'image'),
                        'size'           => (int)$rawSize,
                        'formatted_size' => $sizeText,
                        'formatted_date' => $item->created_at ? $item->created_at->format('Y-m-d H:i') : '-',
                        'extension'      => strtoupper(pathinfo($item->original_name ?? $item->path ?? '', PATHINFO_EXTENSION) ?: 'IMG'),
                        'is_used'        => isset($usedIdMap[$item->id]),
                        'is_protected'   => (bool)($item->is_protected ?? false),
                        'thumb_url'      => $thumbUrl,
                        'webp_url'       => !empty($item->webp_path) ? $itemDisk->url($item->webp_path) : null,
                        'avif_url'       => !empty($item->avif_path) ? $itemDisk->url($item->avif_path) : null,
                        'width'          => $item->width,
                        'height'         => $item->height,
                    ];
                }

                if ($request->ajax() || $request->wantsJson()) {
                    return response()->json([
                        'success' => true,
                        'images'  => [
                            'current_page' => $paginator->currentPage(),
                            'data'         => $transformedItems,
                            'last_page'    => $paginator->lastPage(),
                            'total'        => $paginator->total(),
                            'per_page'     => $paginator->perPage(),
                        ]
                    ], 200);
                }

                return view('admin.images.index', compact('paginator'));

            } catch (\Throwable $e) {
                return response()->json([
                    'success' => false,
                    'message' => '서버 내부 오류: ' . $e->getMessage(),
                    'images'  => ['data' => []]
                ], 200);
            }
        }

        public function store(Request $request)
        {
            $request->validate([
                'image'           => 'required|image|mimes:jpeg,png,jpg,webp,avif,gif|max:30720',
                'folder_id'       => 'nullable',
                'new_folder_name' => 'nullable|string|max:50',
                'type'            => 'nullable|string|max:50',
            ]);

            $file = $request->file('image');
            $originalName = $file->getClientOriginalName();

            if ($this->hasInvalidFileName($originalName)) {
                return response()->json([
                    'success' => false,
                    'message' => '사용할 수 없는 특수문자나 예약어가 포함된 파일명입니다.'
                ], 422);
            }

            $type = $request->input('type', 'general');
            $targetFolderId = null;

            // 1. OS 폴더 드롭 시 신규 폴더 생성 또는 기존 매핑
            if ($request->filled('new_folder_name')) {
                $folderName = trim($request->input('new_folder_name'));
                $parentId = $request->filled('folder_id') && !in_array($request->input('folder_id'), ['orphans', 'all', 'null'], true)
                    ? (int)$request->input('folder_id')
                    : null;

                $folder = DB::table('image_folders')
                    ->where('parent_id', $parentId)
                    ->where('name', $folderName)
                    ->first();

                if ($folder) {
                    $targetFolderId = $folder->id;
                } else {
                    $targetFolderId = DB::table('image_folders')->insertGetId([
                        'name'       => $folderName,
                        'parent_id'  => $parentId,
                        'created_at' => now(),
                        'updated_at' => now(),
                    ]);
                }
            } elseif ($request->filled('folder_id') && !in_array($request->input('folder_id'), ['orphans', 'all', 'null'], true)) {
                $targetFolderId = (int)$request->input('folder_id');
            }

            // 2. 물리 저장 및 다중 변환
            $result = $this->imageService->uploadToTemp($file, $type);
            $image = $result['image'];

            // 3. image_folder_pivot 피벗 연결
            if ($targetFolderId) {
                DB::table('image_folder_pivot')->updateOrInsert(
                    ['folder_id' => $targetFolderId, 'image_id' => $image->id],
                    ['created_at' => now(), 'updated_at' => now()]
                );
            }

            return response()->json([
                'success'   => true,
                'message'   => $result['is_shared'] 
                    ? '이미 보관함에 존재하는 동일한 이미지입니다. 기존 파일을 재사용합니다.' 
                    : '이미지가 성공적으로 업로드되었습니다.',
                'folder_id' => $targetFolderId,
                'data'      => $image
            ]);
        }

        public function show($id)
        {
            try {
                $image = Image::findOrFail($id);
                $disk = Storage::disk($image->disk ?? 'public');
                $isUsed = DB::table('imageables')->where('image_id', $image->id)->exists();

                return response()->json([
                    'success' => true,
                    'image'   => [
                        'id'             => $image->id,
                        'original_name'  => $image->original_name,
                        'title'          => $image->title ?? '',
                        'alt_text'       => $image->alt_text ?? '',
                        'width'          => $image->width,
                        'height'         => $image->height,
                        'size'           => $image->size,
                        'formatted_size' => round($image->size / 1024, 1) . ' KB',
                        'path'           => $image->path,
                        'original_url'   => $disk->url($image->path),
                        'webp_url'       => $image->webp_path ? $disk->url($image->webp_path) : null,
                        'avif_url'       => $image->avif_path ? $disk->url($image->avif_path) : null,
                        'thumb_url'      => $image->thumbnail_path ? $disk->url($image->thumbnail_path) : $disk->url($image->path),
                        'is_used'        => $isUsed,
                        'is_protected'   => (bool)$image->is_protected,
                        'created_at'     => $image->created_at ? $image->created_at->format('Y-m-d H:i:s') : '-',
                    ]
                ]);
            } catch (\Throwable $e) {
                return response()->json(['success' => false, 'message' => '이미지를 찾을 수 없습니다.'], 404);
            }
        }

        public function update(Request $request, $id)
        {
            $request->validate([
                'title'         => 'nullable|string|max:255',
                'alt_text'      => 'nullable|string|max:255',
                'original_name' => 'nullable|string|max:255',
            ]);

            try {
                $image = Image::findOrFail($id);
                if ($request->filled('title')) $image->title = trim($request->input('title'));
                if ($request->has('alt_text')) $image->alt_text = trim($request->input('alt_text', ''));
                if ($request->filled('original_name')) {
                    $newName = trim($request->input('original_name'));
                    if (!$this->hasInvalidFileName($newName)) $image->original_name = $newName;
                }
                $image->save();

                return response()->json([
                    'success' => true,
                    'message' => '이미지 정보가 성공적으로 수정되었습니다.',
                    'image'   => $image
                ]);
            } catch (\Throwable $e) {
                return response()->json(['success' => false, 'message' => '수정 실패: ' . $e->getMessage()], 500);
            }
        }

        public function toggleProtection($id)
        {
            try {
                $image = Image::findOrFail($id);
                $isUsed = DB::table('imageables')->where('image_id', $id)->exists();
                if ($isUsed) {
                    return response()->json([
                        'success' => false,
                        'message' => '실제 서비스에서 사용 중인 이미지는 상시 보호되며 잠금을 변경할 수 없습니다.'
                    ], 422);
                }

                $image->is_protected = !($image->is_protected ?? false);
                $image->save();

                return response()->json([
                    'success'      => true,
                    'is_protected' => (bool)$image->is_protected,
                    'message'      => $image->is_protected ? '보호 잠금이 설정되었습니다.' : '보호 잠금이 해제되었습니다.'
                ]);
            } catch (\Throwable $e) {
                return response()->json(['success' => false, 'message' => $e->getMessage()], 200);
            }
        }

        public function destroy(Request $request, $id)
        {
            try {
                $image = Image::findOrFail($id);

                $isUsed = DB::table('imageables')->where('image_id', $id)->exists();
                if ($isUsed) {
                    return response()->json([
                        'success' => false,
                        'message' => '실제 서비스에서 사용 중인 이미지는 삭제할 수 없습니다.'
                    ], 422);
                }

                if (!empty($image->is_protected)) {
                    return response()->json([
                        'success' => false,
                        'message' => '보호 잠금(🔒) 상태의 파일입니다. 잠금을 해제한 후 삭제해 주세요.'
                    ], 422);
                }

                DB::transaction(function () use ($image) {
                    $disk = Storage::disk($image->disk ?? 'public');
                    foreach (['path', 'avif_path', 'webp_path', 'thumbnail_path'] as $col) {
                        if (!empty($image->{$col}) && $disk->exists($image->{$col})) {
                            $disk->delete($image->{$col});
                        }
                    }
                    DB::table('image_folder_pivot')->where('image_id', $image->id)->delete();
                    $image->delete();
                });

                return response()->json([
                    'success' => true,
                    'message' => '이미지가 안전하게 삭제되었습니다.'
                ]);
            } catch (\Throwable $e) {
                return response()->json(['success' => false, 'message' => $e->getMessage()], 200);
            }
        }

        public function bulkDelete(Request $request)
        {
            $rawIds = $request->input('ids', $request->input('image_ids', []));
            $ids = array_values(array_filter(array_map('intval', (array)$rawIds)));
            if (empty($ids)) {
                return response()->json(['success' => false, 'message' => '삭제할 이미지를 선택해 주세요.'], 422);
            }

            $images = Image::whereIn('id', $ids)->get();
            $deletedCount = 0;
            $skippedCount = 0;
            $deletedIds   = [];

            DB::transaction(function () use ($images, &$deletedCount, &$skippedCount, &$deletedIds) {
                foreach ($images as $image) {
                    $isUsed = DB::table('imageables')->where('image_id', $image->id)->exists();
                    if ($isUsed || !empty($image->is_protected)) {
                        $skippedCount++;
                        continue;
                    }

                    $disk = Storage::disk($image->disk ?? 'public');
                    foreach (['path', 'avif_path', 'webp_path', 'thumbnail_path'] as $col) {
                        if (!empty($image->{$col}) && $disk->exists($image->{$col})) {
                            $disk->delete($image->{$col});
                        }
                    }

                    $deletedIds[] = $image->id;
                    $image->delete();
                    $deletedCount++;
                }

                if (!empty($deletedIds)) {
                    DB::table('image_folder_pivot')->whereIn('image_id', $deletedIds)->delete();
                }
            });

            return response()->json([
                'success'       => true,
                'message'       => "{$deletedCount}개의 파일이 삭제되었습니다." . ($skippedCount > 0 ? " ({$skippedCount}개 보호 파일 제외)" : ""),
                'deleted_ids'   => $deletedIds,
                'skipped_count' => $skippedCount,
            ]);
        }

        public function rename(Request $request, $id)
        {
            $request->validate(['name' => 'required|string|max:255']);
            $newName = trim($request->input('name'));
            $image = Image::findOrFail($id);

            if ($this->hasInvalidFileName($newName)) {
                return response()->json([
                    'success' => false,
                    'message' => '사용할 수 없는 특수문자나 예약어가 포함된 파일명입니다.'
                ], 422);
            }

            $image->original_name = $newName;
            $image->title = pathinfo($newName, PATHINFO_FILENAME);
            $image->save();

            return response()->json(['success' => true, 'message' => '파일명이 변경되었습니다.']);
        }

        private function hasInvalidFileName(string $filename): bool
        {
            if (trim($filename) === '') return true;
            if (preg_match('/[\\/:*?"<>|#]/', $filename)) return true;

            $reservedNames = [
                'CON', 'PRN', 'AUX', 'NUL',
                'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
                'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'
            ];
            $nameWithoutExt = strtoupper(pathinfo($filename, PATHINFO_FILENAME));
            return in_array($nameWithoutExt, $reservedNames);
        }
    }

---

### 3.3 `ImageFolderController.php` (가상 폴더 제어 및 피벗 이동)
계층 트리 조회, 동일 위치 중복 방지 폴더명 변경(`rename`), DFS 순환 참조 방지 계층 이동(`moveFolder`), 피벗 기준 파일 이동/복사(`moveOrCopyFiles`)를 처리합니다.

    namespace App\Http\Controllers\Admin;

    use App\Http\Controllers\Controller;
    use Illuminate\Http\Request;
    use Illuminate\Support\Facades\DB;

    class ImageFolderController extends Controller
    {
        public function index()
        {
            try {
                $rawFolders = DB::table('image_folders')->orderBy('sort_order', 'asc')->orderBy('name', 'asc')->get();

                // image_folder_pivot 피벗 기준 집계
                $countMap = DB::table('image_folder_pivot')
                    ->select('folder_id', DB::raw('count(*) as aggregate'))
                    ->groupBy('folder_id')
                    ->pluck('aggregate', 'folder_id')
                    ->toArray();

                $folderMap = [];
                foreach ($rawFolders as $f) {
                    $folderMap[$f->id] = [
                        'id'           => (int)$f->id,
                        'parent_id'    => $f->parent_id ? (int)$f->parent_id : null,
                        'name'         => (string)$f->name,
                        'images_count' => (int)($countMap[$f->id] ?? 0),
                        'has_children' => false,
                        'depth'        => 0,
                    ];
                }

                $childrenMap = [];
                foreach ($folderMap as $id => $item) {
                    $pid = $item['parent_id'] ?? 0;
                    $childrenMap[$pid][] = $id;
                    if ($pid && isset($folderMap[$pid])) {
                        $folderMap[$pid]['has_children'] = true;
                    }
                }

                $flatTree = [];
                $traverse = function ($parentId, $depth) use (&$traverse, &$flatTree, $childrenMap, $folderMap) {
                    if (!isset($childrenMap[$parentId])) return;
                    foreach ($childrenMap[$parentId] as $childId) {
                        $node = $folderMap[$childId];
                        $node['depth'] = $depth;
                        $flatTree[] = $node;
                        $traverse($childId, $depth + 1);
                    }
                };
                $traverse(0, 0);

                return response()->json([
                    'success' => true,
                    'folders' => $flatTree
                ]);
            } catch (\Throwable $e) {
                return response()->json([
                    'success' => false,
                    'message' => '폴더 목록 조회 실패: ' . $e->getMessage(),
                    'folders' => []
                ], 200);
            }
        }

        public function store(Request $request)
        {
            $request->validate([
                'name'      => 'required|string|max:50',
                'parent_id' => 'nullable|integer',
            ]);

            $name = trim($request->input('name'));
            $parentId = $request->input('parent_id') ? (int)$request->input('parent_id') : null;

            $exists = DB::table('image_folders')
                ->where('name', $name)
                ->where('parent_id', $parentId)
                ->exists();

            if ($exists) {
                return response()->json([
                    'success' => false,
                    'message' => '동일한 위치에 같은 이름의 폴더가 이미 존재합니다.'
                ], 422);
            }

            $id = DB::table('image_folders')->insertGetId([
                'name'       => $name,
                'parent_id'  => $parentId,
                'created_at' => now(),
                'updated_at' => now(),
            ]);

            return response()->json([
                'success' => true,
                'message' => '폴더가 생성되었습니다.',
                'folder'  => [
                    'id'           => $id,
                    'name'         => $name,
                    'parent_id'    => $parentId,
                    'images_count' => 0
                ]
            ]);
        }

        public function rename(Request $request, $id)
        {
            $request->validate(['name' => 'required|string|max:50']);

            $folder = DB::table('image_folders')->where('id', (int)$id)->first();
            if (!$folder) {
                return response()->json(['success' => false, 'message' => '대상 폴더를 찾을 수 없습니다.'], 404);
            }

            $newName = trim($request->input('name'));

            $isDuplicate = DB::table('image_folders')
                ->where('parent_id', $folder->parent_id)
                ->where('name', $newName)
                ->where('id', '!=', (int)$id)
                ->exists();

            if ($isDuplicate) {
                return response()->json(['success' => false, 'message' => '같은 위치에 동일한 이름의 폴더가 이미 존재합니다.'], 422);
            }

            DB::table('image_folders')->where('id', (int)$id)->update([
                'name'       => $newName,
                'updated_at' => now(),
            ]);

            return response()->json([
                'success' => true,
                'message' => '폴더 이름이 성공적으로 변경되었습니다.',
                'name'    => $newName
            ]);
        }

        public function moveFolder(Request $request)
        {
            $request->validate([
                'folder_id'        => 'required|integer|exists:image_folders,id',
                'target_parent_id' => 'nullable',
            ]);

            $folderId = (int)$request->input('folder_id');
            $rawTargetId = $request->input('target_parent_id');
            $targetParentId = ($rawTargetId === null || $rawTargetId === '0' || $rawTargetId === 0 || $rawTargetId === '') 
                ? null 
                : (int)$rawTargetId;

            // 순환 이동 차단 (자기 자신 및 하위 자손 폴더 포함)
            if ($targetParentId !== null) {
                $invalidTargets = $this->getAllDescendantFolderIds($folderId);
                if (in_array($targetParentId, $invalidTargets, true)) {
                    return response()->json([
                        'success' => false,
                        'message' => '자기 자신 또는 하위 폴더 안으로는 이동할 수 없습니다.'
                    ], 422);
                }
            }

            $sourceFolder = DB::table('image_folders')->where('id', $folderId)->first();
            $isDuplicate = DB::table('image_folders')
                ->where('parent_id', $targetParentId)
                ->where('name', $sourceFolder->name)
                ->where('id', '!=', $folderId)
                ->exists();

            if ($isDuplicate) {
                return response()->json([
                    'success' => false,
                    'message' => '이동하려는 위치에 동일한 이름의 폴더가 이미 존재합니다.'
                ], 422);
            }

            DB::table('image_folders')->where('id', $folderId)->update([
                'parent_id'  => $targetParentId,
                'updated_at' => now(),
            ]);

            return response()->json(['success' => true, 'message' => '폴더 위치가 성공적으로 이동되었습니다.']);
        }

        public function destroy($id)
        {
            $folder = DB::table('image_folders')->where('id', (int)$id)->first();
            if (!$folder) {
                return response()->json(['success' => false, 'message' => '존재하지 않는 폴더입니다.'], 404);
            }

            $allDescendantFolderIds = $this->getAllDescendantFolderIds((int)$id);

            // 가상 폴더 삭제 시 피벗 연결만 일괄 해제 (실물 이미지는 images 테이블에 안전 보존)
            DB::transaction(function () use ($allDescendantFolderIds) {
                DB::table('image_folder_pivot')->whereIn('folder_id', $allDescendantFolderIds)->delete();
                DB::table('image_folders')->whereIn('id', $allDescendantFolderIds)->delete();
            });

            return response()->json(['success' => true, 'message' => '폴더 및 하위 폴더가 안전하게 삭제되었습니다.']);
        }

        public function moveOrCopyFiles(Request $request)
        {
            $request->validate([
                'image_ids'        => 'required|array',
                'target_folder_id' => 'required',
                'action'           => 'nullable|in:move,copy',
            ]);

            $imageIds = array_map('intval', $request->input('image_ids'));
            $targetFolderId = (int)$request->input('target_folder_id');
            $sourceFolderId = $request->input('source_folder_id') ? (int)$request->input('source_folder_id') : null;
            $action = $request->input('action', 'move');

            DB::transaction(function () use ($imageIds, $targetFolderId, $sourceFolderId, $action) {
                if ($action === 'move' && $sourceFolderId) {
                    DB::table('image_folder_pivot')
                        ->where('folder_id', $sourceFolderId)
                        ->whereIn('image_id', $imageIds)
                        ->delete();
                }

                foreach ($imageIds as $imgId) {
                    DB::table('image_folder_pivot')->updateOrInsert(
                        ['folder_id' => $targetFolderId, 'image_id' => $imgId],
                        ['created_at' => now(), 'updated_at' => now()]
                    );
                }
            });

            return response()->json(['success' => true, 'message' => '파일이 성공적으로 이동되었습니다.']);
        }

        private function getAllDescendantFolderIds(int $folderId): array
        {
            $ids = [$folderId];
            $children = DB::table('image_folders')->where('parent_id', $folderId)->pluck('id')->toArray();
            foreach ($children as $cid) {
                $ids = array_merge($ids, $this->getAllDescendantFolderIds((int)$cid));
            }
            return array_unique($ids);
        }
    }

---

## 4. 프론트엔드 핵심 제어 (요약)
프론트엔드(`modal_media.js`)는 데스크톱 탐색기의 UX를 온전히 재현하기 위해 다음 5가지 핵심 파이프라인을 구동합니다:
1. **50개 단위 무한 스크롤 및 4대 정렬**: `IntersectionObserver` 감지선과 결합하여 날짜순, 이름순, 크기순, 확장자순 독립 토글을 지원합니다.
2. **WebKit FileSystem 드롭존**: 윈도우 탐색기에서 폴더째 드래그 시 `webkitGetAsEntry`를 통해 하위 이미지를 재귀 추출하고 가상 폴더(`new_folder_name`)를 자동 생성 및 매핑합니다.
3. **폴더 트리 및 캔버스 양방향 이동**: 좌측 사이드바 트리 노드와 우측 메인 캔버스 폴더 카드 간에 드래그 앤 드롭 이동(`POST /admin/image-folders/move`)을 완벽 연동합니다.
4. **마우스 영역 드래그 다중 선택 (Marquee)**: 캔버스 빈 영역을 마우스로 긁어 사각 박스 내의 이미지와 폴더 카드를 일괄 선택합니다.
5. **SweetAlert2 피드백 통일**: 파일 삭제, 폴더 생성/변경/삭제 및 에러 피드백을 모달 최상단 Z-Index(`10080`) 및 `Pretendard` 폰트로 렌더링합니다.

---

## 5. 서버 인프라 및 최적화 파이프라인

### 5.1 Systemd 큐 워커 서비스: `/etc/systemd/system/laravel-queue.service`

    [Unit]
    Description=GLOBEAU Laravel 13 Queue Worker
    After=network.target

    [Service]
    User=globeau
    Group=www-data
    Restart=always
    RestartSec=5
    ExecStart=/usr/bin/php8.4 /home/globeau/laravel/artisan queue:work --sleep=3 --tries=2 --max-time=3600 --memory=512 --timeout=180

    [Install]
    WantedBy=multi-user.target

### 5.2 백그라운드 큐 Job: `app/Jobs/OptimizeImageJob.php`

    namespace App\Jobs;

    use App\Models\Image;
    use Illuminate\Bus\Queueable;
    use Illuminate\Contracts\Queue\ShouldQueue;
    use Illuminate\Foundation\Bus\Dispatchable;
    use Illuminate\Queue\InteractsWithQueue;
    use Illuminate\Queue\SerializesModels;
    use Illuminate\Support\Facades\Storage;
    use Intervention\Image\ImageManager;
    use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
    use Intervention\Image\Drivers\Gd\Driver as GdDriver;

    class OptimizeImageJob implements ShouldQueue
    {
        use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

        public int $timeout = 180;
        public int $tries = 2;

        public function __construct(public int $imageId) {}

        public function handle(): void
        {
            $image = Image::find($this->imageId);
            if (!$image) return;

            $disk = Storage::disk($image->disk ?: 'public');
            if (!$disk->exists($image->path)) return;

            $fullPath = $disk->path($image->path);
            $driver = (class_exists(ImagickDriver::class) && extension_loaded('imagick')) ? new ImagickDriver() : new GdDriver();
            $manager = new ImageManager($driver);
            $imageInstance = $manager->decodePath($fullPath);

            $encodeFile = function ($img, string $ext) {
                if (method_exists($img, 'encodeUsingFileExtension')) {
                    return (string) $img->encodeUsingFileExtension($ext);
                }
                return (string) $img->encode($ext);
            };

            $dirName  = dirname($image->path);
            $baseDir  = ($dirName === '.' || $dirName === '/') ? '' : "{$dirName}/";
            $baseName = pathinfo($image->filename, PATHINFO_FILENAME);
            $avifRelPath = "{$baseDir}{$baseName}.avif";

            try {
                $avifBinary = $encodeFile(clone $imageInstance, 'avif');
                $absPath = $disk->path($avifRelPath);
                $dir = dirname($absPath);

                if (!is_dir($dir)) {
                    $oldMask = umask(0);
                    @mkdir($dir, 0777, true);
                    umask($oldMask);
                }

                @file_put_contents($absPath, $avifBinary);
                @chmod($absPath, 0666);

                if (file_exists($absPath) && filesize($absPath) > 0) {
                    $image->update(['avif_path' => $avifRelPath]);
                }
                unset($avifBinary);
            } catch (\Throwable $e) {}

            unset($imageInstance);
            gc_collect_cycles();
        }
    }

---

## 6. 시스템 관리 및 실무 운영 가이드

### 6.1 서버 권한 설정 및 데몬 재시작 명령어

    # 1. 스토리지 디렉터리 권한 및 SGID 강제 상속 적용
    sudo chmod -R 777 /home/globeau/laravel/storage/app/public
    sudo find /home/globeau/laravel/storage/app/public -type d -exec chmod 2777 {} +

    # 2. Systemd 큐 워커 서비스 데몬 등록 및 활성화
    sudo systemctl daemon-reload
    sudo systemctl enable --now laravel-queue

    # 3. 소스코드 배포 및 수정 후 캐시 초기화 & 데몬 재기동
    php /home/globeau/laravel/artisan optimize:clear
    php /home/globeau/laravel/artisan queue:restart
    sudo systemctl restart laravel-queue
    sudo systemctl restart apache2

### 6.2 운영 체크리스트
1. **신규 도메인 모델 확장 시**:
   - 도메인 모델에 `Shop.php`와 동일하게 `images()` MorphToMany 관계를 선언하고 `withPivot(['type', 'sort_order'])`를 등록합니다.
   - 컨트롤러 저장/수정 시 `ImageService`의 `syncSingle` 또는 `syncMulti`를 호출합니다.
2. **고아 임시 파일 정산**:
   - 관리자 화면의 `임시 파일` 탭에서 실사용되지 않고 보호 잠금(🔒)이 해제된 이미지들을 주기적으로 선별하여 일괄 영구 삭제(`destroyOrphans`)합니다.
3. **가상 폴더 삭제와 파일 보존**:
   - 가상 폴더를 삭제하면 `image_folder_pivot`의 매핑 관계만 안전하게 삭제되며, 원본 이미지는 `images` 테이블에 영구 보존되어 '전체 이미지' 탭에서 언제든 재사용할 수 있습니다.