inflearn logo
강의

강의

N
챌린지

챌린지

멘토링

멘토링

N
클립

클립

로드맵

로드맵

지식공유

"AI 딸깍의 시대" 원리로 돌파하는 Node.js와 CS Part 2 - 스트림 아키텍처와 하드웨어 통제기

[강의 노트] 7강: 거대한 댐의 취수 펌프: Readable Stream과 흐름 제어(Flow Control)의 마법

7강 흐름 제어 아키택쳐 코드 순서

해결된 질문

26

Minju Kim

작성한 질문수 2

1

  // 3. 작업 종료 알림: end 이벤트를 통해 댐 바닥 확인
  streamRead.on("end", async () => {
    console.log("\\n✅ [종료] 댐 바닥 도착. 모든 데이터 복사 완료!");
    // 메서드: 더 보낼 물이 없다고 선언하고 파이프 닫기 절차 진입

    // finish 이벤트: 쓰기 파이프가 논리적으로 완벽히 닫혔음을 보장
    streamWrite.on("finish", async () => {
      // 쓰기 파일 디스크립터(번호표) 운영체제에 반환 (메모리 누수 방지)
      await fileHandleWrite.close();
      console.timeEnd("복사_소요_시간");
      console.log(
        `📊 요약: 총 ${chunkCount}회 펌프질, ${pauseCount}회 비상 정지(메모리 방어 성공)`,
      );

      // 메서드: 실험용 임시 파일 삭제 클린업 (unlink)
      await fs.unlink(SOURCE_FILE);
      await fs.unlink(DEST_FILE);
    });
    streamWrite.end();

    // 메서드: 사용이 끝난 읽기 파일 디스크립터(번호표) 운영체제에 반환
    await fileHandleRead.close();

읽기 스트림과 쓰기 스트림을 종료하는 3번 단계에서 쓰기 스트림을 end 하기 전에 .on finish를 등록을 해야 콘솔에서 요약을 볼 수 있었습니다. 클로드는 레이스 컨디션이 발생해서 그렇다고 하는데 어떤 부분에서 경합이 일어났는지 잘 모르겠습니다.

javascript node.js 컴퓨터-구조 frontend backend

답변 1

0

nhcodingstudio

안녕하세요 Minju Kim님,

수업 시간에 다루었던 코드를 복습하며 스트림의 이벤트 생명주기까지 디버깅하신 과정을 확인했습니다. Node.js를 다룰 때 이러한 이벤트 타이밍 이슈를 겪고 원인을 분석해 보는 것은 엔진의 내부 동작 원리를 파악해 나가는 데 필요한 과정입니다.

클로드가 언급한 레이스 컨디션(경쟁 상태)이라는 표현이 처음에는 조금 혼란스러우셨을 수 있습니다. 멀티 스레드 환경에서는 자원 경합을 의미하지만, 싱글 스레드 기반의 Node.js 환경에서 이 용어는 '이벤트 발생 시점'과 '리스너 등록 시점' 간의 타이밍 교차를 뜻합니다. 작성하셨던 코드와 Node.js 코어인 이벤트 루프, 그리고 EventEmitter의 동작 메커니즘을 짚어보며, 왜 .on('finish').end()보다 구조적으로 먼저 등록되어야 하는지 살펴보겠습니다.

문제의 핵심은 EventEmitter의 쏘고 잊어버리는(Fire and Forget) 특성에 있습니다. Node.js의 스트림은 기본적으로 EventEmitter를 상속받아 동작하며, 이 시스템의 규칙은 이벤트가 방출되는 정확한 시점에 등록된 리스너만 실행된다는 점입니다. 이미 지나간 이벤트를 뒤늦게 등록된 리스너가 감지할 수는 없습니다. 만약 쓰기 종료를 선언하는 streamWrite.end()를 먼저 호출하고, 그 이후에 streamWrite.on("finish", ...)를 등록했다고 가정해 보겠습니다.

내부 동작을 들여다보면, streamWrite.end()를 호출했을 때 내부에 아직 처리하지 못한 버퍼가 남아있다면 시스템은 이를 비워낼 때까지 기다렸다가 비동기적으로 finish 이벤트를 발생시킵니다. 이 경우 .end() 호출 이후에 리스너를 등록했더라도 이벤트가 나중에 발생하여 정상적으로 실행될 여지가 있습니다.

하지만 바로 이 시점에서 타이밍 경합이 발생합니다. 만약 .end()를 호출한 시점에 이미 버퍼가 완전히 비워져 있고 운영체제로의 플러시까지 끝난 상태라면, Node.js는 지체 없이 finish 이벤트를 발생시킵니다. 기술적으로 Node.js 내부에서는 이 이벤트를 process.nextTick()을 통해 현재 비동기 작업이 끝난 직후, 즉 다음 틱으로 예약합니다.

따라서 동일한 동기 실행 흐름 안에서 .end() 바로 다음 줄에 .on('finish')를 작성했다면 이벤트를 잡을 수 있습니다. 그러나 .end()를 호출한 직후에 await나 다른 비동기 함수가 개입하여 이벤트 루프가 한 틱을 넘겨버린다면, finish 이벤트가 방출된 직후에야 리스너가 등록되는 현상이 발생합니다. 스트림은 이미 닫혔으므로 이벤트는 다시 오지 않으며, 결과적으로 콘솔 요약도 출력되지 않게 됩니다.

즉, 시스템의 종료 처리 프로세스인 end와 이벤트 리스너 등록인 on 간에 타이밍 차이가 발생했고, 종료 작업이 리스너 등록보다 먼저 완료된 상황입니다. 따라서 이벤트를 촉발하는 액션을 실행하기 전에 리스너를 선언적으로 세팅해 두는 것이 예측 가능한 이벤트 프로그래밍을 위한 원칙입니다.

원인은 정확히 파악하셨습니다. 이에 더해 기존 수업용 코드의 구조도 아키텍처 관점에서 조금 더 다듬어 보겠습니다. 특정 콜백 내부에서 이벤트를 동적으로 등록하기보다는, 스트림 객체 생성 초기 시점에 미리 세팅해 두는 것이 예상치 못한 사이드 이펙트를 막고 시스템의 가독성을 높이는 데 유리합니다.

streamWrite.on("finish", async () => {
  console.timeEnd("복사_소요_시간");
  console.log(`📊 요약: 총 ${chunkCount}회 펌프질, ${pauseCount}회 비상 정지`);
  
  await fileHandleWrite.close();
  await fileHandleRead.close();
  await fs.unlink(SOURCE_FILE);
  await fs.unlink(DEST_FILE);
});

streamWrite.on("error", (err) => {
  console.error("쓰기 중 에러 발생:", err);
});

// ... 데이터 읽기/쓰기 로직 ...

streamRead.on("end", () => {
  console.log("\n✅ [종료] 댐 바닥 도착. 모든 데이터 복사 완료!");
  streamWrite.end(); 
});

위와 같이 streamWrite.on("finish") 내부에서 리소스 정리 작업을 수행하도록 가장 먼저 등록해 둡니다. 혹시 모를 상황을 대비해 error 이벤트 리스너도 함께 준비합니다. 이후 데이터 읽기 및 쓰기 로직이 진행되고, streamRead.on("end") 콜백이 호출되었을 때 streamWrite.end()를 실행합니다. 이렇게 구조치를 잡으면 리스너가 대기하고 있으므로 안전하게 종료를 선언할 수 있습니다.

다만, 이벤트 기반의 스트림 제어를 수동으로 처리하는 방식은 시스템의 원리를 이해하는 데는 의미가 있지만, 프로덕션 환경에서는 리스크가 존재합니다. 직접 이벤트를 제어할 경우 미처 잡지 못한 에러로 인한 메모리 누수나 이번에 겪으신 타이밍 이슈에 노출될 수 있습니다. 따라서 Node.js 공식 문서에서는 스트림 간의 파이핑 및 생명주기를 안전하게 관리하기 위해 stream/promises 모듈의 pipeline 메서드 사용을 권장합니다. 구조적 안정성을 위해 실무에서는 이 접근 방식이 훨씬 적합합니다.

기존 코드에서 수동으로 구현했던 펌프질 횟수나 비상 정지 횟수 같은 모니터링 지표는 데이터 파이프라인 중간에, 수업 후반부에서 다룰 내용인데 이를 적용하면 Transform 스트림을 끼워 넣어 데이터를 감시하고 가공하는 방식으로 유지할 수 있습니다. 아래는 pipeline 구조를 가지면서 모니터링 로직을 결합한 개선안입니다.

import { pipeline } from 'stream/promises';
import { Transform } from 'stream';
import fs from 'fs/promises';

async function copyData() {
  console.time("복사_소요_시간");

  let chunkCount = 0;
  let pauseCount = 0;

  const fileHandleRead = await fs.open(SOURCE_FILE, 'r');
  const fileHandleWrite = await fs.open(DEST_FILE, 'w');

  const streamRead = fileHandleRead.createReadStream();
  const streamWrite = fileHandleWrite.createWriteStream();

  // 1. 데이터를 중간에서 감시하고 카운트할 커스텀 Transform 스트림 생성
  const monitorStream = new Transform({
    transform(chunk, encoding, callback) {
      chunkCount++;
      // 파이프라인 내부에서 백프레셔가 작동하여 읽기가 일시 정지되는 순간을 추적
      if (streamWrite.writableNeedDrain) {
        pauseCount++;
      }
      this.push(chunk);
      callback();
    }
  });

  try {
    // 2. 읽기 -> 모니터링(변환) -> 쓰기 스트림을 하나의 관으로 연결
    await pipeline(streamRead, monitorStream, streamWrite);
    
    console.log("\n✅ [종료] 파이프라인 전송 완료!");
    console.timeEnd("복사_소요_시간");
    console.log(`📊 요약: 총 ${chunkCount}회 펌프질, ${pauseCount}회 비상 정지`);

  } catch (err) {
    console.error("❌ 파이프라인 실행 중 에러 발생:", err);
  } finally {
    // 3. 성공하든 실패하든 리소스는 언제나 안전하게 정리
    await fileHandleRead.close();
    await fileHandleWrite.close();
    await fs.unlink(SOURCE_FILE).catch(() => {});
    await fs.unlink(DEST_FILE).catch(() => {});
  }
}

이렇게 파일 핸들과 스트림을 생성한 후 await pipeline(streamRead, monitorStream, streamWrite)을 호출하면, 시스템이 데이터 전송부터 메모리 방어를 위한 백프레셔 제어, 그리고 에러 발생 시 어느 한쪽 스트림이 열려있지 않도록 리소스 정리까지 관리합니다. monitorStream을 통해 청크 카운트가 기록되며, streamWrite.writableNeedDrain 상태를 점검하여 시스템 내부의 백프레셔로 인한 일시 정지도 추적할 수 있습니다. 에러는 catch 블록에서 잡고, finally 블록에서 파일 핸들과 임시 파일들을 확실하게 정리하는 구조가 탄탄한 시스템을 구축하는 방식입니다.

새롭게 도입한 pipeline 기반의 구조가 수동 제어 방식의 취약점을 어떻게 방어하는지 엔진 관점에서 상세히 살펴보면 크게 세 가지 이점을 확인할 수 있습니다. 첫 번째는 결정론적 이벤트 바인딩으로 레이스 컨디션을 차단한다는 점입니다. pipeline은 내부적으로 전달받은 모든 스트림의 데이터, 종료, 에러 등의 이벤트를 데이터 전송이 본격적으로 시작되기 전에 동기적으로 일괄 세팅합니다. 이를 통해 개발자가 직접 .end()를 호출하고 뒤늦게 .on()을 등록하며 발생했던 이벤트 타이밍 교차, 즉 레이스 컨디션 자체가 원천적으로 발생할 수 없는 닫힌 구조를 제공합니다.

두 번째는 자동화된 리소스 회수를 통해 메모리 누수를 방어한다는 것입니다. 수동 제어 환경에서는 한쪽 스트림에서 에러가 발생해 파이프가 끊어질 경우, 반대쪽 파일 핸들이나 버퍼가 닫히지 않고 메모리를 점유하는 좀비 리소스 문제가 빈번하게 일어납니다. 반면 pipeline은 내부 어느 구간에서든 에러가 감지되면 연결된 모든 스트림 객체에 즉각적으로 파기 명령을 내려 연쇄적으로 메모리를 안전하게 해제합니다.

마지막 세 번째는 안전한 백프레셔 처리의 위임입니다. 읽기 속도가 쓰기 속도를 압도할 때 발생하는 메모리 버퍼 초과 현상을 운영체제와 Node.js 내부 파이프라인 로직에 온전히 맡기게 됩니다. 그 결과 데이터를 수동으로 조작하며 생길 수 있는 논리적 결함을 배제하고 시스템 레벨에서 안정적으로 버퍼를 비워냅니다.

요약하자면, 겪으신 현상은 싱글 스레드 환경 내에서 이벤트 방출 속도가 리스너 등록보다 빨라 발생한 논리적인 레이스 컨디션입니다. 이를 방지하기 위해 이벤트를 발생시키는 액션 이전에 리스너를 먼저 등록하는 구조를 습관화하시기 바랍니다. 나아가 복잡한 예외 상황과 리소스 관리를 내부적으로 안전하게 제어해 주는 pipeline 함수를 적극 활용하는 것을 권장합니다. 스트림 코드를 저수준에서 제어해보며 부딪혔던 경험이 앞으로 복잡한 비동기 아키텍처를 설계할 때 견고한 밑거름이 될 것입니다.

참고해주세요!

유니티 제외 설치한 프로그램들 및 파일 삭제 방법

0

15

1

깃허브에서 콤피유아이 매니저 설치하는게 안됩니다.

0

15

2

진리표를 회로로 변환할 때 F가 0인 경우 don't care

0

26

2

eslint.config.js 설정 질문입니다.

0

26

2

수업자료 어디서 찾아볼 수 있나요?

0

22

1

함수 강의의 정답.. 어떤가요?

0

17

0

깃권한요청드립니다

0

26

1

<div id="banner">배너 이미지</div> 관련 질문

0

26

1

5강, 오류 수정과 관련해서

0

40

2

scanf_s 에 관해서 오류나옵니다.

0

44

3

3,4장 이후 미션 제출 질문

0

37

2

컴퓨터를 껐다가 클로드 코드 다시 키는 방법 알려주세요.

0

33

1

강의자료

0

33

1

자문자답- 맞는지 틀린지 확인부탁드립니다.

0

33

1

윈도우에서 Node js를 설치하고 싶어요

0

34

0

addToFile function에서 path를 사용해 새로운 파일을 생성

0

40

1

컴퓨터구조론에 관해서

0

31

1

메모리 동적할당시 메모리창 빨간 글씨

0

38

2

[46강] EventEmitter를 활용한 10가지 패턴 중 플러그인 아키텍처

0

37

2

강의가 누락된것 같습니다.

0

45

2

섹션3에 대한 문의사항

0

71

2

쿼터스 스케메틱에 대한 질문

0

34

2

examtopics와 krdumps 차이가 나는데요 ㅠ

0

59

1

추가 강의 있으면 좋겠어요.

0

66

2