form-data 이슈 트래킹하기

Node.js 환경에서 파일 업로드 시 form-data 및 got 라이브러리 사용 중 발생한 문제 해결 과정을 공유드립니다.

Contents


form-data 란 ?

form-data 는 NPM 에 배포되어있는 라이브러리로, Node.js 와 브라우저 환경에서 FormData 를 쉽게 전송할 수 있도록 도와주는 라이브러리이다. 나의 경우에는 일반적인 application/x-www-form-urlencoded 이나 application/json 컨텐츠 타입의 경우에서는 딱히 해당 라이브러리를 사용하지 않는다. 하지만 multipart/form-data 의 경우에는 편의성을 위해 위의 라이브러리를 사용하는 편이다.


got 이란 ?

got 은 Node.js 환경에서 사용할 수 있는 HTTP Request 라이브러리이다. 현재 사내 Node.js 서버 환경에서는 주로 이 라이브러리를 HTTP 통신 라이브러리로 사용하고 있다. sindresorhus 님의 작업물 중에 만족하면서 사용했던 라이브러리가 많은 것 같다. ESM 을 선호하시기 때문에, 대부분의 라이브러리가 ESM 만을 지원하는 경우가 많다. CommonJS 를 사용하는 어플리케이션에서는 ESM 을 CommonJS 로 변환하여 사용해야 한다.


문제상황 및 해결방법

팀원분께서 파일 업로드 관련 기능을 개발중이셨는데, 파일 업로드 과정에서 에러가 발생하는 현상을 발견하였다. 구현해야 하는 기능은 다음과 같았다.


1. A 서버에서 대용량 파일을 다운로드한다. got 라이브러리를 통해 stream 형태로 파일을 다운로드한다.
2. (1) 에서 다운로드 받은 file stream 을 form-data 라이브러리를 이용하여 B 서버에 multipart/form-data 형태로 파일 업로드한다.


(2) 의 파일 업로드 과정에서, multipart/form-data HTTP 요청이 계속 실패하였다. 처음에는 네트워크 관련 이슈라고 생각하여, 다음과 같은 실험을 진행하였다.


1. 로컬 개발환경에 업로드할 파일을 미리 다운로드 해놓는다.
2. (1) 의 파일을 stream 형태로 읽은 후 form-data 라이브러리를 이용하여 B 서버에 multipart/form-data 형태로 파일 업로드한다.


구현해야 하는 요구사항과 거의 유사하지만, A 서버에서 다운로드 하던 기능을 제거한 후 파일 전송 기능만 구현한 후 테스트 해보았다. 이 시나리오 또한 실패할 것이라 예상하였는데, 예상 외로 업로드에 성공하였다. 위의 두 현상을 통해, 다운로드 받은 file stream 을 업로드 stream 에 적재하는 과정에서 문제가 있을 것이라 판단하였다. 직접 구현한 코드 중에는 문제가 딱히 없는 것 같아, form-data 의 코드를 살펴보기 시작하였다. 다음의 코드 에서 원인을 짐작할 수 있었다.


FormData.prototype._lengthRetriever = function(value, callback) {

  if (value.hasOwnProperty('fd')) {

    // take read range into a account
    // `end` = Infinity –> read file till the end
    //
    // TODO: Looks like there is bug in Node fs.createReadStream
    // it doesn't respect `end` options without `start` options
    // Fix it when node fixes it.
    // https://github.com/joyent/node/issues/7819
    if (value.end != undefined && value.end != Infinity && value.start != undefined) {

      // when end specified
      // no need to calculate range
      // inclusive, starts with 0
      callback(null, value.end + 1 - (value.start ? value.start : 0));

    // not that fast snoopy
    } else {
      // still need to fetch file size from fs
      fs.stat(value.path, function(err, stat) {

        var fileSize;

        if (err) {
          callback(err);
          return;
        }

        // update final size based on the range options
        fileSize = stat.size - (value.start ? value.start : 0);
        callback(null, fileSize);
      });
    }

  // or http response
  } else if (value.hasOwnProperty('httpVersion')) {
    callback(null, +value.headers['content-length']);

  // or request stream http://github.com/mikeal/request
  } else if (value.hasOwnProperty('httpModule')) {
    // wait till response come back
    value.on('response', function(response) {
      value.pause();
      callback(null, +response.headers['content-length']);
    });
    value.resume();

  // something else
  } else {
    callback('Unknown stream');
  }
};


