Vue.js와 Spring Boot를 활용한 대용량 파일 분할 업로드

대용량 파일을 웹 애플리케이션에 업로드하는 과정은 네트워크 불안정성, 서버 메모리 제한, 시간 초과 등 여러 문제로 인해 어려움을 겪을 수 있습니다. 이러한 문제를 해결하기 위한 일반적인 방법 중 하나는 파일을 여러 개의 작은 조각(청크)으로 나누어 업로드하고, 서버에서 이 조각들을 다시 병합하는 '분할 업로드(Chunked Upload)' 방식입니다. 이 문서는 Vue.js 기반의 프런트엔드와 Spring Boot 기반의 백엔드를 사용하여 대용량 파일 분할 업로드를 구현하는 방법을 설명합니다.

1. 프런트엔드 구현 (Vue.js)

프런트엔드에서는 사용자가 파일을 선택하고, 파일을 청크로 분할하여 서버에 전송하는 로직을 담당합니다.

1.1 초기 설정 및 변수

업로드할 파일과 청크 관련 정보를 관리하기 위한 변수를 정의합니다.

data() {
  return {
    uploadedFile: null, // 현재 선택된 파일 객체
    maxChunkCount: 20, // 파일을 나눌 최대 청크 개수 (고정 개수 방식)
    uploadProgress: 0, // 전체 업로드 진행률 (0-100%)
    uploadedBytes: '0 B', // 업로드된 바이트를 가독성 좋은 형태로 표시
    uploadIdentifier: '', // 각 파일 업로드 세션을 위한 고유 ID
    isUploading: false, // 업로드 진행 상태
  };
},

1.2 파일 선택 및 분할 처리

사용자가 파일을 선택하면 `handleFileSelection` 메서드가 호출됩니다. 이 메서드에서 파일 크기를 확인하고, 특정 크기(예: 500MB)보다 크면 파일을 여러 청크로 분할하여 업로드를 시작합니다.

<!-- 파일 선택 버튼 -->
<el-button @click="triggerFileUpload" type="primary" :disabled="isUploading">파일 업로드</el-button>
<input type="file" ref="fileInput" style="display: none;" @change="handleFileSelection">

<!-- 업로드 진행률 표시 -->
<div v-if="isUploading">
  진행률: {{ uploadProgress }}% ({{ uploadedBytes }})
</div>

methods: {
  // 파일 입력 트리거
  triggerFileUpload() {
    this.$refs.fileInput.click();
  },

  // 파일 선택 처리
  handleFileSelection(event) {
    const file = event.target.files[0];
    if (!file) {
      return;
    }

    this.uploadedFile = file;
    this.uploadIdentifier = this.generateUniqueId(); // 업로드 세션 고유 ID 생성
    this.uploadProgress = 0;
    this.uploadedBytes = '0 B';
    this.isUploading = true;

    const fileSize = file.size;
    const FIVE_HUNDRED_MB = 500 * 1024 * 1024;

    if (fileSize <= FIVE_HUNDRED_MB) {
      // 500MB 이하 파일은 전체 파일로 업로드
      this.uploadFullFile(file);
    } else {
      // 500MB 초과 파일은 분할하여 업로드
      this.uploadFileInChunks(file, fileSize);
    }
  },

  // 청크 단위 파일 업로드 시작
  uploadFileInChunks(file, fileSize) {
    const chunkSize = Math.ceil(fileSize / this.maxChunkCount); // 청크당 크기 계산
    const totalChunks = Math.ceil(fileSize / chunkSize); // 총 청크 개수

    let chunksUploaded = 0; // 업로드된 청크 수

    for (let i = 0; i < totalChunks; i++) {
      const start = i * chunkSize;
      const end = Math.min(fileSize, start + chunkSize);
      const chunk = file.slice(start, end); // 파일 청크 생성

      this.uploadChunk(this.uploadIdentifier, chunk, file.name, totalChunks, i)
        .then(() => {
          chunksUploaded++;
          this.updateProgress(fileSize, chunkSize, chunksUploaded);

          if (chunksUploaded === totalChunks) {
            this.mergeUploadedChunks(this.uploadIdentifier, file.name);
          }
        })
        .catch(error => {
          console.error(`청크 ${i} 업로드 실패:`, error);
          this.isUploading = false;
          this.$message.error('파일 청크 업로드 중 오류 발생');
        });
    }
  },

  // 고유 ID 생성 (간단한 예시)
  generateUniqueId() {
    return 'upload_' + Date.now() + '_' + Math.random().toString(36).substring(2, 8);
  },

  // 진행률 업데이트
  updateProgress(totalSize, chunkSize, uploadedChunks) {
    const currentUploadedSize = chunkSize * uploadedChunks;
    this.uploadProgress = Math.min(100, Math.round((currentUploadedSize / totalSize) * 100));
    this.uploadedBytes = this.formatBytes(currentUploadedSize);
  },

  // 바이트 단위 포맷팅
  formatBytes(bytes) {
    if (bytes === 0) return '0 B';
    const k = 1024;
    const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
  },

  // ... (API 호출 메서드는 아래에서 정의)
}

