Frontend/javascript

JavaScript로 파일 다운로드 하기 with 다운로드시 파일 깨짐 방지

민57 2024. 11. 10. 13:06

자바스크립트로 파일 다운로드 함수를 만들어서 사용하고 있었다.

요즘 이렇게 만들어뒀던 코드들을 분석하며 되돌아보는 시간을 가지고 있다.

 


downloadAuthorizedFile()

async function downloadAuthorizedFile(fileName = "file", { downloadURL = "", requestParam, responseType}) {
    function checkIncludeExtension(fileName) {
        const regex = /\.[0-9a-z]+$/i;
        return regex.test(fileName);
    }

    if (!downloadURL)
        throw new Error("Cannot found download URL.");

    const response = await Api.get(downloadURL, requestParam, responseType);
    if (!response || !response.data)
        throw new Error("Fail download file.");

    let fullFileName = fileName;
    if (!checkIncludeExtension(fileName)) {
        const contentType = response.data.type;
        const extension = contentType.split("/")[1];
        fullFileName = extension ? `${fileName}.${extension}` : fileName;
    }

    const fileURL = URL.createObjectURL(response.data);
    const a = document.createElement("a");
    a.href = fileURL;
    a.download = fullFileName;
    document.body.appendChild(a);
    a.click();

    window.URL.revokeObjectURL(fileURL);
    document.body.removeChild(a);
}

 

권한이 필요한 관리자 페이지에서의 다운로드가 기존에 써왔던 방식으로는 작동하지 않아서 해당 함수 로직을 만들어보게 됐고 네이밍이 authorizedFile이 되었다. 기존 방식은 함수 외부에서 다운로드 할 객체를 미리 만들어두고 다운로드 로직을 탔었는데, 지금 방식은 직접 서버에 다운로드 객체를 요청한다.

예전 방식과 지금을 비교하는 포스팅도 글로 쓸 예정이다.

 

코드를 하나씩 뜯어보자.

 


매개변수와 필수값 검증

function downloadAuthorizedFile(fileName = "file", {
  downloadURL = "",
  requestParam,
  responseType
}) {
	if (!downloadURL)
    	throw new Error("Cannot found download URL.");
}

 

매개변수로는 기본 파일 이름인 fileName, 그리고 옵션객체로 downloadURL과 requestParam, responseType을 받는다.

필수 옵션값인 downloadURL이 없을 경우 에러를 발생시켜 유효성을 검사한다.

 

비동기로 서버에서 다운로드할 객체 요청

const response = await Api.get(downloadURL, requestParam, responseType);
if (!response || !response.data)
    throw new Error("Fail download file.");

 

GET 요청을 통해 파일을 비동기적으로 가져온다. 비동기를 이용했기 때문에 함수 자체에 async를 걸어뒀다.

응답 값이 없거나 데이터가 비어있으면 에러를 발생시킨다.

 

확장자 유효성 검증

function checkIncludeExtension(fileName) {
    const regex = /\.[0-9a-z]+$/i;
    return regex.test(fileName);
}

...

let fullFileName = fileName;
if (!checkIncludeExtension(fileName)) {
    const contentType = response.data.type;
    const extension = contentType.split("/")[1];
    fullFileName = extension ? `${fileName}.${extension}` : fileName;
}

자바스크립트 정규식을 통해 확장자가 포함된지 체크하는 내부 함수이다.

원래 이 로직이 없을 때는 확장자가 없는 파일을 다운로드 하려고 할 때 파일이 깨져서 다운로드 됐었다.

파일 이름에 확장자가 없는 경우, 응답 객체의 contentType을 참조하여 MIME 유형 기반의 확장자를 추가한다.

 

다운로드 로직

const fileURL = URL.createObjectURL(response.data);
const a = document.createElement("a");
a.href = fileURL;
a.download = fullFileName;
document.body.appendChild(a);
a.click();

 

다운로드한 데이터를 Blob URL로 변환하고 생성한 a 태그에 속성을 채워준 뒤 클릭해서 다운로드 한다.

 

클린업 수행

window.URL.revokeObjectURL(fileURL);
document.body.removeChild(a);

위에서 생성한 ObjectURL 객체를 revoke 함수를 통해 메모리를 해제하고, 생성한 a 태그도 DOM에서 제거해줬다.

 


어떻게 개선할 수 있을까?

나름 할 수 있는 선에서는 최선으로 짰다고 생각한 코드다. 하지만 시니어가 없는 우리 회사에서는 봐줄 사람도 없고 주변에도 요청하기 마땅치 않은 상황. 코드 리뷰를 받아보는 마음으로 GPT에게 코드를 던져 주고 개선점에 대해서 요청했다.

