[스프링/Spring] MultipartFile 실전 비디오 업로드 구현

dongbrown·2025년 7월 30일

Spring

목록 보기
23/23

1편에서 MultipartFile의 기본 개념을 알아봤다면, 2편에서는 실제 프로덕션 환경에서 사용되는 고급 파일 처리 기법을 살펴보겠습니다. 특히 비디오 파일 처리에 특화된 실전 코드를 분석해보겠습니다.

1. 실전 비디오 처리 서비스 분석

VideoProcessingServiceImpl 코드를 분석하면서 실무에서 어떻게 파일 업로드를 처리하는지 알아보겠습니다.

1.1 서비스 구조 살펴보기

@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class VideoProcessingServiceImpl implements VideoProcessingService {

    private final VideoAnalysisDAO videoAnalysisDAO;

    @Value("${app.upload.path:/tmp/deepfake/uploads}")
    private String uploadPath;

    @Value("${app.upload.max-size:209715200}") // 200MB
    private long maxFileSize;
}

핵심 포인트:

  • @Transactional: DB 작업과 파일 작업의 일관성 보장
  • @Value: 외부 설정으로 업로드 경로와 크기 제한 관리
  • @RequiredArgsConstructor: 생성자 주입으로 의존성 관리

1.2 파일 처리 메인 로직

@Override
public VideoAnalysisVO processUploadedFile(MultipartFile file, String userId) {
    log.info("📁 파일 처리 시작: {} - 사용자: {}", file.getOriginalFilename(), userId);

    try {
        // 1. UUID 생성 (보안을 위한 고유 식별자)
        String videoId = UUID.randomUUID().toString();

        // 2. 파일 저장
        String filePath = saveFile(file, videoId);

        // 3. VO 객체 생성 (완전한 메타데이터 포함)
        VideoAnalysisVO videoAnalysis = VideoAnalysisVO.builder()
                .videoId(videoId)
                .originalFileName(file.getOriginalFilename())
                .storedFileName(extractFileName(filePath))
                .filePath(filePath)
                .fileSize(file.getSize())
                .mimeType(file.getContentType())
                .status("UPLOADED")
                .uploadedAt(LocalDateTime.now())
                .userId(userId)
                .build();

        // 4. DB 저장
        int result = videoAnalysisDAO.insert(videoAnalysis);
        if (result <= 0) {
            throw new RuntimeException("DB 저장에 실패했습니다.");
        }

        // 5. 비동기 메타데이터 처리
        try {
            extractAndUpdateMetadata(videoId);
        } catch (Exception e) {
            log.warn("⚠️ 메타데이터 추출 실패: {}", e.getMessage());
        }

        return videoAnalysis;

    } catch (Exception e) {
        log.error("❌ 파일 처리 중 오류", e);
        throw new RuntimeException("파일 처리에 실패했습니다: " + e.getMessage(), e);
    }
}

실전 팁:

  • UUID 사용으로 파일명 충돌 방지
  • Builder 패턴으로 깔끔한 객체 생성
  • 메타데이터 추출을 별도 단계로 분리 (실패해도 업로드는 성공)

2. Files.copy() vs transferTo() 비교

실제 코드에서는 Files.copy()를 사용하는 것을 볼 수 있습니다. 두 방식의 차이점을 알아보겠습니다.

2.1 transferTo() 방식

// 간단하지만 제약이 많음
@Override
public void saveWithTransferTo(MultipartFile file, String fileName) {
    try {
        File destFile = new File(uploadPath, fileName);
        file.transferTo(destFile);  // 한 번만 호출 가능
    } catch (IOException e) {
        throw new RuntimeException("저장 실패", e);
    }
}

2.2 Files.copy() 방식 (실제 코드)