위의 코드는 HTTP Response Stream 을 HTTP Request 로 전송 할 때, 컨텐츠의 크기를 계산하는 함수이다. FileStream, Node.js HTTP Response, request library 는 지원하지만, got 에 대한 예외처리 코드는 없는 것으로 확인하였다. 그동안 "Readable Stream 은 추상화된 데이터이므로, 이 형태를 지닌 데이터들이면 모두 전송 가능하겠지" 라는 무모한 생각을 했었는데, 생각해보니 Stream 구현체마다 지니고 있는 속성 값이 다르니, 이런 문제가 발생할 수 있겠다는 생각이 들었다. got 의 response stream 도 지원하게 해 주는 PR 을 작성해 보았다. 하지만 해당 레포지토리가 활발하게 운영되고 있는 것 같지는 않아, 빠르게 리뷰를 받을 수 있을지는 모르겠다.

feat: Support sindresorhus's got response stream

위의 작업을 해 보면서, 다음과 같은 점들을 느꼈다.


1. 구식 브라우저 환경, Node.js 환경 등을 모두 지원하려 하다 보니, ES5 미만의 문법으로 코드들을 작성해야 했다. 최근 ECMAScript API 들에 감사하게 되었다.
2. 오래된 라이브러리임에도 테스트코드들이 잘 작성되어 있었다.
3. "위와 같은 예외처리 코드를 사용하지 않고도, Readable Stream API 를 모두 지원할 수는 없을까?" 라는 생각을 하게 되었다. 예를 들어, Readable Stream 을 pipe 하는 과정에서 content-length 를 계산한 후, Readable Stream 이 종료되면 계산된 content-legth 를 반환하게 하는 방법도 가능할 것 같았다.


위의 라이브러리가 활발하게 개발되고 있는 상황은 아니어서, 일단 어플리케이션의 Stream 을 몽키패치하여 위의 문제를 해결하였다.


import * as stream from 'stream';

...

private monkeyPatchStream(data: stream.Readable): stream.Readable {
  // got stream response 일 때에만 몽키패치하도록 기능을 수정한다.
  const isGotStreamResponse = 
    data instanceof stream.Readable &&
    data.hasOwnProperty('options') &&
    data.hasOwnProperty('requestUrl');

  if(!isGotStreamResponse) {
    return data;
  }

  const readable = data as any;
  const symbolKey = Object.getOwnPropertySymbols(readable)
    .find((symbol) => symbol.toString().includes('originalResponse'));

  if(!symbolKey) {
    throw new Error('Got Response Stream 에서 originalResponse Header 가 발견되지 않았습니다');
  }

  // Node.js 의 Native HTTP 모듈로 form-data 라이브러리를 속인다.
  // 위의 코드를 추가하면, form-data 라이브러리가 해당 readable stream 을 HTTP 모듈로 인식하여, 정상적으로 기능을 수행한다.
  readable.httpVersion = '1.1';
  readable.headers = {
    'content-length': response.headers['content-length'],
  }

  return readable;
}


물론 몽키패치 방식이 본질적인 문제 해결은 아니므로, 임시적인 해결 방법이다. form-data 라이브러리에서 PR 을 머지해준다면 제일 좋지만, 그렇지 않다면 해당 라이브러리를 fork 한 후 기능을 커스터마이징하여 사용하는 것도 괜찮은 방법일 것 같다.


이것도 읽어보세요