fetch API를 사용한 스트리밍 요청

Jake Archibald
Jake Archibald

Chromium 105부터 Streams API를 사용하여 전체 본문을 사용하기 전에 요청을 시작할 수 있습니다.

다음과 같은 용도로 사용할 수 있습니다.

  • 서버를 워밍업합니다. 즉, 사용자가 텍스트 입력란에 포커스를 두었을 때 요청을 시작하고 모든 헤더를 제거한 다음 사용자가 'send'를 누를 때까지 기다릴 수 있습니다. 체크합니다.
  • 오디오, 동영상, 입력 데이터 등 클라이언트에서 생성된 데이터를 점진적으로 전송합니다.
  • HTTP/2 또는 HTTP/3을 통해 웹 소켓을 다시 만듭니다.

하지만 이것은 하위 수준의 웹 플랫폼 기능이므로 아이디어의 제한을 받지 마세요. 요청 스트리밍에 대한 훨씬 더 흥미로운 사용 사례를 생각할 수 있습니다.

데모

이를 통해 사용자로부터 서버로 데이터를 스트리밍하고 실시간으로 처리할 수 있는 데이터를 다시 보내는 방법을 확인할 수 있습니다.

네, 좋습니다. 가장 창의적인 예가 아니네요. 그냥 단순하게 만들고 싶었어요. 알았지?

어떻게 작동하는 걸까요?

이전에는 가져오기의 흔적이 흐르는 흥미로운 모험이 있었지만

응답 스트림은 한동안 모든 최신 브라우저에서 사용할 수 있었습니다. 이 명령어를 사용하면 서버에서 응답이 수신될 때 일부 대답에 액세스할 수 있습니다.

const response = await fetch(url);
const reader = response.body.getReader();

while (true) {
  const {value, done} = await reader.read();
  if (done) break;
  console.log('Received', value);
}

console.log('Response fully received');

value는 바이트의 Uint8Array입니다. 가져오는 배열의 수와 배열의 크기는 네트워크 속도에 따라 다릅니다. 빠른 연결을 사용하는 경우 더 적은 수의 큰 '청크'를 얻게 됩니다. 학습합니다. 연결이 느린 경우 더 많은 작은 청크를 얻게 됩니다.

바이트를 텍스트로 변환하려면 TextDecoder를 사용하거나 최신 변환 스트림(타겟 브라우저에서 지원되는 경우)을 사용하면 됩니다.

const response = await fetch(url);
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();

TextDecoderStream는 모든 Uint8Array 청크를 가져와 문자열로 변환하는 변환 스트림입니다.

스트림은 데이터가 도착하는 대로 작업을 시작할 수 있으므로 유용합니다. 예를 들어 100개의 '결과' 목록을 수신하는 경우 100개를 모두 기다리지 않고 첫 번째 결과를 받는 즉시 표시할 수 있습니다.

어쨌든 응답 스트림은 요청 스트림입니다.

스트리밍 요청 본문

요청에는 본문이 포함될 수 있습니다.

await fetch(url, {
  method: 'POST',
  body: requestBody,
});

이전에는 요청을 시작하기 전에 전신을 준비해야 했지만 이제 Chromium 105에서는 자체 ReadableStream 데이터를 제공할 수 있습니다.

function wait(milliseconds) {
  return new Promise(resolve => setTimeout(resolve, milliseconds));
}

const stream = new ReadableStream({
  async start(controller) {
    await wait(1000);
    controller.enqueue('This ');
    await wait(1000);
    controller.enqueue('is ');
    await wait(1000);
    controller.enqueue('a ');
    await wait(1000);
    controller.enqueue('slow ');
    await wait(1000);
    controller.enqueue('request.');
    controller.close();
  },
}).pipeThrough(new TextEncoderStream());

fetch(url, {
  method: 'POST',
  headers: {'Content-Type': 'text/plain'},
  body: stream,
  duplex: 'half',
});

위에서는 '느린 요청입니다'를 전송합니다. 한 번에 한 단어씩 서버에 전달하며 각 단어 사이에 1초 동안 일시중지합니다.

