Ch 05. 이미지 업로드와 파일 관리
게시글에 대표 이미지를 첨부하는 기능을 만듭니다. multipart/form-data로 파일을 서버에 전송하고, 서버에서 저장한 뒤 URL을 반환하는 흐름입니다.
이미지 업로드 API
Nitro는 readMultipartFormData로 multipart/form-data 요청을 파싱합니다. 파일 데이터를 꺼내 public/uploads/ 폴더에 저장하고 접근 가능한 URL을 반환합니다.
파일 이름이 충돌하지 않도록 Date.now()를 접두어로 붙입니다.
// server/api/upload.post.tsimport { writeFile, mkdir } from 'fs/promises'import { join } from 'path'const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']const MAX_SIZE = 5 * 1024 * 1024 // 5MBexport default defineEventHandler(async (event) => { const userId = event.context.userId if (!userId) throw createError({ statusCode: 401 }) const form = await readMultipartFormData(event) const file = form?.find(f => f.name === 'image') if (!file) throw createError({ statusCode: 400, statusMessage: '파일이 없습니다.' }) if (!ALLOWED_TYPES.includes(file.type ?? '')) { throw createError({ statusCode: 400, statusMessage: '허용되지 않는 파일 형식입니다.' }) } if (file.data.length > MAX_SIZE) { throw createError({ statusCode: 400, statusMessage: '파일 크기는 5MB를 초과할 수 없습니다.' }) } const uploadDir = join(process.cwd(), 'public/uploads') await mkdir(uploadDir, { recursive: true }) const ext = (file.filename ?? 'image').split('.').pop() const filename = `${Date.now()}-${userId}.${ext}` await writeFile(join(uploadDir, filename), file.data) return { url: `/uploads/${filename}` }})
mkdir에 recursive: true를 주면 폴더가 이미 있어도 오류 없이 넘어갑니다. 처음 실행할 때 폴더가 없어 실패하는 상황을 방지합니다.
클라이언트 파일 선택과 미리보기
파일을 선택하는 즉시 URL.createObjectURL로 로컬 미리보기를 보여줍니다. 서버에 업로드하기 전에 사용자가 확인할 수 있어 경험이 좋아집니다.
<!-- components/ImageUploader.vue -->
<script setup lang="ts">
const emit = defineEmits<{ uploaded: [url: string] }>()
const imageUrl = ref('')
const file = ref<File | null>(null)
function onFileChange(e: Event) {
const input = e.target as HTMLInputElement
file.value = input.files?.[0] || null
if (file.value) {
imageUrl.value = URL.createObjectURL(file.value)
}
}
async function upload() {
if (!file.value) return
const form = new FormData()
form.append('image', file.value)
const { url } = await $fetch<{ url: string }>('/api/upload', {
method: 'POST',
body: form
})
emit('uploaded', url)
}
</script>
<template>
<div>
<input type="file" accept="image/*" @change="onFileChange" />
<img v-if="imageUrl" :src="imageUrl" alt="미리보기" />
<button v-if="file" type="button" @click="upload">업로드</button>
</div>
</template>
ImageUploader는 업로드가 완료되면 uploaded 이벤트로 URL을 부모 컴포넌트에 전달합니다. 글 작성 폼에서 이 URL을 받아 imageUrl 필드에 저장합니다.
글 작성 폼과 이미지 업로더 연결
<!-- pages/posts/write.vue (이미지 업로드 추가) -->
<script setup lang="ts">
definePageMeta({ middleware: 'auth' })
const title = ref('')
const content = ref('')
const imageUrl = ref('')
async function submit() {
await $fetch('/api/posts', {
method: 'POST',
body: { title: title.value, content: content.value, imageUrl: imageUrl.value || undefined }
})
await navigateTo('/')
}
</script>
<template>
<form @submit.prevent="submit">
<input v-model="title" placeholder="제목" required />
<ImageUploader @uploaded="(url) => imageUrl = url" />
<img v-if="imageUrl" :src="imageUrl" alt="선택된 이미지" />
<textarea v-model="content" placeholder="내용을 입력하세요." required></textarea>
<button type="submit">게시하기</button>
</form>
</template>
파일 관리 시 주의사항
public/uploads/ 폴더는 git에 커밋하지 않는 것이 좋습니다. .gitignore에 public/uploads/ 줄을 추가합니다. 배포 서버에서는 도커 볼륨 또는 외부 스토리지(S3 등)를 사용하는 것이 안정적입니다.
운영 환경으로 넘어가면 로컬 파일 저장 방식 대신 클라우드 스토리지를 사용하는 것을 고려하세요. writeFile 부분만 교체하면 되도록 업로드 로직을 API 핸들러 한 곳에 모아둔 것이 나중에 도움이 됩니다.