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의 스트림은 대용량 데이터를 효율적으로 처리하기 위한 강력한 추상화입니다. 주요 유형은 다음과 같습니다:

  1. 읽기 가능 스트림(Readable): 데이터 소스로부터 데이터를 읽습니다.
  2. 쓰기 가능 스트림(Writable): 데이터를 목적지에 씁니다.
  3. 양방향 스트림(Duplex): 데이터를 읽고 쓸 수 있습니다.
  4. 변환 스트림(Transform): 데이터를 읽고, 변형하고, 출력합니다.

스트림의 주요 이점:

  • 메모리 효율성: 전체 데이터를 메모리에 로드하지 않고 청크 단위로 처리합니다.
  • 시간 효율성: 데이터 처리가 가능해지는 즉시 시작할 수 있습니다.
  • 구성 가능성: 스트림은 파이프를 통해 연결하여 복잡한 데이터 처리 파이프라인을 만들 수 있습니다.

스트림은 파일 처리, 네트워크 통신, 데이터 압축/해제, 데이터 변환 등 다양한 I/O 작업에 적합합니다. 적절한 스트림 구현은 Node.js 애플리케이션의 성능과 확장성을 크게 향상시킬 수 있습니다.

results matching ""

    No results matching ""