Deno 2 실전 가이드 — Node.js 호환 모드·내장 KV·Queues로 외부 인프라 없이 서버리스 백엔드 구축하기
Deno 2가 2024년 10월에 공개됐을 때 백엔드 개발자들이 가장 먼저 던진 질문은 하나였습니다. 기존 npm 생태계와 얼마나 자연스럽게 공존하는가. 이전 버전의 Deno는 "npm이 안 된다"는 이유 하나로 실무 후보에서 곧바로 제외됐으니까요.
이 글은 공식 문서와 릴리스 노트를 정독하고 샘플 프로젝트를 직접 만들어본 결과를 토대로 작성한 가이드입니다. Node.js 호환 모드가 어디까지 실용적인지, Deno KV와 Queues가 Redis·RabbitMQ 역할을 대신할 수 있는 영역과 그 한계가 어디인지를 다룹니다. TypeScript 설정 없이 .ts 파일을 바로 실행하고, 테스트·린터·포매터가 내장된 환경을 원하는 분이라면 관심 있게 읽어주세요.
중요한 전제부터 짚겠습니다. Deno는 Node.js를 전면적으로 대체하는 선택지가 아닙니다. 커뮤니티 규모와 서드파티 생태계 측면에서 아직 격차가 크고, 사용률도 빠르게 성장 중이지만 여전히 Node.js보다 훨씬 작습니다. 그러나 새로운 서버리스 프로젝트, 엣지 함수, 사이드 서비스처럼 인프라를 간소화하고 싶은 영역에서는 충분히 현실적인 선택지가 됐습니다.
버전 기준: 이 글의 예제는 Deno 2.x를 기준으로 작성했습니다. KV, Queues, Cron은 Deno 2.0에서 안정화된 API입니다. OpenTelemetry 지원은
--unstable-otel플래그가 여전히 필요합니다. 최신 버전의 플래그 요구 사항은 공식 릴리스 노트에서 확인하세요.
핵심 개념
Deno 2가 Node.js 생태계와 공존하는 방식
Deno 2의 가장 결정적인 변화는 하위 호환성입니다. 이제 세 가지 방식으로 npm 생태계를 그대로 끌어쓸 수 있습니다.
npm:접두어 임포트 — 별도 설치 없이 즉시 import- 기존
package.json인식 —deno install이 의존성을 설치하고node_modules생성 node:접두어로 Node 내장 모듈 —node:fs,node:path,node:http등 그대로 사용
// npm: 접두어 — node_modules 없이 즉시 사용
import express from "npm:express@4";
import { Hono } from "npm:hono@4";
// node: 접두어 — Node.js 내장 모듈 그대로
import { createServer } from "node:http";
import { join } from "node:path";Deno가 import 구문을 어떻게 해석하는지 흐름을 보면 이렇습니다.
JSR이란? JavaScript Registry의 약자로, Deno 팀이 만든 npm 대안 레지스트리입니다. TypeScript 소스를 그대로 배포하고, 버전 불변성을 보장하며, Deno와 Node.js 양쪽에서 모두 사용할 수 있도록 설계됐습니다.
jsr:@std/http처럼 스코프된 이름을 씁니다.
한 가지 주의할 점이 있습니다. 접두어 없는 import를 처리할 때 Deno는 deno.json의 import map을 package.json보다 먼저 확인합니다. 두 파일이 공존하는 프로젝트에서는 이 우선순위를 인지하고 있어야 합니다.
Deno 측 발표 기준으로 npm 패키지 대부분이 호환되지만, native addon(.node 바이너리)이나 특정 CommonJS 패턴은 예외가 있습니다. 전환 전에 deno check 또는 실행 테스트로 의존성을 먼저 검증해두는 것이 좋습니다.
Deno KV — 런타임에 내장된 키-값 저장소
Deno KV의 설계 철학은 단순합니다. 로컬 개발에서는 SQLite 위에서 동작하지만, Deno Deploy에 올리는 순간 FoundationDB 기반의 전 세계 분산 저장소로 자동 전환됩니다. 코드는 동일하게 Deno.openKv()만 씁니다.
const kv = await Deno.openKv();
// 계층적 키 구조로 데이터 구성
await kv.set(["users", "alice"], { name: "Alice", email: "alice@example.com" });
await kv.set(["users", "bob"], { name: "Bob", email: "bob@example.com" });
// 단일 키 조회
const user = await kv.get(["users", "alice"]);
console.log(user.value); // { name: "Alice", email: "alice@example.com" }
// 접두어 기반 목록 조회
const iter = kv.list({ prefix: ["users"] });
for await (const entry of iter) {
console.log(entry.key, entry.value);
}키는 배열로 표현되며, 이 계층 구조를 활용해 ["posts", postId, "comments"]처럼 논리적인 네임스페이스를 만들 수 있습니다. Redis의 키 이름 규칙(posts:123:comments)과 비슷한 개념이지만 타입 안전성이 더 높습니다.
ACID 트랜잭션과 낙관적 동시성 제어(OCC) 도 지원합니다. 핵심 개념은 versionstamp입니다. Deno KV는 각 키-값 쌍에 쓰기가 발생할 때마다 단조 증가하는 식별자를 자동으로 할당합니다. .check(entry)는 "내가 읽었던 시점의 versionstamp가 커밋 시점에도 동일한가"를 확인합니다. 다른 트랜잭션이 중간에 해당 키를 수정했다면 versionstamp가 달라져 커밋이 실패하고, 루프를 돌며 재시도합니다.
재고 차감이나 잔액 이동처럼 경합 조건이 생기는 상황에서 유용합니다.
async function transferBalance(fromId: string, toId: string, amount: number) {
const kv = await Deno.openKv();
while (true) {
const [fromEntry, toEntry] = await kv.getMany([
["balances", fromId],
["balances", toId],
]);
if (fromEntry.value === null || toEntry.value === null) {
throw new Error("계좌를 찾을 수 없습니다");
}
const fromBalance = fromEntry.value as number;
const toBalance = toEntry.value as number;
if (fromBalance < amount) throw new Error("잔액 부족");
const res = await kv.atomic()
.check(fromEntry) // versionstamp 변화 감지
.check(toEntry)
.set(["balances", fromId], fromBalance - amount)
.set(["balances", toId], toBalance + amount)
.commit();
if (res.ok) break; // 성공 시 종료, 충돌 시 재시도
}
}한 가지 제약을 미리 알아두면 좋습니다. kv.watch() 호출당 최대 10개 키, 원자적 작업당 총 크기 800KiB 등의 제한이 있습니다(정확한 수치는 버전에 따라 달라질 수 있으니 공식 문서를 참조하세요). JOIN이나 집계 쿼리는 KV로 구현하기 어렵고, 그런 용도라면 PostgreSQL과 병행하는 구성이 현실적입니다.
Deno Queues — 별도 인프라 없이 구현하는 메시지 큐
Queues는 KV 위에 구축된 비동기 작업 처리 시스템입니다. 별도 데몬이나 인프라 없이 kv.enqueue()로 발행하고 kv.listenQueue()로 처리합니다. 최소 1회(at-least-once) 전달을 보장하므로, Redis + Bull Queue 패턴과 유사한 구조를 외부 의존성 없이 만들 수 있습니다.
const kv = await Deno.openKv();
// 메시지 발행 — 즉시 실행
await kv.enqueue({
type: "welcome-email",
jobId: crypto.randomUUID(),
userId: "alice",
to: "alice@example.com",
});
// 지연 실행 — 30분 후 처리
await kv.enqueue(
{ type: "trial-reminder", jobId: crypto.randomUUID(), userId: "alice" },
{ delay: 30 * 60 * 1000 }
);at-least-once 의미론이므로 멱등성(idempotency) 처리는 직접 구현해야 합니다. 동일 메시지가 두 번 처리돼도 결과가 같아야 한다는 뜻인데, 이건 RabbitMQ나 SQS를 쓸 때도 마찬가지인 문제입니다. 구체적인 구현 방법은 아래 "실무에서 흔한 실수" 절에서 다룹니다.
실전 적용
네 가지 시나리오를 살펴보기 전에 전체 구성이 어떻게 맞물리는지 먼저 파악하면 개별 예제가 더 잘 들어옵니다.
KV, Queues, Cron은 로컬 개발 환경에서도 그대로 동작합니다. Deno Deploy에 올리면 KV 저장소만 로컬 SQLite에서 전 세계 분산 저장소로 자동 전환됩니다.
시나리오 1: 기존 Express 앱을 Deno에서 실행하기
마이그레이션을 고민 중이라면 전부 고치지 않아도 됩니다. 기존 프로젝트 루트에서 아래만 실행하면 됩니다.
# 기존 package.json의 의존성 설치
deno install
# package.json의 scripts.start를 deno task로 실행
deno task start
# 또는 직접 실행 (필요한 퍼미션 명시)
deno run --allow-net --allow-env --allow-read src/index.tsnpm: 접두어 방식으로 한 파일씩 점진적으로 전환하는 것도 가능합니다.
import express from "npm:express@4";
import cors from "npm:cors@2";
const app = express();
app.use(cors());
app.get("/health", (_req, res) => {
res.json({ status: "ok", runtime: "deno" });
});
app.listen(3000, () => console.log("서버 시작: http://localhost:3000"));package.json이 있는 폴더에서 실행하면 Deno가 알아서 node_modules를 탐색합니다. 굳이 모든 import를 npm:으로 바꾸지 않아도 됩니다.
호환 안 되는 경우를 사전에 확인하세요. native addon(.node 바이너리)을 사용하는 패키지는 동작하지 않습니다. bcrypt, better-sqlite3처럼 C++ 바인딩에 의존하는 패키지가 이 범주에 해당합니다. 또 require()를 동적으로 사용하는 CommonJS 패턴 일부도 오류가 발생할 수 있습니다. 전환 전에 deno check 또는 실행 테스트로 의존성을 먼저 검증해두는 것이 좋습니다.
시나리오 2: Hono + KV로 경량 REST API 서버 구성
새 프로젝트라면 Hono + Deno KV 조합이 가장 간결합니다. Hono는 Express 스타일이지만 Deno, Bun, Cloudflare Workers에서 모두 동작하는 멀티 런타임 프레임워크입니다.
// main.ts
import { Hono } from "npm:hono@4";
type EmailQueueMessage =
| { type: "verify-email"; jobId: string; userId: string; email: string }
| { type: "welcome-email"; jobId: string; userId: string; email: string };
const app = new Hono();
const kv = await Deno.openKv();
// 사용자 생성
app.post("/users", async (c) => {
const body = await c.req.json();
const id = crypto.randomUUID();
const user = { id, ...body, createdAt: new Date().toISOString() };
await kv.set(["users", id], user);
const msg: EmailQueueMessage = {
type: "verify-email",
jobId: crypto.randomUUID(),
userId: id,
email: body.email,
};
await kv.enqueue(msg);
return c.json(user, 201);
});
// 사용자 조회
app.get("/users/:id", async (c) => {
const entry = await kv.get(["users", c.req.param("id")]);
if (!entry.value) return c.json({ error: "Not found" }, 404);
return c.json(entry.value);
});
// 이메일 처리 워커
kv.listenQueue(async (msg: EmailQueueMessage) => {
switch (msg.type) {
case "verify-email":
console.log(`이메일 인증 발송: ${msg.email}`);
// 실제 메일 발송 로직
break;
case "welcome-email":
console.log(`환영 이메일 발송: ${msg.email}`);
break;
}
});
Deno.serve({ port: 3000 }, app.fetch);deno run --allow-net --allow-env main.tsDocker도, Redis도, 별도의 워커 프로세스도 없습니다. 파일 하나에 API 서버와 백그라운드 처리가 함께 들어 있습니다.
시나리오 3: KV Watch로 실시간 채팅 구현
WebSocket과 KV의 watch() API를 조합하면 Pub/Sub 인프라 없이 실시간 기능을 만들 수 있습니다. 새 메시지가 들어올 때마다 KV의 특정 인디케이터 키를 업데이트하고, 연결된 클라이언트마다 그 키를 감시하는 구조입니다.
// chat.ts
const kv = await Deno.openKv();
Deno.serve({ port: 8080 }, async (req) => {
if (req.headers.get("upgrade") !== "websocket") {
return new Response("WebSocket만 허용됩니다", { status: 400 });
}
const url = new URL(req.url);
const roomId = url.searchParams.get("room") ?? "general";
const { socket, response } = Deno.upgradeWebSocket(req);
socket.onopen = async () => {
// 최근 50개 메시지 전송
const messages = [];
for await (const entry of kv.list(
{ prefix: ["messages", roomId] },
{ limit: 50, reverse: true }
)) {
messages.unshift(entry.value);
}
socket.send(JSON.stringify({ type: "history", messages }));
// 새 메시지 감시 시작
const watcher = kv.watch([["last_message_id", roomId]]);
// WebSocket 종료 시 watcher 정리 — 누락 시 스트림 리소스 누수
socket.onclose = () => watcher.cancel();
(async () => {
for await (const [entry] of watcher) {
if (socket.readyState !== WebSocket.OPEN) break;
const latest = await kv.get(["messages", roomId, entry.value as string]);
if (latest.value) {
socket.send(JSON.stringify({ type: "message", data: latest.value }));
}
}
})();
};
socket.onmessage = async (e) => {
const { text, userId } = JSON.parse(e.data);
const msgId = crypto.randomUUID();
const message = { id: msgId, text, userId, timestamp: Date.now() };
await kv.atomic()
.set(["messages", roomId, msgId], message)
.set(["last_message_id", roomId], msgId)
.commit();
};
return response;
});kv.watch()의 10개 키 제한을 감안하면, 채팅방이 많아질 때 last_message_id처럼 단일 키를 인디케이터로 활용하는 패턴이 효과적입니다.
시나리오 4: 크론 잡 스케줄링
Celery나 crontab 없이 코드 안에서 스케줄을 선언할 수 있습니다. Deno Deploy에 배포하면 별도 인프라 없이 그대로 실행됩니다.
Deno.cron("daily-report", "0 9 * * *", async () => {
const kv = await Deno.openKv();
const stats = await collectDailyStats(kv);
await sendSlackReport(stats);
});
Deno.cron("cleanup-sessions", "*/30 * * * *", async () => {
const kv = await Deno.openKv();
const now = Date.now();
for await (const entry of kv.list({ prefix: ["sessions"] })) {
const session = entry.value as { expiresAt: number };
if (session.expiresAt < now) {
await kv.delete(entry.key);
}
}
});장단점 분석
장점
| 항목 | 실무 의미 |
|---|---|
| 제로-설정 TypeScript | tsconfig.json 없이 .ts 파일 직접 실행. ts-node, tsx 설치 불필요 |
| 내장 도구 일체 | deno test, deno lint, deno fmt — 별도 설치 없이 모두 사용 가능 |
| KV + Queues + Cron 내장 | Redis, RabbitMQ, Bull Queue, Celery 없이 키-값 스토어·메시지 큐·스케줄링 구현 가능 |
| 보안 기본값 | 파일·네트워크·환경변수 접근이 기본 차단. --allow-net=host:port처럼 특정 호스트·포트 단위로 세분화 제어 가능 |
| 가벼운 콜드 스타트 | Deno Deploy의 엣지 런타임은 컨테이너 없이 실행되므로 콜드 스타트가 짧습니다. 구체적인 수치는 배포 환경마다 달라지니 직접 측정을 권장합니다 |
| 내장 OpenTelemetry | --unstable-otel 플래그로 분산 트레이싱·메트릭 즉시 활성화 |
| 단일 바이너리 배포 | deno compile로 런타임 포함 실행 파일 생성. Docker 없이 CLI 도구 배포 가능 |
| npm 대다수 호환 | Deno 측 발표 기준 npm 패키지 대부분을 npm: 접두어로 바로 사용 가능 |
단점 및 주의사항
| 항목 | 실무 의미 |
|---|---|
| 상대적으로 작은 생태계 | 커뮤니티 Q&A, 서드파티 튜토리얼이 적음. 문제 발생 시 직접 원인 파악해야 하는 경우가 많음 |
| KV의 구조적 한계 | JOIN·집계 쿼리 불가. watch() 최대 10키, 원자적 작업 크기 제한 존재 (수치는 공식 문서 확인) |
| at-least-once 큐 의미론 | 멱등성을 직접 구현해야 함. "정확히 1회" 보장이 필요하다면 외부 큐가 필요 |
| Deno Deploy 벤더 종속 | KV의 전 세계 분산 기능은 Deno Deploy 전용. 자체 호스팅 시 단일 노드 SQLite 수준 |
| 마이그레이션 비용 | 대규모 운영 프로젝트는 CI/CD 업데이트, native addon 호환성 검토, 팀 교육이 필요 |
| 팀 채용 난이도 | Deno 전문가는 희소. 신규 입사자 온보딩 시 러닝 커브 발생 |
| native addon 미지원 | .node 바이너리를 사용하는 패키지 사용 불가 |
실무에서 흔한 실수
1. 퍼미션 누락으로 런타임 오류
--allow-net을 빠뜨리면 네트워크 요청 시 런타임 에러가 발생합니다. 개발 편의상 --allow-all을 쓰고 싶을 수 있는데, 프로덕션에서는 필요한 퍼미션만 명시하는 방식이 보안상 더 안전합니다.
# 개발 중 — 편의를 위해 전체 허용
deno run --allow-all main.ts
# 프로덕션 — 최소 퍼미션 명시
deno run \
--allow-net=0.0.0.0:3000 \
--allow-env=DATABASE_URL,SMTP_HOST \
--allow-read=/app/static \
main.ts2. at-least-once 큐에서 멱등성 미처리
kv.listenQueue()는 동일 메시지를 두 번 처리할 수 있습니다. 이메일 발송, 결제 처리 같은 부작용이 있는 작업은 반드시 중복 처리 방지 로직이 필요합니다. get 후 set하는 방식은 두 호출 사이에 재진입이 발생할 수 있으므로, 원자적 클레임-선점 패턴을 사용해야 합니다.
type QueueMessage =
| { type: "send-email"; jobId: string; to: string }
| { type: "process-payment"; jobId: string; orderId: string };
kv.listenQueue(async (msg: QueueMessage) => {
const markerKey = ["processing_jobs", msg.jobId];
// 원자적으로 처리 마커 선점 — 이미 존재하면 commit 실패
const claim = await kv.atomic()
.check({ key: markerKey, versionstamp: null })
.set(markerKey, true, { expireIn: 24 * 60 * 60 * 1000 })
.commit();
if (!claim.ok) return; // 이미 처리됐거나 처리 중
switch (msg.type) {
case "send-email":
await sendEmail(msg.to);
break;
case "process-payment":
await processPayment(msg.orderId);
break;
}
});3. KV를 관계형 데이터처럼 쓰려는 시도
복잡한 필터링이나 집계가 필요한 데이터는 KV에 적합하지 않습니다. 세션 토큰, 캐시 데이터, 간단한 사용자 프로필처럼 키로 직접 조회하는 패턴에 가장 잘 맞습니다. 복잡한 쿼리가 필요한 핵심 데이터는 PostgreSQL 등 관계형 DB와 병행하는 구성이 현실적입니다.
마치며
Deno 2는 "Node.js를 대체"하는 것이 아니라 "특정 상황에서 훨씬 간결한 선택지"입니다. 운영 중인 대규모 Node.js 서비스를 당장 전환할 이유는 없습니다. 그러나 새로운 서버리스 서비스, 엣지 함수, 사이드 프로젝트처럼 인프라를 최소화하고 싶은 영역이라면 실제로 검토할 가치가 있습니다.
핵심을 정리하면: TypeScript 네이티브 + 내장 KV·Queues·Cron + 가벼운 콜드 스타트 + npm 대다수 호환이 Deno 2가 제공하는 실질적인 가치입니다. Redis, RabbitMQ, tsconfig.json, 별도의 테스트 러너를 모두 걷어낼 수 있다면 개발 환경이 훨씬 단순해집니다. 반면 생태계 규모, KV의 구조적 제약, Deno Deploy 종속성, 팀 채용 난이도는 현실적으로 고려해야 할 요소들입니다.
시작하고 싶다면 이 순서를 권장합니다.
1단계 — 설치하고 기존 코드 실행해보기
# macOS/Linux
curl -fsSL https://deno.land/install.sh | sh
# 기존 Node.js 프로젝트 폴더에서
deno install
deno task start2단계 — 새 사이드 프로젝트에 KV + Queues 써보기
Hono + Deno KV 조합으로 작은 API 서버 하나를 만들어보세요. Redis 없이 세션 관리나 간단한 큐 처리가 얼마나 간결해지는지 직접 경험하는 것이 이 생태계를 파악하는 빠른 방법 중 하나입니다.
3단계 — Deno Deploy 무료 티어에 배포해보기
GitHub 리포지토리를 연결하면 push마다 자동 배포됩니다. KV가 로컬 SQLite에서 전 세계 FoundationDB로 자동 전환되는 경험을 해보면 이 생태계가 어떤 방향을 지향하는지 더 명확하게 보일 것입니다.