웹 프로그래밍

웹 서버 기초 (#2. Promises와 Events, 그리고 Async/Await)

2025-12-08

논의: 이벤트 루프와 동시성

  • JS 코드는 이벤트 루프 내에서 실행, 에코 서버에서 무언가를 하려면 콜백이 필요하며, 이것이 이벤트 루프가 작동하는 방식입
  • 이는 프로그래머에게는 보이지 않는 Node.js 런타임의 메커니즘
// 의사 코드(pseudo code)!, 런타임은 다음과 유사하게 동작
while (running) {
    let events = wait_for_events(); // blocking
    for (let e of events) {
        do_something(e);    // may invoke callbacks
    }
}

1. 이벤트 루프의 동작 방식

  • 런타임은 OS로부터 새로운 연결, 소켓 읽기, 또는 타이머와 같은 I/O 이벤트폴링(poll)
  • 런타임은 이벤트에 반응하여 개발자가 등록한 콜백(callback)을 호출
  • 모든 이벤트가 처리된 후 이 과정이 반복 \(\rightarrow\) 이벤트 루프(event loop)라고 부름

2. JS 코드와 단일 스레드

  • JS 코드와 런타임은 단일 OS 스레드를 공유하기 때문에 이벤트 루프는 단일 스레드에서 작동
  • 실행은 런타임 코드 또는 JS 코드(콜백 또는 메인 프로그램) 중 하나에서 이루어짐
  • 다일 스레드에서 이러한 기능이 작동하는 이유는, 콜백이 반환되거나 await될 때 제어권이 런타임으로 돌아가, 런타임이 이벤트를 발생시키고 다른 작업을 스케줄링할 수 있기 때문임
  • JS 코드를 실행할 때 이벤트 루프가 멈추기 때문에, 모든 JS 코드는 짧은 시간 안에 끝날 것으로 예상

3. Node.js의 동시성은 이벤트 기반

  • 서버는 동시에 여러 연결을 가질 수 있으며, 각 연결은 이벤트를 발생시킬 수 있음
  • 이벤트 핸들러가 실행되는 동안, 단일 스레드 런타임은 해당 핸들러가 반환될 때까지 다른 연결을 위해 아무것도 할 수 없음
  • 이벤트 처리하는 시간이 길어질수록 다른 모든 것이 지연됨

이벤트 루프에 너무 오래 머무는 것을 피하는 것이 중요

4. 비동기(Asynchronous) vs. 동기(Synchronous)

  • CPU 집약적인 코드는 다음과 같은 방법으로 해결할 수 있음
    • 자발적으로 런타임에 제어권을 양보
    • 멀티스레딩/멀티프로세싱을 통해 CPU 집약적인 코드를 이벤트 루프 밖으로 이동

5. 블로킹 & 논블로킹 I/O

  • OS는 네트워크 I/O를 위해 블로킹 모드(blocking mode)논블로킹 모드(non-blocking mode)를 모두 제공
    • 블로킹 모드에서는 호출하는 OS 스레드가 결과가 준비될 때까지 블로킹됨
    • 논블로킹 모드에서는 결과가 준비되지 않았거나 (또는 준비되었을 때) OS가 즉시 반환하며, (이벤트 루프를 위해) 준비 상태를 통지받는 방법이 있음

6. Node.js의 I/O는 비동기적

  • Node.js 런타임은 논블로킹 모드를 사용, 왜냐하면 블로킹 모드는 이벤트 기반 동시성과 호환되지 않기 때문임
  • 이벤트 루프에서 유일한 블로킹 작업은 할 일이 없을 때 OS에 더 많은 이벤트를 폴링하는 것

7. 콜백과 프로미스

I/O와 관련된 대부분의 Node.js 라이브러리 함수는 콜백 기반이거나 프로미스(Promise) 기반임

  • 프로미스는 콜백을 관리하는 또 다른 방법(또한 비동기적(asynchronous) 방식) \(\rightarrow\) 결과가 콜백을 통해 전달
  • 프로미스는 이벤트 루프를 블로킹하지 않음 \(\rightarrow\) JS 코드는 결과를 기다리지 않고 런타임으로 반환, 결과가 준비되면 런타임이 콜백을 호출하여 프로그램을 계속 진행시킴
  • 그 반대는 동기(synchronous) API이며, 이는 결과를 기다리기 위해 OS 스레드를 블로킹

8. 프로미스, 콜백 그리고 동기 예제

// promise
filehandle.read([options]);

// callback
fs.read(fd[, options], callback);

// synchronous, do not use!
fs.readSync(fd, buffer[, options]);

동기 API는 이벤트 루프를 블로킹하기 때문에 네트워크 애플리케이션에서 사용해서는 안 됨, 그럼에도 동기 방식이 존재하는 이유는 일부 간단한 사용 사례(스크립팅 등)를 위해 존재함

9. 이벤트 기반 프로그래밍

네트워킹을 넘어서는 이벤트 기반 프로그래밍

I/O는 디스크 파일과 네트워킹 이상의 것으로 GUI 시스템에서는 마우스와 키보드로부터의 사용자 입력 또한 I/O 그리고 이벤트 루프는 Node.js 런타임에만 국한되지 않음, 웹 브라우저와 다른 모든 GUI 애플리케이션도 내부적으로 이벤트 루프를 사용함, GUI 프로그래밍 경험을 네트워크 프로그래밍에, 또는 그 반대로 활용할 수 있음

논의: 프로미스(Promise) 기반 I/O

  • I/O 코드를 작성하는 또 다른 스타일, 대안적인 스타일은 콜백 대신 프로미스를 사용함, 프로미스 기반 API의 장점은 그것들을 await하고 결과를 얻을 수 있다는 것 \(\rightarrow\) 프로그램을 여기저기 흩어진 작은 콜백들로 나누는 것을 피할 수 있음
// pseudo code!
while (running) {
    let socket = await server.accept();
    newConn(socket);    // no `await` on this
}

1. read와 write 기본 요소에 대한 가상적인 API

// pseudo code!
async function newConn(socket) {
    while (true) {
        let data = await socket.read();
        if (!data) {
            break;  // EOF
        }
        await socket.write(data);
    }
}

2. 콜프로미스(Promises)와 이벤트(Events)

먼저 async/await와 JS의 Promise 타입에 익숙하지 않은 분들을 위해 간단히 차이점을 소개

  • 콜백(Callbacks) 사용하기, 콜백 기반 API의 예시, 애플리케이션 로직은 콜백 함수에서 계속 이어짐
function my_app() {
    do_something_cb((err, result) => {
        if (err) {
          // fail
        } else {
          // succuss, use the result
        }
    });
}
  • 프로미스에 await를 사용하는 예시, 애플리케이션 로직이 동일한 async 함수 내에서 계속됨 \(\rightarrow\) 애플리케이션 로직이 여러 함수로 나뉘지 않기 때문에, 코드의 응집성이 높음
function do_something_prmoise(): Promise<T>;

async function my_app() {
    try {
        const result: T = await do_something_prmoise();
    } catch (err) {
        // fail.
    }
}
  • 프로미스를 생성, 콜백 기반 API를 프로미스 기반으로 변환함
function do_something_prmoise() {
    return new Promise<T>((resolve, reject) => {
        do_something_cb((err, result) => {
            if (err) {
                reject(err);
            } else {
                resolve(result);
            }
        });
    });
}                

3. 콜백은 피할 수 없음

콜백은 JS에서 피할 수 없음

  • 프로미스 객체를 생성할 때, 실행자(executor) 콜백이 인자로 전달되어 두 개의 콜백을 추가로 받음
    • resolve(): await 구문이 값을 반환하게 함
    • reject(): await 구문이 예외를 던지게 함
  • 결과가 준비되거나 작업이 실패했을 때 둘 중 하나를 반드시 호출해야 함

4. 프로미스 관련 용어

  • 이행됨 (Fulfilled): resolve()가 호출된 상태
  • 거부됨 (Rejected): reject()가 호출된 상태
  • 처리됨 (Settled): 이행되거나 거부된 상태
  • 대기 중 (Pending): 아직 처리되지 않은 상태

5. asyncawait 이해하기

일반 함수는 return으로 런타임에 제어권을 넘김

  • JS 함수에는 일반 함수와 async 함수, 두 가지 종류가 있음
    • 일반 함수는 시작부터 (명시적이든 암묵적이든) return 할 때까지 실행, JS 런타임은 단일 스레드이며 이벤트 기반이므로, JS에서 블로킹(blocking) I/O를 수행할 수 없기 때문에 I/O 완료에 대한 콜백을 등록하고 JS 코드를 종료함
    • 런타임으로 돌아오면, 런타임은 이벤트를 폴링(poll)하고 콜백을 호출할 수 있는데, 이것이 바로 우리가 앞에서 이야기했던 이벤트 루프임

async 함수는 await으로 런타임에 제어권을 넘김

  • 초기에 Promise 타입은 단순히 콜백을 관리하는 방법이었음 \(\rightarrow\) 이는 너무 많은 중첩 함수 없이 여러 콜백을 연결(chaining)할 수 있게 해 주었음
  • 하지만 async 함수가 추가되었기 때문에, 우리는 프로미스의 이러한 사용법에 대해서는 신경 쓰지 않을 것임
  • 일반 함수와 달리, async 함수는 실행 도중에 런타임으로 돌아갈 수 있음 \(\rightarrow\) 이는 프로미스에 await 구문을 사용할 때 발생 그리고 프로미스가 처리(settled)되면, 프로미스의 결과와 함께 async 함수의 실행이 재개됨, 이는 콜백에 의해 중단되지 않고 동일한 함수 내에서 순차적인 I/O 코드를 작성할 수 있기 때문에 훨씬 뛰어난 코딩 경험을 제공함

async 함수 호출은 새로운 태스크(Task)를 시작함

  • async 함수를 호출하면, 해당 async 함수가 반환하거나 예외를 던질 때 스스로 처리(settle)되는 프로미스가 생성됨 \(\rightarrow\) 일반 프로미스처럼 await 할 수 있지만, 만약 그렇게 하지 않더라도 async 함수는 여전히 런타임에 의해 스케줄링됨
  • 이는 멀티스레드 프로그래밍에서 스레드를 시작하는 것과 유사함, 하지만 모든 JS 코드는 단일 OS 스레드를 공유하므로, ’태스크(task)’라는 단어를 사용하는 것이 더 적절함

실습: 이벤트에서 async로 변환하기

  • net 모듈은 프로미스 기반 API를 제공하지 않으므로, 직접 구현해야 함
function soRead(conn: TCPConn): Promise<Buffer>;
function soWrite(conn: TCPConn, data: Buffer): Promise<void>;

1단계: 해결책 분석하기

  • soRead 함수는 소켓 데이터로 이행(resolve)되는 프로미스를 반환함, 이는 세 가지 이벤트에 의존함
    • data 이벤트는 프로미스를 이행시킴, 소켓을 읽는 동안 EOF(End-Of-File)가 발생했는지도 알아야 함
    • end 이벤트 또한 프로미스를 이행시킴, EOF를 나타내는 일반적인 방법은 길이가 0인 데이터를 반환하는 것
    • error 이벤트가 발생했을 때 프로미스를 거부(reject)해야 함, 그렇지 않으면 프로미스는 영원히 대기 상태에 머무르게 됨

2단계: 프로미스 기반 API 구현하기

  • 이러한 이벤트들로부터 프로미스를 이행하거나 거부하려면, 프로미스는 어딘가에 저장되어야 함
  • 이를 위해 TCPConn 래퍼(wrapper) 객체를 만들어야 함 \(\rightarrow\) 프로미스의 resolvereject 콜백은 TCPConn.reader 필드에 저장됨
// TCP 소켓을 위한 프로미스 기반 API
type TCPConn = {
  // JS 소켓 객체
  socket: net.Socket;
  // 현재 읽기 작업의 프로미스 콜백들
  reader: null | {
    resolve: (value: Buffer) => void,
    reject: (reason: Error) => void,
  };
};

3단계: ‘data’ 이벤트 처리하기

  • data 이벤트는 데이터가 도착할 때마다 발생하지만, 프로미스는 프로그램이 소켓에서 데이터를 읽으려고 할 때만 존재함 \(\rightarrow\) data 이벤트가 발생할 준비가 되는 시점을 제어할 방법이 있어야 함
socket.pause(). // 'data' 이벤트를 일시 중지
socket.resume(); // 'data' 이벤트를 재개

이 지식을 바탕으로 이제 soRead 함수를 구현할 수 있음

4단계: soRead 함수 구현하기

// net.Socket으로부터 래퍼 생성
function soInit(socket: net.Socket): TCPConn {
  const conn: TCPConn = {
    socket: socket, reader: null,
  };
  socket.on('data', (data: Buffer) => {
    console.assert(conn.reader);
    // 다음 읽기까지 'data' 이벤트를 일시 중지
    conn.socket.pause();
    // 현재 읽기 작업의 프로미스를 이행
    conn.reader!.resolve(data);
    conn.reader = null;
  });
  return conn;
}

function soRead(conn: TCPConn): Promise<Buffer> {
  console.assert(!conn.reader); // 동시 호출 없음
  return new Promise((resolve, reject) => {
    // 프로미스 콜백 저장
    conn.reader = {resolve: resolve, reject: reject};
    // 그리고 'data' 이벤트를 재개하여 나중에 프로미스를 이행하도록 함
    conn.socket.resume();
  });
}

5단계: TCP 소켓에서 ‘end’ 이벤트 처리하기

  • data 이벤트는 우리가 소켓을 읽을 때까지 일시 중지되므로, 소켓은 생성된 후 기본적으로 일시 중지 상태여야 함, 이를 위한 플래그가 있음
const server = net.createServer({
  pauseOnConnect: true, // 'TCPConn'에 의해 요구됨
});

6단계: ‘end’와 ’error’ 이벤트 처리하기

  • data 이벤트와 달리 enderror 이벤트는 일시 중지할 수 없으며 발생하는 즉시 방출됨, 이 이벤트들을 래퍼 객체에 저장하고 soRead에서 확인하는 방식으로 이를 처리할 수 있음
// TCP 소켓을 위한 프로미스 기반 API
type TCPConn = {
  // JS 소켓 객체
  socket: net.Socket;
  // 'error' 이벤트로부터
  err: null | Error;
  // EOF, 'end' 이벤트로부터
  ended: boolean;
  // 현재 읽기 작업의 프로미스 콜백들
  reader: null | {
    resolve: (value: Buffer) => void,
    reject: (reason: Error) => void,
  };
};
  • 만약 현재 reader 프로미스가 있다면, 그것을 이행하거나 거부함
// net.Socket으로부터 래퍼 생성
function soInit(socket: net.Socket): TCPConn {
  const conn: TCPConn = {
    socket: socket, err: null, ended: false, reader: null,
  };
  socket.on('data', (data: Buffer) => {
    // 생략
  });
  socket.on('end', () => {
    // 이 또한 현재 읽기 작업을 이행
    conn.ended = true;
    if (conn.reader) {
      conn.reader.resolve(Buffer.from('')); // EOF
      conn.reader = null;
    }
  });
  socket.on('error', (err: Error) => {
  // 오류 또한 현재 읽기 작업으로 전달됨
    conn.err = err;
    if (conn.reader) {
      conn.reader.reject(err);
      conn.reader = null;
    }
  });
  return conn;
}
  • soRead가 호출되기 전에 발생한 이벤트들은 저장되었다가 확인됨
// EOF 후에는 빈 'Buffer'를 반환
function soRead(conn: TCPConn): Promise<Buffer> {
  console.assert(!conn.reader); // 동시 호출 없음
  return new Promise((resolve, reject) => {
    // 연결이 읽기 불가능한 상태라면, 즉시 프로미스를 완료
    if (conn.err) {
      reject(conn.err);
      return;
    }
    if (conn.ended) {
      resolve(Buffer.from('')); // EOF
      return;
    }
    // 프로미스 콜백 저장
    conn.reader = {resolve: resolve, reject: reject};
    // 그리고 'data' 이벤트를 재개하여 나중에 프로미스를 이행하도록 함
    conn.socket.resume();
  });
}

7단계: 소켓에 쓰기

  • socket.write 메서드는 쓰기 완료를 알리는 콜백을 받으므로, 프로미스로의 변환은 간단함
function soWrite(conn: TCPConn, data: Buffer): Promise<void> {
  console.assert(data.length > 0);
  return new Promise((resolve, reject) => {
    if (conn.err) {
      reject(conn.err);
      return;
    }
    conn.socket.write(data, (err?: Error) => {
      if (err) {
        reject(err);
      } else {
        resolve();
      }
    });
  });
}
  • Node.js 문서에는 이 작업을 위해 사용할 수 있는 'drain' 이벤트도 있음
  • Node.js 라이브러리는 종종 같은 일을 할 수 있는 여러 방법을 제공하므로, 하나를 선택하고 나머지는 무시하면 됨

8단계: asyncawait 사용하기

  • 프로미스 기반 API에 await를 사용하기 위해, 새 연결을 위한 핸들러(newConn)는 async 함수가 됨
async function newConn(socket: net.Socket): Promise<void> {
  console.log('new connection', socket.remoteAddress, socket.remotePort);
  try {
    await serveClient(socket);
  } catch (exc) {
    console.error('exception:', exc);
    socket.destroy();
  } finally {
    // ...
  }
}
  • await 구문은 거부(rejected)될 때 예외를 던질 수 있으므로, 우리 코드를 try-catch 블록으로 감쌈
  • 실제 프로덕션 코드에서는 모든 예외를 잡는 핸들러를 사용하기보다 실제로 오류를 처리하기를 원할 것임
// 에코 서버
async function serveClient(socket: net.Socket): Promise<void> {
  const conn: TCPConn = soInit(socket);
  while (true) {
    const data = await soRead(conn);
    if (data.length === 0) {
      console.log('end connection');
      break;
    }
    console.log('data', data);
    await soWrite(conn, data);
  }
}

전체 코드

import * as net from "net";

type TCPConn = {
    socket: net.Socket;
    err: null | Error;
    ended: boolean;
    reader: null | {
        resolve: (value: Buffer) => void,
        reject: (value: Error) => void
    };
};

function soInit(socket: net.Socket): TCPConn {
    const conn: TCPConn = {
        socket: socket, err: null, ended: false, reader: null,
    };
    socket.on('data', (data: Buffer) => {
        console.assert(conn.reader);
        conn.socket.pause();
        conn.reader!.resolve(data);
        conn.reader = null;
    });
    socket.on('end', () => {
        conn.ended = true;
        if (conn.reader) {
            conn.reader.resolve(Buffer.from(''));
            conn.reader = null;
        }
    });
    socket.on('error', (err: Error) => {
        conn.err = err;
        if (conn.reader) {
            conn.reader.reject(err);
            conn.reader = null;
        }
    });
    return conn
}

function soRead(conn: TCPConn): Promise<Buffer> {
    console.assert(!conn.reader)
    return new Promise((resolve, reject) => {
        if (conn.err) {
            reject(conn.err);
            return;
        }
        if (conn.ended) {
            resolve(Buffer.from(''))
            return;
        }
        conn.reader = { resolve: resolve, reject: reject };
        conn.socket.resume();
    });
}

function soWrite(conn: TCPConn, data: Buffer): Promise<void> {
    console.assert(data.length > 0);
    return new Promise((resolve, reject) => {
        if (conn.err) {
            reject(conn.err);
            return;
        }
        conn.socket.write(data, (err?: Error | null) => {
            if (err) {
                reject(err)
            } else {
                resolve();
            }
        });
    });
}

async function newConn(socket: net.Socket): Promise<void> {
    console.log('new connection', socket.remoteAddress, socket.remotePort);
    try {
        await serveClient(socket);
    } catch (exc) {
        console.error('exception', exc);
    } finally {
        socket.destroy();
    }
}

async function serveClient(socket: net.Socket): Promise<void> {
    const conn: TCPConn = soInit(socket);
    while (true) {
        const data = await soRead(conn);
        if (data.length === 0) {
            console.log('end connection');
            break;
        }
        console.log('writing data', data);
        await soWrite(conn, data);
    }
}

const server = net.createServer({
    pauseOnConnect: true
});

server.listen({ host: '127.0.0.1', port: 12345 })
server.on('connection', newConn);

토론: 백프레셔(Backpressure)

소켓 쓰기 완료를 기다려야 하는가? 새로운 에코 서버에는 이제 socket.write()가 완료되기를 기다린다는 큰 차이점이 있습니다. 그런데 “쓰기 완료”란 무엇을 의미할까요. 그리고 왜 우리는 그것을 기다려야 할까요.

  • socket.write()는 데이터가 OS에 제출되었을 때 완료
  • 하지만 “왜 데이터가 OS에 즉시 제출될 수 없는가?”라는 새로운 질문이 생김
  • 이 질문은 사실 네트워크 프로그래밍 자체보다 더 깊은 곳까지 파고듬

생산자는 소비자에 의해 병목 현상을 겪는다

  • 비동기 통신이 있는 곳에는 어디든 생산자와 소비자를 연결하는 큐(queue)버퍼(buffer)가 있음
  • 물리적 세계의 큐와 버퍼는 크기가 제한되어 있으며 무한한 양의 데이터를 담을 수 없음
  • 비동기 통신의 한 가지 문제는 생산자는 소비자가 소비하는 것보다 더 빨리 생산할 때 어떤 일이 발생하는가?
  • 큐나 버퍼가 오버플로우되는 것을 방지하는 메커니즘이 있어야 함

TCP에서의 백프레셔: 흐름 제어(Flow Control)

  • 이 메커니즘은 네트워크 애플리케이션에서 종종 백프레셔(backpressure)라고 불리며, TCP에서의 백프레셔는 흐름 제어(flow control)로 알려져 있음
  • 소비자의 TCP 스택은 들어오는 데이터를 수신 버퍼에 저장하여 애플리케이션이 소비할 수 있음, 생산자의 TCP 스택이 보낼 수 있는 데이터의 양은 생산자의 TCP 스택에 알려진 윈도우(window)에 의해 제한되며, 윈도우가 가득 차면 데이터 전송을 일시 중지함
  • 흐름 제어의 효과: TCP는 전송을 일시 중지하고 재개할 수 있으므로 소비자의 수신 버퍼 크기는 제한
  • TCP 흐름 제어는 윈도우를 제어하는 또 다른 메커니즘인 TCP 혼잡 제어(congestion control)와 혼동되어서는 안 됨
                           flow ctrl    bounded!
|producer| ==> |send buf| ===========> |recv buf| ==> |consumer|
    app            OS         TCP          OS            app

애플리케이션과 OS 사이의 백프레셔

  • 이 멋진 메커니즘은 TCP뿐만 아니라 애플리케이션에도 구현되어야 함(생산자 측에 집중해 봄)
  • 애플리케이션은 데이터를 생산하여 OS에 제출하고, 데이터는 송신 버퍼로 가며, TCP 스택은 송신 버퍼에서 데이터를 소비하여 전송함
            write()  may block!
|producer| ========> |send buf| =====> ...
    app                OS        TCP

OS는 어떻게 송신 버퍼가 오버플로우되는 것을 막을까요?

  • 버퍼가 가득 차면 애플리케이션은 더 이상 데이터를 쓸 수 없음 \(\rightarrow\) 애플리케이션은 과잉 생산을 스스로 조절할 책임이 있음, 왜냐하면 데이터는 어딘가로 가야 하지만 메모리는 유한하기 때문
  • 애플리케이션이 블로킹 I/O를 수행하고 있다면, 송신 버퍼가 가득 찼을 때 호출이 블로킹되므로 백프레셔는 자연스럽게 처리 \(\rightarrow\) 하지만 이벤트 루프와 함께 JS로 코딩할 때는 그렇지 않음

무제한 큐는 스스로를 망치는 위험한 기능(Footguns)이다

  • 이제 우리는 “왜 쓰기 완료를 기다리는가?”라는 질문에 답할 수 있음 \(\rightarrow\) 애플리케이션이 기다리는 동안에는 생산할 수 없기 때문
  • socket.write()는 송신 버퍼가 가득 차서 런타임이 OS에 더 이상 데이터를 제출할 수 없더라도 항상 성공 \(\rightarrow\) - 하지만 데이터는 어딘가로 가야 하므로, 런타임 내의 무제한 내부 큐로 들어가게 됨
  • 이는 무제한적인 메모리 사용을 유발할 수 있는, 스스로를 망치기 쉬운 위험한 기능(footgun)
  • 우리의 이전 에코 서버를 예로 들면, 서버는 생산자이자 소비자이며 클라이언트도 마찬가지임
  • 만약 클라이언트가 에코된 데이터를 소비하는 것보다 더 빨리 데이터를 생산한다면 (또는 클라이언트가 데이터를 전혀 소비하지 않는다면), 서버가 쓰기 완료를 기다리지 않을 경우 서버의 메모리는 무한정 증가할 것임 \(\rightarrow\) 백프레셔는 생산자와 소비자를 연결하는 모든 시스템에 존재해야 함
  • 일반적으로 소프트웨어 시스템에서 무제한 큐를 찾아보는 것이 좋은데, 이는 백프레셔의 부재를 나타내는 신호이기 때문임
           write()    unbounded!    event loop
|producer| ======> |internal queue| =========> |send buf| =====> ...
    app                Node.js                     OS      TCP

토론: 이벤트와 순차적 실행

  • 이전 버전과의 또 다른 차이점은 socket.pause()의 사용, 이제 이것이 왜 필수적인지 이해할 수 있음 \(\rightarrow\) 왜냐하면 이것이 백프레셔를 구현하는 데 사용되기 때문
  • 'data' 이벤트를 일시 중지하는 또 다른 이유가 있는데, 콜백 기반 코드에서 이벤트 핸들러가 반환되면, 런타임은 일시 중지되지 않은 경우 다음 'data' 이벤트를 발생시킬 수 있음
  • 문제는 이벤트 콜백의 완료가 이벤트 처리의 완료를 의미하지 않는다는 것으로 처리는 추가적인 콜백으로 계속될 수 있음
  • 데이터가 순서가 있는 바이트 시퀀스라는 점을 고려할 때, 이러한 처리의 뒤섞임(interleaved handling)은 문제를 일으킬 수 있음
  • 이 상황을 경쟁 상태(race condition)라고 하며, 동시성과 관련된 문제의 한 종류임, 이 상황에서는 원치 않는 동시성이 발생하게 됨

결론

프로미스 대 콜백

  • 프로미스와 async/await를 고수하면, 작업들이 순서대로 일어나기 때문에 위에서 설명한 종류의 경쟁 상태를 만들기가 더 어려움
  • 콜백 기반 코드는 코드 실행 순서를 파악하기 더 어려울 뿐만 아니라, 순서를 제어하기도 더 어려움, 요컨대, 콜백은 읽기 더 어렵고 작성할 때 오류가 발생하기 쉬윔
  • 프로미스 기반 스타일을 사용할 때는 백프레셔가 자연스럽게 존재,이는 (Node.js에서는 할 수 없는) 블로킹 I/O로 코딩하는 것과 유사함

Express.js

Express.js는 Node.js 환경에서 가장 널리 사용되는 웹 프레임워크로, 웹 서버나 REST API를 빠르고 간편하게 구축할 수 있도록 다양한 기능과 직관적인 API를 제공합니다.

Express.js 5.x의 중요 변경사항

  • Node.js v18 이상만 공식 지원하며, 구버전 Node.js 지원이 중단
  • 최신 런타임 최적화, 성능 개선, 보안 패치가 자연스럽게 반영
  • 정규식 사용 방식도 명확하게 제한되어 복잡한 패턴 매칭 오류와 성능 저하 가능성을 줄였음
  • 비동기 미들웨어와 라우트에서 발생하는 rejected Promise가 자동으로 오류 처리 미들웨어로 전달, 모든 비동기 라우트에서 try/catch를 수동으로 사용할 필요가 없고, 코드가 매우 간결
  • 새로운 압축 방식(Brotli) 지원 등 최신 웹 표준에 부합하는 기능을 추가

예제

import express from 'express';

const app = express();

app.get('/hello', async (req, res) => {
  const message = await Promise.resolve('Hello from Express 5.x!');
  res.send(message);
});

app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).send('Internal Server Error');
});

app.listen(12345, () => {
  console.log('Express 5.x server running on http://localhost:12345');
});

업데이트 이력

버전 변경 이력

버전 날짜 변경 내용
v.20250922 2025-09-22 Type 관련 내용으로 재편집

참고문헌