@Override
public String saveFile(MultipartFile file, String videoId) {
    log.info("💾 파일 저장: {} - videoId={}", file.getOriginalFilename(), videoId);

    try {
        // 1. 업로드 디렉토리 생성
        Path uploadDir = Paths.get(uploadPath);
        if (!Files.exists(uploadDir)) {
            Files.createDirectories(uploadDir);
            log.info("📁 업로드 디렉토리 생성: {}", uploadDir);
        }

        // 2. 안전한 파일명 생성
        String originalFileName = file.getOriginalFilename();
        String extension = "";
        if (originalFileName != null && originalFileName.contains(".")) {
            extension = originalFileName.substring(originalFileName.lastIndexOf("."));
        }

        String storedFileName = videoId + extension;
        Path filePath = uploadDir.resolve(storedFileName);

        // 3. Files.copy() 사용 - 더 유연함
        Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);

        // 4. 보안을 위한 권한 설정
        File savedFile = filePath.toFile();
        savedFile.setReadOnly();

        log.info("✅ 파일 저장 완료: {}", filePath);
        return filePath.toString();

    } catch (IOException e) {
        log.error("❌ 파일 저장 중 오류", e);
        throw new RuntimeException("파일 저장에 실패했습니다: " + e.getMessage(), e);
    }
}

2.3 성능 및 기능 비교

구분transferTo()Files.copy()
사용 횟수1회만 가능여러 번 가능
유연성낮음높음
성능더 빠름 (내부 최적화)약간 느림
옵션없음REPLACE_EXISTING 등
예외 처리간단더 세밀한 제어

3. 대용량 파일 처리 전략

비디오 파일은 일반적으로 크기가 크기 때문에 특별한 처리가 필요합니다.

3.1 파일 크기 검증

// application.yml 설정
spring:
  servlet:
    multipart:
      max-file-size: 200MB      # 개별 파일 최대 크기
      max-request-size: 200MB   # 전체 요청 최대 크기
      file-size-threshold: 2KB  # 메모리 임계값
private void validateFile(MultipartFile file) {
    // 1. 파일 존재 확인
    if (file == null || file.isEmpty()) {
        throw new IllegalArgumentException("파일이 없거나 비어있습니다.");
    }

    // 2. 크기 검증
    if (file.getSize() > maxFileSize) {
        throw new IllegalArgumentException(
            String.format("파일 크기가 제한을 초과합니다. (최대: %d MB)",
                         maxFileSize / (1024 * 1024)));
    }

    // 3. 확장자 검증
    if (!isValidVideoExtension(file.getOriginalFilename())) {
        throw new IllegalArgumentException("지원하지 않는 비디오 형식입니다.");
    }
}

private boolean isValidVideoExtension(String fileName) {
    if (fileName == null) return false;

    String extension = fileName.toLowerCase();
    return extension.endsWith(".mp4") ||
           extension.endsWith(".avi") ||
           extension.endsWith(".mov") ||
           extension.endsWith(".wmv") ||
           extension.endsWith(".webm") ||
           extension.endsWith(".flv");
}

3.2 스트리밍 처리 (대용량 파일용)

public void saveFileWithStreaming(MultipartFile file, String fileName) throws IOException {
    Path destPath = Paths.get(uploadPath, fileName);

    // 버퍼를 사용한 스트리밍 방식
    try (InputStream inputStream = file.getInputStream();
         OutputStream outputStream = Files.newOutputStream(destPath)) {

        byte[] buffer = new byte[8192]; // 8KB 버퍼
        int bytesRead;

        while ((bytesRead = inputStream.read(buffer)) != -1) {
            outputStream.write(buffer, 0, bytesRead);
        }

        log.info("스트리밍으로 파일 저장 완료: {}", fileName);
    }
}

4. 메타데이터 추출 구현

실제 코드에서 볼 수 있는 메타데이터 추출 로직을 분석해보겠습니다.

4.1 Mock 메타데이터 추출

