Node.js request 패키지의 망가진 Stream 고치기

request

Node.js request 패키지의 망가진 Stream 고치기

얼마 전 사이드잡으로 진행하고 있는 서비스에서 소셜 계정으로 로그인한 아바타 이미지(프로필 사진)가 표시되지 않는 일이 발생했다.

그동안 해당 서비스에서는 아바타 이미지를 스토리지에 저장하지 않고 각 SNS 서비스에서 제공하는 아바타 이미지의 URL을 가져다가 썼는데,
Facebook을 비롯한 일부 서비스의 경우 해당 이미지 URL에 access control을 위한 policy가 붙어있었다.

AWS의 CloudFront나 Akamai 같은 CDN의 경우 언제까지 파일에 접근이 가능한지를 Policy를 통해 제어할 수 있는데,
아바타 이미지의 URL에 그것이 붙어있었던 것.

Graph API로 대체를 할까 고민하다가, 이런 의견도 있고 해서 그냥 로그인할때마다 S3에 올리고 (있으면 덮어쓰고) CloudFront를 통해 서빙을 하는 방향으로 결정하고,
아바타 이미지 URL을 내려받고 S3에 해당 이미지를 저장하도록 아래와 같은 스니펫을 작성했다.

뭐 별 문제 없으리라 생각했다.

네이버 favicon으로 테스트

업로드도 잘 된 것 같았다.

그런데……. 막상 S3에서 확인해보니….

?!?!?!!?

읭??????? 업로드 한 객체의 크기가 0바이트로 뜬다?!?!?!?

혹시나해서 s3.upload에 전달하는 Body property를 Readable Stream 대신 Buffer로 던져보기로 했다.

위 스니펫을 작성하고 다시 동일하게 테스트를 진행했다.

Buffer로 다시 업로드 시도

업로드는 잘 되었다고 떴고, 다시 업로드된 객체를 조회해보면…

오예

S3에 제대로 파일이 올라간다!!!

여기서 Buffer를 쓰는 방법을 택하고 포기할 수 있었지만,
만약 업로드 할 바이너리가 큰 경우, 동시에 들어오는 로그인 요청이 많아지는 경우를 생각하면 찝찝해서 더 파보기로 했다.
메모리는 소중하니까… ㅠㅠ

어디서 잘못된건지 찾기

이제 어디서 잘못된건지 검증해야하는 포인트가 조금 좁혀졌다.

1) AWS SDK에서 s3.upload를 통해 파일을 업로드 할 때, Stream을 사용할 수 없거나
2) AWS SDK로 전달되는 Stream에 문제가 있거나

둘 중 하나일 것이다.

AWS SDK

AWS SDK에서 s3.upload를 통해 파일을 업로드할 때, Stream을 사용할 수 없는지 검증해야 했다.
근데 애초에 내가 Stream을 썼던 것은, AWS SDK documentation에서 Stream을 전달할 수 있다고 나왔기 때문이다.

심지어 예제도 Stream을 쓰는데?

다시 한번 SDK 문서를 체크해봐도 Readable Stream을 쓸 수 있다고 나와있다. 심지어 예제도 Readable Stream을 쓴다.

정말 혹~~시라도 문서가 잘못되어있는 경우를 생각하기 위해, AWS SDK에서 Stream을 받을 수 있는지 없는지 검증해보기로 했다.
동시에 2)를 검증할 수도 있기도 하고…

request의 response

request 쪽에서 response event로 전달받은 response readable stream에 문제가 있는지 검증을 하기 위해,
fs.createReadStream로 로컬 파일을 Readable stream으로 가져와 S3로 업로드를 시도해보기로 했다.

그리고 테스트를 해보면…

file stream으로 s3 업로드

잘 올라가는것을 확인할 수 있다.

일단 여기서 AWS SDK이 Stream을 지원한다는건 검증한 셈이니, request쪽에서 response event를 통해 전달한 response stream에 문제가 있을거라고 예측할 수 있다.

