# Laravel 13 이미지 업로드 최적화 및 비동기 파이프라인 구축 작업 일지

## 1. 개요 및 배경
- **개발 환경**: Ubuntu Linux, PHP 8.4, Laravel 13, Apache2, Intervention Image v3/v4.
- **기존 문제점**:
    - 업로드 요청 처리 과정에서 원본, 썸네일(WebP), 고화질 WebP, 고압축 AVIF 변환을 단일 HTTP 요청에서 동기(Synchronous) 방식으로 처리함.
    - CPU 연산량이 매우 큰 AVIF 인코딩 병목으로 인해 요청당 2~5초 이상의 지연 및 웹페이지 프리징 현상 발생.
    - 클라이언트 측 업로드 프로그레스 바가 부드럽게 증가하지 않고 정지해 있다가 한 번에 완료되는 UX 문제 발생.
    - 다중 사용자 업로드 시 Apache 프로세스 폭증으로 인한 500/504 에러 및 서버 다운 위험 상존.

---

## 2. 세부 트러블슈팅 및 해결 과정

### 2.1. Intervention Image v3/v4 인터페이스 불일치 오류
- **현상**:
    - `Call to undefined method Intervention\Image\ImageManager::make()`
    - `Call to undefined method Intervention\Image\ImageManager::read()`
    - `Call to undefined method Intervention\Image\Image::toWebp()`
- **원인 분석**:
    - PHP `ReflectionClass` 및 `get_class_methods`를 이용해 실제 설치된 라이브러리 인터페이스를 점검함.
    - 현재 환경에 설치된 `ImageManager` 규격은 `read()`/`make()`가 아닌 `decodePath()`, `decode()`를 지원함.
    - `Image` 인스턴스 역시 단축 메서드(`toWebp()`, `toAvif()`) 대신 `encodeUsingFileExtension()` 및 `scaleDown()`/`resizeDown()` 메서드를 사용해야 함을 확인.
- **조치**:
    - 드라이버 인스턴스(`ImagickDriver` 또는 `GdDriver`)를 직접 주입하여 `ImageManager`를 생성.
    - 파일 디코딩을 `$manager->decodePath($filePath)`로 통일.
    - 포맷 변환을 `$image->encodeUsingFileExtension($ext)` 규격으로 변경.

### 2.2. 리눅스 파일 퍼미션 및 umask 이슈
- **현상**:
    - 디스크에 물리 파일(WebP, AVIF)이 기록되지 않고 `Storage::put()`이 `false`를 반환함.
    - DB에는 파일 경로가 저장되나 실제 스토리지 경로(`storage/app/public/temp/`)에는 파일이 누락됨.
- **원인 분석**:
    - Apache 웹 프로세스(`www-data`)가 디렉터리를 생성할 때 시스템 기본 `umask(0022)`로 인해 `0755` 권한으로 생성됨.
    - 백그라운드 큐 워커(`globeau` 계정)가 해당 디렉터리에 대한 쓰기 권한을 얻지 못해 인코딩 바이너리 저장이 거부됨.
- **조치**:
    - `config/filesystems.php`의 `disks.public` 설정에 디렉터리(`0777`) 및 파일(`0666`) 기본 퍼미션 선언.
    - 디렉터리 생성 로직에 `umask(0)` 임시 적용 후 `mkdir(..., 0777, true)` 및 `chmod(..., 0777)` 강제 적용.
    - 파일 저장 시 `file_put_contents` 네이티브 안전 쓰기 및 `chmod(..., 0666)` 적용.
    - 하위 디렉터리 권한 상속을 위한 SGID 설정(`chmod 2777`).

### 2.3. 백그라운드 큐 워커(systemd) 계정 오류
- **현상**: `laravel-queue.service` 시작 시 `status=217/USER` 오류 발생하며 비정상 종료.
- **원인 및 조치**: 시스템 유저명 불일치로 인한 오류였으며, 실제 로그인 유저(`$USER`)와 PHP 8.4 바이너리 절대 경로를 정확히 지정하여 systemd 데몬으로 상시 가동 등록.

---

## 3. 최종 아키텍처 (하이브리드 비동기 처리)

1. **HTTP 요청 시점 (동기 즉각 응답 - 0.1~0.2초 이내)**:
    - 원본 파일 저장.
    - UI 미리보기용 400px WebP 썸네일 즉시 생성.
    - 에디터(TinyMCE) 본문 링크용 고화질 WebP 즉시 생성 및 `webp_path` DB 기록.
    - DB 트랜잭션 커밋 후 클라이언트에 200 OK 즉시 반환 (프로그레스 바 즉시 100% 완료).
