์ด๊ฒฝ๋ยท์ด๊ณ ์ ๋ฉํฐ ๋ฐํ์ ์น ํ์ค ํ๋ ์์ํฌ ์๋ฒฝ ๊ฐ์ด๋: ์์คํ
์๊ตฌ์ฌ์, Cloudflare Workers/Bun/Node.js ํ๊ฒฝ ๊ตฌ์ถ, ์ค๋งํธ ๋ผ์ฐํฐ & Context ์๋ช
์ฃผ๊ธฐ, Zod ์คํค๋ง ๊ฒ์ฆ, Hono RPC ์๋ํฌ์๋ ํ์
์ธ์ดํํฐ, ๋ฏธ๋ค์จ์ด ๋ฐ ์ฃ์ง ์คํธ๋ฆฌ๋ฐ
## 1. ํ์ ์ฌ์ ์ค์น ์๊ฑด & ๊ฐ๋ฐ ํ๊ฒฝ ๊ตฌ์ฑ (Prerequisites)
### ๐ป ์์คํ
์ค์น ๋ฐ ์คํ ์๊ตฌ์ฌ์ (System Requirements)
| ํญ๋ชฉ | ์ต์ ์ฌ์ (Minimum) | ๊ถ์ฅ ์ฌ์ (Recommended) | ์ฐธ๊ณ ๋ฐ ๋น๊ณ |
| :--- | :--- | :--- | :--- |
| **์ด์์ฒด์ (OS)** | Linux, macOS, Windows 10/11 (WSL2 ๊ถ์ฅ) | Ubuntu 22.04 LTS, macOS (Apple Silicon), Windows 11 | ๋ชจ๋ ํ๋์ OS ์ง์ |
| **CPU** | 1 ์ฝ์ด ์ด์ | 2 ์ฝ์ด ~ 4 ์ฝ์ด ์ด์ | TypeScript ํธ๋์คํ์ผ ๋ฐ ๋ฒ๋ค๋ง |
| **๋ฉ๋ชจ๋ฆฌ (RAM)** | 512 MB ์ด์ (์๋ฒ๋ฆฌ์ค 128MB ์ง์) | 2 GB ~ 4 GB ์ด์ | ์ด๊ฒฝ๋ ํ๋ ์์ํฌ (๋ฒ๋ค ํฌ๊ธฐ < 14KB) |
| **๋์คํฌ (Storage)** | 200 MB ์ด์์ ์ฌ์ ๊ณต๊ฐ | 1 GB ์ด์์ SSD | node_modules ๋ฐ ์บ์ ๊ณต๊ฐ |
| **๋คํธ์ํฌ / ๋ฐํ์** | Node.js 18.14.1+, Bun 1.0+, Deno 1.30+ | Bun 1.1+ ๋๋ Node.js 22 LTS, Cloudflare Workers | Web Fetch API ํ์ค ์ค์ ๋ฐํ์ |
Hono(ํธ๋
ธ - ์ผ๋ณธ์ด๋ก '๋ถ๊ฝ(็)'์ ๋ปํจ)๋ Web Fetch API(`Request`, `Response`, `Headers`) ํ์ค์ ์๊ฒฉํ ์ค์ํ์ฌ **์ด๋์๋(Anywhere)** ์คํ๋๋ ์ด๊ฒฝ๋ยท์ด๊ณ ์ ์น ํ๋ ์์ํฌ์
๋๋ค. Express๋ Fastify์ ๋ฌ๋ฆฌ Node.js ์ ์ฉ C++ ๋ฐ์ธ๋ฉ์ ์์กดํ์ง ์์ผ๋ฏ๋ก **Cloudflare Workers, Deno, Bun, Fastly, AWS Lambda, Node.js** ๋ฑ ๋ชจ๋ ์๋ฐ์คํฌ๋ฆฝํธ ๋ฐํ์์์ ๋์ผํ ์ฝ๋๋ก ๊ตฌ๋๋ฉ๋๋ค.
---
### ๐ข 1. ์ง์ ๋ฐํ์ ๋ฒ์ ํ์ธ ๋ฐ ์ค์น
Hono๋ฅผ ์คํํ๊ธฐ ์ํ ์๋ฐ์คํฌ๋ฆฝํธ/ํ์
์คํฌ๋ฆฝํธ ๋ฐํ์์ ์ค๋นํฉ๋๋ค.
```bash
# ์ต์
A: Bun ์ฌ์ฉ ์ (์ด๊ณ ์ ๊ถ์ฅ - Bun์ ๋ค์ดํฐ๋ธ TS ๋ฐ Hono ์ง์์ด ๋งค์ฐ ๋ฐ์ด๋จ)
curl -fsSL https://bun.sh/install | bash
bun -v # 1.0.0 ์ด์ ํ์ธ
# ์ต์
B: Node.js ์ฌ์ฉ ์ (LTS 18.14.1 ์ด์, ๊ถ์ฅ: Node.js 20.x ๋๋ 22.x LTS)
node -v
npm -v
# Node ๋ฒ์ ๊ด๋ฆฌ์(fnm)๋ฅผ ํตํ ์ต์ LTS ์ค์น
fnm install --lts
fnm use lts-latest
```
---
### ๐ ๏ธ 2. `create-hono` ์คํํฐ๋ฅผ ํตํ ํ๋ก์ ํธ ์์ฑ
๊ณต์ ์ค์บํด๋ฉ ๋๊ตฌ์ธ `create-hono`๋ฅผ ์ฌ์ฉํ๋ฉด ์ฌ์ฉํ ๋ฐํ์(Cloudflare Workers, Bun, Node.js ๋ฑ)์ ๋ง๋ ์ต์ ํ๋ ํ
ํ๋ฆฟ์ ๋ํํ์ผ๋ก ์์ฑํ ์ ์์ต๋๋ค:
```bash
# 1. create-hono ๋ํํ CLI ์คํ
npm create hono@latest my-hono-app
# ๋๋ Bun ์ฌ์ฉ ์:
# bun create hono my-hono-app
# 2. ํฐ๋ฏธ๋ ํ๋กฌํํธ ์๋ด์ ๋ฐ๋ผ ์ํ๋ ๋ฐํ์ ์ ํ:
# ? Target directory: my-hono-app
# ? Which template do you want to use?
# cloudflare-workers (๊ถ์ฅ: ๊ธ๋ก๋ฒ ์ฃ์ง ์๋ฒ๋ฆฌ์ค)
# bun (๊ถ์ฅ: ์ด๊ณ ์ ๋ก์ปฌ/์ปจํ
์ด๋ ๋ฐฑ์๋)
# nodejs (๊ธฐ์กด Node.js ์ํ๊ณ ํธํ)
# deno
# 3. ๋๋ ํ ๋ฆฌ ์ด๋ ๋ฐ ์์กด์ฑ ์ค์น
cd my-hono-app
npm install # (Bun ํ
ํ๋ฆฟ์ ๊ฒฝ์ฐ: bun install)
```
---
### โ๏ธ 3. ๋ฐํ์๋ณ ํจํค์ง ๊ตฌ์ฑ ๋ฐ ์คํ ์ง์
์
Hono๋ ์น ํ์ค ์ธํฐํ์ด์ค๋ฅผ ๊ฐ์ง๋ฏ๋ก ๋ฐํ์๋ณ ์ง์
์ ์ด๋ํฐ๋ง ๋ค๋ฅผ ๋ฟ, ์ ํ๋ฆฌ์ผ์ด์
์ฝ๋๋ 100% ๋์ผํฉ๋๋ค:
#### A. Cloudflare Workers ํ๊ฒฝ (`wrangler`)
```bash
# Cloudflare Workers ๋ก์ปฌ ๊ฐ๋ฐ ์๋ฒ ๊ตฌ๋
npx wrangler dev
# Cloudflare ์ ์ธ๊ณ ์ฃ์ง ๋คํธ์ํฌ๋ก ์ํด๋ฆญ ๋ฐฐํฌ
npx wrangler deploy
```
#### B. Bun ํ๊ฒฝ
```bash
# Bun ๋ค์ดํฐ๋ธ ๊ณ ์ ๊ฐ๋ฐ ์๋ฒ ์คํ (ํซ ๋ฆฌ๋ก๋)
bun --watch src/index.ts
```
#### C. Node.js ํ๊ฒฝ (`@hono/node-server`)
Node.js๋ ๋ค์ดํฐ๋ธ Web Fetch ์๋ฒ๊ฐ ์๋๋ฏ๋ก `@hono/node-server` ์ด๋ํฐ๋ฅผ ์ฌ์ฉํฉ๋๋ค:
```bash
# Node.js์ฉ ์๋ฒ ์ด๋ํฐ ๋ฐ TypeScript ์คํ๊ธฐ ์ค์น
npm install @hono/node-server
npm install -D tsx typescript @types/node
# ๊ฐ๋ฐ ์๋ฒ ์คํ
npx tsx watch src/index.ts
```
---
### ๐ฆ 4. ํ์ ์์กด์ฑ ํจํค์ง ์ค์น (`Zod` ๋ฐ ์ ํจ์ฑ ๊ฒ์ฌ๊ธฐ)
ํ์
์์ ํ REST API ๊ตฌ์ถ์ ์ํด Zod ์ ํจ์ฑ ๊ฒ์ฌ๊ธฐ ๋ฐ ํด๋ผ์ด์ธํธ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ค์นํฉ๋๋ค:
```bash
# Zod ์ ํจ์ฑ ๊ฒ์ฆ ๋ฏธ๋ค์จ์ด
npm install @hono/zod-validator zod
# ํ๋ก ํธ์๋ End-to-End RPC ํด๋ผ์ด์ธํธ๋ฅผ ์ํ ํจํค์ง (Hono ์ฝ์ด์ ํฌํจ๋์ด ์์)
# import { hc } from 'hono/client'
```
---
## 2. Hono ๋ฉํ ๋ชจ๋ธ & ์ํคํ
์ฒ ๋ค์ด์ด๊ทธ๋จ (Mental Model & Architecture)
### ๐ก Hono ํต์ฌ ๋์ ์๋ฆฌ (์น ํ์ค๊ณผ ์ด๊ณ ์ ์ ๊ทํํ์ ๋ผ์ฐํ
)
```
[ํด๋ผ์ด์ธํธ ์์ฒญ]
โ (HTTP Request)
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ ๋ฐํ์ ์ถ์ํ ๊ณ์ธต (Runtime Adapters) โ
โ Cloudflare Workers / Bun / Node.js / Deno / AWS Lambdaโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Web Standard Request ๋ณํ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ RegExpRouter (์ ํ ์ ๊ทํํ์ ๊ธฐ๋ฐ ์ด๊ณ ์ ๋ผ์ฐํธ ๋งค์นญ) โ
โ Trie ํธ๋ฆฌ ํ์ ๋๋น ๋ฉ๋ชจ๋ฆฌ ์ ์ฝ & O(1)์ ๊ทผ์ ํ ์๋ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ ๋ฏธ๋ค์จ์ด ํ์ดํ๋ผ์ธ (Onion Model - ์ํ ๊ป์ง ๊ตฌ์กฐ) โ
โ 1. cors() โโโโโโโโโโโบ 2. logger() โโโบ 3. bearerAuth() โ
โ โ โ โ โ
โ โผ โผ โผ โ
โ [ํธ๋ค๋ฌ ์คํ] c.req โโโบ ๋น์ฆ๋์ค ๋ก์ง โโโบ c.json(...) โ
โ โฒ โฒ โฒ โ
โ โ โ โ โ
โ 1. ์๋ต ํค๋ ์ถ๊ฐ โโโ 2. ๊ฒฝ๊ณผ ์๊ฐ ๋ก๊น
โโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
[ํด๋ผ์ด์ธํธ ์๋ต (Web Standard Response)]
```
### ๐ ํต์ฌ ๊ตฌ์ฑ ์์
1. **Context (`c`)**: ๋จ์ผ ์์ฒญ-์๋ต ์๋ช
์ฃผ๊ธฐ ์ ์ฒด๋ฅผ ์บก์ํํ ํต์ฌ ๊ฐ์ฒด์
๋๋ค. `c.req`(์์ฒญ ํ์ฑ), `c.text()`, `c.json()`, `c.env`(์ฃ์ง ํ๊ฒฝ๋ณ์ ๋ฐ KV/D1 ๋ฐ์ธ๋ฉ), `c.set()` / `c.get()`(๋ฏธ๋ค์จ์ด ๊ฐ ์ํ ์ ๋ฌ)์ ์ ๊ณตํฉ๋๋ค.
2. **RegExpRouter**: Hono๊ฐ ์ง์ ๊ฐ๋ฐํ ๋ผ์ฐํ
์์ง์ผ๋ก, ์์ญ ๊ฐ์ ๋ผ์ฐํธ ํจํด์ ๋จ ํ๋์ ๊ฑฐ๋ํ ์ ๊ทํํ์์ผ๋ก ํฉ์ฑํ์ฌ ๋งค์นญํ๋ฏ๋ก JavaScript ์์ง์ C++ ์ ๊ท์ ์ต์ ํ ํํ์ 100% ๋๋ฆฝ๋๋ค.
3. **Multi-Runtime Fetch Handlers**: `export default app`์ ์ ์ธํ๋ฉด Cloudflare Workers์ Bun์ด ์๋์ผ๋ก ๋ค์ดํฐ๋ธ fetch ์ด๋ฒคํธ ๋ฆฌ์ค๋๋ก ์ธ์ํ์ฌ ๊ตฌ๋๋ฉ๋๋ค.
---
## 3. [์ด๋ณด์ 1๋จ๊ณ] ํฌ๋ก์๋ ๋ฐ ๊ธฐ์ด ๋ผ์ฐํ
(Hello World & Basic Routing)
๋จ์ผ ํ์ผ๋ก ๋ฐ๋ก ์คํ ๊ฐ๋ฅํ Hono ์ต์ ์ ํ๋ฆฌ์ผ์ด์
์
๋๋ค.
### ๐ `src/index.ts` (์์ ํ ์คํ ์ฝ๋)
```typescript
import { Hono } from 'hono';
// 1. Hono ์ธ์คํด์ค ์ด๊ธฐํ
const app = new Hono();
// 2. ๋ฃจํธ ๊ฒฝ๋ก GET ์์ฒญ ํธ๋ค๋ฌ (c.text() ์๋ต)
app.get('/', (c) => {
return c.text('๐ฅ Hello, Hono! Welcome to Modern Web Development.');
});
// 3. JSON ์๋ต ๋ฐ ์ํ ์ฝ๋ ์ ์ด (c.json())
app.get('/api/health', (c) => {
return c.json(
{
status: 'ok',
uptime: process.uptime ? process.uptime() : 'edge-runtime',
timestamp: new Date().toISOString(),
},
200
);
});
// 4. ๊ฒฝ๋ก ํ๋ผ๋ฏธํฐ(Path Parameter) ์กฐํ (c.req.param())
app.get('/api/users/:id', (c) => {
const userId = c.req.param('id');
return c.json({
message: '์ฌ์ฉ์ ์กฐํ ์ฑ๊ณต',
userId: Number(userId),
username: `dev_user_${userId}`,
});
});
// 5. ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ(Query Parameter) ์กฐํ (c.req.query())
app.get('/api/search', (c) => {
const query = c.req.query('q') || '';
const page = Number(c.req.query('page') || '1');
const limit = Number(c.req.query('limit') || '10');
return c.json({
searchQuery: query,
pagination: { page, limit },
results: [
{ id: 1, title: `${query} ๊ด๋ จ ๊ธฐ์ ์ํฐํด` },
{ id: 2, title: `${query} ์ค์ ํ๋ก๋์
ํ` },
],
});
});
// 6. POST ์์ฒญ ๋ณธ๋ฌธ(JSON Body) ํ์ฑ (await c.req.json())
app.post('/api/articles', async (c) => {
const body = await c.req.json<{ title: string; content: string }>();
// 201 Created ์๋ต ๋ฐ ์ปค์คํ
ํค๋ ์ค์
c.header('X-Created-By', 'Hono-Engine');
return c.json(
{
success: true,
createdArticle: {
id: Math.floor(Math.random() * 1000) + 1,
title: body.title,
content: body.content,
createdAt: new Date().toISOString(),
},
},
201
);
});
// Bun ๋๋ Cloudflare Workers ํ๊ฒฝ์ ์ํ ๊ธฐ๋ณธ ์ต์คํฌํธ
export default app;
// Node.js ํ๊ฒฝ์์ ์ง์ ์คํํ ๊ฒฝ์ฐ ์๋ ์ฃผ์์ ํด์ ํ์ฌ ๊ตฌ๋:
// import { serve } from '@hono/node-server';
// serve({ fetch: app.fetch, port: 3000 }, (info) => {
// console.log(`๐ Hono Server running on http://localhost:${info.port}`);
// });
```
---
### ๐ป ์คํ ๋ฐ `curl` ํ
์คํธ ๊ฒ์ฆ
```bash
# [์คํ] Bun ํ๊ฒฝ์ธ ๊ฒฝ์ฐ:
bun --watch src/index.ts
# [์คํ] Node.js ํ๊ฒฝ์ธ ๊ฒฝ์ฐ:
npx tsx watch src/index.ts
# -------------------------------------------------------------
# [ํ
์คํธ 1] ํฌ๋ก์๋ ํ
์คํธ ์์ฒญ
curl http://localhost:3000/
# ์ถ๋ ฅ:
# ๐ฅ Hello, Hono! Welcome to Modern Web Development.
# [ํ
์คํธ 2] ๊ฒฝ๋ก ํ๋ผ๋ฏธํฐ ์กฐํ
curl http://localhost:3000/api/users/42
# ์ถ๋ ฅ:
# {"message":"์ฌ์ฉ์ ์กฐํ ์ฑ๊ณต","userId":42,"username":"dev_user_42"}
# [ํ
์คํธ 3] ์ฟผ๋ฆฌ์คํธ๋ง ๊ฒ์
curl "http://localhost:3000/api/search?q=hono&page=1"
# ์ถ๋ ฅ:
# {"searchQuery":"hono","pagination":{"page":1,"limit":10},"results":[{"id":1,"title":"hono ๊ด๋ จ ๊ธฐ์ ์ํฐํด"},{"id":2,"title":"hono ์ค์ ํ๋ก๋์
ํ"}]}
# [ํ
์คํธ 4] POST JSON ์์ฑ
curl -X POST http://localhost:3000/api/articles \
-H "Content-Type: application/json" \
-d '{"title": "Hono ๋์
๊ฐ์ด๋", "content": "14KB ๋ฏธ๋ง์ ๊ฐ๋ฒผ์ด ํ๋ ์์ํฌ"}'
# ์ถ๋ ฅ (HTTP 201 Created):
# {"success":true,"createdArticle":{"id":782,"title":"Hono ๋์
๊ฐ์ด๋","content":"14KB ๋ฏธ๋ง์ ๊ฐ๋ฒผ์ด ํ๋ ์์ํฌ","createdAt":"2026-10-09T08:30:00.000Z"}}
```
---
## 4. [2๋จ๊ณ] ํต์ฌ ๊ธฐ๋ฅ & ์ค๋ฌด ์ํคํ
์ฒ (Core Features & Middleware)
์ํฐํ๋ผ์ด์ฆ ํ๊ฒฝ์์ ํ์์ ์ธ **๋ชจ๋์ ๋ผ์ฐํ
**, **๋ด์ฅ ๋ฏธ๋ค์จ์ด**, **Zod ์ ํจ์ฑ ๊ฒ์ฌ** ๋ฐ **Context ๋ณ์ ๊ณต์ ** ํจํด์
๋๋ค.
### ๐งฉ 1. ๋ชจ๋์ ๋ผ์ฐํฐ ๋ถ๋ฆฌ (`app.route()`)
๊ฑฐ๋ํ ์ ํ๋ฆฌ์ผ์ด์
์ ๋๋ฉ์ธ๋ณ ํ์ ๋ผ์ฐํฐ๋ก ๊น๋ํ๊ฒ ๋ถ๋ฆฌํ ์ ์์ต๋๋ค:
```typescript
// src/routes/posts.ts
import { Hono } from 'hono';
export const postRouter = new Hono()
.get('/', (c) => c.json([{ id: 1, title: '์ฒซ ๋ฒ์งธ ๊ฒ์๊ธ' }]))
.get('/:id', (c) => c.json({ id: c.req.param('id'), title: '์์ธ ๊ฒ์๊ธ' }))
.delete('/:id', (c) => c.json({ message: `๊ฒ์๊ธ ${c.req.param('id')} ์ญ์ ์๋ฃ` }));
```
```typescript
// src/index.ts
import { Hono } from 'hono';
import { postRouter } from './routes/posts';
const app = new Hono();
// '/api/posts' ํ๋ฆฌํฝ์ค๋ก ํ์ ๋ผ์ฐํฐ ๋ง์ดํธ
app.route('/api/posts', postRouter);
export default app;
```
---
### ๐ก๏ธ 2. ํ์ ๋ด์ฅ ๋ฏธ๋ค์จ์ด ํ์ดํ๋ผ์ธ
Hono๋ ์ธ๋ถ ์์กด์ฑ ์์ด ํ๋ ์์ํฌ ์์ฒด์ ๊ณ ์ฑ๋ฅ ๋ฏธ๋ค์จ์ด๋ฅผ ๋ด์ฅํ๊ณ ์์ต๋๋ค:
```typescript
import { Hono } from 'hono';
import { logger } from 'hono/logger';
import { cors } from 'hono/cors';
import { prettyJSON } from 'hono/pretty-json';
import { secureHeaders } from 'hono/secure-headers';
import { bearerAuth } from 'hono/bearer-auth';
const app = new Hono();
// 1. HTTP ์์ฒญ ๋ก๊น
๋ฏธ๋ค์จ์ด (์์ฒญ ๋ฉ์๋, ๊ฒฝ๋ก, ์๋ต ์ฝ๋, ์๋ต ์๊ฐ ํ์)
app.use('*', logger());
// 2. ๋ณด์ HTTP ์๋ต ํค๋ ์๋ ์ถ๊ฐ (HSTS, X-Content-Type-Options ๋ฑ)
app.use('*', secureHeaders());
// 3. CORS(๊ต์ฐจ ์ถ์ฒ ๋ฆฌ์์ค ๊ณต์ ) ํค๋ ์ค์
app.use(
'/api/*',
cors({
origin: ['https://example.com', 'http://localhost:5173'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
maxAge: 86400,
})
);
// 4. JSON ์๋ต ๋ค์ฌ์ฐ๊ธฐ ํฌ๋งทํ
(์ฟผ๋ฆฌ์ ?pretty ์ถ๊ฐ ์ ํ์ฑํ)
app.use('*', prettyJSON());
// 5. ๊ด๋ฆฌ์ ์ ์ฉ ๊ฒฝ๋ก Bearer ํ ํฐ ์ธ์ฆ ๋ฏธ๋ค์จ์ด
const ADMIN_API_TOKEN = 'secret-production-token-12345';
app.use('/api/admin/*', bearerAuth({ token: ADMIN_API_TOKEN }));
app.get('/api/admin/dashboard', (c) => {
return c.json({ systemStatus: 'All Systems Operational', secretValue: 9999 });
});
```
---
### ๐ฏ 3. Zod ๊ธฐ๋ฐ ์๋ํฌ์๋ ์ ํจ์ฑ ๊ฒ์ฆ (`@hono/zod-validator`)
`zValidator`๋ฅผ ์ฌ์ฉํ๋ฉด ์์ฒญ ๋ณธ๋ฌธ(`json`), ์ฟผ๋ฆฌ์คํธ๋ง(`query`), ๊ฒฝ๋ก ํ๋ผ๋ฏธํฐ(`param`), ํค๋(`header`)๋ฅผ ๊ฒ์ฆํ๊ณ , **๊ฒ์ฆ์ ํต๊ณผํ ๋ฐ์ดํฐ์ ํ์
์ ์๋์ผ๋ก ์ถ๋ก **ํฉ๋๋ค:
```typescript
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
const app = new Hono();
// 1. Zod ์คํค๋ง ์ ์
const CreateUserSchema = z.object({
email: z.string().email({ message: '์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ ํ์์ ์
๋ ฅํ์ธ์.' }),
password: z.string().min(8, { message: '๋น๋ฐ๋ฒํธ๋ ์ต์ 8์ ์ด์์ด์ด์ผ ํฉ๋๋ค.' }),
age: z.number().int().min(14, { message: '14์ธ ์ด์๋ง ๊ฐ์
๊ฐ๋ฅํฉ๋๋ค.' }).optional(),
});
const UserQuerySchema = z.object({
role: z.enum(['admin', 'member', 'guest']).default('member'),
});
// 2. ๋ผ์ฐํธ์ zValidator ๋ฏธ๋ค์จ์ด ์ฐ๊ฒฐ
app.post(
'/api/v1/users',
zValidator('json', CreateUserSchema, (result, c) => {
// ์ ํจ์ฑ ๊ฒ์ฆ ์คํจ ์ ์ฌ์ฉ์ ์ ์ ์๋ฌ ์๋ต
if (!result.success) {
return c.json(
{
success: false,
errors: result.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
})),
},
400
);
}
}),
async (c) => {
// 3. ๊ฒ์ฆ ์๋ฃ๋ ๋ฐ์ดํฐ ํ๋ (์๋ฒฝํ TypeScript ์๋์์ฑ ์ ๊ณต)
const validData = c.req.valid('json');
console.log(`์ ๊ท ๊ฐ์
์์ฒญ ์ด๋ฉ์ผ: ${validData.email}`);
return c.json(
{
success: true,
user: {
id: 101,
email: validData.email,
age: validData.age ?? null,
},
},
201
);
}
);
```
---
### ๐ฆ 4. ์ปค์คํ
๋ฏธ๋ค์จ์ด ๋ฐ Context ์ํ ๊ณต์ (`c.set` & `c.get`)
ํ์
์์ ํ๊ฒ ๋ฏธ๋ค์จ์ด์์ ์ธ์ฆ๋ ์ฌ์ฉ์ ์ ๋ณด๋ฅผ ์ ์ฅํ๊ณ ํ์ ํธ๋ค๋ฌ์์ ๊บผ๋ด ์ฐ๋ ํจํด์
๋๋ค:
```typescript
import { Hono } from 'hono';
// 1. Context์ ์ ์ฅํ Variables ํ์
์ธํฐํ์ด์ค ์ ์
interface AppVariables {
Variables: {
currentUser: {
userId: string;
role: 'admin' | 'user';
};
requestId: string;
};
}
// 2. ์ ๋ค๋ฆญ์ผ๋ก AppVariables ์ ๋ฌ
const app = new Hono<AppVariables>();
// 3. ์ปค์คํ
์ธ์ฆ ๋ฐ ์์ฒญ ID ์ฃผ์
๋ฏธ๋ค์จ์ด
app.use('*', async (c, next) => {
const reqId = crypto.randomUUID();
c.set('requestId', reqId);
// ํค๋์์ ํ ํฐ ๊ฒ์ฆ ์๋ฎฌ๋ ์ด์
const authHeader = c.req.header('Authorization');
if (authHeader?.startsWith('Bearer admin-key')) {
c.set('currentUser', { userId: 'admin_01', role: 'admin' });
} else {
c.set('currentUser', { userId: 'guest_99', role: 'user' });
}
// ๋ค์ ํธ๋ค๋ฌ๋ก ์ ์ด๊ถ ์์ (ํ์: await next())
await next();
// ํ์ฒ๋ฆฌ (์๋ต ํค๋์ ์์ฒญ ID ์ฒจ๋ถ)
c.header('X-Request-Id', reqId);
});
// 4. ๋น์ฆ๋์ค ๋ก์ง ํธ๋ค๋ฌ์์ ํ์
์์ ํ๊ฒ ๊บผ๋ด๊ธฐ
app.get('/api/profile', (c) => {
const user = c.get('currentUser'); // { userId: string; role: 'admin' | 'user' } ์๋ ์ถ๋ก
const reqId = c.get('requestId');
return c.json({
requestId: reqId,
profile: {
id: user.userId,
role: user.role,
accessedAt: new Date().toISOString(),
},
});
});
```
---
## 5. [3๋จ๊ณ] ์ค๋ฌด ์์ฉ & ๊ณ ๊ธ ํจํด (Advanced Production Patterns)
### ๐ 1. Hono RPC: ํ๋ก ํธ์๋-๋ฐฑ์๋ ๊ฐ End-to-End ํ์
์ธ์ดํํฐ
Hono RPC๋ฅผ ์ฌ์ฉํ๋ฉด ๋ฐฑ์๋ ์ฝ๋ ์์ฑ๊ธฐ(tRPC๋ GraphQL Code Generator ๋ฑ) ์์ด๋ **๋ฐฑ์๋ ๋ผ์ฐํธ ํ์
๋ง ์ต์คํฌํธ**ํ์ฌ ํ๋ก ํธ์๋์์ ์์ ํ ํ์
์๋์์ฑ์ ๋๋ฆด ์ ์์ต๋๋ค:
#### 1) ๋ฐฑ์๋ (`server.ts`)
```typescript
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
const postSchema = z.object({
title: z.string().min(2),
content: z.string(),
});
// ๋ฉ์๋ ์ฒด์ด๋์ ์ ์งํ์ฌ ๋ผ์ฐํธ ์ธ์คํด์ค ์์ฑ
const apiRoutes = new Hono()
.get('/items', (c) => {
return c.json([
{ id: 1, name: 'Cloudflare Worker' },
{ id: 2, name: 'Hono Framework' },
]);
})
.post('/items', zValidator('json', postSchema), (c) => {
const { title, content } = c.req.valid('json');
return c.json({ id: 3, title, content, success: true }, 201);
});
// ์ต์์ ์ฑ์ ๋ง์ดํธ
const app = new Hono().route('/api', apiRoutes);
// ํด๋ผ์ด์ธํธ๋ฅผ ์ํ ๋ผ์ฐํธ ํ์
์ต์คํฌํธ
export type AppType = typeof apiRoutes;
export default app;
```
#### 2) ํ๋ก ํธ์๋ React/Next.js (`client.ts`)
```typescript
import { hc } from 'hono/client';
import type { AppType } from './server'; // ๋ฐฑ์๋ ํ์
๋ง ์ํฌํธ (๋ฐํ์ ๋ฒ๋ค ํฌ๊ธฐ 0B)
// ๋ฐฑ์๋ API ํด๋ผ์ด์ธํธ ์์ฑ
const client = hc<AppType>('http://localhost:3000/api');
async function main() {
// 1. GET ์์ฒญ (๋ฐํ๊ฐ ํ์
์ด { id: number; name: string }[] ๋ก ์๋ ์ถ๋ก ๋จ)
const res = await client.items.$get();
const items = await res.json();
console.log('์์ดํ
๋ชฉ๋ก:', items);
// 2. POST ์์ฒญ (์์ฒญ ๋ฐ๋ ์ธ์๊ฐ { title: string; content: string } ๋ก ํ์
๊ฒ์ฌ๋จ)
const postRes = await client.items.$post({
json: {
title: '์๋ก์ด ๊ธ ์์ฑ',
content: 'Hono RPC๋ ๋งค์ฐ ๊ฐ๋ ฅํฉ๋๋ค.',
},
});
if (postRes.status === 201) {
const created = await postRes.json();
console.log(`์์ฑ ์ฑ๊ณต! ID: ${created.id}`);
}
}
```
---
### ๐ 2. ์ฃ์ง ์คํธ๋ฆฌ๋ฐ & Server-Sent Events (SSE)
Hono๋ Web Streams API๋ฅผ ํ์ฉํ์ฌ OpenAI/Claude AI ์คํธ๋ฆฌ๋ฐ ์๋ต์ด๋ ์ค์๊ฐ ์๋ฆผ SSE๋ฅผ ์๋ฒฝํ ์ง์ํฉ๋๋ค:
```typescript
import { Hono } from 'hono';
import { streamText, streamSSE } from 'hono/streaming';
const app = new Hono();
// 1. ๋์ฉ๋ ํ
์คํธ / AI ์๋ต ํ ํฐ ๋จ์ ์ฒญํฌ ์คํธ๋ฆฌ๋ฐ
app.get('/api/ai/stream', (c) => {
return streamText(c, async (stream) => {
const message = '์ธ๊ณต์ง๋ฅ LLM์ด ์์ฑํ ์ค์๊ฐ ์๋ต ํ
์คํธ ํ ํฐ์
๋๋ค... ';
for (const char of message.split('')) {
await stream.write(char);
await stream.sleep(40); // 40ms ์ง์ฐ ์๋ฎฌ๋ ์ด์
}
});
});
// 2. ์ค์๊ฐ Server-Sent Events (SSE) ํธ์ ์๋ฆผ
app.get('/api/notifications/sse', (c) => {
return streamSSE(c, async (stream) => {
let count = 1;
while (count <= 5) {
await stream.writeSSE({
data: JSON.stringify({ message: `์๋ฒ ์ํ ํ ์๋ฆผ #${count}`, time: Date.now() }),
event: 'ping',
id: String(count),
});
await stream.sleep(1000); // 1์ด ๊ฐ๊ฒฉ ์ ์ก
count++;
}
});
});
```
---
### โ๏ธ 3. Cloudflare Workers ์ํ๊ณ ์ฐ๋ (D1 SQL & KV ๋ฐ์ธ๋ฉ)
Cloudflare Workers์ ํ๊ฒฝ๋ณ์, Secrets, D1(์๋ฒ๋ฆฌ์ค SQLite), KV(๊ธ๋ก๋ฒ ์บ์)๋ฅผ ํ์
์์ ํ๊ฒ ๋ค๋ฃจ๋ ๋ฐฉ๋ฒ์
๋๋ค:
```typescript
// wrangler.jsonc ์ค์ ์์:
// "kv_namespaces": [{ "binding": "CACHE_KV", "id": "xxxx" }],
// "d1_databases": [{ "binding": "DB", "database_name": "prod-db", "database_id": "xxxx" }]
import { Hono } from 'hono';
// 1. Cloudflare ๋ฐ์ธ๋ฉ ํ๊ฒฝ๋ณ์ ํ์
์ธํฐํ์ด์ค ์ ์
interface Env {
Bindings: {
API_SECRET_KEY: string;
CACHE_KV: KVNamespace;
DB: D1Database;
};
}
const app = new Hono<Env>();
// 2. D1 ๋ฐ์ดํฐ๋ฒ ์ด์ค ์กฐํ ๋ฐ KV ์บ์ฑ ํจํด
app.get('/api/products/:id', async (c) => {
const productId = c.req.param('id');
const cacheKey = `product:${productId}`;
// Step 1: KV ๊ธ๋ก๋ฒ ์บ์ ํ์ธ
const cached = await c.env.CACHE_KV.get(cacheKey, 'json');
if (cached) {
return c.json({ source: 'cache', data: cached });
}
// Step 2: D1 SQL ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฟผ๋ฆฌ ์คํ (Prepared Statement)
const product = await c.env.DB.prepare('SELECT id, name, price, stock FROM products WHERE id = ?')
.bind(productId)
.first();
if (!product) {
return c.json({ error: '์ํ์ ์ฐพ์ ์ ์์ต๋๋ค.' }, 404);
}
// Step 3: KV์ 60์ด๊ฐ ์บ์ ์ ์ฅ (TTL)
await c.env.CACHE_KV.put(cacheKey, JSON.stringify(product), { expirationTtl: 60 });
return c.json({ source: 'database', data: product });
});
```
---
### ๐จ 4. ์ ์ญ ์์ธ ์ฒ๋ฆฌ & HTTP ํ์ค ์๋ฌ ํธ๋ค๋ง
```typescript
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
const app = new Hono();
// ํน์ ๋น์ฆ๋์ค ๋ก์ง์์ HTTPException ๋ฐ์
app.get('/api/protected', (c) => {
const token = c.req.header('Authorization');
if (!token) {
throw new HTTPException(401, { message: '์ธ์ฆ ํ ํฐ์ด ๋๋ฝ๋์์ต๋๋ค.' });
}
return c.text('์ ๊ทผ ์น์ธ ์๋ฃ');
});
// 1. 404 Not Found ์ ์ญ ํธ๋ค๋ฌ
app.notFound((c) => {
return c.json(
{
error: 'Not Found',
message: `์์ฒญํ์ ๊ฒฝ๋ก [${c.req.method} ${c.req.path}]๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค.`,
statusCode: 404,
},
404
);
});
// 2. 500 ์ ์ญ ์๋ฌ ํธ๋ค๋ฌ (๋ชจ๋ ๋น์ ์ ์์ธ ํฌํ)
app.onError((err, c) => {
console.error(`[์๋ฒ ์๋ฌ ๋ฐ์] ${err.message}`, err.stack);
if (err instanceof HTTPException) {
return err.getResponse();
}
return c.json(
{
error: 'Internal Server Error',
message: '์๋ฒ ๋ด๋ถ ์ฒ๋ฆฌ ์ค ์๊ธฐ์น ์์ ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.',
statusCode: 500,
},
500
);
});
```
---
## 6. Hono ํต์ฌ API & ๋ฏธ๋ค์จ์ด ํต ๋ ํผ๋ฐ์ค (Cheat Sheet Quick Reference)
### ๐ `c.req` ์์ฒญ ํ์ฑ ์ฃผ์ ๋ฉ์๋
| ๋ฉ์๋ / ํ๋กํผํฐ | ์ค๋ช
| ์ฌ์ฉ ์์ |
| :--- | :--- | :--- |
| `c.req.param(name)` | ๊ฒฝ๋ก ํ๋ผ๋ฏธํฐ ๊ฐ ์ถ์ถ (`:id`) | `const id = c.req.param('id');` |
| `c.req.query(name)` | ์ฟผ๋ฆฌ์คํธ๋ง ํ๋ผ๋ฏธํฐ ๋จ์ผ ๊ฐ ์ถ์ถ | `const q = c.req.query('q');` |
| `c.req.queries(name)` | ๋์ผ ํค์ ๋ค์ค ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ ๋ฐฐ์ด ์ถ์ถ | `const tags = c.req.queries('tags');` |
| `c.req.header(name)` | ํน์ ์์ฒญ ํค๋ ๊ฐ ์ถ์ถ | `const auth = c.req.header('Authorization');` |
| `await c.req.json()` | JSON ํ์์ ์์ฒญ ๋ณธ๋ฌธ ํ์ฑ | `const body = await c.req.json();` |
| `await c.req.text()` | ์ผ๋ฐ ํ
์คํธ ๋ณธ๋ฌธ ํ์ฑ | `const text = await c.req.text();` |
| `await c.req.formData()`| Multipart/UrlEncoded ํผ ๋ฐ์ดํฐ ํ์ฑ | `const form = await c.req.formData();` |
| `c.req.valid(target)` | `zValidator` ๋ฑ์ผ๋ก ๊ฒ์ฆ๋ ํ์
์์ ๋ฐ์ดํฐ | `const data = c.req.valid('json');` |
### ๐ค `c` ์๋ต ํฌํผ ๋ฉ์๋
| ๋ฉ์๋ | ๋ฐํ Content-Type | ์ค๋ช
|
| :--- | :--- | :--- |
| `c.text(text, status?)` | `text/plain; charset=UTF-8` | ๋ฌธ์์ด ํ
์คํธ ์๋ต |
| `c.json(object, status?)`| `application/json; charset=UTF-8` | JSON ๊ฐ์ฒด ์ง๋ ฌํ ์๋ต |
| `c.html(htmlString, status?)` | `text/html; charset=UTF-8` | HTML ๋ฌธ์ ๋ ๋๋ง ์๋ต |
| `c.redirect(url, status?)` | `Location` ํค๋ ํฌํจ | ๊ธฐ๋ณธ 302 ๋ฆฌ๋ค์ด๋ ํธ ์๋ต |
| `c.notFound()` | `text/plain` (404) | 404 Not Found ๋น ๋ฅธ ๋ฐํ |
| `c.header(key, value)` | - | ์๋ต ํค๋ ์ถ๊ฐ/์ค์ |
| `c.status(statusCode)` | - | HTTP ์๋ต ์ํ ์ฝ๋ ๋ณ๊ฒฝ |
### ๐ ๏ธ ๊ณต์ ๋ด์ฅ ๋ฏธ๋ค์จ์ด ์์ฝ
| ๋ฏธ๋ค์จ์ด ํจ์ | ํจํค์ง ๊ฒฝ๋ก | ์ฃผ์ ์ญํ |
| :--- | :--- | :--- |
| `logger()` | `hono/logger` | ํฐ๋ฏธ๋ ์์ฒญ/์๋ต ์ค์๊ฐ ๋ก๊น
|
| `cors(options)` | `hono/cors` | Cross-Origin ๋ฆฌ์์ค ๊ณต์ ํค๋ ์ ์ด |
| `secureHeaders()` | `hono/secure-headers` | OWASP ๊ถ์ฅ ๋ณด์ ํค๋ ์๋ ์ฃผ์
|
| `bearerAuth(options)` | `hono/bearer-auth` | Bearer ํ ํฐ ๊ฐ์ด ์ธ์ฆ ๊ฒ์ฆ๊ธฐ |
| `basicAuth(options)` | `hono/basic-auth` | HTTP ๊ธฐ๋ณธ ์ธ์ฆ(Base64) ๋ฏธ๋ค์จ์ด |
| `jwt(options)` | `hono/jwt` | JWT ํ ํฐ ๊ฒ์ฆ ๋ฐ ํ์ด๋ก๋ ์ถ์ถ |
| `prettyJSON()` | `hono/pretty-json` | JSON ๋ค์ฌ์ฐ๊ธฐ ๊ฐ๋
์ฑ ๊ฐ์ |
| `compress()` | `hono/compress` | Gzip/Deflate ์๋ต ์์ถ |
| `etag()` | `hono/etag` | HTTP ETag ์บ์ ์๋ณ์ ์๋ ์์ฑ |
| `timeout(ms)` | `hono/timeout` | ์์ฒญ ์๊ฐ ์ด๊ณผ ์ 504 ๋ฐํ |
---
## 7. ์ค๋ฌด ์ฃผ์์ฌํญ & ํ๋ก๋์
์ฒดํฌ๋ฆฌ์คํธ (Gotchas & Production Checklist)
### โ ๏ธ ์์ฃผ ๋ฐ์ํ๋ ์ค์ & ํธ๋ฌ๋ธ์ํ
1. **๋ฏธ๋ค์จ์ด์์ `await next()` ๋๋ฝ ์ฃผ์**:
- ์๋ชป๋ ์: `app.use('*', (c, next) => { console.log('start'); next(); });`
- ํด๊ฒฐ์ฑ
: Hono ๋ฏธ๋ค์จ์ด๋ ๋น๋๊ธฐ ํ๋ก๋ฏธ์ค๋ฅผ ๋ฐํํ๋ฏ๋ก ๋ฐ๋์ `await next()`๋ฅผ ํธ์ถํด์ผ ํ์ ํธ๋ค๋ฌ์ ์๋ต์ด ๋ณด์ฅ๋๋ฉฐ, ์๋ต ํ์ฒ๋ฆฌ(๊ฒฝ๊ณผ ์๊ฐ ์ธก์ ๋ฑ)๊ฐ ์ ์ ์๋ํฉ๋๋ค.
2. **Context(`c`) ๋น๋๊ธฐ ํธ๋ค๋ฌ ์๋ช
์ฃผ๊ธฐ ์ฃผ์**:
- `c` ๊ฐ์ฒด๋ HTTP ์์ฒญ์ด ์ข
๋ฃ๋๋ ์ฆ์ ๊ฐ๋น์ง ์ปฌ๋ ์
์ ๋์์ด ๋๊ฑฐ๋ ๋ฉ๋ชจ๋ฆฌ ํ๋ก ๋ฐํ๋ ์ ์์ต๋๋ค. `setTimeout`์ด๋ ๋ฐฑ๊ทธ๋ผ์ด๋ ํ์คํฌ์์ `c.req`๋ฅผ ๋์ค์ ๋น๋๊ธฐ๋ก ์ฝ์ผ๋ ค๊ณ ํ๋ฉด ์๋ฌ๊ฐ ๋ฐ์ํฉ๋๋ค. ๋ฐฑ๊ทธ๋ผ์ด๋ ์์
์ด ํ์ํ๋ค๋ฉด Cloudflare์ `c.executionCtx.waitUntil(promise)`๋ฅผ ์ฌ์ฉํ์ธ์.
3. **์ฃ์ง ๋ฐํ์์ Node.js ๋ค์ดํฐ๋ธ API ์ ์ฝ**:
- Cloudflare Workers ํ๊ฒฝ์์๋ Node.js์ C++ ๋ค์ดํฐ๋ธ ์ ๋์จ(`.node`), ๋ก์ปฌ ํ์ผ์์คํ
๋๊ธฐ I/O(`fs.readFileSync`), ์ด์์ฒด์ ์์ผ(`net`, `dgram`) ๋ฑ์ ์ง์ ์ธ ์ ์์ต๋๋ค. `wrangler.jsonc`์ `compatibility_flags = ["nodejs_compat"]` ์ค์ ์ ์ถ๊ฐํ์ฌ ์ง์๋๋ Node ํ์ค ๋ผ์ด๋ธ๋ฌ๋ฆฌ(`Buffer`, `crypto` ๋ฑ)๋ฅผ ํ์ธํ์ธ์.
---
### ๐ ํ๋ก๋์
๋ฐฐํฌ ์ฒดํฌ๋ฆฌ์คํธ (Edge & Serverless)
- [ ] **๋ณด์ ํค๋ ํ์ฑํ**: `app.use('*', secureHeaders())`๋ฅผ ์ต์๋จ์ ๋ง์ดํธํ์ฌ XSS ๋ฐ Clickjacking ๋ฐฉ์ง ์กฐ์น ์๋ฃ.
- [ ] **์๊ฒฉํ CORS ์ค์ **: ํ๋ก๋์
์์๋ `origin: '*'` ๋์ ์ ๋ขฐํ ์ ์๋ ๋๋ฉ์ธ ๋ฆฌ์คํธ๋ฅผ ๋ช
์.
- [ ] **Zod ๋ฐํ์ ์คํค๋ง ๊ฒ์ฆ**: ๋ชจ๋ ํด๋ผ์ด์ธํธ ์
๋ ฅ(`body`, `query`, `param`)์ `zValidator`๋ฅผ ์ ์ฉํ์ฌ ์๋ชป๋ ํ๋ผ๋ฏธํฐ ์ฌ์ ์ฐจ๋จ.
- [ ] **์ ์ญ ์๋ฌ ํธ๋ค๋ฌ ๊ตฌ์ฑ**: `app.onError` ๋ฐ `app.notFound`๋ฅผ ์์ฑํ์ฌ ๋ฏผ๊ฐํ ์์คํ
์คํํธ๋ ์ด์ค๊ฐ ์ฌ์ฉ์์๊ฒ ๋
ธ์ถ๋์ง ์๋๋ก ๋ง์คํน.
- [ ] **Hono RPC ์ธํฐํ์ด์ค ๊ด๋ฆฌ**: ํ๋ก ํธ์๋์ ๊ณต์ ํ๋ `export type AppType = typeof routes;`๊ฐ ์ต์ ์ํ๋ก ์ ์ง๋๋์ง ํ์ธ.
- [ ] **๋ฒ๋ค ํฌ๊ธฐ ๋ฐ ํธ๋ฆฌ์์ดํน ๊ฒ์ฆ**: ๋ถํ์ํ ๊ฑฐ๋ Node ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ํฌํธํ์ง ์๊ณ Web Fetch ํ์ค ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ฐ์ ์ฌ์ฉ.
์๊ฒฌ ๋ฐ ์ง๋ฌธ
0์์ง ๋ฑ๋ก๋ ์๊ฒฌ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋๊ธ์ ๋จ๊ฒจ๋ณด์ธ์!
๋๊ธ ์์
๋๊ธ ์ญ์