이를 검증하기 위해, 이번에는 http.request의 shortcut인 http.get을 통해 s3로 업로드를 시도했다.

역시 위 스니펫으로 테스트해보면…

잘된다!

잘 되는것을 확인할 수 있다.
어쩄거나 request 패키지쪽에서 넘어오는 stream에 문제가 있는 것 같은데…

점점 더 미궁속으로

이것저것 테스트를 더 진행하던 와중에, 아래 스니펫이 정상 동작하는것을 확인하고 이슈는 더 미궁속으로 빠지기 시작했다.

분명 request 쪽에서 넘겨주는 response stream에 문제가 있었던 것 같은데.
이슈가 발생하는 이전 스니펫에서 다운로드 받은 파일을 s3가 아닌 로컬 파일로 쓰게 하면 잘 되었기 때문이다.

로컬 파일로 기록

도대체 무엇이 문제인걸까????

우연히 발견한 또 다른 재현 방법

멘탈이 반쯤 나간 상태로 이것 저것 케이스를 더 만들어서 테스트를 해보다가,

우연히 아래처럼 response stream을 다른 stream으로 pipe하는것을 지연했더니,
s3.upload를 통해 업로드를 했던것과 동일하게 0바이트로 파일을 기록한 결과가 나왔다!

지연된 pipe 후 0바이트로 파일이 기록된 모습

여기서 request 대신 아까처럼 http.get을 통해 pipe를 하면..

오예!

이쪽은 잘 되는것을 확인할 수 있다.

자, 이제 무엇이 문제인지 어느정도 추측해볼 수 있다.

이번에 세운 가설은, request 패키지에서 전달하는 response stream은
flowing mode로 동작한다는 것이다.

s3.upload API에서 stream에서 넘어오는 data를 읽어들이기 전에 이미 response 스트림에 모든 데이터가 전달되었고,
따로 이미 해당 스트림은 ‘흐르고 있는’ 모드라 그 데이터가 고스란히 유실된 것.

이 부분을 조금 더 깊게 파보기 위해,
Node.js의 꽃 (?) 이라 할 수 있는 Stream에 대해 알아보자.

Node.js의 꽃, Stream

나는 Node.js에서 가장 매력적이고 핵심적인 부분이 바로 Stream이라 생각한다.

과거 Node.js v0.10 까지는 Stream API에 상당히 많은 변화가 있었다.

혹시라도 Stream를 아직 잘 모른다면, Node.js의 Contributor이자 npm Inc.의 owner인 Isaac Z. Schlueter
playnode 2012에서 발표한 Stream2 슬라이드를 읽어보는 것을 강력하게 추천한다.
Node.js 공식 문서의 Stream 섹션을 참고해보는것도 아주 좋다.

Node.js에서 Stream은 크게 네가지 종류로 분류된다.
Readable Stream, Writable Stream, Duplex Stream, Transform Stream.

여기서 requesthttp.get에서 반환하는 response stream은 Readable Stream에 해당하는데,

Readable Stream의 예로는 우리가 흔히 접할 수 있는 것들을 나열하면 다음과 같다:

1) client의 HTTP Request, server의 HTTP Request (HTTP Class의 IncomingMessage)
2) fs.createReadStream 으로 만든 fs readstream
3) zlib stream
4) crypto stream
5) tcp socket
6) child process의 stdout, stderr
7) process.stdio

Two Modes

Readable Stream에는 두가지 모드가 존재하는데,

flowing mode 혹은 paused mode 이다.

flowing mode에서는 EventEmitter 인터페이스를 통해 최대한 빠르게 데이터가 전달되고,
paused mode에서는 명시적으로 stream.read() 메소드를 호출해 chunk를 읽어야 한다.

기본적으로 Readable Stream은 paused mode이지만, 아래 세가지 방법을 통해 flowing mode로 전환할 수 있다.

  1. data event handler 등록
  2. stream.resume() 메소드 호출
  3. stream.pipe()를 통해 Writable Stream으로 데이터 전달

