← 글 목록

notes

Node stream axios 이슈

서버는 Cursor.pipe(res)로 잘 흘리는데, 브라우저 axios responseType stream은 왜 안 되나. XHR에 stream이 없다는 걸 알고 fetch로 넘어간 기록.

처음에 하려던 것

목록 API가 커졌다. 한 번에 find().toArray() 하면 서버 메모리가 먼저 아프다.

그래서 mongoose QueryCursor를 봤고, Express res가 Writable이라는 걸 다시 확인했다. Readable이면 pipe로 붙일 수 있다.

export function func(req: Request, res: Response, next: NextFunction) {
  const query = queryParser.parseQuery(req.query);
  objectDao
    .listCursor(identityID, query)
    .pipe(res.type('json'));
}

listCursor는 QueryCursor를 돌려주고, 그건 stream의 Readable을 확장한다. res는 Writable이다. 그래서 “DB에서 읽는 족족 JSON으로 흘려보내자”가 서버 쪽 그림이 됐다.

서버에서 Cursor를 Response로 pipe

여기까지는 깔끔했다. 문제는 클라이언트였다.

“axios로도 stream 받지 뭐”

서버가 스트림이니 클라이언트도 스트림으로 받으면 되지 않나, 싶었다. axios 문서에 responseType: 'stream'이 있다.

import axios from 'axios';
import { Readable } from 'stream';

const response = await axios.get(url, { responseType: 'stream' });
const readable: Readable = response.data;

Node에서 돌려보면… 된다. response.data가 Readable이다. pipe도 된다.

그래서 브라우저에서도 같은 코드를 넣었다.

안 됐다.

Readable 비슷한 게 안 왔다. 타입이 기대와 달랐고, “stream인데 왜 stream이 아니지?”가 하루를 잡아먹었다.

원인은 axios가 아니라 transport였다

axios는 런타임에 따라 요청을 다르게 만든다.

  • Node: http / https
  • Browser: XMLHttpRequest

Node 쪽 http 스택은 스트림과 친하다. 브라우저 XHR의 responseType은 공식적으로 대략 이런 것들뿐이다.

"" · arraybuffer · blob · document · json · text

그리고 예전에나 보이던 벤더 확장 (moz-chunked-arraybuffer, ms-stream)은 “표준 스트림 API”가 아니다.

즉 responseType: 'stream'은 Node용 편의 옵션에 가깝고, 브라우저 XHR 세계에는 대응 칸이 없다. axios가 배신한 게 아니라, 내가 “한 API가 양쪽에 같은 의미를 가진다”고 믿은 게 실였다.

같은 axios 코드가 Node와 Browser에서 갈라지는 그림

그래서 뭘 썼나: fetch + getReader

브라우저에서 진짜로 청크를 받으려면 Fetch API의 response.body (ReadableStream)로 가는 게 맞았다. axios에 억지로 맞출 이유가 없었다.

const chunkedUrl = 'https://jigsaw.w3.org/HTTP/ChunkedScript';

fetch(chunkedUrl)
  .then(processChunkedResponse)
  .then((result) => console.log('all done!', result))
  .catch((err) => console.error(err));

function processChunkedResponse(response) {
  let text = '';
  const reader = response.body.getReader();
  const decoder = new TextDecoder();

  return readChunk();

  function readChunk() {
    return reader.read().then(appendChunks);
  }

  function appendChunks(result) {
    const chunk = decoder.decode(result.value || new Uint8Array(), {
      stream: !result.done,
    });
    text += chunk;

    if (result.done) return text;
    return readChunk();
  }
}

핵심은 단순하다. getReader()로 읽고, done이 나올 때까지 이어 붙인다. TextDecoder의 stream: true는 멀티바이트 문자가 청크 경계에 잘렸을 때를 위한 것이다.

fetch로 ReadableStream을 소비하는 흐름

다시 정리하면

서버에서 Cursor를 pipe로 흘리는 건 여전히 좋은 패턴이다. 다만 “스트림”이라는 단어가 Node stream과 브라우저 ReadableStream을 동시에 가리키지 않는다.

axios의 responseType: 'stream'은 Node에서는 편하고, 브라우저에서는 기대를 깨기 쉽다. 브라우저에서 청크를 먹고 싶으면 fetch로 가는 게 덜 아프다.