WebUploader 기반 대용량 영상 파일 분할 업로드 및 무결성 검증 구현

개요

보험 청구 시스템에서 대용량 영상 파일을 안정적으로 업로드하기 위해 WebUploader를 활용한 분할 업로드 및 브라우저 호환 솔루션을 구현한다. 핵심 과제는 다양한 브라우저 환경에서의 안정성, 업로드 진행 상황 보존, 그리고 파일 무결성 검증이다.

아키텍처 설계

전체 흐름은 파일 해시 계산 → 분할 준비 → 순차/병렬 업로드 → 서버 측 병합 → 무결성 검증으로 구성된다. 브라우저 호환성을 위해 WebUploader의 Flash 폴백과 HTML5 API를 병행 활용한다.

핵심 컴포넌트 구조

// UploadManager.js - 업로드 핵심 관리자
class UploadManager {
  constructor(options) {
    this.chunkBytes = options.chunkSize || 8 * 1024 * 1024; // 8MB 기본값
    this.parallelLimit = options.concurrent || 3;
    this.retryMax = options.maxRetries || 5;
    this.apiEndpoints = {
      prepare: '/api/upload/prepare',
      segment: '/api/upload/segment',
      progress: '/api/upload/progress',
      finalize: '/api/upload/complete',
      remove: '/api/upload/cancel'
    };
    this.activeWorkers = new Map();
    this.pendingQueue = [];
  }

  // 파일 고유 식별자 생성 (SHA-256 기반)
  async generateFileSignature(file) {
    const sampleSize = Math.min(file.size, 4 * 1024 * 1024); // 4MB 샘플링
    const buffer = await file.slice(0, sampleSize).arrayBuffer();
    const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
    const hashArray = Array.from(new Uint8Array(hashBuffer));
    const baseId = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
    return `${baseId}-${file.size}-${file.lastModified}`;
  }

  // 분할 정보 구성
  buildSegmentPlan(file, signature) {
    const totalSegments = Math.ceil(file.size / this.chunkBytes);
    return {
      fileId: signature,
      fileName: file.name,
      totalSize: file.size,
      segmentCount: totalSegments,
      segmentSize: this.chunkBytes,
      mimeType: file.type || 'application/octet-stream'
    };
  }
}

프론트엔드 구현

Vue.js 기반 컴포넌트로 구현하며, WebUploader와 네이티브 API를 조부 적용한다.

<!-- VideoUploader.vue -->
<template>
  <div class="uploader-container">
    <div ref="dropZone" class="drop-zone">
      <input 
        type="file" 
        ref="fileInput"
        accept="video/*"
        @change="handleFileSelect"
        hidden
      />
      <button @click="$refs.fileInput.click()">영상 선택</button>
      <p>또는 파일을 여기에 드래그하세요</p>
    </div>
    
    <div v-if="currentTask" class="progress-panel">
      <div class="file-info">
        <span>{{ currentTask.fileName }}</span>
        <span>{{ formatBytes(currentTask.fileSize) }}</span>
      </div>
      <div class="progress-bar">
        <div class="progress-fill" :style="{ width: overallProgress + '%' }"></div>
      </div>
      <div class="segment-status">
        <span>완료: {{ completedSegments }}/{{ totalSegments }}</span>
        <button v-if="isPaused" @click="resumeUpload">계속</button>
        <button v-else @click="pauseUpload">일시정지</button>
        <button @click="cancelUpload">취소</button>
      </div>
    </div>
  </div>
</template>

<script>
import UploadManager from './UploadManager';