그리고 flowing mode의 Readble Stream은 아래 두가지 방법을 통해 다시 paused mode로 전환할 수 있다.

  1. pipe 된 대상 writable stream이 없는 상태라면, stream.pause() 메소드 호출
  2. pipe 된 대상 writable stream이 있는 상태라면, data event handler를 등록 해제하거나 기존에 pipe된 대상 writable stream을 stream.unpipe() 메소드를 통해 파이프 해제

Three States

두가지 모드와 별개로 Readable Stream은 세가지 상태가 존재한다.

  • readable._readableState.flowing = null
  • readable._readableState.flowing = false
  • readable._readableState.flowing = true

readable._readableState.flowingnull 인 경우에는 스트림의 데이터를 소비할 어떠한 매커니즘 (data event handler를 등록하거나 pipe method를 통해 다른 writable stream 연결)이 없다는 것이다.
이 상태에서는 스트림이 데이터를 만들어내지 않는다.

스트림에 data event listener를 등록하거나, readable.pipe() 메소드를 통해 writable stream을 파이프하거나,
readable.resume() 메소드를 호출한다면 readable._readableState.flowingtrue으로 변경되고 스트림이 데이터를 생성할때마다 이벤트를 호출하기 시작할 것이다.

마지막으로 readable._readableState.flowingfalse인 동안에는, 버퍼링을 위해 데이터가 Stream 내부 버퍼에 축적될 것이다.

그렇다면 위 _readableState property를 찍어보면 스트림이 어떤 상태에 있는지 추적해볼 수 있다.
다시 코드로 돌아가서, 아래처럼 _readableState property를 찍어보자.

`response` stream의 `flowing` state가 바뀌었다!

코드에서 스트림에 손도 안댔는데 response stream의 flowing state가 null 에서 true로 바뀐것을 볼 수 있다!

아까 위에서 언급했지만, Readable Stream의 flowing state가 true인 경우 데이터가 흐르게되어 미리 데이터를 소비할 어떠한 매커니즘을 준비해두지 않는다면 이미 흘러서 나가버린 데이터는 살릴 방법이 없다.

이제 정확히 무엇이 문제인지 알았다.

request 패키지에서 전달하는 response stream은 paused & buffered stream이 아니다!

자, 그럼 어떻게 response stream을 paused & buffered stream으로 써멱냐고?
솔루션은 간단하다. 이미 stream에 이를 위한 좋은 (?) 구현체가 있다.

Transform Stream의 구현체이기도 한 Stream.PassThrough가 바로 그것이다.
이름만 보고 유추할 수 있겠지만, 그냥 입력 받은 데이터를 다시 출력해주는 스트림이다.
다만, 기본적으로 paused state로 동작하고 buffering이 built-in 되어있어서 딱 지금 상황에 쓰기 적절하다.

PassThrough를 쓰도록 처음 코드를 고치면 다음과 같다:

그리고 테스트를 해보면…. 두근두근…

잘 올라간다!

잘 고쳐진 것을 확인할 수 있다!!!!!

왜 Stream을 써야하나요?

Download 100MB+ file & upload to s3

using Buffer

vs

using Stream

Buffer vs Stream

v(^_^)v STREAM ALL THE THINGS! (YAY)

STREAM ALL THE THINGS

request와 Streams2 Support

request 패키지의 Contributor인 Mikeal Rogers코멘트에 따르면,

mikeal의 코멘트

새로운 스타일의 Stream이 v3 브랜치에서 지원될 것 같아 보인다.

하지만…

마지막 업데이트의 상태가..?!

현재 v3 브랜치는 마지막 업데이트가 4년 전인 상태로, 사실상 방치중인 상태이다.
언젠가 Stream 관련 업데이트가 나올 때 까지는, 이 방법을 쓸 수밖에 없을 것 같다 ㅠㅠ