영상 스트리밍 #2. 파일 분할 업로드

Bobby·2023년 2월 25일
0

streaming

목록 보기
2/9

🎥 파일 분할 전송

  • 대용량의 파일을 전송할 때 OOM 이 발생했다.

  • 대용량의 파일의 경우 한번에 보내는 것이 아니고 잘게 쪼개서 전송하여 서버에서 합치는 방식으로 문제를 해결 할 수 있다.

ex) 100MB의 파일을 1MB 단위로 100번 전송한다.
이 때 서버에서는 잘게 쪼개진 파일을 하나로 합치기 위해서 파일의 이름과 총 몇개의 조각으로 나눴는지(totalChunkSize), 각 조각이 몇 번째 조각인지(chunkNumber)를 알고 있어야 한다.


🎥 서버

  • 스프링부트 2.7.8 버전을 사용
  • 스프링 웹과 타임리프를 사용했다
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

서비스

  • 작은 단위로 쪼갠 데이터를 임시로 로컬 스토리지에 저장한다.
    {filename}.part{chunkNumber} 이런 형태로 임시파일을 저장해 놓는다.
  • 마지막 데이터가 오면 임시로 저장된 데이터를 하나로 합치고 임시 파일들을 삭제한다.

ChunkUploadService

@Slf4j
@Service
public class ChunkUploadService {
    public boolean chunkUpload(MultipartFile file, int chunkNumber, int totalChunks) throws IOException {
    	// 파일 업로드 위치
        String uploadDir = "video";

        File dir = new File(uploadDir);
        if (!dir.exists()) {
            dir.mkdirs();
        }

		// 임시 저장 파일 이름
        String filename = file.getOriginalFilename() + ".part" + chunkNumber;

        Path filePath = Paths.get(uploadDir, filename);
        // 임시 저장
        Files.write(filePath, file.getBytes());

		// 마지막 조각이 전송 됐을 경우
        if (chunkNumber == totalChunks-1) {
            String[] split = file.getOriginalFilename().split("\\.");
            String outputFilename = UUID.randomUUID() + "." + split[split.length-1];
            Path outputFile = Paths.get(uploadDir, outputFilename);
            Files.createFile(outputFile);
            
            // 임시 파일들을 하나로 합침
            for (int i = 0; i < totalChunks; i++) {
                Path chunkFile = Paths.get(uploadDir, file.getOriginalFilename() + ".part" + i);
                Files.write(outputFile, Files.readAllBytes(chunkFile), StandardOpenOption.APPEND);
                // 합친 후 삭제
                Files.delete(chunkFile);
            }
            log.info("File uploaded successfully");
            return true;
        } else {
            return false;
        }
    }
}

컨트롤러

  • 파일, chunkNumber, totalChunks 를 전송 받는다.
  • 아직 조각이 전송중이라면 상태코드 206 전달하고 마지막 조각까지 전송 된 경우 상태코드 200 전달한다.

ChunkUploadController

@Controller
@RequiredArgsConstructor
public class ChunkUploadController {

    private final ChunkUploadService chunkUploadService;

    @GetMapping("/chunk")
    public String chunkUploadPage() {
        return "chunk";
    }

    @ResponseBody
    @PostMapping("/chunk/upload")
    public ResponseEntity<String> chunkUpload(@RequestParam("chunk") MultipartFile file,
                                              @RequestParam("chunkNumber") int chunkNumber,
                                              @RequestParam("totalChunks") int totalChunks) throws IOException {
        boolean isDone = chunkUploadService.chunkUpload(file, chunkNumber, totalChunks);

        return isDone ?
                ResponseEntity.ok("File uploaded successfully") :
                ResponseEntity.status(HttpStatus.PARTIAL_CONTENT).build();
    }
}

🎥 클라이언트

  • 1MB 씩 쪼개서 전송

chunk.html

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

<input id="video-file" type="file" name="file">
<button onclick="sendVideoChunks()">업로드</button>
<div id="result"></div>

</body>
<script>
    const sendVideoChunks = () => {
        const chunkSize = 1024 * 1024; // 1MB
        const file = document.getElementById("video-file").files[0];
        const resultElement = document.getElementById("result");

  		// total size 계산
        const totalChunks = Math.ceil(file.size / chunkSize);
        let currentChunk = 0;

  		// chunk file 전송
        const sendNextChunk = () => {
  			
  			// chunk size 만큼 데이터 분할
            const start = currentChunk * chunkSize;
            const end = Math.min(start + chunkSize, file.size);

            const chunk = file.slice(start, end);
			
  			// form data 형식으로 전송
            const formData = new FormData();
            formData.append("chunk", chunk, file.name);
            formData.append("chunkNumber", currentChunk);
            formData.append("totalChunks", totalChunks);

            fetch("/chunk/upload", {
                method: "POST",
                body: formData
            }).then(resp => {
  				// 전송 결과가 206이면 다음 파일 조각 전송
                if (resp.status === 206) {
  					// 진행률 표시
                    resultElement.textContent = Math.round(currentChunk / totalChunks * 100) + "%"
                    currentChunk++;
                    if (currentChunk < totalChunks) {
                        sendNextChunk();
                    }
                // 마지막 파일까지 전송 되면 
                } else if (resp.status === 200) {
                    resp.text().then(data => resultElement.textContent = data);
                }
            }).catch(err => {
                console.error("Error uploading video chunk");
            });
        };

        sendNextChunk();
    }
</script>
</html>

🎥 실행

http://localhost:8080/chunk

  • 분할된 파일이 임시 저장 된다.

  • 전송 완료

  • 임시 파일들은 삭제되고 하나로 합쳐진다.

  • 4.39GB의 대용량 파일도 전송이 완료 되었다.

profile
물흐르듯 개발하다 대박나기

1개의 댓글

comment-user-thumbnail
2023년 8월 23일

안녕하세요. 궁금한게 생겨서 여쭈어 봅니다!
다름이 아니라, 같은 환경에서 해당 예제를 실행하면,
2023-08-23 15:16:14.678 INFO 18544 --- [ File Watcher] rtingClassPathChangeChangedEventListener : Restarting due to 2464 class path changes (0 additions, 2464 deletions, 0 modifications)
2023-08-23 15:16:14.696 INFO 18544 --- [ Thread-7] o.apache.catalina.core.StandardService : Stopping service [Tomcat]
2023-08-23 15:16:14.697 INFO 18544 --- [ Thread-7] o.apache.catalina.core.StandardWrapper : Waiting for [1] instance(s) to
be deallocated for Servlet [dispatcherServlet]
2023-08-23 15:16:14.810 INFO 18544 --- [ Thread-7] o.a.c.c.C.[Tomcat].[localhost].[/] : Destroying Spring FrameworkServlet 'dispatcherServlet'
라는 에러 로그와 함께 spring boot가 제부팅이 되는데, 혹시 해당 원인이 있으셨다면, 어떻게 해결하셧는지 알 수 있을까요?

답글 달기