private void extractMetadataFromFile(VideoAnalysisVO videoAnalysis) {
    log.info("🔍 파일 메타데이터 분석 중: {}", videoAnalysis.getFilePath());

    try {
        // 1. 실제 파일 존재 확인
        File file = new File(videoAnalysis.getFilePath());
        if (!file.exists()) {
            log.warn("⚠️ 메타데이터 추출할 파일이 존재하지 않음: {}", videoAnalysis.getFilePath());
            return;
        }

        // 2. 실제 파일 크기 확인 및 업데이트
        long actualSize = file.length();
        if (actualSize != videoAnalysis.getFileSize()) {
            log.info("📏 파일 크기 업데이트: {} -> {}", videoAnalysis.getFileSize(), actualSize);
            videoAnalysis.setFileSize(actualSize);
        }

        // 3. MIME 타입 기반 코덱 추정
        String mimeType = videoAnalysis.getMimeType();
        if (mimeType != null) {
            switch (mimeType.toLowerCase()) {
                case "video/mp4":
                    videoAnalysis.setVideoCodec("H.264");
                    videoAnalysis.setFrameRate(30);
                    break;
                case "video/avi":
                    videoAnalysis.setVideoCodec("XVID");
                    videoAnalysis.setFrameRate(25);
                    break;
                case "video/mov":
                    videoAnalysis.setVideoCodec("H.264");
                    videoAnalysis.setFrameRate(24);
                    break;
                default:
                    videoAnalysis.setVideoCodec("Unknown");
                    videoAnalysis.setFrameRate(30);
            }
        }

        // 4. 파일 크기 기반 시간 추정 (Mock)
        double estimatedDuration = Math.max(30.0, actualSize / (1024.0 * 1024.0) * 8);
        videoAnalysis.setDuration(Math.min(estimatedDuration, 600.0));

        // 5. 총 프레임 수 계산
        if (videoAnalysis.getFrameRate() != null && videoAnalysis.getDuration() != null) {
            videoAnalysis.setTotalFrames((int) (videoAnalysis.getDuration() * videoAnalysis.getFrameRate()));
        }

        log.info("✅ 메타데이터 분석 완료: {}x{}, {}fps, {:.1f}초",
                videoAnalysis.getWidth(), videoAnalysis.getHeight(),
                videoAnalysis.getFrameRate(), videoAnalysis.getDuration());

    } catch (Exception e) {
        log.warn("⚠️ 메타데이터 추출 중 오류: {}", e.getMessage());
        setDefaultMetadata(videoAnalysis);
    }
}

4.2 실제 FFmpeg 연동 (참고용)

// 실제 프로덕션에서는 FFmpeg 라이브러리 사용
public VideoMetadata extractRealMetadata(String filePath) {
    try {
        FFprobe ffprobe = new FFprobe("/usr/local/bin/ffprobe");
        FFmpegProbeResult probeResult = ffprobe.probe(filePath);

        FFmpegFormat format = probeResult.getFormat();
        FFmpegStream videoStream = probeResult.getStreams().get(0);

        return VideoMetadata.builder()
            .duration(format.duration)
            .width(videoStream.width)
            .height(videoStream.height)
            .frameRate(videoStream.r_frame_rate)
            .codec(videoStream.codec_name)
            .bitrate(videoStream.bit_rate)
            .build();

    } catch (IOException e) {
        throw new RuntimeException("메타데이터 추출 실패", e);
    }
}

5. 보안 강화 방법

5.1 경로 탐색 공격 방지

@Override
public boolean deleteFile(String filePath) {
    log.info("🗑️ 파일 삭제: {}", filePath);

    if (filePath == null || filePath.trim().isEmpty()) {
        return false;
    }

    try {
        Path path = Paths.get(filePath);

        // ⭐ 경로 검증 - 업로드 디렉토리 내부만 허용
        Path uploadDir = Paths.get(uploadPath).toAbsolutePath().normalize();
        Path targetPath = path.toAbsolutePath().normalize();

        if (!targetPath.startsWith(uploadDir)) {
            log.error("❌ 허용되지 않은 경로: {}", filePath);
            return false;
        }

        // 파일 권한 복원 후 삭제
        File file = path.toFile();
        file.setWritable(true);

        return Files.deleteIfExists(path);

    } catch (IOException e) {
        log.error("❌ 파일 삭제 중 오류", e);
        return false;
    }
}

