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으로 흘려보내자”가 서버 쪽 그림이 됐다.
여기까지는 깔끔했다. 문제는 클라이언트였다.
“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가 양쪽에 같은 의미를 가진다”고 믿은 게 실였다.
그래서 뭘 썼나: 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는 멀티바이트 문자가 청크 경계에 잘렸을 때를 위한 것이다.
다시 정리하면
서버에서 Cursor를 pipe로 흘리는 건 여전히 좋은 패턴이다. 다만 “스트림”이라는 단어가 Node stream과 브라우저 ReadableStream을 동시에 가리키지 않는다.
axios의 responseType: 'stream'은 Node에서는 편하고, 브라우저에서는 기대를 깨기 쉽다. 브라우저에서 청크를 먹고 싶으면 fetch로 가는 게 덜 아프다.