export default {
  data() {
    return {
      manager: null,
      currentTask: null,
      segmentStates: [], // 각 분할의 상태: pending, uploading, done, failed
      isPaused: false,
      abortControllers: new Map()
    };
  },
  
  computed: {
    totalSegments() {
      return this.segmentStates.length;
    },
    completedSegments() {
      return this.segmentStates.filter(s => s === 'done').length;
    },
    overallProgress() {
      if (!this.totalSegments) return 0;
      return Math.round((this.completedSegments / this.totalSegments) * 100);
    }
  },

  mounted() {
    this.manager = new UploadManager({
      chunkSize: 10 * 1024 * 1024, // 10MB
      concurrent: navigator.hardwareConcurrency > 4 ? 4 : 2
    });
    
    // 드래그 앤 드롭 이벤트
    const zone = this.$refs.dropZone;
    zone.addEventListener('dragover', (e) => {
      e.preventDefault();
      zone.classList.add('drag-over');
    });
    zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
    zone.addEventListener('drop', (e) => {
      e.preventDefault();
      zone.classList.remove('drag-over');
      const files = e.dataTransfer.files;
      if (files.length) this.processFile(files[0]);
    });
  },

  methods: {
    async handleFileSelect(e) {
      const file = e.target.files[0];
      if (file) await this.processFile(file);
    },

    async processFile(file) {
      // 지원 형식 검증
      const allowedTypes = ['video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo'];
      if (!allowedTypes.includes(file.type) && !file.name.match(/\.(mp4|mov|avi|webm)$/i)) {
        alert('지원하지 않는 영상 형식입니다');
        return;
      }

      // 파일 크기 제한 (10GB)
      const maxSize = 10 * 1024 * 1024 * 1024;
      if (file.size > maxSize) {
        alert('최대 10GB까지 업로드 가능합니다');
        return;
      }

      const signature = await this.manager.generateFileSignature(file);
      const plan = this.manager.buildSegmentPlan(file, signature);
      
      // 로컬 저장소에서 이전 진행 상황 확인
      const savedProgress = this.loadProgress(signature);
      
      this.currentTask = {
        file,
        fileId: signature,
        fileName: file.name,
        fileSize: file.size,
        ...plan
      };

      // 분할 상태 초기화
      this.segmentStates = new Array(plan.segmentCount).fill('pending');
      
      // 서버에 업로드 준비 요청
      const serverAck = await this.initializeUpload(plan);
      
      // 이미 완료된 분할 확인
      if (savedProgress?.completedIndices) {
        savedProgress.completedIndices.forEach(idx => {
          this.segmentStates[idx] = 'done';
        });
      }

      await this.executeUpload(serverAck.uploadToken);
    },

    async initializeUpload(plan) {
      const response = await fetch(this.manager.apiEndpoints.prepare, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(plan)
      });
      
      if (!response.ok) throw new Error('업로드 준비 실패');
      return await response.json();
    },

    async executeUpload(token) {
      const pendingIndices = this.segmentStates
        .map((state, idx) => state === 'pending' ? idx : -1)
        .filter(idx => idx !== -1);

      const workerPool = [];
      
      for (const idx of pendingIndices) {
        if (this.isPaused) break;
        
        // 동시 업로드 제한
        while (workerPool.length >= this.manager.parallelLimit) {
          await Promise.race(workerPool);
        }

        const worker = this.uploadSingleSegment(idx, token);
        const trackedWorker = worker.finally(() => {
          const pos = workerPool.indexOf(trackedWorker);
          if (pos > -1) workerPool.splice(pos, 1);
        });
        
        workerPool.push(trackedWorker);
      }

      await Promise.all(workerPool);

      // 모든 분할 완료 시 병합 요청
      if (this.completedSegments === this.totalSegments) {
        await this.finalizeUpload(token);
      }
    },

    async uploadSingleSegment(index, token) {
      const start = index * this.manager.chunkBytes;
      const end = Math.min(this.currentTask.fileSize, start + this.manager.chunkBytes);
      const blob = this.currentTask.file.slice(start, end);
      
      const controller = new AbortController();
      this.abortControllers.set(index, controller);

      const formData = new FormData();
      formData.append('segmentIndex', index);
      formData.append('segmentData', blob, `part-${index}`);
      formData.append('uploadToken', token);
      formData.append('checksum', await this.calculateSegmentHash(blob));

      this.segmentStates[index] = 'uploading';

      try {
        const response = await fetch(this.manager.apiEndpoints.segment, {
          method: 'POST',
          body: formData,
          signal: controller.signal,
          headers: {
            'X-Segment-Index': index,
            'X-Upload-Token': token
          }
        });

        if (!response.ok) throw new Error(`분할 ${index} 업로드 실패`);
        
        this.segmentStates[index] = 'done';
        this.saveProgress();
        
        return await response.json();
      } catch (err) {
        if (err.name !== 'AbortError') {
          this.segmentStates[index] = 'failed';
          // 재시도 로직
          await this.retrySegment(index, token);
        }
        throw err;
      } finally {
        this.abortControllers.delete(index);
      }
    },

    async calculateSegmentHash(blob) {
      const buffer = await blob.arrayBuffer();
      const hash = await crypto.subtle.digest('SHA-256', buffer);
      return Array.from(new Uint8Array(hash))
        .map(b => b.toString(16).padStart(2, '0'))
        .join('');
    },

    async retrySegment(index, token, attempt = 0) {
      if (attempt >= this.manager.retryMax) {
        throw new Error(`분할 ${index} 재시도 한도 초과`);
      }
      
      const delay = Math.min(1000 * Math.pow(2, attempt), 30000); // 지수 백오프
      await new Promise(r => setTimeout(r, delay));
      
      this.segmentStates[index] = 'pending';
      return this.uploadSingleSegment(index, token);
    },

    pauseUpload() {
      this.isPaused = true;
      this.abortControllers.forEach(ctrl => ctrl.abort());
      this.abortControllers.clear();
    },

    resumeUpload() {
      this.isPaused = false;
      this.executeUpload(this.currentTask.fileId);
    },

    cancelUpload() {
      this.pauseUpload();
      fetch(this.manager.apiEndpoints.remove, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ uploadToken: this.currentTask.fileId })
      });
      this.clearProgress(this.currentTask.fileId);
      this.resetState();
    },

    async finalizeUpload(token) {
      const response = await fetch(this.manager.apiEndpoints.finalize, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          uploadToken: token,
          fileId: this.currentTask.fileId
        })
      });
      
      const result = await response.json();
      this.clearProgress(this.currentTask.fileId);
      this.$emit('upload-success', result);
      this.resetState();
    },

    // 로컬 진행 상황 저장 (IndexedDB 또는 localStorage)
    saveProgress() {
      const data = {
        fileId: this.currentTask.fileId,
        completedIndices: this.segmentStates
          .map((s, i) => s === 'done' ? i : -1)
          .filter(i => i !== -1),
        timestamp: Date.now()
      };
      localStorage.setItem(`upload_${this.currentTask.fileId}`, JSON.stringify(data));
    },

    loadProgress(fileId) {
      const saved = localStorage.getItem(`upload_${fileId}`);
      return saved ? JSON.parse(saved) : null;
    },

    clearProgress(fileId) {
      localStorage.removeItem(`upload_${fileId}`);
    },

    resetState() {
      this.currentTask = null;
      this.segmentStates = [];
      this.isPaused = false;
    },

    formatBytes(bytes) {
      const units = ['B', 'KB', 'MB', 'GB'];
      let unitIndex = 0;
      while (bytes >= 1024 && unitIndex < units.length - 1) {
        bytes /= 1024;
        unitIndex++;
      }
      return `${bytes.toFixed(2)} ${units[unitIndex]}`;
    }
  }
};
</script>

