파일 업로드와 다운로드 — 대용량 파일은 어떻게 처리할까
사용자가 프로필 이미지를 업로드하거나 보고서 PDF를 다운로드할 때, 서버에서는 이 파일 데이터를 어떻게 받고 보내는 걸까요? 기가바이트 단위 파일도 처리할 수 있을까요?
개념 정의
스프링 MVC에서 파일 업로드 는 HTTP multipart/form-data 요청을 MultipartFile로 받아 처리하는 것이고, 파일 다운로드 는 Resource 객체를 HTTP 응답으로 스트리밍하는 것입니다.
왜 필요한가
파일 처리는 일반 JSON API와 다릅니다. 파일은 바이너리 데이터이고 크기가 클 수 있으므로, 메모리 관리, 용량 제한, 스트리밍 처리를 고려해야 합니다. 잘못 처리하면 OutOfMemoryError가 발생하거나 서버가 다운될 수 있습니다.
내부 동작
Multipart 요청 처리 흐름
1. 클라이언트가 Content-Type: multipart/form-data로 요청
2. MultipartResolver가 요청을 파싱
3. 파일 데이터를 임시 위치에 저장 (디스크 or 메모리)
4. MultipartFile 객체로 컨트롤러에 전달
5. 컨트롤러에서 파일 처리 (저장, 변환 등)
6. 요청 완료 후 임시 파일 정리
기본 설정
spring:
servlet:
multipart:
enabled: true # Multipart 활성화 (기본 true)
max-file-size: 10MB # 단일 파일 최대 크기 (기본 1MB)
max-request-size: 50MB # 전체 요청 최대 크기 (기본 10MB)
file-size-threshold: 2KB # 메모리에 유지하는 임계값 (초과 시 디스크)
location: /tmp # 임시 파일 저장 위치
코드 예제
기본 파일 업로드
@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();
이어서 응답 객체를 구성하여 클라이언트에 반환하는 부분입니다.
// 고유 파일명 생성
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) : "";
}
}
다중 파일 업로드
@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 함께 받기
@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));
}
파일 타입 검증
@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);
}
이어서 응답 객체를 구성하여 클라이언트에 반환하는 부분입니다.
// 파일 크기 추가 검증
if (file.getSize() > 5 * 1024 * 1024) { // 5MB
throw new BadRequestException("파일 크기는 5MB 이하여야 합니다");
}
// 저장
return ResponseEntity.ok(fileService.store(file));
}
대용량 파일 스트리밍 업로드
@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()));
}
파일 다운로드
@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);
}
이어서 응답 객체를 구성하여 클라이언트에 반환하는 부분입니다.
// 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);
}
인라인 표시 (브라우저에서 직접 보기)
@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로 동적 파일 생성
@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) 공격 을 방지하기 위해 파일 경로를 반드시 검증해야 합니다
- 파일 타입, 크기, 확장자를 서버 사이드에서 검증하는 것이 중요합니다