5.2 파일 타입 검증 강화

public boolean isSecureVideoFile(MultipartFile file) {
    // 1. 확장자 검증
    if (!isValidVideoExtension(file.getOriginalFilename())) {
        return false;
    }

    // 2. MIME 타입 검증
    String contentType = file.getContentType();
    if (contentType == null || !contentType.startsWith("video/")) {
        return false;
    }

    // 3. 매직 넘버 검증 (파일 시그니처)
    try {
        byte[] header = new byte[12];
        file.getInputStream().read(header);

        return isValidVideoSignature(header);
    } catch (IOException e) {
        return false;
    }
}

private boolean isValidVideoSignature(byte[] header) {
    // MP4: 0x66747970 (ftyp)
    // AVI: 0x52494646 (RIFF)
    // MOV: 0x66747970 (ftyp)
    // WebM: 0x1A45DFA3

    // 실제 구현에서는 각 포맷별 시그니처 확인
    return true; // 간략화
}

6. 성능 최적화 팁

6.1 비동기 처리

@Async
@Override
public CompletableFuture<VideoAnalysisVO> processVideoAsync(MultipartFile file, String userId) {
    try {
        VideoAnalysisVO result = processUploadedFile(file, userId);
        return CompletableFuture.completedFuture(result);
    } catch (Exception e) {
        CompletableFuture<VideoAnalysisVO> failedFuture = new CompletableFuture<>();
        failedFuture.completeExceptionally(e);
        return failedFuture;
    }
}

6.2 임시 파일 정리

@Scheduled(cron = "0 0 2 * * ?") // 매일 새벽 2시
public void cleanupTempFiles() {
    try {
        Path tempDir = Paths.get(System.getProperty("java.io.tmpdir"));

        Files.walk(tempDir)
            .filter(path -> path.toString().contains("spring.multipart"))
            .filter(path -> {
                try {
                    return Files.getLastModifiedTime(path)
                           .toInstant()
                           .isBefore(Instant.now().minus(Duration.ofHours(24)));
                } catch (IOException e) {
                    return false;
                }
            })
            .forEach(path -> {
                try {
                    Files.deleteIfExists(path);
                } catch (IOException e) {
                    log.warn("임시 파일 삭제 실패: {}", path);
                }
            });

    } catch (Exception e) {
        log.error("임시 파일 정리 중 오류", e);
    }
}
  • 메타데이터 추출 구현
  • 보안 강화 방법
  • 성능 최적화

7. 실제 컨트롤러 구현

실제 REST API에서 어떻게 이 서비스를 사용하는지 보겠습니다.

@RestController
@RequestMapping("/api/videos")
@RequiredArgsConstructor
@Slf4j
public class VideoUploadController {

    private final VideoProcessingService videoProcessingService;

