Deno 2.0 프로덕션 2026: Node.js에서 마이그레이션하며 실제로 달라진 것들

Deno 2.0이 나왔을 때 솔직히 별로 기대 안 했다. 2020년에 Deno 1.0 나왔을 때도 “Node.js 죽인다”는 말 많았고, 나도 그때 이틀 써보다가 npm 생태계 없어서 그냥 접었다. 그게 끝이었다.

그런데 올해 초, 우리 팀(나 포함 셋)이 유지보수하던 내부 API 서버를 건드릴 일이 생겼다. Express 4.x 기반에 TypeScript를 ts-node로 돌리는 그 지저분한 구조. tsconfig.json, .eslintrc, jest.config.js, prettier.config.js… 설정 파일만 열 개가 넘었다. 그걸 보다가 “이번엔 한번 제대로 써보자”는 생각이 들었다.

2주 동안 써봤다. 이 글은 그 경험이다.


첫 번째 시도에서 포기했다가 다시 돌아온 이유

처음 마이그레이션 시도는 2024년 말이었다. Deno 2.0.0이 막 릴리즈된 직후. 그때 npm: specifier가 생겼다는 말에 한번 해보려다가, 우리가 쓰던 express를 그냥 npm:express로 바꾸면 될 줄 알았다. 됐다. 근데 express가 의존하는 미들웨어 몇 개가 내부적으로 Node.js 전용 API를 쓰는 바람에 런타임 에러가 터졌다. process.binding이라든가, vm 모듈 일부라든가. 그때 “아직 멀었네” 하고 접었다.

올해 초에 다시 시도한 건 2.3.x 버전이었는데, Node.js 호환성 레이어가 꽤 달라져 있었다. node: prefix를 쓰면 fs, path, crypto, buffer 같은 표준 모듈들이 그냥 된다. process 객체도 웬만한 건 지원한다. Deno 팀이 Node.js 호환성을 전략적으로 밀기로 한 게 체감될 정도였다.

그래도 맨 처음엔 반신반의했다. 프레임워크를 hono로 바꾸기로 한 건 어차피 Express에서 성능 문제가 있어서였고, Deno는 그 과정에서 같이 시도해본 거였다.


npm 호환성: 생각보다 훨씬 쓸 만하지만, 함정이 있다

npm:hono, npm:zod, npm:drizzle-orm 다 문제없이 돌아간다. Drizzle은 처음에 좀 걱정했는데, 내가 쓰는 PostgreSQL 드라이버(npm:postgres)까지 포함해서 전혀 문제없었다. 이건 진짜 놀라웠다.

근데 함정이 있다. node_modules가 없는 게 아니다. Deno도 로컬에 캐시를 만드는데, 기본 위치가 ~/.deno/npm/registry.npmjs.org/다. 팀원이랑 처음에 이걸 몰라서 “왜 내 컴퓨터에서만 되냐”는 상황이 있었다. CI에서는 DENO_DIR 환경변수로 캐시 경로를 명시적으로 잡아줘야 한다는 걸 그때 알았다.

// deno.json
{
  "imports": {
    "hono": "npm:hono@^4.6.0",
    "zod": "npm:zod@^3.22.0",
    "postgres": "npm:postgres@^3.4.0"
  },
  "tasks": {
    "dev": "deno run --watch --allow-net --allow-env --allow-read src/main.ts",
    "start": "deno run --allow-net --allow-env --allow-read src/main.ts"
  }
}

Import map을 deno.json 안에 직접 쓸 수 있어서 별도 파일이 필요 없다. 이건 확실히 편하다.

한 가지 더 — npm: 패키지 중에 네이티브 바이너리를 포함한 것들은 아직 운이 좀 필요하다. 우리 프로젝트에서 이미지 리사이징에 sharp를 썼는데, 이게 libvips에 의존하는 네이티브 모듈이라 안 됐다. 결국 그 기능만 별도 Node.js 마이크로서비스로 남겨두고 HTTP로 통신하는 방식으로 우회했다. 깔끔한 해결책은 아니지만, 나머지 90%를 Deno로 옮기는 건 가능했다.


퍼미션 시스템이 프로덕션에서 어떻게 작동하는지 — 금요일 오후에 배운 교훈

퍼미션 시스템은 Deno 초기부터 있던 특징인데, 실제로 프로덕션에 처음 붙여봤을 때 예상과 달랐다.

이론: 명시적으로 허용한 것만 가능하니까 보안상 좋다.
현실: 처음에 퍼미션을 너무 넓게 주면 의미가 없고, 너무 좁게 주면 런타임에 터진다.

금요일 오후에 배포했는데 — 왜 항상 금요일이냐 — 배포 직후에 헬스체크는 통과하는데 특정 엔드포인트에서만 에러가 났다. 알고 보니 그 엔드포인트에서 임시 파일을 /tmp에 쓰는 로직이 있었고, --allow-write--allow-write=/tmp로 제한해놨는데 실제로는 /var/tmp를 쓰는 라이브러리가 있었다. 로컬에서는 --allow-write를 전체로 줬으니 못 잡았던 거다.

