← All writing
Development

node.js Part2 section2,3 stream note taking

솔직히 section2 헷갈려서 주말 지나고 한 번 더 들었다.

stream

  • readable/writable
  • 조금씩 지속적으로 흘려보내기
  • duplex 읽기와 쓰기의 결합 ex) websocket
  • transform 정수기 필터와 비슷한 역할 ex) 암호화 및 복호화
  • 코드 실행의 이면: 하드웨어의 상태 변화
  • 작성 코드 <-> 컴퓨터 내부의 물리적 상태
  • CPU안의 코어, CPU는 두뇌 코어는 요리사 8-16명의 요리사 Multi Core

Promise 방식

// =========================================================================
// 1. 프로미스 기반 비동기 방식 (안전하지만 몹시 느림)
// =========================================================================
async function runPromiseVersion() {
  console.log("▶️ [1. Promise 방식] 시작...");
  console.time("1_Promise_작업시간");

  // 커널에 파일 생성 요청을 보내고 번호표(FileHandle)를 발급받음
  const fileHandle = await fsPromises.open(TARGET_FILE, "w");

  for (let i = 0; i < MAX_ITERATION; i++) {
    // [병목 지점] 매번 디스크에 물리적으로 기록될 때까지 메인 셰프(CPU)가 대기함
    await fileHandle.write(
      `[시스템 로그] 유저 ${i}님의 데이터 처리가 완료되었습니다.\n`,
    );
  }

  await fileHandle.close(); // 사용이 끝난 번호표를 운영체제에 반납 (메모리 누수 방지)

  console.timeEnd("1_Promise_작업시간");
  console.log("✅ 완료! (안전하지만 시간이 너무 오래 걸려 실무 도입 불가)\n");
}

  • main core <-> fs i/o await으로 연결
  • 1개의 로그 쓰기 요청이 완료 될 때까지 대기 sequential
  • 코어는 다른 비동기 이벤트를 처리할 수 있는 non blocking 상태
  • 하지만 for loop의 1만가지 작업을 완료하기 위해 비효율적인 유휴 시간 I/O wait 발생
  • 하나의 core가 100만번의 system call을 발생시킴
  • 메모리는 안정적인 상태를 유지함.

비동기 콜백 방식

function runCallbackVersion() {
  console.log("▶️ [2. Callback 방식] 시작...");
  console.time("2_Callback_작업시간");

  fs.open(TARGET_FILE, "w", (err, fd) => {
    if (err) throw err;

    for (let i = 0; i < MAX_ITERATION; i++) {
      // await가 없으므로 100만 개의 쓰기 요청을 스레드 풀에 한꺼번에 융단 폭격
      // -> 한정된 RAM에 데이터가 순식간에 쌓이며 OOM(메모리 초과) 발생 위험
      // -> 커널 레벨에서 쓰기 순서가 보장되지 않아 로그 데이터 오염 발생
      fs.write(
        fd,
        `[시스템 로그] 유저 ${i}님의 데이터 처리가 완료되었습니다.\n`,
        (err) => {
          if (err) throw err;
        },
      );
    }
    console.log(
      "⚠️ 100만 개의 콜백을 백그라운드에 던졌습니다. (서버 강제 종료 위험)",
    );
  });

  // 파일이 열리기도 전에, 그리고 쓰기가 끝나기도 전에 타이머가 즉시 종료됨 (무의미한 측정)
  console.timeEnd("2_Callback_작업시간");
}

  • 메모리 점유율이 치솟음, js heap memory 초과, OOM으로 서버 사망
  • 식당에 100만명의 손님 한꺼번에 주문을 한 상태, 하지만 주방의 속도를 고려하지 않음
  • race condition이 발생해서 데이터의 무결성을 훼손함

동기방식

function runSyncVersion() {
  console.log("▶️ [3. Sync 방식] 시작...");
  console.time("3_Sync_작업시간");

  // 메인 스레드를 멈춰 세우고(Blocking) 파일 번호표를 받아옴
  const fd = fs.openSync(TARGET_FILE, "w");

  for (let i = 0; i < MAX_ITERATION; i++) {
    // [치명적 문제] 로그가 디스크에 써지는 동안 V8 엔진(메인 스레드)이 그 자리에 얼어붙음
    // -> 이 시간 동안 다른 유저는 웹사이트 접속(API 요청) 불가
    fs.writeSync(
      fd,
      `[시스템 로그] 유저 ${i}님의 데이터 처리가 완료되었습니다.\n`,
    );
  }

  fs.closeSync(fd);

  console.timeEnd("3_Sync_작업시간");
  console.log("✅ 완료! (속도는 빠르나, 실행되는 동안 서버가 완전히 마비됨)\n");
}

  • 메인스레드를 완전히 차단함
  • File descriptor를 대기없이 즉시 반환
  • v8엔진은 정지하고 제어권은 os에 위임

