Hono 기본 사용법
Node.js로 웹 서버를 만들 때 오랫동안 Express가 사실상 표준이었는데요. 그런데 최근 몇 년 사이에 Cloudflare Workers, Deno, Bun 같은 새로운 런타임들이 등장하면서 상황이 달라지고 있습니다. Express는 Node.js에 특화되어 있어서 다른 런타임에서 그대로 쓸 수 없거든요.
이런 흐름 속에서 Hono가 주목받고 있습니다. Hono는 웹 표준(Web Standards) API 위에 만들어진 경량 웹 프레임워크로, 같은 코드가 Bun이든 Deno든 Cloudflare Workers든 어디서나 돌아갑니다. TypeScript 지원도 처음부터 신경 쓴 프레임워크라 타입 추론이 잘 되고, 크기도 14KB가 안 되니까 성능 걱정도 없습니다.
이번 포스팅에서는 Hono를 처음 접하시는 분들을 위해 프로젝트 셋업부터 라우팅, 미들웨어, 유효성 검사, 테스트까지 기본 사용법을 정리해보겠습니다.
Hono 프로젝트 시작하기
Hono는 여러 런타임을 지원하지만, 이 글에서는 Bun을 기준으로 설명하겠습니다. Bun이 설치되어 있지 않다면 Bun 소개 포스팅을 먼저 참고해주세요.
프로젝트를 생성하는 가장 간단한 방법은 create-hono 템플릿을 사용하는 겁니다.
$ bun create hono@latest my-app
실행하면 어떤 런타임용 템플릿을 쓸지 물어보는데, bun을 선택하면 됩니다.
$ cd my-app
$ bun install
이미 있는 프로젝트에 Hono를 추가하고 싶다면 패키지만 설치하면 됩니다.
$ bun add hono
이제 src/index.ts 파일을 열어보면 기본적인 서버 코드가 들어 있습니다.
import { Hono } from "hono";
const app = new Hono();
app.get("/", (c) => {
return c.text("Hello Hono!");
});
export default app;
Express와 비교하면 두 가지가 눈에 띕니다.
먼저 app.listen() 대신 export default app으로 앱을 내보냅니다.
Bun이 export된 객체의 fetch 메서드를 자동으로 HTTP 서버로 띄워주기 때문입니다.
또 하나는 콜백 함수의 인자가 (req, res) 두 개가 아니라 (c) 하나라는 점인데요.
이 c는 Context 객체로, 요청 정보를 읽는 것부터 응답을 보내는 것까지 전부 처리할 수 있습니다.
서버를 실행해볼까요?
$ bun run dev
--hot 플래그 덕분에 코드를 수정하면 서버가 자동으로 다시 로드됩니다.
다른 터미널에서 요청을 보내보겠습니다.
$ curl http://localhost:3000
Hello Hono!
기본 라우팅
HTTP 메서드별로 라우트를 등록하는 방식은 익숙하실 겁니다.
app.get("/users", (c) => c.text("사용자 목록"));
app.post("/users", (c) => c.text("사용자 생성"));
app.put("/users/:id", (c) => c.text("사용자 수정"));
app.delete("/users/:id", (c) => c.text("사용자 삭제"));
모든 HTTP 메서드를 한 번에 처리하고 싶으면 all()을 쓸 수 있고, 특정 메서드 여러 개만 처리하고 싶으면 on()을 씁니다.
app.all("/health", (c) => c.text("OK"));
app.on(["PUT", "DELETE"], "/items/:id", (c) => c.text("수정 또는 삭제"));
같은 경로에 여러 메서드를 등록할 때는 체이닝도 됩니다.
app
.get("/posts", (c) => c.text("목록 조회"))
.post((c) => c.text("새 글 작성"))
.delete((c) => c.text("전체 삭제"));
경로 매개변수
URL에서 동적인 값을 받으려면 :변수명 패턴을 사용합니다.
c.req.param()으로 값을 꺼내면 됩니다.
app.get("/users/:id", (c) => {
const id = c.req.param("id");
return c.json({ id, name: `사용자 ${id}` });
});
$ curl http://localhost:3000/users/7
{"id":"7","name":"사용자 7"}
매개변수가 여러 개이면 인자 없이 호출하면 전부 다 가져올 수 있습니다.
app.get("/users/:userId/posts/:postId", (c) => {
const { userId, postId } = c.req.param();
return c.json({ userId, postId });
});
선택적 매개변수도 지원합니다. ?를 붙이면 해당 세그먼트가 없어도 매칭됩니다.
app.get("/api/animal/:type?", (c) => {
const type = c.req.param("type") ?? "all";
return c.text(`동물 종류: ${type}`);
});
정규식으로 매개변수에 패턴 제약을 걸 수도 있습니다.
app.get("/posts/:date{[0-9]+}/:title{[a-z]+}", (c) => {
const { date, title } = c.req.param();
return c.json({ date, title });
});
쿼리 스트링
쿼리 스트링은 c.req.query()로 가져옵니다.
app.get("/search", (c) => {
const keyword = c.req.query("keyword");
const page = c.req.query("page");
return c.json({ keyword, page });
});
$ curl "http://localhost:3000/search?keyword=hono&page=1"
{"keyword":"hono","page":"1"}
같은 키로 여러 값이 넘어오는 경우에는 queries()를 사용합니다.
app.get("/filter", (c) => {
const tags = c.req.queries("tag");
return c.json({ tags });
});
$ curl "http://localhost:3000/filter?tag=js&tag=ts"
{"tags":["js","ts"]}
응답 메서드
Context 객체에는 용도별로 응답 메서드가 나뉘어 있습니다.
// 텍스트 응답
app.get("/text", (c) => c.text("안녕하세요!"));
// JSON 응답
app.get("/json", (c) => c.json({ message: "안녕하세요!" }));
// HTML 응답
app.get("/html", (c) => c.html("<h1>안녕하세요!</h1>"));
HTTP 상태 코드를 지정하려면 c.status()를 먼저 호출하거나, 응답 메서드의 두 번째 인자로 넘깁니다.
// status()를 먼저 호출하는 방식
app.post("/users", (c) => {
c.status(201);
return c.json({ message: "생성되었습니다" });
});
// 두 번째 인자로 넘기는 방식
app.post("/items", (c) => {
return c.json({ message: "생성되었습니다" }, 201);
});
응답 헤더를 설정하거나 리다이렉트하는 것도 간단합니다.
app.get("/with-header", (c) => {
c.header("X-Custom", "my-value");
return c.text("헤더가 추가되었습니다");
});
app.get("/old-page", (c) => c.redirect("/new-page"));
app.get("/moved", (c) => c.redirect("/new-location", 301));
요청 바디 처리
POST나 PUT 요청의 바디를 읽을 때는 별도의 미들웨어 등록 없이 c.req.json()을 바로 호출하면 됩니다.
Express처럼 express.json() 같은 파싱 미들웨어를 미리 등록할 필요가 없어서 편하더라고요.
app.post("/users", async (c) => {
const { name, email } = await c.req.json();
return c.json(
{
message: "사용자가 생성되었습니다",
user: { name, email },
},
201,
);
});
$ curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name": "Dale", "email": "dale@test.com"}'
{"message":"사용자가 생성되었습니다","user":{"name":"Dale","email":"dale@test.com"}}
폼 데이터는 c.req.parseBody()로 읽습니다.
app.post("/contact", async (c) => {
const body = await c.req.parseBody();
return c.json({ name: body.name, message: body.message });
});
미들웨어
미들웨어는 요청이 핸들러에 도달하기 전이나 응답이 나간 후에 실행되는 함수입니다.
Hono의 미들웨어는 (c, next) 두 인자를 받고, next()를 호출하면 다음 핸들러로 넘어갑니다.
재미있는 건 await next() 앞의 코드는 요청 시점에, 뒤의 코드는 응답 시점에 실행된다는 점입니다.
app.use(async (c, next) => {
console.log(`[${c.req.method}] ${c.req.url}`);
const start = Date.now();
await next();
const elapsed = Date.now() - start;
console.log(`완료: ${elapsed}ms`);
});
위 예제에서 await next() 다음에 응답 시간을 계산하는 코드가 있는데요.
요청 → 미들웨어 순방향 → 핸들러 → 미들웨어 역방향으로 흐르기 때문에 이런 패턴이 가능합니다.
Koa 프레임워크를 써보신 분이라면 “양파 모델(onion model)“이라고 하면 바로 감이 오실 겁니다.
특정 경로에만 미들웨어를 적용하는 것도 됩니다.
app.use("/admin/*", async (c, next) => {
const token = c.req.header("Authorization");
if (!token) {
return c.json({ error: "인증이 필요합니다" }, 401);
}
await next();
});
Hono에는 자주 쓰는 미들웨어가 이미 내장되어 있어서 바로 꺼내 쓸 수 있습니다.
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import { basicAuth } from "hono/basic-auth";
const app = new Hono();
// 모든 요청에 로깅
app.use(logger());
// CORS 허용
app.use(cors());
// /admin 경로에 Basic 인증
app.use(
"/admin/*",
basicAuth({
username: "admin",
password: "secret",
}),
);
CORS 처리부터 JWT 인증, ETag, CSRF 방어, 압축, 캐싱, 보안 헤더까지 웬만한 건 다 내장되어 있어서 별도 패키지를 설치할 일이 거의 없습니다.
라우트 그룹핑
애플리케이션이 커지면 라우트를 파일별로 분리하고 싶어지는데요.
Hono에서는 별도의 Hono 인스턴스를 만들어서 app.route()로 합치는 방식을 씁니다.
import { Hono } from "hono";
const users = new Hono();
users.get("/", (c) => {
return c.json([
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
]);
});
users.get("/:id", (c) => {
const id = c.req.param("id");
return c.json({ id, name: "Alice" });
});
users.post("/", async (c) => {
const body = await c.req.json();
return c.json(body, 201);
});
export default users;
import { Hono } from "hono";
import users from "./routes/users";
const app = new Hono();
app.route("/users", users);
export default app;
app.route("/users", users)를 호출하면 users에 정의된 /는 /users/로, /:id는 /users/:id로 매핑됩니다.
여러 모듈을 조합해서 구조적으로 관리할 수 있습니다.
import { Hono } from "hono";
import users from "./routes/users";
import posts from "./routes/posts";
import comments from "./routes/comments";
const app = new Hono();
app.route("/users", users);
app.route("/posts", posts);
app.route("/comments", comments);
export default app;
basePath()를 사용하면 API 버전 접두사 같은 것도 깔끔하게 처리할 수 있습니다.
const api = new Hono().basePath("/api/v1");
api.get("/users", (c) => c.text("GET /api/v1/users"));
요청 유효성 검사
실제 API를 만들다 보면 요청 데이터가 올바른 형식인지 검증해야 할 일이 많습니다. Hono는 자체적인 유효성 검사 기능을 내장하고 있는데, Zod 같은 외부 라이브러리와 함께 쓰면 훨씬 편해집니다.
먼저 Zod와 Hono용 Zod 밸리데이터를 설치합니다.
$ bun add zod @hono/zod-validator
이제 스키마를 정의하고 라우트에 적용할 수 있습니다.
import { Hono } from "hono";
import { z } from "zod";
import { zValidator } from "@hono/zod-validator";
const app = new Hono();
const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().positive().optional(),
});
app.post("/users", zValidator("json", createUserSchema), (c) => {
const user = c.req.valid("json");
// user의 타입이 자동으로 { name: string; email: string; age?: number }로 추론됨
return c.json({ message: "생성되었습니다", user }, 201);
});
zValidator("json", schema)를 미들웨어로 넣으면 요청 바디가 스키마에 맞는지 자동으로 검증합니다.
유효하지 않으면 400 에러를 돌려주고, 유효하면 c.req.valid("json")으로 타입 안전한 데이터를 꺼낼 수 있습니다.
JSON 바디뿐만 아니라 쿼리 스트링, 경로 매개변수, 헤더도 같은 방식으로 검증할 수 있습니다.
const searchSchema = z.object({
keyword: z.string().min(1),
page: z.string().regex(/^\d+$/).optional(),
});
app.get("/search", zValidator("query", searchSchema), (c) => {
const { keyword, page } = c.req.valid("query");
return c.json({ keyword, page });
});
여러 위치의 데이터를 동시에 검증하는 것도 가능합니다.
app.put(
"/users/:id",
zValidator("param", z.object({ id: z.string() })),
zValidator("json", createUserSchema),
(c) => {
const { id } = c.req.valid("param");
const user = c.req.valid("json");
return c.json({ id, ...user });
},
);
이렇게 TypeScript의 타입 시스템과 런타임 검증을 한 번에 해결할 수 있어서 실수가 확 줄어듭니다.
에러 처리
애플리케이션에서 에러가 발생했을 때 일관된 응답을 보내주는 것도 중요합니다.
Hono에서는 app.onError()로 전역 에러 핸들러를 등록합니다.
app.onError((err, c) => {
console.error(`에러 발생: ${err.message}`);
return c.json({ error: "서버 내부 오류가 발생했습니다" }, 500);
});
위치나 인자 개수 같은 걸 신경 쓸 필요 없이 onError() 한 줄이면 됩니다.
404 응답을 커스터마이징하려면 app.notFound()를 사용합니다.
app.notFound((c) => {
return c.json({ error: "요청한 리소스를 찾을 수 없습니다" }, 404);
});
Hono의 HTTPException을 사용하면 라우트 핸들러 안에서 의도적으로 HTTP 에러를 발생시킬 수도 있습니다.
import { HTTPException } from "hono/http-exception";
app.get("/users/:id", (c) => {
const id = c.req.param("id");
const user = findUser(id);
if (!user) {
throw new HTTPException(404, { message: "사용자를 찾을 수 없습니다" });
}
return c.json(user);
});
테스트
Hono의 큰 장점 중 하나가 테스트하기 정말 쉽다는 겁니다.
app.request() 메서드를 쓰면 실제 서버를 띄우지 않고도 요청을 보내고 응답을 검증할 수 있습니다.
import { describe, expect, test } from "bun:test";
import app from "./index";
describe("사용자 API", () => {
test("GET /users - 목록 조회", async () => {
const res = await app.request("/users");
expect(res.status).toBe(200);
const data = await res.json();
expect(Array.isArray(data)).toBe(true);
});
test("POST /users - 사용자 생성", async () => {
const res = await app.request("/users", {
method: "POST",
body: JSON.stringify({ name: "Dale", email: "dale@test.com" }),
headers: { "Content-Type": "application/json" },
});
expect(res.status).toBe(201);
const data = await res.json();
expect(data.user.name).toBe("Dale");
});
test("GET /not-exist - 404 응답", async () => {
const res = await app.request("/not-exist");
expect(res.status).toBe(404);
});
});
app.request()가 표준 Request/Response 객체를 사용하기 때문에 별도의 테스트 유틸리티가 필요 없습니다.
Bun에서는 bun test로, Node.js에서는 Vitest로 바로 실행할 수 있습니다.
$ bun test
supertest 같은 별도 라이브러리 없이도 테스트를 작성할 수 있다는 게 정말 편합니다.
전체 예제 코드
지금까지 배운 내용을 모아서 간단한 사용자 관리 API를 만들어보겠습니다.
import { Hono } from "hono";
import { logger } from "hono/logger";
import { cors } from "hono/cors";
import { z } from "zod";
import { zValidator } from "@hono/zod-validator";
const app = new Hono();
// 미들웨어
app.use(logger());
app.use(cors());
// 인메모리 데이터
let users = [
{ id: 1, name: "Alice", email: "alice@test.com" },
{ id: 2, name: "Bob", email: "bob@test.com" },
];
let nextId = 3;
// 유효성 검사 스키마
const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
// 사용자 목록 조회
app.get("/users", (c) => {
return c.json(users);
});
// 사용자 상세 조회
app.get("/users/:id", (c) => {
const id = parseInt(c.req.param("id"));
const user = users.find((u) => u.id === id);
if (!user) {
return c.json({ error: "사용자를 찾을 수 없습니다" }, 404);
}
return c.json(user);
});
// 사용자 생성
app.post("/users", zValidator("json", createUserSchema), (c) => {
const { name, email } = c.req.valid("json");
const user = { id: nextId++, name, email };
users.push(user);
return c.json(user, 201);
});
// 사용자 수정
app.put("/users/:id", zValidator("json", createUserSchema), (c) => {
const id = parseInt(c.req.param("id"));
const user = users.find((u) => u.id === id);
if (!user) {
return c.json({ error: "사용자를 찾을 수 없습니다" }, 404);
}
const { name, email } = c.req.valid("json");
user.name = name;
user.email = email;
return c.json(user);
});
// 사용자 삭제
app.delete("/users/:id", (c) => {
const id = parseInt(c.req.param("id"));
const index = users.findIndex((u) => u.id === id);
if (index === -1) {
return c.json({ error: "사용자를 찾을 수 없습니다" }, 404);
}
users.splice(index, 1);
c.status(204);
return c.body(null);
});
// 에러 처리
app.onError((err, c) => {
console.error(`${err}`);
return c.json({ error: "서버 내부 오류가 발생했습니다" }, 500);
});
app.notFound((c) => {
return c.json({ error: "요청한 경로를 찾을 수 없습니다" }, 404);
});
export default app;
서버를 띄우고 실제로 요청을 보내볼까요?
$ curl http://localhost:3000/users
[{"id":1,"name":"Alice","email":"alice@test.com"},{"id":2,"name":"Bob","email":"bob@test.com"}]
$ curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name": "Charlie", "email": "charlie@test.com"}'
{"id":3,"name":"Charlie","email":"charlie@test.com"}
$ curl -X PUT http://localhost:3000/users/1 \
-H "Content-Type: application/json" \
-d '{"name": "Alice Kim", "email": "alice.kim@test.com"}'
{"id":1,"name":"Alice Kim","email":"alice.kim@test.com"}
$ curl -X DELETE http://localhost:3000/users/2 -i
HTTP/1.1 204 No Content
Express에서 Hono로
Express를 사용하고 계신 분들이 Hono로 전환할 때 알아두면 좋은 차이점을 정리해보겠습니다.
가장 크게 달라지는 건 req/res 두 객체 대신 Context 객체 c 하나로 요청과 응답을 모두 처리한다는 점입니다.
아래 표로 주요 API 대응 관계를 정리해봤습니다.
| Express | Hono |
|---|---|
req.params.id | c.req.param("id") |
req.query.page | c.req.query("page") |
req.body (+ express.json()) | await c.req.json() |
res.json(data) | c.json(data) |
res.status(201).json(data) | c.json(data, 201) |
(req, res, next) 미들웨어 | (c, next) 미들웨어 |
(err, req, res, next) 에러 핸들러 | app.onError((err, c) => ...) |
Router + app.use() | new Hono() + app.route() |
Express에서는 @types/express를 따로 설치해야 타입이 잡혔는데, Hono는 처음부터 TypeScript로 작성되어서 경로 매개변수부터 유효성 검사 결과까지 별도 설정 없이 타입이 추론됩니다.
멀티 런타임 지원
Hono의 가장 큰 특징은 한 번 작성한 코드가 여러 런타임에서 그대로 동작한다는 점입니다.
Bun 외에도 Node.js, Deno, Cloudflare Workers, AWS Lambda, Vercel, Fastly Compute 등을 공식 지원하는데요.
Node.js에서는 @hono/node-server 어댑터만 붙이면 되고, Cloudflare Workers에서는 엣지에서 바로 돌릴 수 있어서 콜드 스타트가 거의 없습니다.
개발할 때는 Bun으로 빠르게 돌리다가 프로덕션에서는 Cloudflare Workers로 배포하는 식으로 비즈니스 로직은 건드리지 않고 배포 환경만 바꿀 수 있다는 게 실무에서 꽤 유용합니다.
마치며
지금까지 Hono의 기본적인 사용법을 알아보았습니다. 프로젝트 셋업부터 라우팅, 미들웨어, 유효성 검사, 에러 처리, 테스트까지 웹 서버를 만들 때 필요한 핵심 기능들을 다뤘는데요.
Express를 쓰던 감각으로 금방 적응할 수 있으면서도, 타입 추론이 잘 되고 런타임도 안 가리니까 한 번 배워두면 활용할 데가 많습니다.
Express로 서버를 만들어보신 경험이 있다면 Express 기본 사용법과 비교해보시면 좋겠고, Bun 런타임이 아직 낯설다면 Bun 소개 포스팅도 함께 참고해주세요. 서버리스 환경에 관심이 있으시다면 Cloudflare Workers로 서버리스 애플리케이션 만들기도 살펴보시면 좋겠습니다.
Hono의 공식 문서는 hono.dev에서 확인하실 수 있습니다.
This work is licensed under
CC BY 4.0