본문 바로가기
Javascript

[javascript]chunked 파일 업로드 with ajax, django

by 하이바네 2022. 12. 14.
반응형

분할하여(chunked) 파일 업로드를 해야하는 작업이 있어 여러 자료를 검색했고 구현을 했다.

 

요구 사항은 client에서 ajax를 통해서 대용량 첨부파일을 업로드하는 것이었다. 서버에서 업로드하는 용량이 제한되어 있거나, request를 보내고나서 오랜시간 사용자가 멈춰있는게 문제가 되므로 파일을 분리하여 ajax로 구현을 진행했다.

 

ajax를 사용한것은 비동기 통신을 위해서이다. 그냥 간단히 현재 페이지에서 페이지의 이동 없이 서버쪽으로 요청을 보내고 결과를 받는것이라고 이해하면 편하다. 여기에서 request, callback, response라는 개념들이 나올것이다.

 

가장 도움이 된 자료는 여기에서 확인을 한 자료이다. Django로 개발을 진행중이었기에 만약 다른 언어로 서버 파트를 구현중이라면 동작만 참조해서 구현을 하면 될듯하다. 자세한 설명은 아래에 추가 하였다.

 

 

AJAX file upload in chunks using Django, with a progress bar.

Hello!!! There are lot of methods and technologies who allows to create a file uploader. Django also...

dev.to

 

구현에 대한 간략 설명

 

1. 파일 첨부

2. 업로드 시작

3. file을 slice하여 서버에 업로드

4. 서버에서는 해당 파일을 받아서 업로드 폴더에 파일을 생성

5. 서버에서 마지막 slice인지 체크하여 마지막 조각인지 아닌지만 체크

6. 3~5번 동작이 끝났으면 ajax는 response를 받을것이고 아직 slice가 남아있으면 다음 slice를 서버에 전달

7. 서버에서는 해당 파일을 받아서 업로드 폴더에 파일을 이어쓰기 모드로 추가

8. 파일 slice가 끝날때까지 5~7을 연속하여 동작

9. 업로드가 정말 끝났으면 클라이언트 ajax는 response를 받고 업로드가 완료되었다고 표시

 

전체적인 로직을 설명하면 위와 같이 설명이 가능하다.

 

위의 내용이 기본적인 내용이고, 실제 구현을 할때는 "중복 파일명 업로드", "파일 확장자 제한"과 같은게 고려 되어야 한다.

 

아래에 개발된 소스는 js, jquery, django로 구현된 것으로 model은 따로 올리지 않았고 Notice라는 모델과 NoticeFile이라는 모델이 1:N의 관계를 가지게 구성하였다. 

 

//upload.js
/*
//다중 파일 chunked upload
fileLength != fileIndex 이면 upload를 다시 요청(다음 파일 업로드)
*/

class FileUpload {

    constructor() {
        this.isUpload = false;//업로드 여부(상태값)
        this.maxLength = 1024 * 1024 * 10;//chunked 파일 사이즈
        this.fileIndex = 0;//업로드 파일 인덱스
        this.saveNameArr = new Array();//파일 저장 이름(사용자id_시간)
        this.fileIdArr = new Array();//파일id
    }

    add_file(input){
        this.input = input;
        this.startIndex = this.fileLength||0;//이전 파일 개수  null or undefined일 경우 0으로 대입
        this.fileLength = this.input.files.length;//총 파일 개수

        console.log(this.startIndex);
        //일단 동작 확인 후 이전 length보다 커진 경우 index를 확인해서 그 이후에 값들을 추가해주는 방식으로...

        //현재 chunk index 0으로 채우거나, array에 값 추가
        this.chunkCounter = (this.startIndex == 0) ? new Array(this.fileLength).fill(0) : this.chunkCounter.concat(new Array(this.fileLength - this.startIndex).fill(0));

        //현재 파일 업로드 중지 여부 0으로 채우거나, array에 값 추가
        this.isStop = (this.startIndex == 0 ) ? new Array(this.fileLength).fill(false) : this.isStop.concat(new Array(this.fileLength - this.startIndex).fill(0));

        //파일 리스트 생성
        this.create_file_list(input);

    }

