From e291b29bd7e19f9d5a265caa68de34c18bfbbf6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 25 Feb 2026 20:38:37 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[equipment]=20=EC=84=A4=EB=B9=84=20?= =?UTF-8?q?=EC=82=AC=EC=A7=84=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=8B=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=95=95=EC=B6=95=20(1MB=20?= =?UTF-8?q?=EC=9D=B4=ED=95=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GD 라이브러리로 업로드 전 이미지 압축 처리 - 장축 2048px 초과 시 리사이즈 (비율 유지) - JPEG 품질 85→40 점진적 감소로 1MB 이하 보장 - PNG(투명 없음)/GIF/BMP → JPEG 자동 변환 - PNG(투명 있음) → PNG 유지 (압축 레벨 9) - 임시파일 자동 정리 (finally 블록) --- app/Services/EquipmentPhotoService.php | 187 +++++++++++++++++++++---- 1 file changed, 157 insertions(+), 30 deletions(-) diff --git a/app/Services/EquipmentPhotoService.php b/app/Services/EquipmentPhotoService.php index 0aff32ec..f437e103 100644 --- a/app/Services/EquipmentPhotoService.php +++ b/app/Services/EquipmentPhotoService.php @@ -12,6 +12,16 @@ class EquipmentPhotoService { private const MAX_PHOTOS = 10; + private const MAX_FILE_SIZE = 1048576; // 1MB + + private const MAX_DIMENSION = 2048; + + private const INITIAL_QUALITY = 85; + + private const MIN_QUALITY = 40; + + private const QUALITY_STEP = 5; + public function __construct( private readonly GoogleCloudService $googleCloudService ) {} @@ -31,44 +41,62 @@ public function uploadPhotos(Equipment $equipment, array $files): array foreach ($files as $index => $file) { if ($currentCount + $uploaded >= self::MAX_PHOTOS) { - $errors[] = "최대 ".self::MAX_PHOTOS."장까지 업로드 가능합니다."; + $errors[] = '최대 '.self::MAX_PHOTOS.'장까지 업로드 가능합니다.'; break; } - $extension = $file->getClientOriginalExtension(); - $timestamp = now()->format('Ymd_His'); - $objectName = "equipment-photos/{$tenantId}/{$equipment->id}/{$timestamp}_{$index}.{$extension}"; + $extension = strtolower($file->getClientOriginalExtension()); + $compressedPath = null; - $tempPath = $file->getRealPath(); - $result = $this->googleCloudService->uploadToStorage($tempPath, $objectName); + try { + [$compressedPath, $finalExtension] = $this->compressImage($file->getRealPath(), $extension); - if (! $result) { - $errors[] = "파일 '{$file->getClientOriginalName()}' 업로드 실패"; - Log::error('EquipmentPhoto: GCS 업로드 실패', [ - 'equipment_id' => $equipment->id, - 'file' => $file->getClientOriginalName(), + $timestamp = now()->format('Ymd_His'); + $objectName = "equipment-photos/{$tenantId}/{$equipment->id}/{$timestamp}_{$index}.{$finalExtension}"; + + $result = $this->googleCloudService->uploadToStorage($compressedPath, $objectName); + + if (! $result) { + $errors[] = "파일 '{$file->getClientOriginalName()}' 업로드 실패"; + Log::error('EquipmentPhoto: GCS 업로드 실패', [ + 'equipment_id' => $equipment->id, + 'file' => $file->getClientOriginalName(), + ]); + + continue; + } + + $mimeType = $finalExtension === 'png' ? 'image/png' : 'image/jpeg'; + + File::create([ + 'tenant_id' => $tenantId, + 'document_id' => $equipment->id, + 'document_type' => 'equipment', + 'file_path' => $objectName, + 'original_name' => $file->getClientOriginalName(), + 'display_name' => $file->getClientOriginalName(), + 'file_size' => $result['size'], + 'mime_type' => $mimeType, + 'file_type' => 'image', + 'gcs_object_name' => $objectName, + 'gcs_uri' => $result['uri'], + 'uploaded_by' => Auth::id(), + 'is_temp' => false, ]); - continue; + $uploaded++; + } catch (\Throwable $e) { + $errors[] = "파일 '{$file->getClientOriginalName()}' 압축 실패: {$e->getMessage()}"; + Log::error('EquipmentPhoto: 이미지 압축 실패', [ + 'equipment_id' => $equipment->id, + 'file' => $file->getClientOriginalName(), + 'error' => $e->getMessage(), + ]); + } finally { + if ($compressedPath && file_exists($compressedPath) && $compressedPath !== $file->getRealPath()) { + @unlink($compressedPath); + } } - - File::create([ - 'tenant_id' => $tenantId, - 'document_id' => $equipment->id, - 'document_type' => 'equipment', - 'file_path' => $objectName, - 'original_name' => $file->getClientOriginalName(), - 'display_name' => $file->getClientOriginalName(), - 'file_size' => $result['size'], - 'mime_type' => $file->getMimeType(), - 'file_type' => 'image', - 'gcs_object_name' => $objectName, - 'gcs_uri' => $result['uri'], - 'uploaded_by' => Auth::id(), - 'is_temp' => false, - ]); - - $uploaded++; } return ['uploaded' => $uploaded, 'errors' => $errors]; @@ -131,4 +159,103 @@ public function deleteAllPhotos(Equipment $equipment): void $photo->forceDelete(); } } + + /** + * 이미지 압축 (1MB 이하로) + * + * 1단계: 장축 2048px 초과 시 리사이즈 + * 2단계: JPEG 품질 85→40 점진적 감소 + * + * @return array{0: string, 1: string} [압축 파일 경로, 최종 확장자] + */ + private function compressImage(string $sourcePath, string $extension): array + { + $image = $this->createImageFromFile($sourcePath, $extension); + if (! $image) { + return [$sourcePath, $extension]; + } + + // 1단계: 리사이즈 (장축 2048px 초과 시) + $width = imagesx($image); + $height = imagesy($image); + + if ($width > self::MAX_DIMENSION || $height > self::MAX_DIMENSION) { + $ratio = min(self::MAX_DIMENSION / $width, self::MAX_DIMENSION / $height); + $newWidth = (int) round($width * $ratio); + $newHeight = (int) round($height * $ratio); + + $resized = imagecreatetruecolor($newWidth, $newHeight); + imagecopyresampled($resized, $image, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height); + imagedestroy($image); + $image = $resized; + } + + // PNG 투명도 확인 → 투명 있으면 PNG 유지, 없으면 JPEG 변환 + $outputAsPng = ($extension === 'png') && $this->hasTransparency($image); + $finalExtension = $outputAsPng ? 'png' : 'jpg'; + + $tempPath = tempnam(sys_get_temp_dir(), 'eq_compress_'); + + if ($outputAsPng) { + // PNG: 최대 압축 레벨(9)로 저장 + imagepng($image, $tempPath, 9); + imagedestroy($image); + + return [$tempPath, $finalExtension]; + } + + // 2단계: JPEG 품질 점진적 감소 + $quality = self::INITIAL_QUALITY; + while ($quality >= self::MIN_QUALITY) { + imagejpeg($image, $tempPath, $quality); + + if (filesize($tempPath) <= self::MAX_FILE_SIZE) { + break; + } + + $quality -= self::QUALITY_STEP; + } + + imagedestroy($image); + + return [$tempPath, $finalExtension]; + } + + /** + * 확장자에 따라 GD 이미지 리소스 생성 + */ + private function createImageFromFile(string $path, string $extension): ?\GdImage + { + return match ($extension) { + 'jpg', 'jpeg' => @imagecreatefromjpeg($path), + 'png' => @imagecreatefrompng($path), + 'gif' => @imagecreatefromgif($path), + 'bmp' => @imagecreatefrombmp($path), + default => null, + }; + } + + /** + * 이미지에 투명 픽셀이 있는지 확인 + */ + private function hasTransparency(\GdImage $image): bool + { + $width = imagesx($image); + $height = imagesy($image); + + // 전체 픽셀 검사는 비효율적이므로 샘플링 (최대 1000개) + $step = max(1, (int) sqrt($width * $height / 1000)); + + for ($x = 0; $x < $width; $x += $step) { + for ($y = 0; $y < $height; $y += $step) { + $rgba = imagecolorat($image, $x, $y); + $alpha = ($rgba >> 24) & 0x7F; + if ($alpha > 0) { + return true; + } + } + } + + return false; + } }