개요
보험 청구 시스템에서 대용량 영상 파일을 안정적으로 업로드하기 위해 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 적용, 영상 본문은 원본 유지
- 진단 로깅: 각 분별의 시작/완료/재시도 타임스탬프 상세 기록