엄청 커다란 파일 다운로드

2025. 11. 23. 23:36·개발 공부/Spring

만약 서버 → 클라이언트로 큰 크기의 파일을 생성해서 반환해야 된다고 생각해보자. 예를 들어 전체 유저에 대한 통계 데이터를 생성 + 압축해서 클라이언트에게 반환해야 한다고 해보자. 만약 완성된 전체 데이터의 크기가 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 을 초과하게 되면 여전히 클라이언트는 파일을 정상적으로 받을 수 없다.

파일 다운로드 도중 timeout exception 이 발생하는 예제 케이스

 

이런 경우 스트리밍 반환을 클라에게 하지 말고 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 -> 서버 -> 클라 구조로 반환도 가능할 것 같다.

 

이 밖에도 다양한 방법이 가능할 것 같은데, 이 정도면 현재 요구사항을 만족할 수 있을 것 같아서 만약 다른 요구사항이 요구되거나 이 방법으로 요구사항이 충족되지 못한다면 그때 또 고민해보면 될 것 같다.

 

'개발 공부 > Spring' 카테고리의 다른 글

테스트에서만 @Async 적용되지 않도록 하기  (0) 2023.12.27
[플랭고] 주요/부가 로직 트랜잭션 분리하기 - TransactionalEventListener와 REQUIRES_NEW  (0) 2023.11.27
[플랭고] JPA delete() 쿼리 안 나가는 문제 해결  (0) 2023.09.28
[플랭고] JPQL fetch join + where절 사용 방법과 조건  (0) 2023.08.29
[플랭고] 일대일에서 일대다로 변경 시 validation 관련 문제 (Custom ConstraintValidator)  (0) 2023.08.23
'개발 공부/Spring' 카테고리의 다른 글
  • 테스트에서만 @Async 적용되지 않도록 하기
  • [플랭고] 주요/부가 로직 트랜잭션 분리하기 - TransactionalEventListener와 REQUIRES_NEW
  • [플랭고] JPA delete() 쿼리 안 나가는 문제 해결
  • [플랭고] JPQL fetch join + where절 사용 방법과 조건
gmelon
gmelon
백엔드 개발을 공부하고 있습니다.
  • gmelon
    gmelon's greenhouse
    gmelon
  • 전체
    오늘
    어제
    • 분류 전체보기 (93) N
      • 개발 공부 (30) N
        • Java (6)
        • Spring (11)
        • 알고리즘 (11)
        • 기타 (2) N
      • 프로젝트 (12)
        • [앱] 플랭고 (4)
        • 졸업 프로젝트 (8)
      • 스터디 (0)
        • 자바 (30)
      • 기록 (15)
        • 후기, 회고 (9)
        • SSAFYcial (5)
        • 이것저것 (1)
      • etc. (6)
        • 모각코 (6)
  • 블로그 메뉴

    • 홈
    • 방명록
    • github
    • 스크랩
  • 인기 글

  • 태그

    Java Collector
    groupingBy()
    한글프로그래밍언어
    2024 상반기 회고
    프리티어 종료
    태초마을이야
    java
    싸피 회고
    Collector groupingBy()
    2024 회고
    AWS 프리티어 종료
    자바
    졸업프로젝트
    자바 Collector
    groupingBy mapping
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
gmelon
엄청 커다란 파일 다운로드
상단으로

티스토리툴바