csv 다운로드 기능 구현

부루베릐·2023년 5월 29일
0

TIL

목록 보기
10/20

파일 다운로드와 Content-Disposition

Content-Disposition 헤더 속성은 HTTP 표준이다. Disposition은 특성이나 성향이라는 뜻을 가지고 있는데, 말 그대로 해당 속성의 의미를 직역하면 응답으로 보내는 데이터의 특성이 어떤지를 나타내주는 정보라 할 수 있다. 좀 더 우아하게 말해보자면 Content-Disposition 헤더 속성은 서버의 리소스가 브라우저에서 어떻게 처리될지를 나타내는 속성이라고 할 수 있다.

예시를 보면 좀 더 와닿을 수 있다. Content-Disposition은 크게 inlineattachment로 나뉜다.

Content-Disposition: inline
Content-Disposition: attachment
Content-Disposition: attachment; filename="filename.jpg"

inline은 말 그대로 해당 데이터가 브라우저 내(inline)의 웹 페이지로서 표시되어야 하는 데이터일 때 사용한다. 따로 Content-Disposition을 설정하지 않은 경우 inline이 디폴트 값이 된다.
이에 반해 attachment는 해당 데이터가 다운로드되어야 할 때 사용한다. 이 때 데이터로 다운로드받을 때의 파일 이름도 넣어줄 수 있다.
이렇듯 브라우저가 해당 리소스를 어떻게 다루어야 하는지를 나타내는 속성이 Content-Disposition이다.


서버에서

우리 서비스에서 데이터를 검색하였을 때 검색 결과를 테이블로 표현해주고 있다. 그런데 고객사로부터 이 검색 결과를 CSV 파일로 받을 수 없겠냐는 요청이 여러 번 왔기에 CSV 파일을 다운로드하는 기능을 제작하게 되었다. 백엔드 팀원과 함께 논의 결과, 서버에서 다음과 같은 방식으로 프론트에 데이터를 전달해주기로 하였다.

  • csv-writer 라이브러리를 통해서 CSV 형태의 스트링을 만든다.
  • 이 스트링을 응답 바디에 넣고, 응답 헤더에 Content-Disposition: attachment와 파일 이름을 포함하여 클라이언트에게 보내준다.
  • 그럼 응답 헤더를 보고 브라우저가 자동으로 CSV 파일 다운로드!

CSV 형태의 스트링이라는 말이 낯설게 느껴질 수 있는데, 애초에 CSV는 Comma-Separated Values의 줄임말로 독립된 필드를 쉼표로 구분한 텍스트 데이터를 의미하므로 본질적으로 문자열 데이터의 한 종류이다. 단순한 CSV가 아닌 엑셀 파일을 다운로드할 수 있도록 기능을 제공할지도 고민해 보았으나 그러기엔 가용된 시간이 많지 않아(ㅠㅠ) 간단하게 CSV 파일로 다운로드 기능을 제공하게 되었다.


axios로 시도

첫번째 시도

직접 프로젝트에서 axios 요청을 통해서 파일을 요청하였는데, 응답 헤더에 Content-Disposition: attachment가 있었는데도 파일이 다운로드되지 않았다.

개발자 도구의 Network 탭에서 보았을 때 Content-Disposition 값이 제대로 되어 있음에도 파일 다운로드가 이루어지지 않는다… 왤까?

혹시나 해서 axios 응답 객체를 살펴보았다. axios 응답 데이터를 살펴보면 CSV 형식의 텍스트 데이터가 문자열로 담겨서 응답 body에 왔다는 것을 알 수 있다. 그리고 headers에서 응답 헤더에 지정된 값을 볼 수 있다.

// axios 응답 데이터
const res = await axios.get('https://api-adress/invoices/csv')
console.log(res)

