지난주, 프로덕션 환경에서 간헐적으로 파드(Pod)가 재시작되는 현상이 보고되었습니다. 로그를 확인해 보니 범인은 아주 익숙하고도 악명 높은 FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory였습니다. 원인을 추적한 결과, 사용자가 업로드한 4GB짜리 CSV 로그 파일을 파싱 하여 DB에 적재하는 배치 작업이 메모리 한계(Container Memory Limit: 1GiB)를 초과하고 있었습니다. Node.js는 기본적으로 V8 엔진 위에서 동작하며, 대용량 데이터를 메모리에 한 번에 올리려고 시도하면(fs.readFile 등) 필연적으로 이 문제가 발생합니다. 오늘은 단순히 파일을 읽는 것을 넘어, Node.js Streams와 Backpressure 메커니즘을 이용해 메모리 사용량을 50MB 미만으로 유지하며 대용량 파일 처리를 안정화한 과정을 공유합니다.
Node.js 성능 병목과 Backpressure의 이해
문제의 시스템은 AWS ECS Fargate 환경에서 구동 중이었으며, Node.js v18 LTS 버전을 사용하고 있었습니다. 메모리 관리 관점에서 볼 때, 가장 흔한 실수는 데이터를 '덩어리(Chunk)'가 아닌 '전체(Whole)'로 다루는 것입니다. 하지만 스트림을 사용한다고 해서 무조건 문제가 해결되는 것은 아닙니다. 여기서 등장하는 핵심 개념이 바로 Backpressure(배압)입니다.
Backpressure는 데이터를 읽는 속도(Readable Stream)가 데이터를 쓰는 속도(Writable Stream)보다 빠를 때 발생합니다. 예를 들어, 디스크에서 파일을 읽는 속도는 매우 빠르지만, 이를 가공해서 원격 DB에 INSERT 하는 속도는 네트워크 I/O로 인해 상대적으로 느립니다. 이때 Node.js가 읽어 들인 데이터를 메모리 버퍼(Internal Buffer)에 계속 쌓아두게 되면, 결국 버퍼가 넘쳐 OOM이 발생합니다. 이것은 수도꼭지(Source)는 콸콸 틀어져 있는데, 배수구(Destination)가 좁아 싱크대가 넘치는 상황과 정확히 일치합니다.
source.pipe(dest)를 사용하더라도, 중간에 복잡한 Transform 로직이나 비동기 처리가 잘못 구현되면 Backpressure 제어권이 상실되어 힙 메모리가 폭발합니다.
공식 문서인 Node.js Stream Docs에서도 언급하듯, 스트림의 내부 버퍼 크기인 highWaterMark(기본값 16KB 또는 64KB)가 가득 찼을 때 stream.write()는 false를 반환합니다. 이때 쓰기를 멈추고 drain 이벤트가 발생할 때까지 기다려야 하는데, 초보적인 구현에서는 이 신호를 무시하고 데이터를 계속 밀어 넣는 경우가 많습니다.
실패 사례: 이벤트 리스너 방식의 함정
처음에는 fs.createReadStream을 사용하여 data 이벤트 리스너를 등록하고, 들어오는 청크를 바로 처리하는 방식을 시도했습니다.
// ⛔️ DO NOT USE IN PRODUCTION
// Backpressure를 전혀 고려하지 않은 코드
const fs = require('fs');
const readStream = fs.createReadStream('huge-file.csv');
readStream.on('data', async (chunk) => {
// 비동기 처리가 완료되기도 전에 다음 'data' 이벤트가 계속 발생함
await database.insert(chunk);
});
이 코드는 치명적인 결함이 있습니다. database.insert가 완료되기를 기다리지 않고(await가 있어도 이벤트 루프는 멈추지 않음) readStream은 미친 듯이 데이터를 뿜어냅니다. 결과적으로 수천 개의 DB 요청이 Promise 큐에 쌓이게 되고, Node.js 성능 저하와 함께 프로세스가 터져버립니다. 단순히 스트림 객체를 쓴다고 해결되는 것이 아니라, '흐름 제어'가 필요합니다.
해결책: Pipeline API와 Async Generator
이 문제를 해결하기 위해 Node.js v15부터 안정화된 stream/promises의 pipeline API와 Async Generator를 조합하여 사용했습니다. 이 방식은 에러 핸들링을 자동화하고(스트림 중 하나라도 에러가 나면 전체를 close 처리), 비동기 흐름 제어를 매우 직관적으로 만들어줍니다.
// ✅ Optimized Solution
const { pipeline } = require('stream/promises');
const fs = require('fs');
const zlib = require('zlib');
const { Transform } = require('stream');
async function processLargeFile(inputPath, outputPath) {
console.time('StreamProcess');
try {
await pipeline(
// 1. Source: 파일 읽기 (64KB 청크)
fs.createReadStream(inputPath, { highWaterMark: 64 * 1024 }),
// 2. Transform: 데이터 파싱 및 가공 (Async Generator 활용)
async function* (source) {
let buffer = '';
for await (const chunk of source) {
buffer += chunk;
const lines = buffer.split('\n');
// 마지막 조각은 불완전할 수 있으므로 남겨둠
buffer = lines.pop();
for (const line of lines) {
if (!line.trim()) continue;
// 여기서 데이터를 가공 (CSV 파싱 등)
const processed = `[Processed] ${line}\n`;
yield processed; // 다음 스트림으로 전달
}
}
// 남은 버퍼 처리
if (buffer) yield `[Processed] ${buffer}\n`;
},
// 3. Transform: 압축 (CPU Intensive 작업)
zlib.createGzip(),
// 4. Destination: 파일 쓰기
fs.createWriteStream(outputPath)
);
console.log('Pipeline succeeded.');
} catch (err) {
console.error('Pipeline failed:', err);
// pipeline이 자동으로 스트림을 destroy 하므로 별도 cleanup 불필요
} finally {
console.timeEnd('StreamProcess');
}
}
processLargeFile('./input_5gb.csv', './output.csv.gz');
위 코드의 핵심은 async function* (source) 부분입니다. for await...of 문법을 사용하면, Readable Stream이 데이터를 공급하는 속도에 맞춰 반복문이 돕니다. 더 중요한 점은, yield를 통해 데이터를 내보낼 때, 다음 스트림(여기서는 Gzip)이 받아들일 준비가 될 때까지(내부 버퍼가 비워질 때까지) 자동으로 대기한다는 점입니다. 개발자가 복잡한 stream.write() === false 체크 로직을 짤 필요 없이, 언어 차원에서 Backpressure를 자연스럽게 처리해 줍니다.
성능 검증 및 결과 분석
실제 3.8GB 크기의 CSV 파일을 대상으로 기존 방식(메모리에 로드 시도)과 최적화된 스트림 방식을 비교해 보았습니다. 테스트 환경은 AWS t3.medium 인스턴스입니다.
| 지표 (Metric) | 기존 방식 (readFile) | 최적화 방식 (Stream Pipeline) |
|---|---|---|
| 최대 메모리 사용량 (RSS) | 4.2GB (OOM Crash) | 48MB (Constant) |
| 처리 상태 | 실패 (Process Exit) | 성공 (완료) |
| CPU 점유율 | 스파이크 발생 (100%) | 안정적 (30~40%) |
결과는 극명합니다. 스트림 파이프라인 방식은 파일 크기가 10GB, 100GB로 늘어나더라도 메모리 사용량이 50MB 내외로 일정하게 유지됩니다. 이는 데이터가 메모리에 머무르지 않고 '흘러가기' 때문입니다. 특히 pipeline API가 에러 발생 시 모든 스트림의 리소스를(File Descriptor 등) 즉시 해제해 주므로, 장기간 실행되는 서버에서 발생할 수 있는 OS 레벨의 리소스 누수까지 방지할 수 있었습니다.
주의사항 및 Edge Cases
이 패턴을 적용할 때 몇 가지 주의해야 할 점이 있습니다.
- HighWaterMark 튜닝: 기본값 64KB는 일반적인 경우에 적합하지만, 처리해야 할 데이터 청크가 매우 크거나(예: 고해상도 이미지 처리), 반대로 매우 작은 데이터가 빈번하게 오가는 경우 이 값을 조정해야 성능을 극대화할 수 있습니다.
-
문자열 인코딩 문제: 위 예제 코드에서
buffer.split('\n')을 사용했는데, 멀티바이트 문자(한글 등)가 청크 경계에 걸쳐서 짤리는 경우(문자가 됨)가 발생할 수 있습니다. 이를 완벽하게 처리하려면readline모듈을 스트림으로 사용하거나,string_decoder모듈을 명시적으로 사용해야 합니다. -
비동기 동시성 제어: 스트림 내부에서 DB 호출 등 비동기 작업을 할 때, 순서가 중요하다면 반드시
await를 해야 하지만, 순서가 중요하지 않다면 병렬 처리를 통해 속도를 높일 수 있습니다. 단, 이때도Promise.all등으로 동시 실행 개수를 제한하지 않으면 다시 Backpressure 이슈가 발생할 수 있습니다.
stream.pipeline을 기본으로 사용하고, 레거시 .pipe() 메서드는 가급적 사용을 피하세요. 에러 핸들링의 복잡도가 현저히 다릅니다.
결론 (Conclusion)
Node.js에서 대용량 데이터를 다룰 때 OOM은 피할 수 없는 숙명처럼 여겨지지만, 사실은 스트림의 Backpressure 메커니즘을 제대로 이해하지 못해 발생하는 '구현상의 버그'인 경우가 대부분입니다. 단순히 fs.readFile을 fs.createReadStream으로 바꾸는 것에 그치지 말고, pipeline API와 Async Generator를 활용하여 데이터의 생산 속도와 소비 속도의 균형을 맞추세요. 이것이 메모리 제한이 엄격한 클라우드 환경에서 Node.js 애플리케이션을 견고하게 운영하는 핵심 비결입니다.
Post a Comment