Python/Flask

Dropzone.js를 사용해 대용량 파일 업로드 API 성능 개선

검정비니 2023. 10. 9. 20:11
728x90
반응형

파일 업로드

일반적으로 파일 업로드를 할 때에는 multipart/form-data 형식으로 업로드를 진행하도록 API를 디자인하게 된다. 이때, 일반적인 파일들은 파일 크기가 그렇게 크지 않겠지만, 영상 파일과 같이 용량이 GB 단위가 되는 경우에는 단순히 한번의 요청으로 전체 파일을 올리는 것이 매우 어려워지게 된다.

다음 코드는 multipart/form-data 형식으로 데이터를 업로드하기 위한 API를 Flask와 flask-restx로 구현한 간단한 예제이다:

import uuid
from flask import request, make_response
from flask_restx import Resource, Namespace
from werkzeug.utils import secure_filename


api = Namespace("upload", description="Upload data with multipart/form-data")


class FileException(Exception):
    def __init__(self, message):
        super(FileException, self).__init__(message)
        self.message = message



@api.route("/")
class FileUploader(Resource):

    def post(self):
        """
        Expects a video file in the request body.
        To upload video, the user should use the "multipart/form-data" as a content-type.
        The key for the video file should be "video".

        The pose data should be sent as a stringified JSON object.
        """
        file = request.files["video"]

        if not file:
            print('No file found')
            return make_response(("No file found", 400))
        
        filename = secure_filename(file.filename)
        file_uuid = str(uuid.uuid4())
        try:
         video_size = file.seek(0, os.SEEK_SET)
            
            # save video info to DB
            save_video_info(file_uuid, filename, video_size)
        except FileException as fe:
            print(f'{str(fe)}')
            return make_response((str(fe), 400))

        except Exception as e:
            print(f'{str(e)}')
            return make_response((str(e), 500))

        return make_response(("File uploaded successfully", 200))


위의 코드에서는 "video"라는 key 값으로 업로드된 파일을 검색한 뒤, 해당 키값이 없으면 400 에러를 반환한다. 그 후, save_video_info 라는 커스텀 함수(데이터베이스에 업로드된 파일에 대한 정보를 저장하는 함수)를 호출한 뒤, Exception 없이 정상적으로 처리가 되면 200 코드를 리턴한다.

위의 코드로 15MB 정도 되는 파일을 업로드할 때에는 큰 문제가 없이 여러명의 사용자가 파일을 업로드할 수 있다. 그러나, 파일의 크기가 커질수록 점점 API 자체의 latency가 증가하게 된다.

이러한 문제를 일으키는 가장 결정적인 병목은 네트워크와 메모리, 특히 메모리이다.

 

왜 메모리가 문제인가?

파일 업로드 API의 경우, 서버에서는 요청이 들어왔을 때 서버 메모리 상에 업로드 요청으로 들어온 파일 데이터를 모두 올리게 된다. 업로드 시도를 한 파일이 전부 업로드가 되면 위의 HTTP POST 핸들러의 코드가 실행되면서 업로드 요청에 대한 비즈니스 로직의 처리가 시작된다.

즉, 파일의 용량이 크면 클수록 메모리 상에 올려야 하는 데이터의 크기가 증가하기 때문에 불가피하게 latency가 기하급수적으로 증가하게 되는 것이다.

이러한 부작용을 이용한 해킹 기법 역시 존재하는데, 파일 업로드 API로 일부러 대용량의 더미 파일들을 업로드하는 것이다. 대용량 파일 업로드 요청을 보내게 되면 서버에서는 이를 받기 위해서 그만큼의 메모리를 할당하게 되고, 언어나 프레임워크마다 차이가 있겠지만 대부분의 경우 OutOfMemory 에러나 지나친 context switching 등으로 인해 서버가 다른 요청을 처리하지 못하는 등의 문제를 겪게 된다.

보통 이러한 이유로 인해 클라우드 및 기업 서버에서는 API 게이트웨이 상에서 단일 요청당 페이로드의 최대 크기를 제한하는 등의 방법을 적용한다. AWS API Gateway의 경우에는 이 크기가 10MB로 되어있다 (이러한 문제로 인해, 람다 함수로 파일 업로드 기능을 구현할 때에는 단일 요청으로 업로드 가능한 최대 파일 크기가 10MB로 제한된다).

 

또다른 문제점이 있을까?

기능적인 문제와 보안적인 문제가 있을 수 있지만, 위와 같은 대용량 파일 업로드라는 비즈니스 로직의 경우 UI/UX적으로도 꽤 큰 어려움이 존재하게 된다.