이 경험 이후로 테스트 환경에서도 프로덕션과 동일한 퍼미션 플래그를 쓰도록 바꿨다. 당연한 말 같지만, Node.js에서 이런 식으로 생각할 일이 없었으니까 처음엔 습관이 안 됐다.

퍼미션 시스템 자체는 좋다고 생각한다. “기본적으로 안전하다”는 게 “따로 신경 안 써도 된다”는 의미가 아니라는 걸 명심해야 한다. 오히려 명시적으로 관리해야 할 것들이 생기는 거다. 나한테는 부담보다 장점이 더 컸다 — 서비스가 어떤 리소스에 접근하는지 실행 커맨드 한 줄로 파악할 수 있어서 코드 리뷰할 때 실제로 도움이 됐다.

// src/main.ts
// 실행: deno run --allow-net=0.0.0.0:8080 --allow-env=DATABASE_URL,PORT --allow-write=/tmp src/main.ts

import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";

const app = new Hono();

app.get("/health", (c) => c.json({ status: "ok", runtime: "deno" }));

app.post(
  "/process",
  zValidator(
    "json",
    z.object({
      id: z.string().uuid(),
      payload: z.string().max(10_000),
    })
  ),
  async (c) => {
    const { id, payload } = c.req.valid("json");
    // fetch, crypto 등 Web API는 별도 import 없이 전역에서 사용 가능
    const hashBuffer = await crypto.subtle.digest(
      "SHA-256",
      new TextEncoder().encode(payload)
    );
    const hash = Array.from(new Uint8Array(hashBuffer))
      .map((b) => b.toString(16).padStart(2, "0"))
      .join("");
    return c.json({ id, processed: true, hash });
  }
);

Deno.serve({ port: Number(Deno.env.get("PORT") ?? 8080) }, app.fetch);

Deno.servecreateServer보다 훨씬 간단한 건 소소하게 좋다.


TypeScript, 포매터, 테스트 — 설정 파일이 사라지는 느낌

Node.js 프로젝트에서 TypeScript 세팅을 처음부터 다시 한 적 있으면 알 거다. tsc, ts-node 또는 tsx, tsconfig.json 경로 설정, eslint-plugin-@typescript-eslint, Prettier 설정… 이게 다 따로 논다.

Deno는 TypeScript를 그냥 실행한다. deno run src/main.ts하면 된다. 타입 체크도 deno check src/main.ts. 포매팅은 deno fmt, 린팅은 deno lint. 전부 deno.json 하나로 설정 가능하다.

처음에 “그래도 ESLint만큼 세밀하게 설정 못 하겠지”라고 생각했는데, 솔직히 우리 팀이 ESLint에서 실제로 쓰던 규칙이 뭐가 있었나 다시 보니까… deno lint가 기본으로 잡아주는 것들이랑 크게 다르지 않았다. 오히려 no-explicit-any 같은 건 더 잘 잡는다.

테스트는 deno test를 쓰는데, Jest 방식이랑 다르다. Deno.test() API를 쓰는데, 처음엔 낯설었지만 적응하면 괜찮다. 단, 기존 Jest 테스트를 그대로 옮기는 건 안 된다. 우리 팀에서 테스트 마이그레이션이 제일 시간이 걸렸다 — 약 200개 테스트를 옮기는 데 나 혼자 사흘쯤 걸렸다. 마이그레이션 비용을 계산할 때 이 부분을 가장 과소평가하기 쉽다.

그래도 deno test --watch로 파일 변경 감지 테스트 실행이 되는 건 편했다. 설정 없이.


2주 후 결론 — 어떤 프로젝트에 쓸 것인가

새로 만드는 백엔드 서비스라면 지금 당장 Deno 2.x로 시작하는 게 맞다고 생각한다. 생태계가 충분히 성숙했고, hono + drizzle-orm 조합이면 웬만한 REST API는 다 만들 수 있다. TypeScript 설정에 쓰는 시간이 줄어드는 것만으로도 초기 세팅이 훨씬 빠르다.

기존 Node.js 프로젝트를 마이그레이션하는 건 다르다. 규모에 따라 달라지는데, 우리처럼 15,000줄 정도면 2~3주는 각오해야 한다. 특히 네이티브 모듈 의존성이 있으면 더 걸린다. 무조건 하라고 말하긴 어렵다. 다만 Express에서 다른 프레임워크로 어차피 이전할 계획이 있다면, 그 타이밍에 같이 하는 게 제일 효율적이다.

한 가지, 아직 확신하지 못하는 게 있다. 팀 규모가 10명 이상으로 커지거나 모노레포 구조를 복잡하게 가져갈 때 Deno가 얼마나 잘 버텨주는지. 우리가 세 명이라 단순한 구조에서만 써봤고, 대규모 조직에서의 경험이 없다. 그 부분은 아직 모르겠다.

Deno 2.x는 이제 “실험적인 것”이 아니다. 2주 동안 실제로 트래픽 받는 서비스에서 돌려봤고, 안정적이었다. Node.js가 기본값이어야 한다는 생각을 이제는 안 한다.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top