왜 만들었나
지금까지 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_name과 stored_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'튜토리얼 > 백엔드' 카테고리의 다른 글
| [PHP] REST API 서버 만들기 — 프레임워크 없이 순수 PHP로 CRUD API 구현 (0) | 2026.03.04 |
|---|---|
| [PHP] 게시판 CRUD 만들기 — SQLite, CSRF 방어, 소유권 검사, 페이지네이션 (0) | 2026.03.04 |
| [PHP] 로그인/회원가입 시스템 만들기 — PDO SQLite, 세션, CSRF 방어 (0) | 2026.03.04 |
| [Python] FastAPI로 REST API 서버 만들기 — Pydantic, CRUD, 자동 문서화 (0) | 2026.03.04 |