티스토리 뷰
파일 업로드는 왜 유독 사고가 많이 날까? 이미지 업로드, 대용량 요청, 검증 포인트까지 한 번에 정리하기 — FastAPI · Spring Boot · Node.js
octo54 2026. 5. 8. 10:26파일 업로드는 왜 유독 사고가 많이 날까? 이미지 업로드, 대용량 요청, 검증 포인트까지 한 번에 정리하기 — FastAPI · Spring Boot · Node.js
백엔드 만들다 보면 파일 업로드는 꼭 한 번쯤 “그냥 붙이면 되겠지” 하고 들어가게 됩니다.
처음엔 진짜 별거 없어 보여요.
- 프론트에서 파일 하나 보내고
- 서버에서 받고
- 디스크나 S3에 저장하고
- URL 내려주면 끝
근데 막상 이걸 실서비스로 붙이기 시작하면, 이 파트가 생각보다 위험합니다.
- 용량 제한을 안 걸어서 서버 메모리가 튀고
- 확장자만 보고 이미지인 줄 알았는데 아닌 파일이 들어오고
- 원본 파일명을 그대로 써서 덮어쓰기나 경로 문제 생기고
- 아무 라우트나 업로드 받게 열어놨다가 악성 업로드 표면이 넓어지고
- 로컬에서는 되는데 운영에선 multipart 설정 때문에 깨지고
OWASP는 파일 업로드를 보호할 때 허용 확장자만 명시하고, Content-Type을 맹신하지 말고, 파일명을 서버가 생성한 값으로 바꾸고, 크기 제한을 두라고 권고합니다. (OWASP Cheat Sheet Series)
이번 글에서는 이걸
FastAPI · Spring Boot · Node.js 기준으로 풀어보겠습니다.
파일 업로드에서 먼저 봐야 하는 6가지
저는 파일 업로드를 붙일 때 기술보다 먼저 이 체크리스트부터 봅니다.
- 무슨 파일만 받을 건가
- 최대 몇 MB까지 받을 건가
- 원본 파일명을 그대로 쓸 건가
- 메모리로 받을 건가, 스트리밍/임시파일로 받을 건가
- 어디에 저장할 건가
- 업로드 권한은 누가 가지는가
OWASP도 거의 같은 관점으로 정리합니다. 허용 확장자 제한, 파일 타입 검증, 파일 시그니처 검증, 파일명 안전성, 저장 위치, 사용자 권한을 함께 보라고 설명합니다. (OWASP Cheat Sheet Series)
즉 파일 업로드는 “multipart 받는 법”보다
업로드 정책을 먼저 정하는 문제에 더 가깝습니다. (OWASP Cheat Sheet Series)
multipart/form-data는 왜 JSON 요청이랑 감각이 다를까
이건 초반에 꼭 체감해야 하는 차이예요.
JSON API는 보통 body를 한 번에 읽고 DTO로 검증하죠.
그런데 파일 업로드는 보통 multipart/form-data로 들어옵니다.
FastAPI 문서도 업로드 파일은 form data로 전송되기 때문에 python-multipart가 필요하다고 설명합니다. (FastAPI)
Spring MVC도 multipart/form-data가 들어오면 multipart resolver가 이를 파싱해서 파일과 폼 필드를 접근 가능하게 만든다고 설명합니다. (Home)
즉 파일 업로드는 보통 이런 특징이 있습니다.
- JSON body처럼 단순하지 않음
- 파일과 텍스트 필드가 섞일 수 있음
- 크기 제한, 임시 저장, 디스크 flush 같은 인프라 설정이 같이 필요함
그래서 저는 파일 업로드 API는 그냥 “DTO 하나 더 추가”가 아니라
별도 엔드포인트와 별도 정책이 필요한 기능으로 보는 편입니다. (Home)
원본 파일명을 그대로 저장하면 왜 위험할까
이건 진짜 많이 하는 실수예요.
예를 들어 사용자가 profile.png를 올렸다고 해서
그 이름 그대로 서버에 저장해버리면 문제가 생길 수 있습니다.
- 같은 이름 덮어쓰기
- 이상한 문자/길이 문제
- 경로 처리 실수
- 파일명 기반 공격 표면 증가
OWASP는 파일명을 애플리케이션이 생성한 값으로 바꾸고, 파일명 길이 제한과 허용 문자 제한을 고려하라고 권고합니다. (OWASP Cheat Sheet Series)
그래서 실무에서는 보통 이렇게 갑니다.
- 원본 이름은 메타데이터로만 보관
- 실제 저장명은 UUID나 해시 기반으로 생성
- 확장자도 허용 목록 기준으로 재결정
이게 훨씬 안전합니다. (OWASP Cheat Sheet Series)
Content-Type만 믿으면 안 되는 이유
프론트나 클라이언트가 보내는 Content-Type은 참고 정보일 뿐입니다.
OWASP도 이 헤더는 위조될 수 있으니 신뢰하지 말라고 설명합니다. (OWASP Cheat Sheet Series)
즉 이런 검증을 같이 보는 편이 좋아요.
- 확장자 허용 목록
- MIME 타입
- 가능하면 파일 시그니처(매직 넘버) 확인
- 이미지라면 실제로 열리는지 추가 검증
저는 초반엔 최소한
확장자 + MIME 타입 + 크기 제한 + 서버 생성 파일명
이 4개는 꼭 넣는 쪽을 추천합니다. 이 방향은 OWASP 권고와도 맞습니다. (OWASP Cheat Sheet Series)
이번 글에서 맞출 공통 예제
이번에는 “프로필 이미지 업로드”로 통일하겠습니다.
조건은 이렇게 둘게요.
- jpg, jpeg, png만 허용
- 최대 5MB
- 서버가 저장 파일명 생성
- 업로드 결과로 저장 경로와 원본 파일명 반환
이 정도면 실제 서비스 초반 업로드 API로 꽤 그럴듯합니다.
1) FastAPI에서 이미지 업로드 API 만들기
FastAPI는 File과 UploadFile로 업로드를 받을 수 있고, 문서에서는 UploadFile 사용을 자세히 설명합니다. 또한 업로드 파일을 받으려면 python-multipart를 설치해야 한다고 안내합니다. (FastAPI)
특히 UploadFile은 bytes보다 업로드 파일 처리에 적합한 선택으로 소개됩니다. (FastAPI)
추천 구조
fastapi-backend/
├── app/
│ ├── api/
│ │ └── upload.py
│ ├── services/
│ │ └── file_service.py
│ └── main.py
├── uploads/
└── requirements.txt
requirements.txt
fastapi
uvicorn[standard]
python-multipart
app/services/file_service.py
from pathlib import Path
from uuid import uuid4
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png"}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
UPLOAD_DIR = Path("uploads")
def ensure_upload_dir() -> None:
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
def validate_extension(filename: str) -> str:
ext = Path(filename).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
raise ValueError("허용되지 않는 파일 확장자입니다.")
return ext
def generate_safe_filename(ext: str) -> str:
return f"{uuid4().hex}{ext}"
app/api/upload.py
from pathlib import Path
from fastapi import APIRouter, File, HTTPException, UploadFile, status
from app.services.file_service import (
MAX_FILE_SIZE,
UPLOAD_DIR,
ensure_upload_dir,
generate_safe_filename,
validate_extension,
)
router = APIRouter(prefix="/api/uploads", tags=["uploads"])
@router.post("/profile-image", status_code=status.HTTP_201_CREATED)
async def upload_profile_image(file: UploadFile = File(...)):
try:
ensure_upload_dir()
ext = validate_extension(file.filename or "")
safe_name = generate_safe_filename(ext)
save_path = UPLOAD_DIR / safe_name
contents = await file.read()
if len(contents) > MAX_FILE_SIZE:
raise ValueError("파일 크기는 5MB 이하여야 합니다.")
with open(save_path, "wb") as f:
f.write(contents)
return {
"original_filename": file.filename,
"stored_filename": safe_name,
"stored_path": str(save_path),
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
app/main.py
from fastapi import FastAPI
from app.api.upload import router as upload_router
app = FastAPI(title="backend-series-fastapi-file-upload")
app.include_router(upload_router)
실행
uvicorn app.main:app --reload
FastAPI에서 핵심 감각
FastAPI는 문서 그대로 UploadFile로 시작하는 게 제일 무난합니다. python-multipart 설치가 필요하고, 업로드는 form data로 온다는 점도 꼭 기억해야 합니다. (FastAPI)
다만 위 예제는 이해를 위해 await file.read()로 한 번에 읽고 있어서, 아주 큰 파일이나 고부하 환경이라면 더 세밀한 처리로 가야 합니다.
초반엔 이미지 몇 MB 수준이면 충분히 시작 가능하지만, 대용량 업로드로 가면 저장 방식과 메모리 전략을 다시 봐야 해요. 이건 FastAPI만의 문제가 아니라 업로드 전반의 특성입니다. (FastAPI)
2) Spring Boot에서 MultipartFile 업로드 만들기
Spring MVC는 multipart가 활성화되면 multipart/form-data 요청을 파싱하고, MultipartFile과 일반 폼 필드를 함께 다룰 수 있다고 설명합니다. @RequestPart를 쓰면 파일과 메타데이터를 함께 받을 수도 있습니다. (Home)
Spring Boot는 multipart 업로드를 기본 서블릿 지원 위에 구성하고, 기본값으로 파일당 1MB, 요청당 10MB 제한을 둔다고 문서에서 설명합니다. 이 값은 spring.servlet.multipart.max-file-size와 spring.servlet.multipart.max-request-size로 조정할 수 있습니다. (Home)
추천 구조
springboot-backend/
├── src/main/java/com/example/backend/
│ ├── BackendApplication.java
│ ├── controller/
│ │ └── UploadController.java
│ └── service/
│ └── FileService.java
├── src/main/resources/
│ └── application.yml
└── uploads/
src/main/resources/application.yml
spring:
servlet:
multipart:
max-file-size: 5MB
max-request-size: 5MB
Spring Boot는 이 프로퍼티들로 multipart 크기 제한을 조정할 수 있다고 공식 문서에서 설명합니다. (Home)
src/main/java/com/example/backend/service/FileService.java
package com.example.backend.service;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
import java.util.UUID;
@Service
public class FileService {
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(".jpg", ".jpeg", ".png");
private static final Path UPLOAD_DIR = Path.of("uploads");
public record UploadResult(
String originalFilename,
String storedFilename,
String storedPath
) {}
public UploadResult saveProfileImage(MultipartFile file) throws IOException {
Files.createDirectories(UPLOAD_DIR);
String originalFilename = file.getOriginalFilename();
if (originalFilename == null || originalFilename.isBlank()) {
throw new IllegalArgumentException("파일명이 올바르지 않습니다.");
}
String ext = getExtension(originalFilename);
if (!ALLOWED_EXTENSIONS.contains(ext)) {
throw new IllegalArgumentException("허용되지 않는 파일 확장자입니다.");
}
String storedFilename = UUID.randomUUID().toString().replace("-", "") + ext;
Path savePath = UPLOAD_DIR.resolve(storedFilename);
file.transferTo(savePath);
return new UploadResult(
originalFilename,
storedFilename,
savePath.toString()
);
}
private String getExtension(String filename) {
int lastDot = filename.lastIndexOf(".");
if (lastDot == -1) {
return "";
}
return filename.substring(lastDot).toLowerCase();
}
}
src/main/java/com/example/backend/controller/UploadController.java
package com.example.backend.controller;
import com.example.backend.service.FileService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/uploads")
public class UploadController {
private final FileService fileService;
public UploadController(FileService fileService) {
this.fileService = fileService;
}
@PostMapping("/profile-image")
@ResponseStatus(HttpStatus.CREATED)
public FileService.UploadResult uploadProfileImage(
@RequestParam("file") MultipartFile file
) throws Exception {
return fileService.saveProfileImage(file);
}
}
src/main/java/com/example/backend/BackendApplication.java
package com.example.backend;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BackendApplication {
public static void main(String[] args) {
SpringApplication.run(BackendApplication.class, args);
}
}
Spring Boot에서 핵심 감각
Spring은 업로드가 굉장히 정석적으로 잘 들어옵니다.
MultipartFile로 시작하고, 크기 제한은 spring.servlet.multipart.*로 잡는 방식이 가장 무난합니다. 기본값이 파일당 1MB, 요청당 10MB라는 점도 알아두면 디버깅에 꽤 도움이 됩니다. (Home)
또 파일만 받는 게 아니라 JSON 비슷한 메타데이터와 같이 받을 땐 @RequestPart가 꽤 유용합니다. Spring 문서도 이 패턴을 예제로 보여줍니다. (Home)
3) Node.js에서 multer로 이미지 업로드 만들기
Express 쪽은 multer가 가장 흔한 선택지입니다. Express 공식 문서도 multer를 multipart/form-data 처리용 미들웨어로 소개합니다. (expressjs.com)
그리고 여기서 진짜 중요한 경고가 하나 있어요.
multer 문서는 전역 미들웨어로 깔지 말고, 파일 업로드를 실제로 처리하는 라우트에서만 쓰라고 경고합니다. 악의적 사용자가 예상하지 못한 라우트로 파일을 올릴 수 있기 때문입니다. (expressjs.com)
또 multer는 limits 옵션으로 크기 제한을 둘 수 있고, 이는 DoS 공격 방어에도 도움이 된다고 README가 설명합니다. fileFilter로 허용 파일을 거를 수도 있습니다. (GitHub)
추천 구조
node-backend/
├── src/
│ ├── routes/
│ │ └── upload.route.js
│ └── server.js
└── uploads/
package.json
{
"name": "backend-series-node-file-upload",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "node src/server.js"
},
"dependencies": {
"express": "^5.1.0",
"multer": "^2.0.2"
}
}
src/routes/upload.route.js
import { Router } from "express";
import multer from "multer";
import path from "node:path";
import fs from "node:fs";
import crypto from "node:crypto";
const router = Router();
const uploadDir = "uploads";
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const allowedExtensions = new Set([".jpg", ".jpeg", ".png"]);
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const safeName = `${crypto.randomUUID()}${ext}`;
cb(null, safeName);
},
});
const upload = multer({
storage,
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
},
fileFilter: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (!allowedExtensions.has(ext)) {
return cb(new Error("허용되지 않는 파일 확장자입니다."));
}
cb(null, true);
},
});
router.post("/profile-image", upload.single("file"), (req, res) => {
if (!req.file) {
return res.status(400).json({ message: "파일이 없습니다." });
}
return res.status(201).json({
originalFilename: req.file.originalname,
storedFilename: req.file.filename,
storedPath: req.file.path,
});
});
export default router;
src/server.js
import express from "express";
import uploadRouter from "./routes/upload.route.js";
const app = express();
const PORT = 3000;
app.use("/api/uploads", uploadRouter);
app.use((err, req, res, next) => {
return res.status(400).json({ message: err.message });
});
app.listen(PORT, () => {
console.log(`server running on http://localhost:${PORT}`);
});
Node.js에서 핵심 감각
Node.js는 여기서 제일 조심해야 할 게
multer를 아무 데나 전역으로 열지 않는 것입니다. 공식 문서가 직접 경고할 정도라서 이건 꼭 기억할 가치가 있어요. (expressjs.com)
그리고 limits와 fileFilter를 같이 쓰는 감각도 중요합니다. 크기 제한은 DoS 방어에 도움이 되고, fileFilter는 어떤 파일을 받을지 제어하는 첫 번째 관문입니다. (GitHub)
세 스택을 나란히 놓고 보면
FastAPI
Spring Boot
- MultipartFile과 spring.servlet.multipart.* 설정 조합이 정석입니다. (Home)
- 메타데이터와 파일을 함께 받을 땐 @RequestPart가 깔끔합니다. (Home)
Node.js
- multer가 사실상 표준적인 출발점입니다. (expressjs.com)
- 전역 미들웨어 금지, limits, fileFilter가 핵심입니다. (expressjs.com)
실무에서 꼭 같이 챙기면 좋은 것
첫째, 허용 목록 방식으로 가세요.
OWASP도 필요한 확장자만 명시적으로 허용하라고 권고합니다. (OWASP Cheat Sheet Series)
둘째, 원본 파일명은 저장용 키로 쓰지 마세요.
서버 생성 이름으로 바꾸는 게 낫습니다. (OWASP Cheat Sheet Series)
셋째, 크기 제한은 프레임워크 레벨과 비즈니스 레벨 둘 다 보세요.
Spring Boot는 설정으로, multer는 limits로, FastAPI는 코드나 프록시 레벨과 함께 잡아가는 식이 현실적입니다. (Home)
넷째, Content-Type만 믿지 마세요.
OWASP가 명시적으로 경고합니다. (OWASP Cheat Sheet Series)
다섯째, 업로드 권한 자체를 제한하세요.
OWASP도 승인된 사용자만 업로드하도록 하라고 권고합니다. (OWASP Cheat Sheet Series)
주니어 때 자주 하는 실수
1. 파일 확장자만 보고 끝내기
확장자 체크는 필요하지만 충분하지 않습니다. OWASP는 Content-Type을 맹신하지 말고 타입 검증과 시그니처 검증까지 보라고 설명합니다. (OWASP Cheat Sheet Series)
2. 원본 파일명을 그대로 저장
이건 초반엔 편해도 나중에 꼭 문제를 만듭니다. (OWASP Cheat Sheet Series)
3. 파일 크기 제한을 안 걸기
업로드는 용량 공격 표면이 됩니다. OWASP도 크기 제한을 권고하고, multer도 limits가 DoS 방어에 도움이 된다고 설명합니다. (OWASP Cheat Sheet Series)
4. Node.js에서 multer를 전역으로 붙이기
공식 문서가 직접 하지 말라고 경고합니다. (expressjs.com)
5. JSON API 감각으로 업로드도 똑같이 생각하기
multipart는 별도 설정과 별도 검증 흐름이 필요합니다. (FastAPI)
이번 글 핵심 정리
이번 글의 핵심은 이겁니다.
파일 업로드는 “파일 받기”가 아니라, 어떤 파일을 어떤 조건으로 어디에 얼마나 안전하게 저장할지 정하는 기능이다.
정리하면 이렇게 볼 수 있어요.
- multipart는 JSON 요청과 감각이 다르다
- 허용 확장자만 명시적으로 열자
- Content-Type만 믿지 말자
- 서버 생성 파일명을 쓰자
- 크기 제한을 두자
- 업로드 라우트와 권한을 분리하자
스택별 감각은 이렇습니다.
- FastAPI: UploadFile로 시작하고 python-multipart를 꼭 챙긴다. (FastAPI)
- Spring Boot: MultipartFile과 multipart 설정 프로퍼티 조합이 가장 무난하다. (Home)
- Node.js: multer는 강력하지만 범위를 라우트 단위로 정확히 제한해야 한다. (expressjs.com)
다음 글 예고
다음 글에서는 이어서
이미지 업로드 이후, 파일을 로컬 디스크에 둘지 S3 같은 외부 스토리지로 뺄지를 다뤄보겠습니다.
즉,
- 언제 로컬 저장이 괜찮은지
- 언제 오브젝트 스토리지가 필요한지
- 업로드 API와 저장소 추상화는 어떻게 나누는지
- FastAPI · Spring Boot · Node.js에서 서비스/스토리지 계층을 어떻게 끊는지
이 흐름으로 이어가겠습니다.
출처
- FastAPI 공식 문서 — Request Files / UploadFile / python-multipart. (FastAPI)
- Spring Framework 공식 문서 — Multipart, MultipartResolver, MultipartFile, @RequestPart. (Home)
- Spring Boot 공식 문서/가이드 — multipart 기본값과 spring.servlet.multipart.max-file-size, max-request-size. (Home)
- Express 공식 문서 — multer middleware. (expressjs.com)
- multer README — limits, fileFilter, DoS 방어 설명. (GitHub)
- OWASP File Upload Cheat Sheet — 허용 확장자, 타입 검증, 서버 생성 파일명, 크기 제한, 업로드 권한. (OWASP Cheat Sheet Series)
백엔드개발, FastAPI, SpringBoot, Nodejs, Express, 파일업로드, 이미지업로드, Multipart, Multer, 백엔드시리즈
'study > 백엔드' 카테고리의 다른 글
- Total
- Today
- Yesterday
- ai철학
- REACT
- llm
- DevOps
- fastapi
- LangChain
- 딥러닝
- 주니어개발자
- PostgreSQL
- NestJS
- 쿠버네티스
- Prisma
- flax
- seo 최적화 10개
- Next.js
- 생성형AI
- nextJS
- 개발블로그
- Python
- JAX
- SEO최적화
- 백엔드개발
- rag
- 웹개발
- kotlin
- node.js
- Express
- CI/CD
- JWT
- nodejs
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | |||||
| 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| 10 | 11 | 12 | 13 | 14 | 15 | 16 |
| 17 | 18 | 19 | 20 | 21 | 22 | 23 |
| 24 | 25 | 26 | 27 | 28 | 29 | 30 |
| 31 |