2. **백그라운드 큐 워커 (비동기 처리)**:
    - CPU 부하가 극심한 AVIF 인코딩만 `OptimizeImageJob`으로 분리하여 백그라운드에서 1개씩 순차 처리.
    - 작업 완료 시 DB의 `avif_path`를 갱신.
    - 서버 부하 격리로 인해 동시 업로드가 몰려도 웹서버 다운 방지.

---

## 4. 파일별 최종 소스코드

### 4.1. config/filesystems.php (설정 수정부)
disks.public 배열에 권한 옵션을 추가합니다.

    'public' => [
        'driver' => 'local',
        'root' => storage_path('app/public'),
        'url' => env('APP_URL') . '/storage',
        'visibility' => 'public',
        'throw' => false,
        'permissions' => [
            'file' => [
                'public' => 0666,
                'private' => 0600,
            ],
            'dir' => [
                'public' => 0777,
                'private' => 0700,
            ],
        ],
    ],

### 4.2. app/Jobs/OptimizeImageJob.php
고부하 AVIF 인코딩을 전담하는 백그라운드 큐 Job 클래스입니다.

    <?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";

            $ensureDirectoryPermission = function (string$dirPath) {
                if (!is_dir($dirPath)) {$oldMask = umask(0);
                    @mkdir($dirPath, 0777, true);
                    umask($oldMask);
                }
                @chmod($dirPath, 0777);
            };

            $saveBinary = function (string$relPath, string $binary) use ($disk, $ensureDirectoryPermission): bool {$absPath = $disk->path($relPath);
                $dir = dirname($absPath);
                $ensureDirectoryPermission($dir);

                @file_put_contents($absPath,$binary);
                @chmod($absPath, 0666);

                return file_exists($absPath) && filesize($absPath) > 0;
            };

            try {
                $avifBinary = $encodeFile(clone$imageInstance, 'avif');
                if ($saveBinary($avifRelPath,$avifBinary)) {
                    $image->update(['avif_path' =>$avifRelPath]);
                }
                unset($avifBinary);
            } catch (\Throwable $e) {
                // AVIF 미지원 시 예외 무시
            }

            unset($imageInstance);
            gc_collect_cycles();
        }
    }

### 4.3. app/Services/ImageService.php
원본, 썸네일, 본문용 WebP를 즉시 생성하고 에디터 본문 동기화를 보장하는 서비스 클래스입니다.

    <?php

    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());

            $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);
                };

                // 1. 원본 저장
                $storageDisk->put($tempOriginPath, file_get_contents($file));
                @chmod($storageDisk->path($tempOriginPath), 0666);
                $createdPhysicalFiles[] =$tempOriginPath;

                // 2. 화면 프리뷰용 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);

                // 3. 에디터 본문용 고화질 WebP 생성
                $webpBinary =$encodeFile(clone $imageInstance, 'webp');$storageDisk->put($tempWebpPath,$webpBinary);
                @chmod($storageDisk->path($tempWebpPath), 0666);
                $createdPhysicalFiles[] =$tempWebpPath;
                unset($webpBinary,$imageInstance);
                gc_collect_cycles();

                // 4. DB 등록 (webp_path 즉시 보장)
                $clientName =$file->getClientOriginalName();
                $titleOnly  = pathinfo($clientName, PATHINFO_FILENAME);

                $image = DB::transaction(function () use ($tempOriginPath, $tempThumbPath,$tempWebpPath,
                    $baseName,$origExt, $clientName,$file, $width,$height, $hash,$titleOnly
                ) {
                    return Image::create([
                        'folder_id'      => null,
                        '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,
                    ]);
                });

                // 5. 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 \vert{}\vert{} 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);
                        }
                    }

                    $image->delete();$deletedCount++;
                }
            });

            return $deletedCount;
        }
    }

### 4.4. /etc/systemd/system/laravel-queue.service
서버 부팅 및 백그라운드 상시 가동용 systemd 유닛 파일입니다.

    [Unit]
    Description=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. 서버 운영 및 배포 필수 명령어

1. 권한 보정 및 SGID 적용 (터미널 1회 실행):
    sudo chmod -R 777 /home/globeau/laravel/storage/app/public
    sudo find /home/globeau/laravel/storage/app/public -type d -exec chmod 2777 {} +

2. 큐 워커 서비스 등록 및 활성화:
    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