    //input파일 비우기(페이지 저장 전에 사용)
    truncate_file(){
        document.querySelector('#files').value = '';
    }

    //파일 리스트 붙여넣기
    create_file_list(input){
        let self = this;
        const wrapper = document.getElementById('file_list');
        const len = input.files.length;
        console.log(input.files);

        for(let i=self.startIndex; i< len; i++){
            let className = `progress-bar${i}`;
            let stopBtnName = `stop-upload${i}`;
            //console.log(className);
            let list = `<div>
                            <span class="filename">${input.files[i].name}</span>
                            <div class="progress" style="margin-top: 5px;">
                                <div class="${className} bg-success" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="width: 0%"></div>
                            </div>
                            <span class='stop-upload${i}' data-index='${i}'>X</span>
                        </div>`;
            wrapper.insertAdjacentHTML("beforeend",list);

            //정지 버튼 이벤트 연결
            $(`.stop-upload${i}`).on('click',function(){
                const fileIndex = $(this).data('index');

                if(self.isStop[fileIndex]){//정지 버튼을 한번 누른 경우
                    alert('이미 삭제 처리된 파일 입니다.');
                }
                else{//정지 및 삭제 처리
                    self.isStop[fileIndex] = true;
                    //업로드 취소 상태로 화면 표시 변경
                    $(`.progress-bar${fileIndex}`).addClass('bg-danger').removeClass('bg-success');
                    $(`.progress-bar${fileIndex}`).css('width', '100%');

                    //파일 삭제 처리
                    self.delete_file(self.fileIdArr[fileIndex]);
                }
            })
        }
    }

    //this.input.files에 있는 모든 파일을 순차적으로 업로드
    upload() {
        this.file = this.input.files[this.fileIndex];

        this.createSaveName(this.file);

        this.upload_file(0, null);
        this.display_status();
    }

    //변경된 이름 저장
    createSaveName(file){
        let extension = '.'+file.name.split('.').reverse()[0];
        this.saveNameArr[this.fileIndex] = $("input[name='user_id']").val() + '_' + Date.now() + extension;
    }

    //업로드 상황 표시
    display_status(){
        (this.isUpload == true) ? $('#attach-text').text(`(업로드 중)`) : $('#attach-text').text(`(업로드 완료)`);
    }

    //다음 파일 업로드(다음 파일 여부 체크 후)
    uploadNext(){
        this.fileIndex++;
        if(this.fileLength != this.fileIndex){
            this.upload();
        }
        else{//업로드 완료
            this.isUpload = false;
        }

        this.display_status();
    }

