npm install 없이 데이터베이스와 실시간 통신까지 — Node.js 24가 정리한 백엔드 의존성 구조
작년에 팀 프로젝트에서 better-sqlite3 네이티브 바이너리 빌드가 CI에서 터지는 걸 두 시간 동안 잡았던 기억이 있습니다. 빌드 환경 차이, 파이썬 버전, 컴파일러 버전... 솔직히 그날 이후로 "SQLite 하나 쓰는데 이게 맞나?" 싶었습니다. 그러다 Node.js 24 릴리스 노트를 훑다가 node:sqlite가 자리 잡은 걸 보고 진심으로 반가웠습니다.
Node.js 24는 2025년 5월 6일 Current로 출시되었고 2025년 10월 LTS로 전환될 예정입니다. 한 가지 짚어둘 점이 있는데, 이 글에서 다루는 기능 대부분은 사실 Node.js 22 계열에서 이미 도입되었고 24에서 자리 잡은 것들입니다.
node:sqlite: 22.5.0(2024년 7월)에 도입, 24에서도 여전히 Stability 1 (Experimental) — 다만 기본으로 사용 가능- 내장
WebSocket: 22.4.0에서 플래그 없이 활성화, 22 후반에 stable require(esm): 22.12.0(2024년 12월)에 언플래그- Permission Model: 23.5.0에서
--permission으로 stable 승격
Node.js 24는 이 흐름을 안정 궤도에 얹은 셈입니다. Deno·Bun이 선점한 "Batteries Included" 전략을 Node.js가 진지하게 따라잡고 있고, 이 네 가지 변화만으로 better-sqlite3, ws, 그리고 일부 Babel 설정까지 걷어낼 수 있습니다.
이 글에서는 각 기능이 실제로 어떻게 동작하는지, 어떤 상황에서 쓰면 좋고 어떤 상황에서 여전히 기존 도구가 나은지를 중심으로 풀어봅니다. 주의사항과 함정도 솔직하게 짚겠습니다.
핵심 개념
node:sqlite — 네이티브 빌드 없는 SQLite
node:sqlite는 better-sqlite3나 sqlite3 같은 서드파티 패키지를 설치하지 않고도 SQLite를 쓸 수 있게 해주는 내장 모듈입니다. 하나 미리 말해둘 게 있는데, Node.js 24 시점에서도 이 모듈은 문서상 Stability 1 (Experimental) 입니다. 기본 활성화되어 있어 별도 플래그 없이 쓸 수 있지만, API가 미세하게 바뀔 가능성이 남아 있다는 점은 프로덕션 도입 시 감안해야 합니다.
핵심 클래스는 두 가지입니다.
DatabaseSync: 데이터베이스 연결 객체. 인메모리(:memory:) 또는 파일 경로를 인자로 받습니다.StatementSync:db.prepare()로 생성하는 준비된 구문(prepared statement)..run(),.get(),.all()메서드로 실행합니다.
API 전체가 동기(synchronous) 입니다. await 없이 바로 결과를 받아볼 수 있어 코드가 직관적입니다.
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync(':memory:');
db.exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE
)
`);
const insert = db.prepare('INSERT INTO users (name, email) VALUES (?, ?)');
insert.run('Alice', 'alice@example.com');
const select = db.prepare('SELECT * FROM users WHERE name = ?');
const row = select.get('Alice');
console.log(row); // { id: 1, name: 'Alice', email: 'alice@example.com' }
db.close();빌드 스텝이 전혀 없습니다. package.json에 추가할 항목도 없습니다. Docker 이미지에서 python3, make, g++ 같은 빌드 도구를 걷어낼 수 있고, CI 파이프라인에서 네이티브 컴파일 단계가 사라집니다.
고급 SQLite 튜닝이 필요하다면 PRAGMA를 직접 호출하면 됩니다.
db.exec('PRAGMA journal_mode=WAL');
db.exec('PRAGMA synchronous=NORMAL');require(esm) 안정화 — CJS와 ESM 사이의 벽
저도 처음엔 헷갈렸는데, require(esm)이 정확히 무엇을 해결하는지 맥락부터 짚어봅니다.
Node.js 생태계는 오랫동안 CommonJS(require)와 ES Modules(import) 사이에서 이중 고통을 겪어왔습니다. chalk, unified, remark 같은 유명 패키지들이 ESM 전용으로 전환하면서, CJS 코드베이스에서 이 패키지들을 쓰려면 ERR_REQUIRE_ESM 에러를 맞닥뜨리거나 동적 import()로 우회해야 했습니다.
Node.js 22.12.0에서 언플래그된 require(esm)이 24에서 완전히 자리 잡으면서 이 문제가 해결됩니다. top-level await를 포함하지 않는 ESM 모듈은 CJS 파일에서 require()로 그대로 불러올 수 있습니다.
// 이전: ERR_REQUIRE_ESM 에러
// Node.js 22.12+/24: 정상 동작
const { unified } = require('unified'); // ESM-only 패키지
const remarkParse = require('remark-parse');아래 흐름도는 어떤 경우에 require()를 쓸 수 있고, 어떤 경우에 동적 import()가 여전히 필요한지를 보여줍니다.
한 가지 주의할 점은 패키지가 "module-sync" export 조건을 지원하는지 확인해야 한다는 것입니다. 확인 방법은 간단합니다. 해당 패키지의 package.json을 열어 exports 필드에 "module-sync" 조건이 있는지 보면 됩니다.
{
"exports": {
".": {
"module-sync": "./dist/index.js",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}이 조건이 있으면 CJS에서 require()로 안전하게 불러올 수 있습니다. 최신 유지보수되는 ESM 패키지 상당수가 이 조건을 추가하고 있지만, 오래된 ESM 전용 패키지 중에는 아직 없는 경우도 있으니 문제가 생기면 여기부터 확인해보세요.
클라이언트 전용 내장 WebSocket
WebSocket 전역 클래스는 Node.js 22.4.0에서 플래그 없이 활성화되었고, 24에서도 기본으로 사용 가능합니다. WHATWG WebSocket 표준을 따르기 때문에 브라우저에서 쓰던 코드를 그대로 Node.js에서 실행할 수 있습니다.
중요한 포인트가 두 가지 있습니다. 첫째, WebSocket은 전역 클래스이므로 별도 import가 필요 없습니다. 둘째, 내장 WebSocket은 클라이언트(연결을 맺는 쪽) 전용입니다. 서버 측 구현은 별도로 마련해야 합니다.
// import 불필요 — WebSocket은 전역 클래스
const ws = new WebSocket('ws://localhost:8080');
ws.addEventListener('open', () => {
ws.send('Hello from Node.js');
});
ws.addEventListener('message', ({ data }) => {
console.log('받은 메시지:', data);
});
ws.addEventListener('close', () => {
console.log('연결 종료');
});서버 측 WebSocket이 필요하다면 node:http의 upgrade 이벤트를 직접 처리하거나, ws 패키지를 계속 사용하는 것이 현실적입니다. 룸 관리, 네임스페이스, 폴백 등 복잡한 기능이 필요하다면 Socket.IO도 여전히 좋은 선택입니다.
Permission Model — 프로세스 수준 권한 격리
Permission Model은 Node.js 20에서 --experimental-permission으로 시작해서, 23.5.0 이후 --permission 플래그로 stable 승격되었습니다. 프로세스 수준에서 파일시스템, 자식 프로세스, 워커 스레드, 네이티브 애드온, WASI 접근을 제한할 수 있습니다.
여기서 흔한 오해 하나를 짚고 갈게요. Node.js Permission Model은 Deno와 달리 네트워크 접근 제어(--allow-net)를 제공하지 않습니다. Node.js 문서상 지원하는 플래그는 다음뿐입니다.
--allow-fs-read,--allow-fs-write--allow-child-process--allow-worker--allow-addons--allow-wasi
네트워크를 제한하고 싶다면 별도 방화벽, 프록시, 또는 애플리케이션 수준 화이트리스트로 처리해야 합니다. 이 점을 헷갈리면 실무에서 크게 삽질하니 꼭 기억해두세요.
# 읽기는 /app 전체, 쓰기는 /app/uploads만, 자식 프로세스와 워커·애드온은 모두 차단
node --permission \
--allow-fs-read=/app \
--allow-fs-write=/app/uploads \
server.js--permission만 켜면 위 플래그에 명시되지 않은 모든 파일시스템 접근, 자식 프로세스 생성, 워커 스레드, 네이티브 애드온 로드가 차단됩니다.
런타임 중에 process.permission.has()로 현재 권한 상태를 확인할 수 있습니다. 이 API가 지원하는 카테고리도 위 목록과 같습니다.
if (!process.permission.has('fs.read', '/etc/passwd')) {
console.log('민감 파일 접근 차단됨');
}
if (process.permission.has('fs.write', '/app/uploads')) {
// 업로드 디렉터리에 쓰기 수행
}
if (!process.permission.has('child')) {
console.log('자식 프로세스 생성 불가 — exec/spawn 호출 시 ERR_ACCESS_DENIED');
}실전 적용
시나리오 1: 설정값·캐시 저장용 경량 SQLite
테스트 픽스처, 개발 환경 설정 저장, 임시 캐시 — 여기에 PostgreSQL을 띄우는 건 오버엔지니어링입니다. node:sqlite가 딱 맞는 자리입니다.
여기서 바로 다음 절에서 다룰 "실무 함정"을 미리 반영해서, db.close()를 프로세스 종료에 훅으로 붙여둔 형태로 작성합니다.
import { DatabaseSync } from 'node:sqlite';
import { join } from 'node:path';
const db = new DatabaseSync(join(process.cwd(), 'config.db'));
db.exec('PRAGMA journal_mode=WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER DEFAULT (unixepoch())
)
`);
const upsert = db.prepare(`
INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE
SET value = excluded.value,
updated_at = unixepoch()
`);
const get = db.prepare('SELECT value FROM settings WHERE key = ?');
export function setSetting(key, value) {
upsert.run(key, JSON.stringify(value));
}
export function getSetting(key, fallback = null) {
const row = get.get(key);
return row ? JSON.parse(row.value) : fallback;
}
// WAL 파일 잔존 방지 — 프로세스 종료 시 명시적 close
let closed = false;
function shutdown() {
if (closed) return;
closed = true;
db.close();
}
process.on('exit', shutdown);
process.on('SIGINT', () => { shutdown(); process.exit(0); });
process.on('SIGTERM', () => { shutdown(); process.exit(0); });better-sqlite3와 API가 거의 동일해서 마이그레이션도 어렵지 않습니다.
시나리오 2: ESM 전용 패키지를 CJS 코드베이스에서 사용
레거시 Express 앱에서 ESM 전용으로 전환된 Markdown 처리 파이프라인을 써야 하는 상황입니다. Node.js 22.12 이전이라면 동적 import()로 우회해야 했지만, 이제는 그냥 require()를 쓸 수 있습니다.
주의할 점 하나. remark 패키지는 이미 unified + remark-parse + remark-stringify가 조합된 완성된 프로세서입니다. unified().use(remark)로 넘기는 플러그인이 아닙니다. 올바른 패턴은 아래 두 가지 중 하나입니다.
// routes/docs.js (CommonJS)
'use strict';
// 방식 A: remark 프로세서 사용
const { remark } = require('remark');
const remarkHtml = require('remark-html');
function markdownToHtmlA(markdown) {
return String(remark().use(remarkHtml).processSync(markdown));
}
// 방식 B: unified를 직접 구성
const { unified } = require('unified');
const remarkParse = require('remark-parse');
function markdownToHtmlB(markdown) {
const processor = unified().use(remarkParse).use(remarkHtml);
return String(processor.processSync(markdown));
}
module.exports = { markdownToHtml: markdownToHtmlA };트랜스파일 레이어나 별도 플래그 없이 바로 동작합니다. 기존 Babel 설정을 단순화하거나 제거할 수 있는 계기가 됩니다.
시나리오 3: Permission Model로 마이크로서비스 격리
로그와 업로드 파일을 다루면서 외부 명령을 절대 실행할 필요가 없는 서비스에 최소 권한 원칙을 적용하는 예시입니다. 네트워크는 Permission Model이 제어하지 않으니, 여기서는 파일시스템·자식 프로세스·워커 스레드·애드온을 명시적으로 화이트리스트/블랙리스트 처리합니다.
{
"scripts": {
"start": "node --permission --allow-fs-read=/app/src,/app/node_modules,/app/config --allow-fs-write=/app/logs,/app/uploads src/server.js"
}
}위 설정은 --allow-child-process, --allow-worker, --allow-addons를 모두 생략했으므로 서비스에서 child_process.spawn, worker_threads.Worker, C++ 애드온 로드를 시도하면 즉시 ERR_ACCESS_DENIED가 발생합니다.
// src/server.js
import { createServer } from 'node:http';
const required = [
['fs.read', '/app/src'],
['fs.read', '/app/config'],
['fs.write', '/app/logs'],
];
for (const [perm, resource] of required) {
if (!process.permission.has(perm, resource)) {
console.error(`필수 권한 누락: ${perm} → ${resource}`);
process.exit(1);
}
}
// 서비스 성격상 자식 프로세스는 절대 필요 없어야 함
if (process.permission.has('child')) {
console.warn('경고: child process 권한이 열려 있음 — 배포 스크립트 검토 필요');
}
const server = createServer((req, res) => {
// 서비스 로직
});
server.listen(3000);공급망 공격(supply chain attack) 완화에도 유용합니다. 악의적인 트랜지티브 의존성이 /etc/passwd를 읽거나 /tmp 밖으로 파일을 쓰거나 curl | sh류 페이로드를 위해 자식 프로세스를 띄우려 해도, Permission Model이 활성화된 상태라면 ERR_ACCESS_DENIED로 차단됩니다. 다만 네트워크로 데이터를 유출하려는 시도까지 막고 싶다면 아웃바운드 방화벽이나 프록시 화이트리스트가 별도로 필요합니다.
장단점 분석
node:sqlite
| 항목 | 내용 |
|---|---|
| 장점 | 네이티브 빌드 불필요, node_modules 공간 절약, CI/CD 단순화, 직관적인 동기 API |
| 단점 | 여전히 Experimental(24 기준), 동기 API라 고부하 쿼리 시 이벤트 루프 블로킹, 고급 튜닝은 PRAGMA 직접 처리 |
| 추천 용도 | 설정 저장, 임시 캐시, 테스트 픽스처, 개발 환경 단독 DB |
| 비추천 용도 | 대규모 프로덕션 DB (PostgreSQL 권장), 동시 쓰기 부하가 높은 환경 |
실무에서 흔한 실수
db.close()누락:DatabaseSync는 명시적으로 닫아야 합니다. 파일 기반 DB라면 프로세스 종료 시 WAL 파일이 남을 수 있습니다. 시나리오 1처럼process.on('exit', ...),SIGINT,SIGTERM을 함께 훅으로 잡아두세요.- prepared statement를 루프 안에서 매번 재생성:
db.prepare()는 루프 밖에서 한 번만 호출하고 재사용해야 성능이 나옵니다. 루프 안에서 매번 준비하면 오히려 더 느려질 수 있습니다. - WAL 모드 미설정: 기본 journal 모드에서는 읽기-쓰기 동시성이 떨어집니다. 파일 기반 DB라면 시작할 때
PRAGMA journal_mode=WAL을 설정하는 것을 권장합니다.
내장 WebSocket
| 항목 | 내용 |
|---|---|
| 장점 | 브라우저 API와 동일, ws 클라이언트 제거, 코드 재사용 용이 |
| 단점 | 클라이언트 전용, 서버 측 WebSocket은 별도 구현 필요 |
| 추천 용도 | 외부 WebSocket 서버에 연결하는 클라이언트 코드, 서버 간 실시간 통신 |
| 비추천 용도 | 서버 측 WebSocket 핸들링, 룸 관리·네임스페이스 필요 시 (Socket.IO 유지 고려) |
실무에서 흔한 실수
- 존재하지 않는 모듈을 import 시도:
import { WebSocket } from 'node:websocket'같은 코드는 동작하지 않습니다.WebSocket은 전역 클래스이므로 import 없이new WebSocket(url)로 바로 사용합니다. - 서버와 클라이언트를 혼동: 내장 WebSocket으로 서버를 만들려다 막히는 경우가 많습니다. 서버는
node:http의upgrade이벤트나ws패키지로 구현해야 합니다.
require(esm) 안정화
| 항목 | 내용 |
|---|---|
| 장점 | ERR_REQUIRE_ESM 해결, 레거시 CJS에서 최신 ESM 패키지 즉시 활용, 빌드 파이프라인 단순화 |
| 단점 | top-level await 포함 ESM은 여전히 동적 import() 필요, "module-sync" 미지원 구형 패키지에서 문제 소지 |
| 추천 용도 | 레거시 CJS 코드베이스에서 ESM 전용 패키지 사용, Babel 설정 단순화 |
| 비추천 용도 | top-level await에 의존하는 초기화 로직을 가진 패키지 |
실무에서 흔한 실수
- 모든 ESM이
require()가능하다고 착각:top-level await가 있는 ESM은 여전히 동적import()가 필요합니다. 패키지 소스에서await가 모듈 최상위에 쓰였는지 확인하는 습관이 필요합니다. - 구형 패키지 호환성 미확인: 패키지의
package.json에서exports필드에"module-sync"조건이 있는지 확인하세요. 없다면require()가 실패할 수 있습니다.
Permission Model
| 항목 | 내용 |
|---|---|
| 장점 | 프로세스 수준 격리, 공급망 공격의 파일·프로세스 페이로드 차단, 제로 트러스트 지향 아키텍처 구현 가능 |
| 단점 | 네트워크 제어는 범위 밖(별도 수단 필요), 레거시 패키지가 광범위한 파일시스템 접근을 가정하면 운영 번거로움, 초기 권한 목록 파악에 시간 소요 |
| 추천 용도 | 외부 패키지를 많이 쓰는 마이크로서비스, 보안 민감 환경, 컨테이너 배포 |
| 비추천 용도 | 빠른 프로토타이핑 단계, 레거시 모노리스에 바로 적용 시 |
실무에서 흔한 실수
--allow-fs-read=*로 너무 넓게 허용: 이렇게 설정하면 Permission Model을 켠 의미가 없습니다. 구체적인 경로를 지정하는 최소 권한 원칙이 핵심입니다.- 네트워크 차단을 기대: 앞서 강조한 대로 Node.js Permission Model은 네트워크를 제어하지 않습니다. 외부 통신 통제가 목적이라면 방화벽, 아웃바운드 프록시, 서비스 메시 정책을 병행해야 합니다.
- 자식 프로세스 필요성을 나중에 발견:
--permission을 켠 상태에서 배포 후--allow-child-process없이child_process.exec를 호출하는 라이브러리가 있으면 런타임에 실패합니다. 도입 전 스테이징에서 통합 테스트로 실제 호출 경로를 훑어야 합니다.
내장 기능으로 의존성 줄이기, 어디까지 현실적인가
정리하자면 이렇습니다. node:sqlite로 네이티브 빌드 없는 데이터베이스를, 내장 WebSocket으로 서드파티 없는 실시간 클라이언트를, require(esm)으로 CJS/ESM 경계를, Permission Model로 프로세스 수준 격리를 얻을 수 있습니다. 다만 대부분은 Node.js 22에서 시작된 흐름이 24에서 마무리된 것에 가깝고, node:sqlite처럼 아직 Experimental 상태인 것도 있습니다.
모든 것을 당장 바꿀 필요는 없습니다. 대규모 프로덕션 DB는 여전히 PostgreSQL이 낫고, 복잡한 WebSocket 서버는 ws 패키지가 편리하며, 네트워크 접근 통제는 Permission Model 밖의 문제입니다. 하지만 새 프로젝트를 시작하거나 의존성을 정리할 기회가 생긴다면, 내장 기능부터 먼저 시도해볼 만합니다.
어디서부터 시작할까
node:sqlite로컬 도입: 테스트 픽스처나 개발 환경 설정 저장에 먼저 적용해 봅니다.better-sqlite3와 API가 거의 같아서 마이그레이션 비용이 낮고, Experimental 딱지 때문에 걱정된다면 개발용부터 시작하면 됩니다.- 레거시 코드에서
require(esm)검증:ERR_REQUIRE_ESM에러를 만나던 패키지를 Node.js 24에서 그냥require()로 불러봅니다.top-level await가 없고"module-sync"조건이 있는 패키지라면 대부분 바로 동작합니다. - Permission Model 스테이징 적용: 스테이징 환경에서
--permission+ 최소한의--allow-fs-*조합으로 서비스를 띄워보고, 통합 테스트로 실패 지점을 훑습니다. 여기서 필요한 권한 목록을 확정한 뒤 프로덕션으로 옮기는 순서가 안전합니다.
참고 자료
- Node.js 24.0.0 공식 릴리스 노트
- OpenJS Foundation — What's New with Node.js 24
- Node.js 공식 문서 — SQLite API (node:sqlite)
- Node.js 공식 문서 — Permissions & Permission Model
- Node.js 공식 문서 — WebSocket (Learn)
- Joyee Cheung — require(esm) in Node.js: from experiment to stability
- Joyee Cheung — require(esm) in Node.js: implementer's tales
- AppSignal Blog — What's New in Node.js 24
- Better Stack — Getting Started with Native SQLite in Node.js
- LogRocket — Using the built-in SQLite module in Node.js
- LogRocket — 10 Node.js 24 features you're probably not using
- Stackademic — Native WebSocket Support in Node.js 24
- NodeSource — Node.js 24 Is Here: What You Need to Know
- Red Hat — An introduction to Node.js 24