우리는 한다, 개발을.

메타서베이 파일 다운로드 이슈

⌛ 8 mins

최근 작업을 하던 중 메타서베이의 주요 이슈 중 하나인 파일 다운로드의 문제점에 대한 원인을 찾게 되어 공유하고자 이번 포스팅을 하게 되었습니다.

이슈

메타서베이의 파일 다운로드는 UPLOADER 문항을 통해 설문 응답자가 업로드한 사진 또는 문서들을 다운로드해주는 기능입니다.

(기본적으로 aws s3에 파일이 업로드됩니다.)

메타서베이에서 프로젝트를 선택하고 DATA 탭으로 이동하면 [파일] 버튼이 있습니다.

img.png

[파일] 버튼을 클릭하면 데이터베이스의 fileQueue 컬렉션에 작업을 등록합니다.

img_1.png

fileQueue 컬렉션에 프로젝트가 등록되고 순서가 되면 파일 다운로드를 실행합니다.

var find={};
find.STATUS="대기";
db.collection("FILE_QUEUE").find(find).sort({ORD:1}).limit(1).toArray()
    .then(function (result) {
        if(result.length==0){
            JobComplete();
        } else {
            datas.QUEUE=result[0];
            datas.SNUM=datas.QUEUE.SNUM;
            CheckOldData();
        }
    }, function (err) {
        throw err;
    });

문제는 업로드한 파일의 용량, 개수가 많을 경우 다운로드가 제대로 되지 않고 에러가 발생한다는 것입니다.

문제를 파악하기 위해 코드를 살펴보겠습니다.

// 다운로드 전체 코드
function MakeUploaderS3File() {
	var promises=[];
	datas.ANSWERS.forEach(function(answer){
		if(answer.DATA==null || answer.DATA.length==0)return;
		answer.DATA.forEach(function(answerData){
			var answerNameSrc=answerData.NAME.replace(/--[\d]+/g,'');
			//UPLOADER 문항 아니면 패스
			if(datas.uploaderQuestions.indexOf(answerNameSrc)<0)return;
			var max=datas.UPLOADERS_MAX[answerNameSrc];
			var multiMax=datas.UPLOADERS_MULTI_MAX[answerNameSrc];
			if(max==null)return;
			for(var i=1;i<=max;i++){
				var fileinfos=answerData["FILEINFO"+i];
				if(fileinfos==null)continue;
				if(fileinfos instanceof Array==false){
					fileinfos=[fileinfos];
				}
				fileinfos.forEach(function(fileinfo,idx){
					if(fileinfo.S3_KEY!=null){
						promises.push(DoOne(answer,fileinfo));
					}
				});
			}
		});
	});
	if(promises.length==0){
		return MakeDir();
	}
	Promise.all(promises)
		.then(function(result){
			return MakeDir();
		})
		.catch(function(err){
			throw err;
		})
	function DoOne(answer,fileinfo){
		return new Promise(function (subResolve, subReject) {
			fileinfo.filePath="./temp/"+fileinfo.filename;
			
			var file = fs.createWriteStream(fileinfo.filePath)
				.on('close',function(){
					return subResolve();
				}).on('error',function(err){
					return subReject(err);
				});
			var params = {
				Bucket: common.s3FileServerBucket,
				Key: fileinfo.S3_KEY
			};
			s3.getObject(params).createReadStream().on('error',function(err){
				console.log(err);
				return subReject(err);
			}).pipe(file);
		});
	}
}

위 코드는 s3에서 파일을 읽어와 서버에 저장해 주는 함수입니다.

자세히 보면 promises라는 배열에 DoOne 함수로 리턴 받은 Promise들을 차곡차곡 푸시하고 있습니다.

function DoOne(answer,fileinfo){
    return new Promise(function (subResolve, subReject) {
        fileinfo.filePath="./temp/"+fileinfo.filename;

        var file = fs.createWriteStream(fileinfo.filePath)
            .on('close',function(){
                return subResolve();
            }).on('error',function(err){
                return subReject(err);
            });
        var params = {
            Bucket: common.s3FileServerBucket,
            Key: fileinfo.S3_KEY
        };
        s3.getObject(params).createReadStream().on('error',function(err){
            console.log(err);
            return subReject(err);
        }).pipe(file);
    });
}