백엔드 구현

Spring Boot 기반으로 구현하며, 임시 저장소 관리와 병합 프로세스를 담당한다.

// UploadController.java
@RestController
@RequestMapping("/api/upload")
public class UploadController {
    
    @Autowired
    private SegmentStorageService storageService;
    
    @Autowired
    private FileIntegrityChecker integrityChecker;

    @PostMapping("/prepare")
    public ResponseEntity<UploadSession> initializeUpload(@RequestBody UploadPlan plan) {
        String uploadToken = UUID.randomUUID().toString();
        
        UploadSession session = new UploadSession();
        session.setUploadToken(uploadToken);
        session.setFileId(plan.getFileId());
        session.setTotalSegments(plan.getSegmentCount());
        session.setCreatedAt(Instant.now());
        session.setExpiresAt(Instant.now().plus(24, ChronoUnit.HOURS));
        
        storageService.createSession(session);
        
        return ResponseEntity.ok(session);
    }

    @PostMapping("/segment")
    public ResponseEntity<SegmentAck> receiveSegment(
            @RequestParam("uploadToken") String token,
            @RequestParam("segmentIndex") int index,
            @RequestParam("segmentData") MultipartFile data,
            @RequestHeader("X-Segment-Hash") String clientHash) throws IOException {
        
        UploadSession session = storageService.getSession(token);
        if (session == null || session.isExpired()) {
            return ResponseEntity.status(410).build(); // Gone
        }

        // 저장 전 해시 검증
        String serverHash = integrityChecker.computeHash(data.getInputStream());
        if (!serverHash.equalsIgnoreCase(clientHash)) {
            return ResponseEntity.status(409)
                .body(new SegmentAck(false, index, "checksum_mismatch"));
        }

        Path segmentPath = storageService.storeSegment(token, index, data);
        session.markSegmentReceived(index, serverHash);
        storageService.updateSession(session);

        return ResponseEntity.ok(new SegmentAck(true, index, segmentPath.toString()));
    }

