Problem Drilling

브라우저에서 Excel 파일 다운로드하기: Blob과 CORS 해결과정

남희정 2024. 4. 22. 21:19
엑셀 데이터를 바이너리로 전달해드릴거에요. 

 

엑셀 데이터를 바이너리로..? 사수분께서 엑셀 파일로 변환된 데이터를 바이너리로 전달해준다고 하셨다. 구현하면서 배운 부분이 많아, 해당 부분을 공유하고 싶었어서 이 기회로 더욱 깊이 공부해보고 정리해보았다. 

 

예를 들어 간단한 설문조사 결과가 담긴 데이터를 관리하는 사용자가 데이터를 출력하기 위해 Excel 파일을 필요로 한다고 가정한다. 사용자는 내보내고 싶은 결과를 선택할 수 있어야하고 해당 데이터를 서버로 요청을 보내고, 응답으로 Excel 파일을 다운 받을 수 있어야한다. 이때 응답을 바이너리 데이터로 받는다는 말이다. 어떻게 해야할지 천천히 알아보자.

 

Blob : Binary Large Object

하나의 Entity로서 저장되는 바이너리 데이터의 모임. 일반적으로 그림, 오디오와 같은 멀티미디어 파일 바이너리를 객체 형태로 저장한 것을 의미한다. 기가바이트에 달하는 큰 데이터 조각인 경우가 많다.

 

최초의 Blob은 짐 스타키가 발명한 무정형의 큰 데이터 덩어리였다. 자료형과 정의는 전통적인 컴퓨터 데이터베이스 시스템에 본래 정의되지 않은 데이터를 기술하기 위해 도입되었다. 당시 저장하려는 크기가 너무 컸기 때문에 1970년대와 1980년대에 데이터베이스 시스템의 필드에 처음 정의되었다. 기술이 발전함에 따라 디스크 공간이 점점 저렴해지면서 사용되게 되었다.

 

- 비디오, 오디오, 이미지, 그래픽, XML 파일 등

 

Blob을 사용하면 분산된 데이터를 마치 하나의 연속된 블록처럼 취급하고 처리할 수 있다.

 

Blob의 구성

⚫️ blobParts : Blob, BufferSource, String 값의 배열 

⚫️ options

    ⚪️ type : Blob타입, MIME-type

    ⚪️ ending : 줄바꿈은 현재 OS의 새 줄(\r\n 또는 \n)에 맞게 변환할지 여부를 결정,
        "transparent"가 default이고 "native"로 변환 설정도 가능

new Blob(blobParts, options);

// example
const blob = new Blob([JSON.stringify(obj, null, 2)], {
  type: "application/json",
});

 

Blob 객체는 파일시스템의 경로 대신 메모리에 바이너리 데이터를 직접 저장하여 사용하므로, 웹 브라우저에서도 서버와의 통신 없이 데이터를 빠르게 처리할 수 있다. 이 부분은 아래에서 좀 더 자세히 설명하겠다!

 

Blob이 어떤 타입인지 알았으니 본격적으로 백엔드에서 보내주는 Blob을 받아보자! 어떻게 받을 수 있을까?

우선 요청시 정말 중요한 부분이 있다. 

responseType 지정으로 MIME Type을 "blob"으로 해준다.

만약 axios를 쓴다면 POST 요청의 config에 해당하는 부분에 지정해주어야 한다.

post<T = any, R = AxiosResponse<T>, D = any>(url: string, data?: D, config?: AxiosRequestConfig<D>): Promise<R>;

// ResponseType
export type ResponseType =
    | 'arraybuffer'
    | 'blob'
    | 'document'
    | 'json'
    | 'text' // 지정해주지 않으면 Default
    | 'stream';

 

Blob을 응답 받는다고 하면, 그것을 또 어떻게 다운로드할 수 있을까?

다운로드가 가능한 Blob url을 만들어서 DOM에 연결해줄 수 있다!

 

✔️ URL.createObjectURL

URL.createObjectURL 메서드를 이용하면 Blob 객체로 고유한 URL을 생성할 수 있다. 이때 생성되는 URL의 형태는 blob:/의 형태를 띄게 된다. 그리고 변환된 URL은 src를 속성으로 가지는 모든 HTML 태그와 CSS 속성에서 사용 가능하다.

 

const blobUrl = window.URL.createObjectURL(newBlob);
console.log(blobUrl); // blob:xxxxxx-xxxxxxx-xxxx-xxx

 

변환된 URL은 현재 탭의 브라우저 메모리에 저장되고, 저장된 URL은 매핑된 Blob 객체를 참고하고 있는 형태다. 이런 원리 때문에 짧은 문자열만으로도 원래의 Blob 객체에 접근이 가능하고 그에 따른 파일을 가져올 수 있다. 변환된 URL은 항상 현재 문서에서만 유효하다. 변환된 URL은 현재 문서를 새로고침 하거나 다른 페이지에서 사용하려고 한다면 제대로 사용할 수 없다.

 

✔️ URL.revokeObjectURL

Blob 객체가 URL로 변환되어 매핑이 이루어진 채 메모리에 저장되게 되면, 명시적으로 해당 URL이 해제되기 전까지 브라우저는 해당 URL이 유효하다고 판단한다. 따라서 가비지 컬렉션 이 이루어지지 않는다. 따라서 blob URL 생성 후 더이상 사용하지 않을 시점이라고 판단되면 명시적으로 해제해주는 것이 좋다.

 

다운로드를 위해 blob을 사용한다면, 동적으로 생성한 Blob으로 오직 다운로드 순간에만 필요하고 그 이후엔 필요하지 않기 때문에 즉시 해제를 통해 메모리 누수를 방지할 수 있다.

 

