만약 서버 → 클라이언트로 큰 크기의 파일을 생성해서 반환해야 된다고 생각해보자. 예를 들어 전체 유저에 대한 통계 데이터를 생성 + 압축해서 클라이언트에게 반환해야 한다고 해보자. 만약 완성된 전체 데이터의 크기가 3GB 라고 하면 메모리가 3GB 이하인 (혹은 그 이상이어도) 서버는 버티지 못하고 OOM 으로 죽어버릴 것이다. 또한 통계성으로 유저에 대한 이런저런 정보를 모두 모아야 해서, 생성하는데 시간이 오래걸리는 데이터라면 timeout 도 발생할 가능성이 있다. 이러한 문제를 어떻게 해결하면 좋을까??

1. 스트리밍 다운로드
가장 먼저 생각난 해결책은 스트리밍으로 파일을 반환하는 방법이다. 일반적으로라면 아래의 과정을 따를텐데,
1. 전체 유저에 대한 데이터 생성
2. `.zip` 으로 압축
3. 클라에게 반환
이걸 아래와 같이 변경하는 것이다.
1. 한명의 유저에 대한 데이터 생성
2. 스트리밍으로 바로 클라에게 반환
3. 모든 유저에 대해 `1~2` 반복 (이전 유저에 대한 데이터는 GC 가능)
코드로 보면 아래와 같다.
@RestController
@RequestMapping("/download")
@Slf4j
public class DownloadTestController {
/**
* 스트리밍 방식 - 1000명 유저 데이터를 ZIP으로 압축하면서 즉시 전송
*/
@GetMapping("/streaming")
public ResponseEntity<StreamingResponseBody> downloadStreaming() {
log.info("=== 스트리밍 다운로드 시작 ===");
long startMemory = getMemoryUsage();
long startTime = System.currentTimeMillis();
log.info("시작 메모리: {}MB", startMemory);
StreamingResponseBody stream = outputStream -> {
try (ZipOutputStream zipOut = new ZipOutputStream(outputStream)) {
zipOut.setLevel(Deflater.BEST_SPEED);
int userCount = 1000;
for (int i = 1; i <= userCount; i++) {
String userId = String.format("USER_%04d", i);
// ZIP 엔트리 추가
ZipEntry entry = new ZipEntry(userId + ".dat");
zipOut.putNextEntry(entry);
// 2MB 데이터 생성하면서 즉시 압축
byte[] buffer = new byte[2 * 1024 * 1024];
new Random().nextBytes(buffer);
zipOut.write(buffer); // 즉시 압축되고 전송됨
zipOut.closeEntry();
if (i % 100 == 0) {
long currentMemory = getMemoryUsage();
log.info("진행: {}/{}명, 현재 메모리: {}MB, 증가량: {}MB", i, userCount, currentMemory, currentMemory - startMemory);
}
}
long endTime = System.currentTimeMillis();
long endMemory = getMemoryUsage();
log.info("=== 스트리밍 완료 ===");
log.info("소요시간: {}초", (endTime - startTime) / 1000);
log.info("시작 메모리: {}MB", startMemory);
log.info("종료 메모리: {}MB", endMemory);
log.info("메모리 증가: {}MB", endMemory - startMemory);
}
};
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"users-streaming.zip\"")
.contentType(MediaType.parseMediaType("application/zip"))
.body(stream);
}
/**
* 비스트리밍 방식 - 1000명 유저 데이터를 전부 메모리에서 ZIP으로 압축 후 한번에 반환
*/
@GetMapping("/non-streaming")
public ResponseEntity<byte[]> downloadNonStreaming() {
log.info("=== 비스트리밍 다운로드 시작 ===");
long startMemory = getMemoryUsage();
long startTime = System.currentTimeMillis();
log.info("시작 메모리: {}MB", startMemory);
try {
// 메모리에 전체 ZIP 생성
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zipOut = new ZipOutputStream(baos)) {
zipOut.setLevel(Deflater.BEST_SPEED);
int userCount = 1000;
for (int i = 1; i <= userCount; i++) {
String userId = String.format("USER_%04d", i);
// ZIP 엔트리 추가
ZipEntry entry = new ZipEntry(userId + ".dat");
zipOut.putNextEntry(entry);
// 2MB 데이터 생성
byte[] buffer = new byte[2 * 1024 * 1024];
new Random().nextBytes(buffer);
zipOut.write(buffer); // 메모리에 계속 쌓임
zipOut.closeEntry();
if (i % 100 == 0) {
long currentMemory = getMemoryUsage();
log.info("진행: {}/{}명, 현재 메모리: {}MB, 증가량: {}MB", i, userCount, currentMemory, currentMemory - startMemory);
}
}
}
// 완성된 ZIP을 byte[]로 변환
byte[] zipData = baos.toByteArray();
long endTime = System.currentTimeMillis();
long endMemory = getMemoryUsage();
log.info("=== 비스트리밍 완료 ===");
log.info("ZIP 크기: {}MB", zipData.length / 1024 / 1024);
log.info("소요시간: {}초", (endTime - startTime) / 1000);
log.info("시작 메모리: {}MB", startMemory);
log.info("종료 메모리: {}MB", endMemory);
log.info("메모리 증가: {}MB", endMemory - startMemory);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"users-non-streaming.zip\"")
.contentType(MediaType.parseMediaType("application/zip"))
.body(zipData);
} catch (OutOfMemoryError | IOException e) {
long errorMemory = getMemoryUsage();
log.error("OutOfMemoryError 발생! 현재 메모리: {}MB", errorMemory);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
private long getMemoryUsage() {
Runtime runtime = Runtime.getRuntime();
return (runtime.totalMemory() - runtime.freeMemory()) / 1024 / 1024;
}
}
각각에 대한 결과를 보면,
스트리밍 방식
스트리밍 방식은 우선 다운로드 시 전체 용량을 알지 못하므로 프로그레스바가 이렇게 빙글빙글 돌게 된다.

