inflearn logo
강의

강의

N
챌린지

챌린지

멘토링

멘토링

N
클립

클립

로드맵

로드맵

지식공유

"AI 딸깍의 시대" 원리로 돌파하는 Node.js와 CS Part 3: TCP/UDP 소켓과 네트워크 코어

20강. 속도 불균형의 통제: write()의 경고 신호와 drain 이벤트를 이용한 수동 배압 제어

20강 4분 30초 코드와 6분 30초 코드 관련 질문

해결된 질문

34

경원

작성한 질문수 4

0

destinationFileHandle, fileWriteStream을 connection 콜백 안에 선언하는 이유는 이해했는데 초기화를 첫 data 이벤트까지 미루는 이유가 있을까요.

선언만 해두고 나중에 초기화 하는건 undefined 를 나중에 상태 체크때문에 회피하는 편인걸로 알아서

아래 코드처럼 connection 시점에 바로 파일을 열고, 그 뒤에 data 리스너를 등록해도 될거 같아서요.

uploaderServer.on("connection", async (clientSocket) => {
  const destinationFileHandle = await fs.open("./storage/uploaded-video.mp4", "w");
  const fileWriteStream = destinationFileHandle.createWriteStream();
  let receivedBytes = 0;

  clientSocket.on("data", (chunk) => {
    fileWriteStream.write(chunk);
    receivedBytes += chunk.length;
  });
});

javascript node.js 컴퓨터-구조 frontend backend 소프트웨어-설계 인공지능(ai)

답변 1

0

nhcodingstudio

안녕하세요 경원 님! 좋은 질문 남겨주셔서 감사합니다!!

질문해 주신 내용을 보니 단순히 강의 코드를 따라 작성하는 데 그치지 않고, 왜 파일 핸들과 스트림을 첫 번째 data 이벤트에서 초기화하도록 설계했는지, 그리고 더 간결하게 작성할 수 있는 방법은 없는지 고민해 보신 것 같습니다.

특히 connection 콜백 내부에서 파일을 먼저 열고, 준비가 끝난 다음 data 이벤트 리스너를 등록하는 방식을 직접 제안해 주신 점이 좋았습니다. 실제로 이런 고민은 비동기 I/O와 스트림을 다루면서 자연스럽게 마주치는 설계상의 선택이기도 합니다.

먼저 결론부터 말씀드리면, 제안해 주신 방식도 충분히 유효하며, 오히려 현재 강의에서 다루는 단순한 TCP 파일 업로드 구조에서는 더 직관적인 설계가 될 수 있습니다.

강의에서 파일 초기화를 첫 번째 data 이벤트까지 미룬 것은 반드시 그렇게 해야만 하는 기술적인 제약이 있기 때문은 아닙니다.

강의에서는 첫 번째 데이터가 도착했을 때 파일을 준비하는 과정과, 그 이후 디스크의 쓰기 속도가 네트워크의 수신 속도를 따라가지 못할 때 발생하는 백프레셔를 구분해 살펴보려는 의도가 있었습니다.

즉, 처음에는 파일이 준비되지 않았기 때문에 pause()로 수신을 멈추고, 파일을 연 다음 resume()으로 다시 시작합니다. 이후에는 write()의 반환값이 false일 때 수신을 멈추고, drain 이벤트가 발생하면 다시 시작하는 구조입니다.

이 두 상황에서 pause()와 resume()이 어떻게 사용되는지를 보여주기 위해 초기화 과정을 첫 번째 data 이벤트 내부에 배치했습니다.

다만 여기에는 분명한 단점도 있습니다.

파일을 처음 여는 과정과 실제 데이터를 기록하는 과정이 하나의 이벤트 콜백에 섞이면서 조건 분기가 늘어나고, 첫 번째 데이터 조각만 별도로 처리해야 합니다. 그래서 코드를 읽는 입장에서는 오히려 전체적인 흐름을 파악하기 어려울 수 있습니다.

