문서 / Hono와 프로젝트 구조
Hono와 프로젝트 구조
파일을 역할별로 나누고 SSR 웹앱을 로컬 실행부터 배포까지 연결합니다.
역할별 파일 구성
Hono는 Web 표준 Request·Response를 사용하는 서버 프레임워크입니다. 조립스페이스의 Worker 진입점에서 라우팅·미들웨어·HTML 응답을 구성할 수 있습니다. Hono 공식 안내
진입점에는 경로 조합과 공통 오류 처리만 두세요. 화면 HTML, 업무 로직, DB 쿼리, 브라우저 코드를 한 파일에 계속 추가하지 마세요. 서버 소스는 여러 파일로 유지하고 배포할 때 번들로 합칠 수 있습니다.
src/
index.ts # 경로 조합
types.ts # 서버 바인딩 타입
routes/products.ts # 상품 URL과 응답
routes/jobs.ts # 정기 실행 경로
middleware/session.ts # 세션 확인
views/layout.ts # 공통 SSR 레이아웃
services/ # 결제·실시간 등 업무 로직
repositories/ # 규모가 커지면 DB 접근 분리
public/ # 브라우저 JS·CSS·이미지
migrations/001_init.sql
tests/
wrangler.jsonc
tsconfig.json아래는 공개 상품 목록·상세 SSR과 만료 세션 정리까지 실행되는 최소 예제입니다. 로그인·결제·실시간은 뒤의 가이드에서 추가합니다. 기존 프로젝트에 적용할 때 같은 파일이나 테이블을 덮어쓰지 마세요.
설치와 설정
연결한 작업 폴더에서 실행합니다. 이미 package.json이 있다면 기존 설정에 의존성을 추가하세요. 버전은 설치 후 lockfile로 고정하고 함께 커밋하세요.
npm init -y
npm install hono
npm install -D typescript esbuild wrangler @cloudflare/workers-types{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"types": [
"@cloudflare/workers-types"
],
"lib": [
"ES2022",
"DOM"
],
"skipLibCheck": true
},
"include": [
"src/**/*.ts"
]
}{
"name": "docs-local-example",
"main": "src/index.ts",
"compatibility_date": "2026-09-06",
"d1_databases": [
{
"binding": "DB",
"database_name": "docs-local",
"database_id": "00000000-0000-0000-0000-000000000001"
}
]
}위 DB ID는 로컬 개발용 예제입니다. 로컬 Wrangler 설정을 조립스페이스 운영 바인딩으로 복사하지 않습니다. 운영에서는 프로젝트의 DB·스토리지를 사용합니다.
실행 가능한 소스
export type AppEnv = {
Bindings: { DB: D1Database; STORAGE: R2Bucket; SESSION_HASH_SALT: string };
Variables: { identity: { user_id: string; session_hash: string } };
};import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { products } from './routes/products';
import { jobs } from './routes/jobs';
import type { AppEnv } from './types';
const app = new Hono<AppEnv>();
app.route('/', products);
app.route('/jobs', jobs);
app.get('/health', (c) => c.json({ ok: true }));
app.notFound((c) => c.text('페이지를 찾을 수 없습니다.', 404));
app.onError((error, c) => {
if (error instanceof HTTPException) return error.getResponse();
// 원문 요청·쿠키·PG 응답 대신 별도 오류 ID를 기록합니다.
const errorId = crypto.randomUUID();
console.error('request_failed', errorId);
return c.json({ error: 'internal_error', errorId }, 500);
});
export default app;import { html } from 'hono/html';
export function layout(title: string, description: string, body: ReturnType<typeof html>) {
return html`<!doctype html>
<html lang="ko"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${title}</title><meta name="description" content="${description}">
</head><body><header><a href="/">상품 목록</a></header>
<main>${body}</main></body></html>`;
}import { Hono } from 'hono';
import { html } from 'hono/html';
import { layout } from '../views/layout';
import type { AppEnv } from '../types';
type Product = { id: number; name: string; description: string; price: number };
export const products = new Hono<AppEnv>();
products.get('/', async (c) => {
const raw = c.req.query('after') || '0';
if (!/^\d{1,15}$/.test(raw)) return c.text('잘못된 커서입니다.', 400);
const after = Number(raw);
const { results } = await c.env.DB.prepare(
'SELECT id,name,description,price FROM products WHERE published=1 AND id>? ORDER BY id LIMIT 21'
).bind(after).all<Product>();
const rows = results.slice(0, 20);
const next = results.length > 20 ? rows[rows.length - 1].id : null;
// html은 문자열 값을 이스케이프합니다. 사용자 입력을 raw()에 넣지 마세요.
const body = html`<h1>상품 목록</h1><ul>${rows.map(p =>
html`<li><a href="/products/${p.id}">${p.name}</a> · ${p.price}원</li>`
)}</ul>${next === null ? '' : html`<a href="/?after=${next}">다음 페이지</a>`}`;
c.header('Cache-Control', 'no-cache');
return c.html(layout('상품 목록', '상품과 가격을 확인하세요.', body));
});
products.get('/products/:id', async (c) => {
const id = c.req.param('id');
if (!/^\d{1,15}$/.test(id)) return c.notFound();
const p = await c.env.DB.prepare(
'SELECT id,name,description,price FROM products WHERE id=? AND published=1'
).bind(Number(id)).first<Product>();
if (!p) return c.notFound();
c.header('Cache-Control', 'no-cache');
return c.html(layout(p.name, p.description,
html`<article><h1>${p.name}</h1><p>${p.description}</p><p>${p.price}원</p></article>`));
});import { Hono } from 'hono';
import type { AppEnv } from '../types';
export const jobs = new Hono<AppEnv>();
jobs.post('/cleanup-sessions', async (c) => {
// 스케줄러는 비공개 Dispatch 호출로 이 URL을 전달합니다.
// X-JoripSpace-Cron-ID 같은 공개 헤더만으로 허용하면 안 됩니다.
if (new URL(c.req.url).hostname !== 'joripspace-cron.internal')
return c.json({ error: 'scheduler_required' }, 403);
const result = await c.env.DB.prepare(
'DELETE FROM app_sessions WHERE session_hash IN (' +
'SELECT session_hash FROM app_sessions WHERE expires_at<=? ORDER BY expires_at LIMIT 100)'
).bind(Date.now()).run();
// 반복 실행해도 안전하며, 남은 항목은 다음 스케줄에서 처리합니다.
return c.json({ ok: true, deleted: result.meta.changes });
});CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
price INTEGER NOT NULL CHECK(price > 0),
published INTEGER NOT NULL DEFAULT 0 CHECK(published IN (0,1))
);
CREATE INDEX idx_products_public ON products(published,id);
INSERT INTO products(id,name,description,price,published)
VALUES(1,'예제 상품','SSR로 표시하는 공개 상품입니다.',1000,1);
CREATE TABLE app_sessions (
session_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX idx_sessions_expiry ON app_sessions(expires_at,session_hash);DB는 공개 예제 상품만 저장합니다. 세션 테이블은 비어 있으므로 로그인 없이 접근할 수 있는 영역은 공개 상품 페이지뿐입니다.
로컬 실행과 확인
npx wrangler d1 execute docs-local --local --file migrations/001_init.sql
npx tsc --noEmit
npx wrangler dev터미널에 표시된 로컬 주소에서 /, /products/1, /health를 열고 존재하지 않는 상품이 HTTP 404인지 확인하세요. DB 초기화 SQL은 새 예제 DB에 한 번만 적용합니다. 이후 변경은 새 마이그레이션으로 추가하세요.
조립스페이스 배포
npx esbuild src/index.ts --bundle --format=esm --platform=browser --target=es2022 --outfile=dist/worker.js
joripspace db migrate --project PROJECT --file migrations/001_init.sql
joripspace deploy --project PROJECT --source dist/worker.js --label "Hono SSR 예제"이 명령은 GitHub 자동 배포가 연결되지 않은 새 예제 프로젝트 기준입니다. 실제 프로젝트 이름을 넣고 운영 DB의 기존 테이블과 충돌하지 않는지 먼저 확인하세요. GitHub 연결 프로젝트는 지정된 브랜치와 배포 흐름을 따릅니다.
번들은 실행 코드입니다. 수정할 원본 src·마이그레이션·package.json·lockfile도 Git에 보관하세요. 배포 후 공개 URL의 목록·상세·404를 다시 확인합니다.