몇 가지 문제를 짚어줬다.

 

API 호출 에러 처리가 부족

try {
    const response = await Api.get(downloadURL, requestParam, responseType);
    if (!response || !response.data) throw new Error("Failed to download file: No data in response.");
} catch (error) {
    console.error("Download error:", error.message);
    throw new Error("Failed to download file. Please check the network connection or URL.");
}

try-catch 블록을 사용해서 네트워크 오류나 API 호출 실패 시 구체적인 메시지와 함께 처리하는 방법을 추가했다고 한다.

에러를 단순하게 처리했던 이유는 사실 따로 있다.

Api.get 이 부분은 내가 axios를 래핑해서 API 모듈을 따로 만들어서 사용하고 있는데, 이 모듈엔 에러를 처리하는 공통 핸들러가 따로 있다. 서버에서 에러가 발생하면 서버 에러 메시지를 표시해주기 때문에 굳이 넣지 않았던 것이다.

그래서 이 부분은 따로 추가하지 않아도 될 것 같다.

 

파일 타입에 의존적인 확장자 처리

if (!checkIncludeExtension(fileName)) {
    const defaultExtension = 'txt'; // 기본 확장자 설정
    const contentType = response.data.type;
    const extension = contentType.split("/")[1] || defaultExtension;
    fullFileName = `${fileName}.${extension}`;
}

MIME 타입에 의존하지 않고 기본 확장자를 설정하는 코드이다.

현재 내 코드는 파일 타입에 너무 의존적으로 확장자를 처리하고 있다고 한다. 일부 경우엔 MIME 타입과 실제 확장자가 다를 수 있다고 하는데, 이 부분은 넣어줘도 좋을 것 같다.

 

중복된 DOM 추가/삭제

const downloadAnchor = document.createElement("a"); // 재사용 가능한 <a> 요소

async function downloadAuthorizedFile(fileName = "file", { downloadURL = "", requestParam, responseType }) {
	...
    const fileURL = URL.createObjectURL(response.data);
    downloadAnchor.href = fileURL;
    downloadAnchor.download = fullFileName;
    downloadAnchor.click();

    window.URL.revokeObjectURL(fileURL);
}

<a> 요소를 매번 동적으로 추가하고 삭제하면서 DOM 변경이 많아진다고 한다.

확실히 이 부분에서 성능이 저하될 가능성이 있어보이긴 했다. 하지만 파일 다운로드 함수는 그렇게 많이 호출되는 부분은 아닐 것 같아서 크게 신경쓰이는 부분은 아니였다.

GPT가 제안한 방법은 재사용 가능한 <a> 요소를 전역으로 선언하는 방법을 제안했다.

파일 다운로드가 아닌 다른 부분에서 성능을 고려하게 된다면 이용해도 좋을 것 같다.

 

개선점을 반영한 전체 코드

async function downloadAuthorizedFile(fileName = "file", { downloadURL = "", requestParam, responseType}) {
    function checkIncludeExtension(fileName) {
        const regex = /\.[0-9a-z]+$/i;
        return regex.test(fileName);
    }

    if (!downloadURL)
        throw new Error("Cannot found download URL.");

    const response = await Api.get(downloadURL, requestParam, responseType);
    if (!response || !response.data)
        throw new Error("Fail download file.");

    let fullFileName = fileName;
    if (!checkIncludeExtension(fileName)) {
    	const defaultExtension = "txt";
        const contentType = response.data.type;
        const extension = contentType.split("/")[1] || defaultExtension;
        fullFileName = `${fileName}.${extension}`;
    }

    const fileURL = URL.createObjectURL(response.data);
    const a = document.createElement("a");
    a.href = fileURL;
    a.download = fullFileName;
    document.body.appendChild(a);
    a.click();

    window.URL.revokeObjectURL(fileURL);
    document.body.removeChild(a);
}

다운로드 함수 사용 예시

const handleDownload = () => {
  const downloadURL = `/users/${entityId}/files`;
  const requestData = {
    downloadURL: downloadURL,
    responseType: "blob"
  }
    
  downloadAuthorizedFile(originEntity.fileName, requestData);
}

 

확장자가 깨지는 이슈가 없었다면 확장자를 자동으로 추가하는 기능이 없었을 지도 모른다.

모든 개발이 정해진 프로세스대로만 동작하면 최선이지만 사용자가 한 명이라도 있다면 쉬운 일은 아닐 것이다.

여러 가지 다양한 이슈를 겪어보고 해결하는 과정을 통해서 더 나은 코드를 짜는 사람이 되고 싶다.

728x90