스트림

async function runBadStream() {
  console.log("▶️ [4. Bad Stream 방식] 시작...");
  console.time("4_BadStream_작업시간");

  const fileHandle = await fsPromises.open(TARGET_FILE, "w");
  const stream = fileHandle.createWriteStream(); // 16KB 내부 버퍼 생성

  for (let i = 0; i < MAX_ITERATION; i++) {
    // 디스크가 처리하는 속도를 무시하고 16KB RAM 버퍼에 100만 개를 0.1초 만에 때려 박음
    // 내부적으로 false(버퍼 초과)를 반환하지만 무시하고 강행 -> 메모리 터짐
    stream.write(`[시스템 로그] 유저 ${i}님의 데이터 처리가 완료되었습니다.\n`);
  }
  await fileHandle.close()

  console.timeEnd("4_BadStream_작업시간");
  console.log(
    "⚠️ 완료된 것처럼 보이지만, 사실 커널이 백그라운드에서 힘겹게 옮겨 적는 중입니다. (메모리 폭주)\n",
  );
}

  • await으로 파일을 열고 스트림을 생성 버퍼는 16kb
  • for loop내에서 stream.write를 실행
  • 버퍼는 메모리 적재하는 공간, stream.write 메모리에 data를 push
  • data는 단지 memory에 쏟아 부어졌을 뿐, disk에 적는 작업은 백그라운드에서 실행 중

stream backpressure

async function runOptimalStream() {
  console.log("▶️ [5. Optimal Stream (백프레셔 제어) 방식] 시작...");
  console.time("5_OptimalStream_작업시간");

  // 파일 핸들 획득과 스트림 파이프 생성을 한 번에 깔끔하게 처리 (기본 16KB 버퍼)
  const stream = fs.createWriteStream(TARGET_FILE);

  for (let i = 0; i < MAX_ITERATION; i++) {
    const data = `[시스템 로그] 유저 ${i}님의 데이터 처리가 완료되었습니다.\n`;

    // [핵심 흐름 제어] 버퍼 한계치(16KB)를 초과하여 false가 반환(백프레셔 발생)되면
    if (!stream.write(data)) {
      // 무작정 붓기를 멈추고, 커널이 디스크로 물을 다 빼내어 버퍼가 비워질 때(drain)까지 대기
      await new Promise((resolve) => stream.once("drain", resolve));
    }
  }

  // 100만 번의 쓰기가 끝나면 파이프 입구를 잠금 (더 보낼 데이터가 없음을 커널에 알림)
  stream.end();

  // 파이프에 남은 마지막 잔여 데이터까지 디스크에 안전하게 기록될 때까지 완벽히 대기
  await finished(stream);

  console.timeEnd("5_OptimalStream_작업시간");
  console.log(
    "✅ 완벽하게 완료! (메모리 안전, 메인 스레드 논블로킹, 압도적 속도)\n",
  );
}

  • 완벽한 해결책 조건: 가장 빠르고, 최소 메모리, 논 블로킹
  • 스트림의 back pressure를 제어
  • stream.write의 true/false 반환을 이용해서 제어
  • stream.once 1회만 트리거 되는 event emitter "dreain"신호를 활용
  • 메모리는 폭발하지 않고 일정한 최소수위를 유지, main thread도 차단 안됨

streaming data : abstraction interface

  • 거대한 데이터는 한번에 이동하지 않고 잘게 분할된다.

stream 4 types

writable stream

  • properties, method, event를 가지고 있음
  • highwatermakr에 도달하면 systemcall 발생
  • 16kb를 한꺼번에 밀어내는 flush

readable stream

  • .read() 데이터를 원할 때 가져오는
  • .pause() 일시정지
  • .resume() 재개

duplex

  • 읽기 전용 버퍼 + 쓰기 전용 버퍼
  • 쉼 없는 양방향 소통: web socket

transform

  • input data -> 가공 및 변형 -> 결과물
  • zlib, crypto 모듈