사용자가 프로필 이미지를 업로드하거나 보고서 PDF를 다운로드할 때, 서버에서는 이 파일 데이터를 어떻게 받고 보내는 걸까요? 기가바이트 단위 파일도 처리할 수 있을까요?

개념 정의

스프링 MVC에서 파일 업로드 는 HTTP multipart/form-data 요청을 MultipartFile로 받아 처리하는 것이고, 파일 다운로드 는 Resource 객체를 HTTP 응답으로 스트리밍하는 것입니다.

왜 필요한가

파일 처리는 일반 JSON API와 다릅니다. 파일은 바이너리 데이터이고 크기가 클 수 있으므로, 메모리 관리, 용량 제한, 스트리밍 처리를 고려해야 합니다. 잘못 처리하면 OutOfMemoryError가 발생하거나 서버가 다운될 수 있습니다.

내부 동작

Multipart 요청 처리 흐름

PLAINTEXT
1. 클라이언트가 Content-Type: multipart/form-data로 요청
2. MultipartResolver가 요청을 파싱
3. 파일 데이터를 임시 위치에 저장 (디스크 or 메모리)
4. MultipartFile 객체로 컨트롤러에 전달
5. 컨트롤러에서 파일 처리 (저장, 변환 등)
6. 요청 완료 후 임시 파일 정리

기본 설정

YAML
spring:
  servlet:
    multipart:
      enabled: true              # Multipart 활성화 (기본 true)
      max-file-size: 10MB        # 단일 파일 최대 크기 (기본 1MB)
      max-request-size: 50MB     # 전체 요청 최대 크기 (기본 10MB)
      file-size-threshold: 2KB   # 메모리에 유지하는 임계값 (초과 시 디스크)
      location: /tmp             # 임시 파일 저장 위치

코드 예제

기본 파일 업로드

JAVA
@RestController
@RequestMapping("/api/files")
public class FileController {

    private final Path uploadDir = Path.of("/var/uploads");

    @PostMapping("/upload")
    public ResponseEntity<FileResponse> upload(@RequestParam("file") MultipartFile file) {
        if (file.isEmpty()) {
            throw new BadRequestException("파일이 비어있습니다");
        }

        // 파일 정보
        String originalName = file.getOriginalFilename();
        String contentType = file.getContentType();
        long size = file.getSize();

이어서 응답 객체를 구성하여 클라이언트에 반환하는 부분입니다.

JAVA
        // 고유 파일명 생성
        String savedName = UUID.randomUUID() + getExtension(originalName);
        Path targetPath = uploadDir.resolve(savedName);

        // 저장
        file.transferTo(targetPath); // 내부적으로 스트리밍 처리

        return ResponseEntity.ok(new FileResponse(savedName, originalName, size));
    }

    private String getExtension(String filename) {
        if (filename == null) return "";
        int dotIndex = filename.lastIndexOf(".");
        return dotIndex >= 0 ? filename.substring(dotIndex) : "";
    }
}

다중 파일 업로드

JAVA
@PostMapping("/upload-multiple")
public ResponseEntity<List<FileResponse>> uploadMultiple(
        @RequestParam("files") List<MultipartFile> files) {

    List<FileResponse> responses = new ArrayList<>();

    for (MultipartFile file : files) {
        if (!file.isEmpty()) {
            String savedName = UUID.randomUUID() + getExtension(file.getOriginalFilename());
            file.transferTo(uploadDir.resolve(savedName));
            responses.add(new FileResponse(savedName, file.getOriginalFilename(), file.getSize()));
        }
    }

    return ResponseEntity.ok(responses);
}

파일 + JSON 함께 받기