1.3 청크 업로드 및 병합 API 호출

각 청크를 서버에 전송하고, 모든 청크가 성공적으로 업로드되면 병합 요청을 보냅니다.

// 청크 파일 업로드 API 호출
async uploadChunk(uploadId, chunkFile, originalFileName, totalChunks, chunkIndex) {
  const formData = new FormData();
  formData.append("uploadId", uploadId);
  formData.append("chunkFile", chunkFile);
  formData.append("fileName", originalFileName);
  formData.append("totalChunks", totalChunks);
  formData.append("chunkIndex", chunkIndex);

  try {
    const response = await this.$http({ // 'this.$http'는 Axios 인스턴스라고 가정
      url: `/api/files/chunk-upload`,
      method: 'post',
      data: formData,
      headers: {
        'Content-Type': 'multipart/form-data'
      }
    });
    if (response.data.code !== 200) {
      throw new Error(response.data.message || '청크 업로드 실패');
    }
    return response.data;
  } catch (error) {
    console.error("청크 업로드 요청 오류", error);
    throw error;
  }
},

// 청크 병합 API 호출
async mergeUploadedChunks(uploadId, originalFileName) {
  const mergeData = new FormData();
  mergeData.append("uploadId", uploadId);
  mergeData.append("fileName", originalFileName);

  try {
    const response = await this.$http({
      url: `/api/files/merge-chunks`,
      method: 'post',
      data: mergeData,
      headers: {
        'Content-Type': 'multipart/form-data'
      }
    });
    if (response.data.code === 200) {
      this.$message.success('파일 업로드 및 병합 성공');
      this.isUploading = false;
      // 추가적인 UI 업데이트 또는 파일 목록 새로고침 로직
    } else {
      this.$message.error(response.data.message || '파일 병합 실패');
      this.isUploading = false;
    }
  } catch (error) {
    console.error("파일 병합 요청 오류", error);
    this.$message.error('파일 병합 중 오류 발생');
    this.isUploading = false;
  }
},

// 전체 파일 업로드 (500MB 이하 파일용)
async uploadFullFile(file) {
  const formData = new FormData();
  formData.append('file', file);
  formData.append('fileName', file.name);

  try {
    const response = await this.$http({
      url: `/api/files/full-upload`,
      method: 'post',
      data: formData,
      onUploadProgress: (progressEvent) => {
        this.uploadProgress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
        this.uploadedBytes = this.formatBytes(progressEvent.loaded);
      },
      headers: {
        'Content-Type': 'multipart/form-data'
      }
    });
    if (response.data.code === 200) {
      this.$message.success('파일 전체 업로드 성공');
      this.isUploading = false;
    } else {
      this.$message.error(response.data.message || '파일 전체 업로드 실패');
      this.isUploading = false;
    }
  } catch (error) {
    console.error("전체 파일 업로드 요청 오류", error);
    this.$message.error('파일 전체 업로드 중 오류 발생');
    this.isUploading = false;
  }
}

2. 백엔드 구현 (Spring Boot)

백엔드에서는 프런트엔드로부터 전송된 파일 청크를 수신하고, 임시 저장소에 저장한 후, 모든 청크가 완료되면 최종적으로 하나의 파일로 병합하는 로직을 구현합니다.

2.1 파일 저장 경로 설정

`application.properties` 또는 `application.yml` 파일에 파일 저장 경로를 설정합니다.

# application.properties 예시
app.upload.temp-dir=/app/uploads/temp
app.upload.final-dir=/app/uploads/final

2.2 Spring Boot 컨트롤러

파일 업로드 및 병합을 처리하는 REST API 엔드포인트를 정의합니다.

import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.nio.file.*;
import java.util.Comparator;
import java.util.stream.Stream;

// 간단한 API 응답 클래스 (실제 프로젝트에서는 더 상세하게 구현)
class ApiResponse {
    private int code;
    private String message;
    // Getters and Setters
    public ApiResponse(int code, String message) { this.code = code; this.message = message; }
    public int getCode() { return code; }
    public void setCode(int code) { this.code = code; }
    public String getMessage() { return message; }
    public void setMessage(String message) { this.message = message; }
}

@RestController
@RequestMapping("/api/files")
public class FileUploadController {

    @Value("${app.upload.temp-dir}")
    private String tempUploadDir; // 청크 파일 임시 저장 경로
    @Value("${app.upload.final-dir}")
    private String finalUploadDir; // 최종 병합 파일 저장 경로

