자바 백엔드 입문 31편 — 파일 업로드 @RequestPart MultipartFile

2026-05-17자바 백엔드 입문

자바 백엔드 입문 31편. multipart/form-data로 파일·이미지를 서버로 업로드하는 표준 패턴. @RequestPart·MultipartFile·application.yml 크기 제한·S3 업로드 흐름까지 풀어쓴 학습 노트.

📚 자바 백엔드 입문 · 31편 — 파일 업로드 @RequestPart MultipartFile

이 글은 자바 백엔드 입문 시리즈 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-Type
  • MultipartFile = 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 조합

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!