JAVA
@PostMapping(value = "/upload-with-data", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ProductResponse> uploadWithData(
        @RequestPart("file") MultipartFile file,
        @RequestPart("data") @Valid ProductRequest request) {

    // file: 이미지 파일
    // data: JSON 데이터 (자동으로 역직렬화됨)
    String imageUrl = fileService.store(file);
    Product product = productService.create(request, imageUrl);

    return ResponseEntity.ok(ProductResponse.from(product));
}

파일 타입 검증

JAVA
@PostMapping("/upload-image")
public ResponseEntity<?> uploadImage(@RequestParam("file") MultipartFile file) {
    // MIME 타입 검증
    String contentType = file.getContentType();
    if (contentType == null || !contentType.startsWith("image/")) {
        throw new BadRequestException("이미지 파일만 업로드 가능합니다");
    }

    // 확장자 검증
    String extension = getExtension(file.getOriginalFilename()).toLowerCase();
    Set<String> allowed = Set.of(".jpg", ".jpeg", ".png", ".gif", ".webp");
    if (!allowed.contains(extension)) {
        throw new BadRequestException("허용되지 않는 파일 형식입니다: " + extension);
    }

이어서 응답 객체를 구성하여 클라이언트에 반환하는 부분입니다.

JAVA
    // 파일 크기 추가 검증
    if (file.getSize() > 5 * 1024 * 1024) { // 5MB
        throw new BadRequestException("파일 크기는 5MB 이하여야 합니다");
    }

    // 저장
    return ResponseEntity.ok(fileService.store(file));
}

대용량 파일 스트리밍 업로드

JAVA
@PostMapping("/upload-large")
public ResponseEntity<?> uploadLarge(@RequestParam("file") MultipartFile file) throws IOException {
    String savedName = UUID.randomUUID() + getExtension(file.getOriginalFilename());
    Path targetPath = uploadDir.resolve(savedName);

    // 스트리밍으로 저장 (메모리에 전체를 올리지 않음)
    try (InputStream inputStream = file.getInputStream()) {
        Files.copy(inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING);
    }

    return ResponseEntity.ok(new FileResponse(savedName, file.getOriginalFilename(), file.getSize()));
}

파일 다운로드

JAVA
@GetMapping("/download/{filename}")
public ResponseEntity<Resource> download(@PathVariable String filename) throws IOException {
    Path filePath = uploadDir.resolve(filename).normalize();

    // 경로 조작 방지
    if (!filePath.startsWith(uploadDir)) {
        throw new BadRequestException("잘못된 파일 경로입니다");
    }

    Resource resource = new FileSystemResource(filePath);
    if (!resource.exists()) {
        throw new FileNotFoundException("파일을 찾을 수 없습니다: " + filename);
    }

이어서 응답 객체를 구성하여 클라이언트에 반환하는 부분입니다.

JAVA
    // MIME 타입 추측
    String contentType = Files.probeContentType(filePath);
    if (contentType == null) {
        contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE;
    }

    return ResponseEntity.ok()
        .contentType(MediaType.parseMediaType(contentType))
        .header(HttpHeaders.CONTENT_DISPOSITION,
                "attachment; filename=\"" + resource.getFilename() + "\"")
        .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(resource.contentLength()))
        .body(resource);
}

인라인 표시 (브라우저에서 직접 보기)

JAVA
@GetMapping("/view/{filename}")
public ResponseEntity<Resource> viewInline(@PathVariable String filename) throws IOException {
    Path filePath = uploadDir.resolve(filename);
    Resource resource = new FileSystemResource(filePath);

    String contentType = Files.probeContentType(filePath);

    return ResponseEntity.ok()
        .contentType(MediaType.parseMediaType(contentType))
        .header(HttpHeaders.CONTENT_DISPOSITION,
                "inline; filename=\"" + resource.getFilename() + "\"") // inline!
        .body(resource);
}

StreamingResponseBody로 동적 파일 생성

JAVA
@GetMapping("/export/report")
public ResponseEntity<StreamingResponseBody> exportReport() {
    StreamingResponseBody stream = outputStream -> {
        // 서블릿 스레드를 점유하지 않고 데이터를 청크 단위로 작성
        try (var workbook = reportService.generateExcel()) {
            workbook.write(outputStream);
        }
    };

    return ResponseEntity.ok()
        .contentType(MediaType.parseMediaType(
            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
        .header(HttpHeaders.CONTENT_DISPOSITION,
                "attachment; filename=\"report.xlsx\"")
        .body(stream);
}

주의할 점

1. 대용량 파일을 byte[]로 읽으면 OutOfMemoryError가 발생한다

MultipartFile.getBytes()는 파일 전체를 메모리에 로드합니다. 1GB 파일을 업로드하면 1GB의 힙 메모리를 사용하며, 동시 업로드가 몇 건만 되어도 OutOfMemoryError가 발생합니다. 대용량 파일은 반드시 getInputStream()으로 스트리밍 처리하세요.

2. 파일명을 검증하지 않으면 Path Traversal 공격에 취약하다

클라이언트가 파일명을 ../../etc/passwd로 보내면, 서버의 의도하지 않은 경로에 파일이 저장되거나 기존 파일이 덮어써질 수 있습니다. getOriginalFilename()을 그대로 파일 경로에 사용하지 말고, UUID로 파일명을 생성하거나 Paths.get(filename).getFileName()으로 경로 요소를 제거해야 합니다.

3. max-file-size를 설정하지 않으면 디스크가 가득 찰 수 있다

Spring Boot의 기본 max-file-size는 1MB이지만, 이를 무제한으로 풀면 악의적인 사용자가 수 GB 파일을 반복 업로드하여 서버 디스크를 고갈시킬 수 있습니다. 서비스 요구사항에 맞는 적절한 제한을 설정하고, 업로드된 파일의 총 용량도 모니터링하세요.

정리

  • MultipartFile로 파일 업로드를 받고, transferTo()getInputStream()으로 저장합니다
  • 대용량 파일 은 getInputStream()으로 스트리밍 처리해야 메모리 부족을 방지합니다
  • 파일 다운로드는 Resource를 응답으로 반환하고, Content-Disposition 헤더로 파일명을 지정합니다
  • 경로 조작(Path Traversal) 공격 을 방지하기 위해 파일 경로를 반드시 검증해야 합니다
  • 파일 타입, 크기, 확장자를 서버 사이드에서 검증하는 것이 중요합니다
댓글 로딩 중...