본문 바로가기

Spring Boot

[Lessons learned] 대용량 파일 다운로드 처리

현재 수행하고 있는 프로젝트를 진행하면서 대용량 파일 다운로드 기능을 개선해야 될 일이 생겼다.
기능 개선 과정에서 처리한 방법을 기록용으로 남겨둔다.


 


기존 로직을 간단하게 표현하면 다음과 같다.

1-1. Application에서 대용량 데이터를 DB로부터 조회해 호스트에 마운트되어 있는 NAS에 파일을 생성
1-2. 생성된 파일의 상대 경로를 Frontend로 응답
2-1. Frontend의 콜백에서 NAS가 마운트되어 있는 Web server로 파일 다운로드 요청
2-2. 다운로드

 


다른 추가적인 문제점들이 있을 수 있겠지만, 가장 크게 보인 문제점은 다음과 같다.
1. 서버 디렉터리 구조 노출
2. 파일 생성 요청과 파일 다운로드 요청으로 나뉘어 요청을 두 번 하도록 구성이 되어있어, 불필요한 오버헤드 발생

 


이러한 문제점들을 해결하기 위해
처음 요청에서 생성된 파일의 상대 경로를 응답해주는 것이 아니라
아예 파일 바이너리 데이터를 response body에 담아서 응답해주는 구조로 변경하기로 했다.

 


러프하게 개선 후 대용량 파일 다운로드 API의 프로세스는 아래와 같다

 


1. 대용량 파일 다운로드 API 호출시, DB 조회를 통해 파일을 생성
2. FileInputStream으로 파일을 buffer 단위로 읽어 HttpServletResponse의 OutputStream에 write
3. Frontend에서 응답받은 바이너리 데이터를 blob으로 읽어 다운로드 링크 생성 및 click 처리


1번의 파일 생성 부분은 기존 로직을 동일하게 사용했고,
DB에서 select를 해서 메모리에 데이터를 올리는 과정에서도 대용량 처리이기 때문에
조회부분에서도 대용량 처리를 해줘야한다.

현재 프로젝트에서는 MyBatis를 사용하고 있기 떄문에 MyBatis에서 제공하는 ResultHandler 인터페이스를 구현해
Row단위로 끊어서 파일 스트림에 작성하는 로직으로 구성한다.


2, 3번 로직이 이번 포스팅의 핵심이다.


먼저 파일을 FileInputStream으로 buffer 단위로 끊어서 읽고 OutputStream에 write하는 코드를 확인해보자.
파일을 처리하는 클래스들의 구조는 조금 다르지만 핵심이 되는 파일 스트리밍 처리에 집중하고자
SampleController 코드를 작성했다.


@RestController
@RequiredArgsConstructor
public class SampleController {

    /**
     * ...
     */

    // DB에서 데이터를 조회하거나 내부 로직을 수행해 가져온 대용량 데이터로 파일을 생성하는 책임을 갖는 sample service
    private final SampleService sampleService; 

    @GetMapping("file/big")
    public void downloadBigFile(HttpServletRequest request, HttpServletResponse response) throws UnsupportedEncodingException {
        
        File bigFile = sampleService.createBigFile(request); // 파라미터를 Dto로 매핑해서 받거나 PostMapping의 경우 RequestBody로 받아도 됨

        /*
         * 서비스
         */
        String fileName = "filepath";
        File file = new File(fileName);
        
        // Content-Disposition 헤더 설정
        // 다운로드 파일명을 여기에 설정해준다.
        response.setHeader(HttpHeaders.CONTENT_DISPOSITION, String.format(
            "attachment; filename*=UTF-8'''%s'", URLEncoder.encode(fileName, "UTF-8")  // 한글 파일명 꺠짐을 방지하기 위해 파일명을 UTF-8로 인코딩 해 헤더 set
        ));
        // Content-Type 헤더 설정
        // Content-Type: "application/octet-stream"
        response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);

        if (!file.exists()) {
            throw new IllegalStateException("파일을 찾을 수 없습니다.");
        }

        /**
         * 파일 바이너리 데이터를 Base64로 인코딩해서 write
         * Frontend에서 텍스트 데이터로 처리하도록 설정이 되어있어
         * Base64로 인코딩해서 응답해줘야 한다.
         * 
         * 스트리밍 처리 중 바이너리 데이터를 Base64로 인코딩해줘야 하므로
         * HttpServletResponse의 output stream을 wrap해 base64os 사용
         */
        try (FileInputStream fis = new FileInputStream(file);
             OutputStream os = response.getOutputStream();
             OutputStream base64os = Base64.getEncoder().wrap(os)) {
            
            byte[] buffer = new byte[1024];
            int bytesRead;
            while ((bytesRead = fis.read(buffer)) != -1) {
                base64os.write(buffer, 0, bytesRead);
            }

            base64os.flush();
        }
        catch(IOException e) {
            throw new IllegalStateException("대용량 파일 다운로드 처리중 오류가 발생했습니다.");
        }
    }
}



이렇게 파일 바이너리 데이터 스트림을 프론트로 내려주고 
개발자도구의 Network 탭을 확인해 어떤 응답이 내려왔는지 확인해보면 아래 캡쳐와 같이 바이너리 데이터가 응답으로 들어오게 된다.

 


Spring Boot 백엔드 관련 포스팅이라 작성을 할까 말까 했는데 기록겸 함께 프론트 코드도 함께 작성한다.

부록으로 간단하게 3번 항목에 해당하는 바이너리 스트림 데이터를 blob 파일로 묶어 브라우저 다운로드를 실행하는 예제 함수는 다음과 같다.

 

const downloadBlob = (data, disposition) => {
	let filename = 'download-file'; // 기본 파일 이름
    
    if (disposition && disposition.indexOf('attachment') !== -1) {
    	// filename* 속성을 찾아 인코딩된 파일 이름 추출
        const regex = /filename\*=[^;\n]*/;
        const matches = regex.exec(disposition);
        
        if (matches != null) {
        	const encodedFilename = matches[0].split('=')[1]; // filename* 값 추출
            const decodedFilename = decodeURIComponent(encodedFilename.replace(/^UTF-8''/, '').replace(/['"]/g, '')); // URL 디코딩
            filename = decodedFilename; // 디코딩된 파일 이름 설정
        }
    }
    
    const url = window.URL.createObjectURL(data); // blob url 생성
    const link = document.createElement('a'); // 링크 element 생성
    link.href = url;
    link.setAttribute('download', filename); // 다운로드 파일 이름 설정
    document.body.appendChild(link);
    link.click(); // 링크 다운로드
    document.body.removeChild(link); // 링크 element 제거
    URL.revokeObjectURL(link.href);
};

 

 

이렇게 처리하게 되면 크롬 기준으로 자동으로 다운로드가 된다