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

Jake Archibald
Jake Archibald

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

이를 사용하여 다음 작업을 할 수 있습니다.

  • 서버를 워밍업합니다. 즉, 사용자가 텍스트 입력 필드에 포커스를 두면 요청을 시작하고 모든 헤더를 처리한 다음 사용자가 '전송'을 누를 때까지 기다린 후 사용자가 입력한 데이터를 전송할 수 있습니다.
  • 오디오, 동영상, 입력 데이터와 같이 클라이언트에서 생성된 데이터를 점진적으로 전송합니다.
  • 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초의 일시중지를 두고 한 번에 한 단어씩 서버에 'This is a slow request'를 전송합니다.

요청 본문의 각 청크는 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' 스트림을 사용하면 됩니다. 인수 없이 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으로 임의의 데이터를 압축합니다.