CodeIgniter 4로 블로그 만들기 #27 — 대표 이미지 업로드와 썸네일
이제 글에 대표 이미지를 붙일 차례입니다. 목록에서는 작은 썸네일로, 상세에서는 큰 대표 이미지로 보여 줍니다.
파일 업로드는 텍스트 입력과 다른 문제를 몇 가지 안고 있습니다. 사용자가 올린 파일이 정말 이미지인지 검증해야 하고, 어디에 저장할지 정해야 하고, 목록용 작은 이미지를 만들어야 하고, 글을 수정하면서 이미지를 교체하면 기존 파일을 정리해야 합니다. 이번 회차는 이 흐름을 CI4의 두 기능 — Uploaded Files(업로드 파일 다루기)와 Image Manipulation(이미지 조작) — 으로 처리합니다.
준비물 / 목표
posts테이블에image컬럼 추가(마이그레이션)PostModel의allowedFields에image- 업로드 검증(이미지 형식·MIME·2MB) → 저장 → 400×250 썸네일 생성
- 이미지 교체 시 기존 파일 정리(고아 파일 방지)
- 웹 루트 밖(
writable/uploads)에 저장하고 컨트롤러로 서빙(경로 탈출 차단) multipart/form-data폼, 목록 썸네일·상세 대표 이미지 표시
1. image 컬럼 마이그레이션
DB에는 파일명만 저장합니다. 경로나 도메인은 저장하지 않습니다 — 그건 코드가 만들면 되고, 서버를 옮기거나 도메인이 바뀌어도 데이터를 안 건드려도 되기 때문입니다. 이미지는 선택 사항이라 null을 허용합니다.
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddImageToPosts extends Migration
{
public function up()
{
// 대표 이미지의 '저장된 파일명'만 보관한다(경로·도메인은 코드가 만든다).
// 이미지는 선택이므로 null 허용.
$this->forge->addColumn('posts', [
'image' => [
'type' => 'VARCHAR',
'constraint' => 255,
'null' => true,
'after' => 'body',
],
]);
}
public function down()
{
$this->forge->dropColumn('posts', 'image');
}
}
php spark migrate
그리고 PostModel의 allowedFields에 image를 추가합니다. 이게 빠지면 아무리 $data['image']를 채워도 저장되지 않습니다.
protected $allowedFields = [
'title',
'slug',
'body',
'image',
];
2. 업로드 저장 헬퍼 — 검증·저장·썸네일
작성과 수정 양쪽에서 똑같이 "업로드된 이미지를 검증하고 저장"해야 하므로, Posts 컨트롤러에 private 헬퍼로 모읍니다. 반환값을 세 가지 상태로 구분하는 것이 이 메서드의 핵심 설계입니다.
/**
* 업로드된 대표 이미지를 검증·저장하고 저장 파일명을 돌려준다.
*
* @return string|false|null 저장 파일명 / 검증 실패(false) / 업로드 없음(null)
*/
private function saveUploadedImage(): string|false|null
{
$file = $this->request->getFile('image');
// 파일을 고르지 않았으면 이미지 없이 진행한다.
if ($file === null || $file->getError() === UPLOAD_ERR_NO_FILE) {
return null;
}
// 이미지 형식·용량 검증(2MB 이하).
if (! $this->validate([
'image' => 'is_image[image]|mime_in[image,image/jpg,image/jpeg,image/png,image/webp]|max_size[image,2048]',
])) {
return false;
}
$dir = WRITEPATH . 'uploads';
$name = $file->getRandomName();
$file->move($dir, $name);
// 목록용 썸네일(400x250 크롭). 원본은 상세에서 사용.
service('image')
->withFile($dir . '/' . $name)
->fit(400, 250, 'center')
->save($dir . '/thumb_' . $name);
return $name;
}
몇 가지를 짚어 봅니다.
- 세 가지 반환 상태. 이미지는 선택 사항이므로 "안 올림(
null)"과 "올렸는데 검증 실패(false)"와 "성공(파일명)"을 구분해야 합니다. 호출부에서=== null/=== false로 갈라 다르게 처리합니다. is_image+mime_in+max_size. 확장자만 믿지 않고, 실제 이미지인지(is_image)와 MIME 타입, 그리고 용량(max_size[image,2048]→ 2048KB = 2MB)까지 검증합니다.getRandomName(). 사용자가 준 원래 파일명을 그대로 쓰지 않고 무작위 이름으로 저장합니다. 파일명 충돌과 위험한 문자열을 동시에 피합니다.service('image')->fit(400, 250, 'center'). CI4의 이미지 서비스로 400×250 크기에 맞춰 중앙 크롭한 썸네일을 만들고thumb_접두사를 붙여 저장합니다. 원본은 상세에서, 썸네일은 목록에서 씁니다.
교체 시 기존 파일을 지우기 위한 짝꿍 헬퍼도 함께 둡니다.
/**
* 글에 딸린 이미지 원본과 썸네일을 파일시스템에서 지운다.
*/
private function deleteImageFiles(?string $name): void
{
if ($name === null || $name === '') {
return;
}
foreach ([$name, 'thumb_' . $name] as $f) {
$path = WRITEPATH . 'uploads/' . $f;
if (is_file($path)) {
@unlink($path);
}
}
}
3. 작성·수정에서 헬퍼 엮기 — 고아 파일 방지
작성(create)에서는, 이미지를 먼저 파일시스템에 옮긴 뒤 DB insert를 합니다. 그런데 파일은 옮겼는데 DB 저장이 실패하면, 아무 글에도 딸리지 않은 고아 파일이 남습니다. 그래서 저장 실패 시 방금 옮긴 파일을 되돌립니다.
// 대표 이미지(선택). 검증 실패면 false, 미업로드면 null, 성공이면 파일명.
$image = $this->saveUploadedImage();
if ($image === false) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
if ($image !== null) {
$data['image'] = $image;
}
// 검증 실패 시: 방금 옮긴 이미지 파일을 되돌리고(고아 방지) 폼으로 돌아간다.
if (! $model->insert($data)) {
if ($image !== null) {
$this->deleteImageFiles($image);
}
return redirect()->back()
->withInput()
->with('errors', $model->errors());
}
수정(update)은 더 미묘합니다. 새 이미지가 올라오면 교체하는데, 기존 파일은 DB 반영이 성공한 뒤에 지워야 합니다. 반대 순서로 하면, DB 갱신이 실패했을 때 이미 지워진 기존 이미지 참조가 깨지기 때문입니다.
// 새 대표 이미지가 올라오면 교체한다. 단 기존 파일은 DB 반영이
// 성공한 뒤에 지운다(실패 시 기존 이미지 참조가 깨지지 않도록).
$image = $this->saveUploadedImage();
$oldImage = $post->image;
if ($image === false) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
if ($image !== null) {
$data['image'] = $image;
}
// 검증 실패 시: 방금 옮긴 새 파일을 되돌리고 입력값을 들고 폼으로 돌아간다.
if (! $model->update($id, $data)) {
if ($image !== null) {
$this->deleteImageFiles($image);
}
return redirect()->back()->withInput()->with('errors', $model->errors());
}
// 반영 성공 후에야 기존 이미지를 정리한다.
if ($image !== null) {
$this->deleteImageFiles($oldImage);
}
새 파일 실패 → 새 파일 되돌리기, 성공 → 기존 파일 지우기. 이 순서 감각이 파일과 DB를 함께 다룰 때의 요령입니다.
4. 웹 루트 밖에 저장하고, 컨트롤러로 서빙
이미지는 public/이 아니라 writable/uploads/에 저장했습니다. 이곳은 웹 루트 밖이라 브라우저가 URL로 직접 접근할 수 없습니다. 그래서 이미지를 컨트롤러로 내보내는 라우트를 하나 둡니다.
// 업로드 이미지 서빙(writable/uploads 는 웹 루트 밖이라 컨트롤러로 내보낸다).
$routes->get('uploads/(:segment)', 'Posts::image/$1');
/**
* writable/uploads 의 이미지를 스트리밍한다(웹 루트 밖이라 컨트롤러로 서빙).
*/
public function image(string $name): ResponseInterface
{
$name = basename($name); // 경로 탈출 방지
$path = WRITEPATH . 'uploads/' . $name;
if (! is_file($path)) {
throw PageNotFoundException::forPageNotFound();
}
return $this->response
->setHeader('Content-Type', mime_content_type($path) ?: 'application/octet-stream')
->setBody((string) file_get_contents($path));
}
basename($name)가 이 메서드의 안전장치입니다. 사용자가 ../../.env 같은 경로를 넘겨 서버의 다른 파일을 읽으려는 경로 탈출(path traversal) 공격을 막습니다. basename은 디렉터리 부분을 다 떼고 파일명만 남기므로, 업로드 폴더 밖으로는 나갈 수 없습니다.
5. 폼과 화면
파일을 보내려면 폼의 인코딩을 multipart/form-data로 바꿔야 합니다. 이걸 빠뜨리면 파일이 서버에 도착하지 않습니다.
<form class="form" action="<?= site_url('posts') ?>" method="post" enctype="multipart/form-data">
<?= csrf_field() ?>
...
<div>
<label for="image">대표 이미지 <small>(선택)</small></label>
<p class="field-hint">JPG·PNG·WebP, 2MB 이하. 목록에는 썸네일로 보입니다.</p>
<input type="file" name="image" id="image" accept="image/png,image/jpeg,image/webp">
</div>
수정 폼에서는 현재 대표 이미지(썸네일)를 미리 보여 주고, 새 파일을 올리면 교체된다고 안내합니다.
<?php if ($post->image !== null && $post->image !== ''): ?>
<p><img class="image-current" src="<?= site_url('uploads/thumb_' . $post->image) ?>" alt="현재 대표 이미지"></p>
<?php endif ?>
목록(index.php)에서는 이미지가 있는 글에 썸네일을 붙이고, 상세(show.php)에서는 원본 대표 이미지를 큼직하게 보여 줍니다.
<?php // 목록 — 썸네일 ?>
<img src="<?= site_url('uploads/thumb_' . $post->image) ?>" alt="" loading="lazy">
<?php // 상세 — 원본 ?>
<img class="post-cover" src="<?= site_url('uploads/' . $post->image) ?>" alt="<?= esc($post->title) ?>">
목록은 uploads/thumb_...(썸네일), 상세는 uploads/...(원본)을 참조하는 차이에 주목하세요. 둘 다 위에서 만든 uploads/(:segment) 라우트를 거쳐 Posts::image가 서빙합니다.
6. 업로드 폴더를 저장소에 남기기
writable/uploads/에 저장된 이미지 자체는 저장소에 커밋하면 안 됩니다. 하지만 폴더 구조는 남겨야 클론했을 때 저장할 곳이 있습니다. .gitkeep 빈 파일을 두고, .gitignore에서 그 파일만 예외(negation) 처리합니다.
/writable/uploads/*
!/writable/uploads/index.html
!/writable/uploads/.gitkeep
마무리 — 커밋과 태그
git add .
git commit -m "feat: 대표 이미지 업로드와 썸네일"
git tag ep27
다음 회차
다음 글에서는 **버그 수정과 코드 정리(리팩터링)**를 합니다. 지금까지 컨트롤러와 뷰 여기저기에 흩어져 있던 "본인 또는 관리자인가?" 판정 로직을 공통 헬퍼로 모으고, 수정 폼에 빠져 있던 권한 가드를 채웁니다. 여기서 중요한 건, 이미 통과하고 있는 초록 테스트 위에서 안전하게 리팩터링한다는 점입니다.
이번 회차 요약
- 다루는 파일:
app/Database/Migrations/..._AddImageToPosts.php(신규) /app/Models/PostModel.php(image필드) /app/Controllers/Posts.php(업로드 검증·저장·썸네일·서빙) /app/Config/Routes.php(uploads/...) /create.php·edit.php(multipart파일 입력) /index.php·show.php(이미지 표시) /.gitignore·writable/uploads/.gitkeep/app.css- 핵심: Uploaded Files로 검증·저장(
is_image·mime_in·max_size,getRandomName), Image로 400×250 썸네일. 웹 루트 밖 저장 +basename으로 경로 탈출 차단. 파일-DB 실패 시 고아 파일 정리.
다음: 버그 수정과 코드 정리
댓글 0
아직 댓글이 없습니다.