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

제이크 아치볼드
제이크 아치볼드

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

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

  • 서버를 예열합니다. 즉, 사용자가 텍스트 입력란에 포커스를 맞추면 요청을 시작하고 모든 헤더를 버린 다음 사용자가 '전송'을 누를 때까지 기다린 후 입력한 데이터를 전송할 수 있습니다.
  • 클라이언트에서 생성된 데이터(예: 오디오, 동영상, 입력 데이터)를 점진적으로 전송합니다.
  • HTTP/2 또는 HTTP/3을 통해 웹 소켓을 다시 만듭니다.

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

데모

이를 통해 사용자의 데이터를 서버로 스트리밍하고 실시간으로 처리될 수 있는 데이터를 다시 전송할 수 있는 방법을 알 수 있습니다.

예, 그냥 상상력을 발휘할 수 있는 예가 아니에요. 그냥 단순하게 해두고 싶었던 거죠. 괜찮죠?

어쨌든 이것은 어떻게 작동할까요?

이전에는 가져오기 스트림의 흥미진진한 모험

Response 스트림은 한동안 모든 최신 브라우저에서 사용할 수 있었습니다. 서버에서 수신되는 응답의 일부에 액세스할 수 있습니다.

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',
});

위의 명령어는 서버에 'This is a down request'를 한 번에 한 단어씩, 각 단어 사이에 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가 있으면 스트림으로 작업하는 것이 더 쉬울 때가 있습니다. 이렇게 하려면 'ID' 스트림을 사용하면 됩니다. 'ID' 스트림은 읽기 가능/쓰기가 가능한 쌍으로, 쓰기 가능한 쪽으로 전달된 모든 것을 가져와서 읽을 수 있는 쪽으로 보냅니다. 인수 없이 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을 통해 임의의 데이터를 압축합니다.