    @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public ResponseEntity<?> uploadVideo(
            @RequestParam("file") MultipartFile file,
            @RequestParam("userId") String userId,
            @RequestParam(value = "description", required = false) String description) {

        try {
            // 1. 기본 검증
            if (file.isEmpty()) {
                return ResponseEntity.badRequest()
                    .body(Map.of("error", "파일이 비어있습니다."));
            }

            // 2. 파일 처리
            VideoAnalysisVO result = videoProcessingService.processUploadedFile(file, userId);

            // 3. 응답 데이터 구성
            Map<String, Object> response = Map.of(
                "success", true,
                "videoId", result.getVideoId(),
                "message", "업로드가 완료되었습니다.",
                "fileInfo", Map.of(
                    "originalName", result.getOriginalFileName(),
                    "size", result.getFileSize(),
                    "uploadedAt", result.getUploadedAt()
                )
            );

            log.info("✅ 비디오 업로드 성공: videoId={}, userId={}",
                    result.getVideoId(), userId);

            return ResponseEntity.ok(response);

        } catch (IllegalArgumentException e) {
            log.warn("⚠️ 잘못된 요청: {}", e.getMessage());
            return ResponseEntity.badRequest()
                .body(Map.of("error", e.getMessage()));

        } catch (Exception e) {
            log.error("❌ 비디오 업로드 실패", e);
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(Map.of("error", "서버 오류가 발생했습니다."));
        }
    }

    @GetMapping("/{videoId}")
    public ResponseEntity<?> getVideoInfo(@PathVariable String videoId) {
        try {
            VideoAnalysisVO video = videoProcessingService.getVideoInfo(videoId);

            if (video == null) {
                return ResponseEntity.notFound().build();
            }

            return ResponseEntity.ok(video);

        } catch (Exception e) {
            log.error("❌ 비디오 정보 조회 실패: videoId={}", videoId, e);
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(Map.of("error", "조회 중 오류가 발생했습니다."));
        }
    }

    @GetMapping("/user/{userId}")
    public ResponseEntity<?> getUserVideos(
            @PathVariable String userId,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "10") int size) {

        try {
            List<VideoAnalysisVO> videos = videoProcessingService.getUserVideos(userId, page, size);
            int totalCount = videoProcessingService.getUserUploadCount(userId);

            Map<String, Object> response = Map.of(
                "videos", videos,
                "pagination", Map.of(
                    "page", page,
                    "size", size,
                    "totalCount", totalCount,
                    "totalPages", (totalCount + size - 1) / size
                )
            );

            return ResponseEntity.ok(response);

        } catch (Exception e) {
            log.error("❌ 사용자 비디오 목록 조회 실패: userId={}", userId, e);
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(Map.of("error", "목록 조회 중 오류가 발생했습니다."));
        }
    }

    @DeleteMapping("/{videoId}")
    public ResponseEntity<?> deleteVideo(@PathVariable String videoId) {
        try {
            boolean deleted = videoProcessingService.deleteVideo(videoId);

            if (deleted) {
                return ResponseEntity.ok(Map.of("message", "비디오가 삭제되었습니다."));
            } else {
                return ResponseEntity.notFound().build();
            }

        } catch (Exception e) {
            log.error("❌ 비디오 삭제 실패: videoId={}", videoId, e);
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(Map.of("error", "삭제 중 오류가 발생했습니다."));
        }
    }
}

8. 프론트엔드 연동 예시

8.1 HTML + JavaScript

<!DOCTYPE html>
<html>
<head>
    <title>비디오 업로드</title>
    <style>
        .upload-area {
            border: 2px dashed #ccc;
            border-radius: 10px;
            width: 480px;
            height: 200px;
            text-align: center;
            padding: 20px;
            margin: 20px auto;
            cursor: pointer;
        }
        .upload-area.dragover {
            border-color: #007bff;
            background-color: #f8f9fa;
        }
        .progress-bar {
            width: 100%;
            height: 20px;
            background-color: #f0f0f0;
            border-radius: 10px;
            overflow: hidden;
            margin: 10px 0;
        }
        .progress-fill {
            height: 100%;
            background-color: #007bff;
            width: 0%;
            transition: width 0.3s;
        }
    </style>
