TIL

Java 파일 다운로드 OutOfMemoryError 해결방법(전체 예시)

빈코 2025. 1. 11. 15:31

개요

안녕하세요 빈코입니다. 오늘은 저번 포스팅에 이어 Java에서 파일 다운로드 시 메모리 부족으로 인해 생기는 OutOfMemoryError에 대해 전반적인 코드를 소개하려 합니다. 저번 포스팅과 중복되는 부분도 있겠지만, 해당 포스팅만 보시는 분들을 위해 전체적으로 다 소개하려 합니다. 코드는 하단에서 설명할게요😁

 

설정📚

저번 포스팅에서 언급했듯이 Java에서 StreamingResponseBody를 사용하려면 여러 가지 설정이 필요합니다. 첫 번째로 javax.servlet-api 버전이 3.0 이상이어야 합니다. Maven이나 Gradle에 아래와 같이 추가해주세요😁

// Maven
<dependency>
    <groupId>javax.servlet</groupId>
    <artifactId>javax.servlet-api</artifactId>
    <version>4.0.1</version> <!-- 3.0 이상 -->
    <scope>provided</scope>
</dependency>

 

// Gradle
dependencies {
    implementation 'javax.servlet:javax.servlet-api:4.0.1' // 3.0 이상
}

 

 

두 번째로는 <async-supported> 설정이 필요합니다. 해당 설정은 비동기 처리 방식을 지원하기 위해 설정하는 것이기 때문에 Servlet과 Filter에 설정이 필요합니다. 만약 Servlet과 Filter 모두 사용한다면 꼭 두 곳 전부 적용해주셔야 합니다.

// 예시
<servlet>
    <servlet-name>asyncServlet</servlet-name>
    <servlet-class>com.example.MyAsyncServlet</servlet-class>
    <async-supported>true</async-supported> <!-- 비동기 지원 활성화 -->
</servlet>

 

마지막으로 web.xml 상단에 있는 web-app 버전을 <async-supported> 옵션 인식하기 위해 3.0 이상으로 설정해줘야 합니다.

<web-app xmlns="http://java.sun.com/xml/ns/javaee" 
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://java.sun.com/xml/ns/javaee 
         http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
         version="3.0">
    <!-- 서블릿 및 필터 설정 -->
</web-app>

 

Controller📙

@RequestMapping(value = "/download/file", method = RequestMethod.GET)
public StreamingResponseBody fileDownloadTest(HttpServletRequest request, @RequestParam Map<String, Object> param) throws Exception {
   ListenableFuture<StreamingResponseBody> result =downloadService.fileDownloadTest(param);

   return result.get(5, TimeUnit.MINUTES);
}

 

Controller는 클라이언트가 HTTP GET 요청으로 "/download/file"로 보낼 때 fileDownloadTest 메서드가 실행되게 구현했습니다. param에는 하단 js에서 넘겨주는 파일 이름(file_name)과 파일 경로(file_path)가 들어가 있으며, 해당 값을 downloadService에 넘겨줍니다. 

 

해당 다운로드 방식은 스트리밍 방식이기 때문에 비동기 처리를 지원하는 ListenableFuture 객체를 사용하였고, 비동기 작업이 완료되면 StreamingResponseBody 결과를 result에 담아서 return 합니다.

 

해당 파일이 특정 오류로 인해 다운로드가 안될 수 있기 때문에 timeout을 이용하여 최대 5분 동안 기다리게 구현하였고, 다운로드 실패 시 TimeoutException이 발생하게 됩니다.

 

ServiceImpl📘

@Override
public ListenableFuture<StreamingResponseBody> fileDownTest(Map<String, Object> param) throws Exception {
   if (param.containsKey("file_name") && param.containsKey("file_path")) {
      String fileName = param.get("file_name").toString();
      String filePath = param.get("file_path").toString();
      
      if (fileName!=null && filePath!=null) {
         InputStream inputStream = Files.newInputStream(Paths.get(filePath+"/"+fileName));

         StreamingResponseBody streamingResponseBody = outputStream -> {
            FileCopyUtils.copy(inputStream, outputStream);
         };
         return new AsyncResult<>(streamingResponseBody);
      }
   }
   return null;
}

 

DownloadService에 정의하는 부분은 생략하고 DownloadServiceImpl 코드를 보면, 첫 번째로 DownloadService에 정의된 fileDownTest 메서드를 Override 합니다(재정의 하기 위함)

 

