본문 바로가기
튜토리얼/백엔드

[PHP] 파일 업로드 시스템 만들기 — 다중 업로드, 파일 교체, 다운로드 스트리밍

by 루까(Luka) 2026. 3. 5.
반응형

왜 만들었나

지금까지 PHP로 로그인, 게시판, REST API를 만들었다. 그런데 실제 서비스에서 빠지지 않는 기능이 하나 있다. 파일 업로드다. 단순히 $_FILES로 받아서 저장하면 되는 것 같지만, 실제로는 주의해야 할 것들이 꽤 있다.

  • MIME 타입을 클라이언트 제출값이 아니라 서버에서 직접 검사해야 한다
  • 저장 파일명을 원본 그대로 쓰면 경로 조작 공격에 노출된다
  • 다중 파일 업로드 시 $_FILES 배열 구조가 직관적이지 않다
  • 파일 교체는 기존 파일 삭제 + 새 파일 저장 + DB 업데이트가 원자적으로 돼야 한다

이 튜토리얼은 다중 파일 업로드, 파일명 수정 + 선택적 파일 교체, 다운로드 스트리밍, 삭제까지 완전한 파일 관리 시스템을 구현한다.


기술 상세

아키텍처

28-php-file-upload/
├── db.php       # PDO 싱글턴, 상수 정의, 헬퍼 함수
├── auth.php     # 세션, CSRF
├── header.php   # 공통 CSS + 네비게이션
├── footer.php
├── index.php    # 파일 목록 (공개)
├── upload.php   # 다중 파일 업로드 (로그인 필수)
├── edit.php     # 메타데이터 수정 + 파일 교체 (소유자만)
├── delete.php   # 삭제 POST 핸들러 (소유자만)
├── download.php # 스트림 다운로드 (공개)
├── login.php / register.php / logout.php
└── uploads/     # 파일 저장 디렉터리 (gitignore)

DB 스키마

