맵 파일을 각 로봇의 에이전트로 배포하는 기능이 필요하다.
서버는 맵 데이터를 ZIP으로 묶어 저장해 두고, 에이전트가 이를 다운로드한 뒤 SHA256 체크섬으로 파일이 손상 없이 전달되었는지 검증하는 구조다.
이 글은 그 배포 파이프라인에서 "ZIP을 디스크에 저장한 뒤, 체크섬을 위해 같은 파일을 다시 읽는" 불필요한 2-pass 디스크 I/O를 Node.js의 PassThrough 스트림 하나로 제거한 과정을 정리한 것이다.
가장 단순한 형태는 이랬다. ZIP을 만들어 저장한 뒤, 파일 전체를 메모리에 올려 해시를 계산한다.
// ZIP 생성 → 디스크 저장
await this.downloadAndSaveMapZip(mapId, deployType, filePath);
// 전체 파일을 메모리에 로드하여 체크섬 계산
const fileBuffer = fs.readFileSync(filePath);
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
const fileSize = fileBuffer.length;
맵 파일이 작을 때는 아무 문제가 없다. 하지만 3D가 포함된 맵은 수백 MB까지 커진다. readFileSync는 파일 크기만큼 힙 메모리를 통째로 점유하므로, 배포 요청이 겹치면 메모리 사용량이 파일 크기 배수로 튀어 오른다.
그래서 먼저 체크섬 계산을 스트리밍으로 바꿨다. crypto.createHash()가 반환하는 Hash 객체는 Transform 스트림이기도 해서, pipeline에 그대로 꽂을 수 있다.
import { pipeline } from 'stream/promises';
// 스트리밍 체크섬 — 청크 단위로 읽으며 해시 갱신
const hash = crypto.createHash('sha256');
await pipeline(fs.createReadStream(filePath), hash);
const checksum = hash.digest('hex');
const fileSize = fs.statSync(filePath).size;
메모리 문제는 이걸로 해결됐다. 청크(기본 64KB) 단위로 읽으며 해시를 갱신하므로 파일이 아무리 커도 메모리 사용량은 일정하다.
그런데 곰곰이 보면 이상한 점이 남아 있다.
전체 흐름을 시간순으로 늘어놓으면 이렇다.
[1-pass] ZIP 스트림 ──────> 디스크 쓰기 (수백 MB 쓰기)
[2-pass] 디스크 읽기 ─────> SHA256 계산 (수백 MB 읽기)
ZIP 데이터는 저장되는 순간 이미 우리 프로세스의 메모리를 한 번 통과했다.
그런데 해시를 계산하겠다고 방금 쓴 파일을 처음부터 끝까지 다시 읽는다.
500MB 맵이라면 500MB를 쓰고, 500MB를 또 읽는 셈이다.
원하는 그림은 이거다. 데이터가 지나가는 길목에서 저장과 해시를 동시에 처리하는 것.
Unix로 치면 tee 명령이 하는 일이다.
┌──> 디스크 쓰기
ZIP 스트림 ──┤
└──> SHA256 계산
Node.js의 stream.PassThrough는 들어온 청크를 아무 가공 없이 그대로 내보내는 Transform 스트림이다. "아무것도 안 하는 스트림이 왜 필요하지?" 싶지만, 진짜 쓸모는 분기점 역할에 있다.
Readable 스트림은 .pipe()를 여러 번 호출해 여러 목적지로 데이터를 보낼 수 있다.
PassThrough를 중간에 끼우면, 하나의 소스 파이프라인을 받아 여러 소비자에게 갈라 주는 깔끔한 분기점이 만들어진다.
실제 적용한 코드는 다음과 같다. (ZIP 생성에는 archiver를 사용한다)
import * as archiver from 'archiver';
import { PassThrough } from 'stream';
private async downloadAndSaveMapZip(
mapId: string,
deployType: MapDeployType,
outputPath: string,
): Promise<{ checksum: string; fileSize: number }> {
return new Promise((resolve, reject) => {
const output = fs.createWriteStream(outputPath);
const archive = archiver('zip', { zlib: { level: 1 } });
const hash = crypto.createHash('sha256');
// ZIP 데이터가 passThrough를 통과하면서
// 디스크 저장과 SHA256 계산을 동시에 처리한다
const passThrough = new PassThrough();
passThrough.pipe(output); // 분기 1: 디스크 쓰기
passThrough.pipe(hash); // 분기 2: 해시 계산
archive.pipe(passThrough);
output.on('close', () => {
resolve({
checksum: hash.digest('hex'),
fileSize: output.bytesWritten, // 저장 스트림 기준 파일 크기
});
});
archive.on('error', (err) => reject(err));
// ... gRPC 스트리밍으로 받은 맵 파일들을 archive.append()로 추가 ...
});
}
호출부는 이렇게 단순해진다. 별도의 체크섬 계산 단계 자체가 사라졌다.
// 저장 + 체크섬 + 파일 크기가 한 번의 스트림 통과로 끝난다
const { checksum, fileSize } = await this.downloadAndSaveMapZip(mapId, deployType, filePath);
흐름은 이제 1-pass다.
ZIP 스트림 ──> PassThrough ──┬──> 디스크 쓰기 (수백 MB 쓰기)
└──> SHA256 계산 (디스크 읽기 0회)
단순해 보이지만 몇 가지 짚고 넘어갈 부분이 있다.
하나의 Readable에 .pipe()를 여러 개 붙이면, 목적지 중 하나라도 버퍼가 차서 write()가 false를 반환하면 소스가 pause된다. 즉 전체 흐름은 가장 느린 소비자 (대부분 디스크 쓰기) 속도에 맞춰지고, 빠른 소비자(해시) 때문에 데이터가 유실되거나 메모리에 쌓이는 일은 없다.
단, 분기는 반드시 데이터가 흐르기 전에 붙여야 한다. 스트림이 flowing 상태로 전환된 뒤에 .pipe()를 추가하면 이미 지나간 청크는 받지 못한다. 위 코드에서 archive.pipe(passThrough)를 분기 설정 다음에 호출하는 이유다.
hash.digest()는 모든 데이터가 해시에 반영된 후 호출해야 한다. 위 코드는 파일 쓰기가 완전히 끝나는 output의 close 이벤트에서 digest()를 호출한다. 백프레셔 덕분에 output이 닫히는 시점이면 hash에도 같은 데이터가 모두 들어간 뒤라는 것이 보장된다.
stream/promises의 pipeline()은 에러 전파와 리소스 정리를 자동으로 해 주지만, 1:N 분기(fan-out)를 표현할 수 없다. 분기가 필요한 지점에서는 수동 .pipe()를 쓰되, archive.on('error')`처럼 에러 핸들러를 직접 등록해야 한다. 이걸 빼먹으면 에러가 unhandled로 터지므로 주의.
저장이 끝난 파일을 fs.statSync()로 다시 조회할 필요 없이, WriteStream의 bytesWritten을 쓰면 시스템 콜 하나도 아낄 수 있다. 사소하지만 "저장 스트림이 알고 있는 값을 다시 디스크에 물어보지 않는다"는 같은 원칙의 연장이다.
"통과 중에 계산한 체크섬"과 "저장된 파일을 다시 읽어 계산한 체크섬"이 같다는 것을 테스트로 못 박아 두었다.
it('ZIP 생성 중 계산한 체크섬이 실제 파일의 체크섬과 일치한다', async () => {
const result = await service.initiateDeploy({ ... });
// 실제 저장된 파일로 체크섬을 직접 계산
const fileBuffer = fs.readFileSync(path.join(tmpDir, result.fileId));
const expectedChecksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
// 스트림 통과 중에 계산되어 기록된 체크섬과 비교
const record = getDeployRecord(result.deployId);
expect(record.checksum).toBe(expectedChecksum);
});
| 구분 | 변경 전 | 변경 후 |
|---|---|---|
| 디스크 쓰기 | 1회 (파일 크기만큼) | 1회 |
| 디스크 읽기 | 1회 (파일 크기만큼) | 0회 |
| 체크섬 계산 시점 | 저장 완료 후 별도 단계 | 저장과 동시 |
| 메모리 사용 | 청크 단위 (일정) | 청크 단위 (일정) |
핵심은 한 문장이다. 데이터가 어차피 메모리를 지나간다면, 지나가는 길목에서 필요한 계산을 전부 끝내라. PassThrough는 그 길목을 만들어 주는 가장 단순한 도구다.
같은 패턴은 여기저기 응용할 수 있다.
스트림을 "데이터를 옮기는 파이프"가 아니라 "계산을 끼워 넣을 수 있는 컨베이어 벨트"로 보기 시작하면, 2-pass로 짜여 있던 코드가 꽤 많이 눈에 들어올 것이다.