    //upload file
    upload_file(start, model_id) {
        this.isUpload = true;

        //console.log(this.file, this.fileIndex);
        var end;
        var self = this;
        var existingPath = model_id;
        var formData = new FormData();
        var nextChunk = start + this.maxLength + 1;
        //console.log('this.file',this.file);
        var currentChunk = this.file.slice(start, nextChunk);
        var uploadedChunk = start + currentChunk.size;

        //console.log(this.file.size, uploadedChunk, start, nextChunk, currentChunk.size)

        if (uploadedChunk >= this.file.size) {
            end = 1;
        } else {
            end = 0;
        }
        formData.append('file', currentChunk);//chunked된 파일 사이즈
        formData.append('originalName', this.file.name);//실제 파일명
        formData.append('saveName', this.saveNameArr[this.fileIndex]);//저장 파일명
        formData.append('end', end);//종료 여부
        formData.append('existingPath', existingPath);//저장 경로
        formData.append('nextSlice', nextChunk);//다음 chunked  !!이 부부을 form으로 보내야하는지는 재검증 필요!!
        $.ajaxSetup({
            headers: {
                "X-CSRFToken": document.querySelector('[name=csrfmiddlewaretoken]').value,
            }
        });
        //console.log('start ajax');
        let send = $.ajax({
            xhr: function () {
                var xhr = new XMLHttpRequest();
                xhr.upload.addEventListener('progress', function (e) {
                    if (e.lengthComputable) {

                        //전송 여부 상태 체크
                        if(self.isStop[self.fileIndex] == true){//전송 중지 버튼을 누른 경우
                            send.abort();
                            self.uploadNext();//파일이 더 있으면 추가 업로드
                        }
                        else{
                            //((현재 chunk 인덱스 * 전송 사이즈 ) + chunk되어 전송된 사이즈) / 총 파일 용량 * 100
                            var percent = Math.floor(((self.chunkCounter[self.fileIndex]*self.maxLength) + e.loaded) / self.file.size * 100);

                            let progressbarName = `progress-bar${self.fileIndex}`;
                            $(`.${progressbarName}`).css('width', percent + '%')
                            $(`.${progressbarName}`).text(percent + '%')
                        }
                    }
                });
                return xhr;
            },

            url: '/notice/ajax_upload_file',
            type: 'POST',
            dataType: 'json',
            cache: false,
            processData: false,
            contentType: false,
            data: formData,
            error: function (xhr) {
                console.log(xhr.statusText);
            },
            success: function (res) {
                //파일 업로드 후 입력 NoticeFile.id값 저장
                if(res.fileId){
                    self.fileIdArr[self.fileIndex] = res.fileId;
                    const $fileIdList = $("input[name='file_id_list']")
                    $fileIdList.val(`${$fileIdList.val()}${res.fileId},`);
                }

                //파일 업로드 부분
                if (nextChunk < self.file.size) {//파일 업로드
                    //현재 chunks 위치 업데이트
                    self.chunkCounter[self.fileIndex]++;

                    // upload file in chunks
                    existingPath = res.existingPath
                    self.upload_file(nextChunk, existingPath);
                } else {//파일 업로드 완료 메시지 및 다음 파일 업로드
                    // upload complete
                    $('.textbox').text(res.data);
                    alert(res.data)

                    self.uploadNext();

                    //id받기 -> input에 저장
                }
            }
        });
    };

    //첨부파일 삭제
    delete_file(fileId){
        var formData = new FormData();
        formData.append('fileId',fileId);

        $.ajaxSetup({
            headers: {
                "X-CSRFToken": document.querySelector('[name=csrfmiddlewaretoken]').value,
            }
        });
        $.ajax({
            url: '/notice/ajax_delete_file',
            type: 'POST',
            dataType: 'json',
            cache: false,
            processData: false,
            contentType: false,
            data: formData,
            error: function (xhr) {
                console.log(xhr.statusText);
            },
            success: function(res){
                alert(res.data);

                //파일 삭제 처리 시 id가 저장된 input에서 제거
                const $fileIdList = $("input[name='file_id_list'");
                $fileIdList.val($fileIdList.val().replace(`${fileId},`,''));
            }
        })
    }

    //첨부파일 삭제
    delete_file_modify(fileId){

        //삭제 여부 체크
        const isDel = $(`.attached${fileId}`).data('is-delete');

        if(isDel){
            alert('이미 삭제된 파일 입니다.');
        }
        else{
            $(`.attached${fileId}`).data('is-delete',1)
            //status만 변경
            $(`.attached${fileId}`).addClass('bg-danger').removeClass('bg-success');
            $(`.attached${fileId}`).css('width', '100%');

            //파일 삭제
            this.delete_file(fileId);
        }
    }
}

//전송할 파일 저장할 객체
const dT = new DataTransfer();
const uploader = new FileUpload();

(function ($) {
    //파일 첨부하기 버튼 trigger
    $('#attach').on('click',function(){
        $('#files').trigger('click');
    });

    //files에 첨부파일이 선택되거나 하면
    $('#files').on('change', (e) => {
        let files = document.querySelector('#files').files

        for(let i=0; i<files.length; i++){
            dT.items.add(files[i]);
        }

        uploader.add_file(dT);

        if(!uploader.isUpload)
            uploader.upload();
    });

})(jQuery);

ondragenter = function(evt) {
    evt.preventDefault();
    evt.stopPropagation();
};

ondragover = function(evt) {
    evt.preventDefault();
    evt.stopPropagation();
};

ondragleave = function(evt) {
    evt.preventDefault();
    evt.stopPropagation();
};


ondrop = function(evt) {
    evt.preventDefault();
    evt.stopPropagation();

    //jquery 이벤트로 접근
    const files = evt.originalEvent.dataTransfer.files;

    for(let i=0; i<files.length; i++){
        dT.items.add(files[i]);
    }

    //파일 추가
    uploader.add_file(dT);

    //파일이 업로드중이 아닐 경우 업로드 시작
    if(!uploader.isUpload)
        uploader.upload();
};

$('#dropBox')
    .on('dragover', ondragover)
    .on('dragenter', ondragenter)
    .on('dragleave', ondragleave)
    .on('drop', ondrop);
<!--django template-->
{% extends 'main/home.html' %}
{% load static %}
{% block content %}
<div class="container">
    <h1>공지사항 등록(관리자)</h1>
    <div>
        <form id="frm" enctype="multipart/form-data" >
            {% csrf_token %}
            <input type="hidden" name="user_id" value="{{ user.id }}">
            <input type="hidden" name="file_id_list" value="{{ fileList|default_if_none:''}}">
            <div>
                <label>제목</label>
                <input type="text" name="title" value="{{ form.title|default_if_none:''}}">
            </div>
            <div>
                <label>내용</label>
                <input type="text" name="content" value="{{ form.content|default_if_none:''}}">
            </div>
            <div>
                <div class="drop-box" id="dropBox" style="width: 100%; height: 400px; border: 4px dashed gray;" >
                    <p style="text-align: center; vertical-align: middle; line-height: 400px; font-size: 24px; color: gray;">Drag & Drop to Upload File</p>
                </div>
                <label id="attach">파일 첨부<span class="small" id="attach-text"></span></label>
                <input class="d-none" type="file" id="files" name="files" multiple="multiple">
            </div>
            <div id="file_list">
                <!--기존 파일 존재 시 리스트 출력(삭제 가능)-->
                {% for file in form.noticeFile_notice.all %}
                <div>
                    <span class="filename">{{ file.original_name }}</span>
                    <div class="progress" style="margin-top: 5px;">
                        <div class="attached{{file.id}} bg-success" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="width: 100%"></div>
                    </div>
                    <span onclick="uploader.delete_file_modify({{file.id}})" >X</span>
                </div>
                {% endfor %}
            </div>

            {% if form.id %}
            <div><a href="javascript:modify()">수정</a></div>
            {% else %}
            <div><a href="javascript:register()">등록</a></div>
            {% endif %}
            <div><a href="{% url 'notice:index' %}">목록</a></div>
        </form>
    </div>
</div>
{% endblock %}

{% block script %}
<script src="{% static 'js/upload.js' %}"></script>
<script>
    const register = () => {

        if(uploader.isUpload && uploader.fileLength > uploader.fileIndex){//현재 업로드중이고 업로드가 완료되지 않은 경우
            alert('파일 업로드가 완료되지 않았습니다.');
        }
        else{
            uploader.truncate_file();

            //let params = $("#frm").serializeArray();
            let form = $("#frm")[0];
            let formData = new FormData(form);
            //let files = $("#files")[0].files[0];
            //formData.append('files',files);

            //console.log(params);
            console.log(formData);

            fetch('{% url 'notice:ajax_create'%}',{
                method:"POST",
                /*
                headers: {
                    'Content-type':'multipart/form-data',
                    'X-Requested-With': 'XMLHttpRequest',
                    'X-CSRFToken':'{{csrf_token}}'},
                */
                headers: {
                    'X-CSRFToken':'{{csrf_token}}'},
                cache: "no-store",
                body:formData,
            })
            .then(response => response.json())
            .then((data) => {
                if(data.code == 200)
                    location.href='{% url 'notice:index' %}'
                else
                    alert(data.msg);
            })
            .catch((error) => console.log(error))
        }
    }

    const modify = () => {
        let form = $('#frm')[0];
        form.method = 'post';
        form.submit();
    }
