콘텐츠로 건너뛰기

PHP에서 파일 업로드를 구현하는 방법 | 검증·저장·보안 완전 가이드

카테고리: PHP·구현

PHP에서 파일 업로드 기능을 구현할 때 "일단 작동하는" 코드를 작성하기는 쉽지만, 보안·검증·오류 처리까지 고려한 견고한 구현은 생각보다 깊습니다. 본 기사에서는 $_FILES의 구조부터 시작하여 MIME 타입 검증·확장자 화이트리스트·안전한 저장 경로·파일명 새니타이제이션까지, 본프로덕션 환경에서 사용할 수 있는 수준의 구현 방법을 체계적으로 설명합니다.

Browser PHP-FPM /tmp uploads/ POST multipart/form-data tmp file write $_FILES populated move_uploaded_file()
그림: $_FILES 처리 시퀀스 (POST → /tmp → move_uploaded_file)

$_FILES 구조 이해하기

HTML 폼에서 enctype="multipart/form-data"를 지정하여 파일을 전송하면, PHP는 $_FILES 슈퍼글로벌 변수에 업로드 정보를 저장합니다. 폼의 name 속성이 userfile인 경우, $_FILES['userfile']은 다음 5개의 키를 가진 연관 배열이 됩니다.

// $_FILES の構造
[
    'name'     => 'photo.jpg',        // クライアント側のファイル名
    'type'     => 'image/jpeg',       // クライアントが申告するMIMEタイプ
    'tmp_name' => '/tmp/phpA1B2C3',   // サーバーの一時保存パス
    'error'    => 0,                  // エラーコード(0 = 成功)
    'size'     => 204800,             // ファイルサイズ(バイト)
]

중요한 점으로, type은 클라이언트가 전송한 값이며 위조 가능합니다. MIME 타입 검증을 위해 반드시 finfo를 사용하여 서버 측에서 확인하세요.

UPLOAD_ERR_* 에러 코드 목록 및 해결 방법

$_FILES['userfile']['error']에는 다음 중 하나의 상수값이 들어갑니다. 업로드 처리 초반에 이 값을 확인하고 에러를 적절히 처리하는 것이 중요합니다.

상수 의미 대처 방법
UPLOAD_ERR_OK 0 업로드 성공 처리 계속
UPLOAD_ERR_INI_SIZE 1 php.ini 의 upload_max_filesize 초과 php.ini 설정 재검토
UPLOAD_ERR_FORM_SIZE 2 HTML 폼의 MAX_FILE_SIZE 초과 폼 설정 검토
UPLOAD_ERR_PARTIAL 3 파일이 중간에만 전송되었습니다 재업로드 촉구
UPLOAD_ERR_NO_FILE 4 파일이 선택되지 않았습니다 파일 선택 유도
UPLOAD_ERR_NO_TMP_DIR 6 임시 디렉토리가 존재하지 않음 서버 설정 확인
UPLOAD_ERR_CANT_WRITE 7 디스크 쓰기 실패 디스크 용량·권한 확인
UPLOAD_ERR_EXTENSION 8 PHP 확장으로 인해 업로드 중지됨 확장 모듈 설정 확인
function getUploadErrorMessage(int $errorCode): string
{
    return match ($errorCode) {
        UPLOAD_ERR_INI_SIZE   => 'ファイルサイズがサーバーの上限を超えています。',
        UPLOAD_ERR_FORM_SIZE  => 'ファイルサイズがフォームの上限を超えています。',
        UPLOAD_ERR_PARTIAL    => 'ファイルが完全にアップロードされませんでした。再度お試しください。',
        UPLOAD_ERR_NO_FILE    => 'ファイルが選択されていません。',
        UPLOAD_ERR_NO_TMP_DIR => 'サーバーエラー: 一時ディレクトリが見つかりません。',
        UPLOAD_ERR_CANT_WRITE => 'サーバーエラー: ファイルの書き込みに失敗しました。',
        UPLOAD_ERR_EXTENSION  => 'サーバーエラー: 拡張機能によりアップロードが拒否されました。',
        default               => '不明なエラーが発生しました。',
    };
}

MIME 타입 유효성 검사(finfo 사용)

클라이언트가 전송하는 $_FILES['userfile']['type']은 위조될 수 있으므로, 서버는 실제 파일 내용에서 MIME 타입을 판정해야 합니다. PHP의 finfo_file() (또는 FileInfo 클래스)를 사용합니다.

function validateMimeType(string $tmpPath, array $allowedMimes): bool
{
    $finfo = new finfo(FILEINFO_MIME_TYPE);
    $detectedMime = $finfo->file($tmpPath);

    return in_array($detectedMime, $allowedMimes, true);
}

// 使用例: 画像ファイルのみ許可
$allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!validateMimeType($_FILES['userfile']['tmp_name'], $allowedMimes)) {
    throw new RuntimeException('許可されていないファイル形式です。');
}

