문서 / Hono와 프로젝트 구조
Hono와 프로젝트 구조
파일을 역할별로 나누고 SSR 웹앱을 로컬 실행부터 배포까지 연결합니다.
역할별 파일 구성
Hono는 조립스페이스의 기본 서버 프레임워크입니다. Web 표준 Request·Response를 바탕으로 API 라우팅, 공통 미들웨어, 서버에서 렌더링하는 웹페이지를 구성할 수 있습니다. 이 가이드에서는 상품 목록과 상세 페이지가 있는 웹앱을 만들고 조립스페이스에 배포합니다. Hono 공식 안내
아래 예제는 진입점에서 라우트와 공통 오류 처리를 연결하고, 화면과 기능별 코드는 별도 파일로 나눕니다. 기능이 늘어날 때 필요한 부분을 쉽게 찾을 수 있으며, 배포 시에는 서버 소스를 하나의 번들로 묶습니다.
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예제에는 공개 상품 목록·상세 페이지와 만료 세션 정리 기능이 포함되어 있습니다. 로그인·결제·실시간 기능은 각 가이드에서 이어서 살펴볼 수 있습니다. 기존 앱에 적용할 때는 파일명과 테이블명이 겹치는지 먼저 확인하세요.
설치와 설정
새 작업 폴더에서 다음 명령으로 프로젝트를 준비합니다. 기존 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"
}
]
}wrangler.jsonc의 DB ID는 로컬 실행용입니다. 조립스페이스에 배포하면 해당 프로젝트에 연결된 DB와 스토리지를 사용하므로 로컬 DB ID를 운영 설정에 옮길 필요가 없습니다.
실행 가능한 소스
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 응답을 확인합니다.