back

Posts

CORS오류를 피해 파일을 다운로드해 보자!

2025-02-02

이전 회사에서 외부 경로에 있는 파일 다운로드를 구현하는 일이 있었습니다. 유저가 버튼을 클릭하면 해당 유저가 찍은 사진과 비디오를 다운로드하는 간단한 흐름이었는데요. 처음엔 단순히 브라우저에서 <a> 태그를 사용하면 될 줄 알았지만 그게 아니었습니다.

당시 파일들은 S3에 업로드되어 저장되고 있었고, 내부 사정상 브라우저에서 다운을 받기 위해 접근하면 CORS가 발생하는 상황이었습니다.

백엔드 없이 프론트로만 기획이 되어 있어서 따로 API 요청이 불가했고, 결국 Next.js API Routes를 사용해서 파일을 다시 구성하는 방법으로 로직을 구현했습니다.

큰 흐름은 아래와 같습니다.

  1. 외부 파일이 존재하는지 확인
  2. 유저가 다운로드 버튼 클릭
  3. Next.js API Routes를 사용해서 파일을 다시 전달

순차적으로 하나씩 구성해보겠습니다.

1. 파일 존재여부 확인

파일의 경로는 URL 파라미터에 담겨 오는 형식이었습니다. 따라서 해당 경로에 파일이 존재하는지 여부를 확인하는 게 필수였는데요. HEAD 메소드를 사용해서 간단히 구현이 가능합니다.

HEAD 메소드는 GET과 동일한 응답을 요청하지만 응답 본문을 포함하지 않습니다. 따라서 응답값의 reponse가 정상적이라면 파일이 존재한다고 판단할 수 있습니다.

const response = await fetch(url, { method: 'HEAD' });

if (!response.ok) {
  return res.status(404).json({ exists: false });
}

return res.status(200).json({ exists: true });

2. 다운로드 버튼 클릭

유저가 다운로드 버튼을 클릭하면 정석적인 방법으로 <a> 태그를 생성 후 클릭하게 하는 방법으로 구현했습니다.

const downloadFile = async () => {
  const response = await fetch(`/api/download-file?url=${encodeURIComponent(url)}&name=${encodeURIComponent(fileName)}`);

  if (!response.ok) {
    alert('Failed to download the file.');
    return;
  }

  // 응답 데이터를 Blob으로 변환
  const blob = await response.blob();

  // Blob 데이터를 사용해 브라우저에서 파일 다운로드 트리거
  const downloadUrl = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = downloadUrl;
  a.download = fileName || 'downloaded-file';
  a.click();

  // 메모리 누수를 방지하기 위해 URL 객체 해제
  URL.revokeObjectURL(downloadUrl);
};

여기서 문제가 발생했는데요. 모바일 사파리 브라우저에서는 다운받은 사진과 비디오를 미리 볼 수 있는 기능이 있습니다. 하지만 a.click() 이후 바로 URL.revokeObjectURL을 사용하여 미리보기 가능한 URL을 제거하고 있었죠.

유저가 파일을 여러 번 다운하는 경우는 거의 없다고 판단하여, 결국 URL.revokeObjectURL는 컴포넌트가 언마운트되는 경우에 작동하도록 수정했습니다.

3. API Routes 코드

파일을 다시 전달하는 코드는 Node.js의 스트림을 사용했습니다. 스트림은 데이터를 조각 단위로 처리할 수 있어 대용량 파일도 효율적으로 전송이 가능합니다.

먼저 query로 받은 URL이 올바른 경로인지 확인합니다.

https.get(url, (externalRes) => {
  if (externalRes.statusCode !== 200) {
    return res.status(404).json({ error: '파일이 없습니다.' });
  }
});

그 다음 query로 받은 파일 이름을 설정하고 Content-Type과 같은 헤더를 설정합니다. 유저가 다운받는 파일은 변하지 않으므로 캐싱 기간을 1년으로 설정했습니다.

// 파일 이름 및 Content-Type 설정
const fileName = name && typeof name === 'string' ? name : url.split('/').pop() || 'downloaded-file';
const contentType = externalRes.headers['content-type'] || 'application/octet-stream';

// 응답 헤더 설정
res.setHeader('Content-Type', contentType);
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');

이후 pipe(res)를 사용해 브라우저에 서버 응답 스트림과 클라이언트 응답 스트림을 연결시켜줍니다.

// 스트리밍 방식으로 외부 응답 데이터를 클라이언트에 전송
externalRes.pipe(res);

완성된 코드는 아래와 같습니다.

import type { NextApiRequest, NextApiResponse } from 'next';
import https from 'https';

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  const { url, name } = req.query;

  // URL 유효성 검사
  if (!url || typeof url !== 'string') {
    return res.status(400).json({ error: '유효하지 않은 URL' });
  }

  try {
    https.get(url, (externalRes) => {
      if (externalRes.statusCode !== 200) {
        return res.status(404).json({ error: '파일이 없습니다.' });
      }

      // 파일 이름 및 Content-Type 설정
      const fileName = name && typeof name === 'string' ? name : url.split('/').pop() || 'downloaded-file';
      const contentType = externalRes.headers['content-type'] || 'application/octet-stream';

      // 응답 헤더 설정
      res.setHeader('Content-Type', contentType);
      res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
      res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');

      // 스트리밍 방식으로 외부 응답 데이터를 클라이언트에 전송
      externalRes.pipe(res);
    }).on('error', (err) => {
      console.error('Streaming error:', err);
      res.status(500).json({ error: '파일 다운로드 실패 : Streaming' });
    });
  } catch (error) {
    console.error('Download error:', error);
    res.status(500).json({ error: '파일 다운로드 실패 : URL 다운로드' });
  }
}

생각보다 간단하면서도 복잡했는데요. Node.js의 스트림과 HEAD 메소드를 더 깊이 공부할 수 있는 좋은 경험이었습니다.