    /**
     * 작은 파일 전체 업로드 처리
     */
    @PostMapping("/full-upload")
    public ResponseEntity<ApiResponse> handleFullFileUpload(
            @RequestParam("file") MultipartFile multipartFile,
            @RequestParam("fileName") String originalFileName) {
        try {
            Path targetPath = Paths.get(finalUploadDir, originalFileName);
            Files.createDirectories(targetPath.getParent()); // 상위 디렉토리 생성
            Files.copy(multipartFile.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING);
            return ResponseEntity.ok(new ApiResponse(200, "파일 전체 업로드 성공"));
        } catch (IOException e) {
            e.printStackTrace();
            return ResponseEntity.status(500).body(new ApiResponse(500, "파일 전체 업로드 실패: " + e.getMessage()));
        }
    }

    /**
     * 파일 청크 업로드 처리
     */
    @PostMapping("/chunk-upload")
    public ResponseEntity<ApiResponse> handleChunkFileUpload(
            @RequestParam("uploadId") String uploadIdentifier,
            @RequestParam("chunkFile") MultipartFile chunkPart,
            @RequestParam("fileName") String originalFileName,
            @RequestParam("totalChunks") Integer totalChunks,
            @RequestParam("chunkIndex") Integer chunkIndex) {

        try {
            // 청크 저장을 위한 임시 디렉토리 생성 (uploadId별로 구분)
            Path chunkTempDir = Paths.get(tempUploadDir, uploadIdentifier);
            Files.createDirectories(chunkTempDir);

            // 청크 파일 이름: {chunkIndex}.part
            Path chunkFilePath = chunkTempDir.resolve(chunkIndex + ".part");
            Files.copy(chunkPart.getInputStream(), chunkFilePath, StandardCopyOption.REPLACE_EXISTING);

            return ResponseEntity.ok(new ApiResponse(200, "청크 " + chunkIndex + " 업로드 성공"));
        } catch (IOException e) {
            e.printStackTrace();
            return ResponseEntity.status(500).body(new ApiResponse(500, "청크 업로드 실패: " + e.getMessage()));
        }
    }

    /**
     * 업로드된 청크 파일 병합
     */
    @PostMapping("/merge-chunks")
    public ResponseEntity<ApiResponse> mergeFileChunks(
            @RequestParam("uploadId") String uploadIdentifier,
            @RequestParam("fileName") String originalFileName) {

        Path chunkTempDir = Paths.get(tempUploadDir, uploadIdentifier);
        Path finalFileDir = Paths.get(finalUploadDir);
        Path finalFilePath = finalFileDir.resolve(originalFileName);

        if (!Files.exists(chunkTempDir) || !Files.isDirectory(chunkTempDir)) {
            return ResponseEntity.badRequest().body(new ApiResponse(400, "임시 청크 디렉토리를 찾을 수 없습니다: " + uploadIdentifier));
        }

        try {
            Files.createDirectories(finalFileDir); // 최종 파일 저장 디렉토리 생성
            Files.deleteIfExists(finalFilePath); // 기존 파일이 있다면 삭제 (덮어쓰기)

            // 청크 파일을 인덱스 순서대로 정렬하여 병합
            try (Stream<Path> chunkStream = Files.list(chunkTempDir)) {
                chunkStream
                    .filter(Files::isRegularFile)
                    .sorted(Comparator.comparingInt(p -> Integer.parseInt(p.getFileName().toString().replace(".part", ""))))
                    .forEach(chunkPath -> {
                        try {
                            Files.write(finalFilePath, Files.readAllBytes(chunkPath), StandardOpenOption.CREATE, StandardOpenOption.APPEND);
                        } catch (IOException e) {
                            throw new RuntimeException("청크 병합 중 오류 발생: " + chunkPath.getFileName(), e);
                        }
                    });
            }

            // 병합 완료 후 임시 청크 디렉토리 및 파일 삭제
            try (Stream<Path> chunkFileStream = Files.list(chunkTempDir)) {
                chunkFileStream.forEach(path -> {
                    try {
                        Files.delete(path);
                    } catch (IOException e) {
                        System.err.println("임시 청크 파일 삭제 실패: " + path + " - " + e.getMessage());
                    }
                });
            }
            Files.delete(chunkTempDir); // 임시 디렉토리 삭제

            return ResponseEntity.ok(new ApiResponse(200, "파일 병합 성공: " + originalFileName));

        } catch (Exception e) {
            e.printStackTrace();
            return ResponseEntity.status(500).body(new ApiResponse(500, "파일 병합 실패: " + e.getMessage()));
        }
    }
}

태그: Vue.js Spring Boot 대용량 파일 업로드 분할 업로드 청크 업로드

7월 28일 14:33에 게시됨