2016년은 웹 스트림(web stream)의 해다.


원문
Jake Archibald, https://jakearchibald.com/2016/streams-ftw/



그렇다. 신년 초부터 한해의 일에 대해 이야기한다는 것은 가벼운 일은 아니다. 그러나 웹 스트림 API의 잠재력은 나를 매우 흥분 시켰기에 그런 일을 가능하게 했다.

요약하면, 스트림은 "cloud"라는 단어를 "butt"로 변경 하거나 MPEG를 GIF로 변환 하는 작업과 같이 익숙한 일들을 할 수 있다. 그러나 가장 중요한것은 제공 내용을 가장 빠르게 서비스 워커로 결합시킬 수 있다는 것이다.

스트림, 과연 뭐에 좋은가?

좋은 것은 분명한데...

프라미스(promise)는 단독 값에 대한 비동기 전송을 대체하는 가장 좋은 방법이다. 그러나 여러 값에 대한 전송이나 큰값을 분산하여 전송해야 하는 경우에는 좋은 방법이 아닐 수 있다.

이미지를 가져오고 표현하기 원할때에는 다음의 절차대로 진행된다.

  1. 네트워크로 부터 데이터를 가져온다.
  2. 가져온 데이터를 가공하고 압축된 데이터를 raw pixel 데이터로 변환한다.
  3. 화면에 보여준다.

우리는 한번에 하나의 스탭씩 실행하거나 아니면 스트림을 이용할 수 있다. streaming

만약 우리가 비트단위로 핸들링하고 변환한다면, 이미지의 일부를 빨리 렌더링 하는 방법을 얻을 수 있다. 심지어 데이터를 병렬로 가져올 수 있기 때문에, 전체 이미지를 빠르게 렌더링하는 것도 가능하다. 이것이 스트리밍이다! 우리는 네트워크로부터 스트림을 읽고 압축데이터를 픽셀 데이터로 변환한 후 화면에 그리게 된다.

당신은 이벤트로도 비슷한 성과를 얻을 수 있다. 그러나 스트림은 또다른 이점을 제공한다.

  • 시작/종료 지점 - 스트림은 무한이라고 하더라도 처리할 수 있다.
  • 읽지 않은 값의 버퍼링 - 반면에 리스너가 등록되기 전의 이벤트는 손실된다.
  • 파이핑(piping)을 통한 변경 - 당신은 비동기 시퀀스 형성과 함께 스트림을 파이핑 할 수 있다
  • 에러 핸들링 내장 - 에러는 파이프를 타고 아래로 전파될 것이다.
  • 취소 지원 - 그리고 취소 메시지는 파이프로 다시 전달된다.
  • 흐름 제어 - 당신은 리더(reader)의 속도에 반응할 수 있다.

위의 항목 중 마지막 하나는 정말 중요하다. 다운로드와 영상을 표현하는데 스트림을 사용한다고 가정해보자. 만약에 초당 200 프레임의 비디오를 다운로드하고 디코딩 한다고 해도 화면에는 초당 24 프레임만 표현될 것이며 결국 디코드 프레임의 거대한 백로그와 메모리 부족의 결과만 얻게 될 것이다.

여기가 흐름제어가 들어오는 곳이다. 렌더링을 핸들링하는 스트림은 초당 24회의 디코더 스트림으로 부터 프레임을 가져온다. 디코더는 읽혀지것 보다 프레임을 생성하는것이 더 빠르며 점점 느려진다고 알려져있다. 네트워크 스트림은 가져오는 데이터가 디코더에의해 읽혀지는 것 보다 빠르며 느리게 다운로드 된다고 알려져있다.

스트림과 리더의 타이트한 관계 때문에 스트림은 오로지 하나의 리더만 가질 수 있다. 그러나 읽지 않은 스트림은 “티드(teed)”할 수 있는데, 이는 동일한 데이터를 두개의 스트림으로 나누는 것을 의미한다. 이 경우 티(tee)는 양 리더 모두를 통해 버퍼를 관리한다.

좋다. 그것이 이론일 뿐이고 내 이야기에 전적으로 동의한다고 볼 수는 없지만, 당신이 계속해서 이야기를 듣길 바란다.

