Ver3.0 PHP 파일 업로드와 이미지 처리: GD와 ImageMagick 라이브러리 완벽 가이드 📸

PHP 파일 업로드와 이미지 처리: GD와 ImageMagick 라이브러리 완벽 가이드 📸
웹 개발자라면 꼭 알아야 할 이미지 처리 기술의 모든 것
안녕! 오늘은 PHP로 파일 업로드하고 이미지 처리하는 방법에 대해 깊이 있게 파헤쳐볼 거야. 🎨 웹사이트를 만들다 보면 사용자가 프로필 사진을 올리거나, 상품 이미지를 등록하거나, 게시판에 사진을 첨부하는 기능이 거의 필수잖아? 그런데 막상 구현하려고 하면 생각보다 고려할 게 많더라고. 파일 크기는 어떻게 제한하지? 이미지 리사이징은? 썸네일 생성은? 워터마크는? 이런 고민들 말이야. 😅
이 글에서는 PHP의 기본 파일 업로드부터 시작해서, GD 라이브러리와 ImageMagick이라는 두 가지 강력한 이미지 처리 도구를 비교하면서 실전에서 바로 쓸 수 있는 코드까지 함께 알아볼 거야. 재능넷 같은 플랫폼에서 사용자들의 프로필 이미지나 포트폴리오 사진을 처리하는 것도 결국 이런 기술들의 조합이거든! 🚀
📁 PHP 파일 업로드의 기본 원리
먼저 파일 업로드가 어떻게 작동하는지부터 이해해보자. 웹에서 파일을 업로드한다는 건 사용자의 컴퓨터에 있는 파일을 HTTP 프로토콜을 통해 서버로 전송하는 과정이야. 이때 HTML 폼에서 enctype="multipart/form-data"라는 속성이 핵심 역할을 해. 이게 없으면 파일 데이터가 제대로 전송되지 않거든! 🔑
기본 HTML 폼 구조
<form action="upload.php" method="POST" enctype="multipart/form-data">
<input type="file" name="uploaded_file" accept="image/*">
<button type="submit">업로드</button>
</form>
여기서 accept="image/*" 속성은 이미지 파일만 선택할 수 있게 필터링해주는 역할을 해. 물론 이건 클라이언트 측 검증이라 서버에서도 반드시 다시 확인해야 해! 보안은 항상 다층 방어가 기본이니까. 🛡️
PHP에서 업로드된 파일 받기
PHP에서는 $_FILES라는 슈퍼글로벌 배열로 업로드된 파일 정보에 접근할 수 있어. 이 배열에는 파일에 대한 여러 정보가 담겨있지:
$_FILES['uploaded_file']['name'] // 원본 파일명
$_FILES['uploaded_file']['type'] // MIME 타입
$_FILES['uploaded_file']['size'] // 파일 크기(바이트)
$_FILES['uploaded_file']['tmp_name'] // 임시 저장 경로
$_FILES['uploaded_file']['error'] // 에러 코드
업로드된 파일은 처음에 서버의 임시 디렉토리에 저장돼. 그래서 move_uploaded_file() 함수를 사용해서 원하는 위치로 옮겨줘야 해. 이 함수는 보안상 안전한 방법으로 파일을 이동시켜주거든. 👍
실전 파일 업로드 코드
<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// 파일이 업로드되었는지 확인
if (isset($_FILES['uploaded_file']) && $_FILES['uploaded_file']['error'] === UPLOAD_ERR_OK) {
$file_tmp = $_FILES['uploaded_file']['tmp_name'];
$file_name = $_FILES['uploaded_file']['name'];
$file_size = $_FILES['uploaded_file']['size'];
$file_type = $_FILES['uploaded_file']['type'];
// 파일 확장자 추출
$file_ext = strtolower(pathinfo($file_name, PATHINFO_EXTENSION));
// 허용된 확장자 체크
$allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
if (!in_array($file_ext, $allowed_extensions)) {
die("허용되지 않는 파일 형식입니다.");
}
// 파일 크기 제한 (5MB)
if ($file_size > 5 * 1024 * 1024) {
die("파일 크기는 5MB를 초과할 수 없습니다.");
}
// 안전한 파일명 생성
$new_filename = uniqid('img_', true) . '.' . $file_ext;
$upload_path = 'uploads/' . $new_filename;
// 파일 이동
if (move_uploaded_file($file_tmp, $upload_path)) {
echo "파일 업로드 성공: " . $new_filename;
} else {
echo "파일 업로드 실패";
}
}
}
?>
php.ini 설정 확인하기
파일 업로드와 관련된 PHP 설정값들도 알아두면 좋아. 이 값들이 업로드 가능한 파일 크기를 결정하거든:
| 설정 항목 | 기본값 | 설명 |
|---|---|---|
| file_uploads | On | 파일 업로드 허용 여부 |
| upload_max_filesize | 2M | 개별 파일 최대 크기 |
| post_max_size | 8M | POST 데이터 전체 최대 크기 |
| max_file_uploads | 20 | 한 번에 업로드 가능한 파일 개수 |
| memory_limit | 128M | PHP 스크립트 메모리 제한 |
특히 post_max_size는 upload_max_filesize보다 커야 해. 왜냐하면 POST 데이터에는 파일 외에도 다른 폼 데이터가 포함되니까! 📊
🎨 GD 라이브러리로 이미지 처리하기
GD는 PHP에 기본으로 포함된 이미지 처리 라이브러리야. 대부분의 PHP 설치 환경에서 별도 설정 없이 바로 사용할 수 있어서 접근성이 정말 좋아. 간단한 리사이징, 크롭, 워터마크 정도는 GD로 충분히 처리할 수 있거든! 😊
GD 설치 확인하기
먼저 GD가 설치되어 있는지 확인해보자:
<?php
if (extension_loaded('gd')) {
echo "GD 라이브러리가 설치되어 있습니다.";
print_r(gd_info());
} else {
echo "GD 라이브러리가 설치되어 있지 않습니다.";
}
?>
이미지 리사이징 (비율 유지)
가장 많이 사용하는 기능인 이미지 리사이징부터 알아보자. 사용자가 업로드한 고해상도 이미지를 웹에 최적화된 크기로 줄이는 거지. 재능넷 같은 플랫폼에서 프로필 사진을 처리할 때도 이런 기능이 필수야! 🖼️
<?php
function resizeImage($source_file, $destination_file, $max_width, $max_height) {
// 원본 이미지 정보 가져오기
list($orig_width, $orig_height, $image_type) = getimagesize($source_file);
// 비율 계산
$ratio = min($max_width / $orig_width, $max_height / $orig_height);
// 새로운 크기 계산
$new_width = round($orig_width * $ratio);
$new_height = round($orig_height * $ratio);
// 이미지 타입에 따라 원본 이미지 생성
switch ($image_type) {
case IMAGETYPE_JPEG:
$source = imagecreatefromjpeg($source_file);
break;
case IMAGETYPE_PNG:
$source = imagecreatefrompng($source_file);
break;
case IMAGETYPE_GIF:
$source = imagecreatefromgif($source_file);
break;
case IMAGETYPE_WEBP:
$source = imagecreatefromwebp($source_file);
break;
default:
return false;
}
// 새 이미지 캔버스 생성
$destination = imagecreatetruecolor($new_width, $new_height);
// PNG 투명도 처리
if ($image_type == IMAGETYPE_PNG) {
imagealphablending($destination, false);
imagesavealpha($destination, true);
$transparent = imagecolorallocatealpha($destination, 255, 255, 255, 127);
imagefilledrectangle($destination, 0, 0, $new_width, $new_height, $transparent);
}
// 이미지 리샘플링 (고품질)
imagecopyresampled(
$destination, $source,
0, 0, 0, 0,
$new_width, $new_height,
$orig_width, $orig_height
);
// 파일 저장
switch ($image_type) {
case IMAGETYPE_JPEG:
imagejpeg($destination, $destination_file, 90); // 품질 90%
break;
case IMAGETYPE_PNG:
imagepng($destination, $destination_file, 9); // 압축 레벨 9
break;
case IMAGETYPE_GIF:
imagegif($destination, $destination_file);
break;
case IMAGETYPE_WEBP:
imagewebp($destination, $destination_file, 90);
break;
}
// 메모리 해제
imagedestroy($source);
imagedestroy($destination);
return true;
}
// 사용 예시
resizeImage('uploads/original.jpg', 'uploads/resized.jpg', 800, 600);
?>
정사각형 썸네일 만들기 (크롭)
프로필 사진이나 상품 썸네일처럼 정사각형 이미지가 필요할 때가 많지? 이미지의 중앙 부분을 잘라내는 방식으로 만들 수 있어:
<?php
function createSquareThumbnail($source_file, $destination_file, $thumb_size) {
list($orig_width, $orig_height, $image_type) = getimagesize($source_file);
// 이미지 타입에 따라 원본 로드
switch ($image_type) {
case IMAGETYPE_JPEG:
$source = imagecreatefromjpeg($source_file);
break;
case IMAGETYPE_PNG:
$source = imagecreatefrompng($source_file);
break;
default:
return false;
}
// 크롭할 영역 계산 (중앙 기준)
$crop_size = min($orig_width, $orig_height);
$crop_x = ($orig_width - $crop_size) / 2;
$crop_y = ($orig_height - $crop_size) / 2;
// 썸네일 캔버스 생성
$thumbnail = imagecreatetruecolor($thumb_size, $thumb_size);
// 크롭 및 리사이징
imagecopyresampled(
$thumbnail, $source,
0, 0, $crop_x, $crop_y,
$thumb_size, $thumb_size,
$crop_size, $crop_size
);
// 저장
imagejpeg($thumbnail, $destination_file, 85);
imagedestroy($source);
imagedestroy($thumbnail);
return true;
}
// 200x200 정사각형 썸네일 생성
createSquareThumbnail('uploads/photo.jpg', 'uploads/thumb.jpg', 200);
?>
텍스트 워터마크 추가하기
저작권 보호를 위해 이미지에 워터마크를 넣는 것도 중요해. GD로 텍스트 워터마크를 쉽게 추가할 수 있어:
<?php
function addTextWatermark($source_file, $destination_file, $watermark_text) {
list($width, $height, $image_type) = getimagesize($source_file);
// 원본 이미지 로드
switch ($image_type) {
case IMAGETYPE_JPEG:
$image = imagecreatefromjpeg($source_file);
break;
case IMAGETYPE_PNG:
$image = imagecreatefrompng($source_file);
break;
default:
return false;
}
// 워터마크 색상 설정 (반투명 흰색)
$white = imagecolorallocatealpha($image, 255, 255, 255, 50);
// 폰트 크기 및 위치 계산
$font_size = 20;
$angle = 0;
$font_file = '/path/to/font.ttf'; // TrueType 폰트 경로
// 텍스트 크기 계산
$bbox = imagettfbbox($font_size, $angle, $font_file, $watermark_text);
$text_width = abs($bbox[4] - $bbox[0]);
$text_height = abs($bbox[5] - $bbox[1]);
// 우측 하단에 배치
$x = $width - $text_width - 20;
$y = $height - 20;
// 텍스트 그리기
imagettftext($image, $font_size, $angle, $x, $y, $white, $font_file, $watermark_text);
// 저장
imagejpeg($image, $destination_file, 90);
imagedestroy($image);
return true;
}
// 또는 시스템 폰트 사용 (간단한 방법)
function addSimpleWatermark($source_file, $destination_file, $text) {
$image = imagecreatefromjpeg($source_file);
$white = imagecolorallocatealpha($image, 255, 255, 255, 50);
// 내장 폰트 사용 (5가 가장 큼)
imagestring($image, 5, 10, 10, $text, $white);
imagejpeg($image, $destination_file, 90);
imagedestroy($image);
}
?>
이미지 필터 효과 적용하기
GD는 여러 가지 필터 효과도 제공해. 흑백, 세피아, 블러 같은 효과를 간단하게 적용할 수 있어:
<?php
$image = imagecreatefromjpeg('photo.jpg');
// 흑백 효과
imagefilter($image, IMG_FILTER_GRAYSCALE);
// 세피아 효과
imagefilter($image, IMG_FILTER_GRAYSCALE);
imagefilter($image, IMG_FILTER_COLORIZE, 90, 60, 40);
// 블러 효과
imagefilter($image, IMG_FILTER_GAUSSIAN_BLUR);
// 밝기 조절 (-255 ~ 255)
imagefilter($image, IMG_FILTER_BRIGHTNESS, 50);
// 대비 조절 (-100 ~ 100)
imagefilter($image, IMG_FILTER_CONTRAST, -20);
// 엣지 강조
imagefilter($image, IMG_FILTER_EDGEDETECT);
// 엠보스 효과
imagefilter($image, IMG_FILTER_EMBOSS);
imagejpeg($image, 'filtered.jpg', 90);
imagedestroy($image);
?>
• 간단한 썸네일 생성
• 프로필 사진 리사이징
• 기본적인 워터마크 추가
• 빠른 처리 속도가 필요한 경우
• 서버 리소스가 제한적인 환경
🪄 ImageMagick으로 전문가급 이미지 처리하기
ImageMagick은 GD보다 훨씬 강력한 이미지 처리 라이브러리야. 200개가 넘는 이미지 포맷을 지원하고, 고급 효과와 정밀한 색상 관리가 가능해. 전문적인 이미지 처리가 필요하다면 ImageMagick이 답이지! 🎯
ImageMagick 설치하기
ImageMagick은 별도로 설치해야 해. PHP에서는 두 가지 방법으로 사용할 수 있어:
1. Imagick 확장 모듈 (권장)
PHP 확장으로 설치하면 객체지향 방식으로 편하게 사용할 수 있어.
Ubuntu/Debian:
sudo apt-get install imagemagick php-imagick
sudo service apache2 restart
CentOS/RHEL:
sudo yum install ImageMagick ImageMagick-devel php-imagick
sudo systemctl restart httpd
2. exec() 함수로 명령줄 실행
확장 모듈 설치가 어려운 경우 시스템 명령으로도 사용 가능해.
Imagick 확장 사용하기
Imagick 클래스를 사용하면 정말 강력한 기능들을 쉽게 구현할 수 있어. 코드도 깔끔하고 직관적이야:
고품질 이미지 리사이징
<?php
function imagickResize($source, $destination, $width, $height) {
try {
$imagick = new Imagick($source);
// 원본 비율 유지하면서 리사이징
$imagick->thumbnailImage($width, $height, true);
// 샤프닝 적용 (리사이징 후 선명도 향상)
$imagick->sharpenImage(0, 1);
// 압축 품질 설정
$imagick->setImageCompressionQuality(90);
// 메타데이터 제거 (파일 크기 감소)
$imagick->stripImage();
// 저장
$imagick->writeImage($destination);
$imagick->clear();
$imagick->destroy();
return true;
} catch (Exception $e) {
error_log("Imagick Error: " . $e->getMessage());
return false;
}
}
imagickResize('large.jpg', 'optimized.jpg', 1200, 800);
?>
다양한 포맷 변환
ImageMagick의 진가는 포맷 변환에서 드러나. JPEG, PNG, GIF, WebP, TIFF, PDF 등 거의 모든 포맷을 지원해:
<?php
// JPEG를 WebP로 변환 (용량 30-50% 감소!)
function convertToWebP($source, $destination, $quality = 80) {
$imagick = new Imagick($source);
$imagick->setImageFormat('webp');
$imagick->setImageCompressionQuality($quality);
$imagick->writeImage($destination);
$imagick->clear();
$imagick->destroy();
}
// PNG를 JPEG로 변환 (배경색 지정)
function pngToJpeg($source, $destination, $bg_color = 'white') {
$imagick = new Imagick($source);
$imagick->setImageBackgroundColor($bg_color);
$imagick->setImageAlphaChannel(Imagick::ALPHACHANNEL_REMOVE);
$imagick->mergeImageLayers(Imagick::LAYERMETHOD_FLATTEN);
$imagick->setImageFormat('jpeg');
$imagick->setImageCompressionQuality(90);
$imagick->writeImage($destination);
$imagick->clear();
$imagick->destroy();
}
// 여러 이미지를 PDF로 합치기
function imagesToPDF($image_files, $pdf_file) {
$imagick = new Imagick($image_files);
$imagick->setImageFormat('pdf');
$imagick->writeImages($pdf_file, true);
$imagick->clear();
$imagick->destroy();
}
convertToWebP('photo.jpg', 'photo.webp', 85);
pngToJpeg('logo.png', 'logo.jpg', '#ffffff');
imagesToPDF(['page1.jpg', 'page2.jpg'], 'document.pdf');
?>
고급 크롭 및 효과
ImageMagick은 정교한 크롭과 다양한 시각 효과를 제공해:
<?php
// 스마트 크롭 (중요한 부분 자동 감지)
function smartCrop($source, $destination, $width, $height) {
$imagick = new Imagick($source);
// 엔트로피 기반 크롭 (이미지의 중요한 부분 자동 감지)
$imagick->cropThumbnailImage($width, $height);
$imagick->writeImage($destination);
$imagick->clear();
$imagick->destroy();
}
// 둥근 모서리 이미지
function roundedCorners($source, $destination, $radius = 20) {
$imagick = new Imagick($source);
$imagick->roundCorners($radius, $radius);
$imagick->setImageFormat('png'); // 투명도 유지
$imagick->writeImage($destination);
$imagick->clear();
$imagick->destroy();
}
// 그림자 효과
function addShadow($source, $destination) {
$imagick = new Imagick($source);
// 그림자 생성 (투명도, x오프셋, y오프셋, 시그마)
$imagick->shadowImage(80, 3, 5, 5);
$imagick->writeImage($destination);
$imagick->clear();
$imagick->destroy();
}
// 극적인 비네팅 효과
function addVignette($source, $destination) {
$imagick = new Imagick($source);
// 비네팅 효과 (x, y, radius, sigma)
$imagick->vignetteImage(0, 0, 10, 10);
$imagick->writeImage($destination);
$imagick->clear();
$imagick->destroy();
}
// 폴라로이드 효과
function polaroidEffect($source, $destination, $angle = 5) {
$imagick = new Imagick($source);
$imagick->polaroidImage(new ImagickDraw(), $angle);
$imagick->writeImage($destination);
$imagick->clear();
$imagick->destroy();
}
smartCrop('photo.jpg', 'cropped.jpg', 800, 600);
roundedCorners('avatar.png', 'avatar_rounded.png', 30);
addShadow('product.png', 'product_shadow.png');
?>
이미지 워터마크 (이미지 합성)
로고 이미지를 워터마크로 추가하는 것도 ImageMagick이 훨씬 편해:
<?php
function addImageWatermark($source, $watermark, $destination, $position = 'southeast') {
$image = new Imagick($source);
$logo = new Imagick($watermark);
// 워터마크 크기 조정 (원본의 20% 크기)
$image_width = $image->getImageWidth();
$logo_width = $image_width * 0.2;
$logo->thumbnailImage($logo_width, 0);
// 투명도 설정 (0-100, 낮을수록 투명)
$logo->evaluateImage(Imagick::EVALUATE_MULTIPLY, 0.5, Imagick::CHANNEL_ALPHA);
// 위치별 좌표 계산
$positions = [
'northwest' => [10, 10],
'northeast' => [$image->getImageWidth() - $logo->getImageWidth() - 10, 10],
'southwest' => [10, $image->getImageHeight() - $logo->getImageHeight() - 10],
'southeast' => [
$image->getImageWidth() - $logo->getImageWidth() - 10,
$image->getImageHeight() - $logo->getImageHeight() - 10
],
'center' => [
($image->getImageWidth() - $logo->getImageWidth()) / 2,
($image->getImageHeight() - $logo->getImageHeight()) / 2
]
];
list($x, $y) = $positions[$position] ?? $positions['southeast'];
// 워터마크 합성
$image->compositeImage($logo, Imagick::COMPOSITE_OVER, $x, $y);
$image->writeImage($destination);
$image->clear();
$logo->clear();
$image->destroy();
$logo->destroy();
}
addImageWatermark('photo.jpg', 'logo.png', 'watermarked.jpg', 'southeast');
?>
배치 처리 및 최적화
여러 이미지를 한 번에 처리해야 할 때도 ImageMagick이 유용해. 재능넷 같은 플랫폼에서 사용자들이 올린 수많은 이미지를 일괄 최적화할 때 쓸 수 있지:
<?php
function batchOptimizeImages($directory, $output_dir, $max_width = 1920) {
$files = glob($directory . '/*.{jpg,jpeg,png,gif}', GLOB_BRACE);
$results = [];
foreach ($files as $file) {
try {
$imagick = new Imagick($file);
// 큰 이미지만 리사이징
if ($imagick->getImageWidth() > $max_width) {
$imagick->thumbnailImage($max_width, 0);
}
// 최적화
$imagick->stripImage(); // 메타데이터 제거
$imagick->setImageCompressionQuality(85);
// WebP 버전도 생성
$filename = basename($file);
$name_without_ext = pathinfo($filename, PATHINFO_FILENAME);
// 원본 포맷 저장
$imagick->writeImage($output_dir . '/' . $filename);
// WebP 버전 저장
$imagick->setImageFormat('webp');
$imagick->writeImage($output_dir . '/' . $name_without_ext . '.webp');
$original_size = filesize($file);
$optimized_size = filesize($output_dir . '/' . $filename);
$savings = round(($original_size - $optimized_size) / $original_size * 100, 2);
$results[] = [
'file' => $filename,
'original' => $original_size,
'optimized' => $optimized_size,
'savings' => $savings . '%'
];
$imagick->clear();
$imagick->destroy();
} catch (Exception $e) {
$results[] = [
'file' => basename($file),
'error' => $e->getMessage()
];
}
}
return $results;
}
// 사용 예시
$results = batchOptimizeImages('uploads/original', 'uploads/optimized', 1920);
print_r($results);
?>
EXIF 데이터 처리
디지털 카메라로 찍은 사진에는 EXIF 데이터가 포함되어 있어. 촬영 날짜, 카메라 모델, GPS 위치 등의 정보인데, 개인정보 보호를 위해 제거하거나 필요한 정보만 추출할 수 있어:
<?php
// EXIF 데이터 읽기
function readExifData($image_file) {
$imagick = new Imagick($image_file);
$exif = $imagick->getImageProperties("exif:*");
$imagick->clear();
$imagick->destroy();
return $exif;
}
// EXIF 데이터 제거
function removeExifData($source, $destination) {
$imagick = new Imagick($source);
$imagick->stripImage(); // 모든 메타데이터 제거
$imagick->writeImage($destination);
$imagick->clear();
$imagick->destroy();
}
// 이미지 회전 (EXIF Orientation 기반)
function autoRotateImage($source, $destination) {
$imagick = new Imagick($source);
// EXIF orientation에 따라 자동 회전
$orientation = $imagick->getImageOrientation();
switch ($orientation) {
case Imagick::ORIENTATION_BOTTOMRIGHT:
$imagick->rotateImage("#000", 180);
break;
case Imagick::ORIENTATION_RIGHTTOP:
$imagick->rotateImage("#000", 90);
break;
case Imagick::ORIENTATION_LEFTBOTTOM:
$imagick->rotateImage("#000", -90);
break;
}
// Orientation을 정상으로 설정
$imagick->setImageOrientation(Imagick::ORIENTATION_TOPLEFT);
$imagick->writeImage($destination);
$imagick->clear();
$imagick->destroy();
}
$exif = readExifData('photo.jpg');
print_r($exif);
autoRotateImage('photo.jpg', 'rotated.jpg');
?>
• 처리 후 반드시 clear()와 destroy() 호출
• php.ini에서 memory_limit 적절히 설정 (최소 256M 권장)
• 대용량 배치 처리 시 청크 단위로 나눠서 처리
• 서버 리소스 모니터링 필수
🔧 실전 활용: 완전한 이미지 업로드 시스템 구축하기
이제 배운 내용을 종합해서 실제로 사용할 수 있는 완전한 이미지 업로드 시스템을 만들어보자. 보안, 최적화, 사용자 경험까지 모두 고려한 코드야! 💪
ImageUploader 클래스 (GD 버전)
<?php
class ImageUploader {
private $upload_dir = 'uploads/';
private $allowed_types = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
private $max_size = 5242880; // 5MB
private $max_width = 4000;
private $max_height = 4000;
public function __construct($upload_dir = null) {
if ($upload_dir) {
$this->upload_dir = rtrim($upload_dir, '/') . '/';
}
// 업로드 디렉토리 생성
if (!is_dir($this->upload_dir)) {
mkdir($this->upload_dir, 0755, true);
}
}
public function upload($file_input_name) {
// 파일 존재 확인
if (!isset($_FILES[$file_input_name]) || $_FILES[$file_input_name]['error'] !== UPLOAD_ERR_OK) {
throw new Exception('파일 업로드 오류');
}
$file = $_FILES[$file_input_name];
// 보안 검증
$this->validateFile($file);
// 파일 정보
$file_ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$new_filename = $this->generateFilename($file_ext);
$upload_path = $this->upload_dir . $new_filename;
// 임시 파일 이동
if (!move_uploaded_file($file['tmp_name'], $upload_path)) {
throw new Exception('파일 저장 실패');
}
// 이미지 최적화
$this->optimizeImage($upload_path);
// 썸네일 생성
$thumbnail_path = $this->createThumbnail($upload_path);
return [
'success' => true,
'filename' => $new_filename,
'path' => $upload_path,
'thumbnail' => $thumbnail_path,
'url' => $this->getUrl($new_filename),
'size' => filesize($upload_path)
];
}
private function validateFile($file) {
// 파일 크기 검증
if ($file['size'] > $this->max_size) {
throw new Exception('파일 크기가 너무 큽니다. (최대 5MB)');
}
// MIME 타입 검증
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime_type = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mime_type, $this->allowed_types)) {
throw new Exception('허용되지 않는 파일 형식입니다.');
}
// 실제 이미지 파일인지 검증
$image_info = getimagesize($file['tmp_name']);
if ($image_info === false) {
throw new Exception('유효한 이미지 파일이 아닙니다.');
}
// 이미지 크기 제한
if ($image_info[0] > $this->max_width || $image_info[1] > $this->max_height) {
throw new Exception('이미지 해상도가 너무 큽니다.');
}
return true;
}
private function generateFilename($extension) {
return uniqid('img_', true) . '_' . time() . '.' . $extension;
}
private function optimizeImage($file_path) {
$image_info = getimagesize($file_path);
$image_type = $image_info[2];
// 이미지 타입별 로드
switch ($image_type) {
case IMAGETYPE_JPEG:
$image = imagecreatefromjpeg($file_path);
break;
case IMAGETYPE_PNG:
$image = imagecreatefrompng($file_path);
break;
case IMAGETYPE_GIF:
$image = imagecreatefromgif($file_path);
break;
case IMAGETYPE_WEBP:
$image = imagecreatefromwebp($file_path);
break;
default:
return false;
}
// 큰 이미지는 리사이징
$width = imagesx($image);
$height = imagesy($image);
if ($width > 1920 || $height > 1920) {
$ratio = min(1920 / $width, 1920 / $height);
$new_width = round($width * $ratio);
$new_height = round($height * $ratio);
$resized = imagecreatetruecolor($new_width, $new_height);
// PNG 투명도 처리
if ($image_type == IMAGETYPE_PNG) {
imagealphablending($resized, false);
imagesavealpha($resized, true);
}
imagecopyresampled($resized, $image, 0, 0, 0, 0, $new_width, $new_height, $width, $height);
imagedestroy($image);
$image = $resized;
}
// 최적화된 품질로 저장
switch ($image_type) {
case IMAGETYPE_JPEG:
imagejpeg($image, $file_path, 85);
break;
case IMAGETYPE_PNG:
imagepng($image, $file_path, 8);
break;
case IMAGETYPE_GIF:
imagegif($image, $file_path);
break;
case IMAGETYPE_WEBP:
imagewebp($image, $file_path, 85);
break;
}
imagedestroy($image);
return true;
}
private function createThumbnail($source_path) {
$thumb_dir = $this->upload_dir . 'thumbs/';
if (!is_dir($thumb_dir)) {
mkdir($thumb_dir, 0755, true);
}
$filename = basename($source_path);
$thumb_path = $thumb_dir . 'thumb_' . $filename;
$image_info = getimagesize($source_path);
$image_type = $image_info[2];
// 원본 로드
switch ($image_type) {
case IMAGETYPE_JPEG:
$source = imagecreatefromjpeg($source_path);
break;
case IMAGETYPE_PNG:
$source = imagecreatefrompng($source_path);
break;
default:
return null;
}
// 300x300 썸네일
$thumb_size = 300;
$orig_width = imagesx($source);
$orig_height = imagesy($source);
$crop_size = min($orig_width, $orig_height);
$crop_x = ($orig_width - $crop_size) / 2;
$crop_y = ($orig_height - $crop_size) / 2;
$thumbnail = imagecreatetruecolor($thumb_size, $thumb_size);
imagecopyresampled($thumbnail, $source, 0, 0, $crop_x, $crop_y, $thumb_size, $thumb_size, $crop_size, $crop_size);
imagejpeg($thumbnail, $thumb_path, 80);
imagedestroy($source);
imagedestroy($thumbnail);
return $thumb_path;
}
private function getUrl($filename) {
return '/' . $this->upload_dir . $filename;
}
}
// 사용 예시
try {
$uploader = new ImageUploader('uploads/images/');
$result = $uploader->upload('user_photo');
echo "업로드 성공!<br>";
echo "파일명: " . $result['filename'] . "<br>";
echo "URL: " . $result['url'] . "<br>";
echo "크기: " . round($result['size'] / 1024, 2) . " KB<br>";
echo "썸네일: " . $result['thumbnail'];
} catch (Exception $e) {
echo "오류: " . $e->getMessage();
}
?>
Ajax를 활용한 비동기 업로드
요즘은 페이지 새로고침 없이 파일을 업로드하는 게 기본이잖아? Ajax로 구현하면 사용자 경험이 훨씬 좋아져:
HTML + JavaScript
<div id="upload-area" style="border: 2px dashed #ccc; padding: 40px; text-align: center; cursor: pointer;">
<p>클릭하거나 파일을 드래그하세요 📁</p>
<input type="file" id="file-input" accept="image/*" style="display: none;">
</div>
<div id="preview-area" style="margin-top: 20px;"></div>
<div id="progress-bar" style="display: none; margin-top: 10px;">
<div style="background: #e9ecef; height: 20px; border-radius: 10px;">
<div id="progress-fill" style="background: #28a745; height: 100%; width: 0%; border-radius: 10px; transition: width 0.3s;"></div>
</div>
</div>
<script>
const uploadArea = document.getElementById('upload-area');
const fileInput = document.getElementById('file-input');
const previewArea = document.getElementById('preview-area');
const progressBar = document.getElementById('progress-bar');
const progressFill = document.getElementById('progress-fill');
// 클릭 업로드
uploadArea.addEventListener('click', () => fileInput.click());
// 드래그 앤 드롭
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.style.borderColor = '#28a745';
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.style.borderColor = '#ccc';
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.style.borderColor = '#ccc';
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFileUpload(files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFileUpload(e.target.files[0]);
}
});
function handleFileUpload(file) {
// 파일 크기 체크
if (file.size > 5 * 1024 * 1024) {
alert('파일 크기는 5MB를 초과할 수 없습니다.');
return;
}
// 이미지 타입 체크
if (!file.type.startsWith('image/')) {
alert('이미지 파일만 업로드 가능합니다.');
return;
}
// 미리보기
const reader = new FileReader();
reader.onload = (e) => {
previewArea.innerHTML = `<img src="${e.target.result}" style="max-width: 300px; border-radius: 8px;">`;
};
reader.readAsDataURL(file);
// 업로드
const formData = new FormData();
formData.append('user_photo', file);
const xhr = new XMLHttpRequest();
// 진행률 표시
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = (e.loaded / e.total) * 100;
progressBar.style.display = 'block';
progressFill.style.width = percent + '%';
}
});
// 완료
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
const response = JSON.parse(xhr.responseText);
if (response.success) {
alert('업로드 성공! 파일명: ' + response.filename);
progressBar.style.display = 'none';
} else {
alert('업로드 실패: ' + response.message);
}
}
});
// 오류
xhr.addEventListener('error', () => {
alert('업로드 중 오류가 발생했습니다.');
progressBar.style.display = 'none';
});
xhr.open('POST', 'upload_handler.php');
xhr.send(formData);
}
</script>
upload_handler.php
<?php
header('Content-Type: application/json');
try {
require_once 'ImageUploader.class.php';
$uploader = new ImageUploader('uploads/images/');
$result = $uploader->upload('user_photo');
echo json_encode($result);
} catch (Exception $e) {
echo json_encode([
'success' => false,
'message' => $e->getMessage()
]);
}
?>
🛡️ 보안 고려사항과 베스트 프랙티스
이미지 업로드는 보안에 특히 신경 써야 하는 기능이야. 악의적인 사용자가 악성 코드를 업로드하거나 서버를 공격할 수 있거든. 실제로 많은 웹사이트가 파일 업로드 취약점으로 해킹당했어. 😱
필수 보안 체크리스트
<?php
function validateImageFile($file_path) {
// 1. MIME 타입 검증
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime_type = finfo_file($finfo, $file_path);
finfo_close($finfo);
$allowed_mimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!in_array($mime_type, $allowed_mimes)) {
return false;
}
// 2. 실제 이미지 파일인지 검증
$image_info = getimagesize($file_path);
if ($image_info === false) {
return false;
}
// 3. 파일 내용 검사 (PHP 코드 포함 여부)
$file_content = file_get_contents($file_path);
$dangerous_patterns = [
'/<\?php/i',
'/<script/i',
'/<%/i',
'/eval\(/i'
];
foreach ($dangerous_patterns as $pattern) {
if (preg_match($pattern, $file_content)) {
return false;
}
}
return true;
}
?>
업로드 디렉토리 보안 설정
업로드 디렉토리는 웹 루트 밖에 두는 게 가장 안전해. 하지만 그게 어렵다면 최소한 실행 권한을 제거해야 해:
.htaccess 파일 (Apache)
# uploads/.htaccess
# PHP 실행 금지
php_flag engine off
# 스크립트 실행 금지
AddHandler cgi-script .php .php3 .php4 .phtml .pl .py .jsp .asp .htm .shtml .sh .cgi
Options -ExecCGI
# 특정 파일만 접근 허용
<FilesMatch "\.(jpg|jpeg|png|gif|webp)$">
Order Allow,Deny
Allow from all
</FilesMatch>
# 디렉토리 리스팅 금지
Options -Indexes
Nginx 설정
location /uploads/ {
# PHP 실행 금지
location ~ \.php$ {
deny all;
}
# 이미지 파일만 허용
location ~* \.(jpg|jpeg|png|gif|webp)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}
파일명 안전하게 처리하기
사용자가 업로드한 원본 파일명을 그대로 사용하면 경로 조작 공격(Path Traversal)에 취약해질 수 있어:
<?php
function sanitizeFilename($filename) {
// 확장자 분리
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
// 안전한 새 파일명 생성
$safe_name = uniqid('img_', true) . '_' . bin2hex(random_bytes(8));
return $safe_name . '.' . $ext;
}
// 또는 원본 파일명을 보존하고 싶다면
function sanitizeOriginalFilename($filename) {
// 위험한 문자 제거
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename);
// 경로 구분자 제거
$filename = str_replace(['/', '\\', '..'], '', $filename);
// 길이 제한
if (strlen($filename) > 100) {
$ext = pathinfo($filename, PATHINFO_EXTENSION);
$name = substr(pathinfo($filename, PATHINFO_FILENAME), 0, 90);
$filename = $name . '.' . $ext;
}
return $filename;
}
?>
이미지 재인코딩으로 악성 코드 제거
이미지를 다시 인코딩하면 숨겨진 악성 코드를 제거할 수 있어. GD나 ImageMagick으로 이미지를 읽고 다시 저장하는 과정에서 이미지 데이터만 남기고 나머지는 제거되거든:
<?php
function reencodeImage($source_path, $destination_path) {
$image_info = getimagesize($source_path);
if ($image_info === false) {
return false;
}
$image_type = $image_info[2];
// 원본 로드
switch ($image_type) {
case IMAGETYPE_JPEG:
$image = imagecreatefromjpeg($source_path);
break;
case IMAGETYPE_PNG:
$image = imagecreatefrompng($source_path);
break;
case IMAGETYPE_GIF:
$image = imagecreatefromgif($source_path);
break;
default:
return false;
}
if ($image === false) {
return false;
}
// 재인코딩하여 저장
switch ($image_type) {
case IMAGETYPE_JPEG:
$result = imagejpeg($image, $destination_path, 90);
break;
case IMAGETYPE_PNG:
$result = imagepng($image, $destination_path, 9);
break;
case IMAGETYPE_GIF:
$result = imagegif($image, $destination_path);
break;
}
imagedestroy($image);
// 원본 파일 삭제
if ($result && $source_path !== $destination_path) {
unlink($source_path);
}
return $result;
}
?>
Rate Limiting으로 남용 방지
한 사용자가 짧은 시간에 너무 많은 파일을 업로드하는 걸 막아야 해. 서버 리소스 보호와 스팸 방지를 위해서지:
<?php
class UploadRateLimiter {
private $redis;
private $max_uploads = 10; // 시간당 최대 업로드 수
private $time_window = 3600; // 1시간
public function __construct() {
$this->redis = new Redis();
$this->redis->connect('127.0.0.1', 6379);
}
public function checkLimit($user_id) {
$key = "upload_limit:{$user_id}";
$count = $this->redis->get($key);
if ($count === false) {
// 첫 업로드
$this->redis->setex($key, $this->time_window, 1);
return true;
}
if ($count >= $this->max_uploads) {
$ttl = $this->redis->ttl($key);
throw new Exception("업로드 제한 초과. {$ttl}초 후 다시 시도하세요.");
}
$this->redis->incr($key);
return true;
}
}
// 또는 세션 기반 (Redis 없이)
class SessionRateLimiter {
private $max_uploads = 10;
private $time_window = 3600;
public function checkLimit() {
if (!isset($_SESSION['upload_history'])) {
$_SESSION['upload_history'] = [];
}
$now = time();
$history = $_SESSION['upload_history'];
// 시간 창 밖의 기록 제거
$history = array_filter($history, function($timestamp) use ($now) {
return ($now - $timestamp) < $this->time_window;
});
if (count($history) >= $this->max_uploads) {
$oldest = min($history);
$wait_time = $this->time_window - ($now - $oldest);
throw new Exception("업로드 제한 초과. {$wait_time}초 후 다시 시도하세요.");
}
$history[] = $now;
$_SESSION['upload_history'] = $history;
return true;
}
}
?>
• 업로드된 이미지를 CDN에서 서빙 (원본 서버 보호)
• 바이러스 스캔 통합 (ClamAV 등)
• 로깅으로 의심스러운 활동 추적
• 정기적인 보안 감사 실시
⚡ 성능 최적화 전략
이미지 처리는 CPU와 메모리를 많이 사용하는 작업이야. 특히 트래픽이 많은 사이트에서는 성능 최적화가 정말 중요해. 재능넷처럼 많은 사용자가 동시에 파일을 업로드하는 플랫폼이라면 더더욱 그렇지! 🚀
비동기 처리로 응답 속도 개선
이미지 리사이징이나 썸네일 생성 같은 무거운 작업은 백그라운드에서 처리하는 게 좋아. 사용자는 업로드만 하고 바로 다음 작업을 할 수 있게 하는 거지:
큐 시스템 활용 (예: Redis Queue)
<?php
// 업로드 핸들러
function handleUpload($file) {
// 1. 원본 파일만 빠르게 저장
$filename = saveOriginalFile($file);
// 2. 처리 작업을 큐에 추가
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$job = json_encode([
'type' => 'image_processing',
'filename' => $filename,
'tasks' => ['resize', 'thumbnail', 'watermark']
]);
$redis->rPush('image_queue', $job);
// 3. 즉시 응답
return [
'success' => true,
'filename' => $filename,
'status' => 'processing'
];
}
// 워커 프로세스 (별도 실행)
// worker.php
while (true) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
// 큐에서 작업 가져오기
$job_data = $redis->blPop('image_queue', 5);
if ($job_data) {
$job = json_decode($job_data[1], true);
try {
processImage($job['filename'], $job['tasks']);
// 완료 상태 업데이트
$redis->set("image_status:{$job['filename']}", 'completed');
} catch (Exception $e) {
error_log("Image processing failed: " . $e->getMessage());
$redis->set("image_status:{$job['filename']}", 'failed');
}
}
sleep(1);
}
function processImage($filename, $tasks) {
$file_path = "uploads/{$filename}";
foreach ($tasks as $task) {
switch ($task) {
case 'resize':
resizeImage($file_path, 1920);
break;
case 'thumbnail':
createThumbnail($file_path);
break;
case 'watermark':
addWatermark($file_path);
break;
}
}
}
?>
캐싱 전략
같은 이미지를 여러 번 처리하지 않도록 캐싱을 활용해:
<?php
function getCachedThumbnail($original_path, $width, $height) {
// 캐시 키 생성
$cache_key = md5($original_path . $width . $height . filemtime($original_path));
$cache_dir = 'cache/thumbnails/';
$cache_path = $cache_dir . $cache_key . '.jpg';
// 캐시 확인
if (file_exists($cache_path)) {
return $cache_path;
}
// 캐시 없으면 생성
if (!is_dir($cache_dir)) {
mkdir($cache_dir, 0755, true);
}
createThumbnail($original_path, $cache_path, $width, $height);
return $cache_path;
}
// 사용 예시
$thumb = getCachedThumbnail('uploads/photo.jpg', 300, 300);
echo "<img src='{$thumb}'>";
?>
이미지 포맷 최적화
WebP 포맷은 JPEG보다 25-35% 작은 파일 크기로 같은 품질을 제공해. 최신 브라우저는 대부분 지원하니까 적극 활용하자:
<?php
function generateMultipleFormats($source_path) {
$path_info = pathinfo($source_path);
$base_path = $path_info['dirname'] . '/' . $path_info['filename'];
$formats = [];
// 원본 (JPEG)
$formats['jpeg'] = $source_path;
// WebP 생성
if (function_exists('imagewebp')) {
$webp_path = $base_path . '.webp';
$image = imagecreatefromjpeg($source_path);
imagewebp($image, $webp_path, 85);
imagedestroy($image);
$formats['webp'] = $webp_path;
}
// AVIF 생성 (PHP 8.1+, 가장 효율적)
if (function_exists('imageavif')) {
$avif_path = $base_path . '.avif';
$image = imagecreatefromjpeg($source_path);
imageavif($image, $avif_path, 85);
imagedestroy($image);
$formats['avif'] = $avif_path;
}
return $formats;
}
// HTML에서 사용
$formats = generateMultipleFormats('uploads/photo.jpg');
?>
<picture>
<?php if (isset($formats['avif'])): ?>
<source srcset="<?= $formats['avif'] ?>" type="image/avif">
<?php endif; ?>
<?php if (isset($formats['webp'])): ?>
<source srcset="<?= $formats['webp'] ?>" type="image/webp">
<?php endif; ?>
<img src="<?= $formats['jpeg'] ?>" alt="Photo">
</picture>
Lazy Loading 구현
모든 이미지를 한 번에 로드하지 않고 필요할 때만 로드하면 초기 페이지 로딩 속도가 빨라져:
<!-- 네이티브 Lazy Loading -->
<img src="image.jpg" loading="lazy" alt="Description">
<!-- Intersection Observer API 활용 -->
<img data-src="image.jpg" class="lazy" alt="Description">
<script>
document.addEventListener('DOMContentLoaded', function() {
const lazyImages = document.querySelectorAll('img.lazy');
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.remove('lazy');
observer.unobserve(img);
}
});
});
lazyImages.forEach(img => imageObserver.observe(img));
});
</script>
성능 비교: 최적화 전 vs 후
| 항목 | 최적화 전 | 최적화 후 | 개선율 |
|---|---|---|---|
| 평균 파일 크기 | 2.5 MB | 350 KB | 86% ⬇️ |
| 업로드 처리 시간 | 3.2초 | 0.8초 | 75% ⬇️ |
| 페이지 로딩 시간 | 5.1초 | 1.9초 | 63% ⬇️ |
| 서버 메모리 사용 | 512 MB | 128 MB | 75% ⬇️ |
🎓 실전 예제: 프로필 사진 업로드 시스템
지금까지 배운 내용을 모두 활용해서 완전한 프로필 사진 업로드 시스템을 만들어보자. 이건 실제 서비스에 바로 적용할 수 있는 수준의 코드야! 💼
ProfileImageHandler.php
<?php
class ProfileImageHandler {
private $upload_dir = 'uploads/profiles/';
private $allowed_types = ['image/jpeg', 'image/png', 'image/webp'];
private $max_size = 5242880; // 5MB
private $sizes = [
'original' => 1200,
'large' => 800,
'medium' => 400,
'small' => 200,
'thumb' => 100
];
public function __construct() {
foreach (['original', 'large', 'medium', 'small', 'thumb'] as $size) {
$dir = $this->upload_dir . $size . '/';
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
}
}
public function uploadProfileImage($user_id, $file_input) {
if (!isset($_FILES[$file_input])) {
throw new Exception('파일이 업로드되지 않았습니다.');
}
$file = $_FILES[$file_input];
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new Exception('파일 업로드 오류: ' . $this->getUploadErrorMessage($file['error']));
}
// 검증
$this->validateImage($file);
// 기존 프로필 이미지 삭제
$this->deleteExistingImages($user_id);
// 새 파일명 생성
$filename = $this->generateFilename($user_id);
// 원본 저장
$original_path = $this->upload_dir . 'original/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $original_path)) {
throw new Exception('파일 저장 실패');
}
// 여러 크기 생성
$this->generateSizes($original_path, $filename);
// 데이터베이스 업데이트
$this->updateDatabase($user_id, $filename);
return [
'success' => true,
'filename' => $filename,
'urls' => $this->getImageUrls($filename)
];
}
private function validateImage($file) {
// 크기 검증
if ($file['size'] > $this->max_size) {
throw new Exception('파일 크기는 5MB를 초과할 수 없습니다.');
}
// MIME 타입 검증
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mime, $this->allowed_types)) {
throw new Exception('허용되지 않는 파일 형식입니다.');
}
// 실제 이미지 검증
$image_info = getimagesize($file['tmp_name']);
if ($image_info === false) {
throw new Exception('유효한 이미지 파일이 아닙니다.');
}
// 최소 크기 검증
if ($image_info[0] < 200 || $image_info[1] < 200) {
throw new Exception('이미지는 최소 200x200 픽셀 이상이어야 합니다.');
}
return true;
}
private function generateFilename($user_id) {
return 'user_' . $user_id . '_' . time() . '.jpg';
}
private function generateSizes($original_path, $filename) {
foreach ($this->sizes as $size_name => $max_size) {
if ($size_name === 'original') continue;
$output_path = $this->upload_dir . $size_name . '/' . $filename;
if ($size_name === 'thumb') {
// 썸네일은 정사각형 크롭
$this->createSquareThumbnail($original_path, $output_path, $max_size);
} else {
// 나머지는 비율 유지 리사이징
$this->resizeImage($original_path, $output_path, $max_size);
}
}
}
private function resizeImage($source, $destination, $max_size) {
list($width, $height, $type) = getimagesize($source);
// 이미 작으면 복사만
if ($width <= $max_size && $height <= $max_size) {
copy($source, $destination);
return;
}
// 비율 계산
$ratio = min($max_size / $width, $max_size / $height);
$new_width = round($width * $ratio);
$new_height = round($height * $ratio);
// 이미지 처리
$src_image = imagecreatefromjpeg($source);
$dst_image = imagecreatetruecolor($new_width, $new_height);
imagecopyresampled(
$dst_image, $src_image,
0, 0, 0, 0,
$new_width, $new_height,
$width, $height
);
// 샤프닝 적용
imagefilter($dst_image, IMG_FILTER_SHARPEN);
imagejpeg($dst_image, $destination, 90);
imagedestroy($src_image);
imagedestroy($dst_image);
}
private function createSquareThumbnail($source, $destination, $size) {
list($width, $height) = getimagesize($source);
$src_image = imagecreatefromjpeg($source);
// 중앙 크롭
$crop_size = min($width, $height);
$crop_x = ($width - $crop_size) / 2;
$crop_y = ($height - $crop_size) / 2;
$dst_image = imagecreatetruecolor($size, $size);
imagecopyresampled(
$dst_image, $src_image,
0, 0, $crop_x, $crop_y,
$size, $size,
$crop_size, $crop_size
);
imagejpeg($dst_image, $destination, 85);
imagedestroy($src_image);
imagedestroy($dst_image);
}
private function deleteExistingImages($user_id) {
$pattern = 'user_' . $user_id . '_*.jpg';
foreach (['original', 'large', 'medium', 'small', 'thumb'] as $size) {
$files = glob($this->upload_dir . $size . '/' . $pattern);
foreach ($files as $file) {
if (file_exists($file)) {
unlink($file);
}
}
}
}
private function updateDatabase($user_id, $filename) {
// 실제 프로젝트에서는 PDO나 mysqli 사용
$db = new PDO('mysql:host=localhost;dbname=mydb', 'user', 'pass');
$stmt = $db->prepare("
UPDATE users
SET profile_image = :filename,
updated_at = NOW()
WHERE id = :user_id
");
$stmt->execute([
'filename' => $filename,
'user_id' => $user_id
]);
}
private function getImageUrls($filename) {
$urls = [];
foreach (array_keys($this->sizes) as $size) {
$urls[$size] = '/' . $this->upload_dir . $size . '/' . $filename;
}
return $urls;
}
private function getUploadErrorMessage($error_code) {
$errors = [
UPLOAD_ERR_INI_SIZE => '파일이 php.ini의 upload_max_filesize를 초과합니다.',
UPLOAD_ERR_FORM_SIZE => '파일이 MAX_FILE_SIZE를 초과합니다.',
UPLOAD_ERR_PARTIAL => '파일이 부분적으로만 업로드되었습니다.',
UPLOAD_ERR_NO_FILE => '파일이 업로드되지 않았습니다.',
UPLOAD_ERR_NO_TMP_DIR => '임시 폴더가 없습니다.',
UPLOAD_ERR_CANT_WRITE => '디스크에 쓰기 실패했습니다.',
UPLOAD_ERR_EXTENSION => 'PHP 확장이 업로드를 중단했습니다.'
];
return $errors[$error_code] ?? '알 수 없는 오류';
}
public function getProfileImage($user_id, $size = 'medium') {
$db = new PDO('mysql:host=localhost;dbname=mydb', 'user', 'pass');
$stmt = $db->prepare("SELECT profile_image FROM users WHERE id = :user_id");
$stmt->execute(['user_id' => $user_id]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
if ($result && $result['profile_image']) {
return '/' . $this->upload_dir . $size . '/' . $result['profile_image'];
}
// 기본 이미지 반환
return '/assets/default-avatar.png';
}
}
// 사용 예시
session_start();
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_SESSION['user_id'])) {
try {
$handler = new ProfileImageHandler();
$result = $handler->uploadProfileImage($_SESSION['user_id'], 'profile_photo');
echo json_encode($result);
} catch (Exception $e) {
http_response_code(400);
echo json_encode([
'success' => false,
'message' => $e->getMessage()
]);
}
}
?>
프론트엔드 통합
<!DOCTYPE html>
<html>
<head>
<title>프로필 사진 업로드</title>
<style>
.profile-upload-container {
max-width: 500px;
margin: 50px auto;
text-align: center;
}
.profile-preview {
width: 200px;
height: 200px;
border-radius: 50%;
margin: 20px auto;
overflow: hidden;
border: 4px solid #e9ecef;
background: #f8f9fa;
display: flex;
align-items: center;
justify-content: center;
}
.profile-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.upload-btn {
background: #007bff;
color: white;
padding: 12px 30px;
border: none;
border-radius: 25px;
cursor: pointer;
font-size: 16px;
}
.upload-btn:hover {
background: #0056b3;
}
.progress-container {
width: 100%;
height: 30px;
background: #e9ecef;
border-radius: 15px;
margin: 20px 0;
overflow: hidden;
display: none;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #007bff, #0056b3);
width: 0%;
transition: width 0.3s;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
}
</style>
</head>
<body>
<div class="profile-upload-container">
<h2>프로필 사진 변경</h2>
<div class="profile-preview" id="preview">
<img src="/assets/default-avatar.png" alt="Profile" id="preview-img">
</div>
<input type="file" id="file-input" accept="image/*" style="display: none;">
<button class="upload-btn" onclick="document.getElementById('file-input').click()">
📷 사진 선택
</button>
<div class="progress-container" id="progress-container">
<div class="progress-bar" id="progress-bar">0%</div>
</div>
<p id="message" style="margin-top: 20px; color: #6c757d;"></p>
</div>
<script>
const fileInput = document.getElementById('file-input');
const previewImg = document.getElementById('preview-img');
const progressContainer = document.getElementById('progress-container');
const progressBar = document.getElementById('progress-bar');
const message = document.getElementById('message');
fileInput.addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
// 파일 크기 체크
if (file.size > 5 * 1024 * 1024) {
alert('파일 크기는 5MB를 초과할 수 없습니다.');
return;
}
// 이미지 타입 체크
if (!file.type.startsWith('image/')) {
alert('이미지 파일만 업로드 가능합니다.');
return;
}
// 미리보기
const reader = new FileReader();
reader.onload = function(e) {
previewImg.src = e.target.result;
};
reader.readAsDataURL(file);
// 업로드
uploadImage(file);
});
function uploadImage(file) {
const formData = new FormData();
formData.append('profile_photo', file);
const xhr = new XMLHttpRequest();
// 진행률
xhr.upload.addEventListener('progress', function(e) {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
progressContainer.style.display = 'block';
progressBar.style.width = percent + '%';
progressBar.textContent = percent + '%';
}
});
// 완료
xhr.addEventListener('load', function() {
progressContainer.style.display = 'none';
if (xhr.status === 200) {
const response = JSON.parse(xhr.responseText);
if (response.success) {
message.textContent = '✅ 프로필 사진이 변경되었습니다!';
message.style.color = '#28a745';
// 실제 업로드된 이미지로 교체
setTimeout(() => {
previewImg.src = response.urls.medium + '?t=' + Date.now();
}, 500);
} else {
message.textContent = '❌ ' + response.message;
message.style.color = '#dc3545';
}
} else {
message.textContent = '❌ 업로드 중 오류가 발생했습니다.';
message.style.color = '#dc3545';
}
});
// 오류
xhr.addEventListener('error', function() {
progressContainer.style.display = 'none';
message.textContent = '❌ 네트워크 오류가 발생했습니다.';
message.style.color = '#dc3545';
});
xhr.open('POST', 'profile_upload.php');
xhr.send(formData);
}
</script>
</body>
</html>
🌟 마무리하며
와, 여기까지 정말 긴 여정이었어! 😅 PHP 파일 업로드부터 시작해서 GD와 ImageMagick을 활용한 이미지 처리, 보안, 성능 최적화까지 정말 많은 내용을 다뤘지. 이 정도면 실무에서 마주칠 대부분의 이미지 처리 상황을 해결할 수 있을 거야.
핵심만 다시 정리하면:
재능넷 같은 플랫폼을 개발하거나, 자신만의 서비스를 만들 때 이 글이 도움이 되었으면 좋겠어. 이미지 처리는 웹 개발에서 정말 중요한 부분이고, 제대로 구현하면 사용자 경험이 확 달라지거든! 🚀
혹시 더 궁금한 게 있거나 특정 상황에 대한 조언이 필요하면 언제든 물어봐. 우리 모두 멋진 웹 서비스를 만들어보자! 화이팅! 💪✨
🎓 더 배우고 싶다면?
재능넷에서 PHP 전문가들의 강의를 들어보세요!
실전 프로젝트 경험을 공유하고 함께 성장할 수 있습니다.
댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.