Spring & Spring Boot/대용량 파일 전송 시스템 개발

[24] 대용량 파일 전송 시스템 개발 - 파일 다운로드에 Redis를 활용하기 (바이트 배열) 2

carrotweb 2026. 9. 28. 21:36
728x90
반응형

대용량 파일 다운로드를 Redis에 저장할 때 문자열(String)이 아닌 바이트 배열(byte[])로 저장해서 가져오도록 수정하겠습니다.

 

기존 소스처럼 DownloadCheckController.java 파일과 DownloadProcessController.java 파일을 합치지 않고 유지하겠습니다.

 

1. transmission\src\main\java\com\example\transmission\controller\DownloadCheckController.java 파일을 수정합니다.

저장 방식을 SaveType.STRING에서 SaveType.BYTE로 변경합니다.

// Redis에 저장
int chunkCount = 0;
try {
    String key = "download:" + fileID;
    chunkCount = downloadRedisService.pushFileToList(orginFilePath, key, chunkSize, IDownloadRedisService.SaveType.BYTE, 10, TimeUnit.MINUTES);
    if (chunkCount <= 0) {
        return responseError(HttpStatus.INTERNAL_SERVER_ERROR, 995, "다운로드 파일이 준비가 되지않았습니다.");
    }
} catch (FileNotFoundException e) {
    // 파일이 없을 때 404
    return responseError(HttpStatus.NOT_FOUND, 995, e.getMessage());
} catch (Exception e) {
    return responseError(HttpStatus.INTERNAL_SERVER_ERROR, 995, e.getMessage());
}

 

2. transmission\src\main\java\com\example\transmission\base\controller\BaseController.java 파일을 수정합니다.

응답 데이터가 byte[]로 변경되기 때문에 responseErrorHeader()를 추가합니다.

/**
 * 에러 응답
 * @param status HTTP 상태
 * @param code 응답 코드
 * @param message 응답 메시지
 * @return ResponseEntity 객체 (Header)
 */
protected ResponseEntity<byte[]> responseErrorHeader(HttpStatus status, int code, String message) {
    return ResponseEntity.status(status)
            .header("X-Error-Code", String.valueOf(code))
            .header("X-Error-Message", URLEncoder.encode(message, StandardCharsets.UTF_8))
            .build();
}

 

3. transmission\src\main\java\com\example\transmission\controller\DownloadProcessController.java 파일을 수정합니다.

응답 데이터를 Object에서 byte[]로 변경합니다.