fileinfos.forEach(function(fileinfo,idx){
    if(fileinfo.S3_KEY!=null){
        promises.push(DoOne(answer,fileinfo));
    }
});

그리고 promises 배열에 값이 있으면 Promise.all 메서드를 이용하여 Promise들을 일괄처리합니다.

if(promises.length==0){
    return MakeDir();
}

Promise.all(promises)
    .then(function(result){
        return MakeDir();
    })
    .catch(function(err){
        throw err;
    })

로컬 PC에서 해당 로직을 실행했을 때 이 과정에서 문제가 발생한 것으로 확인하였습니다.

해당 로직이 실행되고 시간이 좀 지난 뒤에 request time too skewed s3 라는 에러를 뱉으며 비정상 종료된 것입니다.

해당 에러는 요청 시간과 현재 시간의 차이가 커서 발생한 오류라고 합니다.

파일이 다운로드 되는 것을 보면서 에러가 발생한 원인을 대략적으로 파악하였습니다.

우선 Promise.all 메서드를 통해 DoOne 함수들이 동시에 실행되면서 s3.getObject 메서드 역시 동시에 호출되는 것으로 이해했고

메서드가 동시에 호출은 되지만 파일은 한 번에 모두 받아지는 것이 아니라 순차적으로 다운로드되는 것처럼 보였습니다.

img_2.png

파일이 다운로드 되는 중간에 캡처한 이미지입니다.

어떤 파일은 다운로드가 되었고 어떤 파일은 다운로드가 되지 않아 용량이 0인 것을 확인할 수 있습니다.

파일이 한 번에 다운로드 되지 않기 때문에 어떤 DoOne 함수는 s3.getObject를 호출한 뒤 종료되지 않고 시간이 경과하여

처음 메서드를 호출한 시점과의 시간 차이가 현저히 발생하여 에러가 발생한 것이라고 의심이 들었고

이를 확인해 보기 위해 DoOne 함수가 한 번에 호출되는 것이 아니라 순차적으로 호출되도록 코드를 수정해 보았습니다.

async function MakeUploaderS3File() {
  for (const answer of datas.ANSWERS) {
    if(answer.DATA==null || answer.DATA.length==0) continue;

    for (const answerData of answer.DATA) {
      if(datas.uploaderQuestions.indexOf(answerData.NAME)<0) continue;
      var max=datas.UPLOADERS_MAX[answerData.NAME];
      var multiMax=datas.UPLOADERS_MULTI_MAX[answerData.NAME];
      if(max==null)return;
      for(var i=1;i<=max;i++){
        var fileinfos=answerData["FILEINFO"+i];
        if(fileinfos==null) continue;
        if(fileinfos instanceof Array==false){
          fileinfos=[fileinfos];
        }

        for (const fileinfo of fileinfos) {
          if(fileinfo.S3_KEY!=null){
            await DoOne(answer,fileinfo)
          }
        }

      }
    }
  }

  async function DoOne(answer,fileinfo){
    return new Promise(function (subResolve, subReject) {
      fileinfo.filePath=filePathHead+datas.SNUM+'\\'+fileinfo.filename+'.'+fileinfo.ext;

      var file = fs.createWriteStream(fileinfo.filePath)
        .on('close',function(){
          return subResolve();
        }).on('error',function(err){
          return subReject(err);
        });
      var params = {
        Bucket: common.s3FileServerBucket,
        Key: fileinfo.S3_KEY
      };
      s3.getObject(params).createReadStream().on('error',function(err){
        return subReject(err);
      }).pipe(file);
    });
  }
}

기존에 DoOne 함수를 통해 리턴 받은 Promise를 Promise.all 메서드로 한 번에 처리하는 것이 아니라

async/await를 통해 하나의 함수가 실행이 끝나면 다음 함수가 실행되도록 로직을 수정하였습니다.

위와 같이 수정하였더니 시간은 다소 걸리지만 파일 다운로드는 정상적으로 완료된 것을 확인하였습니다.

후기

여러 개의 Promise를 처리해야 할 때 async/await를 이용하여 순차적으로 처리하는 경우 시간이 다소 소요됩니다.

이런 경우 Promise들을 배열에 담아 Promise.all의 인수로 넘겨주면 동시에 처리할 수 있으므로 종종 사용하는 경우가 있습니다.

하지만 위와 같은 문제가 발생할 수 있으므로 Promise.all을 사용할 때 주의가 필요할 것 같습니다.