    @PostMapping("/complete")
    public ResponseEntity<MergeResult> finalizeUpload(
            @RequestBody FinalizeRequest request) throws IOException {
        
        UploadSession session = storageService.getSession(request.getUploadToken());
        if (!session.isComplete()) {
            return ResponseEntity.status(422)
                .body(new MergeResult(false, "incomplete_segments"));
        }

        // 분할 병합
        Path mergedFile = storageService.mergeSegments(session);
        
        // 전체 파일 무결성 검증
        boolean integrityValid = integrityChecker.verifyFile(
            mergedFile, 
            session.getCollectedHashes()
        );

        if (!integrityValid) {
            Files.deleteIfExists(mergedFile);
            return ResponseEntity.status(500)
                .body(new MergeResult(false, "integrity_check_failed"));
        }

        // 메타데이터 저장 및 세션 정리
        FileMetadata metadata = new FileMetadata();
        metadata.setOriginalName(session.getOriginalFileName());
        metadata.setStoragePath(mergedFile.toString());
        metadata.setFileSize(Files.size(mergedFile));
        metadata.setUploadedAt(Instant.now());
        
        storageService.persistMetadata(metadata);
        storageService.cleanupSession(session.getUploadToken());

        return ResponseEntity.ok(new MergeResult(true, metadata.getFileId()));
    }
}

분할 병합 서비스

@Service
public class SegmentStorageService {
    
    @Value("${upload.temp-dir}")
    private Path tempBaseDir;
    
    @Value("${upload.final-dir}")
    private Path finalDir;

    public Path mergeSegments(UploadSession session) throws IOException {
        Path sessionDir = tempBaseDir.resolve(session.getUploadToken());
        Path outputPath = finalDir.resolve(session.getFileId());
        
        // NIO 기반 효율적 병합
        try (FileChannel outChannel = FileChannel.open(
                outputPath, 
                StandardOpenOption.CREATE, 
                StandardOpenOption.WRITE)) {
            
            for (int i = 0; i < session.getTotalSegments(); i++) {
                Path segmentFile = sessionDir.resolve(String.format("part_%05d", i));
                
                try (FileChannel inChannel = FileChannel.open(
                        segmentFile, 
                        StandardOpenOption.READ)) {
                    
                    long position = 0;
                    long remaining = inChannel.size();
                    
                    // 대용량 파일을 위한 청크 단위 전송
                    while (remaining > 0) {
                        long transferred = inChannel.transferTo(position, remaining, outChannel);
                        position += transferred;
                        remaining -= transferred;
                    }
                }
                
                // 병합 완료된 분할 삭제
                Files.deleteIfExists(segmentFile);
            }
        }
        
        Files.deleteIfExists(sessionDir);
        return outputPath;
    }
}

브라우저 호환성 처리

레거시 환경을 위한 WebUploader 통합 및 폴백 전략이다.

// CompatibilityLayer.js
class UploadCompatibilityLayer {
  constructor() {
    this.mode = this.detectBestMode();
    this.uploader = null;
  }

  detectBestMode() {
    // 기능 탐지 기반 모드 선택
    if (window.FileReader && 
        window.Blob && 
        window.XMLHttpRequest && 
        'slice' in Blob.prototype) {
      return 'native'; // 최신 브라우저
    }
    
    if (window.WebUploader) {
      return 'webuploader'; // Flash 폴백
    }
    
    return 'unsupported';
  }

  initialize(container, options) {
    if (this.mode === 'native') {
      return this.initNativeUploader(container, options);
    }
    
    if (this.mode === 'webuploader') {
      return this.initWebUploader(container, options);
    }
    
    throw new Error('업로드를 지원하지 않는 브라우저입니다');
  }

  initWebUploader(container, options) {
    this.uploader = WebUploader.create({
      swf: '/static/webuploader/Uploader.swf',
      server: options.segmentUrl,
      pick: { id: container, multiple: false },
      resize: false,
      chunked: true,
      chunkSize: options.chunkSize || 10 * 1024 * 1024,
      threads: options.concurrent || 3,
      formData: {
        uploadToken: options.token
      },
      
      // WebUploader 이벤트 브리지
      beforeSendFile: (file) => {
        return options.onPrepare?.(file);
      },
      
      beforeSend: (block) => {
        // 분할 해시 계산 (WebUploader는 기본 해시 미지원)
        block.data = block.data || {};
        block.data.segmentIndex = block.chunk;
        block.data.totalSegments = Math.ceil(file.size / block.chunkSize);
        return options.onBeforeSegment?.(block);
      },
      
      afterSendFile: (file, ret) => {
        return options.onComplete?.(file, ret);
      },
      
      onError: (type) => {
        const errors = {
          'Q_TYPE_DENIED': '지원하지 않는 파일 형식',
          'F_EXCEED_SIZE': '파일 크기 초과',
          'Q_EXCEED_NUM_LIMIT': '파일 개수 초과'
        };
        options.onError?.(errors[type] || type);
      }
    });

    // 진행 상황 이벤트 연결
    this.uploader.on('uploadProgress', (file, percentage) => {
      options.onProgress?.(file, percentage);
    });

    return this.uploader;
  }