반면 제안해 주신 방식은 연결이 수립된 이후 파일을 준비하고, 준비가 끝나면 데이터를 처리한다는 순서가 명확합니다.

uploaderServer.on("connection", async (clientSocket) => {
  const destinationFileHandle = await fs.open(
    "./storage/uploaded-video.mp4",
    "w"
  );

  const fileWriteStream =
    destinationFileHandle.createWriteStream();

  let receivedBytes = 0;

  clientSocket.on("data", (chunk) => {
    fileWriteStream.write(chunk);
    receivedBytes += chunk.length;
  });
});

여기서 중요한 부분은 await fs.open()이 완료된 다음 data 리스너를 등록한다는 점입니다.

Node.js의 소켓은 기본적으로 읽기 흐름이 일시 정지된 상태에서 시작하며, 일반적으로 data 리스너를 등록하면 흐름 모드(Flowing Mode)로 전환됩니다.

따라서 위 코드처럼 파일을 먼저 열고, 이후 data 리스너를 등록하면 파일이 준비되기 전에 data 콜백이 실행되는 상황을 피할 수 있습니다.

이 부분은 수강생님께서 정확하게 짚어주셨습니다.

다만 여기서 한 가지 구분하면 좋은 부분이 있습니다. data 리스너가 없다는 것이 네트워크 데이터 자체가 도착하지 않는다는 의미는 아닙니다.

파일을 여는 동안에도 클라이언트는 데이터를 보낼 수 있고, 운영체제의 TCP 수신 버퍼 등에 데이터가 쌓일 수 있습니다. Node.js가 데이터를 애플리케이션에 전달하는 흐름과 실제 네트워크에서 데이터가 들어오는 흐름은 완전히 동일하지 않습니다.

따라서 리스너를 나중에 등록하는 방식은 파일 초기화 이전에 데이터 처리 콜백이 실행되는 문제를 해결해 주지만, 네트워크 수신 자체를 중단시키는 방식은 아닙니다.

이 차이를 이해해 두시면 이후 TCP의 흐름 제어나 백프레셔를 공부하실 때도 도움이 될 것 같습니다.

또한 질문에서 말씀하신 undefined에 대한 이해도 조금 보충해 드리고 싶습니다.

let destinationFileHandle;처럼 선언만 해두면 초기값이 undefined가 되는 것은 맞습니다.

다만 이것은 단순히 나중에 상태를 체크하기 위한 관례라기보다는, 비동기 작업의 진행 상황에 따라 아직 존재하지 않는 자원을 나중에 할당해야 하기 때문이라고 이해하시면 조금 더 정확합니다.

예를 들어 강의 코드에서는 connection 콜백 안에서 파일 핸들을 선언하지만, 실제 파일 열기는 첫 번째 data 이벤트에서 수행합니다. 이때 서로 다른 콜백에서 같은 변수를 참조해야 하므로 외부에 let으로 선언해 둔 것입니다.

반대로 제안해 주신 코드에서는 파일 열기를 먼저 완료하므로 const를 사용할 수 있고, 별도로 초기화 여부를 검사할 필요도 없습니다.

실무에서도 한 번 생성된 이후 다른 객체로 재할당할 필요가 없는 자원이라면 const를 사용하는 편이 코드의 의도를 전달하는 데 도움이 됩니다.

다만 여기서 한 가지 더 생각해 볼 부분은 파일을 언제 생성할 것인지에 따라 서버의 동작 자체가 달라진다는 점입니다.

첫 번째 data 이벤트에서 파일을 열면 실제 데이터가 도착한 연결에 대해서만 파일을 생성합니다.

반면 connection 시점에 파일을 열면 데이터를 전혀 보내지 않는 클라이언트가 접속하더라도 파일이 생성되거나 기존 파일이 초기화될 수 있습니다.

특히 예제의 "w" 옵션은 기존 파일이 있으면 내용을 비우는 방식이기 때문에, 여러 클라이언트가 동시에 연결하는 상황에서는 주의가 필요합니다.

