자바 백엔드 입문 31편. multipart/form-data로 파일·이미지를 서버로 업로드하는 표준 패턴. @RequestPart·MultipartFile·application.yml 크기 제한·S3 업로드 흐름까지 풀어쓴 학습 노트.
이 글은 자바 백엔드 입문 시리즈 59편 중 31편이에요. 29편 요청 데이터 추출에서 빠진 "파일 업로드" 표준 패턴을 풀어 가요. 회원 프로필 이미지·상품 사진·문서 첨부 — 모든 백엔드의 단골 시나리오.
파일 업로드가 어렵게 들리는 이유
파일 업로드는 HTTP 본문이 JSON 같은 단순 텍스트가 아니라 multipart/form-data 라는 특수 포맷이라 — 평범한 @RequestBody 로 처리 X. "왜 이것만 다른 어노테이션을?" 가 어색해요.
이 글에서는 택배 박스 비유로 풀어요. JSON 요청 = "편지지 한 장", multipart 요청 = "여러 칸 나뉜 박스(이름·이메일·실제 파일 같이 들어 있음)". 끝까지 따라오시면 파일 업로드 표준 패턴이 한 그림에 들어와요.
MultipartFile — Spring의 파일 추상화
Spring이 multipart 요청 안의 파일 부분을 자바로 다루는 표준 객체 — MultipartFile.
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) {
String originalName = file.getOriginalFilename();
long size = file.getSize();
String contentType = file.getContentType();
byte[] bytes = file.getBytes(); // 메모리에 통째로 (큰 파일은 InputStream)
Path savePath = Paths.get("/uploads", UUID.randomUUID() + "-" + originalName);
file.transferTo(savePath); // 디스크에 저장
return originalName + " 업로드 완료";
}
핵심 메서드 5종:
| 메서드 | 역할 |
|---|---|
getOriginalFilename() |
클라이언트가 보낸 원본 파일명 |
getSize() |
파일 크기 (bytes) |
getContentType() |
MIME 타입 (image/jpeg 등) |
getBytes() |
파일 내용을 byte[]로 (작은 파일) |
transferTo(Path) |
디스크에 저장 |
getInputStream() |
스트림으로 읽기 (큰 파일) |
@RequestPart — JSON과 파일 동시에
@RequestParam 으로도 받을 수 있지만, @RequestPart 가 더 정확한 표현. 특히 "JSON + 파일 같이" 받을 때 필수.
@PostMapping("/products")
public Product create(
@RequestPart("info") @Valid ProductRequest info, // JSON 부분
@RequestPart("image") MultipartFile image) { // 파일 부분
return productService.create(info, image);
}
multipart 안에 "info" 라는 키로 JSON 들어오고 "image" 키로 파일 들어오는 구조. 클라이언트는 이렇게 보내요.
POST /products
Content-Type: multipart/form-data; boundary=----X
------X
Content-Disposition: form-data; name="info"
Content-Type: application/json
{"name":"키보드","price":50000}
------X
Content-Disposition: form-data; name="image"; filename="kbd.jpg"
Content-Type: image/jpeg
(이진 파일 데이터)
------X--
JSON과 파일이 같은 요청에 같이. 회원가입 + 프로필 사진, 상품 등록 + 대표 이미지 같은 흔한 패턴.
여러 파일 한 번에
MultipartFile[] 또는 List<MultipartFile> 로 받으면 끝.
@PostMapping("/gallery")
public void uploadMany(@RequestPart("images") List<MultipartFile> images) {
for (MultipartFile img : images) {
save(img);
}
}
크기 제한 설정 — application.yml
기본 제한이 깐깐해서(1MB) — 큰 파일 받으려면 명시 설정.
spring:
servlet:
multipart:
max-file-size: 10MB # 파일 한 개 최대
max-request-size: 50MB # 요청 전체 최대
enabled: true # 기본 true
file-size-threshold: 2KB # 메모리/디스크 임계점
max-file-size 초과 시 — MaxUploadSizeExceededException 자동. 33편 글로벌 핸들러 에서 잡아 400 응답.
검증 — 파일 타입·확장자
업로드된 파일을 그대로 저장하면 보안 위험. 검증 표준 패턴.
private static final Set<String> ALLOWED_TYPES = Set.of(
"image/jpeg", "image/png", "image/webp"
);
private static final long MAX_SIZE = 5 * 1024 * 1024; // 5MB
public void validateImage(MultipartFile file) {
if (file.isEmpty()) throw new BadRequestException("빈 파일");
if (file.getSize() > MAX_SIZE) throw new BadRequestException("파일 크기 초과");
if (!ALLOWED_TYPES.contains(file.getContentType())) {
throw new BadRequestException("이미지 형식 아님");
}
// 원본 파일명에 있는 ../ 같은 경로 조작 차단
String safeName = StringUtils.cleanPath(file.getOriginalFilename());
if (safeName.contains("..")) throw new BadRequestException("파일명 위험");
}
Content-Type 만 믿지 마세요 — 클라이언트가 조작 가능. 진짜 파일 형식 검증은 이미지의 매직 바이트 까지 검사하는 게 안전 (예: Apache Tika 라이브러리).
저장 전략 — 로컬 vs S3
작은 시스템 = 로컬 디스크. 대형 시스템 = AWS S3 같은 클라우드 스토리지.
로컬 디스크 (간단)
Path target = Paths.get("/var/uploads", filename);
Files.createDirectories(target.getParent());
file.transferTo(target);
S3 업로드 (실무 표준)
@Service
@RequiredArgsConstructor
public class S3UploadService {
private final S3Client s3;
private static final String BUCKET = "my-uploads";
public String upload(MultipartFile file, String dir) throws IOException {
String key = dir + "/" + UUID.randomUUID() + "-" + file.getOriginalFilename();
s3.putObject(PutObjectRequest.builder()
.bucket(BUCKET).key(key)
.contentType(file.getContentType())
.build(),
RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
return "https://" + BUCKET + ".s3.amazonaws.com/" + key;
}
}
업로드 후 — DB에는 "S3 URL 문자열" 만 저장, 파일 본체는 S3에. 회사 시스템 표준.
file.getBytes() 는 파일 전체를 메모리에 로드. 100MB 파일 = 100MB RAM 사용. 큰 파일은 getInputStream() 으로 스트림 처리. S3 업로드도 InputStream 직접 전달 표준.
한 줄 정리 — Spring 파일 업로드 = MultipartFile + @RequestPart. JSON + 파일 동시 받기는 @RequestPart 두 개. 검증·크기 제한·S3 업로드가 실무 표준 패턴.
시험 직전 한 번 더 — 파일 업로드 입문자가 매번 헷갈리는 것
multipart/form-data= 파일 업로드 표준 Content-TypeMultipartFile= Spring 파일 추상화- 핵심 메서드 =
getOriginalFilename·getSize·getContentType·getBytes·transferTo·getInputStream @RequestParam또는@RequestPart로 받음- JSON + 파일 동시 =
@RequestPart두 개 (각각 키 명시) - 여러 파일 =
List<MultipartFile>또는MultipartFile[] - 기본 크기 제한 = 1MB (작음)
- 크기 제한 =
spring.servlet.multipart.max-file-size - 초과 시 =
MaxUploadSizeExceededException - 검증 = 빈 파일·크기·Content-Type·경로 조작 (../) 모두 확인
- Content-Type만 믿지 X — 매직 바이트 검사 권장 (Apache Tika)
- 큰 파일 =
getInputStream()스트림 처리 getBytes()= 작은 파일만, 전체를 메모리에 로드- 로컬 저장 =
transferTo(Path) - 실무 표준 = S3 업로드 + DB에 URL 저장
- 파일명 충돌 =
UUID또는 타임스탬프 prefix - 사용자 업로드 파일은 정적 경로 노출 X (보안)
- 이미지 리사이즈·썸네일 = ImageIO 또는 Thumbnailator
- 대용량 업로드 = chunked upload·Tus 같은 프로토콜
- 한국 회사 표준 = S3 + CloudFront CDN 조합
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 26편 — Filter vs Interceptor 비교
- 27편 — @Controller @RequestMapping
- 28편 — @RestController와 JSON 응답
- 29편 — RequestParam PathVariable RequestBody
- 30편 — ArgumentResolver와 @LoginUser
다음 글: