2020년 10월 10일

파일 업로드 재개하기

fetch 메서드를 사용하면 꽤 쉽게 파일 업로드를 할 수 있습니다.

업로드 중 연결이 끊긴 후에 업로드를 재개하려면 어떻게 해야 할까요. 업로드 재개를 위해 내장된 기능은 없지만 부분적으로 구현할 수 있는 여러 기능이 있습니다.

아마도 용량이 큰 파일(재 업로드를 해야 하는 상황이라면) 업로드를 재개하려면 업로드 진행률 표시가 동반되어야 합니다. fetch 메서드로는 업로드 진행률을 알 수 없으므로 XMLHttpRequest를 사용합니다.

별 도움 안 되는 진행률 이벤트

업로드를 재개하기 위해서 연결이 끊기기 전까지 얼마나 업로드가 되었는지 알아야 합니다.

xhr.upload.onprogress로 업로드 진행률을 추적할 수 있습니다.

불행하게도 업로드 진행률 추적은 데이터를 보낼 때 작동할 뿐, 서버가 데이터를 받았는지 브라우저는 알 수 없어서 파일 업로드 재개에 도움이 되지 않습니다.

어쩌면 지역 네트워크 프락시 지연이나, 원격 서버 프로세스가 죽어서 처리를 하지 못하거나, 단지 중간에 손실이 일어나 리시버에 도달하지 못했을 수 있습니다.

이것이 업로드 진행률 이벤트가 멋진 진행률을 보여주는 것 외에는 그다지 유용하지 않은 이유입니다.

업로드를 재개하기 위해서 서버로부터 수신받은 바이트의 정확한 숫자를 알아야 합니다. 그리고 바이트 숫자는 서버만이 말해줄 수 있어서 추가로 요청을 해야 합니다.

알고리즘

  1. 첫째, 업로드를 할 파일에 고윳값을 구분할 파일 아이디를 생성하세요.

    let fileId = file.name + '-' + file.size + '-' + +file.lastModifiedDate;

    파일 아이디는 파일 업로드를 재개할 때 서버에 어떤 파일을 재개할지 말해주는 데 필요합니다.

    이름이나 크기 혹은 최종 수정 날짜가 변하면 별도의 fileId가 생성됩니다.

  2. 서버에 요청을 보내어 얼마만큼 바이트를 전송했는지 질의합니다. 이렇게요.

    let response = await fetch('status', {
      headers: {
        'X-File-Id': fileId
      }
    });
    
    // 서버가 얼마만큼 파일 바이트를 가졌는지 확인합니다.
    let startByte = +await response.text();

    서버가 X-File-Id 헤더에서 파일 업로드를 추적한다고 가정합니다. 헤더의 파일 업로드 추적 작업은 서버사이드에서 구현되어 있어야 합니다.

    아직 파일이 서버에 없으면 서버는 0으로 응답해야 합니다.

  3. startByte에서 파일을 보내기 위해 Blobslice 메서드를 사용합니다.

    xhr.open("POST", "upload", true);
    
    // 파일 아이디를 통해 서버는 어떤 파일을 업로드 받을지 알게 됩니다.
    xhr.setRequestHeader('X-File-Id', fileId);
    
    // 서버는 업로드를 재개할 파일의 시작 바이트를 통해 파일 업로드가 재개될 것을 알게 됩니다.
    xhr.setRequestHeader('X-Start-Byte', startByte);
    
    xhr.upload.onprogress = (e) => {
      console.log(`Uploaded ${startByte + e.loaded} of ${startByte + e.total}`);
    };
    
    // 업로드를 할 파일은 input.files[0]나 또 다른 출처가 될 수 있습니다.
    xhr.send(file.slice(startByte));

    파일 아이디인 X-File-Id를 서버로 보내 업로드를 진행할 파일이 어떤 것인지 알리고, 시작 바이트인 X-Start-Byte를 서버에 보내 파일 업로드를 초기화하지 않고 파일 업로드를 다시 시작한다는 것을 서버에 알게 합니다.

    서버는 기록을 확인해서 파일에 업로드가 있었는지 그리고 현재 서버에 업로드가 된 파일 크기가 정확히 X-Start-Byte인지 확인한 후에 서버에 업로드가 된 파일 크기의 시점부터 파일 데이터를 추가합니다.

여기에 시범을 위해 Node.js로 작성된 서버와 클라이언트 코드가 있습니다.

Nginx 서버의 완충 기억 장치에 파일 업로드를 저장하고, 저장이 완료되면 Nginx 서버 뒤에 있는 Node.js로 완료된 파일 업로드를 전달하기 때문에 이 사이트에서만 부분적으로 작동합니다.

그래도 다운로드를 받아서 로컬에서 전체 시범을 실행해보세요.

결과
server.js
uploader.js
index.html
let http = require('http');
let static = require('node-static');
let fileServer = new static.Server('.');
let path = require('path');
let fs = require('fs');
let debug = require('debug')('example:resume-upload');

let uploads = Object.create(null);

function onUpload(req, res) {

  let fileId = req.headers['x-file-id'];
  let startByte = +req.headers['x-start-byte'];

  if (!fileId) {
    res.writeHead(400, "No file id");
    res.end();
  }

  // we'll files "nowhere"
  let filePath = '/dev/null';
  // could use a real path instead, e.g.
  // let filePath = path.join('/tmp', fileId);

  debug("onUpload fileId: ", fileId);

  // initialize a new upload
  if (!uploads[fileId]) uploads[fileId] = {};
  let upload = uploads[fileId];

  debug("bytesReceived:" + upload.bytesReceived + " startByte:" + startByte)

  let fileStream;

  // if startByte is 0 or not set, create a new file, otherwise check the size and append to existing one
  if (!startByte) {
    upload.bytesReceived = 0;
    fileStream = fs.createWriteStream(filePath, {
      flags: 'w'
    });
    debug("New file created: " + filePath);
  } else {
    // we can check on-disk file size as well to be sure
    if (upload.bytesReceived != startByte) {
      res.writeHead(400, "Wrong start byte");
      res.end(upload.bytesReceived);
      return;
    }
    // append to existing file
    fileStream = fs.createWriteStream(filePath, {
      flags: 'a'
    });
    debug("File reopened: " + filePath);
  }


  req.on('data', function(data) {
    debug("bytes received", upload.bytesReceived);
    upload.bytesReceived += data.length;
  });

  // send request body to file
  req.pipe(fileStream);

  // when the request is finished, and all its data is written
  fileStream.on('close', function() {
    if (upload.bytesReceived == req.headers['x-file-size']) {
      debug("Upload finished");
      delete uploads[fileId];

      // can do something else with the uploaded file here

      res.end("Success " + upload.bytesReceived);
    } else {
      // connection lost, we leave the unfinished file around
      debug("File unfinished, stopped at " + upload.bytesReceived);
      res.end();
    }
  });

  // in case of I/O error - finish the request
  fileStream.on('error', function(err) {
    debug("fileStream error");
    res.writeHead(500, "File error");
    res.end();
  });

}

function onStatus(req, res) {
  let fileId = req.headers['x-file-id'];
  let upload = uploads[fileId];
  debug("onStatus fileId:", fileId, " upload:", upload);
  if (!upload) {
    res.end("0")
  } else {
    res.end(String(upload.bytesReceived));
  }
}


function accept(req, res) {
  if (req.url == '/status') {
    onStatus(req, res);
  } else if (req.url == '/upload' && req.method == 'POST') {
    onUpload(req, res);
  } else {
    fileServer.serve(req, res);
  }

}




// -----------------------------------

if (!module.parent) {
  http.createServer(accept).listen(8080);
  console.log('Server listening at port 8080');
} else {
  exports.accept = accept;
}
class Uploader {

  constructor({file, onProgress}) {
    this.file = file;
    this.onProgress = onProgress;

    // create fileId that uniquely identifies the file
    // we could also add user session identifier (if had one), to make it even more unique
    this.fileId = file.name + '-' + file.size + '-' + +file.lastModifiedDate;
  }

  async getUploadedBytes() {
    let response = await fetch('status', {
      headers: {
        'X-File-Id': this.fileId
      }
    });

    if (response.status != 200) {
      throw new Error("Can't get uploaded bytes: " + response.statusText);
    }

    let text = await response.text();

    return +text;
  }

  async upload() {
    this.startByte = await this.getUploadedBytes();

    let xhr = this.xhr = new XMLHttpRequest();
    xhr.open("POST", "upload", true);

    // send file id, so that the server knows which file to resume
    xhr.setRequestHeader('X-File-Id', this.fileId);
    // send the byte we're resuming from, so the server knows we're resuming
    xhr.setRequestHeader('X-Start-Byte', this.startByte);

    xhr.upload.onprogress = (e) => {
      this.onProgress(this.startByte + e.loaded, this.startByte + e.total);
    };

    console.log("send the file, starting from", this.startByte);
    xhr.send(this.file.slice(this.startByte));

    // return
    //   true if upload was successful,
    //   false if aborted
    // throw in case of an error
    return await new Promise((resolve, reject) => {

      xhr.onload = xhr.onerror = () => {
        console.log("upload end status:" + xhr.status + " text:" + xhr.statusText);

        if (xhr.status == 200) {
          resolve(true);
        } else {
          reject(new Error("Upload failed: " + xhr.statusText));
        }
      };

      // onabort triggers only when xhr.abort() is called
      xhr.onabort = () => resolve(false);

    });

  }

  stop() {
    if (this.xhr) {
      this.xhr.abort();
    }
  }

}
<!DOCTYPE HTML>

<script src="uploader.js"></script>

<form name="upload" method="POST" enctype="multipart/form-data" action="/upload">
  <input type="file" name="myfile">
  <input type="submit" name="submit" value="Upload (Resumes automatically)">
</form>

<button onclick="uploader.stop()">Stop upload</button>


<div id="log">Progress indication</div>

<script>
  function log(html) {
    document.getElementById('log').innerHTML = html;
    console.log(html);
  }

  function onProgress(loaded, total) {
    log("progress " + loaded + ' / ' + total);
  }

  let uploader;

  document.forms.upload.onsubmit = async function(e) {
    e.preventDefault();

    let file = this.elements.myfile.files[0];
    if (!file) return;

    uploader = new Uploader({file, onProgress});

    try {
      let uploaded = await uploader.upload();

      if (uploaded) {
        log('success');
      } else {
        log('stopped');
      }

    } catch(err) {
      console.error(err);
      log('error');
    }
  };

</script>

아시다시피 여러 최신 네트워킹 메서드는 기능 면에서 파일 매니저에 가깝습니다 – 오버 헤더 통제, 진행률 표시, 파일을 부분적으로 보냄 등.

이제 파일 업로드 재개를 구현할 수 있습니다.

튜토리얼 지도

댓글

댓글을 달기 전에 마우스를 올렸을 때 나타나는 글을 먼저 읽어주세요.
  • 추가 코멘트, 질문 및 답변을 자유롭게 남겨주세요. 개선해야 할 것이 있다면 댓글 대신 이슈를 만들어주세요.
  • 잘 이해되지 않는 부분은 구체적으로 언급해주세요.
  • 댓글에 한 줄짜리 코드를 삽입하고 싶다면 <code> 태그를, 여러 줄로 구성된 코드를 삽입하고 싶다면 <pre> 태그를 이용하세요. 10줄 이상의 코드는 plnkr, JSBin, codepen 등의 샌드박스를 사용하세요.