Backend · Infra

ajax로 다운로드가 안될 때 : Uncaught InvalidStateError: Failed to read the 'responseText' property from 'XMLHttpRequest': The value is only accessible if the object's 'responseType' is '' or 'text' (was 'blob').

devhyen 2024. 10. 22. 14:13

 

파일 다운로드를 하는 방법은 여러가지가 있는데, 

가장 간단하게는 

location.href = 'api 주소';

 

로 구현할 수 있다. 

 

제이쿼리를 쓰고 있는 프로젝트였고, 파일의 크기가 클 수 있기 때문에 

사용자들이 기다리는 것을 위해서 spinner 를 다운로드 받는 중에 노출 시키도록 변경 하려고 했다. 

단순히 링크만 보내는 것은 파일이 다운로드가 완료 된 후에 spinner를 끄는 것이 불가하기 때문에 

ajax를 사용하기로 한다. 

 

일반적으로 Ajax 요청은 JSON,XML,HTML과 같은 텍스트 데이터를 처리하지만, 파일 다운로드의 경우 바이너리데이터(blob)으로 응답이 전달된다. 

그렇기 때문에 

xhrFields: {
    responseType: 'blob'
}

 

와 같은 설정이 필요하다. 

 

$.ajax({
                    url: /download?${filePaths},
                    method: 'GET',
                    xhrFields: {
                        responseType: 'blob',
                        withCredentials: true  // 쿠키를 포함하여 요청
                    },
                    success: function(data, status, xhr) {
                        // 파일 이름 추출 (백엔드의 Content-Disposition 헤더 사용)
                        var filename = "";
                        var disposition = xhr.getResponseHeader('Content-Disposition');
                        if (disposition && disposition.indexOf('attachment') !== -1) {
                            var matches = /filename="([^"]*)"/.exec(disposition);
                            if (matches != null && matches[1]) {
                                filename = matches[1];
                            }
                        }

                        // Blob을 사용해 파일 다운로드
                        var blob = data;  // responseType이 blob일 때, data가 이미 blob으로 전달됨
                        var link = document.createElement('a');
                        link.href = window.URL.createObjectURL(blob);
                        link.download = filename || 'download.zip';  // 기본 파일 이름 설정
                        link.click();
                    },
                    error: function() {
                        alert("File download failed.");
                    }
                });

            },
            error: function(xhr, status, error) {
                console.error('Error:', error);
            }
        })
    });

 

위와 같은 코드를 작성하였는데, 

 

Uncaught InvalidStateError: Failed to read the 'responseText' property from 'XMLHttpRequest': The value is only accessible if the object's 'responseType' is '' or 'text' (was 'blob'). at XMLHttpRequest.c (jquery.min:3:26787)

 

에러발생..

 

찾아보니 jquery 버전의 문제였다. 

jquery 1.12.1 버전을 3.3.1버전으로 변경하게 되면 문제가 발생하지 않는다고 한다. 

 

하지만, 나 혼자 하는 프로젝트가 아니다 보니 

jquery 버전을 올릴 수는 없고, 

fetch방식으로 변경하게 되었다. 

fetch('/download')
    .then(response => {
        if (!response.ok) {
            throw new Error('Network response was not ok');
        }
        return response.blob(); // Blob으로 응답을 처리
    })
    .then(blob => {
        const url = window.URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = 'yourfile.zip'; // 원하는 파일 이름 지정
        document.body.appendChild(a);
        a.click();
        a.remove();
    })
    .catch(error => console.error('There has been a problem with your fetch operation:', error));

fetch 방식으로 변경하다 보니 그래도 에러가 계속 발생함 

디버깅을 해보니, 서버측에서 발생하는 오류로 

Could not write content: No serializer found for class java.io.FileDescriptor and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) ) (through reference chain: org.springframework.core.io.InputStreamResource["inputStream"]->java.io.FileInputStream["fd"])

 

이 오류는 Spring이 InputStreamResource에서 InputStream을 직렬화 하려고 시도하면서 발생한 오류이다. 

Spring이 직렬화할 수 없는 객체를 포함하고 있어서 나타나는 문제다..

 

href.location 방식에서는 나타나지 않던 문제가 fetch API에서는 발생한건가? 

  • fetch를 사용하는 경우 자바스크립트로 HTTP 요청을 보내고, 서버로부터 받은 응답을 수동으로 처리해야한다.
  • fetch는 JSON응답을 처리하기 위해 설계된 API로 기본적으로 JSON으로 변환하려고 시도한다.
  • 이 과정에서 InputStreamResource 같은 스트리밍 데이터를 처리할 때 문제가 발생할 수 있다.

반면, href를 동해 하게 된다면, 응답에 Content-Disposition 헤더가 포함되어 있어 브라우저가 파일을 다운로드를 처리할 수 있다. 

 

수정된 서버 코드 

public void downloadZip(HttpServletResponse response) {
        Path zipFilePath;

        try {
            // Zip 파일 생성
            zipFilePath = fileService.createZipFile("filepath");

            // 파일이 존재하는지 확인
            if (!Files.exists(zipFilePath)) {
                response.sendError(HttpServletResponse.SC_NOT_FOUND, "Zip file not found: " + zipFilePath.toString());
                return;
            }

            File zipFile = zipFilePath.toFile();
            try (FileInputStream fileInputStream = new FileInputStream(zipFile);
                 OutputStream outputStream = response.getOutputStream()) {

                // 응답 헤더 설정
                response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + new String(zipFile.getName().getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1) + "\"");
                response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);

                // 파일 내용을 스트리밍
                byte[] buffer = new byte[4096];
                int bytesRead;
                while ((bytesRead = fileInputStream.read(buffer)) != -1) {
                    outputStream.write(buffer, 0, bytesRead);
                }
                outputStream.flush();
            }
        } catch (IOException e) {
            // IO 예외 처리
            response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Failed to download zip file: " + e.getMessage());
        } catch (Exception e) {
            // 일반 예외 처리
            response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "An unexpected error occurred: " + e.getMessage());
        }
    }
}

 

클라이언트로 파일 전송을 위해 HttpServletResponse를 사용하여 응답을 직접 설정했다. 

원래 문제가 발생한 이유 

  • InputStreamResource로 zip 파일을 응답으로 반환할 때, Spring이 JSON으로 직렬화 하려고 시도함
  • InputStreamResource내부에서 사용되는 FileInputStream은 직렬화 할 수 없는 클래스
  • 그 과정에서 오류가 발생

수정된 원리

  • HttpServletResponse를 사용하여 직접 응답을 구성하여 Spring의 JSON 직렬화 하는 것을 우회할 수 있다.
  • 응답을 직접 설정하므로 파일을 스트리밍 할 수 있고, 이 과정에서 Spring은 JSON으로 직렬화 하려고 시도하지 않는다. 
  • FileInputStream을 사용하여 Zip파일 내용을 읽고, OutputStream을 통해 클라이언트에 직접 전송한다. 메모리 사용이 효율적이다.
  • try-with-resources 를 사용하여 자동으로 자원을 닫아 메모리 누수를 방지한다.