요청 본문의 각 청크는 Uint8Array 바이트여야 하므로 pipeThrough(new TextEncoderStream())를 사용하여 변환합니다.

제한사항

스트리밍 요청은 웹의 새로운 기능이므로 다음과 같은 몇 가지 제한사항이 있습니다.

반이중인가요?

요청에서 스트림을 사용하도록 허용하려면 duplex 요청 옵션을 'half'로 설정해야 합니다.

잘 알려지지 않은 HTTP의 기능 (표준 동작인지 여부는 요청 대상에 따라 다름)으로, 요청을 보내는 동안에도 응답을 받을 수 있다는 것입니다. 하지만 잘 알려져 있지 않아 서버에서 잘 지원되지 않으며 어떤 브라우저에서도 지원되지 않습니다.

브라우저에서는 서버가 응답을 더 빨리 보내더라도 요청 본문이 완전히 전송될 때까지는 응답을 사용할 수 없습니다. 이는 모든 브라우저 가져오기에 적용됩니다.

이러한 기본 패턴을 '반이중'이라고 합니다. 하지만 Deno의 fetch와 같은 일부 구현은 기본적으로 '전이중'으로 설정되었습니다. 를 사용합니다. 즉, 요청이 완료되기 전에 응답을 사용할 수 있습니다.

따라서 이 호환성 문제를 해결하려면 브라우저에서 duplex: 'half'에 스트림 본문이 있는 요청에 지정해야 합니다.

향후 브라우저에서 스트리밍 및 비 스트리밍 요청을 위해 duplex: 'full'가 지원될 수 있습니다.

한편, 이중 통신에 대한 차선책은 스트리밍 요청으로 한 번 가져오기를 한 다음, 다른 가져오기를 수행하여 스트리밍 응답을 수신하는 것입니다. 서버는 이러한 두 요청을 연결할 방법이 필요합니다(예: URL의 ID). 데모는 이렇게 작동합니다.

제한된 리디렉션

일부 HTTP 리디렉션 형식은 브라우저가 다른 URL로 요청 본문을 다시 보내야 합니다. 이를 지원하기 위해 브라우저는 스트림의 콘텐츠를 버퍼링해야 하며, 이는 일종의 요점을 무효화하므로 그렇게 하지 않습니다.

요청에 스트리밍 본문이 있고 응답이 303이 아닌 HTTP 리디렉션인 경우 가져오기가 거부되고 리디렉션을 따르지 않습니다.

303 리디렉션은 명시적으로 메서드를 GET로 변경하고 요청 본문을 삭제하므로 허용됩니다.

CORS 필요 및 프리플라이트 트리거

스트리밍 요청에는 본문이 있지만 Content-Length 헤더가 없습니다. 이는 새로운 종류의 요청이므로 CORS가 필요하며 이러한 요청은 항상 프리플라이트를 트리거합니다.

no-cors 요청의 스트리밍은 허용되지 않습니다.

HTTP/1.x에서 작동하지 않음

연결이 HTTP/1.x인 경우 가져오기가 거부됩니다.

이는 HTTP/1.1 규칙에 따라 요청 및 응답 본문이 Content-Length 헤더를 전송하여 수신 데이터 양을 알거나 청크 분할 인코딩을 사용하도록 메시지 형식을 변경해야 하기 때문입니다. 단위 인코딩을 사용하면 본문이 여러 부분으로 분할되며, 각 부분에 고유한 콘텐츠 길이가 있습니다.

청크 인코딩은 HTTP/1.1 응답의 경우 매우 일반적이지만 요청의 경우에는 매우 드물기 때문에 호환성 위험이 너무 큽니다.

잠재적 문제

이는 새로운 기능으로 오늘날 인터넷에서 잘 사용되지 않습니다. 주의해야 할 문제는 다음과 같습니다.

서버 측의 비호환성

일부 앱 서버는 스트리밍 요청을 지원하지 않고 대신 전체 요청이 수신될 때까지 기다린 후에 전체 요청을 확인할 수 있습니다. 이 경우 논란의 여지가 있습니다. 대신 NodeJS 또는 Deno와 같이 스트리밍을 지원하는 앱 서버를 사용하세요.