브라우저 스트림은 기본적으로 많은 것을 로드한다. 다운로드하는 것 같은 페이지/이미지/비디오 등의 일부를 표시하는 브라우저를 볼 때마다 스트리밍 덕분이라는 것을 알아야 한다. 또한 그것은 최근에 스트림을 스크립트에 노출되게 한 표준화 노력 덕분이기도 하다.

스트림 + 패치(fetch) API

응답(response) 객체는 패치 스펙(fetch spec)에 정의된 대로, 다양한 형식으로 응답을 읽을 수 있게 한다. 하지만 response.body는 근본적인 스트림에 엑세스 할 수 있게 한다. response.body는 크롬의 현재 안정 버전에서 지원한다.

스트림을 이용하면 메모리에 전체 응답을 유지하지 않고 헤더에 의존하지 않으면서 응답 내용의 길이를 얻을 수 있다.

// fetch() returns a promise that
// resolves once headers have been received
fetch(url).then(response => {
  // response.body is a readable stream.
  // Calling getReader() gives us exclusive access to
  // the stream's content
  var reader = response.body.getReader();
  var bytesReceived = 0;

  // read() returns a promise that resolves
  // when a value has been received
  reader.read().then(function processResult(result) {
    // Result objects contain two properties:
    // done  - true if the stream has already given
    //         you all its data.
    // value - some data. Always undefined when
    //         done is true.
    if (result.done) {
      console.log("Fetch complete");
      return;
    }

    // result.value for fetch streams is a Uint8Array
    bytesReceived += result.value.length;
    console.log('Received', bytesReceived, 'bytes of data so far');

    // Read some more, and call this function again
    return reader.read().then(processResult);
  });
});

데모 보기 (1.3mb)

데모에서는 서버로부터 1.3mb의 gzip으로 압축된(압축전 7.7mb) HTML파일을 가져왔다. 그러나, 결과는 메모리에 저장되지 않는다. 각 조각의 사이즈는 기록되지만 조각들 자체는 GC(garbage collected)된다.

result.value는 스트림이 제공하는 것은 무엇이든(string, number, date, ImageData, DOM엘리먼트, …) 간에 생성하지만, 이경우의 가져오는 스트림은 항상 이진 데이터 Unit8Array다. 전체 응답은 각각의 Unit8Array를 모아서 맞춘 것이다. 텍스트로 응답을 원한다면 아래 예제와 같이 TextDecoder를 사용할 수 있다.

var decoder = new TextDecoder();
var reader = response.body.getReader();

// read() returns a promise that resolves
// when a value has been received
reader.read().then(function processResult(result) {
  if (result.done) return;
  console.log(
    decoder.decode(result.value, {stream: true})
  );

  // Read some more, and recall this function
  return reader.read().then(processResult);
});

{stream: true}는 디코더의 버퍼 유지를 의미한다. 만약 result.value가 UTF-8 코드 지점을 통과해 중간에서 종료된다면, 와 같은 문자는 3 bytes([0xE2, 0x99, 0xA5])만 보여주게 된다.

TextDecoder는 현재는 작고 볼품없지만 향후에는 변환 스트림(transform stream)이 될 가능성이 있다(한번의 변환 스트림은 정의되어있다). 변환 스트림은 .writable에 쓰기 가능한 스트림과 .readable에 읽을 수 있는 스트림 객체이다. 그것은 조각들을 쓰기가능하며 가공된 상태로 취해, 읽기 가능한 상태로 내보낸다. 변환 스트림을 사용예제는 다음과 같다.

Hypothetical future-code:
var reader = response.body
  .pipeThrough(new TextDecoder()).getReader();

reader.read().then(result => {
  // result.value will be a string
});

브라우저는 자신이 소유한 응답 스트림과 TextDocoder 변환 스트림 모두를 최적화할 수 있다.

가져오기(fetch) 취소하기

스트림은 stream.cancel()(패치의 경우 response.body.cancel()을 사용한다)이나 reader.cancel()을 사용하여 취소할 수 있다. 다운로드를 중지하여 반응을 얻어낸다.

데모 보기