</head>
<body>
    <div class="container">
        <h2>비디오 업로드</h2>

        <div class="upload-area" id="uploadArea">
            <p>클릭하거나 파일을 드래그해서 업로드하세요</p>
            <input type="file" id="fileInput" accept="video/*" style="display: none;">
        </div>

        <div class="progress-bar" id="progressBar" style="display: none;">
            <div class="progress-fill" id="progressFill"></div>
        </div>

        <div id="result"></div>
    </div>

    <script>
        const uploadArea = document.getElementById('uploadArea');
        const fileInput = document.getElementById('fileInput');
        const progressBar = document.getElementById('progressBar');
        const progressFill = document.getElementById('progressFill');
        const result = document.getElementById('result');

        // 클릭 업로드
        uploadArea.addEventListener('click', () => {
            fileInput.click();
        });

        // 드래그 앤 드롭
        uploadArea.addEventListener('dragover', (e) => {
            e.preventDefault();
            uploadArea.classList.add('dragover');
        });

        uploadArea.addEventListener('dragleave', () => {
            uploadArea.classList.remove('dragover');
        });

        uploadArea.addEventListener('drop', (e) => {
            e.preventDefault();
            uploadArea.classList.remove('dragover');

            const files = e.dataTransfer.files;
            if (files.length > 0) {
                uploadFile(files[0]);
            }
        });

        // 파일 선택 이벤트
        fileInput.addEventListener('change', (e) => {
            if (e.target.files.length > 0) {
                uploadFile(e.target.files[0]);
            }
        });

        // 파일 업로드 함수
        function uploadFile(file) {
            // 파일 크기 검증 (200MB)
            if (file.size > 200 * 1024 * 1024) {
                alert('파일 크기가 200MB를 초과합니다.');
                return;
            }

            // 비디오 파일 타입 검증
            if (!file.type.startsWith('video/')) {
                alert('비디오 파일만 업로드 가능합니다.');
                return;
            }

            const formData = new FormData();
            formData.append('file', file);
            formData.append('userId', 'user123'); // 실제로는 로그인 사용자 ID

            // 진행률 표시
            progressBar.style.display = 'block';

            // XMLHttpRequest를 사용한 업로드 (진행률 추적 가능)
            const xhr = new XMLHttpRequest();

            xhr.upload.addEventListener('progress', (e) => {
                if (e.lengthComputable) {
                    const percentComplete = (e.loaded / e.total) * 100;
                    progressFill.style.width = percentComplete + '%';
                }
            });

            xhr.addEventListener('load', () => {
                if (xhr.status === 200) {
                    const response = JSON.parse(xhr.responseText);
                    result.innerHTML = `
                        <div style="color: green; margin-top: 10px;">
                            <h3>✅ 업로드 성공!</h3>
                            <p><strong>비디오 ID:</strong> ${response.videoId}</p>
                            <p><strong>원본 파일명:</strong> ${response.fileInfo.originalName}</p>
                            <p><strong>파일 크기:</strong> ${(response.fileInfo.size / 1024 / 1024).toFixed(2)} MB</p>
                            <p><strong>업로드 시간:</strong> ${new Date(response.fileInfo.uploadedAt).toLocaleString()}</p>
                        </div>
                    `;
                } else {
                    const error = JSON.parse(xhr.responseText);
                    result.innerHTML = `
                        <div style="color: red; margin-top: 10px;">
                            <h3>❌ 업로드 실패</h3>
                            <p>${error.error}</p>
                        </div>
                    `;
                }

                // 진행률 바 숨기기
                setTimeout(() => {
                    progressBar.style.display = 'none';
                    progressFill.style.width = '0%';
                }, 1000);
            });

            xhr.addEventListener('error', () => {
                result.innerHTML = `
                    <div style="color: red; margin-top: 10px;">
                        <h3>❌ 네트워크 오류</h3>
                        <p>업로드 중 오류가 발생했습니다.</p>
                    </div>
                `;
                progressBar.style.display = 'none';
            });

            xhr.open('POST', '/api/videos/upload');
            xhr.send(formData);
        }
    </script>
</body>
</html>

8.2 React 컴포넌트

import React, { useState, useCallback } from 'react';
import axios from 'axios';