메모리 사용량 변화를 보면, 조금씩 변화는 있지만 대략 300MB 를 넘지 않는 것을 알 수 있다.
(GC 나 메모리 설정에 따라 점진적으로 증가하다가 GC 가 되어 확 줄어드는 모습이 반복되는 케이스도 있었음)

비스트리밍 방식
반면 비스트리밍 방식은 아래와 같이 메모리가 대책없이 계속 증가하는 것을 볼 수 있다.

만약 JVM의 메모리 제한이 2GB 였다면, 파일을 클라이언트로 반환하다가 중간에 터졌을 것이다. 중간에 한번 GC 가 되긴 했지만 어쨌든 다운로드 받아야 하는 `.zip` 파일 자체는 메모리에 계속 올려두고 있어야 하므로 스트리밍과 같은 효율적인 GC 는 불가능할 것으로 보인다.

2. S3 에 스트리밍으로 업로드 + 클라에는 Presigned URL 만 전달
스트리밍 방식으로 메모리 제약은 극복했지만, timeout 제약은 여전히 극복이 불가능하다. 즉, 통계 데이터 계산에 시간이 오래걸려 설정된 tomcat / aws 등의 timeout 을 초과하게 되면 여전히 클라이언트는 파일을 정상적으로 받을 수 없다.

이런 경우 스트리밍 반환을 클라에게 하지 말고 S3 등의 저장소에 하는 방법이 가능할 것 같다.
프로세스
주요 프로세스는 아래와 같다.
1. 클라가 서버에게 파일 다운로드 요청을 보낸다.
2. 서버는 요청을 저장하고, 클라에게 바로 성공 응답을 보낸다.
3. 서버는 데이터를 생성하면서, S3 에 스트리밍 방식으로 데이터를 업로드 한다.
4. 업로드가 완료되면 앞서 저장해둔 요청 정보와 S3 에 저장된 데이터를 연결해주고, 요청의 상태를 완료로 변경한다. (sse 나, 롱 폴링, 이메일 등으로 완료 여부를 클라에게 전달)
5. 클라에서 작업이 완료됨을 인지하면 서버에게 presigned url 을 요청하고, 서버가 반환해준 url 을 통해 파일을 다운로드 받는다.
요청 엔티티 예제
요청을 저장하는 방법은 여러 가지가 있겠지만, 만약 단순히 db 에 저장한다고 하면 아래와 같은 엔티티를 사용할 수 있을 것 같다.
@Entity
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DownloadRequest {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private User user;
@Enumerated(EnumType.STRING)
private DownloadType type;
@Enumerated(EnumType.STRING)
private DownloadStatus status;
// s3 관련 데이터들 (필요하면 테이블 분리도 가능)
private String fileName;
pricate String objectKey;
// createdAt, updatedAt 정보들
public enum DownloadType {
STATISTICS
}
public enum DownloadStatus {
PENDING,
PROCESSING,
COMPLETED,
FAILED
}
}
클라에서 처음에 요청을 보내면 DownloadStatus 가 PENDING 으로 생성이 되고, 작업이 시작되면 PROCESSING 으로 변경된 후 완료가 되면 COMPLETED 나 FAILED 로 변경되는 식이다.
완료된 요청을 확인하고 클라에서 서버에게 파일을 요청하면 서버는 objectKey 를 통해 파일에 대한 Presigned URL 를 생성해 클라에게 전달할 수 있다. 또한 원한다면 스트리밍 방식으로 S3 -> 서버 -> 클라 구조로 반환도 가능할 것 같다.
이 밖에도 다양한 방법이 가능할 것 같은데, 이 정도면 현재 요구사항을 만족할 수 있을 것 같아서 만약 다른 요구사항이 요구되거나 이 방법으로 요구사항이 충족되지 못한다면 그때 또 고민해보면 될 것 같다.
