Deno.serve()를 활용한 HTTP 서버 구축부터 Hono 프레임워크, PostgreSQL 및 Deno KV 데이터베이스 연동, 테스트 작성, 배포까지 REST API 개발 전 과정을 다룹니다.
이 장에서는 Deno로 실제 프로덕션에서 사용할 수 있는 REST API를 단계별로 구축합니다. 가장 기본적인 Deno.serve()부터 시작하여, Hono 프레임워크 도입, 데이터베이스 연동, 인증, 테스트, 배포까지 전 과정을 다룹니다.
Deno.serve({ port: 8000 }, async (req: Request): Promise<Response> => {
const url = new URL(req.url);
const method = req.method;
// 라우팅
if (url.pathname === "/api/health" && method === "GET") {
return Response.json({ status: "ok", timestamp: new Date().toISOString() });
}
if (url.pathname === "/api/echo" && method === "POST") {
const body = await req.json();
return Response.json({ echo: body });
}
return Response.json({ error: "Not Found" }, { status: 404 });
});Deno.serve()는 웹 표준 Request와 Response를 직접 사용합니다. 추가 라이브러리 없이 바로 HTTP 서버를 구축할 수 있지만, 라우팅이 복잡해지면 프레임워크의 도움이 필요합니다.
type Handler = (req: Request, params: Record<string, string>) => Promise<Response> | Response;
interface Route {
method: string;
pattern: URLPattern;
handler: Handler;
}
class Router {
private routes: Route[] = [];
get(path: string, handler: Handler) {
this.addRoute("GET", path, handler);
}
post(path: string, handler: Handler) {
this.addRoute("POST", path, handler);
}
put(path: string, handler: Handler) {
this.addRoute("PUT", path, handler);
}
delete(path: string, handler: Handler) {
this.addRoute("DELETE", path, handler);
}
private addRoute(method: string, path: string, handler: Handler) {
this.routes.push({
method,
pattern: new URLPattern({ pathname: path }),
handler,
});
}
async handle(req: Request): Promise<Response> {
for (const route of this.routes) {
if (route.method !== req.method) continue;
const match = route.pattern.exec(req.url);
if (match) {
const params = match.pathname.groups as Record<string, string>;
return await route.handler(req, params);
}
}
return Response.json({ error: "Not Found" }, { status: 404 });
}
}
export { Router };URLPattern은 웹 표준 API로, URL 경로의 패턴 매칭을 수행합니다. Deno에서 기본 지원되며, Express의 app.get("/users/:id") 같은 동적 경로 매칭을 가능하게 합니다.
실제 프로젝트에서는 라우팅, 미들웨어, 에러 처리, 입력 검증 등 다양한 기능이 필요합니다. Hono는 이러한 요구를 충족하면서도 엣지 런타임에 최적화된 경량 웹 프레임워크입니다.
- 초경량: 코어 사이즈 ~14KB
- 멀티 런타임: Deno, Bun, Cloudflare Workers, Node.js
- TypeScript 기본: 완전한 타입 안전성
- 웹 표준 기반: Request/Response API 사용
- 풍부한 미들웨어: CORS, JWT, Logger, Validator 등{
"imports": {
"hono": "jsr:@hono/hono@^4",
"@std/assert": "jsr:@std/assert@^1",
"zod": "npm:zod@^3.22"
},
"tasks": {
"dev": "deno run --watch --allow-net --allow-env --allow-read=. src/main.ts",
"test": "deno test --allow-net --allow-env --allow-read=.",
"check": "deno check src/main.ts"
}
}import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import { prettyJSON } from "hono/pretty-json";
import { userRoutes } from "./routes/users.ts";
import { postRoutes } from "./routes/posts.ts";
const app = new Hono();
// 전역 미들웨어
app.use("*", logger());
app.use("*", prettyJSON());
app.use(
"/api/*",
cors({
origin: ["http://localhost:3000", "https://myapp.example.com"],
allowMethods: ["GET", "POST", "PUT", "DELETE"],
allowHeaders: ["Content-Type", "Authorization"],
}),
);
// 헬스 체크
app.get("/api/health", (c) => {
return c.json({
status: "ok",
version: "1.0.0",
timestamp: new Date().toISOString(),
});
});
// 라우트 마운트
app.route("/api/users", userRoutes);
app.route("/api/posts", postRoutes);
// 404 핸들러
app.notFound((c) => {
return c.json({ error: "요청한 리소스를 찾을 수 없습니다" }, 404);
});
// 전역 에러 핸들러
app.onError((err, c) => {
console.error(`에러 발생: ${err.message}`);
return c.json({ error: "내부 서버 오류가 발생했습니다" }, 500);
});
Deno.serve({ port: 8000 }, app.fetch);간단한 프로젝트나 프로토타이핑에서는 Deno KV만으로도 충분한 데이터 저장소 역할을 합니다.
interface User {
id: string;
name: string;
email: string;
createdAt: string;
updatedAt: string;
}
class UserStore {
private kv: Deno.Kv;
private constructor(kv: Deno.Kv) {
this.kv = kv;
}
static async create(): Promise<UserStore> {
const kv = await Deno.openKv();
return new UserStore(kv);
}
async findAll(): Promise<User[]> {
const users: User[] = [];
const iter = this.kv.list<User>({ prefix: ["users"] });
for await (const entry of iter) {
users.push(entry.value);
}
return users;
}
async findById(id: string): Promise<User | null> {
const result = await this.kv.get<User>(["users", id]);
return result.value;
}
async findByEmail(email: string): Promise<User | null> {
const result = await this.kv.get<string>(["users_by_email", email]);
if (!result.value) return null;
return this.findById(result.value);
}
async create(data: Omit<User, "id" | "createdAt" | "updatedAt">): Promise<User> {
const id = crypto.randomUUID();
const now = new Date().toISOString();
const user: User = {
id,
...data,
createdAt: now,
updatedAt: now,
};
// 이메일 중복 검사를 포함한 원자적 저장
const existingEmail = await this.kv.get(["users_by_email", user.email]);
if (existingEmail.value) {
throw new Error("이미 사용 중인 이메일입니다");
}
const result = await this.kv.atomic()
.check(existingEmail) // 이메일 중복 방지
.set(["users", id], user)
.set(["users_by_email", user.email], id)
.commit();
if (!result.ok) {
throw new Error("사용자 생성 실패 (동시성 충돌)");
}
return user;
}
async update(id: string, data: Partial<Pick<User, "name" | "email">>): Promise<User> {
const existing = await this.kv.get<User>(["users", id]);
if (!existing.value) {
throw new Error("사용자를 찾을 수 없습니다");
}
const updated: User = {
...existing.value,
...data,
updatedAt: new Date().toISOString(),
};
const result = await this.kv.atomic()
.check(existing)
.set(["users", id], updated)
.commit();
if (!result.ok) {
throw new Error("업데이트 실패 (동시성 충돌)");
}
return updated;
}
async delete(id: string): Promise<void> {
const existing = await this.kv.get<User>(["users", id]);
if (!existing.value) {
throw new Error("사용자를 찾을 수 없습니다");
}
await this.kv.atomic()
.check(existing)
.delete(["users", id])
.delete(["users_by_email", existing.value.email])
.commit();
}
}
export { UserStore, type User };프로덕션 환경에서 관계형 데이터가 필요하다면 PostgreSQL을 사용합니다.
import { Client } from "npm:pg@8";
class Database {
private client: Client;
constructor() {
this.client = new Client({
hostname: Deno.env.get("DB_HOST") ?? "localhost",
port: Number(Deno.env.get("DB_PORT") ?? 5432),
user: Deno.env.get("DB_USER") ?? "postgres",
password: Deno.env.get("DB_PASSWORD") ?? "",
database: Deno.env.get("DB_NAME") ?? "myapp",
});
}
async connect(): Promise<void> {
await this.client.connect();
}
async query<T>(sql: string, params?: unknown[]): Promise<T[]> {
const result = await this.client.queryObject<T>(sql, params);
return result.rows;
}
async close(): Promise<void> {
await this.client.end();
}
}
export { Database };PostgreSQL 연동 시 --allow-net 권한에 데이터베이스 호스트를 명시적으로 포함해야 합니다. 예: --allow-net=0.0.0.0:8000,localhost:5432. 또한 --allow-env=DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_NAME으로 필요한 환경 변수만 허용하세요.
import { Hono } from "hono";
import { z } from "zod";
import { UserStore } from "../db/kv-store.ts";
// 입력 검증 스키마
const CreateUserSchema = z.object({
name: z.string().min(2, "이름은 2글자 이상이어야 합니다"),
email: z.string().email("유효한 이메일 주소를 입력하세요"),
});
const UpdateUserSchema = z.object({
name: z.string().min(2).optional(),
email: z.string().email().optional(),
});
const userRoutes = new Hono();
let store: UserStore | null = null;
async function getStore(): Promise<UserStore> {
if (!store) {
store = await UserStore.create();
}
return store;
}
// GET /api/users - 사용자 목록 조회
userRoutes.get("/", async (c) => {
const userStore = await getStore();
const users = await userStore.findAll();
return c.json({ users, total: users.length });
});
// GET /api/users/:id - 사용자 상세 조회
userRoutes.get("/:id", async (c) => {
const userStore = await getStore();
const user = await userStore.findById(c.req.param("id"));
if (!user) {
return c.json({ error: "사용자를 찾을 수 없습니다" }, 404);
}
return c.json({ user });
});
// POST /api/users - 사용자 생성
userRoutes.post("/", async (c) => {
const body = await c.req.json();
// Zod로 입력 검증
const validation = CreateUserSchema.safeParse(body);
if (!validation.success) {
return c.json(
{
error: "입력 검증 실패",
details: validation.error.issues,
},
400,
);
}
try {
const userStore = await getStore();
const user = await userStore.create(validation.data);
return c.json({ user }, 201);
} catch (error) {
if (error instanceof Error && error.message.includes("이미 사용 중")) {
return c.json({ error: error.message }, 409);
}
throw error;
}
});
// PUT /api/users/:id - 사용자 수정
userRoutes.put("/:id", async (c) => {
const body = await c.req.json();
const validation = UpdateUserSchema.safeParse(body);
if (!validation.success) {
return c.json({ error: "입력 검증 실패", details: validation.error.issues }, 400);
}
try {
const userStore = await getStore();
const user = await userStore.update(c.req.param("id"), validation.data);
return c.json({ user });
} catch (error) {
if (error instanceof Error && error.message.includes("찾을 수 없")) {
return c.json({ error: error.message }, 404);
}
throw error;
}
});
// DELETE /api/users/:id - 사용자 삭제
userRoutes.delete("/:id", async (c) => {
try {
const userStore = await getStore();
await userStore.delete(c.req.param("id"));
return c.json({ message: "삭제 완료" });
} catch (error) {
if (error instanceof Error && error.message.includes("찾을 수 없")) {
return c.json({ error: error.message }, 404);
}
throw error;
}
});
export { userRoutes };Zod를 활용한 입력 검증은 TypeScript의 타입 시스템과 런타임 검증을 연결합니다. 스키마에서 추론된 타입을 사용하면 컴파일 타임과 런타임 모두에서 타입 안전성이 보장됩니다. Hono는 Zod와의 통합 미들웨어인 @hono/zod-validator도 제공합니다.
import { assertEquals } from "@std/assert";
const BASE_URL = "http://localhost:8000/api";
// 테스트용 서버 시작은 별도 프로세스로 관리
// deno task dev 실행 후 테스트 수행
Deno.test("GET /api/health - 헬스 체크", async () => {
const response = await fetch(`${BASE_URL}/health`);
assertEquals(response.status, 200);
const body = await response.json();
assertEquals(body.status, "ok");
assertEquals(typeof body.timestamp, "string");
});
Deno.test("POST /api/users - 사용자 생성", async () => {
const response = await fetch(`${BASE_URL}/users`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "테스트 사용자",
email: `test-${Date.now()}@example.com`,
}),
});
assertEquals(response.status, 201);
const body = await response.json();
assertEquals(body.user.name, "테스트 사용자");
assertEquals(typeof body.user.id, "string");
});
Deno.test("POST /api/users - 잘못된 입력 검증", async () => {
const response = await fetch(`${BASE_URL}/users`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "",
email: "invalid-email",
}),
});
assertEquals(response.status, 400);
const body = await response.json();
assertEquals(body.error, "입력 검증 실패");
});
Deno.test("GET /api/users/:id - 존재하지 않는 사용자", async () => {
const response = await fetch(`${BASE_URL}/users/non-existent-id`);
assertEquals(response.status, 404);
});import { assertEquals, assertRejects } from "@std/assert";
import { UserStore } from "./kv-store.ts";
Deno.test("UserStore - 사용자 CRUD", async () => {
const store = await UserStore.create();
// 생성
const user = await store.create({
name: "테스트",
email: `crud-test-${Date.now()}@example.com`,
});
assertEquals(user.name, "테스트");
// 조회
const found = await store.findById(user.id);
assertEquals(found?.email, user.email);
// 수정
const updated = await store.update(user.id, { name: "수정됨" });
assertEquals(updated.name, "수정됨");
// 삭제
await store.delete(user.id);
const deleted = await store.findById(user.id);
assertEquals(deleted, null);
});
Deno.test("UserStore - 이메일 중복 검사", async () => {
const store = await UserStore.create();
const email = `dup-test-${Date.now()}@example.com`;
await store.create({ name: "첫 번째", email });
await assertRejects(
() => store.create({ name: "두 번째", email }),
Error,
"이미 사용 중인 이메일",
);
});# 모든 테스트 실행
deno test --allow-net --allow-env --allow-read=.
# 특정 파일 테스트
deno test --allow-net --allow-env src/db/kv-store_test.ts
# 커버리지 포함 실행
deno test --allow-net --allow-env --coverage=./coverage
deno coverage ./coverageFROM denoland/deno:2.0.0
WORKDIR /app
# 의존성 캐시를 위해 설정 파일 먼저 복사
COPY deno.json deno.lock ./
RUN deno install
# 소스 코드 복사
COPY src/ ./src/
# 타입 체크
RUN deno check src/main.ts
# 최소 권한으로 실행
EXPOSE 8000
CMD ["deno", "run", "--allow-net=0.0.0.0:8000", "--allow-env=DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_NAME", "--allow-read=.", "--no-prompt", "src/main.ts"]# CLI 배포
deployctl deploy --project=my-api --prod src/main.ts
# 또는 GitHub 연동으로 자동 배포# 현재 플랫폼용 바이너리 생성
deno compile --allow-net=0.0.0.0:8000 --allow-env --output=my-api src/main.ts
# 크로스 컴파일
deno compile --target=x86_64-unknown-linux-gnu --output=my-api-linux src/main.ts
deno compile --target=aarch64-apple-darwin --output=my-api-macos-arm src/main.tsdeno compile은 런타임을 포함한 단일 실행 파일을 생성합니다. 대상 시스템에 Deno가 설치되어 있지 않아도 실행할 수 있으며, 권한 설정도 바이너리에 포함됩니다. Go 언어의 바이너리 배포와 유사한 경험을 제공합니다.
my-api/
src/
main.ts # 엔트리포인트
routes/
users.ts # 사용자 API 라우트
users_test.ts # 사용자 API 테스트
posts.ts # 게시글 API 라우트
db/
kv-store.ts # Deno KV 데이터 계층
kv-store_test.ts # KV 스토어 테스트
postgres.ts # PostgreSQL 연동 (선택)
middleware/
auth.ts # 인증 미들웨어
rate-limit.ts # 요청 제한 미들웨어
deno.json # Deno 설정
deno.lock # 의존성 잠금 파일
Dockerfile # Docker 배포이 구조를 기반으로 다음 장에서는 Fresh 프론트엔드를 결합한 풀스택 애플리케이션을 구축합니다.
이 글이 도움이 되셨나요?
Fresh 프론트엔드와 Deno.serve API, Deno KV를 결합한 풀스택 할 일 관리 애플리케이션을 구축합니다. 인증, 배포, 모니터링까지 실전 프로젝트의 전체 과정을 다룹니다.
Deno Deploy를 중심으로 서버리스 및 엣지 배포 전략을 다룹니다. 엣지 컴퓨팅 개념, 콜드 스타트 성능, Deno KV를 활용한 엣지 상태 관리, Cloudflare Workers 호환성을 분석합니다.
Deno의 공식 웹 프레임워크 Fresh를 심층 분석합니다. Islands Architecture, Preact 기반 컴포넌트, 라우팅, 미들웨어, 데이터 페칭 등 핵심 기능을 다룹니다.