이 데모에서는 검색어로 큰 문서를 찾는데, 메모리에서 작은 일부분만을 유지하며, 일치하는 부분이 발견되면 가져오기를 멈춘다.

어쨌든, 이 모든것은 2015년도 가능했던 일이다. 이제부터 재미있는 새로운 것에 대해 이야기하겠다.

자신의 읽을 수 있는 스트림을 생성하자

크롬 카나리아(Chrome Canary, 개발자 및 얼리어답터 용)에서 "실험적인 웹 플랫폼 기능(Experimental web platform features)"을 활성화 시키면 바로 자신의 스트림(your own stream)을 생성할 수 있다.

var stream = new ReadableStream({
  start(controller) {},
  pull(controller) {},
  cancel(reason) {}
}, queuingStrategy);
  • start는 곧바로 호출된다. 이는 모든 기반 데이터 소스를 설정하는데 사용된다(문자열과 같은 이벤트, 다른 스트림 또는 변수 등에서 데이터를 얻을 때를 의미함). 만약 당신이 프라미스(promise)를 반환하고 거절한다면, 스트림을 통해 에러 신호를 보낼 것이다.
  • pull은 스트림의 버퍼가 가득차지 않았을 때 호출되며, 가득찰 때 까지 호출된다. 다시말해 만약 당신이 프라미스를 반환하고 거절한다면, 스트림을 통해 에러 신호를 보낼 것이다. 또한, pull은 완전한 프라미스가 반환될 때 까지 다시 호출되지 않을 것이다.
  • cancel은 스트림이 취소되면 호출된다. 이는 모든 기본 데이터 소스를 취소 하는데 사용된다.
  • queuingStrategy는 하나의 아이템에 기본적으로 얼마나 많은 스트림이 이론적으로 쌓이는지를 정의한다. - 더 자세한 사항은 여기를 참조하길 바란다.

controller에 대해서는

  • controller.enqueue(whatever) - 스트림 버퍼의 큐 데이터
  • controller.close() - 스트림의 종료 신호
  • controller.error(e) - 터미널 에러 신호
  • controller.desiredSize - 남아있는 버퍼의 총량(버퍼가 가득차면 음수일 수 있음). 이 숫자는 queuingStrategy를 사용하여 계산된다.

그래서 나는 다음 예제와 같이 0.9보다 큰 숫자가 나올때 까지 매 초마다 랜덤 숫자가 생성되는 스트림 생성을 원했다.

var interval;
var stream = new ReadableStream({
  start(controller) {
    interval = setInterval(() => {
      var num = Math.random();

      // Add the number to the stream
      controller.enqueue(num);

      if (num > 0.9) {
        // Signal the end of the stream
        controller.close();
        clearInterval(interval);
      }
    }, 1000);
  },
  cancel() {
    // This is called if the reader cancels,
    //so we should stop generating numbers
    clearInterval(interval);
  }
});