</script>
{% endblock %}
#ajax_views.py
'''
 - ajax는 view파일을 별도로
 - 글작성, 글수정, 글삭제
 ajax_create
 ajax_update
 ajax_delete
'''
import os
from django.http import JsonResponse
from notice.forms import NoticeForm
from django.utils import timezone
from notice.models import (Notice, NoticeFile)


def ajax_create(request):
    if request.method == "POST":

        form = NoticeForm(request.POST)

        # print(request.FILES);#파일 첨부에 있는 것들은 실제로 넘어올 필요가 없음
        # print(form)

        if form.is_valid():
            notice = form.save(commit=False)
            notice.written_date = timezone.now()
            notice.save()

            #notice file 저장
            fileList = request.POST['file_id_list'].split(',')
            res = [int(ele) for ele in fileList if ele != '']#빈칸 제거 및 int형으로 변환
            noticeFileList = NoticeFile.objects.filter(pk__in=res, writer=request.user)

            notice.noticeFile_notice.set(noticeFileList)# 확인필요
            # for file in noticeFileList:
            #     notice.noticeFile_notice.add(file)

            data = {'code': '200', 'msg': '등록 되었습니다.'}
        else:
            #print(form.non_field_errors())
            data = {'code': '400', 'msg': '에러가 발생했습니다.'}

        return JsonResponse(data)

    else:
        print("Error")


def ajax_delete(request):
    if request.method == "POST":

        # 파일 삭제, 파일 테이블 삭제, 게시글 삭제
        noticeId = request.POST['notice_id']
        notice = Notice.objects.get(pk=noticeId)
        te = notice.noticeFile_notice.all()
        for file in notice.noticeFile_notice.all():
            delete_file(file)

        notice.delete()
        #notice.noticeFile_notice.remove()
        #notice.noticeFile_notice.all()
        #notice.notice_file.all()
        #for file in notice.notice_file:
        #     print(file)
        #     #notice.notice_file

        data = {'code':'200', 'msg':'삭제 되었습니다.'}
        return JsonResponse(data)
    else:
        print("Error")


def ajax_upload_file(request):
    if request.method == 'POST':
        file = request.FILES['file'].read()
        originalName = request.POST['originalName']
        saveName = request.POST['saveName']
        existingPath = request.POST['existingPath']
        end = request.POST['end']
        nextSlice = request.POST['nextSlice']

        if file == "" or originalName == "" or saveName == "" or existingPath == "" or end == "" or nextSlice == "":
            res = JsonResponse({'data': 'Invalid Request'})
            return res
        else:
            if existingPath == 'null':
                path = 'media/' + saveName
                with open(path, 'wb+') as destination:
                    destination.write(file)
                FileFolder = NoticeFile()
                FileFolder.existingPath = saveName
                FileFolder.eof = end
                FileFolder.original_name = originalName
                FileFolder.save_name = saveName
                FileFolder.writer = request.user
                FileFolder.save()

                if int(end):
                    res = JsonResponse(
                        {'data': 'Uploaded Successfully', 'existingPath': saveName, 'fileId': FileFolder.id})
                else:
                    res = JsonResponse({'existingPath': saveName, 'fileId': FileFolder.id})
                return res

            else:
                path = 'media/' + existingPath
                noticeFile = NoticeFile.objects.get(existingPath=existingPath)
                if noticeFile.save_name == saveName:
                    if not noticeFile.eof:
                        with open(path, 'ab+') as destination:
                            destination.write(file)
                        if int(end):
                            noticeFile.eof = int(end)
                            noticeFile.save()
                            res = JsonResponse(
                                {'data': 'Uploaded Successfully', 'existingPath': noticeFile.existingPath})
                        else:
                            res = JsonResponse({'existingPath': noticeFile.existingPath})
                        return res
                    else:
                        res = JsonResponse({'data': 'EOF found. Invalid request'})
                        return res
                else:
                    res = JsonResponse({'data': 'No such file exists in the existingPath'})
                    return res
    else:
        res = JsonResponse({'data': '오류가 발생했습니다.'})
        return res