const VideoUpload = () => {
    const [uploadProgress, setUploadProgress] = useState(0);
    const [uploading, setUploading] = useState(false);
    const [result, setResult] = useState(null);
    const [error, setError] = useState(null);

    const handleFileUpload = useCallback(async (file) => {
        // 검증
        if (!file) return;

        if (file.size > 200 * 1024 * 1024) {
            setError('파일 크기가 200MB를 초과합니다.');
            return;
        }

        if (!file.type.startsWith('video/')) {
            setError('비디오 파일만 업로드 가능합니다.');
            return;
        }

        const formData = new FormData();
        formData.append('file', file);
        formData.append('userId', 'user123');

        setUploading(true);
        setError(null);
        setResult(null);

        try {
            const response = await axios.post('/api/videos/upload', formData, {
                headers: {
                    'Content-Type': 'multipart/form-data',
                },
                onUploadProgress: (progressEvent) => {
                    const percentCompleted = Math.round(
                        (progressEvent.loaded * 100) / progressEvent.total
                    );
                    setUploadProgress(percentCompleted);
                },
            });

            setResult(response.data);

        } catch (err) {
            setError(err.response?.data?.error || '업로드 중 오류가 발생했습니다.');
        } finally {
            setUploading(false);
            setUploadProgress(0);
        }
    }, []);

    const handleDrop = useCallback((e) => {
        e.preventDefault();
        const files = e.dataTransfer.files;
        if (files.length > 0) {
            handleFileUpload(files[0]);
        }
    }, [handleFileUpload]);

    const handleDragOver = useCallback((e) => {
        e.preventDefault();
    }, []);

    return (
        <div className="video-upload">
            <h2>비디오 업로드</h2>

            <div
                className={`upload-area ${uploading ? 'disabled' : ''}`}
                onDrop={handleDrop}
                onDragOver={handleDragOver}
                onClick={() => document.getElementById('fileInput').click()}
            >
                {uploading ? (
                    <div>
                        <p>업로드 중... {uploadProgress}%</p>
                        <div className="progress-bar">
                            <div
                                className="progress-fill"
                                style={{ width: `${uploadProgress}%` }}
                            />
                        </div>
                    </div>
                ) : (
                    <p>클릭하거나 파일을 드래그해서 업로드하세요</p>
                )}

                <input
                    id="fileInput"
                    type="file"
                    accept="video/*"
                    style={{ display: 'none' }}
                    onChange={(e) => handleFileUpload(e.target.files[0])}
                    disabled={uploading}
                />
            </div>

            {error && (
                <div className="error">
                    <h3>❌ 오류</h3>
                    <p>{error}</p>
                </div>
            )}

            {result && (
                <div className="success">
                    <h3>✅ 업로드 성공!</h3>
                    <p><strong>비디오 ID:</strong> {result.videoId}</p>
                    <p><strong>원본 파일명:</strong> {result.fileInfo.originalName}</p>
                    <p><strong>파일 크기:</strong> {(result.fileInfo.size / 1024 / 1024).toFixed(2)} MB</p>
                </div>
            )}
        </div>
    );
};

export default VideoUpload;

9. 에러 처리 및 로깅 전략

9.1 글로벌 예외 처리

@ControllerAdvice
@Slf4j
public class FileUploadExceptionHandler {

    @ExceptionHandler(MaxUploadSizeExceededException.class)
    public ResponseEntity<?> handleMaxSizeException(MaxUploadSizeExceededException e) {
        log.warn("⚠️ 파일 크기 초과: {}", e.getMessage());

        return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
            .body(Map.of(
                "error", "파일 크기가 제한을 초과했습니다.",
                "maxSize", "200MB"
            ));
    }

    @ExceptionHandler(MultipartException.class)
    public ResponseEntity<?> handleMultipartException(MultipartException e) {
        log.warn("⚠️ 멀티파트 요청 오류: {}", e.getMessage());

        return ResponseEntity.badRequest()
            .body(Map.of("error", "파일 업로드 형식이 올바르지 않습니다."));
    }