finfo는 파일의 처음 바이트(매직 넘버)를 읽어 MIME 유형을 판정하므로 확장자를 위장한 공격에도 대응할 수 있습니다. 다만 텍스트 파일(CSV, JSON 등)은 text/plain으로 판정될 수 있으므로 확장자 체크와 함께 사용하기를 권장합니다.

확장자 화이트리스트

MIME 타입 검증과 함께 파일 확장자의 화이트리스트 확인도 수행하세요. 블랙리스트(.php, .exe 등을 금지)보다는 화이트리스트(허용하는 것만 나열)가 더 안전합니다.

function validateExtension(string $originalName, array $allowedExtensions): bool
{
    // pathinfo() で拡張子を取得し、小文字に正規化
    $extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));

    return in_array($extension, $allowedExtensions, true);
}

// 使用例
$allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
if (!validateExtension($_FILES['userfile']['name'], $allowedExtensions)) {
    throw new RuntimeException('許可されていない拡張子です。');
}

// MIMEタイプと拡張子の対応を明示的にマッピングする方法
$mimeToExtensions = [
    'image/jpeg' => ['jpg', 'jpeg'],
    'image/png'  => ['png'],
    'image/gif'  => ['gif'],
    'image/webp' => ['webp'],
];

$finfo = new finfo(FILEINFO_MIME_TYPE);
$detectedMime = $finfo->file($_FILES['userfile']['tmp_name']);
$extension = strtolower(pathinfo($_FILES['userfile']['name'], PATHINFO_EXTENSION));

if (
    !isset($mimeToExtensions[$detectedMime]) ||
    !in_array($extension, $mimeToExtensions[$detectedMime], true)
) {
    throw new RuntimeException('ファイル形式と拡張子が一致しません。');
}

파일 크기 제한 코드 예제

PHP 레벨에서의 크기 검사는 $_FILES['userfile']['size']를 사용하여 수행됩니다. 그러나 php.ini의 upload_max_filesize를 초과하는 파일은 $_FILES에 도달하지 않으며 UPLOAD_ERR_INI_SIZE 오류가 발생합니다. 따라서 먼저 오류 코드를 확인하는 것이 중요합니다.

const MAX_UPLOAD_SIZE = 10 * 1024 * 1024; // 10 MiB

function validateFileSize(int $fileSize, int $maxSize = MAX_UPLOAD_SIZE): bool
{
    return $fileSize > 0 && $fileSize <= $maxSize;
}

// 使用例
if (!validateFileSize($_FILES['userfile']['size'])) {
    $maxMiB = MAX_UPLOAD_SIZE / (1024 * 1024);
    throw new RuntimeException("ファイルサイズが上限({$maxMiB} MiB)を超えています。");
}

// アップロード処理を関数にまとめた例
function processUpload(array $file): array
{
    // 1. エラーコード確認
    if ($file['error'] !== UPLOAD_ERR_OK) {
        throw new RuntimeException(getUploadErrorMessage($file['error']));
    }

    // 2. サイズチェック
    if (!validateFileSize($file['size'])) {
        throw new RuntimeException('ファイルサイズが上限を超えています。');
    }

    // 3. MIMEタイプチェック
    $allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
    if (!validateMimeType($file['tmp_name'], $allowedMimes)) {
        throw new RuntimeException('許可されていないファイル形式です。');
    }

    // 4. 拡張子チェック
    $allowedExts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
    if (!validateExtension($file['name'], $allowedExts)) {
        throw new RuntimeException('許可されていない拡張子です。');
    }

    return $file;
}

안전한 저장 위치 (public 외부)

업로드된 파일은 문서 루트(예: public_html, public)의 외부에 저장해야 합니다. public 디렉터리 내에 저장하면 URL을 통해 직접 접근되거나 PHP 파일로 실행될 위험이 있습니다.

// 推奨ディレクトリ構成
// /var/www/
//   ├── public/         ← ドキュメントルート(外部からアクセス可能)
//   │   └── index.php
//   └── storage/        ← public の外(外部から直接アクセス不可)
//       └── uploads/

// 定数で保存先を明示
define('UPLOAD_DIR', dirname(__DIR__) . '/storage/uploads/');

// ディレクトリが存在しない場合は作成
if (!is_dir(UPLOAD_DIR)) {
    mkdir(UPLOAD_DIR, 0755, true);
}

// ダウンロード提供時は PHP 経由でストリーミング
function serveFile(string $filename): void
{
    $filePath = UPLOAD_DIR . basename($filename);

    if (!file_exists($filePath)) {
        http_response_code(404);
        exit;
    }

    $finfo = new finfo(FILEINFO_MIME_TYPE);
    $mimeType = $finfo->file($filePath);

    header('Content-Type: ' . $mimeType);
    header('Content-Length: ' . filesize($filePath));
    header('Content-Disposition: attachment; filename="' . basename($filename) . '"');
    readfile($filePath);
    exit;
}

파일명 새니타이즈 (uniqid 사용)

클라이언트가 보낸 파일명을 그대로 쓰는 것은 위험합니다. 디렉터리 트래버설 (../../etc/passwd 같은 경로) 이나 특수 문자가 든 파일명을 통한 OS 명령 주입의 위험이 있습니다. uniqid() 로 고유한 이름을 생성하고 원래 파일명은 별도로 데이터베이스에 기록하는 방법이 안전합니다.