def ajax_delete_file(request):
    # 현재 로그인 사용자가 아니면 막는 처리 필요
    if request.method == 'POST':
        if request.POST['fileId'] != 'undefined':
            try:
                noticeFile = NoticeFile.objects.get(id=request.POST['fileId'], writer=request.user)
                delete_file(noticeFile)
            except Exception as e:
                print(e)

        res = JsonResponse({'data': '파일이 삭제 되었습니다.'})
        return res
    else:
        res = JsonResponse({'data': '오류가 발생했습니다.'})
        return res


#파일 삭제 로직
def delete_file(fileObj):
    try:
        if fileObj:
            path = 'media/' + fileObj.existingPath
            if os.path.exists(path):
                os.remove(path)

            fileObj.delete()# 객체 삭제

    except Exception as e:
        print(e)
#models.py

from django.db import models


class Notice(models.Model):
    class Meta:
        db_table = 'tb_notice'
        # ordering = ('-created_date',)

    title = models.CharField(max_length=254)
    content = models.TextField(blank=True, null=True)
    #files = models.FileField()
    written_date = models.DateTimeField(null=True, blank=True)
    writer = models.ForeignKey('user.User', on_delete=models.SET_NULL, null=True, blank=True)
    is_display = models.BooleanField(default=True)
    is_del = models.BooleanField(default=False)
    #notice_file = models.ManyToManyField('notice.NoticeFile', blank=True, null=True, related_name='notice_noticeFile')


class NoticeFile(models.Model):
    class Meta:
        db_table = 'tb_notice_files'

    existingPath = models.CharField(unique=True, max_length=100)
    original_name = models.CharField(max_length=50)
    save_name = models.CharField(max_length=50)
    eof = models.BooleanField()
    writer = models.ForeignKey('user.User', on_delete=models.SET_NULL, null=True, blank=True)
    notice = models.ManyToManyField('notice.Notice', blank=True, null=True, related_name='noticeFile_notice')

 

위의 소스가 현재 구현된 소스이며, 사용 전에 알아야할 것이 있다.

 

1. 첨부파일은 저장 파일명과 실제 파일명 두개를 구분하여 저장을하고 chunked된 파일을 보낼때 이어붙이기 여부 체크를 파일명으로 처리하고 있다. 파일명은 userId + timestamp를 사용하기에 문제는 없다고 생각된다. 단, 현재 코드에는 프론트단에서 해당 파일명을 정리하고 있다. 조금 더 명확하게 하려면 백엔드에서 파일명 정리를 하고 파일 생성 시 프론트로 보내주는 작업이 되어야 맞지 않을까 싶다.

 

2. upload.js안에 delete_file_modify라는 메소드가 있다. 원래는 없었던 것인데, 글 수정하기에서 첨부파일 삭제를 처리해주기 위해서 만들어졌다. 가능하면 이 부분을 쓰지 않게 전체 코드를 수정하는게 좋을듯 하다.

 

3. 앞어 말했듯이 확장자 제한에 대한 로직은 들어있지 않다. 운영중인 곳에 사용을 하려면 반드시 확인하고 넘어가야한다.

 

728x90

댓글