대용량 다운로드 테스트

This commit is contained in:
2026-02-11 15:52:19 +09:00
parent e209eeb826
commit e15b35943b
5 changed files with 151 additions and 87 deletions

View File

@@ -1,48 +0,0 @@
package com.kamco.cd.kamcoback.common.download;
import com.kamco.cd.kamcoback.common.download.dto.DownloadSpec;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
@Service
@RequiredArgsConstructor
public class DownloadExecutor {
public ResponseEntity<StreamingResponseBody> stream(DownloadSpec spec) throws IOException {
if (!Files.isReadable(spec.filePath())) {
return ResponseEntity.notFound().build();
}
long fileSize = Files.size(spec.filePath());
StreamingResponseBody body =
os -> {
try (InputStream in = Files.newInputStream(spec.filePath())) {
in.transferTo(os);
os.flush();
} catch (Exception e) {
// 고용량은 중간 끊김 흔하니까 throw 금지
}
};
String fileName =
spec.downloadName() != null
? spec.downloadName()
: spec.filePath().getFileName().toString();
return ResponseEntity.ok()
.contentType(
spec.contentType() != null ? spec.contentType() : MediaType.APPLICATION_OCTET_STREAM)
.contentLength(fileSize)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"")
.body(body);
}
}

View File

@@ -0,0 +1,132 @@
package com.kamco.cd.kamcoback.common.download;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.ResourceRegion;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpRange;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
/**
* Range(이어받기) 지원 파일 다운로드 응답 생성기.
*
* <p>- Range 헤더 없으면 200 + Resource - Range 헤더 있으면 206 + ResourceRegion - 잘못된 Range면 416 +
* Content-Range: bytes
*
* <p>주의: - 다중 Range는 첫 번째 Range만 처리(안정성 목적). - 파일이 이미 생성되어 있는 대용량(zip 등) 다운로드에 적합.
*/
@Component
public class RangeDownloadResponder {
/** 기본(zip) 다운로드 응답 생성. */
public ResponseEntity<?> buildZipResponse(
Path filePath, String downloadFileName, HttpServletRequest request) throws IOException {
return buildResponse(
filePath, downloadFileName, MediaType.APPLICATION_OCTET_STREAM, request, Map.of());
}
/** 추가 헤더가 필요한 경우(예: X-Accel-Buffering) 사용. */
public ResponseEntity<?> buildZipResponse(
Path filePath,
String downloadFileName,
HttpServletRequest request,
Map<String, String> extraHeaders)
throws IOException {
return buildResponse(
filePath, downloadFileName, MediaType.APPLICATION_OCTET_STREAM, request, extraHeaders);
}
/** 콘텐츠 타입까지 지정하고 싶을 때 사용. */
public ResponseEntity<?> buildResponse(
Path filePath,
String downloadFileName,
MediaType contentType,
HttpServletRequest request,
Map<String, String> extraHeaders)
throws IOException {
if (!Files.isRegularFile(filePath)) {
return ResponseEntity.notFound().build();
}
long totalSize = Files.size(filePath);
Resource resource = new FileSystemResource(filePath);
// 공통 헤더(200/206 공통)
String disposition = "attachment; filename=\"" + downloadFileName + "\"";
String rangeHeader = request.getHeader(HttpHeaders.RANGE);
if (rangeHeader == null || rangeHeader.isBlank()) {
// 200 OK (전체 다운로드)
ResponseEntity.BodyBuilder ok =
ResponseEntity.ok()
.contentType(contentType)
.header(HttpHeaders.CONTENT_DISPOSITION, disposition)
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
.contentLength(totalSize);
applyExtraHeaders(ok, extraHeaders);
return ok.body(resource);
}
// Range 파싱
List<HttpRange> ranges;
try {
ranges = HttpRange.parseRanges(rangeHeader);
} catch (IllegalArgumentException ex) {
// 416 Range Not Satisfiable
ResponseEntity.BodyBuilder badRange =
ResponseEntity.status(416).header(HttpHeaders.CONTENT_RANGE, "bytes */" + totalSize);
applyExtraHeaders(badRange, extraHeaders);
return badRange.build();
}
// 실무에선 단일 Range만 처리하는 게 안전합니다.
HttpRange range = ranges.get(0);
long start = range.getRangeStart(totalSize);
long end = range.getRangeEnd(totalSize);
if (start >= totalSize) {
ResponseEntity.BodyBuilder badRange =
ResponseEntity.status(416).header(HttpHeaders.CONTENT_RANGE, "bytes */" + totalSize);
applyExtraHeaders(badRange, extraHeaders);
return badRange.build();
}
long regionLength = end - start + 1;
ResourceRegion region = new ResourceRegion(resource, start, regionLength);
// 206 Partial Content
ResponseEntity.BodyBuilder partial =
ResponseEntity.status(206)
.contentType(contentType)
.header(HttpHeaders.CONTENT_DISPOSITION, disposition)
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
.header(HttpHeaders.CONTENT_RANGE, "bytes " + start + "-" + end + "/" + totalSize)
.contentLength(regionLength);
applyExtraHeaders(partial, extraHeaders);
return partial.body(region);
}
private void applyExtraHeaders(
ResponseEntity.HeadersBuilder<?> builder, Map<String, String> extraHeaders) {
if (extraHeaders == null || extraHeaders.isEmpty()) return;
extraHeaders.forEach(
(k, v) -> {
if (k != null && !k.isBlank() && v != null) {
builder.header(k, v);
}
});
}
}

View File

@@ -1,12 +0,0 @@
package com.kamco.cd.kamcoback.common.download.dto;
import java.nio.file.Path;
import java.util.UUID;
import org.springframework.http.MediaType;
public record DownloadSpec(
UUID uuid, // 다운로드 식별(로그/정책용)
Path filePath, // 실제 파일 경로
String downloadName, // 사용자에게 보일 파일명
MediaType contentType // 보통 OCTET_STREAM
) {}