Contents
see List대용량 데이터를 한 번에 읽으면 왜 문제가 될까
Node.js 서비스에서 CSV 내보내기, 업로드 파일 변환, 로그 집계, 외부 API 응답 중계 같은 작업은 처음에는 간단해 보인다. 파일을 읽어 배열에 담고 변환한 뒤 응답으로 보내면 기능은 동작한다. 그러나 데이터가 수백 MB를 넘기면 메모리 사용량이 급격히 증가하고, 가비지 컬렉션이 길어지며, 같은 프로세스가 처리하던 다른 HTTP 요청까지 느려질 수 있다. 컨테이너 메모리 제한을 넘으면 프로세스가 종료되어 재시작되는 문제도 생긴다.
이때 필요한 방식이 스트림(stream)이다. 스트림은 전체 데이터를 메모리에 올리지 않고 일정 크기의 조각으로 읽고, 처리하고, 다음 대상에 전달한다. 핵심은 단순히 readStream을 쓰는 데 있지 않다. 읽는 쪽의 속도와 쓰는 쪽의 속도가 다를 때 데이터가 쌓이지 않도록 흐름을 제어하는 backpressure를 유지해야 한다. Node.js의 pipe와 pipeline은 이 제어를 기본으로 제공하므로, 대용량 처리에서는 수동 이벤트 연결보다 우선 검토할 만하다.
스트림을 구성하는 네 가지 역할
- Readable은 파일, HTTP 요청, 데이터베이스 커서처럼 데이터를 공급하는 입력 스트림이다.
- Writable은 파일, HTTP 응답, 압축 결과처럼 데이터를 소비하는 출력 스트림이다.
- Transform은 입력 조각을 변환해 다시 출력한다. CSV 행 정리, 인코딩 변환, 마스킹, 압축이 대표적이다.
- Duplex는 읽기와 쓰기를 모두 제공한다. 네트워크 소켓 등이 해당한다.
실무에서 가장 흔한 구조는 Readable → Transform → Writable이다. 각 단계가 빠르게 처리할 수 있는 만큼만 다음 데이터를 받아야 한다. pipe는 출력 버퍼가 가득 차면 입력을 잠시 멈추고, 비워지면 다시 읽게 하므로 생산자와 소비자의 처리량 차이를 흡수한다.
파일을 압축해 내려주는 기본 구현
아래 예시는 원본 로그 파일을 gzip으로 압축해 HTTP 응답으로 전달한다. pipeline은 중간 스트림에서 발생한 오류를 한 곳으로 전달하고, 작업 종료까지 기다릴 수 있게 한다. response에 이미 헤더를 보낸 뒤 오류가 발생할 수 있으므로, 오류 응답을 다시 쓰기보다 연결을 정리하는 판단도 필요하다.
import { createReadStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { createGzip } from 'node:zlib';
export async function downloadLog(req, res) {
const filePath = '/var/app/export/access.log';
res.setHeader('Content-Type', 'application/gzip');
res.setHeader('Content-Disposition', 'attachment; filename=access.log.gz');
try {
await pipeline(
createReadStream(filePath, { highWaterMark: 64 * 1024 }),
createGzip(),
res
);
} catch (error) {
console.error('log download failed', error);
if (!res.headersSent) {
res.statusCode = error.code === 'ENOENT' ? 404 : 500;
res.end('download failed');
} else {
res.destroy(error);
}
}
}highWaterMark는 내부 버퍼의 기준 크기다. 값을 크게 하면 I/O 호출 횟수는 줄 수 있지만 동시에 여러 요청이 들어올 때 메모리 사용량이 늘어난다. 반대로 지나치게 작으면 처리량이 떨어질 수 있다. 먼저 기본값 또는 64KB 수준으로 시작하고, 실제 파일 크기와 동시 다운로드 수, CPU 사용량을 측정한 뒤 조정하는 편이 안전하다. 추측만으로 수 MB 단위 버퍼를 지정하는 것은 피한다.
행 단위 처리에서는 조각 경계를 의식한다
스트림의 chunk는 CSV 한 행이나 JSON 한 객체 단위로 보장되지 않는다. 네트워크와 파일 I/O 상태에 따라 한 행이 둘로 나뉘거나 여러 행이 한 조각에 들어올 수 있다. 따라서 단순히 chunk를 줄바꿈으로 나누고 즉시 처리하면 마지막 조각이 깨질 수 있다. 문자열 잔여분을 보관하거나, CSV 파서와 NDJSON 파서처럼 스트림 입력을 지원하는 검증된 라이브러리를 사용한다.
import { Transform } from 'node:stream';
function createLineTransform(onLine) {
let remainder = '';
return new Transform({
transform(chunk, encoding, callback) {
const lines = (remainder + chunk.toString('utf8')).split(/\r?\n/);
remainder = lines.pop() ?? '';
for (const line of lines) {
if (line) onLine(line);
}
callback();
},
flush(callback) {
if (remainder) onLine(remainder);
callback();
}
});
}이 예시는 줄 단위 로그에 적합하지만, 따옴표 안의 줄바꿈을 허용하는 CSV에는 충분하지 않다. 입력 포맷의 문법이 복잡할수록 직접 split하는 방식보다 전용 파서를 선택해야 한다. 또한 Transform 내부에서 비동기 저장 작업을 한다면 callback을 저장 완료 후 호출해야 backpressure가 유지된다. callback을 먼저 호출하고 Promise를 방치하면 동시 작업이 무제한으로 늘어나 데이터베이스 연결과 메모리가 다시 포화될 수 있다.
오류 처리와 취소를 운영 기준으로 만들기
스트림에는 입력 파일 없음, 권한 오류, 클라이언트 연결 종료, 압축 오류, 대상 저장소 장애가 모두 발생할 수 있다. on('error')를 일부 스트림에만 붙이면 다른 단계의 오류가 누락될 수 있다. Promise 기반 pipeline을 사용하면 어느 단계에서든 실패를 하나의 catch에서 처리할 수 있어 정리 로직을 표준화하기 쉽다. HTTP 요청이 취소되었을 때는 AbortController를 연결해 불필요한 파일 읽기와 변환을 멈추는 것도 중요하다.
관측 항목도 미리 정한다. 처리한 바이트 수, 처리 시간, 실패 유형, 동시 실행 수, 메모리 RSS를 기록하면 대용량 작업이 시스템에 미치는 영향을 확인할 수 있다. 민감한 원문 데이터나 전체 행은 로그에 남기지 말고, 요청 식별자와 집계 수치 중심으로 남긴다. 데이터베이스에서 대량 행을 읽는 경우에도 한 번에 전부 조회하지 말고 cursor 또는 페이지 단위 조회와 Writable의 처리 완료를 연결한다.
배포 전 체크리스트
- 전체 파일을 Buffer나 배열에 누적하는 코드가 없는지 확인한다.
- Readable, Transform, Writable 연결은 pipeline으로 종료와 오류를 함께 관리한다.
- 입력 조각이 레코드 경계와 일치하지 않는다는 전제로 파싱한다.
- 쓰기 속도가 느릴 때 callback과 await가 backpressure를 보존하는지 점검한다.
- 파일 크기, 동시 요청, 클라이언트 취소 조건으로 부하 테스트를 수행한다.
- 메모리, 처리 시간, 실패 원인을 집계하고 비밀 데이터는 로그에서 제외한다.
스트림은 대용량 처리의 성능 기법이면서 서비스 안정성 장치다. 데이터 크기보다 처리 흐름을 먼저 설계하고, 오류·취소·관측까지 포함하면 Node.js 프로세스 하나가 감당할 수 있는 작업 범위를 예측 가능하게 만들 수 있다.
javascript
| No | 작성일 | Title |
|---|---|---|
| 2051 | 2025. 11. 30. | Zustand - 간단한 상태 관리 라이브러리 |
| 2050 | 2025. 11. 30. | React Query(TanStack Query)로 서버 상태 관리 |
| 2049 | 2025. 11. 30. | Webpack에서 Vite로 마이그레이션 |
| 2048 | 2025. 11. 30. | JavaScript ES2024 새 기능 |
| 2047 | 2025. 11. 30. | Vue 3 Composition API 활용법 |
| 2046 | 2025. 11. 30. | Next.js App Router 마이그레이션 가이드 |
| 2045 | 2025. 11. 30. | TypeScript 타입 시스템 완벽 가이드 |
| 2044 | 2025. 11. 30. | React 18 새 기능 - Concurrent Features |
| 1864 | 2022. 07. 27. | [vue3] vue3 setup 에서 자식 컴포넌트 메소드(함수) 호출하기 |
| 1848 | 2022. 06. 11. | [ vuejs ] router 데이터 전달방식 |