하지만 아직 숲에서 나가지 않네요. NodeJS와 같은 애플리케이션 서버는 일반적으로 "프런트 엔드 서버"라고 하는 다른 서버 뒤에 위치하며, 이 서버는 CDN 뒤에 위치할 수도 있습니다. 이 중 하나가 체인의 다음 서버에 전달하기 전에 요청을 버퍼링하기로 결정하면 요청 스트리밍의 이점을 잃게 됩니다.

제어할 수 없는 비호환성

이 기능은 HTTPS를 통해서만 작동하므로 자신과 사용자 간의 프록시에 대해 걱정할 필요가 없지만 사용자가 자신의 컴퓨터에서 프록시를 실행 중일 수 있습니다. 일부 인터넷 보호 소프트웨어는 브라우저와 네트워크 사이에서 일어나는 모든 것을 모니터링하기 위해 이 작업을 수행합니다. 이 소프트웨어가 본문을 버퍼링하는 경우도 있습니다.

이를 방지하려면 '기능 테스트'를 만들면 됩니다. 스트림을 닫지 않고 일부 데이터를 스트리밍하는 위의 데모와 유사합니다. 서버가 데이터를 수신하면 다른 가져오기를 통해 응답할 수 있습니다. 일단 이렇게 되면 클라이언트가 스트리밍 요청을 엔드 투 엔드로 지원한다는 것을 알 수 있습니다.

특성 감지

const supportsRequestStreams = (() => {
  let duplexAccessed = false;

  const hasContentType = new Request('', {
    body: new ReadableStream(),
    method: 'POST',
    get duplex() {
      duplexAccessed = true;
      return 'half';
    },
  }).headers.has('Content-Type');

  return duplexAccessed && !hasContentType;
})();

if (supportsRequestStreams) {
  // …
} else {
  // …
}

특성 감지의 작동 방식은 다음과 같습니다.

브라우저가 특정 body 유형을 지원하지 않으면 객체에서 toString()를 호출하고 결과를 본문으로 사용합니다. 따라서 브라우저가 요청 스트림을 지원하지 않는 경우 요청 본문은 "[object ReadableStream]" 문자열이 됩니다. 문자열을 본문으로 사용할 때 Content-Type 헤더를 text/plain;charset=UTF-8로 편리하게 설정할 수 있습니다. 따라서 헤더가 설정된 경우 브라우저가 요청 객체의 스트림을 지원하지 않는 것이므로 조기에 종료할 수 있습니다.

Safari는 요청 객체의 스트림을 지원하지만 fetch와 함께 사용하도록 허용하지 않습니다. 따라서 Safari에서 현재 지원하지 않는 duplex 옵션이 테스트되었습니다.

쓰기 가능한 스트림에 사용

WritableStream가 있으면 스트림 작업이 더 쉬운 경우가 있습니다. 이렇게 하려면 스트림은 쓰기 가능한 끝으로 전달된 모든 것을 가져와서 읽을 수 있는 끝으로 보내는 읽기/쓰기 가능한 쌍입니다. 인수 없이 TransformStream를 만들어 이 중 하나를 만들 수 있습니다.

const {readable, writable} = new TransformStream();

const responsePromise = fetch(url, {
  method: 'POST',
  body: readable,
});

이제 쓰기 가능한 스트림으로 전송하는 모든 항목이 요청의 일부가 됩니다. 이렇게 하면 스트림을 함께 작성할 수 있습니다. 예를 들어 다음은 데이터를 한 URL에서 가져와 압축한 후 다른 URL로 전송하는 예입니다.

// Get from url1:
const response = await fetch(url1);
const {readable, writable} = new TransformStream();

// Compress the data from url1:
response.body.pipeThrough(new CompressionStream('gzip')).pipeTo(writable);

// Post to url2:
await fetch(url2, {
  method: 'POST',
  body: readable,
});

위의 예에서는 압축 스트림을 사용하여 gzip을 통해 임의의 데이터를 압축합니다.