import axios from 'axios';

const sendPostRequest = (url, data) => {
  axios({
    method: 'post',
    url: url,
    data: data,
    responseType: 'blob'  // blob으로 설정 필수
  })
  .then((res) => {
    const url = window.URL.createObjectURL(new Blob([res.data])); // ✔️
    const link = document.createElement('a');
    link.href = url;
    link.setAttribute('download', 파일이름 ); 
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
    window.URL.revokeObjectURL(url); // ✔️
  })
  .catch((error) => {
    console.error('Request failed:', error);
  });
};

 

위의 코드가 제일 기본으로 Blob 데이터를 응답받는 방식이다. 

 

파일 이름을 백엔드에서 지정해주는 파일 이름으로 받고 싶다..!

여기까지 구현이 쉬웠다. 다운로드도 잘 되었고! 다만, 추가적으로 백엔드에서 지정해준 파일명을 받아오고 싶었다. Talend API TESTER에서도 `EXPORT.xlsx` 이런 식으로 받아지고 있었기에 의아했지만 시도를 해보았다.

 

Response Headers

 

백엔드에서 filename은 header의 content-disposition영역에서 확인할 수 있었다. 네트워크 탭에서 분명히 명시되어있다. (사진은 임의의 파일 응답 헤더를 확인한 것이다.)

 

응답을 받을 때 헤더의 content-disposition에 지정된 `filename=` 영역을 추출하여 다운로드를 시도했다.

import axios from 'axios';

const sendPostRequest = (url, data) => {
  axios({
    method: 'post',
    url: url,
    data: data,
    responseType: 'blob' 
  })
  .then((res) => {
    const url = window.URL.createObjectURL(new Blob([res.data]));
    const link = document.createElement('a');
	// filename 가져오기
	const fileName = res.headers["content-disposition"].match(/filename="?(.+)"?/)[1];
    link.href = url;
    link.setAttribute('download', fileName ); 
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link); 
  })
  .catch((error) => {
    console.error('Request failed:', error);
  });
};

 

그런데 계속 undefined로 찍히는 것이다. 왜!

 

확인해보니 CORS 문제였다. 일반적으로 접근이 허용되는 res.header에 해당되지 않는 영역이었다.

CORS 허용 목록에 있는 응답 헤더

 

⚫️ Cache-Control

⚫️ Content-Language

⚫️ Content-Length

⚫️ Content-Type

⚫️ Expires

⚫️ Last-Modified

⚫️ Pragma

 

허용하고 싶은 응답 헤더를 개별적으로 적용하거나 와일드 카드를 사용해서 허용할 수 있다.

Access-Control-Expose-Headers: [<header-name>[, <header-name>]*]
Access-Control-Expose-Headers: *

 

나는 axios를 사용하는 터라 cors 미들웨어를 사용해서 Content-Disposition 헤더를 exposedHeaders 옵션에 개별 추가 해주었다. 

app.use(cors({
	exposedHeaders: ['Content-Disposition']
}))

 

이렇게 적용해주니 백엔드에서 지정해준 파일 이름대로 다운로드 되는 것을 확인할 수 있었다.

 

또한 PostRequest를 공통적으로 사용할 경우 responseType을 default 값인 text로 많이 사용할 것이고 생략하고 있을 것이다. 추가적으로 선언하기엔 번거롭기에 props로 responseType?: string으로 타입을 설정해주고 blob처럼 특이사항이 있을 경우 선언해서 쓰는 것이 좋을 것이다.

 

설문조사 결과를 서버로 보내고, 엑셀로 다운받는 경우를 예로 들겠다.

import axios, { AxiosResponse } from 'axios';

interface SurveyResponse {
  userId: number;
  response: string;
}

const postRequest = async (
  url: string,
  data: any,
  responseType?: 'arraybuffer' | 'blob' | 'document' | 'json' | 'text' | 'stream' = 'text'
): Promise<AxiosResponse<any>> => {
  try {
    const response = await axios.post(url, data, { responseType });
    return response;
  } catch (error) {
    console.error('Error in postRequest:', error);
    throw error;  
  }
};

const handleSendSurveyResults = async (surveyResponses: SurveyResponse[]): Promise<void> => {
  try {
    const res = await postRequest("/api/survey/results", { surveyData: surveyResponses }, 'blob');
    const url = window.URL.createObjectURL(new Blob([res.data]));
    const link = document.createElement("a");
    const fileName = res.headers["content-disposition"].match(/filename="?(.+)"?/)[1];
    link.href = url;
    link.setAttribute("download", fileName);
    document.body.appendChild(link);
    link.click();
    link.remove();
    window.URL.revokeObjectURL(url);
  } catch (error) {
    console.error('Failed to send survey results:', error);
  }
};

 

posrRequest를 훅으로 따로 관리하면 좋을 것 같지만~ 생략.. 참고해서 적용할 수 있는 부분이라 생각된다. 

 

 

 

회사에서 실무를 접하면서 이 배움을 어떻게 남기고 공유할 수 있을까를 고민 중인 요즘이다. 차근차근 성장할 수 있는 환경에서 해결해가는 과정이 즐거웠다. 매번 이렇게 부딪히며 겪으며 성장해보겠다.

 

 


[Blob(블랍) 이해하기]

[Blob에 대해서 이해하고 엑셀 파일 갖고 오기]

[Blob 스토리지란?]

[What is a BLOB (Binary Large Object)? Can it be Tokenized?]

[Blob]

[Base64 / Blob / ArrayBuffer / File 다루기 총정리]

[바이너리 라지 오브젝트]

[Blob이란?]

[CORS-safelisted response header]

[Access-Control-Expose-Headers]

[Axios Response header의 값이 없는 경우에 대해]