    @ExceptionHandler({IOException.class, FileNotFoundException.class})
    public ResponseEntity<?> handleIOException(Exception e) {
        log.error("❌ 파일 I/O 오류", e);

        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(Map.of("error", "파일 처리 중 오류가 발생했습니다."));
    }

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<?> handleIllegalArgumentException(IllegalArgumentException e) {
        log.warn("⚠️ 잘못된 인수: {}", e.getMessage());

        return ResponseEntity.badRequest()
            .body(Map.of("error", e.getMessage()));
    }
}

9.2 커스텀 예외 정의

public class VideoProcessingException extends RuntimeException {
    private final String errorCode;
    private final Object details;

    public VideoProcessingException(String errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
        this.details = null;
    }

    public VideoProcessingException(String errorCode, String message, Object details) {
        super(message);
        this.errorCode = errorCode;
        this.details = details;
    }

    // getters...
}

// 사용 예시
public class VideoValidationException extends VideoProcessingException {
    public VideoValidationException(String message) {
        super("VALIDATION_ERROR", message);
    }
}

public class FileStorageException extends VideoProcessingException {
    public FileStorageException(String message, Object details) {
        super("STORAGE_ERROR", message, details);
    }
}

10. 모니터링 및 메트릭

10.1 업로드 통계 수집

@Component
@Slf4j
public class UploadMetricsCollector {

    private final MeterRegistry meterRegistry;
    private final Counter uploadCounter;
    private final Counter errorCounter;
    private final Timer uploadTimer;

    public UploadMetricsCollector(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
        this.uploadCounter = Counter.builder("video.upload.total")
            .description("Total video uploads")
            .register(meterRegistry);
        this.errorCounter = Counter.builder("video.upload.errors")
            .description("Video upload errors")
            .register(meterRegistry);
        this.uploadTimer = Timer.builder("video.upload.duration")
            .description("Video upload duration")
            .register(meterRegistry);
    }

    public void recordUpload(String fileExtension, long fileSize) {
        uploadCounter.increment(
            Tags.of(
                "extension", fileExtension,
                "size_range", getSizeRange(fileSize)
            )
        );
    }

    public void recordError(String errorType) {
        errorCounter.increment(Tags.of("error_type", errorType));
    }

    public Timer.Sample startTimer() {
        return Timer.start(meterRegistry);
    }

    private String getSizeRange(long size) {
        if (size < 10 * 1024 * 1024) return "small";    // < 10MB
        if (size < 50 * 1024 * 1024) return "medium";   // 10-50MB
        if (size < 100 * 1024 * 1024) return "large";   // 50-100MB
        return "xlarge";                                  // > 100MB
    }
}

마무리

이번 2편에서는 실제 프로덕션 환경에서 사용되는 고급 파일 처리 기법들을 살펴봤습니다:

  • 실전 비디오 처리 서비스 구조 분석
  • Files.copy() vs transferTo() 비교
  • 대용량 파일 처리 전략
  • 메타데이터 추출 구현
  • 보안 강화 방법
  • 성능 최적화
  • 실제 컨트롤러 및 프론트엔드 연동
  • 에러 처리 및 모니터링 전략

Spring의 MultipartFile을 사용하면 복잡한 파일 업로드 로직도 안전하고 효율적으로 구현할 수 있습니다. 특히 비디오 파일처럼 큰 용량의 파일을 다룰 때는 보안, 성능, 사용자 경험 모든 면을 고려해야 합니다.

핵심은 단계별 검증, 안전한 파일 저장, 적절한 에러 처리, 모니터링입니다. 이런 요소들을 잘 조합하면 견고하고 확장 가능한 파일 업로드 시스템을 구축할 수 있습니다.

0개의 댓글