Node.js 인터뷰 질문 35
질문: Node.js의 스트림(Streams)에 대해 설명하고, 어떤 종류가 있으며 언제 사용해야 하는지 설명해주세요.
답변:
스트림(Streams)은 Node.js에서 데이터를 효율적으로 처리하기 위한 핵심 개념으로, 데이터를 작은 청크(chunks)로 나누어 순차적으로 처리하는 추상적인 인터페이스입니다. 스트림은 특히 대용량 데이터를 메모리 효율적으로 처리할 때 유용합니다.
스트림의 기본 개념
스트림은 마치 수도관을 통해 물이 흐르는 것처럼 데이터가 한 지점에서 다른 지점으로 연속적으로 흐르는 방식으로 작동합니다. 이 방식은 전체 데이터를 메모리에 한번에 로드하는 대신, 작은 단위로 처리하므로 메모리 사용량을 크게 줄일 수 있습니다.
// 파일을 스트림으로 읽기
const fs = require("fs");
const readableStream = fs.createReadStream("large-file.txt");
readableStream.on("data", (chunk) => {
console.log(`Received ${chunk.length} bytes of data.`);
});
readableStream.on("end", () => {
console.log("Finished reading the file.");
});
스트림의 종류
Node.js에서는 4가지 기본 유형의 스트림을 제공합니다:
1. 읽기 가능 스트림(Readable Streams)
데이터를 소비할 수 있는 소스입니다. 예를 들어 파일 읽기, HTTP 요청, 프로세스 stdin 등이 있습니다.
const fs = require("fs");
const readableStream = fs.createReadStream("input.txt");
readableStream.on("data", (chunk) => {
console.log(`Received chunk: ${chunk}`);
});
readableStream.on("end", () => {
console.log("No more data.");
});
readableStream.on("error", (err) => {
console.error(`Error: ${err.message}`);
});
2. 쓰기 가능 스트림(Writable Streams)
데이터를 쓸 수 있는 대상입니다. 예를 들어 파일 쓰기, HTTP 응답, 프로세스 stdout 등이 있습니다.
const fs = require("fs");
const writableStream = fs.createWriteStream("output.txt");
writableStream.write("안녕하세요!\n");
writableStream.write("Node.js 스트림을 사용 중입니다.\n");
writableStream.end("스트림 작성을 완료합니다.");
writableStream.on("finish", () => {
console.log("모든 데이터가 스트림에 기록되었습니다.");
});
writableStream.on("error", (err) => {
console.error(`오류 발생: ${err.message}`);
});
3. 듀플렉스 스트림(Duplex Streams)
읽기와 쓰기가 모두 가능한 스트림입니다. 예를 들어 TCP 소켓이 있습니다.
const net = require("net");
const server = net.createServer((socket) => {
// 소켓은 듀플렉스 스트림입니다
socket.on("data", (data) => {
console.log(`수신된 데이터: ${data}`);
// 데이터를 다시 클라이언트로 전송
socket.write(`서버에서 응답: ${data}`);
});
socket.on("end", () => {
console.log("클라이언트 연결 종료");
});
});
server.listen(3000, () => {
console.log("서버가 포트 3000에서 수신 중입니다");
});
4. 변환 스트림(Transform Streams)
듀플렉스 스트림의 한 유형으로, 입력 데이터를 처리하고 변환하여 출력할 수 있습니다. 예를 들어 압축, 암호화, 인코딩 변환 등이 있습니다.
const fs = require("fs");
const zlib = require("zlib");
// 파일을 읽고, 압축하고, 새 파일에 쓰는 예제
fs.createReadStream("input.txt")
.pipe(zlib.createGzip()) // 변환 스트림
.pipe(fs.createWriteStream("input.txt.gz"))
.on("finish", () => {
console.log("파일 압축이 완료되었습니다.");
});
스트림 사용 사례
스트림은 다음과 같은 상황에서 특히 유용합니다:
1. 대용량 파일 처리
대용량 파일을 처리할 때, 전체 파일을 메모리에 로드하는 대신 스트림을 사용하면 메모리 사용량을 크게 줄일 수 있습니다.
// 대용량 파일 복사
const fs = require("fs");
// 스트림 사용
fs.createReadStream("large-video.mp4")
.pipe(fs.createWriteStream("copy-video.mp4"))
.on("finish", () => {
console.log("파일 복사가 완료되었습니다.");
});
// vs. 메모리에 전체 로딩 (좋지 않은 방식)
// fs.readFile('large-video.mp4', (err, data) => {
// fs.writeFile('copy-video.mp4', data, (err) => {
// console.log('파일 복사가 완료되었습니다.');
// });
// });
2. 네트워크 통신
HTTP 서버에서 응답을 스트리밍하면 클라이언트는 전체 데이터가 준비되기 전에 데이터를 받기 시작할 수 있어 사용자 경험이 향상됩니다.
const http = require("http");
const fs = require("fs");
const server = http.createServer((req, res) => {
// 비디오 스트리밍 예제
if (req.url === "/video") {
res.writeHead(200, { "Content-Type": "video/mp4" });
fs.createReadStream("video.mp4").pipe(res);
} else {
res.writeHead(404);
res.end("페이지를 찾을 수 없습니다");
}
});
server.listen(3000, () => {
console.log("서버가 포트 3000에서 실행 중입니다");
});
3. 데이터 변환
데이터를 한 형식에서 다른 형식으로 변환할 때 스트림 파이프라인을 사용하면 효율적입니다.
const fs = require("fs");
const csv = require("csv-parser");
const JSONStream = require("JSONStream");
// CSV를 JSON으로 변환
fs.createReadStream("data.csv")
.pipe(csv())
.pipe(JSONStream.stringify())
.pipe(fs.createWriteStream("data.json"))
.on("finish", () => {
console.log("CSV에서 JSON으로 변환이 완료되었습니다.");
});
4. 실시간 데이터 처리
실시간으로 들어오는 데이터를 처리할 때 스트림을 사용하면 효과적입니다.
const WebSocket = require("ws");
const fs = require("fs");
const wss = new WebSocket.Server({ port: 8080 });
const logStream = fs.createWriteStream("chat-logs.txt", { flags: "a" });
wss.on("connection", (ws) => {
ws.on("message", (message) => {
// 메시지를 모든 클라이언트에게 브로드캐스트
wss.clients.forEach((client) => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
// 로그 파일에 메시지 기록
logStream.write(`${new Date().toISOString()}: ${message}\n`);
});
});
스트림 API의 주요 메소드와 이벤트
읽기 가능 스트림의 주요 이벤트와 메소드
이벤트:
data
: 데이터 청크가 도착할 때 발생end
: 더 이상 읽을 데이터가 없을 때 발생error
: 오류 발생 시close
: 스트림이 닫힐 때 발생
메소드:
pipe(destination)
: 데이터를 목적지 스트림으로 전달read([size])
: 특정 크기의 데이터를 읽음pause()
: 데이터 수신을 일시 중지resume()
: 일시 중지된 스트림 재개
쓰기 가능 스트림의 주요 이벤트와 메소드
이벤트:
drain
: 쓰기 버퍼가 비워지면 발생finish
: 모든 데이터가 쓰여지고 스트림이 종료되면 발생error
: 오류 발생 시close
: 스트림이 닫힐 때 발생
메소드:
write(chunk[, encoding][, callback])
: 데이터 청크 작성end([chunk][, encoding][, callback])
: 스트림에 쓰기를 완료
스트림 파이프라인
pipe()
메소드는 여러 스트림을 연결하는 간단한 방법을 제공합니다.
const fs = require("fs");
const zlib = require("zlib");
// 파일 읽기 → 압축 → 암호화 → 파일 쓰기
fs.createReadStream("input.txt")
.pipe(zlib.createGzip())
.pipe(crypto.createCipher("aes-192-cbc", "password"))
.pipe(fs.createWriteStream("input.txt.gz.enc"));
Node.js 10부터는 stream.pipeline()
메소드가 추가되었으며, 오류 처리와 리소스 정리가 개선되었습니다.
const { pipeline } = require("stream");
const fs = require("fs");
const zlib = require("zlib");
pipeline(
fs.createReadStream("input.txt"),
zlib.createGzip(),
fs.createWriteStream("input.txt.gz"),
(err) => {
if (err) {
console.error("Pipeline failed:", err);
} else {
console.log("Pipeline succeeded.");
}
}
);
스트림 모드: 흐름 제어
읽기 가능 스트림은 두 가지 작동 모드가 있습니다:
- 흐름 모드(Flowing Mode): 데이터가 자동으로 처리됩니다.
data
이벤트 핸들러를 추가하거나pipe()
메소드를 사용할 때 활성화됩니다. - 일시 중지 모드(Paused Mode):
read()
메소드를 명시적으로 호출하여 데이터를 가져옵니다.
// 흐름 모드 예제
const fs = require("fs");
const readable = fs.createReadStream("file.txt");
readable.on("data", (chunk) => {
console.log(`Received ${chunk.length} bytes of data.`);
// 처리가 느리면 일시 중지할 수 있습니다
readable.pause();
console.log("Paused the stream");
setTimeout(() => {
console.log("Resuming the stream");
readable.resume();
}, 1000);
});
// 일시 중지 모드 예제
const fs = require("fs");
const readable = fs.createReadStream("file.txt", { highWaterMark: 1024 });
readable.pause(); // 명시적으로 일시 중지
// 일정 간격으로 데이터 읽기
setInterval(() => {
const chunk = readable.read(64);
if (chunk) {
console.log(`Read ${chunk.length} bytes of data`);
}
}, 100);
객체 모드 스트림
기본적으로 스트림은 버퍼 또는 문자열을 처리하지만, objectMode
옵션을 설정하면 JavaScript 객체를 직접 처리할 수 있습니다.
const { Transform } = require("stream");
// 객체 모드 변환 스트림
const uppercaseTransform = new Transform({
objectMode: true,
transform(chunk, encoding, callback) {
// 객체의 name 속성을 대문자로 변환
chunk.name = chunk.name.toUpperCase();
callback(null, chunk);
},
});
// 사용 예
const user = { name: "john", age: 30 };
uppercaseTransform.write(user);
uppercaseTransform.on("data", (transformedUser) => {
console.log(transformedUser); // { name: 'JOHN', age: 30 }
});
사용자 정의 스트림 구현
Node.js에서는 기본 스트림 클래스를 상속하여 사용자 정의 스트림을 구현할 수 있습니다.
const { Readable } = require("stream");
// 1부터 10까지 숫자를 생성하는 읽기 가능 스트림
class CounterStream extends Readable {
constructor(options) {
super(options);
this.count = 1;
}
_read() {
if (this.count <= 10) {
const buf = Buffer.from(`${this.count}\n`, "utf8");
this.push(buf);
this.count += 1;
} else {
this.push(null); // 데이터 종료를 알림
}
}
}
const counter = new CounterStream();
counter.pipe(process.stdout);
스트림 성능 최적화
스트림을 사용할 때 성능을 최적화하기 위한 몇 가지 팁:
- 적절한 청크 크기 설정:
highWaterMark
옵션을 사용하여 버퍼 크기를 조정합니다. - 백프레셔(Backpressure) 처리:
write()
메소드의 반환 값을 확인하여 목적지 스트림이 가득 찼는지 확인합니다. - 메모리 사용량 모니터링: 대용량 데이터를 처리할 때 메모리 사용량을 주시합니다.
const fs = require("fs");
const readStream = fs.createReadStream("input.txt", {
highWaterMark: 64 * 1024,
}); // 64KB 버퍼
const writeStream = fs.createWriteStream("output.txt");
readStream.on("data", (chunk) => {
// 쓰기 스트림이 가득 찼는지 확인
const canContinue = writeStream.write(chunk);
// 가득 찼다면 일시 중지 후 drain 이벤트 대기
if (!canContinue) {
readStream.pause();
writeStream.once("drain", () => {
readStream.resume();
});
}
});
readStream.on("end", () => {
writeStream.end();
});
스트림과 버퍼의 차이점
- 버퍼(Buffer): 고정된 크기의 메모리 영역으로, 한 번에 처리될 이진 데이터의 임시 저장소입니다.
- 스트림(Stream): 시간이 지남에 따라 데이터를 처리하는 추상적인 인터페이스로, 대량의 데이터를 작은 청크로 나누어 처리합니다.
버퍼는 데이터의 일부를 메모리에 저장하는 단순한 개체인 반면, 스트림은 데이터를 소비하고 생성하는 방법에 대한 인터페이스입니다. 스트림은 내부적으로 버퍼를 사용하지만, 데이터 흐름을 관리하는 추가 기능을 제공합니다.
Node.js 스트림의 최신 기능
Node.js는 지속적으로 스트림 API를 개선하고 있습니다. 최근 버전에서 추가된 중요한 기능들:
- Promises API (Node.js v15+): 스트림 작업에 프로미스 기반 API 제공
const fs = require("fs");
const { pipeline } = require("stream/promises");
async function run() {
try {
await pipeline(
fs.createReadStream("input.txt"),
fs.createWriteStream("output.txt")
);
console.log("Pipeline succeeded.");
} catch (err) {
console.error("Pipeline failed.", err);
}
}
run();
- Compose (Node.js v16+): 여러 변환 스트림을 하나로 결합
const { compose } = require("stream");
const { gunzip, createDecipheriv } = require("zlib");
const { createReadStream, createWriteStream } = require("fs");
const combined = compose(gunzip(), createDecipheriv("aes-192-cbc", key, iv));
createReadStream("file.gz.enc").pipe(combined).pipe(createWriteStream("file"));
요약
- 스트림은 Node.js에서 데이터를 효율적으로 처리하기 위한 추상적 인터페이스입니다.
- 네 가지 기본 유형이 있습니다: 읽기 가능, 쓰기 가능, 듀플렉스, 변환 스트림.
- 스트림은 대용량 파일 처리, 네트워크 통신, 데이터 변환, 실시간 데이터 처리에 이상적입니다.
pipe()
메소드와pipeline()
함수는 여러 스트림을 쉽게 연결할 수 있게 해줍니다.- 스트림은 메모리 효율성, 시간 효율성, 구성 가능성을 제공합니다.
- 사용자 정의 스트림을 구현하여 특정 데이터 처리 요구 사항을 충족할 수 있습니다.
Node.js 개발자는 스트림을 이해하고 효과적으로 활용하는 것이 중요하며, 특히 대용량 데이터 처리나 실시간 애플리케이션을 구축할 때 스트림의 장점을 최대한 활용해야 합니다.