현재, 현장에서 일하는 직원들을 위한 협업툴을 서비스하는 회사에서 개발을 하고 있다. 입사 초기에는 기존 서비스를 리액트로 마이그레이션 하는 작업을 천천히 맡아 진행하고 있었는데, SAAS 부분을 강화하는 프로젝트를 진행하게 되면서 운영 어드민을 새롭게 리드해서 만들게 되었다. 운영 어드민의 경우에 여러가지 핵심 비지니스 로직들이 많아서 부담이 되는 부분도 있었지만, 다양한 데이터를 다뤄보고 비지니스 로직을 구현할 수 있어 즐겁게 진행하는 중이다. 그 중에 결제 관련 기능을 구현하면서 인보이스, 청구서 다운로드 기능을 구현하게 됐다. 기존에 URL을 통해서 쉽게 다운로드를 구현할 수 있구나 싶었는데, 전과는 다른 방법을 이용해야 했기 때문에 기록으로 남겨두려고 한다. API 호출했는데 이상한 문자만 와요! 라는 부끄러운 소리를 했던 기억을 되돌려보며... 똑똑히 기억해야겠다.
부끄럽게도 그 동안 짧은 개발기간을 보내오는 동안 blob을 다뤄본 기억이 없었다. console을 통해 만나본 response 속에 이상한 문자가 바로 blob의 형태이다. blob은 Binary Large Object의 약자로 이름에서 예상이 되는 것처럼 바이너리 형태의 큰 객체(이미지, 비디오 등)를 담을 수 있다. 자바스크립트에서는 이를 사용하기 위해 new Blob을 사용할 수 있다.
우선 blob을 이용하기 위해서는 Axios에서 responseType을 설정해야 한다.
export const downloadFile = async (
) => {
return receive(
client(
url,
{
method: 'GET',
responseType: 'blob',
}
)
);
};
위처럼 responseType 을 지정한 후에는 response를 이용해서 가공을 진행하면 된다.
// Blob은 배열 객체 안의 모든 데이터를 합쳐 blob으로 반환하기 때문에 []안에 담는다!
const blob = new Blob([res.data])
// window 객체의 createObjuctURL을 이용해서 blob:http://~~~ 식의 url을 만들어 준다.
const fileUrl = window.URL.createObjectURL(blob);
// link 안에 위에서 만든 url을 가지고 있는 a 태그를 만들고 보이지 않도록 해준다.
// a 태그는 노출하지 않고 자동으로 클릭되도록 할 예정!
const link = document.createElement('a');
link.href = fileUrl;
link.style.display = 'none';
지금까지의 과정을 간단히 설명하자면 blob 객체를 만들고 이를 통해서 url을 생성한다. 이 url을 가지고 있는 a태그를 만들고
이를 클릭해서 실행되도록 하면 된다. 다만, 파일 이름을 지정해줘야하는 단계가 남아있는데 나의 경우에는 파일 이름이 header에
"content-disposition" 를 통해 담겨오기는 했지만 인코딩 되어 있었기 때문에 디코딩이 필요했다. 코드는 아래와 같았다.
// response의 headers에 있는 content-disposition을 찾아서 디코딩 한다.
const injectFilename = (res: ApiResponse) => {
const disposition = res.headers['content-disposition'];
const fileName = decodeURI(
disposition
.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)[1]
.replace(/['"]/g, '')
);
return fileName;
};
마지막으로, a 태그의 다운로드 프로퍼티에 만들어 준 이름을 넣어주고 클릭되도록 하면 된다.
link.download = injectFilename(res);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(fileUrl);
위의 코드를 적용하면서 처음에는 에러가 많이 났다. 파일 이름도 테스트라고 입력 했다가 확장자를 제대로 입력하지 않아서 txt 파일로 받아지기도 하고
파일을 열어보니 알 수 없는 문자가 가득한 외계어의 파일이 열리기도 하고... 이름도 완벽하고 확장자도 완벽했지만 자꾸만 열 수 없다는 알림이 뜨면서 확인이 되지 않기도 했다.
이상한 문자의 파일이 자꾸 뜨는 오류의 경우는 파일 이름을 정확하게 입력하면서 해결이 됐고, 파일이 손상되서 열 수 없는 이유는 다른 코드를 수정하면서
responseType을 지워버렸기 때문이었다. 그렇게 몇 번의 시도 끝에 정확하게 원하는 파일을 다운로드 할 수 있었다.
파일을 다운로드 하는 방법이 위와 같은 방법만 있지는 않다. 중간에 다른 방법을 이용해서 시도했고 성공하기도 했는데 나는 사용하지 않았지만 간단히 소개만 해보려고 한다.
나는 리액트를 사용하여 프로젝트를 진행하고 있고 API 통신의 경우 Axios를 사용하고 있기 때문에 form의 action를 사용하고 싶지 않았다. 하지만 form의 action을 사용하면 조금 더 간단하게 시도해 볼 수 있다.
<form action={url} method="get">
<button type="submit">버튼</button>
</form>
위와 같이 시도하면 된다. form의 action을 이용하기 위해서는 submit 이벤트를 이용해야 하고 이를 통해 새로고침이 발생하기 때문에 나는 target="_blank"를 통해서 새 창이 열리도록 했고 다운로드를 받을 수 있었다.
이 방법을 시도해보면서 반성을 하게 됐는데, 그 동안 form에 있는 action의 기능을 전혀 사용해보지 않았다는 점이었다. react의 경우에 주로 onClick이나 onSumbit을 이용하면 거의 모든 기능 구현에 문제가 없기도 했기 때문에 고려조차 하지 않았는데, 때로는 훨씬 적은 코드로도 동일한 기능을 수행할 수 있다는 점을 알게 됐다.
물론, 새로고침 때문에 새 창을 이용하거나 API에 관련 된 로직 등을 흩어지도록 만들고 싶지 않아서 통일성을 위해 onClick 이벤트를 이용해 기능을 구현했지만 다른 문제를 접근할 때 편하고 익숙한 방법만 찾고 있지는 않는지 다시 한 번 생각해보게 된 계기가 됐다. 고작 5-6개월 정도 스타트업 개발자로서 즐겁게 일하고 있는데, 앞으로도 많은 문제를 만나고 이겨내면서 오래 오래 개발을 해나가고 싶다. 그 기간 동안 고이기 보다는 신선하고 효율적인 방법에 대해서 많이 고민해 볼 수 있다면 좋겠다.
아래는 편하게 볼 수 있도록 전체 코드이다.
const onClick = () => {
downloadFile().then(res => {
const blob = new Blob([res.data])
const fileUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = fileObjectUrl;
link.style.display = 'none';
const injectFilename = (res: ApiResponse) => {
const disposition = res.headers['content-disposition'];
const fileName = decodeURI(
disposition
.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)[1]
.replace(/['"]/g, '')
);
return fileName;
};
link.download = injectFilename(res);
document.body.appendChild(link);
link.click();
link.remove();
})
}
onClick으로 구현하려다가 form 태그 이용한 다운로드 방법 잘 보고 갑니다!