상품/이벤트 운영에 필수
관리자 페이지와 연결
보안 위험이 큼
서버 비용과 성능에 영향
사용자
↓
프론트엔드에서 파일 선택
↓
FormData로 파일 전송
↓
백엔드에서 파일 수신
↓
파일 검증
↓
로컬 서버 또는 S3에 저장
↓
DB에 파일 URL/메타데이터 저장
↓
프론트엔드에서 업로드 결과 표시
1. 관리자가 이벤트 배너 이미지 선택
2. React에서 FormData 생성
3. POST /api/admin/banners/upload 요청
4. NestJS에서 파일 크기와 확장자 검증
5. AWS S3에 이미지 업로드
6. DB에 이미지 URL 저장
7. 관리자 페이지에서 업로드된 배너 미리보기 표시
POST /api/consults
Content-Type: application/json
{
"name": "홍길동",
"phone": "01012345678"
}
POST /api/upload
Content-Type: multipart/form-data
file: image.png
type: banner
FormData 객체를 사용합니다.input type="file"을 사용해 파일을 선택할 수 있습니다.function FileInput() {
return <input type="file" />;
}
<input type="file" accept="image/*" />
multiple 속성을 사용합니다.<input type="file" multiple />
import { useState } from "react";
function BannerUpload() {
const [file, setFile] = useState<File | null>(null);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = event.target.files?.[0];
if (!selectedFile) return;
setFile(selectedFile);
};
return (
<input
type="file"
accept="image/*"
onChange={handleChange}
/>
);
}
event.target.files는 사용자가 선택한 파일 목록입니다.FormData에 담아서 서버로 전송합니다.async function uploadBanner(file: File) {
const formData = new FormData();
formData.append("file", file);
formData.append("type", "banner");
const response = await fetch("/api/admin/uploads/banner", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error("파일 업로드에 실패했습니다.");
}
return response.json();
}
FormData를 사용할 때는 보통 Content-Type을 직접 지정하지 않습니다.multipart/form-data 헤더를 자동으로 설정합니다.// 피하는 것이 좋은 방식
headers: {
"Content-Type": "multipart/form-data"
}
import axios from "axios";
async function uploadBanner(file: File) {
const formData = new FormData();
formData.append("file", file);
const response = await axios.post(
"/api/admin/uploads/banner",
formData
);
return response.data;
}
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
if (file.size > MAX_FILE_SIZE) {
alert("5MB 이하의 파일만 업로드할 수 있습니다.");
return;
}
const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
if (!allowedTypes.includes(file.type)) {
alert("JPG, PNG, WebP 파일만 업로드할 수 있습니다.");
return;
}
import { useState } from "react";
function ImagePreviewUpload() {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = event.target.files?.[0];
if (!selectedFile) return;
const objectUrl = URL.createObjectURL(selectedFile);
setPreviewUrl(objectUrl);
};
return (
<div>
<input type="file" accept="image/*" onChange={handleChange} />
{previewUrl && (
<img
src={previewUrl}
alt="업로드 미리보기"
style={{ width: 300 }}
/>
)}
</div>
);
}
URL.createObjectURL로 로컬 파일의 임시 미리보기 URL을 만들 수 있습니다.URL.revokeObjectURL로 메모리를 정리하는 것이 좋습니다.FileInterceptor와 Multer를 사용해 파일 업로드를 처리할 수 있습니다.import {
Controller,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
@Controller('admin/uploads')
export class UploadController {
@Post('banner')
@UseInterceptors(FileInterceptor('file'))
uploadBanner(@UploadedFile() file: Express.Multer.File) {
return {
success: true,
message: '파일 업로드에 성공했습니다.',
data: {
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
},
};
}
}
FileInterceptor('file')의 'file'은 프론트엔드 formData.append("file", file)의 필드명과 일치해야 합니다.import {
Controller,
Post,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
@Controller('admin/uploads')
export class UploadController {
@Post('images')
@UseInterceptors(FilesInterceptor('files', 5))
uploadImages(@UploadedFiles() files: Express.Multer.File[]) {
return {
success: true,
message: '여러 파일 업로드에 성공했습니다.',
data: files.map((file) => ({
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
})),
};
}
}
FilesInterceptor('files', 5)는 최대 5개의 파일을 받을 수 있다는 의미입니다.import { BadRequestException } from '@nestjs/common';
const MAX_FILE_SIZE = 5 * 1024 * 1024;
if (file.size > MAX_FILE_SIZE) {
throw new BadRequestException('5MB 이하의 파일만 업로드할 수 있습니다.');
}
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedMimeTypes.includes(file.mimetype)) {
throw new BadRequestException('JPG, PNG, WebP 파일만 업로드할 수 있습니다.');
}
홍길동_신분증.png
../../../server.js
banner final 최종 진짜최종.png
import { randomUUID } from 'crypto';
import * as path from 'path';
function createSafeFileName(originalName: string) {
const ext = path.extname(originalName).toLowerCase();
const fileName = `${randomUUID()}${ext}`;
return fileName;
}
프론트엔드
↓ 파일 업로드 요청
백엔드
↓ 파일 검증
AWS S3
↓ 업로드 완료
백엔드
↓ 파일 URL 또는 key 저장
DB
id
originalName
storedName
mimeType
size
bucket
key
url
createdAt
{
"id": 1,
"originalName": "banner.png",
"storedName": "0f4b7f8e-8b7a-4a1c-9f2a.png",
"mimeType": "image/png",
"size": 345123,
"bucket": "my-service-bucket",
"key": "banners/0f4b7f8e-8b7a-4a1c-9f2a.png",
"url": "https://cdn.example.com/banners/0f4b7f8e-8b7a-4a1c-9f2a.png"
}
key를 기준으로 관리하는 것이 더 유연한 경우가 많습니다./images/banner.webp
/uploads/products/galaxy-s25.png
/assets/main.js
/fonts/Pretendard.woff2
사용자
↓
CloudFront CDN
↓
S3 원본 파일
main.a1b2c3.js
style.x9y8z7.css
banner.0f4b7f8e.webp
Cache-Control: public, max-age=31536000, immutable
index.html
robots.txt
sitemap.xml
Cache-Control: no-cache
원본 이미지:
product-original/galaxy-s25.png
최적화 이미지:
product/galaxy-s25_320.webp
product/galaxy-s25_768.webp
product/galaxy-s25_1200.webp
허용:
jpg, jpeg, png, webp
차단:
exe, sh, js, html, php, zip
프로필 이미지: 2MB 이하
배너 이미지: 5MB 이하
첨부파일: 10MB 이하
프론트엔드에서 업로드 버튼 숨김
↓
백엔드에서 ADMIN 권한 검사
↓
권한 없으면 403 Forbidden
deletedAt을 기록한 뒤 일정 기간 후 삭제할 수 있습니다.React + NestJS + AWS S3 환경에서 관리자 배너 이미지 업로드 기능을 만들고 싶어.
조건:
1. 프론트엔드는 React + TypeScript
2. 백엔드는 NestJS
3. 저장소는 AWS S3
4. 이미지는 jpg, png, webp만 허용
5. 최대 파일 크기는 5MB
6. 관리자 권한이 있는 사용자만 업로드 가능
7. S3 key는 banners/{uuid}.{ext} 형식
8. DB에는 originalName, key, url, size, mimeType 저장
9. 업로드 실패 시 사용자 메시지와 서버 로그를 분리
프론트엔드 코드, NestJS Controller/Service 구조, DB 저장 구조, 주의점을 나눠서 설명해줘.
multipart/form-data와 FormData를 사용합니다.key, url, mimeType, size, originalName 같은 메타데이터를 저장합니다.