  // 네이티브 모드는 UploadManager 직접 활용
  initNativeUploader(container, options) {
    // DOM 이벤트 기반 네이티브 구현
    const input = document.createElement('input');
    input.type = 'file';
    input.accept = options.accept || 'video/*';
    
    input.addEventListener('change', (e) => {
      const file = e.target.files[0];
      if (file) options.onSelect?.(file);
    });
    
    container.addEventListener('click', () => input.click());
    return { trigger: () => input.click() };
  }
}

무결성 검증 체계

다중 계층 해시 검증으로 데이터 손상을 방지한다.

@Component
public class FileIntegrityChecker {
    
    private static final String HASH_ALGORITHM = "SHA-256";

    public String computeHash(InputStream input) throws IOException {
        try {
            MessageDigest digest = MessageDigest.getInstance(HASH_ALGORITHM);
            byte[] buffer = new byte[8192];
            int read;
            while ((read = input.read(buffer)) != -1) {
                digest.update(buffer, 0, read);
            }
            return bytesToHex(digest.digest());
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("해시 알고리즘 미지원", e);
        }
    }

    public boolean verifyFile(Path file, List<String> segmentHashes) throws IOException {
        // 분할 해시들의 결합 해시 계산
        MessageDigest combinedDigest;
        try {
            combinedDigest = MessageDigest.getInstance(HASH_ALGORITHM);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }

        for (String hash : segmentHashes) {
            combinedDigest.update(hexToBytes(hash));
        }
        
        String expectedHash = bytesToHex(combinedDigest.digest());
        
        // 실제 파일 해시 계산
        String actualHash;
        try (InputStream is = Files.newInputStream(file)) {
            actualHash = computeHash(is);
        }
        
        // 추가: 전체 파일 직접 해시 비교 (이중 검증)
        MessageDigest fullDigest = MessageDigest.getInstance(HASH_ALGORITHM);
        try (InputStream is = Files.newInputStream(file)) {
            byte[] buffer = new byte[65536];
            int read;
            while ((read = is.read(buffer)) != -1) {
                fullDigest.update(buffer, 0, read);
            }
        }
        String fullHash = bytesToHex(fullDigest.digest());
        
        return expectedHash.equals(fullHash);
    }

    private String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        for (byte b : bytes) {
            sb.append(String.format("%02x", b));
        }
        return sb.toString();
    }

    private byte[] hexToBytes(String hex) {
        int len = hex.length();
        byte[] data = new byte[len / 2];
        for (int i = 0; i < len; i += 2) {
            data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
                                 + Character.digit(hex.charAt(i + 1), 16));
        }
        return data;
    }
}

데이터베이스 스키마

-- 업로드 세션 관리
CREATE TABLE upload_sessions (
    upload_token    VARCHAR(36) PRIMARY KEY,
    file_id         VARCHAR(64) NOT NULL,
    file_name       VARCHAR(255) NOT NULL,
    total_size      BIGINT NOT NULL,
    segment_count   INT NOT NULL,
    received_segments INT DEFAULT 0,
    segment_hashes  JSON,
    status          ENUM('preparing', 'uploading', 'merging', 'completed', 'failed'),
    created_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    expires_at      TIMESTAMP NOT NULL,
    INDEX idx_file_id (file_id),
    INDEX idx_expires (expires_at)
) ENGINE=InnoDB;

-- 업로드된 파일 메타데이터
CREATE TABLE uploaded_files (
    file_id         VARCHAR(64) PRIMARY KEY,
    original_name   VARCHAR(255) NOT NULL,
    storage_path    VARCHAR(500) NOT NULL,
    file_size       BIGINT NOT NULL,
    content_type    VARCHAR(100),
    checksum_sha256 VARCHAR(64) NOT NULL,
    uploaded_by     VARCHAR(100),
    uploaded_at     TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    claim_id        VARCHAR(50),
    INDEX idx_claim (claim_id)
) ENGINE=InnoDB;

운영 고려사항

대용량 파일 처리를 위한 추가 구성 요소다.

  • 스토리지 계층화: 임시 분할은 고속 SSD에, 최종 파일은 객체 스토리지(S3/MinIO)로 마이그레이션
  • 세션 정리: 만료된 미완료 업로드는 배치 작업으로 정리 (24시간 주기)
  • 압축 전송: 텍스트 기반 메타데이터는 gzip 적용, 영상 본문은 원본 유지
  • 진단 로깅: 각 분별의 시작/완료/재시도 타임스탬프 상세 기록

태그: WebUploader ResumableUpload FileIntegrity Vue.js SpringBoot

7월 21일 09:09에 게시됨