public ResponseEntity<byte[]> downloadProcess(@ModelAttribute DownloadProcessDto downloadProcessDto) {

 

문자열(String)을 읽어오는 downloadRedisService.getStringInList()를 바이트 배열(byte[])로 읽어오는 downloadRedisService.getByteInList()로 변경합니다.

// Redis에서 읽기
String key = "download:" + orginFileID;
byte[] chunkData = downloadRedisService.getByteInList(key, chunkPos);
if (chunkData == null) {
    return responseError(HttpStatus.INTERNAL_SERVER_ERROR, 996, "다운로드 파일을 읽는 중에 오류가 발생하였습니다.");
}

 

기존 JSON 형식으로 전달되던 정보와 데이터를 application/octet-stream 형식으로 변경해서 헤더(Header)에 포함시키고 본문(Body)에는 바이트 배열(byte[])로 전송되도록 변경합니다.

// HTTP 헤더 설정
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
headers.setContentLength(contentLength);

// 제어 정보 헤더 추가
headers.add("X-Result", "success");
headers.add("X-File-ID", fileID);
headers.add("X-Chunk-Size", String.valueOf(contentLength));
headers.add("X-Chunk-Count", String.valueOf(chunkCount));
headers.add("X-Chunk-Pos", String.valueOf(chunkPos));
headers.add("X-Response-Code", "100");

return new ResponseEntity<>(chunkData, headers, HttpStatus.OK);

 

4. transmission\src\main\webapp\scripts\transmission\download.js 파일을 수정합니다.

ajax에서 응답 타입을 JSON에서 바이너리(Blob)로 받게 변경합니다.

this.downloadFileProcess = function(downloadFileItem) {
    :
    $.ajax({
        :
        dataType: 'json',
        :
    });

 

변경 후

this.downloadFileProcess = function(downloadFileItem) {
    :
    $.ajax({
        :
        xhrFields: {
            // 응답 데이터를 바이너리(Blob) 객체로 받기 위해 설정
            responseType: 'blob'
        },
        :
    });

 

ajax에서 동기 방식(async: false)과 응답 타입인 Blob(responseType: 'blob')은 동시에 사용할 수 없습니다.

그래서 동기 방식으로 처리되게 async-await으로 변경합니다.

this.downloadFileProcess = function(downloadFileItem) {
    :
    let resultData = {};
    $.ajax({
        :
        async: false,
        :
    });

 

변경 후

this.downloadFileProcess = async function(downloadFileItem) {
    :
    try {
        let resultData = {};
        await $.ajax({
        :

 

Ajax 요청 시 error 콜백 대신 try-catch 문을 사용하고 catch 블록에서 throw로 에러를 던져 외부 then-catch에서 처리할 수 있게 변경합니다.

this.downloadFileProcess = function(downloadFileItem) {
    :
    let resultData = {};
    $.ajax({
        :
        success: function(resData) {
            resultData = resData;
        },
        error: function(xhr, status, error) {
            resultData = xhr.responseJSON;
        }
    });

 

async/await 구조에서 $.ajax 안에서 error 콜백을 처리해도 외부에서는 resultData를 받을 수 없고 외부에서 try-catch로 해도 error 정보를 받을 수 없습니다. 

그래서 아래와 같이 error 콜백을 지우면 $.ajax가 실패할 때 자동으로 reject되어 외부 try-catch로 전달됩니다.

error 객체에는 xhr, status, error 정보가 포함되어 있습니다.

 

변경 후

this.downloadFileProcess = async function(downloadFileItem) {
    :
    try {
        let resultData = {};
        await $.ajax({
        });

        return resultData;
    } catch (error) {
        let errorData = {};

        throw errorData;
    }

 

 

정보가 본문이 아닌 응답 헤더(Header)로 오기 때문에 응답 헤더(Header)에서 정보를 추출하여 resultData를 생성합니다.

this.downloadFileProcess = function(downloadFileItem) {
    :
    let resultData = {};
    $.ajax({
        :
        success: function(resData) {
            resultData = resData;
        },
        :
    });
this.downloadFileProcess = async function(downloadFileItem) {
    :
    try {
        let resultData = {};
        await $.ajax({
            success: function(blobData, textStatus, request) {
                // 응답 Header에서 제어 정보 추출
                const result = request.getResponseHeader('X-Result');
                const fileID = request.getResponseHeader('X-File-ID');
                const chunkSize = parseInt(request.getResponseHeader('X-Chunk-Size'), 10);
                const chunkCount = parseInt(request.getResponseHeader('X-Chunk-Count'), 10);
                const chunkPos = parseInt(request.getResponseHeader('X-Chunk-Pos'), 10);
                const code = parseInt(request.getResponseHeader('X-Response-Code'), 10);

                resultData = {
                    'result': result,
                    'fileID': fileID,
                    'chunkSize': chunkSize,
                    'chunkCount': chunkCount,
                    'chunkPos': chunkPos,
                    'chunkData': blobData, // Blob 객체
                    'code': code,
                    'message': '청크 데이터가 다운로드 되었습니다.'
                };
            }
        });

        return resultData;
    }

 

에러 발생 시에도 응답 헤더(Header)에서 정보를 추출하여 errorData를 생성합니다.

그리고 외부에서 에러를 처리하기 위해 throw로 에러를 던져줍니다.

this.downloadFileProcess = function(downloadFileItem) {
    :
    let resultData = {};
    $.ajax({
        :
        error: function(xhr, status, error) {
            resultData = xhr.responseJSON;
        }
    });
this.downloadFileProcess = async function(downloadFileItem) {
    :
    } catch (error) {
        let errorData = {};

        // 응답 Header에서 에러 코드와 메시지 정보 추출
        const errorCode = error.getResponseHeader('X-Error-Code');
        let errorMessage = error.getResponseHeader('X-Error-Message') || '';
        errorMessage = decodeURIComponent(errorMessage.replace(/\+/g, " "));

        if (errorCode && errorMessage) {
            errorData = {
                'result': 'failure',
                'code': errorCode,
                'message': errorMessage
            };
        } else {
            errorData = {
                'result': 'failure',
                'code': '500',
                'message': '서버 오류로 인해 정보를 가져오지 못하였습니다.'
            };
        }

        throw errorData;
    }

 

Promise를 반환하는 함수를 처리하기 위해 then()과 화살표 함수를 사용했습니다.

화살표 함수는 자신만의 this를 생성하지 않고 상위 스코프의 this를 그대로 유지(상속)합니다.

this.downloadProcess = function(downloadFileItem) {
    :
    this.downloadFileProcess(downloadFileItem).then((resultData) => {
    	:
        this.downloadProgress(downloadFileItem);
        :
    })
});

 

화살표 함수를 사용하지 않을 경우에는 then()에 진입하기 전에 위에서 this를 self 변수에 할당하고 then()에서 사용하면됩니다.

const self = this;
:
this.downloadFileProcess(downloadFileItem).then(function(resultData) {
	:
    self.downloadProgress(downloadFileItem);
    :
})

 

또는 bind()로 this를 전달할 수 있습니다.

this.downloadFileProcess(downloadFileItem).then(function(resultData) {
}.bind(this))

 

전체 코드

// 다운로드
const Transmission_Download = function() {
    // 카테고리
    this.category = "";
    // 다운로드 진행 프로그레스 (CallBack)
    this.onDownloadProgress = null;
    // 다운로드 완료 (CallBack)
    this.onDownloadDone = null;
    // 다운로드 파일 리스트
    this.downloadFileList = [];
    // 다운로드 시작
    this.downloadStart = function(fileID) {
        let downloadFileItem = this.downloadFileList.find(function(item, index) {
            return item.fileID == fileID;
        });

        if (downloadFileItem == null) {
            downloadFileItem = {
                'state' : 1, // 다운로드 상태 (0:준비, 1:대기, 2:체크, 3:진행, 4:중지, 5:완료, 8:삭제, 9:에러)
                'stateMessage' : '', // 다운로드 상태 메시지
                'fileID' : fileID, // 파일 ID
                'fileName' : '', // 파일 명
                'downloadFileID' : '', // 다운로드 파일 ID
                'chunkSize' : 0, // 청크 크기
                'chunkCount' : 0, // 청크 수
                'chunkPos' : 0, // 청크 위치
                'chunkData' : [] // 청크 데이터
            };
            this.downloadFileList.push(downloadFileItem);
        } else {
            if (downloadFileItem.state == 4) {
                downloadFileItem.state = 3;
            }
        }

        const me = this;
        setTimeout(function() {
            me.downloadLoop();
        }, 100);
    }
    // 다운로드 중지
    this.downloadPause = function(fileID) {
        const downloadFileItem = this.downloadFileList.find(function(item, index) {
            return item.fileID == fileID;
        });
        if (downloadFileItem.state < 5) {
            downloadFileItem.state = 4;
        }
    }
    // 다운로드 취소
    this.downloadStop = function(fileID) {
        const downloadFileItem = this.downloadFileList.find(function(item, index) {
            return item.fileID == fileID;
        });
        if (downloadFileItem.state == 4) {
            downloadFileItem.state = 8;
            const me = this;
            setTimeout(function() {
                me.downloadLoop();
            }, 100);
        }
    }
    // 다운로드 루프
    this.downloadLoop = function() {
        for (let index = 0; index < this.downloadFileList.length; index++) {
            const downloadFileItem = this.downloadFileList[index];
            if (downloadFileItem.state == 1) {
                const resultData = this.downloadFileCheck(downloadFileItem);
                if (resultData.result == "success") {
                    downloadFileItem.state = 2;
                    downloadFileItem.downloadFileID = resultData.fileID;
                    downloadFileItem.fileName = resultData.fileName;
                    downloadFileItem.chunkSize = resultData.chunkSize;
                    downloadFileItem.chunkCount = resultData.chunkCount;
                    downloadFileItem.chunkPos = resultData.chunkPos;
                    downloadFileItem.stateMessage = resultData.message;

                    var process = true;

                    downloadFileItem.state = 3;

                    if (process) {
                        this.downloadProcess(downloadFileItem);
                    }
                } else {
                    downloadFileItem.state = 9;
                    downloadFileItem.stateMessage = resultData.message;
                    if (this.onDownloadProgress != null) {
                        this.onDownloadProgress(downloadFileItem.fileID, 0);
                    }
                    if (this.onDownloadDone != null) {
                        this.onDownloadDone(downloadFileItem.fileID);
                    }

                    this.downloadFileList.splice(index, 1);
                    index++;
                }
            } else if (downloadFileItem.state == 3) {
                this.downloadProcess(downloadFileItem);
            } else if (downloadFileItem.state == 5 || downloadFileItem.state == 8 || downloadFileItem.state == 9) {
                if (this.onDownloadProgress != null) {
                    this.onDownloadProgress(downloadFileItem.fileID, 0);
                }
                if (this.onDownloadDone != null) {
                    this.onDownloadDone(downloadFileItem.fileID);
                }

                this.downloadFileList.splice(index, 1);
                index++;
            }
        }
    }
    // 다운로드 파일 체크
    this.downloadFileCheck = function(downloadFileItem) {
        const formData = new FormData();
        formData.append("category", this.category);
        formData.append("fileID", downloadFileItem.fileID);

        let resultData = {};
        $.ajax({
            url: '/repository/downloadCheck',
            type: 'POST',
            enctype : "multipart/form-data",
            data: formData,
            dataType: 'json',
            async: false,
            contentType: false,
            processData: false,
            success: function(resData) {
                resultData = resData;
            },
            error: function(xhr, status, error) {
                resultData = xhr.responseJSON;
            }
        });

        return resultData;
    }
    this.downloadFileProcess = async function(downloadFileItem) {
        const formData = new FormData();
        formData.append("fileID", downloadFileItem.downloadFileID);
        formData.append("chunkPos", downloadFileItem.chunkPos);

        try {
            let resultData = {};
            await $.ajax({
                url: '/repository/downloadProcess',
                type: 'POST',
                enctype : "multipart/form-data",
                data: formData,
                xhrFields: {
                    // 응답 데이터를 바이너리(Blob) 객체로 받기 위해 설정
                    responseType: 'blob'
                },
                contentType: false,
                processData: false,
                success: function(blobData, textStatus, request) {
                    // 응답 Header에서 제어 정보 추출
                    const result = request.getResponseHeader('X-Result');
                    const fileID = request.getResponseHeader('X-File-ID');
                    const chunkSize = parseInt(request.getResponseHeader('X-Chunk-Size'), 10);
                    const chunkCount = parseInt(request.getResponseHeader('X-Chunk-Count'), 10);
                    const chunkPos = parseInt(request.getResponseHeader('X-Chunk-Pos'), 10);
                    const code = parseInt(request.getResponseHeader('X-Response-Code'), 10);

                    resultData = {
                        'result': result,
                        'fileID': fileID,
                        'chunkSize': chunkSize,
                        'chunkCount': chunkCount,
                        'chunkPos': chunkPos,
                        'chunkData': blobData, // Blob 객체
                        'code': code,
                        'message': '청크 데이터가 다운로드 되었습니다.'
                    };
                }
            });

            return resultData;
        } catch (error) {
            let errorData = {};

            // 응답 Header에서 에러 코드와 메시지 정보 추출
            const errorCode = error.getResponseHeader('X-Error-Code');
            let errorMessage = error.getResponseHeader('X-Error-Message') || '';
            errorMessage = decodeURIComponent(errorMessage.replace(/\+/g, " "));

            if (errorCode && errorMessage) {
                errorData = {
                    'result': 'failure',
                    'code': errorCode,
                    'message': errorMessage
                };
            } else {
                errorData = {
                    'result': 'failure',
                    'code': '500',
                    'message': '서버 오류로 인해 정보를 가져오지 못하였습니다.'
                };
            }

            throw errorData;
        }
    }
    // 다운로드 진행
    // 청크 데이터 다운로드 (청크 수 만큼 재귀 호출)
    this.downloadProcess = function(downloadFileItem) {
        const me = this;
        if (downloadFileItem.state != 3) {
            setTimeout(function() {
                me.downloadLoop();
            }, 100);
            return;
        }

        this.downloadFileProcess(downloadFileItem).then((resultData) => {
            if (resultData.result == "success") {
                downloadFileItem.chunkCount = resultData.chunkCount;
                downloadFileItem.chunkPos = resultData.chunkPos;

                downloadFileItem.chunkData.push(resultData.chunkData);

                this.downloadProgress(downloadFileItem);

                if (downloadFileItem.chunkPos == downloadFileItem.chunkCount) {
                    downloadFileItem.state = 5;
                    downloadFileItem.stateMessage = "다운로드 완료";

                    // 다운로드
                    const blob = new Blob(downloadFileItem.chunkData);
                    const aTag = document.createElement('a');
                    aTag.href = URL.createObjectURL(blob);
                    aTag.download = downloadFileItem.fileName;
                    aTag.click();
                    URL.revokeObjectURL(aTag.href);

                    // 청크 데이터 삭제
                    downloadFileItem.chunkData = [];

                    setTimeout(function() {
                        me.downloadLoop();
                    }, 100);
                } else {
                    setTimeout(function() {
                        me.downloadProcess(downloadFileItem);
                    }, 10);
                }
            } else {
                downloadFileItem.state = 9;
                downloadFileItem.stateMessage = resultData.message;
                setTimeout(function() {
                    me.downloadLoop();
                }, 100);
            }
        }).catch((error) => {
            downloadFileItem.state = 9;
            downloadFileItem.stateMessage = error.message;
            setTimeout(function() {
                me.downloadLoop();
            }, 100);
        });
    }
    // 다운로드 진행 프로그레스 바 위치
    this.downloadProgress = function(downloadFileItem) {
        if (this.onDownloadProgress != null) {
            const percentComplete = downloadFileItem.chunkPos / downloadFileItem.chunkCount;
            const percent = percentComplete * 100;
            this.onDownloadProgress(downloadFileItem.fileID, percent);
        }
    }
}

 

 

 

 

 

728x90
반응형