단일 요청으로 큰 파일을 업로드하게 되면, API의 응답이 올 때까지 프론트엔드에서는 대기를 할 수 밖에 없게 된다. 업로드 요청을 보낸 뒤 장시간 동안 아무런 반응 없이 대기만 해야 하는 UI는 결코 사용자의 관점에서 좋은 UI라고 볼 수가 없다.

 

하나의 큰 파일을 보내는 대신 여러 개의 작은 파일로 쪼개자

위와 같이 대용량 파일의 업로드가 성능 저하 등 다양한 문제를 유발하기 때문에 일반적으로 사용하는 가장 보편적인 방법으로는 데이터를 작은 조각으로 나누어서 여러 번 업로드 API를 호출하는 것이다.

이렇게 하면 상대적으로 작은 크기를 업로드하기 때문에 메모리적으로 부담이 적어지게 되며, UI 상에서도 각 chunk upload API가 성공할 때마다 progress bar를 조작하는 등의 방법으로 사용자에게 업로드 상태를 더 자세히 나타낼 수 있게 된다.

dropzone.js

https://www.dropzone.dev/는 이러한 chunked upload를 위한 기능을 구현한 자바스크립트 라이브러리이다. 프론트엔드에서 chunked 업로드를 진행할 때에는 단순히 파일을 조각내는 것 뿐만이 아니라 신경써야할 사항들이 상당히 존재한다.

 

우선, 여러 개의 파일에 대해서 각각 적절히 chunking 과정을 거쳐야 하며, 동시에 몇개의 파일에 대해서 업로드 처리를 할지에 대해서 결정해야 한다. 또한, 미리 정한 chunk 사이즈보다 작은 파일이 업로드 대상이 되었을 때나, chunking 과정 중 남은 데이터의 크기가 chunk보다 작을 경우에 대해서도 고려를 하여야 한다.


그뿐만이 아니라, 업로드되는 chunked file의 순서나 고유 아이디(해당 요청이 어느 파일에 대한 내용인지를 식별할 수 있게 해주는 uuid), 원본 파일의 크기, 총 chunk의 수, 요청 실패 시 retry 여부 등 고려해야 하는 사항이 너무나도 많이 있다.

이러한 기능들을 직접 개발하는 것도 좋겠지만, 빠르게 개발을 해야 하는 경우에는 적절하지 못하다. 그렇기 때문에 직접 개발을 하는 대신 dropzone.js를 사용해서 프론트엔드 쪽 기능을 구현하게 되었다.

dropzone.js에서는 위에서 언급한 원본 파일의 크기, 총 chunk의 수, 현재 chunk의 순서 등을 HTTP 헤더에 포함해서 업로드를 진행한다. 예를 들어, 총 chunk의 수는 `dztotalchunkcount`라는 키 값을 사용하고, 원본 파일의 크기는 `dztotalfilesize`라는 키 값을 사용한다. 따라서, 서버에서는 HTTP 헤더에서 적절한 키값을 사용해서 해당하는 값을 추출하고, 이를 사용해서 업로드된 데이터에 대한 validation을 진행할 수 있게 된다 (원본 파일의 크기보다 더 큰 용량을 보내는 경우, 현재 chunk index가 총 chunk의 수보다 큰 경우 등).

아래는 dropzone.js 클라이언트로부터 업로드되는 파일을 받는 API를 Flask와 flask-restx로 구현한 예제이다:

from flask import request, make_response
from flask_restx import Resource, Namespace
import os
from werkzeug.utils import secure_filename


api = Namespace("upload", description="Upload data with dropzone.js")

@api.route("/")
class DropzoneFileUploader(Resource):
    def post(self):
    try:
            file = request.files['file']
            filename = file.filename
            extension_name = get_file_extension(filename)
        except:
            print('Invalid request key')
            return make_response(('Invalid request key', 400))

        # get current chunk for dropzone chunked upload
        current_chunk = int(request.form['dzchunkindex'])

        # generate a unique file name with dropzone uuid after checking if the file extension is valid and supported
        dz_uuid = request.form['dzuuid']
        
        # generate filepath, check if filename is secure, and check if file already exists
        save_path = os.path.join(_INPUT_MEDIA_DIR, secure_filename(filename))
        if os.path.exists(save_path) and current_chunk == 0:
            print(f'File {filename} already exists')
            # 400 and 500s will tell dropzone that an error occurred and show an error
            return make_response(('File already exists', 400))
        
        
        try:
            self._update_chunk(file, save_path)
        except OSError as ose:
            print(f'Error while saving the file: {str(ose)}')
            return make_response(("Not sure why, but we couldn't write the file to disk", 500))

        total_chunks = int(request.form['dztotalchunkcount'])
        total_filesize = int(request.form['dztotalfilesize'])

        # Check if this was not the last chunk
        if current_chunk + 1 != total_chunks:
            print(f'Chunk {current_chunk + 1} of {total_chunks} for file {filename} complete')
            # Tell Dropzone that everything is OK
            return make_response(('Chunk uploaded', 200))

        # This was the last chunk, the file should be complete and the size we expect
        if os.path.getsize(save_path) != int(total_filesize):
            print(f"File {filename} was completed, but has a size mismatch. Was {os.path.getsize(save_path)} but we expected {total_filesize} ")
            os.remove(save_path)
            return make_response(('Size mismatch', 500))
        print(f'File {filename} has been uploaded successfully')
        
        
        # use try-except to handle unexpected errors from the backend
        try:
            file_uuid = str(uuid.uuid4())
            # save video info to DB
            save_video_info(file_uuid, filename, video_size)
        except:
            log_error(f'Error while saving the video info to DB')
            return make_response(('Server Error', 500))

        # Tell Dropzone that everything is OK
        return make_response(('Chunk uploaded', 200))