이것은 어느 방식이 절대적으로 옳다는 문제라기보다는, 서버에서 파일을 생성하는 시점을 어떤 이벤트에 연결할 것인지에 대한 설계상의 선택입니다.

실제 서비스라면 클라이언트의 연결이 수립되었다는 사실만으로 저장 파일을 생성하기보다는, 업로드 요청이 유효한지 확인한 다음 파일을 준비하도록 설계할 수도 있습니다.

따라서 데이터 수신 전에 파일을 준비하는 구조와, 실제 업로드가 확인된 뒤에 파일을 생성하는 구조는 각각의 장단점이 있다고 보시면 좋겠습니다.

그리고 제안해 주신 코드를 실제 대용량 업로드에 사용하려면 한 가지는 반드시 보완하는 것이 좋겠습니다.

바로 fileWriteStream.write(chunk)의 반환값입니다.

현재 코드는 들어온 데이터를 그대로 기록하지만, 디스크의 쓰기 속도가 네트워크 수신 속도보다 느려지는 상황을 고려하지는 않습니다.

이번 20강에서 다루는 핵심도 바로 이 부분이므로, 제안해 주신 초기화 방식을 유지하면서 다음과 같이 작성해 볼 수 있습니다.

uploaderServer.on("connection", async (clientSocket) => {
  // 파일을 준비하는 동안 소켓의 읽기 흐름을 정지
  clientSocket.pause();

  try {
    const destinationFileHandle = await fs.open(
      "./storage/uploaded-video.mp4",
      "w"
    );

    const fileWriteStream =
      destinationFileHandle.createWriteStream();

    let receivedBytes = 0;

    // 디스크 쓰기 버퍼가 비워지면 수신 재개
    fileWriteStream.on("drain", () => {
      clientSocket.resume();
    });

    // 데이터 수신 및 백프레셔 제어
    clientSocket.on("data", (chunk) => {
      receivedBytes += chunk.length;

      if (!fileWriteStream.write(chunk)) {
        clientSocket.pause();
      }
    });

    // 파일 스트림 준비 및 리스너 등록 후 수신 재개
    if (!clientSocket.destroyed) {
      clientSocket.resume();
    }

    // 실제 서비스에서는 종료 및 오류 처리도 별도로 필요합니다.
  } catch (error) {
    console.error("파일 초기화 실패:", error);
    clientSocket.destroy();
  }
});

이 코드는 설명을 위해 종료 처리와 일부 오류 처리를 생략한 예시입니다. 다만 기존 강의 코드보다 파일 초기화와 데이터 처리의 책임이 명확하게 구분되어 있다는 장점이 있습니다.

여기서 clientSocket.pause()는 파일 초기화가 끝나기 전 수신 흐름을 명시적으로 멈추기 위한 것이고, fileWriteStream.write(chunk)가 false를 반환했을 때 호출하는 pause()는 쓰기 버퍼의 적체를 방지하기 위한 것입니다.

같은 메서드이지만 사용 목적이 조금 다릅니다.

특히 첫 번째 데이터 조각에도 write()의 반환값을 검사한다는 점이 중요합니다. 강의 예제에서는 첫 번째 조각을 별도로 처리하면서 해당 반환값을 확인하지 않았는데, 엄밀하게는 첫 번째 write()에서도 백프레셔가 발생할 수 있습니다.

따라서 이 부분은 강의 코드에서도 보완하는 것이 더 정확하겠습니다.

한 가지 실무적인 팁을 더 말씀드리면, 파일 업로드에서는 데이터를 수신하는 것뿐만 아니라 파일을 정상적으로 끝까지 기록했는지 확인하는 과정도 중요합니다.

예를 들어 clientSocket의 end 이벤트가 발생했다고 해서 파일에 대한 모든 쓰기 작업까지 완료되었다고 단정할 수는 없습니다.

네트워크에서 데이터를 모두 받았다는 것과 파일 쓰기 스트림이 정상적으로 종료되었다는 것은 서로 다른 단계이기 때문입니다.