function generateSafeFilename(string $originalName): string
{
    // 拡張子のみ元のファイルから引き継ぐ
    $extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));

    // uniqid() + mt_rand() でユニークな名前を生成
    // more_entropy=true で精度を上げる
    $uniqueName = uniqid('upload_', true);

    // さらにランダム性を加える場合は bin2hex(random_bytes(8)) を使う
    // $uniqueName = bin2hex(random_bytes(16));

    return $uniqueName . '.' . $extension;
}

// 使用例
$safeFilename = generateSafeFilename($_FILES['userfile']['name']);
$savePath = UPLOAD_DIR . $safeFilename;

// is_uploaded_file() で正規のアップロードか確認してから移動
if (!is_uploaded_file($_FILES['userfile']['tmp_name'])) {
    throw new RuntimeException('不正なファイルアップロードを検出しました。');
}

if (!move_uploaded_file($_FILES['userfile']['tmp_name'], $savePath)) {
    throw new RuntimeException('ファイルの保存に失敗しました。');
}

// 元のファイル名、保存名、MIMEタイプをDBに記録
// db_insert(['original_name' => $_FILES['userfile']['name'], 'saved_name' => $safeFilename, ...]);

php.ini 설정 (upload_max_filesize / post_max_size)

PHP에서 파일 업로드를 받으려면 php.ini (또는 .htaccess / 로컬 php.ini 설정)를 적절히 설정해야 합니다. 중요한 것은 upload_max_filesize 뿐만 아니라 post_max_size도 함께 변경해야 한다는 점입니다.

; php.ini の設定
; ファイルアップロードを有効化
file_uploads = On

; 1ファイルあたりの上限(M = MiB 単位)
upload_max_filesize = 20M

; POSTリクエスト全体の上限
; upload_max_filesize より大きく設定する(フォームデータのオーバーヘッド分)
post_max_size = 25M

; 最大実行時間(大きなファイルのアップロードに対応)
max_execution_time = 300

; 最大入力時間(アップロードの読み込み時間)
max_input_time = 300

; メモリ上限(post_max_size より大きくする)
memory_limit = 128M

설정값의 반영은 phpinfo() 또는 ini_get()으로 확인할 수 있습니다.

// 現在の設定値を確認
echo ini_get('upload_max_filesize');  // 例: "20M"
echo ini_get('post_max_size');        // 例: "25M"

// バイト単位に変換するユーティリティ
function convertToBytes(string $value): int
{
    $value = trim($value);
    $last = strtolower($value[-1]);
    $num = (int) $value;

    return match ($last) {
        'g' => $num * 1024 * 1024 * 1024,
        'm' => $num * 1024 * 1024,
        'k' => $num * 1024,
        default => $num,
    };
}

요약: 안전한 파일 업로드 처리 체크리스트

  • UPLOAD_ERR_*로 에러 코드 확인하기
  • finfo로 서버측에서 MIME 유형을 판정하고 있음
  • 화이트리스트 방식으로 파일 확장자 검증
  • $_FILES['userfile']['size']로 파일 크기를 확인하고 있습니다
  • 저장 위치가 public 디렉토리 외부로 설정되어 있음
  • is_uploaded_file()로 정규 업로드를 확인하고 있음
  • move_uploaded_file()로 파일을 이동하고 있음
  • uniqid() 또는 random_bytes()로 파일명을 생성하고 있습니다.
  • php.ini 의 upload_max_filesizepost_max_size 를 적절하게 설정했습니다

이 기사에서 사용할 수 있는 테스트 파일

❓ 자주 묻는 질문

PHP의 업로드 파일 크기 상한 기본값은?
php.ini의 upload_max_filesize 기본값은 2MB이며 post_max_size는 8MB로 설정되어 있습니다.
PHP에서 여러 파일을 동시에 업로드하려면?
input 태그에 multiple 속성을 붙이고 name 속성을 배열 형식(files[])으로 합니다. $_FILES['files']로 배열로 받을 수 있습니다.
PHP 파일 업로드에서 보안상 주의할 점은?
확장자 화이트리스트 검증, finfo_file()로 MIME 타입 확인, 업로드 위치를 문서 루트 밖에 두는 것이 중요합니다.

자주 묻는 질문

PHP의 업로드 파일 크기 제한의 기본값은?

php.ini의 upload_max_filesize 기본값은 2MB입니다. post_max_size도 8MB로 설정되어 있습니다.

PHP에서 여러 파일을 동시에 업로드하려면?

input 태그에 multiple 속성을 추가하고 name 속성을 배열 형식(files[])으로 설정하세요. $_FILES['files']로 배열로 받을 수 있습니다.

PHP 파일 업로드에서 보안상 주의해야 할 점은 무엇인가요?

finfo_file()을 사용하여 파일 확장자 화이트리스트 검증, MIME 타입 확인, 업로드 경로를 문서 루트 외부에 저장하는 것이 중요합니다.