Node.js
고친 사람 github-actions[bot]
Node.js 는 자바스크립트를 브라우저 밖에서 돌려 주는 실행 환경입니다. 서버 프로그램이나 명령줄 도구를 자바스크립트로 짤 수 있게 해 줍니다. 스레드 하나로 수많은 연결을 번갈아 처리하는 방식이 특징입니다.
쉽고 빠른 이해
Node.js 는 브라우저에서만 돌던 자바스크립트를 서버에서도 돌게 해 줍니다. 파일을 읽고 네트워크 연결을 받는 기능이 여기에 붙어 있습니다.
서버는 요청을 받으면 대부분의 시간을 데이터베이스나 파일을 기다리며 보냅니다. 요청마다 스레드를 붙여 기다리게 하면 스레드가 금방 불어납니다. Node.js 는 기다리는 동안 스레드를 붙잡지 않습니다.
- 요청이 오면 할 일을 시작합니다. 기다릴 일은 맡겨 둡니다
- 그동안 스레드 하나가 다음 요청을 받습니다
- 맡긴 일이 끝나면 미리 넘겨 둔 함수가 불립니다
대가도 있습니다. 계산이 오래 걸리는 코드 하나가 그 스레드를 붙잡으면 다른 요청이 전부 멈춥니다.
상세
이 절은 Node.js 가 무엇인지를 차례로 봅니다. 무엇으로 이루어졌나, 스레드 하나로 어떻게 요청 여럿을 받나, 그 방식이 어디서 막히나, 무엇을 내주고 얻었나입니다. 백엔드 개발자에게 익숙한 스레드 기반 서버와 견주면서 봅니다.
브라우저 밖의 자바스크립트
자바스크립트는 원래 웹 페이지 안에서 도는 언어였습니다. 코드를 읽어 실행하는 자바스크립트 엔진이 브라우저 안에 들어 있었기 때문입니다. 브라우저 밖에는 그 코드를 돌릴 프로그램이 없었습니다.
Node.js 는 크롬 브라우저의 자바스크립트 엔진인 V8(Chrome V8 engine)을 떼어 와 독립 프로그램으로 만들었습니다. 그런데 엔진만으로는 파일을 열거나 네트워크 연결을 받을 수 없습니다. 이렇게 파일·네트워크·데이터베이스처럼 프로그램 바깥과 데이터를 주고받는 일을 입출력(I/O, Input/Output)이라고 합니다.
Node.js 는 입출력 기능을 엔진 바깥에 덧붙였습니다. 그 덧붙인 기능이 표준 라이브러리입니다.
파일을 다루는 fs, 웹 서버를 여는 http, 다른 프로세스를 띄우는 child_process 가 그 예입니다.
엔진과 라이브러리를 묶은 실행 환경이라서 Node.js 는 스스로를 런타임이라고 소개합니다.
아래 그림은 코드 한 줄이 어느 계층을 거쳐 운영체제에 닿는지를 보입니다. 계산은 V8 이 맡습니다. 입출력을 기다리는 일은 libuv 라는 C 라이브러리가 맡습니다.
flowchart TD
A["내가 짠 자바스크립트 코드"]
subgraph N["Node.js"]
B["표준 라이브러리 · fs · http"]
C["V8 엔진 · 코드를 실행한다"]
D["libuv · 입출력을 기다려 준다"]
end
E["운영체제"]
A --> B
A --> C
B --> D
D --> E
가장 짧은 웹 서버
아래는 요청이 오면 hello 를 돌려주는 서버입니다. 포트 3000 번에서 요청을 기다립니다.
const http = require("node:http");
http.createServer((req, res) => {
res.end("hello"); // 응답 본문
}).listen(3000); // 3000번 포트
createServer 에 넘긴 함수는 요청이 올 때마다 불립니다. 이렇게 나중에 불리도록 넘겨 두는 함수가
콜백입니다. Node.js 의 코드는 대부분 이 꼴로 짜입니다.
이벤트 루프 — 스레드 하나가 요청 여럿을 받는 방법
Spring 같은 스레드 기반 서버는 요청 하나에 스레드 하나를 붙입니다. 그 스레드는 데이터베이스 응답을 기다리는 동안 멈춰 있습니다. 이렇게 결과가 올 때까지 스레드가 멈춰 기다리는 방식을 블로킹이라고 합니다. 요청이 만 개면 기다리는 스레드도 만 개가 됩니다.
Node.js 는 자바스크립트 코드를 스레드 하나에서만 돌립니다. 이 스레드는 입출력을 만나면 그 일을 맡겨 둔 채 다음 일로 넘어갑니다. 이 방식이 논블로킹 입출력입니다. 누가 대신 기다리는지는 아래 「기다리는 일은 누가 하나」에서 봅니다.
맡긴 일이 끝나면 그 결과를 처리할 콜백이 줄을 섭니다. 스레드는 지금 실행하던 코드가 끝나면 줄 맨 앞의 콜백을 꺼내 실행합니다. 이 「꺼내고 실행하고 다시 꺼내는」 되풀이가 이벤트 루프입니다.
flowchart TD
S["스크립트를 처음부터 끝까지 실행한다"]
Q{"줄 선 콜백이 있나"}
R["맨 앞 콜백을 꺼내 실행한다"]
W{"아직 기다리는 입출력이 있나"}
X["프로세스가 끝난다"]
S --> Q
Q -->|있다| R
R --> Q
Q -->|없다| W
W -->|있다 · 알림이 올 때까지 기다린다| Q
W -->|없다| X
그림에서 보듯 Node.js 는 스크립트를 다 실행한 뒤 저절로 루프에 들어갑니다. 루프를 시작하라는 호출이 따로 없습니다. 더 불릴 콜백도 기다리는 일도 없으면 프로세스가 끝납니다.
순서가 코드 순서와 다르다
이벤트 루프 때문에 콜백은 지금 실행 중인 코드가 다 끝난 뒤에야 불립니다. 이 절은 setTimeout 으로
그것을 봅니다. setTimeout 은 두 번째 인자로 준 시간이 지난 뒤에 콜백을 부르는 함수입니다.
그 시간을 0 으로 줘도 콜백은 바로 불리지 않습니다. 아래 코드는 적힌 순서와 찍히는 순서가 다릅니다.
console.log("A"); // 첫째로 A
setTimeout(() => {
console.log("B"); // 셋째로 B
}, 0);
console.log("C"); // 둘째로 C
setTimeout 은 콜백을 줄에 세워 둘 뿐입니다. 스레드는 남은 코드를 먼저 실행해 C 를 찍습니다.
그 코드가 끝난 다음에야 줄에서 B 를 찍는 콜백을 꺼냅니다.
콜백 안에서 또 콜백을 넘기는 일이 거듭되면 코드가 안쪽으로 깊게 들여써집니다. 그래서 요즘 코드는 Promise 를 씁니다. Promise 는 나중에 올 결과를 담는 객체입니다. 콜백을 안으로 넣는 대신 결과가 오면 할 일을 뒤에 이어 붙입니다.
async/await 는 Promise 를 쓰는 코드를 위에서 아래로 읽히게 쓰는 문법입니다. 바뀐 것은 겉모양입니다. 아래에서 도는 것은 같은 이벤트 루프입니다.
기다리는 일은 누가 하나
스레드가 하나라면 파일 읽기는 누가 기다리는지 궁금해집니다. 답은 libuv 입니다. 네트워크 입출력에는 운영체제가 준비된 연결을 알려 주는 기능이 있습니다. libuv 는 그 알림만 받습니다. 리눅스에서는 epoll 이 그 기능입니다.
파일 읽기처럼 운영체제가 그런 알림을 주지 않는 일도 있습니다. 이런 일은 libuv 가 따로 둔 스레드 풀에서 처리합니다. 스레드 풀은 미리 만들어 둔 스레드 몇 개에 일을 나눠 주는 장치입니다.
그러니 Node.js 프로세스 안에 스레드가 하나뿐인 것은 아닙니다. 내가 짠 자바스크립트가 도는 스레드가 하나라는 뜻입니다. 기다리는 일은 뒤에서 libuv 와 운영체제가 합니다.
오래 걸리는 계산은 전부를 멈춘다
이 방식은 기다림이 많은 일에 맞춰져 있습니다. 반대로 계산이 오래 걸리는 일에는 약합니다. 큰 JSON(JavaScript Object Notation) 문서를 파싱하거나, 이미지를 줄이거나, 비밀번호를 해시하는 일이 그렇습니다.
계산은 맡겨 둘 수가 없습니다. 자바스크립트 스레드가 직접 붙잡고 해야 합니다. 그동안 이벤트 루프가 돌지 못하니 줄 선 콜백이 전부 기다립니다. 요청 하나의 긴 계산이 서버의 모든 요청을 느리게 만듭니다. 이것을 흔히 「이벤트 루프를 막는다」고 말합니다.
이름 끝에 Sync 가 붙은 함수도 같은 문제를 냅니다. fs.readFileSync 가 그 예입니다. 이런 함수는
앞에서 본 블로킹 방식으로 돕니다. 결과가 올 때까지 스레드를 멈춥니다. 서버가 요청을 처리하는 도중에
이런 함수를 부르면 루프가 거기서 섭니다.
막는 계산을 피하는 길은 둘입니다. 첫째는 worker_threads 모듈로 스레드를 하나 더 띄워 계산을 넘기는 길입니다. 새로 띄운 스레드에서도 자바스크립트가 돕니다.
둘째는 프로세스를 여러 개 띄우는 길입니다. cluster 모듈이나 child_process 로 프로세스를 띄워
CPU 코어마다 하나씩 돌립니다.
npm — 패키지를 받아 쓰는 도구
Node.js 를 설치하면 npm(Node Package Manager, 노드 패키지 매니저)이 같이 따라옵니다. npm 은 남이 만든 코드 묶음인 패키지를 내려받아 프로젝트에 넣어 주는 패키지 매니저입니다. 자바의 Maven 이나 Gradle 과 비슷한 일을 합니다.
프로젝트가 어떤 패키지를 쓰는지는 package.json 파일에 적습니다. 내려받은 패키지는 프로젝트 안의
node_modules 폴더에 쌓입니다. Express 같은 웹 프레임워크도 이렇게 받아 씁니다.
프론트엔드와 서버가 같은 언어를 쓴다
브라우저 코드와 서버 코드를 한 언어로 짤 수 있다는 점이 Node.js 를 고르는 큰 이유입니다. 화면을 그리는 React 컴포넌트를 서버에서도 실행할 수 있습니다. 컴포넌트는 버튼이나 목록 같은 화면 조각을 코드로 짠 단위입니다.
서버에서 화면을 HTML(HyperText Markup Language) 문서로 미리 만들어 보내는 방식이 서버 사이드 렌더링입니다. Next.js 같은 프레임워크는 이 일을 Node.js 위에서 합니다.
무엇을 내주었나
위 특징들은 저마다 무언가를 내주고 얻은 것입니다.
| 고른 것 | 얻는 것 | 내준 것 |
|---|---|---|
| 자바스크립트 스레드 하나 | 요청마다 스레드를 붙이지 않는다 | 오래 걸리는 계산 하나가 모든 요청을 세운다 |
| 논블로킹 입출력 | 기다리는 연결이 많아도 스레드가 불어나지 않는다 | 콜백이 코드 순서와 다른 순서로 불린다 |
| 서버에서도 자바스크립트 | 브라우저와 서버 코드를 한 언어로 짠다 | 긴 계산은 따로 띄운 스레드나 프로세스로 빼야 한다 |
그래서 Node.js 는 기다림이 많은 서버에 자주 쓰입니다. 요청 대부분이 데이터베이스나 다른 서비스를 기다리는 API(Application Programming Interface) 서버가 그렇습니다. 연결을 오래 붙잡는 WebSocket 서버와 서버 사이드 렌더링도 여기에 듭니다. 반대로 요청마다 긴 계산을 하는 서버에는 위의 막힘 문제가 걸림돌이 됩니다.
관련 항목
Node.js 를 이루는 구성 요소
V8 · libuv · 이벤트 루프 · 스레드 풀 · 표준 라이브러리 · worker_threads · cluster 모듈 · child_process
Node.js 가 바탕에 두는 개념
런타임 · 자바스크립트 엔진 · 논블로킹 I-O · 비동기 프로그래밍 · 콜백 · Promise · async-await · epoll · 스레드 · 동시성 · JavaScript · 브라우저
Node.js 에서 자주 나는 장애
이벤트 루프 블로킹 · 콜백 지옥 · 메모리 누수 · 처리되지 않은 Promise 거부
Node.js 위에서 도는 프레임워크와 도구
npm · Express · Next.js · NestJS · React · TypeScript · 패키지 매니저
같은 일을 두고 겨루는 런타임과 언어
Deno · Bun · JVM · Go · Python · Java
Node.js 로 짓는 서버의 종류
API 서버 · WebSocket · 서버 사이드 렌더링 · 명령줄 도구
다른 이름: 노드JS · nodejs · 노드제이에스