웹 서버 기초 (#2. Promises와 Events, 그리고 Async/Await)
2025-12-08
이벤트를 폴링(poll)함이벤트에 반응하여 개발자가 등록한 콜백(callback)을 호출이벤트 루프(event loop)라고 부름단일 OS 스레드를 공유하기 때문에 이벤트 루프는 단일 스레드에서 작동await될 때 제어권이 런타임으로 돌아가, 런타임이 이벤트를 발생시키고 다른 작업을 스케줄링할 수 있기 때문임이벤트 루프에 너무 오래 머무는 것을 피하는 것이 중요
블로킹 모드(blocking mode)와 논블로킹 모드(non-blocking mode)를 모두 제공
I/O와 관련된 대부분의 Node.js 라이브러리 함수는 콜백 기반이거나 프로미스(Promise) 기반임
동기 API는 이벤트 루프를 블로킹하기 때문에 네트워크 애플리케이션에서 사용해서는 안 됨, 그럼에도 동기 방식이 존재하는 이유는 일부 간단한 사용 사례(스크립팅 등)를 위해 존재함
네트워킹을 넘어서는 이벤트 기반 프로그래밍
I/O는 디스크 파일과 네트워킹 이상의 것으로 GUI 시스템에서는 마우스와 키보드로부터의 사용자 입력 또한 I/O 그리고 이벤트 루프는 Node.js 런타임에만 국한되지 않음, 웹 브라우저와 다른 모든 GUI 애플리케이션도 내부적으로 이벤트 루프를 사용함, GUI 프로그래밍 경험을 네트워크 프로그래밍에, 또는 그 반대로 활용할 수 있음
await하고 결과를 얻을 수 있다는 것 \(\rightarrow\) 프로그램을 여기저기 흩어진 작은 콜백들로 나누는 것을 피할 수 있음먼저 async/await와 JS의 Promise 타입에 익숙하지 않은 분들을 위해 간단히 차이점을 소개
await를 사용하는 예시, 애플리케이션 로직이 동일한 async 함수 내에서 계속됨 \(\rightarrow\) 애플리케이션 로직이 여러 함수로 나뉘지 않기 때문에, 코드의 응집성이 높음콜백은 JS에서 피할 수 없음
resolve(): await 구문이 값을 반환하게 함reject(): await 구문이 예외를 던지게 함resolve()가 호출된 상태reject()가 호출된 상태async와 await 이해하기일반 함수는
return으로 런타임에 제어권을 넘김
async 함수, 두 가지 종류가 있음
return 할 때까지 실행, JS 런타임은 단일 스레드이며 이벤트 기반이므로, JS에서 블로킹(blocking) I/O를 수행할 수 없기 때문에 I/O 완료에 대한 콜백을 등록하고 JS 코드를 종료함
async함수는await으로 런타임에 제어권을 넘김
Promise 타입은 단순히 콜백을 관리하는 방법이었음 \(\rightarrow\) 이는 너무 많은 중첩 함수 없이 여러 콜백을 연결(chaining)할 수 있게 해 주었음async 함수가 추가되었기 때문에, 우리는 프로미스의 이러한 사용법에 대해서는 신경 쓰지 않을 것임async 함수는 실행 도중에 런타임으로 돌아갈 수 있음 \(\rightarrow\) 이는 프로미스에 await 구문을 사용할 때 발생 그리고 프로미스가 처리(settled)되면, 프로미스의 결과와 함께 async 함수의 실행이 재개됨, 이는 콜백에 의해 중단되지 않고 동일한 함수 내에서 순차적인 I/O 코드를 작성할 수 있기 때문에 훨씬 뛰어난 코딩 경험을 제공함
async함수 호출은 새로운 태스크(Task)를 시작함
async 함수를 호출하면, 해당 async 함수가 반환하거나 예외를 던질 때 스스로 처리(settle)되는 프로미스가 생성됨 \(\rightarrow\) 일반 프로미스처럼 await 할 수 있지만, 만약 그렇게 하지 않더라도 async 함수는 여전히 런타임에 의해 스케줄링됨net 모듈은 프로미스 기반 API를 제공하지 않으므로, 직접 구현해야 함
soRead 함수는 소켓 데이터로 이행(resolve)되는 프로미스를 반환함, 이는 세 가지 이벤트에 의존함
data 이벤트는 프로미스를 이행시킴, 소켓을 읽는 동안 EOF(End-Of-File)가 발생했는지도 알아야 함end 이벤트 또한 프로미스를 이행시킴, EOF를 나타내는 일반적인 방법은 길이가 0인 데이터를 반환하는 것error 이벤트가 발생했을 때 프로미스를 거부(reject)해야 함, 그렇지 않으면 프로미스는 영원히 대기 상태에 머무르게 됨TCPConn 래퍼(wrapper) 객체를 만들어야 함 \(\rightarrow\) 프로미스의 resolve와 reject 콜백은 TCPConn.reader 필드에 저장됨data 이벤트는 데이터가 도착할 때마다 발생하지만, 프로미스는 프로그램이 소켓에서 데이터를 읽으려고 할 때만 존재함 \(\rightarrow\) data 이벤트가 발생할 준비가 되는 시점을 제어할 방법이 있어야 함이 지식을 바탕으로 이제 soRead 함수를 구현할 수 있음
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();
});
}data 이벤트는 우리가 소켓을 읽을 때까지 일시 중지되므로, 소켓은 생성된 후 기본적으로 일시 중지 상태여야 함, 이를 위한 플래그가 있음data 이벤트와 달리 end와 error 이벤트는 일시 중지할 수 없으며 발생하는 즉시 방출됨, 이 이벤트들을 래퍼 객체에 저장하고 soRead에서 확인하는 방식으로 이를 처리할 수 있음// 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();
});
}socket.write 메서드는 쓰기 완료를 알리는 콜백을 받으므로, 프로미스로의 변환은 간단함'drain' 이벤트도 있음async와 await 사용하기await를 사용하기 위해, 새 연결을 위한 핸들러(newConn)는 async 함수가 됨await 구문은 거부(rejected)될 때 예외를 던질 수 있으므로, 우리 코드를 try-catch 블록으로 감쌈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);소켓 쓰기 완료를 기다려야 하는가? 새로운 에코 서버에는 이제
socket.write()가 완료되기를 기다린다는 큰 차이점이 있습니다. 그런데 “쓰기 완료”란 무엇을 의미할까요. 그리고 왜 우리는 그것을 기다려야 할까요.
socket.write()는 데이터가 OS에 제출되었을 때 완료큐(queue)나 버퍼(buffer)가 있음백프레셔(backpressure)라고 불리며, TCP에서의 백프레셔는 흐름 제어(flow control)로 알려져 있음윈도우(window)에 의해 제한되며, 윈도우가 가득 차면 데이터 전송을 일시 중지함TCP 혼잡 제어(congestion control)와 혼동되어서는 안 됨socket.write()는 송신 버퍼가 가득 차서 런타임이 OS에 더 이상 데이터를 제출할 수 없더라도 항상 성공 \(\rightarrow\) - 하지만 데이터는 어딘가로 가야 하므로, 런타임 내의 무제한 내부 큐로 들어가게 됨무제한적인 메모리 사용을 유발할 수 있는, 스스로를 망치기 쉬운 위험한 기능(footgun)임서버는 생산자이자 소비자이며 클라이언트도 마찬가지임일반적으로 소프트웨어 시스템에서 무제한 큐를 찾아보는 것이 좋은데, 이는 백프레셔의 부재를 나타내는 신호이기 때문임socket.pause()의 사용, 이제 이것이 왜 필수적인지 이해할 수 있음 \(\rightarrow\) 왜냐하면 이것이 백프레셔를 구현하는 데 사용되기 때문'data' 이벤트를 일시 중지하는 또 다른 이유가 있는데, 콜백 기반 코드에서 이벤트 핸들러가 반환되면, 런타임은 일시 중지되지 않은 경우 다음 'data' 이벤트를 발생시킬 수 있음경쟁 상태(race condition)라고 하며, 동시성과 관련된 문제의 한 종류임, 이 상황에서는 원치 않는 동시성이 발생하게 됨async/await를 고수하면, 작업들이 순서대로 일어나기 때문에 위에서 설명한 종류의 경쟁 상태를 만들기가 더 어려움Express.js는 Node.js 환경에서 가장 널리 사용되는 웹 프레임워크로, 웹 서버나 REST API를 빠르고 간편하게 구축할 수 있도록 다양한 기능과 직관적인 API를 제공합니다.
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 관련 내용으로 재편집 |