이제 요청을 보내는 프론트엔드 코드를 작성할 차례이다.

dropzone.js를 사용하는 웹페이지의 경우, 아래의 코드를 html에 추가해주면 된다:

<script type="application/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/dropzone/5.4.0/min/dropzone.min.js"></script>
<script type="application/javascript">
    Dropzone.options.dropper = {
        paramName: 'file',
        chunking: true,
        forceChunking: true,
        retryChunks: true,
        retryChunksLimit: 3,
        parallelUploads: 2,
        url: '/upload/',
        maxFilesize: 3750,  // megabytes
        chunkSize: 20000000, // bytes : 20MB (default: 1MB)
    }
</script>


현재 예제에서는 dropzone.js v5.4.0을 사용하고 있으나, 만약 더 최신버전이 있다면 최신버전을 사용하기를 권장한다.

또한, 위의 코드에서 maxFilesize는 업로드 가능한 최대 파일 사이즈를 의미하는데, 위의 설정대로면 3.75GB 까지 업로드가 가능하다 (필요에 따라 조절하면 된다). 그리고, chunkSize 매개변수는 단일 chunk의 크기를 의미한다.

 

적절한 chunk 크기는 어느정도인가?

chunksize가 작으면 chunk의 수가 많아지기 때문에 API 호출 회수가 증가하게 되고, chunksize를 그렇다고 너무 키우면 애초에 회피하고자 했던 문제와 다시 직면하게 된다. 다시말해 메모리와 API 호출 회수 사이에 tradeoff가 발생하게 된다.

이와 관련해서 정확한 문제 파악을 위해 여러 chunksize에 대해서 실험을 진행하였다.
클라이언트 측 네트워크는 상용 와이파이(KT 5G)를 사용하였고, 서버는 AWS EC2 인스턴스 중에서 t3.micro 인스턴스를 생성하였다. 실험을 위해서는 mp4 확장자를 가지는 1.1GB 크기의 영상 파일을 사용하였다.

 

chunksize 총 API 호출 회수 단일 요청당 소요 시간 (latency)
1MB 3073회 각 요청당 소요 시간 약 0.7초
2MB 1561회 각 요청당 소요 시간 약 0.8초
10MB 308회 각 요청당 소요 시간 약 1.1초
20MB 154회 각 요청당 소요 시간 약 1.3초

 

위에서 볼 수 있듯이, chunksize가 1MB일 때와 20MB일 때에 업로드 완료까지 걸리는 총 시간이 약 20배정도 차이나게 된다. t3.micro 인스턴스 자체에 할당된 메모리가 1GB 정도 밖에 안되기 때문에 `vmstat`을 사용해서 서버 리소스 상태를 관측할 때 더 큰 chunksize에 대해서는 실험을 하기가 애매할 것 같아서 20MB보다 큰 사이즈에 대해서는 실험을 진행하지 않았다.

 

그러나 결과적으로 chunksize 조절을 통해서 default 값일 때보다 약 20배 정도 업로드 성능 향상을 달성할 수 있었다.

 

마무리하며

여러 프로젝트들을 진행하면서 영상 파일 업로드와 관련해서 여러 경험을 할 수 있었는데 매번 일정 크기 이상의 파일을 업로드하게 되면 네트워크나 메모리 등의 이슈로 시스템 전체에 성능 저하를 경험했었다. 이번 프로젝트에서는 엣지 디바이스가 아닌 서버 개발에 참여를 하게 되었기에 이 부분에 대해서 조금 더 심도 깊은 이해를 하고 싶었고, 결과적으로는 이전 프로젝트들에서 개발되었던 서버들보다 더 높은 성능을 가지는 대용량 파일 업로드 API를 구현할 수 있었다.

 

반응형