CREATE TABLE files (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id      INTEGER NOT NULL,
    orig_name    TEXT    NOT NULL,   -- 화면에 표시되는 파일명 (변경 가능)
    stored_name  TEXT    NOT NULL UNIQUE, -- 디스크 저장명 (UUID 기반)
    description  TEXT    NOT NULL DEFAULT '',
    size         INTEGER NOT NULL,
    mime         TEXT    NOT NULL,
    created_at   TEXT    NOT NULL DEFAULT (datetime('now')),
    updated_at   TEXT,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

orig_namestored_name을 분리한 것이 핵심이다. 사용자가 파일명을 바꿔도 디스크의 실제 파일명은 변하지 않고, 교체 시에만 stored_name이 새로 생성된다.


실제 소스 코드

다중 파일 업로드 — $_FILES 정규화

// <input name="files[]" multiple> 사용 시 $_FILES 구조:
// $_FILES['files']['name']     = ['a.jpg', 'b.pdf', ...]
// $_FILES['files']['tmp_name'] = ['/tmp/phpXXX', '/tmp/phpYYY', ...]
// --- 단일 파일 배열로 정규화 ---
$count = count($_FILES['files']['name']);
$items = [];
for ($i = 0; $i < $count; $i++) {
    $items[] = [
        'name'     => $_FILES['files']['name'][$i],
        'tmp_name' => $_FILES['files']['tmp_name'][$i],
        'error'    => $_FILES['files']['error'][$i],
        'size'     => $_FILES['files']['size'][$i],
    ];
}

PHP의 $_FILES 다중 파일 구조는 직관과 반대다. 파일별 배열이 아니라 속성별 배열이라 직접 정규화해야 한다.

MIME 검증 — finfo 사용

// $_FILES['type']는 클라이언트 전송값 → 신뢰 불가
// finfo_file()로 서버에서 직접 판별
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime  = finfo_file($finfo, $item['tmp_name']);
finfo_close($finfo);

if (!in_array($mime, ALLOWED_MIME, true)) {
    $errors[] = $item['name'] . ': 허용되지 않는 파일 형식 (' . $mime . ')';
    continue;
}

finfo는 파일 내용의 매직 바이트를 읽어 MIME을 판별한다. 확장자 스푸핑이나 MIME 헤더 위조를 막을 수 있다.

안전한 저장 파일명

$ext         = strtolower(pathinfo($item['name'], PATHINFO_EXTENSION));
$stored_name = bin2hex(random_bytes(16)) . ($ext ? '.' . $ext : '');
$dest        = UPLOAD_DIR . $stored_name;

move_uploaded_file($item['tmp_name'], $dest);

bin2hex(random_bytes(16))으로 32자리 16진수 파일명을 생성한다. 원본 파일명의 특수문자, 경로 조작(../), 공백 등을 모두 회피한다.

파일 교체 (선택적 re-upload)

// edit.php — new_file이 있을 때만 교체, 없으면 메타데이터만 수정
if (!empty($_FILES['new_file']['name']) && $_FILES['new_file']['error'] === UPLOAD_ERR_OK) {
    // 검증 (MIME, 크기)
    $new_stored = bin2hex(random_bytes(16)) . '.' . $ext;
    move_uploaded_file($f['tmp_name'], UPLOAD_DIR . $new_stored);

    // 기존 물리 파일 삭제
    $old = UPLOAD_DIR . $file['stored_name'];
    if (file_exists($old)) unlink($old);

    $new_mime = $mime;
    $new_size = $f['size'];
}

// new_file 유무에 관계없이 DB 업데이트
$upd->execute([$orig_name, $new_stored, $desc, $new_size, $new_mime, $id]);

$new_stored는 교체 전에는 기존 값, 교체 후에는 새 값으로 분기된다. 단일 UPDATE 쿼리로 처리할 수 있다.

다운로드 스트리밍

// download.php — 파일을 직접 URL로 노출하지 않고 PHP가 스트리밍
header('Content-Type: ' . $file['mime']);
header('Content-Disposition: attachment; filename="' . rawurlencode($file['orig_name']) . '"');
header('Content-Length: ' . $file['size']);
header('Cache-Control: no-cache');

readfile($path);
exit;

uploads/ 폴더를 웹루트 외부에 두거나 .htaccess로 직접 접근을 차단하고, 항상 download.php를 통해서만 제공한다. 파일명에 한글이 있어도 rawurlencode()로 올바르게 인코딩한다.

삭제 — 파일 + DB 동시

// delete.php
if ($file && $file['user_id'] == $_SESSION['user_id']) {
    $path = UPLOAD_DIR . $file['stored_name'];
    if (file_exists($path)) unlink($path);      // 물리 파일
    get_db()->prepare('DELETE FROM files WHERE id = ?')->execute([$id]); // DB
}

소유자 확인 후 물리 파일을 먼저 삭제하고 DB 레코드를 삭제한다. 순서가 뒤바뀌면 파일이 남아도 DB에서 참조가 끊겨 고아 파일(orphan file)이 생긴다.


비교 테이블

항목 이번 구현 흔한 실수
MIME 검증 finfo_file() (서버 판별) $_FILES['type'] (클라이언트 전송값)
저장 파일명 UUID 랜덤 원본 파일명 그대로 사용
다운로드 방식 PHP 스트리밍 파일 URL 직접 노출
파일 교체 검증 → 새 파일 저장 → 기존 삭제 → DB 업데이트 기존 삭제 먼저 → 실패 시 데이터 손실
파일명 분리 orig_name / stored_name 구분 하나의 파일명만 관리

삽질 기록

1. $_FILES 다중 파일 구조

<input name="files[]" multiple>로 올리면 $_FILES['files'][0]이 첫 번째 파일일 것 같지만, 실제는 $_FILES['files']['name'][0]이다. 속성이 바깥, 인덱스가 안쪽이라 루프 전에 정규화가 필요하다.

2. move_uploaded_file vs rename

rename()으로 임시 파일을 옮기면 안 된다. PHP의 업로드 보안 검사를 우회할 수 있다. 반드시 move_uploaded_file()을 써야 한다. 이 함수는 is_uploaded_file() 확인까지 내부적으로 수행한다.

3. 파일 교체 순서

처음엔 기존 파일을 먼저 unlink()했다. 그러면 새 파일 저장이 실패했을 때 기존 파일도 없어진다. 새 파일 저장 성공을 확인한 후 기존 파일을 삭제하는 순서로 바꿨다.

4. rawurlencode vs urlencode

한글 파일명을 Content-Disposition에 넣을 때 urlencode()는 공백을 +로 바꾸는 반면, rawurlencode()%20으로 바꿔서 HTTP 헤더에 더 적합하다.


마무리

파일 업로드는 구현이 쉬워 보이지만, 보안 취약점이 가장 많이 발생하는 기능 중 하나다. MIME 검증을 서버에서 하고, 저장 파일명을 랜덤으로 생성하고, 다운로드는 PHP 스트리밍으로 하는 세 가지 원칙만 지켜도 주요 공격 벡터 대부분을 막을 수 있다.


기술 스택 PHP 8 PDO SQLite finfo CSRF 다중 파일 업로드

소스 코드 GitHub

로컬 실행

cd 28-php-file-upload
php -S localhost:8080
# → http://localhost:8080
반응형