대용량 파일 다운로드를 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);
}
}
}
'Spring & Spring Boot > 대용량 파일 전송 시스템 개발' 카테고리의 다른 글
| [26] 대용량 파일 전송 시스템 개발 - application를 로컬(local), 개발(dev), 운영(prod) 환경으로 분리 (0) | 2026.09.30 |
|---|---|
| [25] 대용량 파일 전송 시스템 개발 - Redis의 Key 동시성 문제 해결, Redisson (0) | 2026.09.29 |
| [23] 대용량 파일 전송 시스템 개발 - 파일 다운로드에 Redis를 활용하기 (바이트 배열) 1 (0) | 2026.09.28 |
| [22] 대용량 파일 전송 시스템 개발 - 파일 다운로드에 Redis를 활용하기 (문자열) (0) | 2026.09.19 |
| [21] 대용량 파일 전송 시스템 개발 - Redis를 활용한 메모리 캐싱 (0) | 2026.09.17 |