Node.js 인터뷰 질문 46
질문: Node.js의 스트림(Stream)에 대해 설명하고, 주요 스트림 유형과 사용 예제를 설명해주세요.
답변:
스트림(Stream)은 Node.js에서 데이터를 효율적으로 처리하기 위한 추상 인터페이스입니다. 스트림을 사용하면 대용량 데이터를 작은 청크(chunk)로 나누어 처리할 수 있어, 메모리 효율성이 높아지고 데이터 처리 시간이 단축됩니다.
스트림의 기본 개념
스트림은 시간이 지남에 따라 사용 가능해지는 데이터의 연속적인 흐름을 다룹니다. 파일 읽기/쓰기, 네트워크 통신과 같은 I/O 작업에 특히 유용합니다. 대용량 데이터를 처리할 때 전체 데이터를 메모리에 로드하는 대신, 스트림은 데이터를 청크 단위로 처리하므로 메모리 사용량이 크게 감소합니다.
Node.js의 스트림 유형
Node.js에서는 다음과 같은 4가지 기본 스트림 유형을 제공합니다:
1. 읽기 가능 스트림(Readable Stream)
데이터 소스로부터 데이터를 읽어들이는 스트림입니다. 예를 들어, fs.createReadStream
은 파일로부터 데이터를 읽어들이는 읽기 가능 스트림을 생성합니다.
const fs = require("fs");
// 읽기 가능 스트림 생성
const readableStream = fs.createReadStream("large-file.txt", {
encoding: "utf8",
highWaterMark: 16 * 1024, // 16KB 청크 크기 (기본값)
});
// 'data' 이벤트를 통해 청크 단위로 데이터 수신
readableStream.on("data", (chunk) => {
console.log(`${chunk.length} 바이트의 데이터 수신`);
});
// 'end' 이벤트는 모든 데이터가 읽혔을 때 발생
readableStream.on("end", () => {
console.log("읽기 완료");
});
// 'error' 이벤트는 오류 발생 시 트리거됨
readableStream.on("error", (err) => {
console.error("오류 발생:", err);
});
2. 쓰기 가능 스트림(Writable Stream)
데이터를 목적지(파일, 네트워크 등)에 쓰는 스트림입니다. 예를 들어, fs.createWriteStream
은 파일에 데이터를 쓰는 쓰기 가능 스트림을 생성합니다.
const fs = require("fs");
// 쓰기 가능 스트림 생성
const writableStream = fs.createWriteStream("output.txt");
// 데이터 쓰기
writableStream.write("안녕하세요!\n");
writableStream.write("Node.js 스트림에 대해 알아보고 있습니다.\n");
// 쓰기 완료 및 스트림 종료
writableStream.end("스트림 작성을 완료합니다.");
// 'finish' 이벤트는 모든 데이터가 쓰여지고 스트림이 종료되었을 때 발생
writableStream.on("finish", () => {
console.log("쓰기 완료");
});
// 'error' 이벤트는 오류 발생 시 트리거됨
writableStream.on("error", (err) => {
console.error("오류 발생:", err);
});
3. 양방향 스트림(Duplex Stream)
읽기와 쓰기가 모두 가능한 스트림입니다. net.Socket
은 양방향 스트림의 예입니다.
const net = require("net");
// TCP 서버 생성
const server = net.createServer((socket) => {
// socket은 양방향 스트림
// 데이터 수신 (읽기)
socket.on("data", (data) => {
console.log(`클라이언트로부터 수신: ${data}`);
// 데이터 전송 (쓰기)
socket.write(`Echo: ${data}`);
});
socket.on("end", () => {
console.log("클라이언트 연결 종료");
});
});
server.listen(3000, () => {
console.log("서버가 포트 3000에서 실행 중입니다.");
});
4. 변환 스트림(Transform Stream)
데이터를 읽고, 변형한 후, 변형된 데이터를 출력하는 스트림입니다. 이는 양방향 스트림의 특별한 형태입니다. zlib.createGzip
은 변환 스트림의 예입니다.
const fs = require("fs");
const zlib = require("zlib");
// 변환 스트림 생성 (gzip 압축)
const gzip = zlib.createGzip();
// 스트림 파이핑: 읽기 -> 변환 -> 쓰기
const readStream = fs.createReadStream("large-file.txt");
const writeStream = fs.createWriteStream("large-file.txt.gz");
readStream
.pipe(gzip) // 데이터 압축
.pipe(writeStream) // 압축된 데이터 쓰기
.on("finish", () => {
console.log("파일 압축 완료");
});
스트림 파이핑(Piping)
파이핑은 여러 스트림을 연결하여 데이터를 한 스트림에서 다른 스트림으로 자동으로 전달하는 메커니즘입니다. 이를 통해 데이터 처리 파이프라인을 구축할 수 있습니다.
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("파일이 복사되고 압축되었습니다.");
});
사용자 정의 스트림 생성
Node.js에서는 기본 스트림 클래스를 상속하여 사용자 정의 스트림을 만들 수 있습니다.
읽기 가능 스트림 예제
const { Readable } = require("stream");
class CounterStream extends Readable {
constructor(max) {
super();
this.max = max;
this.current = 0;
}
_read() {
this.current += 1;
if (this.current <= this.max) {
const buf = Buffer.from(`${this.current}\n`, "utf8");
this.push(buf);
} else {
this.push(null); // 스트림 종료
}
}
}
// 1부터 10까지 숫자를 생성하는 스트림
const counter = new CounterStream(10);
counter.pipe(process.stdout);
쓰기 가능 스트림 예제
const { Writable } = require("stream");
class LogStream extends Writable {
constructor(options) {
super(options);
}
_write(chunk, encoding, callback) {
console.log(`[${new Date().toISOString()}] ${chunk.toString()}`);
callback();
}
}
// 로그 스트림 생성
const logger = new LogStream();
// 스트림에 데이터 쓰기
logger.write("로그 메시지 1\n");
logger.write("로그 메시지 2\n");
logger.end("마지막 로그 메시지\n");
변환 스트림 예제
const { Transform } = require("stream");
class UppercaseTransform extends Transform {
_transform(chunk, encoding, callback) {
// 데이터를 대문자로 변환
const upperChunk = chunk.toString().toUpperCase();
this.push(upperChunk);
callback();
}
}
// 파이프라인에서 변환 스트림 사용
const uppercaser = new UppercaseTransform();
process.stdin.pipe(uppercaser).pipe(process.stdout);
스트림 이벤트
스트림은 이벤트 기반으로 작동하며, 다음과 같은 주요 이벤트를 발생시킵니다:
읽기 가능 스트림 이벤트
data
: 데이터 청크가 사용 가능할 때end
: 더 이상 읽을 데이터가 없을 때error
: 오류 발생 시close
: 스트림이 닫힐 때
쓰기 가능 스트림 이벤트
drain
: 쓰기 버퍼가 비워졌을 때finish
: 모든 데이터가 쓰여졌을 때error
: 오류 발생 시close
: 스트림이 닫힐 때
스트림 모드: flowing vs. paused
읽기 가능 스트림은 두 가지 작동 모드를 가집니다:
1. flowing 모드
'data' 이벤트 핸들러가 연결되면 스트림은 flowing 모드로 전환되어 데이터가 자동으로 전달됩니다.
const readableStream = fs.createReadStream("file.txt");
readableStream.on("data", (chunk) => {
console.log(chunk);
});
2. paused 모드
기본 모드로, read()
메서드를 호출하여 명시적으로 데이터를 요청합니다.
const readableStream = fs.createReadStream("file.txt");
readableStream.on("readable", () => {
let chunk;
while (null !== (chunk = readableStream.read())) {
console.log(`읽은 데이터: ${chunk.length} 바이트`);
}
});
백 프레셔(Backpressure) 처리
백 프레셔는 목적지 스트림이 소스 스트림보다 느릴 때 발생하는 상황입니다. 적절한 백 프레셔 처리는 메모리 누수와 성능 문제를 방지합니다.
const fs = require("fs");
const readableStream = fs.createReadStream("large-file.txt");
const writableStream = fs.createWriteStream("output.txt");
// 백 프레셔 처리 예제
readableStream.on("data", (chunk) => {
// write()는 버퍼가 가득 차면 false를 반환
const canContinue = writableStream.write(chunk);
if (!canContinue) {
console.log("백 프레셔 - 읽기 일시 중지");
readableStream.pause();
}
});
// 쓰기 버퍼가 비워졌을 때 읽기 재개
writableStream.on("drain", () => {
console.log("드레인 - 읽기 재개");
readableStream.resume();
});
readableStream.on("end", () => {
writableStream.end();
});
스트림 파이프라인과 오류 처리
Node.js v10 이상에서는 stream.pipeline()
함수를 사용하여 여러 스트림을 연결하고 오류를 적절히 처리할 수 있습니다.
const fs = require("fs");
const zlib = require("zlib");
const { pipeline } = require("stream");
// 스트림 파이프라인 생성
pipeline(
fs.createReadStream("input.txt"),
zlib.createGzip(),
fs.createWriteStream("input.txt.gz"),
(err) => {
if (err) {
console.error("파이프라인 오류:", err);
} else {
console.log("파이프라인 성공적으로 완료");
}
}
);
스트림 사용의 실제 사례
1. 대용량 파일 처리
const fs = require("fs");
const csv = require("csv-parser");
// 대용량 CSV 파일 처리
fs.createReadStream("large-data.csv")
.pipe(csv())
.on("data", (row) => {
// 각 행을 개별적으로 처리
console.log(row);
})
.on("end", () => {
console.log("CSV 파일 처리 완료");
});
2. HTTP 응답 스트리밍
const http = require("http");
const fs = require("fs");
const server = http.createServer((req, res) => {
if (req.url === "/video") {
// 비디오 파일 스트리밍
const videoFile = fs.createReadStream("large-video.mp4");
res.writeHead(200, { "Content-Type": "video/mp4" });
videoFile.pipe(res);
} else {
res.writeHead(404);
res.end("Not Found");
}
});
server.listen(3000, () => {
console.log("서버가 포트 3000에서 실행 중입니다.");
});
3. 데이터 변환 파이프라인
const fs = require("fs");
const { Transform } = require("stream");
const { pipeline } = require("stream/promises"); // Node.js v15+
// JSON 객체를 CSV 형식으로 변환하는 변환 스트림
class JSONtoCSV extends Transform {
constructor(options) {
super({ ...options, objectMode: true });
this.headers = null;
}
_transform(chunk, encoding, callback) {
const data = typeof chunk === "string" ? JSON.parse(chunk) : chunk;
if (!this.headers) {
this.headers = Object.keys(data);
this.push(this.headers.join(",") + "\n");
}
const values = this.headers.map((header) => {
const val = data[header];
return typeof val === "string" ? `"${val.replace(/"/g, '""')}"` : val;
});
this.push(values.join(",") + "\n");
callback();
}
}
// JSON 데이터를 CSV로 변환하는 파이프라인
async function convertJSONtoCSV() {
try {
await pipeline(
fs.createReadStream("data.json"),
new Transform({
objectMode: true,
transform(chunk, encoding, callback) {
// JSON 문자열을 파싱하여 객체로 변환
try {
const data = JSON.parse(chunk.toString());
callback(null, data);
} catch (err) {
callback(err);
}
},
}),
new JSONtoCSV(),
fs.createWriteStream("output.csv")
);
console.log("JSON에서 CSV로 변환 완료");
} catch (err) {
console.error("변환 중 오류 발생:", err);
}
}
convertJSONtoCSV();
요약
Node.js의 스트림은 대용량 데이터를 효율적으로 처리하기 위한 강력한 추상화입니다. 주요 유형은 다음과 같습니다:
- 읽기 가능 스트림(Readable): 데이터 소스로부터 데이터를 읽습니다.
- 쓰기 가능 스트림(Writable): 데이터를 목적지에 씁니다.
- 양방향 스트림(Duplex): 데이터를 읽고 쓸 수 있습니다.
- 변환 스트림(Transform): 데이터를 읽고, 변형하고, 출력합니다.
스트림의 주요 이점:
- 메모리 효율성: 전체 데이터를 메모리에 로드하지 않고 청크 단위로 처리합니다.
- 시간 효율성: 데이터 처리가 가능해지는 즉시 시작할 수 있습니다.
- 구성 가능성: 스트림은 파이프를 통해 연결하여 복잡한 데이터 처리 파이프라인을 만들 수 있습니다.
스트림은 파일 처리, 네트워크 통신, 데이터 압축/해제, 데이터 변환 등 다양한 I/O 작업에 적합합니다. 적절한 스트림 구현은 Node.js 애플리케이션의 성능과 확장성을 크게 향상시킬 수 있습니다.