파일 처리가 필요한 순간
웹 애플리케이션은 데이터베이스만 사용하는 것처럼 보이지만, 실제로는 파일도 자주 다룹니다. 게시판 첨부파일, 프로필 이미지, 오류 로그, 간단한 설정 파일, CSV 내보내기 기능이 모두 파일 처리와 연결됩니다.
파일 처리는 크게 세 가지로 나눌 수 있습니다.
서버에 있는 파일을 읽는다.
새 내용을 파일에 쓴다.
사용자가 업로드한 파일을 안전한 위치로 옮긴다.
여기서 중요한 점은 "파일도 사용자 입력처럼 조심해서 다뤄야 한다"는 것입니다. 특히 업로드 파일은 사용자가 서버에 직접 파일을 보내는 기능입니다. 확장자만 믿거나 원래 파일명을 그대로 사용하면 보안 문제가 생길 수 있습니다.
AI 생성 이미지
파일 읽기, 쓰기, 추가하기
가장 간단한 파일 읽기는 file_get_contents()로 할 수 있습니다. 파일 전체 내용을 문자열로 읽어 오는 함수입니다.
<?php
declare(strict_types=1);
$path = __DIR__ . '/data/message.txt';
if (file_exists($path)) {
$content = file_get_contents($path);
if ($content === false) {
echo '파일을 읽지 못했습니다.';
exit;
}
echo nl2br(htmlspecialchars($content, ENT_QUOTES, 'UTF-8'));
} else {
echo '파일이 없습니다.';
}__DIR__는 현재 PHP 파일이 있는 디렉터리를 뜻합니다. 파일 경로를 쓸 때 상대 경로만 사용하면 "현재 실행 위치"에 따라 다른 파일을 찾을 수 있습니다. 그래서 __DIR__를 기준으로 경로를 만드는 습관이 좋습니다.
파일에 쓰기는 file_put_contents()를 사용합니다.
<?php
declare(strict_types=1);
$path = __DIR__ . '/data/message.txt';
$result = file_put_contents($path, '새 메시지입니다.' . PHP_EOL, LOCK_EX);
if ($result === false) {
echo '파일을 저장하지 못했습니다.';
}이 코드는 기존 내용을 지우고 새 내용으로 덮어씁니다. LOCK_EX는 쓰는 동안 다른 요청이 동시에 같은 파일을 건드리는 일을 줄이기 위한 잠금 옵션입니다. 완벽한 데이터베이스 트랜잭션은 아니지만, 로그나 간단한 파일 저장에서는 도움이 됩니다.
기존 내용 뒤에 덧붙이고 싶다면 FILE_APPEND를 사용합니다.
<?php
declare(strict_types=1);
$logPath = __DIR__ . '/storage/logs/app.log';
$line = '[' . date('Y-m-d H:i:s') . '] 페이지 방문' . PHP_EOL;
file_put_contents($logPath, $line, FILE_APPEND | LOCK_EX);정리하면 다음과 같습니다.
읽기:
file_get_contents($path)쓰기:
file_put_contents($path, $content)추가:
file_put_contents($path, $content, FILE_APPEND)
더 세밀하게 제어해야 할 때는 fopen(), fgets(), fwrite(), fclose()를 사용합니다. 파일 모드도 알아 두면 좋습니다.
r: 읽기 전용w: 쓰기 전용, 기존 내용 삭제a: 추가 전용, 기존 내용 뒤에 작성
초보 단계에서는 먼저 file_get_contents()와 file_put_contents()에 익숙해지는 것이 좋습니다. 다만 두 함수 모두 실패할 수 있으므로, 실제 기능을 만들 때는 반환값을 확인하는 습관도 함께 들이세요.
디렉터리와 경로 다루기
파일을 저장하려면 디렉터리가 먼저 있어야 합니다. 없는 디렉터리에 파일을 쓰려고 하면 오류가 납니다.
<?php
declare(strict_types=1);
$dir = __DIR__ . '/storage/logs';
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$path = $dir . '/app.log';
file_put_contents($path, '로그 시작' . PHP_EOL, FILE_APPEND | LOCK_EX);mkdir($dir, 0755, true)에서 세 번째 인수 true는 중간 디렉터리까지 함께 만들라는 뜻입니다. 예를 들어 storage가 없고 그 안의 logs도 없다면 둘 다 만듭니다.
실제 코드에서는 디렉터리 생성이 실패할 수도 있다는 점도 고려해야 합니다. 권한이 없거나, 같은 이름의 파일이 이미 있거나, 서버 설정 때문에 실패할 수 있습니다.
<?php
declare(strict_types=1);
$dir = __DIR__ . '/storage/logs';
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
exit('로그 디렉터리를 만들 수 없습니다.');
}마지막에 is_dir($dir)를 한 번 더 확인하는 이유는 아주 짧은 순간에 다른 요청이 먼저 디렉터리를 만들 수도 있기 때문입니다. 초보 단계에서는 조금 길어 보일 수 있지만, 파일 저장 코드는 이런 작은 예외 상황을 의식하는 것이 좋습니다.
경로를 다룰 때는 다음 원칙을 기억하세요.
사용자가 보낸 값을 그대로 파일 경로에 붙이지 않는다.
파일 존재 확인은
file_exists()를 사용한다.디렉터리 확인은
is_dir()를 사용한다.가능한 한
__DIR__를 기준으로 절대 경로를 만든다.업로드 파일명은 사용자가 보낸 원래 이름 대신 서버에서 새로 만든다.
특히 ../../config.php 같은 값이 경로에 섞이면 의도하지 않은 상위 디렉터리 파일에 접근할 수 있습니다. 이를 경로 이동 공격이라고 부릅니다. 원래 파일명을 화면에 보여줄 필요가 있더라도, 저장 파일명은 별도로 안전하게 만들어야 합니다.
CSV 파일을 배열로 읽기
CSV는 쉼표로 값을 구분한 텍스트 파일입니다. 엑셀이나 스프레드시트와 데이터를 주고받을 때 자주 사용합니다. 단순히 explode(',', $line)처럼 쉼표로만 나누면 따옴표 안에 들어 있는 쉼표를 제대로 처리하지 못할 수 있으므로, CSV를 읽을 때는 fgetcsv()를 사용하는 편이 안전합니다.
예를 들어 data/users.csv 파일이 다음과 같다고 가정해 봅시다.
name,email
홍길동,[email protected]
김영희,[email protected]이 파일을 PHP 배열로 바꿀 수 있습니다.
<?php
declare(strict_types=1);
$path = __DIR__ . '/data/users.csv';
$users = [];
if (($handle = fopen($path, 'r')) !== false) {
$headers = fgetcsv($handle);
if ($headers !== false) {
while (($row = fgetcsv($handle)) !== false) {
if (count($headers) !== count($row)) {
continue;
}
$users[] = array_combine($headers, $row);
}
}
fclose($handle);
}
foreach ($users as $user) {
echo htmlspecialchars($user['name'], ENT_QUOTES, 'UTF-8') . '
';
}fgetcsv()는 CSV 한 줄을 읽어 배열로 바꿉니다. 첫 줄을 제목 행으로 읽고, 이후 줄을 array_combine()으로 연관 배열로 바꾸면 다루기 편합니다. 다만 제목 개수와 실제 데이터 개수가 맞지 않으면 배열로 묶을 수 없으므로, 예제처럼 개수를 한 번 확인해 주면 오류를 줄일 수 있습니다.
JSON 파일을 배열로 읽고 쓰기
JSON은 배열과 객체 형태의 데이터를 문자열로 표현하는 형식입니다. PHP 배열과 구조가 비슷해서 설정 파일이나 간단한 데이터 저장에 사용하기 좋습니다.
data/posts.json 파일이 다음과 같다고 해 봅시다.
[
{"title": "첫 글", "author": "홍길동"},
{"title": "두 번째 글", "author": "김영희"}
]PHP에서는 이렇게 읽습니다.
<?php
declare(strict_types=1);
$path = __DIR__ . '/data/posts.json';
$json = file_get_contents($path);
if ($json === false) {
exit('JSON 파일을 읽지 못했습니다.');
}
try {
$posts = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
exit('JSON 형식이 올바르지 않습니다.');
}
foreach ($posts as $post) {
echo htmlspecialchars($post['title'], ENT_QUOTES, 'UTF-8') . '
';
}json_decode($json, true)에서 두 번째 인수 true는 JSON을 PHP 연관 배열로 바꾸라는 뜻입니다. JSON_THROW_ON_ERROR를 사용하면 JSON 형식이 잘못되었을 때 조용히 실패하지 않고 예외를 던집니다. 위 예제처럼 try와 catch로 감싸면 사용자에게 적절한 오류 메시지를 보여줄 수 있습니다.
배열을 다시 JSON 파일에 저장할 수도 있습니다.
<?php
declare(strict_types=1);
$path = __DIR__ . '/data/posts.json';
$posts = [
['title' => '첫 글', 'author' => '홍길동'],
['title' => '두 번째 글', 'author' => '김영희'],
];
try {
$json = json_encode(
$posts,
JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR
);
} catch (JsonException $e) {
exit('JSON으로 변환하지 못했습니다.');
}
if (file_put_contents($path, $json, LOCK_EX) === false) {
exit('JSON 파일을 저장하지 못했습니다.');
}JSON_UNESCAPED_UNICODE는 한글을 읽기 좋은 형태로 저장하게 해 줍니다. JSON_PRETTY_PRINT는 줄바꿈과 들여쓰기를 넣어 사람이 보기 쉽게 만듭니다.
이미지 업로드 폼 만들기
파일 업로드 폼은 일반 폼과 조금 다릅니다. 반드시 method='post'와 enctype='multipart/form-data'를 사용해야 합니다.
<form action='upload.php' method='post' enctype='multipart/form-data'>
<label>
프로필 이미지
<input type='file' name='profile_image' accept='image/png,image/jpeg'>
</label>
<button type='submit'>업로드</button>
</form>multipart/form-data는 문자 데이터뿐 아니라 파일 데이터를 함께 전송하기 위한 인코딩 방식입니다. 이 설정이 빠지면 PHP의 $_FILES에 파일 정보가 제대로 들어오지 않습니다.
accept 속성은 브라우저에서 선택 가능한 파일 종류를 제한하는 데 도움을 줍니다. 하지만 사용자가 우회할 수 있으므로 보안 검사는 반드시 서버에서 다시 해야 합니다.
업로드 파일 안전하게 저장하기
업로드된 파일 정보는 $_FILES 배열에 들어옵니다. 핵심 흐름은 다음과 같습니다.
파일이 실제로 업로드되었는지 확인한다.
업로드 오류 코드를 확인한다.
파일 크기를 제한한다.
서버에서 MIME 타입을 검사한다.
허용한 형식만 통과시킨다.
안전한 새 파일명을 만든다.
move_uploaded_file()로 최종 위치에 저장한다.
파일 업로드는 클라이언트 검증을 믿으면 안 됩니다. 서버에서 오류, 크기, 실제 파일 종류를 다시 확인하고, 원래 파일명 대신 충돌하기 어려운 새 이름을 사용하는 방식이 권장됩니다.1
<?php
declare(strict_types=1);
function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
exit('POST 요청만 허용합니다.');
}
if (!isset($_FILES['profile_image'])) {
exit('업로드된 파일이 없습니다.');
}
$file = $_FILES['profile_image'];
if ($file['error'] !== UPLOAD_ERR_OK) {
exit('업로드에 실패했습니다. 오류 코드: ' . $file['error']);
}
if (!is_uploaded_file($file['tmp_name'])) {
exit('정상적인 업로드 파일이 아닙니다.');
}
$maxSize = 2 * 1024 * 1024; // 2MB
if ($file['size'] > $maxSize) {
exit('2MB 이하 이미지만 업로드할 수 있습니다.');
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
if ($mime === false) {
exit('파일 형식을 확인하지 못했습니다.');
}
$allowed = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
];
if (!isset($allowed[$mime])) {
exit('JPG 또는 PNG 이미지만 업로드할 수 있습니다.');
}
$uploadDir = __DIR__ . '/storage/uploads';
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true) && !is_dir($uploadDir)) {
exit('업로드 디렉터리를 만들 수 없습니다.');
}
$baseDir = realpath($uploadDir);
if ($baseDir === false) {
exit('업로드 디렉터리를 확인할 수 없습니다.');
}
$filename = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
$destination = $baseDir . DIRECTORY_SEPARATOR . $filename;
if (!move_uploaded_file($file['tmp_name'], $destination)) {
exit('파일 저장에 실패했습니다.');
}
echo '업로드 완료: ' . e($filename);여기서 $_FILES['profile_image']['tmp_name']은 PHP가 임시로 저장한 업로드 파일 경로입니다. 이 파일은 요청 처리 중에만 안전하게 옮겨야 합니다. 최종 저장에는 rename()이 아니라 move_uploaded_file()을 사용합니다. 이 함수는 해당 파일이 HTTP 업로드로 들어온 파일인지 확인한 뒤 이동합니다.2
확장자 검사는 원래 파일명에서 가져오지 않았습니다. 대신 서버가 감지한 MIME 타입을 기준으로 허용 목록을 통과시키고, 그 결과에 맞는 확장자를 붙였습니다. $_FILES['type'] 값은 브라우저가 보낸 정보라 믿을 수 없습니다.
또 하나 기억할 점은 MIME 타입 검사도 만능은 아니라는 것입니다. 이미지 업로드를 더 엄격하게 다루고 싶다면 getimagesize()로 이미지 크기를 확인하거나, 이미지 처리 라이브러리로 새 이미지 파일을 다시 만들어 저장하는 방법도 사용합니다. 이 장에서는 우선 "원래 파일명과 확장자를 믿지 않고, 서버에서 다시 검사한다"는 원칙을 잡는 데 집중하면 됩니다.
업로드 보안 체크리스트
파일 업로드는 편리하지만 위험합니다. 다음 원칙을 지키세요.
원래 파일명을 저장 파일명으로 쓰지 않는다.
확장자만 보고 파일 종류를 판단하지 않는다.
finfo로 MIME 타입을 확인한다.허용 목록 방식으로 JPG, PNG 등 필요한 형식만 받는다.
파일 크기를 PHP 설정뿐 아니라 코드에서도 제한한다.
업로드 디렉터리에서 PHP 파일이 실행되지 않게 한다.
가능하면 업로드 파일을 공개 웹 루트 바깥에 저장한다.
사용자에게 출력하는 파일명은
htmlspecialchars()로 이스케이프한다.
학습용 프로젝트에서는 storage/uploads처럼 프로젝트 안에 저장할 수 있습니다. 하지만 실무에서는 업로드 파일을 바로 실행 가능한 공개 디렉터리에 두지 않는 편이 안전합니다. 공개해야 하는 이미지라면 별도 스크립트를 통해 읽어 주거나, 웹 서버 설정으로 업로드 폴더에서 스크립트 실행을 막아야 합니다.
작은 실습: 방문 로그 남기기
다음 코드는 사용자가 페이지를 열 때마다 로그를 남깁니다.
<?php
declare(strict_types=1);
$dir = __DIR__ . '/storage/logs';
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
exit('로그 디렉터리를 만들 수 없습니다.');
}
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$line = date('Y-m-d H:i:s') . ' / ' . $ip . PHP_EOL;
if (file_put_contents($dir . '/access.log', $line, FILE_APPEND | LOCK_EX) === false) {
exit('방문 기록을 저장하지 못했습니다.');
}
echo '방문 기록을 저장했습니다.';이 예제는 나중에 오류 로그, 관리자 기록, 업로드 기록을 남기는 기능으로 확장할 수 있습니다. 다만 로그 파일이 계속 커질 수 있으므로 실제 운영에서는 날짜별로 파일을 나누거나 오래된 로그를 정리하는 방식도 필요합니다.
실습 과제
storage/memo.txt파일에 사용자가 입력한 메모를 한 줄씩 추가하는 페이지를 만들어 보세요.data/products.csv파일을 만들고 상품명과 가격을 표로 출력해 보세요.게시글 배열을
posts.json에 저장한 뒤 다시 읽어 목록으로 출력해 보세요.JPG와 PNG만 허용하는 프로필 이미지 업로드 기능을 만들고, 업로드된 파일명을 화면에 출력해 보세요.
자주 발생하는 오류 점검
$_FILES가 비어 있다면 폼에enctype='multipart/form-data'가 있는지 확인합니다.파일 저장이 실패하면 저장 디렉터리가 존재하는지, PHP가 그 디렉터리에 쓸 권한이 있는지 확인합니다.
업로드 용량이 큰 파일이 실패하면
php.ini의upload_max_filesize,post_max_size설정을 확인합니다.한글이 JSON에서 깨져 보이면
JSON_UNESCAPED_UNICODE옵션을 사용합니다.JSON을 읽거나 저장할 때 실패 원인을 알고 싶다면
JSON_THROW_ON_ERROR를 함께 사용합니다.파일 내용을 화면에 출력할 때 HTML이 실행되면
htmlspecialchars()를 적용합니다.원래 파일명을 그대로 저장하고 있다면 파일명 충돌과 경로 조작 위험이 없는지 다시 점검합니다.
이미지처럼 보이는 파일이라도 확장자만 믿지 말고
finfo로 MIME 타입을 확인합니다.
파일 처리는 웹 애플리케이션의 기본 기능입니다. 읽기, 쓰기, 추가는 단순하지만, 업로드는 보안 검사가 함께 있어야 합니다. 다음으로 데이터베이스를 배우면 파일로 저장하던 데이터를 MySQL에 저장하고, 업로드 파일 경로만 데이터베이스에 기록하는 구조로 확장할 수 있습니다.
참고
1How to securely upload files with PHP?
2Secure File Upload in PHP 8: A Production-Ready Implementation Guide
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.