따라서 실무에서는 fileWriteStream.end()로 쓰기 종료를 요청하고, 스트림의 finish 또는 완료를 기다리는 적절한 방법으로 쓰기 완료를 확인합니다. 아울러 error, close 이벤트를 통해 클라이언트가 중간에 연결을 끊거나 파일 쓰기 작업이 실패했을 때도 자원이 정상적으로 정리되도록 처리하는 것이 중요합니다.

파일 핸들 역시 createWriteStream()의 자동 닫기 동작과 연결되어 있으므로, 스트림이 관리하는 파일 디스크립터를 언제 닫을 것인지 명확하게 정리하는 편이 안전합니다. 별도의 파일 핸들 정리와 스트림의 종료 작업을 무작정 중복해서 수행하는 것은 피하는 것이 좋습니다.

이러한 종료 및 오류 처리까지 포함한다면 현재 코드보다 조금 더 많은 로직이 필요하지만, 질문해 주신 파일 초기화 방식 자체는 좋은 접근이라고 생각합니다.

이번 질문에서 특히 인상적이었던 점은 let과 const의 문법적인 차이를 넘어, 비동기 자원의 초기화 시점과 이벤트 리스너 등록 순서가 실제 시스템 동작에 어떤 영향을 주는지 고민하셨다는 것입니다.

이런 부분은 네트워크 프로그래밍에서 상당히 중요한 관점입니다.

강의에서 작성한 코드는 pause(), resume(), write(), drain의 동작을 직접 관찰하기 위한 학습용 구성이고, 실제 코드에서는 제안해 주신 방식처럼 초기화 단계를 분리하거나 다음 강의에서 살펴볼 pipe() 같은 기능을 활용해 흐름 제어를 단순화할 수 있습니다.

무엇보다 이번 질문 덕분에 강의 예제에서 첫 번째 데이터 조각의 백프레셔 처리와 파일 종료 및 자원 정리 부분을 조금 더 명확하게 보완할 필요가 있다는 점도 다시 확인하게 되었습니다. 해당 내용은 강의 노트에도 추가 설명을 반영하겠습니다.

좋은 질문 남겨주셔서 다시 한번 감사드립니다. 앞으로도 강의 코드에서 더 간결하거나 안전하게 작성할 수 있는 부분이 보인다면 편하게 의견 남겨주세요. 저도 함께 검토하면서 더 좋은 방향으로 강의 내용을 보완해 나가겠습니다 :D

56강 프로젝트 초기화 커밋 시 main이 아니라 master라는 칩이 붙습니다.

0

3

1

강의 자료

0

8

1

오랜만에 인사드립니다 강사님

0

9

1

awesome mcp

0

17

1

강의신청 후

0

19

1

강의 교안이 수업과 다릅니다.

0

11

1

강의할때 문서? 텍스트 있는거 어디서 받아요?

0

16

1

[Himalaya 네이버 메일 연동] GPG 캐시 만료 시 Hermes에서 인증 실패하는 문제 해결 방법 문의

0

24

0

VSCODE Extension Claude와 터미널 차이

0

24

0

URL 안내 없이 휙휙넘어가니깐 따라가기가 어려워요

0

19

0

claude-nextjs-starterkit에서 .claude폴더 생성

0

24

1

사용자설정에 claude 설정들은 어떻게 하나요??

0

35

1

윈도우 환경에서는 진행이 어렵나요?

0

44

1

Claude Code 자동 Update 활성화 방법은?

0

17

1

사이트 로그인이 안됩니다

1

20

1

화질 문의

0

32

1

JpaPagingItemReader는 좋은게 없는거야..??

0

28

1

강의 문의

0

19

0

커밋하기전.. 문의

0

28

1

Part2 수강 방법

0

35

1

강의중에 언급되는 스타트 키트 어디있나요?

0

35

1

안티그래비티 말고 VSCODE로 해도 될까요

0

30

1

영상에서 소개되는 코드와 강의 노트 코드가 달라요

0

43

1

섹션5-Unit5.7 화면 자동화 문의

0

48

1