{
  data: "Id,Name,Currency,...", // CSV 스타일의 문자열
  status: 200,
  statusText: 'OK',
  headers: { content-length: "100", content-type: "text/html; charset=uft-8" },
  config: { url: "invoices/csv", method: "get", ... },
  request: { XMLHttpRequest { //... } } // 요청 주체가 브라우저(XMLHttpRequest)
}

그런데 header에 Content-Disposition 속성이 없다! Network 탭에서는 헤더가 보였지만 응답 객체에는 없다! 이 때문에 다운로드가 되지 않았던 게 아닐까?


두번째 시도

Content-Disposition 헤더가 axios 응답 객체에 없는 이유는 API의 CORS 설정으로 인해 헤더를 통해 전송해주는 정보가 한정되어 있기 때문이다. 기본적으로 클라이언트에게 노출되는 헤더의 종류는 한정되어 있다(참조). Content-Length, Content-Type, Cache-Control 등이 그것이다.

이에 반해 Content-Disposition 헤더 속성은 그렇지 않다. Content-Disposition 속성은 브라우저에게 이 파일을 다운로드하라고 지시하는 역할을 한다. 만약 악의적인 사이트가 이 헤더를 사용하면 브라우저는 사용자의 동의 없이도 자동으로 파일을 다운로드하게 되므로 보안상 사용자에게 위협이 될 여지가 있다. 따라서 스크립트에서 직접 이 헤더를 조작하는 것이 권장되지 않으므로 Content-Disposition 속성은 기본적으로 스크립트에 노출되지 않는다.

그럼에도 불구하고 Content-Disposition을 응답 헤더에 넣어 클라이언트에게 노출되게 하고 싶다면, 서버에서 응답을 보낼 때 응답 헤더 속성 중 하나인 Access-Control-Expose-Header에 공개하고 싶은 헤더 정보를 넣어 주어야 한다.

그러나 이렇게까지 했는데도 여전히 다운로드는 되지 않는다. 왜..?

그 이유는 Axios(XML) 요청에 의한 응답은 브라우저가 아니라 자바스크립트 코드에서 핸들링되어야 하기 때문이다. 브라우저는 응답 헤더에 Content-Disposition이 있다 하더라도 아무것도 하지 않는다. 파일 다운로드를 구현하는 작업은 자바스크립트, 즉 오로지 개발자의 몫이다. 이 블로그에서 꽤 상세하게 열불을 내며(?) 이 문제에 대해 이야기하고 있다. 이 블로그에서도 왜 이렇게까지 해야 하는지에 대한 근본적인 원인은 나와 있지 않지만, 나는 브라우저에서 자동으로 다운로드를 해 주지 않음으로써 위의 Content-Disposition으로 인해 발생할 수 있는 문제에 대한 책임을 어느 정도는 덜기 위해 이렇게 동작하는 게 아닌가 한다.


anchor 태그로 시도

위에서 Content-Disposition 헤더만을 보고 자동으로 파일을 다운로드하는 것은 보안상 좋지 않고, axios 요청을 통해 다운로드할 파일 정보를 받았다 하더라도 별도의 자바스크립트를 통해 다운로드 기능을 구현해야 한다고 하였다.

파일을 다운로드하는 방법 중 가장 널리 쓰이는 방식은 anchor 태그를 만들고, a 태그의 href에 파일 URL을 넣은 후 click 이벤트를 트리거하는 방식이다. 만약 해당 href의 요청에 대한 응답 헤더의 Content-Disposition의 값이 inline이라면 해당 페이지로 이동을 하겠지만, attachment라면 파일을 다운로드한다.

원래는 a 태그의 href에 파일 URL을 넣어 어떤 파일을 다운로드하여야 하는지 브라우저에게 알려주어야 한다. 우리 서비스에서는 S3의 signed URL을 사용하여 S3 상에 올라가 있는 파일을 다운로드할 때 href에 이 signed URL을 넣는다.

const fileDownload = async (url: string) => {
  const response = await fetch(url)
  if (!response.ok) {
    throw new Error(`Failed to download file: ${url}`)
  }
  return await new Promise((resolve, reject) => {
    setTimeout(() => {
      const link = document.createElement('a')
      link.href = url
      link.download = url
      document.body.appendChild(link)
      link.click()

      link.addEventListener('error', err => {
        reject(err)
      })

      document.body.removeChild(link)
      resolve(true)
    }, 100)
  })
}

이때 href에 파일 URL 대신에 요청 API URL 주소를 넣어주면 어떨까? 어차피 응답 헤더에 Content-Disposition: attachment가 포함되어 오므로 파일이 다운로드되지 않을까?

async fileDownload('https://api-adress/invoices/csv')

시도 결과

401 에러가 발생한다. 우리 서비스는 로그인을 한 사용자만이 사용할 수 있는 서비스이기 때문에 그렇다. 요청 API가 프라이빗이므로 인증된 사용자만이 요청을 보내고 받을 수 있다는 뜻이다.

근데 S3 파일을 다운로드 할 때에는 위의 방법대로 해도 파일이 잘 다운로드 된다. 이번 케이스는 무엇이 다른 것일까? S3 signedUrl은 미리 인증을 끝낸 후 일정 시간 동안 파일을 다운로드할 수 있는 창구 역할을 하는 URL이다. 따라서 authorization 헤더가 필요 없으므로 anchor 태그로도 잘 받아왔던 것이다. 하지만 이번 케이스에서는 URL 자체가 인증 정보를 필요로 하므로 헤더에 authorization: Bearer ${token}을 넣어 주어야 한다. 그래야 이 사용자가 로그인된 사용자인지 그 여부를 알 수 있기 때문이다. 이것도 나름 일인데... 더 나은 방식은 없을까?


data URL을 사용하는 방법

data URL이란?

data URL은 웹 페이지에서 이미지, 비디오, 텍스트 등의 파일을 문서 안에 인라인으로 삽입할 수 있도록 해 주는 URL이다.

<!-- 다음과 같이 인라인으로 이미지 데이터를 삽입할 수 있다. -->
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..." />

Data URL의 구성을 살펴보면, data: 문자열이 맨 앞에 붙어 있고 그 뒤에 파일의 정보가 담기는 형식이다.

data:[<mediatype>][;base64],<data>
  • mediatype
    • 해당 데이터의 유형(MIME 타입, 파일이 어떤 유형인지 알려주는 매커니즘)을 뜻한다. 텍스트의 경우에는 text/plain, text/html, text/csv 등이 있을 수 있고, 이미지의 경우에는 image/jpeg, image/png 등이 있을 수 있겠다.
  • base64
    • 해당 데이터가 base64로 인코딩 되어있는지를 나타내는 키워드이다.
  • data
    • 데이터 자체를 이야기한다. 만약 데이터가 순수한 text 자체라면 이 data 부분에 텍스트 문자열이 들어갈 수도 있다. 그게 아니라면 base64로 인코딩된 이진 데이터가 들어가기도 한다.

아래의 코드에서 각각 텍스트와 이미지 데이터에 대한 data URL을 나타내 보았다.

const textURL = "data:text/plain,this is example data"
const imageURL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..."

data URL로 파일 다운로드하기

앞서 data URL을 사용하면 데이터를 인라인으로 문서에 넣을 수 있다고 이야기했다. data URL은 데이터의 형식과 타입에 대한 정보 및 데이터 자체를 포함한 URL이다. 따라서 data URL을 anchor 태그의 href에 삽입하여 파일을 다운로드할 수 있다.

const fileDownload = async (url: string, fileName: string) => {
  const response = await fetch(url)
  if (!response.ok) {
    throw new Error(`Failed to download file: ${url}`)
  }
  return await new Promise((resolve, reject) => {
    setTimeout(() => {
      const link = document.createElement('a')
      link.href = url                          // data URL 삽입
      link.download = fileName                 // 다운로드될 파일 이름 설정
      document.body.appendChild(link)
      link.click()

      link.addEventListener('error', err => {
        reject(err)
      })

      document.body.removeChild(link)
      resolve(true)
    }, 100)
  })
}

const res = await axios.get('https://api-adress/invoices/csv')
const csvDataURL = 'data:text/csv;charset=utf-8,' + res.data
const fileName = res.headers['content-disposition'] // attachment; filename="filename.jpg"
				          .split('; ')[1]
				          .split('filename=')[1]
				          .slice(1, -1)

await csvFileDownload(csvDataURL, fileName)

단점은 없을까

data URL의 경우 문법이 꽤 간편하다는 장점이 있다. 그냥 데이터를 문자열 형식으로 받은 후 data URL을 만들어 다운로드만 하면 된다. 그렇다면 data URL를 사용하여 파일 다운로드를 구현하였을 때의 단점은 없을까?

가장 우선적으로 생각나는 단점은 파일의 용량이 커질수록 data URL의 크기도 커진다는 것이다. 파일의 내용이 문자열로서 data URL에 담기므로, 파일의 용량이 너무 크다면 URL 자체도 커져 메모리에 부담을 줄 수도 있다. 여기서는 해당되지 않지만, 만약 이미지 등의 크기가 큰 리소스를 인라인으로 HTML에 삽입하였을 경우 base64로 인코딩된 데이터가 들어가게 된다. 바이너리 데이터를 base64 인코딩을 통해 ASCII 문자열로 변경하는 과정에서 크기가 늘어나게 되고, 또 이 데이터를 인라인으로 받아오는 과정에서 화면 렌더링이 느려지는 단점도 존재한다(인라인으로 받아오면 캐싱도 되지 않는다!).

또한 몇몇 브라우저에서는 data URL 데이터의 용량을 제한하고 있다. 현재 크롬의 경우 512MB, 사파리는 2048MB, 그리고 파이어폭스는 32MB까지가 한계이다(출처). 물론 지금 당장은 이렇게까지 데이터의 크기가 늘어나지는 않겠지만, 나중에 데이터가 쌓이고 늘어날수록 문제가 될 수도 있다.

일단 지금은 data URL로 구현해도 문제는 없으나, data URL보다 더 나은 방법이 있을지는 계속 고민해야 할 것 같다.


출처

You Can’t Prompt a File Download With the Content Disposition Header Using Axios (XHR). Sorry.

How to Download Files with JavaScript

Data URLs - HTTP | MDN

0개의 댓글