Spring/대용량 파일 전송

[4] 대용량 파일 전송 시스템 개발 - 대용량 파일 업로드, Blob

carrotweb 2026. 8. 20. 19:48
728x90
반응형

대용량 파일 업로드

대용량 파일을 청크로 분리하여 업로드하는 프로세스를 시퀀스 다이어그램(Sequence Diagram)으로 표현했습니다.

 

Sequence Diagram(시퀀스 다이어그램)

 

웹 브라우저에서 대용량 파일을 청크(Chunk)로 분리하기 위해서 slice() 함수를 사용하면 됩니다.

 

slice (https://developer.mozilla.org/en-US/docs/Web/API/Blob/slice)

File API의 slice 함수를 사용하여 Blob(Binary Large Object)를 가져옵니다.

 

파일에서 시작 바이트(begin)부터 끝 바이트(end)까지(end 미포함) 복사하여 Blob(Binary Large Object) 객체로 반환합니다.

slice(start, end);
// 업로드 파일 시작 위치 (Byte)
const startPos = uploadFileItem.chunkPos * uploadFileItem.chunkSize;
// 업로드 파일 종료 위치 (Byte)
const endPos = Math.min(uploadFileItem.file.size, startPos + uploadFileItem.chunkSize);
// 청크 데이터
let chunkData = uploadFileItem.file.slice(startPos, endPos);

 

Blob(Binary Large Object) – 블롭/블랍/비랍

Blob (https://developer.mozilla.org/en-US/docs/Web/API/Blob)
Blob는 이진 데이터(Binary Data)를 처리하는 객체입니다.

 

시퀀스 다이어그램(Sequence Diagram)에 있는 순서로 설명하겠습니다.

1. 클라이언트에서 파일들(하나 또는 여러 개)을 선택하면 파일별로 업로드 파일 리스트 배열에 정보를 추가합니다.

// 업로드 파일 리스트
this.uploadFileList = [];

// 업로드 파일 순서번호
this.uploadFileOrder = 1;
const files = event.target.files ? event.target.files : event.originalEvent.dataTransfer.files;

for (let index = 0; index < files.length; index++) {
    const file = files[index];
    uploadFileItem = {
        'fileOrder' : this.uploadFileOrder++, // 업로드 파일 순서번호
        'state' : 0, // 업로드 상태 (0:준비, 1:대기, 2:체크, 3:진행, 4:중지, 5:완료, 8:삭제, 9:에러)
        'stateMessage' : '', // 업로드 상태 메시지
        'file' : file, // 업로드 파일
        'fileID' : '', // 업로드 파일 ID
        'chunkSize' : (1024 * 100), // 청크 크기 - 기본값 100KB
        'chunkCount' : 0, // 청크 수
        'chunkPos' : 0 // 청크 위치
    };
    this.uploadFileList.push(uploadFileItem);
}

업로드 진행 상태는 0(준비) → 1(대기) → 2(체크 : 업로드 파일 체크) → 3(진행 : 업로드 루프) → 5(완료 : 업로드 완료) 입니다.

  • 1(대기) 상태에서 시작 버튼을 누르면 2(체크 : 업로드 파일 체크) 상태로 변경 후 3(진행 : 업로드 루프) 상태로 됩니다.
  • 3(진행 : 업로드 루프) 상태에서 중지 버튼을 누르면 4(중지 : 업로드 중지) 상태로 됩니다.
  • 4(중지 : 업로드 중지) 상태에서 다시 시작 버튼을 누르면 3(진행 : 업로드 루프) 상태로 됩니다.
  • 4(중지 : 업로드 중지) 상태에서 삭제 버튼을 누르면 8(삭제 : 업로드 삭제) 상태로 됩니다.
  • 에러가 발생하면 모든 상태에서 9(에러) 상태로 됩니다.

시작 버튼을 누르면 업로드 루프(this.uploadLoop) 함수가 재귀호출되는 방식입니다.

 

2. 업로드 시작 버튼을 누르면 업로드 상태를 준비에서 대기로 변경하고 서버에 업로드할 파일의 정보(카테고리, 파일명, 파일크기)를 전송합니다.

// 업로드 파일 정보 체크
this.uploadFileCheck = function(uploadFileItem) {
    uploadFileItem.state = 1;
    
    :
    
    const formData = new FormData();
    formData.append("category", this.category);
    formData.append("fileName", uploadFileItem.file.name);
    formData.append("fileSize", uploadFileItem.file.size);

    let resultData = {};
    $.ajax({
        url: '/repository/uploadCheck',
        type: 'POST',
        enctype : "application/x-www-form-urlencoded",
        data: formData,
        dataType: 'json',
        async: false,
        contentType: false,
        processData: false,
        success: function(resData) {
            resultData = resData;
        },
        error: function(request, status, error) {
            if (request.status == 400) {
                resultData = request.responseJSON;
            } else {
                resultData = {'result': 'failure', 'errorCode': '500', 'errorMessage': '서버 오류로 인해 정보를 가져오지 못하였습니다.'};
            }
        }
    });
    
    :
 }

 

3. 서버에서 업로드할 파일 정보로 업로드 임시 파일 테이블에서 조회합니다. 업로드 중이면 파일 ID, 청크 크기, 전체 청크 수, 청크 위치를 가져옵니다.

// 업로드 파일 정보
TempUploadFile tempUploadFile = new TempUploadFile();
tempUploadFile.setCategory(category);
tempUploadFile.setOrginFileName(fileName);
tempUploadFile.setFileSize(fileSize);
tempUploadFile.setRegID(regID);

// 업로드 중인 건지 확인
TempUploadFile tempUploadFileItem = uploadService.selectTempUploadFileItem(tempUploadFile);
if (tempUploadFileItem != null) {
    fileID = tempUploadFileItem.getFileID();
    chunkSize = tempUploadFileItem.getChunkSize();
    chunkCount = tempUploadFileItem.getChunkCount();
    chunkPos = tempUploadFileItem.getChunkPos();
    code = 200;
    message = "업로드 중인 파일입니다.";
}

 

업로드 중이 아니면 카테고리 정보를 조회하여 청크 크기와 임시 저장 경로를 가져옵니다.

// 카테고리 정보 조회
Category categoryUpInfo = new Category();
categoryUpInfo.setCategoryID(category);
categoryUpInfo = uploadService.selectCategoryUpItem(categoryUpInfo);

chunkSize = categoryUpInfo.getUpChunkSize();
tempUploadPath = categoryUpInfo.getUpTempFilePath();

 

파일 ID를 생성하고 업로드 파일이 임시적으로 저장될 임시 파일 명과 임시 파일 경로를 생성합니다. 그리고 파일의 크기를 청크의 크기로 나누어 전송될 청크 수를 계산합니다.

// 업로드 파일 ID 생성
fileID = UUID.randomUUID().toString().replaceAll("-", "");
tempUploadFile.setFileID(fileID);

// 업로드 임시 파일 명 생성
String fileExtension = fileName.substring(posExt + 1);
String tempFileName = UUID.randomUUID().toString().replaceAll("-", "") + "." + fileExtension;
// 업로드 임시 파일 위치
String filePath = tempUploadPath + "/" + tempFileName;
tempUploadFile.setFilePath(filePath);

// 청크 크기
tempUploadFile.setChunkSize(chunkSize);

// 청크 수
chunkCount = (int)Math.ceil((double)fileSize / (double)chunkSize);
tempUploadFile.setChunkCount(chunkCount);

// 청크 위치
tempUploadFile.setChunkPos(0);

 

업로드할 파일 정보를 임시 파일 테이블에 등록하고 클라이언트에 전달합니다.

// 등록
boolean result = uploadService.insertTempUploadFileItem(tempUploadFile);

:

Map<String, Object> resultMap = new HashMap<>();
resultMap.put("result", "success");
resultMap.put("fileID", fileID);
resultMap.put("chunkSize", chunkSize);
resultMap.put("chunkCount", chunkCount);
resultMap.put("chunkPos", chunkPos);
resultMap.put("code", code);
resultMap.put("message", message);

ObjectMapper objectMapper = new ObjectMapper();
String resultJson = objectMapper.writeValueAsString(resultMap);

resp.setContentType("application/json");
resp.setCharacterEncoding("utf-8");
resp.setStatus(HttpServletResponse.SC_OK);
resp.getWriter().write(resultJson);

 

4. 클라이언트에서 업로드 루프를 통해 재귀호출합니다. 업로드 상태를 대기에서 체크로 변경하고 이전에 업로드 중일 경우 이어서 업로드할지 아니면 이전 업로드를 삭제하고 다시 업로드할지 선택하여 처리합니다. 업로드 상태를 체크에서 진행로 변경합니다.

// 업로드 루프
this.uploadLoop = function() {
    let complete = true;
    let completeCount = 0;
    for (let index = 0; index < this.uploadFileList.length; index++) {
        const uploadFileItem = this.uploadFileList[index];
        if (uploadFileItem.state == 1) {
            const resultData = this.uploadFileCheck(uploadFileItem);
            
            :
            
            uploadFileItem.state = 2;
            uploadFileItem.fileID = resultData.fileID;
            uploadFileItem.chunkSize = resultData.chunkSize;
            uploadFileItem.chunkCount = resultData.chunkCount;
            uploadFileItem.chunkPos = resultData.chunkPos;
            uploadFileItem.publicKey = resultData.publicKey;
            uploadFileItem.useCrypto = resultData.useCrypto;

            var process = true;
            // 이전에 업로드된 위치가 있으면 처리
            if (uploadFileItem.chunkPos > 0) {
                let result = true;
                result = confirm("동일한 파일 이름으로 업로드 중 중지된 파일입니다.\n이어서 업로드 할까요?\n취소하면 다시 처음부터 업로드 합니다.");
                if (result) {
                    this.uploadProgressPos(uploadFileItem);
                } else {
                    const resultDeleteData = this.uploadFileDelete(uploadFileItem);
                    if (resultDeleteData.result == "success") {
                        const resultNewData = this.uploadFileCheck(uploadFileItem);
                        if (resultNewData.result == "success") {
                            uploadFileItem.fileID = resultNewData.fileID;
                            uploadFileItem.chunkSize = resultNewData.chunkSize;
                            uploadFileItem.chunkCount = resultData.chunkCount;
                            uploadFileItem.chunkPos = resultNewData.chunkPos;
                            uploadFileItem.publicKey = resultData.publicKey;
                            uploadFileItem.useCrypto = resultData.useCrypto;
                        } else {
                            process = false;
                            alert(resultNewData.errorMessage);
                        }
                    }
                }
            }
            uploadFileItem.state = 3;

            if (process) {
                this.uploadFileProcess(uploadFileItem);
            }
        }
        :
    }
}

 

5. 클라이언트에 청크 크기와 청크 위치를 계산하여 파일에서 시작 바이트(begin)부터 끝 바이트(end)까지 복사하여 Blob 객체를 생성하여 서버로 전달합니다.

// 업로드 진행
// 파일을 청크 크기로 잘라서 업로드 (청크 수 만큼 재귀 호출)
this.uploadFileProcess = async function(uploadFileItem) {

    :

    // 업로드 파일 시작 위치 (Byte)
    const startPos = uploadFileItem.chunkPos * uploadFileItem.chunkSize;
    // 업로드 파일 종료 위치 (Byte)
    const endPos = Math.min(uploadFileItem.file.size, startPos + uploadFileItem.chunkSize);
    // 청크 데이터
    let chunkData = uploadFileItem.file.slice(startPos, endPos);

    const me = this;

    var formData = new FormData();
    formData.append("fileID", uploadFileItem.fileID);
    formData.append("chunkPos", uploadFileItem.chunkPos);
    formData.append("file", chunkData);

    $.ajax({
        url: '/repository/uploadProcess',
        type: 'POST',
        enctype : "multipart/form-data",
        data: formData,
        async: false,
        contentType: false,
        processData: false,
        success: function(resData) {
            uploadFileItem.chunkPos = resData.chunkPos;
            uploadFileItem.chunkCount = resData.chunkCount;
            if (uploadFileItem.chunkPos == uploadFileItem.chunkCount) {
                uploadFileItem.state = 5;
                uploadFileItem.stateMessage = "업로드 완료";
                setTimeout(function() {
                    me.uploadLoop();
                }, 100);
            } else {
                setTimeout(function() {
                    me.uploadFileProcess(uploadFileItem);
                }, 10);
            }
        },
        error: function(request, status, error) {
            uploadFileItem.state = 9;
            uploadFileItem.stateMessage = request.responseJSON.errorMessage;
            :
        }
    });
}

 

6. 서버에서 파일 ID로 업로드 임시 파일 테이블에서 조회해서 임시 파일에 이어서 저장하고 청크 위치를 증가시켜 업로드 임시 파일 테이블에서 업데이트한 후 클라이언트에 전달합니다.

// 업로드 파일 정보
TempUploadFile tempUploadFile = new TempUploadFile();
tempUploadFile.setFileID(fileID);
tempUploadFile.setRegID(regID);

// 업로드 중인 건지 확인
TempUploadFile tempUploadFileItem = uploadService.selectTempUploadFileProcessItem(tempUploadFile);
if (tempUploadFileItem != null) {
    if (chunkPos == tempUploadFileItem.getChunkPos()) {
        
        :
        
        // 파일 쓰기 스트림
        FileOutputStream fos = new FileOutputStream(tempFile, append);

        // 청크 파일 읽기
        InputStream chunkFileInputStream = chunkFile.getInputStream();

        byte[] buffer = new byte[1024]; // 버퍼 크기 설정
        int bytesRead = -1;
        // Blob
        while ((bytesRead = chunkFileInputStream.read(buffer)) != -1) {
            fos.write(buffer, 0, bytesRead);
        }
        
        :
        
        fos.close();
        chunkFileInputStream.close();

        // 청크 위치 업데이트
        chunkPos++;
        tempUploadFile.setChunkPos(chunkPos);
        boolean result = uploadService.updateUploadChunkPosItem(tempUploadFile);
        if (result) {
            Map<String, Object> resultMap = new HashMap<>();
            resultMap.put("result", "success");
            resultMap.put("fileID", fileID);
            resultMap.put("chunkSize", tempUploadFileItem.getChunkSize());
            resultMap.put("chunkCount", tempUploadFileItem.getChunkCount());
            resultMap.put("chunkPos", chunkPos);
            resultMap.put("code", 100);
            resultMap.put("message", "청크 파일이 업로드 되었습니다.");
           }
       
        :
       
        ObjectMapper objectMapper = new ObjectMapper();
        String resultJson = objectMapper.writeValueAsString(resultMap);

        resp.setContentType("application/json");
        resp.setCharacterEncoding("utf-8");
        resp.setStatus(HttpServletResponse.SC_OK);
        resp.getWriter().write(resultJson);
    }
}

 

728x90
반응형