Node.js는 브라우저 외부에서 실행되는 JavaScript 런타임으로, V8 엔진 기반으로 구축되었으며 네트워크, 파일, 프로세스, 스트림 및 운영체제 접근을 위한 API를 제공합니다. 주로 REST API, GraphQL 서비스, 실시간 WebSocket 서버, 마이크로서비스, CLI(Command Line Interface) 도구, 프록시, API 게이트웨이 등에 널리 쓰입니다. Node.js의 핵심 강점은 비차단 I/O(non-blocking I/O)로, 단일 프로세스로도 수많은 네트워크 연결을 효율적으로 처리할 수 있다는 점입니다. 반면 트레이드오프로 워커 스레드나 별도 프로세스, 외부 서비스로 분리하지 않으면 CPU 연산이 오래 걸리는 작업이 이벤트 루프를 차단할 수 있습니다.
JavaScript는 V8에서 실행되는 반면, Node.js API는 비동기 작업을 운영체제나 libuv의 스레드 풀에 위임합니다. 메인 JavaScript 스레드는 I/O 작업이 완료되기를 기다리지 않고 다른 코드를 계속해서 실행합니다. 작업이 완료되면 해당 콜백이나 Promise 후속 작업이 이벤트 루프에서 처리될 준비를 마칩니다. 이러한 방식을 통해 Node.js는 사용자 JavaScript가 대개 단일 메인 스레드에서 실행됨에도 불구하고 높은 동시성을 달성합니다.
V8은 JavaScript를 컴파일 및 실행하고, JavaScript 객체와 메모리를 관리합니다. libuv는 크로스 플랫폼 이벤트 루프, 비동기 I/O 연동, 그리고 파일 시스템 작업 일부나 DNS, 암호화(crypto) 연산 등을 처리하는 워커 스레드 풀을 제공합니다. 이벤트 루프는 완료된 작업의 콜백이 언제 실행되어야 하는지 결정합니다. Node.js 자체는 JavaScript API를 네이티브 기능과 연결해 주는 런타임이며, V8 단독으로는 HTTP 서버, 파일 시스템 접근, 프로세스 API 등을 제공하지 않습니다.
단일 Node.js 프로세스는 일반적으로 하나의 메인 스레드에서 사용자 JavaScript를 실행하므로, 두 개의 일반적인 요청 핸들러가 해당 스레드에서 완전히 동시에 JavaScript를 실행하지는 않습니다. 그러나 플랫폼 자체가 문자 그대로 하나의 스레드로만 동작하는 것은 아닙니다. libuv는 스레드 풀을 보유하고 있고, 운영체제는 비동기 I/O를 수행하며, V8은 가비지 컬렉션을 수행하고, Node.js는 `worker_threads` 및 자식 프로세스도 지원합니다. 따라서 이 표현은 주로 기본 JavaScript 실행 모델을 설명하는 것입니다.
Node.js는 요청마다 스레드를 차단(blocking)하는 대신 입출력(I/O) 작업을 시작한 후 곧바로 이벤트 루프로 제어를 반환합니다. 예를 들어 1,000건의 데이터베이스 호출이 진행 중인 동안에도 메인 스레드는 계속해서 새로운 연결을 수락하고 준비된 콜백을 처리할 수 있습니다. 이러한 방식은 HTTP 호출, 데이터베이스 쿼리, 파일 시스템 및 소켓 작업과 같은 I/O 바운드 워크로드에 매우 적합합니다. 반면 요청 핸들러에서 무거운 동기 CPU 연산을 수행하면 이벤트 루프가 차단되어 다른 모든 요청 처리가 지연되므로 성능이 크게 저하됩니다.
libuv 이벤트 루프는 timers, pending callbacks, idle/prepare, poll, check, close callbacks 등의 단계로 구성됩니다. timers 단계에서는 setTimeout 및 setInterval 콜백을 실행하고, poll 단계에서는 대부분의 I/O 이벤트를 처리하며, check 단계에서는 setImmediate 콜백을 실행하고, close 단계에서는 소켓 종료와 같은 닫기 이벤트를 처리합니다. 또한 Node.js에는 process.nextTick과 Promise 마이크로태스크를 포함하는 마이크로태스크 큐가 있어 콜백 실행 사이에 처리됩니다. 마이크로태스크가 과도하게 많아지면 I/O 기아 현상이 발생해 서비스가 멈춘 것처럼 보일 수 있습니다.
동기 작업은 다음 줄의 코드가 실행되기 전에 반환되며 메인 스레드를 차단(block)합니다. 요청 핸들러 내의 fs.readFileSync가 대표적인 예입니다. 비동기 작업은 콜백이나 Promise를 통해 나중에 결과를 반환합니다. 논블로킹은 작업이 대기 중인 동안 스레드가 다른 작업을 계속 처리할 수 있음을 의미합니다. 이 용어들은 서로 연관되어 있지만 동일하지는 않습니다. 비동기는 결과가 어떻게 전달되는지를 나타내고, 논블로킹은 스레드가 대기하는지 여부를 나타냅니다. 백엔드 요청 핸들러에서는 일반적으로 비동기 논블로킹 API를 사용하는 것이 바람직합니다.
이벤트 루프 기아 상태는 JavaScript 작업이나 마이크로태스크가 이벤트 루프를 계속 점유하여 I/O, 타이머 또는 새로운 요청을 제때 처리하지 못할 때 발생합니다. 일반적인 원인으로는 과도한 동기식 연산, 대용량 JSON 처리, 제어되지 않는 무제한의 Promise 체인, 과도한 `process.nextTick` 사용 등이 있습니다. 해결 방법으로는 작업을 작은 단위로 분할하기, 입력 크기 제한하기, CPU 프로파일링 수행하기, CPU 집약적인 작업을 `worker_threads`나 별도의 서비스로 분리하기, 자주 실행되는 요청 경로에서 동기식 API 사용 피하기 등이 있습니다.
콜백은 나중에 실행되도록 전달되는 함수로, 주로 비동기 작업이 완료된 후 실행됩니다. 전통적인 Node.js 콜백은 보통 에러 우선(error-first) 스타일인 callback(error, result) 형태를 따릅니다. 콜백 지옥은 의존성이 있는 작업들이 깊게 중첩되어 에러 처리와 제어 흐름을 읽기 어려워질 때 발생합니다. 이를 해결하려면 함수를 기명 함수로 분리하거나, Promise 또는 async/await를 사용하고, 독립적인 작업은 중첩하는 대신 Promise.all을 사용하여 실행합니다.
import fs from 'node:fs';
fs.readFile('file.txt', 'utf8', (err, data) => {
if (err) {
console.error(err);
return;
}
console.log(data);
});
Promise는 비동기 작업의 미래 결과를 나타내며 대기(pending), 이행(fulfilled), 거부(rejected) 상태를 가질 수 있습니다. async 함수는 항상 Promise를 반환합니다. await는 Promise가 처리(settle)될 때까지 현재 async 함수만을 일시 중단하며, Node.js 프로세스 전체를 차단하지는 않습니다. 좋은 답변이라면 try/catch를 통한 에러 처리, 처리되지 않고 방치되는 Promise(floating Promise) 방지, 그리고 독립적인 작업들이 불필요하게 순차적으로 실행되지 않도록 Promise.all을 활용하는 방법 등을 언급해야 합니다.
11process.nextTick, queueMicrotask, setImmediate, setTimeout은 어떻게 다른가요?
process.nextTick은 현재 작업이 끝난 직후이자 이벤트 루프가 다음 단계로 넘어가기 전에 실행됩니다. queueMicrotask는 Promise 후속 처리와 유사하게 표준 JavaScript 마이크로태스크를 예약합니다. setImmediate는 체크(check) 단계에서 실행됩니다. setTimeout(fn, 0)은 최소 지연 시간 후에 타이머(timers) 단계에서 실행됩니다. 일반적으로 nextTick은 Promise 마이크로태스크보다 먼저 실행되며, setTimeout(0)과 setImmediate의 실행 순서는 컨텍스트에 따라 다릅니다. I/O(Input/Output) 콜백 이후에는 대체로 setImmediate가 먼저 실행됩니다.
동기 코드는 try/catch를 사용합니다. Promise 기반 코드는 try/catch 내부에서 await를 사용하거나 .catch를 연결해야 합니다. 오류 우선 콜백(error-first callback)은 결과를 사용하기 전에 에러 인자를 먼저 확인해야 합니다. 서버 애플리케이션에서는 중앙 에러 미들웨어, 요청 컨텍스트 기반 로깅, 안전한 클라이언트 에러 메시지 반환, 그리고 정상적인 종료(graceful shutdown) 처리가 중요합니다. uncaughtException은 손상된 프로세스를 계속 실행 상태로 유지하기 위해 사용해서는 안 되며, 일반적으로 로그를 기록하고 리소스를 정리한 뒤 프로세스를 재시작하라는 신호로 다뤄야 합니다.
모듈은 명시적으로 내보낸(export) 인터페이스를 갖춘 격리된 코드 단위입니다. Node.js는 node:fs 및 node:http와 같은 내장 모듈, 로컬 프로젝트 모듈, npm 패키지, CommonJS 모듈, ECMAScript 모듈을 지원합니다. 모듈을 사용하면 책임 분리가 명확해지고 재사용성이 높아집니다. 프로덕션 환경에서의 트레이드오프는 결합도(coupling)입니다. 모듈 간에 서로 너무 깊게 의존하거나 모듈을 가져올(import) 때 부수 효과(side effect)를 숨겨두면 테스트와 애플리케이션 시작 시점의 동작을 파악하기가 어려워집니다.
// math.js
export function add(a, b) {
return a + b;
}
// app.js
import { add } from './math.js';
14CommonJS의 require와 ES Modules (ECMAScript Modules)의 import는 어떻게 다른가요?
CommonJS는 require와 module.exports를 사용하는 반면, ES Modules는 import와 export를 사용합니다. ES Modules는 정적 분석과 최상위 레벨 await(top-level await)를 지원하며, package.json의 "type": "module" 설정이나 .mjs 확장자를 통해 활성화됩니다. 반면 CommonJS는 대개 동기적으로 로드되며 .cjs 확장자로 명시할 수 있습니다. 최신 Node.js 프로젝트에서는 두 시스템이 공존하므로, 단순히 이분법적으로 설명하기보다는 파일 확장자, package.json 설정 및 프로젝트 컨텍스트에 따라 모듈 모드가 결정된다는 점을 명확히 설명하는 것이 좋은 답변입니다.
15npm (Node Package Manager), package.json, package-lock.json은 각각 무엇인가요?
npm은 패키지 관리자이자 CLI 도구입니다. package.json은 스크립트, 메타데이터, dependencies 및 devDependencies를 정의합니다. package-lock.json은 정확한 의존성 트리를 기록하여 재현 가능한 설치를 보장합니다. CI 환경에서는 lock 파일만을 엄격히 기준으로 설치하고 package.json과 lock 파일의 내용이 불일치할 때 실패하도록 동작하는 `npm ci`를 주로 사용합니다. 이를 통해 의도치 않은 의존성 변경으로부터 빌드를 보호할 수 있습니다.
Node.js는 process.env를 통해 환경 변수를 읽어오지만, 모든 값이 문자열이므로 애플리케이션 시작 시점에 유효성 검증과 타입 변환을 거쳐야 합니다. 비밀값(secret)은 코드 저장소에 커밋해서는 안 되며, .env 파일은 gitignore에 포함해야 합니다. 또한 운영 환경의 시크릿은 배포 플랫폼이나 시크릿 매니저(secret manager)를 통해 주입받아야 합니다. 잘 설계된 서비스는 필수 설정이 누락되었을 때 즉시 실패(fail fast)하도록 구현됩니다. NODE_ENV는 단순한 관례일 뿐 보안 경계가 아니므로, 코드에서 이를 보호 메커니즘으로 의존해서는 안 됩니다.
const port = Number(process.env.PORT ?? 3000);
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL is required');
}
EventEmitter는 Node.js 프로세스 내에서 발행-구독(publish-subscribe) 패턴을 구현합니다. on은 리스너를 등록하고, once는 1회성 이벤트 리스너를 등록하며, off는 리스너를 제거하고, emit은 등록된 순서대로 리스너를 동기적으로 호출합니다. 이러한 동기적 특성은 매우 중요한데, 리스너 처리가 느리면 이벤트를 발생시킨 호출자도 차단되기 때문입니다. error 이벤트는 특별히 취급되어 처리되지 않은 error 이벤트가 발생하면 프로세스가 비정상 종료될 수 있습니다. 또한 오래 실행되는 시스템에서는 메모리 누수를 방지하기 위해 사용하지 않는 리스너를 명시적으로 제거해야 합니다.
import { EventEmitter } from 'node:events';
const emitter = new EventEmitter();
emitter.on('orderCreated', order => {
console.log(`Order ${order.id} created`);
});
emitter.emit('orderCreated', { id: 42 });
`Buffer`는 고정된 크기의 바이트 시퀀스를 나타냅니다. 파일 처리, TCP 소켓, HTTP 본문, 이미지, 암호화 및 바이너리 프로토콜 등에 사용됩니다. `Buffer.from`은 기존 데이터로부터 버퍼를 생성하고, `Buffer.alloc`은 0으로 채워진 메모리를 할당하며, `Buffer.allocUnsafe`는 더 빠르지만 덮어쓰기 전까지 이전 메모리 내용이 남아 있을 수 있습니다. 따라서 `allocUnsafe`는 메모리를 즉시 채우고 오래된 데이터를 외부에 절대 노출하지 않는 경우에만 적합합니다.
스트림은 모든 데이터를 한 번에 메모리에 로드하는 대신 청크(chunk) 단위로 나누어 처리합니다. 주요 유형으로는 `Readable`, `Writable`, `Duplex`, `Transform`이 있습니다. 파일, HTTP 요청 및 응답 객체, TCP 소켓, gzip 스트림 등이 대표적인 예시입니다. 스트림은 메모리 사용량과 지연 시간을 줄여주기 때문에 대용량 파일이나 네트워크 트래픽을 다룰 때 매우 유용합니다. 프로덕션 수준의 답변이라면 스트림 체인 전체에서 오류가 올바르게 처리되어야 한다는 점도 함께 언급해야 합니다.
배압(backpressure)은 데이터 소스가 청크를 생성하는 속도가 목적지에서 소비하는 속도보다 빠를 때 발생합니다. 배압 처리가 없으면 메모리 사용량이 계속 증가하여 프로세스가 느려지거나 비정상 종료될 수 있습니다. 스트림과 `pipe`로 데이터 흐름을 조율할 수 있지만, `node:stream/promises`의 `pipeline`은 에러를 전파하고 연관된 스트림을 닫으며 `Promise`를 반환하므로 다단계 체인에서 훨씬 안전합니다. 이는 파일 압축, 업로드, 다운로드, 스트림 변환 작업의 기본 방식으로 사용하기에 적합합니다.
import fs from 'node:fs';
import zlib from 'node:zlib';
import { pipeline } from 'node:stream/promises';
await pipeline(
fs.createReadStream('input.txt'),
zlib.createGzip(),
fs.createWriteStream('input.txt.gz')
);