logo hsb.horse
← 블로그 목록으로 돌아가기

블로그

같은 Web Streams 보일러플레이트를 계속 쓰는 데 지쳤다

ReadableStream<Uint8Array> 유틸리티를 반복해서 다시 작성하다가 @hsblabs/web-stream-extras로 묶게 된 이유. 계속 반복되던 세 가지 패턴, ByteTransformStream 기반 클래스, Web Crypto 기반 스트림 암호화까지 정리한다.

게시일:

ReadableStream<Uint8Array> 가 필요해진다. 빈 파일을 열고, 늘 쓰던 컨트롤러 뼈대를 다시 적기 시작한다. 그러다 스트림 끝에서 바이트를 모아야 하는 상황이 생긴다. 또 유틸리티가 필요하다. 그 결과를 이번에는 Uint8Array 가 아니라 ArrayBuffer 를 기대하는 어딘가에 넘겨야 한다. 또 변환이 필요하다.

어려운 작업은 아니다. 다만 전부 노이즈다.

그래서 그 조각들을 @hsblabs/web-stream-extras 로 묶었다.


계속 반복하던 세 가지 패턴

청크 배열로부터 ReadableStream 만들기

// before
const stream = new ReadableStream({
start(controller) {
for (const chunk of chunks) controller.enqueue(chunk);
controller.close();
},
});

스트림을 하나의 Uint8Array 로 수집하기

// before
const chunks: Uint8Array[] = [];
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const result = concatU8Arrays(chunks); // 이것도 직접 써야 했다

Uint8Array, ArrayBuffer, 문자열 사이를 변환하기

정확한 뷰와 복사 패턴을 찾느라 계속 시간을 쓰게 되는 작은 차이들이다.


After

import {
readableFromChunks,
readAllBytes,
stringToBinary,
binaryToString,
} from "@hsblabs/web-stream-extras";
const stream = readableFromChunks([
stringToBinary("hello"),
stringToBinary(" world"),
]);
const result = await readAllBytes(stream);
console.log(binaryToString(result)); // "hello world"

설치:

Terminal window
npm install @hsblabs/web-stream-extras

Node.js ≥22와 최신 브라우저에서 동작한다. 런타임 의존성은 없다.


바이너리 변환 파이프라인 만들기

커스텀 transform 단계가 필요할 때 ByteTransformStreamTransformStream 바닥 작업을 다시 구현하지 않고 확장할 수 있는 타입 기반 클래스를 제공한다.

import { ByteTransformStream } from "@hsblabs/web-stream-extras";
class UpperCaseStream extends ByteTransformStream {
transform(chunk: Uint8Array) {
// 바이트를 처리하고 this.push()로 출력한다
}
}

ByteQueue 는 청크 경계를 넘는 부분 읽기가 필요할 때 내부 버퍼링을 맡는다. encodeBase64UrldecodeBase64Url 은 바이트 데이터와 텍스트가 만나는 지점에서 쓴다.


스트림 암호화

암호화는 별도 서브패스로 분리했다. 필요하지 않다면 내려받지 않는다.

import { encryptStream, decryptStream } from "@hsblabs/web-stream-extras/encryption";
const key = crypto.getRandomValues(new Uint8Array(32));
const plaintext = readableFromChunks([stringToBinary("secret payload")]);
const encrypted = encryptStream(key, plaintext);
const decrypted = decryptStream(key, encrypted);
console.log(binaryToString(await readAllBytes(decrypted))); // "secret payload"

두 함수 모두 ReadableStream<Uint8Array> 를 받아 같은 타입으로 돌려주므로 파이프라인의 다른 부분과 자연스럽게 연결된다. 내부의 EncryptionStreamDecryptionStreamTransformStream 래퍼로 노출되어 직접 써야 할 때 활용할 수 있다.

포맷은 HKDF로 파생한 레코드별 키와 nonce를 쓰는 AES-GCM이다. 스트림을 위한 설계이지 범용 파일 암호화 포맷은 아니다.

마스터 키를 쓰는 패턴

애플리케이션이 별도의 AES-GCM 마스터 키를 관리하고 있고, 암호화된 메타데이터와 함께 스트림별 키를 저장하고 싶다면 webCryptoStream 이 그 패턴을 감싼다.

import { webCryptoStream } from "@hsblabs/web-stream-extras/encryption";
const masterKey = await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
const crypto$ = webCryptoStream(masterKey);
const streamKey = await crypto$.createStreamKey();
const encrypted = await crypto$.encrypt(streamKey, plaintext);
const decrypted = await crypto$.decrypt(streamKey, encrypted);

이미 원시 32바이트 키가 있다면 encryptStream / decryptStream 만으로 충분하다.


하지 않는 것

명확히 적어 둘 만한 항목들:

  • 인증 없음 — 제공하는 것은 비밀성이지 신원 확인이 아니다
  • 키 저장 없음 — 스트림 키를 어디에 저장할지는 애플리케이션 책임
  • 패스워드 기반 키 파생 없음 — PBKDF2 / Argon2는 직접 가져와야 한다
  • 사용자 관리 없음 — 이것은 low-level 스트림 유틸리티이지 auth 프레임워크가 아니다

npm · GitHub

빠르게 부족한 부분을 알려 주려면 issue를 여는 것이 가장 좋다.