메서드 선언부에 작성 된 ListenableFuture로 선언한 이유는 이전에 설명하였듯이 Spring에서 비동기 작업의 결과를 처리하는 객체이기 때문이며 결과 값은 StreamingResponseBody로 클라이언트에게 스트리밍 방식으로 전달합니다. param은 Controller에서 넘어온 값이며 해당 Map 안에는 파일 경로와 파일 이름이 있습니다.

 

두 번째로는 param안에 실제로 파일 경로와 파일 이름에 대한 key가 존재하는지 체크하고, 존재한다면 해당 값들을 각각 fileName과 filepath 변수에 담습니다. 잘못된 요청을 방지하기 위해 각 변수를 null 체크를 진행하고 두 개의 값이 모두 null이 아닐 때 파일 다운로드를 계속 진행합니다.

 

jsp📒

<a class="download_test">
   <span id="download_test">Download File</span>
</a>

 

이제 클라이언트에게 보일 다운로드 버튼을 만들어야 합니다. 테스트용이기 때문에 간단하게 링크 형식으로 진행하였고, 스크립트에서 주요 로직이 진행 될 예정입니다😊

 

js📗

document.getElementById('download_test').addEventListener('click', async () => {
   try {
      const response = await fetch('/download/file?file_name="test"&file_path="/test"'); 
      if (!response.ok) {
         throw new Error('파일 다운로드에 실패했습니다.');
      }

      // 스트리밍 데이터를 Blob으로 변환
      const contentType = response.headers.get('Content-Type');
      const blob = await response.blob();

      // Blob을 링크로 변환하여 다운로드 트리거
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;

      a.download = 'test';
      a.click();

      // 메모리 해제
      URL.revokeObjectURL(url);
   } catch (error) {
      console.error('파일 다운로드 중 오류 발생:', error);
   }
});

 

script에서는 이벤트 리스너를 등록합니다. jsp에서 설정한 id가 download_test를 클라이언트가 클릭하면 이벤트가 발생하여 위 메서드를 실행하게 됩니다. 이때 콜백 함수는 비동기 처리 방식으로 진행되어야 하기 때문에 async 키워드를 사용했습니다.

 

fetch는 서버로 보내는 HTTP 요청입니다. 기본적으로 GET 요청을 보내기 때문에 따로 HTTP 요청 방식(GET, POST 등)에 대한 코드를 구성할 필요가 없으며, 이전에 Controller에 설정한 URL로 요청을 보내도록 구현했습니다. await은 요청이 완료될 때 까지 즉, 파일이 모두 다운로드 받을 때까지 유지하는 역할을 합니다. 다운로드가 모두 끝났으면 해당 결과를 response에 담습니다.

 

그다음은 응답 상태 코드가 200~299(성공 범위)에 속하면 response.ok는 true, 속하지 않으면 false를 반환하기 때문에 해당 조건을 통과하지 못했을 경우에는 에러를 생성합니다.

 

성공적으로 다운로드가 진행되었다면, 스트리밍 데이터를 Blob으로 변환하는 과정을 거칩니다. Blob은 바이너리 데이터를 다루기 위한 JavaScript 객체입니다.

 

URL.createObjectURL(blob) 코드를 통해 Blob 데이터를 브라우저에서 직접 사용할 수 있는 URL로 변환하는데, 해당 URL은 메모리에서만 유효합니다. 이후에, document.createElement 메서드를 이용하여 동적으로 <a> 태그를 생성 후 해당 href 속성을 위에서 만든 URL로 설정합니다.

 

마지막으로 a.download로 파일 이름을 'test'로 설정합니다. 이 값은 클라이언트의 브라우저에 보일 파일 이름입니다. 그리고 다운로드 트리거를 생성하는데 동적으로 만든 URL을 클릭합니다.

 

모든 다운로드 과정이 끝났으면 매모리 해제를 위해 URL.revokeObjectURL(url) 함수를 실행합니다. 해당 과정이 없으면 브라우저가 생성한 URL은 제한된 메모리를 사용하므로 메모리 누수가 일어나기 때문에 반드시 해제하는 것이 좋습니다😁

 

마치며

지금까지 Java에서 파일 다운로드를 스트리밍 방식으로 진행하는 방법에 대해 알아보았습니다. 해당 방식은 대용량 파일을 다운로드할 때 제한적인 메모리로 인해 발생하는 오류를 방지할 수 있기 때문에 숙지하셔서 사용해 보면 좋을 것 같습니다. 설정 방법부터 back 단 까지 모두 예시를 들었기 때문에 천천히 따라 해보세요😊

반응형