실행 결과를 보자. (알림: 결과를 보려면 크롬 카나리아에서 chrome://flags/#enable-experimental-web-platform-features 옵션을 활성화시켜야 함)

controller.enqueue로 데이터를 통과 시키는 것은 직접 해보길 바란다. 위의 예제와 같이 보낼 데이터가 있을 때, 직접 만든 "push source" 스트림을 단순히 호출할 수 있다.
그대신에 pull이 호출될 때 까지 기다릴 수 있다. 그런 후 기반 소스로 부터 수집한 데이타 신호를 사용한 다음 직접 만든 "pull source" 스트림을 대기열에 추가한다. 또는 원한다면, 두가지 방법을 조합할 수 도 있다.

controller.desiredSize를 따르는 것은 가장 스트림이 효율적인 속도에 따라 데이터를 전달하는 것을 의미한다. 이것은 "백프레셔(backpressure) 지원"를 가지고 있다고 알려져 있는데, 이는 당신의 스트림이 리더의 읽는 속도에 반응한다는 것을 의미한다(이전의 비디오 디코딩 예제와 비슷함). 그러나 기기 메모리를 다 사용하지 않는 한 어떤 것도 desiredSize를 무시하는 것은 중단시킬 수 없다. 해당 스펙은 백프레셔와 스트림 생성하기라는 좋은 예제를 갖고있다.

그 자체로 스트림을 생성하는 것은 특히나 재미없다. 그리고 처음 사용하기 때문에, 지원하는 API들도 많지 않다. 그런것들 중 하나가 다음의 Response다.

new Response(readableStream);

본문(body)이 스트림인 HTTP 응답 객체를 생성할 수 있고, 서비스 워커로 부터 응답받은 것을 사용할 수 있다.

천천히 문자열 제공하기

데모 보기 (알림: 결과를 보려면 크롬 카나리아에서 chrome://flags/#enable-experimental-web-platform-features 옵션을 활성화시켜야 함)

당신은 HTML 페이지가 천천히(계획적으로) 렌더링 되는것을 볼 수 있을 것이다. 이 응답은 전적으로 서비스 워커에서 발생시킨 것이다. 여기 그 코드가 있다.

// In the service worker:
self.addEventListener('fetch', event => {
  var html = '…html to serve…';

  var stream = new ReadableStream({
    start(controller) {
      var encoder = new TextEncoder();
      // Our current position in `html`
      var pos = 0;
      // How much to serve on each push
      var chunkSize = 1;

      function push() {
        // Are we done?
        if (pos >= html.length) {
          controller.close();
          return;
        }

        // Push some of the html,
        // converting it into an Uint8Array of utf-8 data
        controller.enqueue(
          encoder.encode(html.slice(pos, pos + chunkSize))
        );

        // Advance the position
        pos += chunkSize;
        // push again in ~5ms
        setTimeout(push, 5);
      }

      // Let's go!
      push();
    }
  });

  return new Response(stream, {
    headers: {'Content-Type': 'text/html'}
  });
});

브라우저에서 응답 본문을 읽을 때 Unit8Array의 조각으로 얻을 것을 기대한다. 만일 플레인 문자열과 같이 어떤 다른것이 통과 된다면 그것은 실패한다. 고맙게도 TextEncoder는 문자열을 가지고 문자열을 표현하는 바이트의 Unit8Array로 반환할 수 있다.

TextDecode 처럼, TextEncoder는 앞으로 스트림으로 변환 될 것이다.

변환된 스트림 제공하기

이미 말했듯이 변환 스트림은 아직 정의되지 않았다. 그러나 다른 스트림에서 데이터 소스를 공급하는 읽을 수 있는 스트림(readable stream)을 생성하여 비슷한 결과를 얻을 수 있다.

"cloud"를 "butt"로

데모 보기 (알림: 결과를 보려면 크롬 카나리아에서 chrome://flags/#enable-experimental-web-platform-features 옵션을 활성화시켜야 함)

당신이 볼 수 있는 것은 이 페이지(위키피디아에있는 클라우드 컴퓨팅 아티클에서 얻은)이지만, 모든 "cloud"라는 단어가 "butt"로 대체된 것이다. 스트림으로 하는 것의 이점은 원본을 다운로드 하는 동안 화면에 변환된 콘텐츠를 얻을 수 있다는 것이다.

다음은 엣지 케이스 일부분의 상세내용을 포함한 코드가 있는 링크다. https://github.com/jakearchibald/isserviceworkerready/blob/master/src/demos/transform-stream/sw.js

MPEG에서 GIF로

비디오 코덱은 정말 효율적이지만, 모바일에서 자동실행은 불가능하다. GIF는 자동실행이 가능하지만 비용이 크다. 진짜로 멍청한 솔루션이 여기에 있다.

데모 보기 (알림: 결과를 보려면 크롬 카나리아에서 chrome://flags/#enable-experimental-web-platform-features 옵션을 활성화시켜야 함)

MPEG 프레임 디코딩을 하는 동안 GIF의 첫번째 프레임이 표시될 수 있는 지금의 상황에는 스트리밍은 유용하다.

그렇기 때문에 사용해야 하는 것이다! 26mb GIF를 오로지 0.9mb의 MPEG를 사용하여 전달하는데, 실시간으로 되지 않고 CPU를 많이 사용한다는 것을 제외하면 완벽하다! 브라우저는 모바일에서 비디오 자동실행을 정말 허용해야한다. 음소거를 사용할 경우는 특히나 그렇다. 이것은 크롬이 지금 당장 노력해야 핸다.

완전 공개: 나는 데모에서 꼼수를 부렸다. 모든 MPEG를 시작전에 다운로드 받아놨다. 나는 네트워크로 부터 스트리밍을 얻길 원했지만 스킬이 부족하여(원문: OutOfSkillError)로 실행할 수 없었다. 또한 GIF는 정말이지 다운로드 동안 반복하면 않아야 하지만, 지금은 반복되고 있다.

페이지 렌더 시간을 소모하여 멀티 소스로부터 하나의 스트림 생성하기

이것은 아마도 서비스워크 + 스트림 조합의 가장 현실적인 적용이다. 장점은 성능면에서 크다.

몇개월 전에 나는 오프라인 우선 위키피디아의 데모를 개발했다. 나는 빠르게 동작하며 현대적인 확장 기능이 추가된 정말 혁신적인 웹앱을 만들기를 원했다.

OSX 의 네트워크 연결 컨디셔너를 사용하여 시뮬레이션한 손실 3G 연결 기반에 대해 성능과 수치적인 부분에 대해서 이야기하겠다.

서비스 워커 없이 표현 컨텐츠는 서버로 보내졌다. 여기 성능에 많은 노력을 기울였고 그에 대한 성과를 얻었다.

1

데모 보기

나쁘지는 않았다. 약간의 오프라인 우선에 좋은 부분을 혼합하여 성능 더욱을 향상시킬 수 있는 서비스 워커를 추가했다. 그리고 결과는?

2

데모 보기

음... 우선 렌더링이 더 빨라졌다. 그러나 컨텐츠 렌더링 시에 큰 퇴보가 발생했다.

가장 빠른 방법은 캐쉬에서 진입 페이지를 제공하는 것이다. 그러나 그것은 위키피디아의 모든 캐싱을 가지고 있어야 한다. 그대신에 나는 CSS, 자바스크립트 그리고 헤더를 제공했다. 빠른 초기 렌더링을 얻고 그런 후 문서의 내용을 가져오는 것에 대한 페이지의 자바스크립트 설정을 했다. 그리고 그곳이 내가 모든 성능을 잃어버린 클라이언트 사이드 렌더링이다.

서버로부터 직접 제공받든 아니면 서비스 워커를 통하든, 다운로드된 HTML을 렌더링 한다. 그러나 나는 자바스크립트를 사용한 페이지로부터 내용을 가져왔다. 그런 후 스트리밍 파서를 우회하여 innerHTML로 추가했다. 이 때문에 내용이 표시되기 전에 완전히 다운로드 되었고, 2초 더 느려졌다. 다운로드하는 컨텐츠가 많아질 수 록 스트리밍 성능 손해는 더 늘어날 것이다. 그러나 나에게는 불행하게도 위키피디아 문서는 매우 크다(구글 문서는 100k).

이것이 내가 자바스크립트 기반의 웹 앱과 프레임워크에 대해 투털되는 이유다 - 그들은 0단계로 스트리밍을 버리는 경향이 있고 그 결과 성능이 더 안좋아진다.

나는 프리패칭(prefetching)과 가짜 스트리밍을 사용하여 성능을 되돌리기 위해 노렸했다. 가짜 스트리밍은 특히나 더한 꼼수다. 페이지는 문서의 내용을 가져와 스트림으로 읽는다. 먼저 내용의 9k를 받아 innerHTML로 추가하고 다시 나머지 내용을 추가한다. 이것은 일부 엘리먼트를 두번 생성하기 때문에 끔찍하지만, 그만큼의 가치는 있다.

3

데모 보기

꼼수들은 문서 내용이 보여지는 시간을 개선하지만, 여전히 납득하기 힘들 만큼 서버 렌더링에 비해 뒤쳐진다. 뿐만아니라 innerHTML을 사용하여 페이지에 추가된 내용은 일반적으로 구문 분석된 내용과 동일하게 동작하지 않는다. 특히 인라인 <script>는 실행되지 않는다.

여기가 스트림이 개입 할 곳이다. 빈 껍데기만 제공하고 JS에게 만드는 역할을 맡기는 대신 캐시로 부터 온 헤더에서 서비스 워커에게 스트림을 생성하도록 했다.그러나 본문은 네트워크로 부터 온다. 그것은 서버 렌더링과 같지만 서비스 워커를 이용한 것이다.

4

데모 보기 (알림: 결과를 보려면 크롬 카나리아에서 chrome://flags/#enable-experimental-web-platform-features 옵션을 활성화시켜야 함)

서비스 워크 + 스트림 조합을 사용하는 것은 거의 짧은 순간에 첫번째 렌더링을 할 수 있다는 것을 의미한다. 그런다음 네트워크로 부터 내용의 작은양만 파이핑하여 일반 서버 렌더링을 수행하면 된다.

내용은 일반적인 HTML 파서로 통과된다. 그래서 당신은 스트리밍을 얻게되고 그것은 수동으로 DOM으로 부터 추가된 컨텐츠를 얻는 것과 다르지 않다.

렌더링 시간 비교

IMAGE ALT TEXT HERE

스트림 교차하기

결합된 스트림은 파이핑(piping)을 지원하지 않기 때문에, 스트림을 결합하는 것은 조금 지저분하게 수동으로 수행해야 한다.

var stream = new ReadableStream({
  start(controller) {
    // Get promises for response objects for each page part
    // The start and end come from a cache
    var startFetch = caches.match('/page-start.inc');
    var endFetch = caches.match('/page-end.inc');
    // The middle comes from the network, with a fallback
    var middleFetch = fetch('/page-middle.inc')
      .catch(() => caches.match('/page-offline-middle.inc'));

    function pushStream(stream) {
      // Get a lock on the stream
      var reader = stream.getReader();

      return reader.read().then(function process(result) {
        if (result.done) return;
        // Push the value to the combined stream
        controller.enqueue(result.value);
        // Read more & process
        return read().then(process);
      });
    }

    // Get the start response
    startFetch
      // Push its contents to the combined stream
      .then(response => pushStream(response.body))
      // Get the middle response
      .then(() => middleFetch)
      // Push its contents to the combined stream
      .then(response => pushStream(response.body))
      // Get the end response
      .then(() => endFetch)
      // Push its contents to the combined stream
      .then(response => pushStream(response.body))
      // Close our stream, we're done!
      .then(() => controller.close());
  }
});

출력물을 스트림하고 템플릿 안에 포함된 값을 스트림으로 처리하며 내용에 대한 파이핑(piping)과 심지어 즉석에서 HTML 이스케이핑하는 Dust.js와 같은 일부 템플릿팅 언어가 있다. 빠트린 것이라곤 웹 스트림 지원이다.

스트림의 미래

읽을 수 있는 스트림 외의 스펙이 아직 개발되고 있음에도, 이미 사용할 수 있다는 것은 매우 놀라운 일이다. 만약 컨텐츠가 많은 사이트의 성능을 개선하면서 구조의 근본적인 변화 없는 오프라인 우선 경험을 제공하기 원한다면, 서비스 워커로 스트림을 구성하는 것이 가장 쉬운 방법이 될 것이다. 그것은 어쨌든 내가 생각하는 블로그가 오프라인 우선 작업을 하게하는 방법이다!

웹에서 초기의 스트림을 갖는 것은 우리가 브라우저가 이미 보유하고 있는 스트리밍 능력에 대해 스크립트로의 접근을 시작할 수 있다는 것을 의미한다. 다음과 같이…

  • Gzip/무손실 압축(deflate)
  • 오디오/비디오 코덱
  • 이미지 코덱
  • HTMl/XML 스트리밍 파서

아직은 초기지만 만약 당신이 스트림에 대해 자신의 API를 준비하기 시작한다면, 일부 경우에 대해서 폴리필(polyfill)을 사용할 수 있는 참조 구현(reference implementation)은 있다.

스트리밍은 브라우저의 가장 큰 자산 중 하나다. 그리고 2016년은 자바스크립트에 의해 잠금 해제되는 해다.


강지웅, FE Development Lab2016.02.22Back to list