feat: [equipment] 설비 사진 업로드 시 이미지 압축 (1MB 이하)

- GD 라이브러리로 업로드 전 이미지 압축 처리
- 장축 2048px 초과 시 리사이즈 (비율 유지)
- JPEG 품질 85→40 점진적 감소로 1MB 이하 보장
- PNG(투명 없음)/GIF/BMP → JPEG 자동 변환
- PNG(투명 있음) → PNG 유지 (압축 레벨 9)
- 임시파일 자동 정리 (finally 블록)
This commit is contained in:
김보곤
2026-02-25 20:38:37 +09:00
parent 7f1327bfea
commit e291b29bd7

View File

@@ -12,6 +12,16 @@ class EquipmentPhotoService
{ {
private const MAX_PHOTOS = 10; 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( public function __construct(
private readonly GoogleCloudService $googleCloudService private readonly GoogleCloudService $googleCloudService
) {} ) {}
@@ -31,44 +41,62 @@ public function uploadPhotos(Equipment $equipment, array $files): array
foreach ($files as $index => $file) { foreach ($files as $index => $file) {
if ($currentCount + $uploaded >= self::MAX_PHOTOS) { if ($currentCount + $uploaded >= self::MAX_PHOTOS) {
$errors[] = "최대 ".self::MAX_PHOTOS."장까지 업로드 가능합니다."; $errors[] = '최대 '.self::MAX_PHOTOS.'장까지 업로드 가능합니다.';
break; break;
} }
$extension = $file->getClientOriginalExtension(); $extension = strtolower($file->getClientOriginalExtension());
$timestamp = now()->format('Ymd_His'); $compressedPath = null;
$objectName = "equipment-photos/{$tenantId}/{$equipment->id}/{$timestamp}_{$index}.{$extension}";
$tempPath = $file->getRealPath(); try {
$result = $this->googleCloudService->uploadToStorage($tempPath, $objectName); [$compressedPath, $finalExtension] = $this->compressImage($file->getRealPath(), $extension);
if (! $result) { $timestamp = now()->format('Ymd_His');
$errors[] = "파일 '{$file->getClientOriginalName()}' 업로드 실패"; $objectName = "equipment-photos/{$tenantId}/{$equipment->id}/{$timestamp}_{$index}.{$finalExtension}";
Log::error('EquipmentPhoto: GCS 업로드 실패', [
'equipment_id' => $equipment->id, $result = $this->googleCloudService->uploadToStorage($compressedPath, $objectName);
'file' => $file->getClientOriginalName(),
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]; return ['uploaded' => $uploaded, 'errors' => $errors];
@@ -131,4 +159,103 @@ public function deleteAllPhotos(Equipment $equipment): void
$photo->forceDelete(); $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;
}
} }