문서 / DB와 파일 저장

DB와 파일 저장

바인딩, 마이그레이션, 인덱스와 권한별 파일 관리 예제입니다.

스키마와 마이그레이션

Hono에서는 c.env.DB, Worker에서는 env.DB로 프로젝트 DB를 사용합니다. Hono 예제 스키마의 products와 app_sessions를 기준으로 설명합니다.

터미널
joripspace db schema --project PROJECT
joripspace db migrate --project PROJECT --file migrations/002_add_feature.sql

위 두 번째 명령의 파일은 추가할 SQL을 작성한 뒤 사용합니다. 기존 테이블을 삭제·재생성하지 말고 이전 코드와 호환되는 컬럼·인덱스를 먼저 추가하세요. 운영 데이터에 적용하기 전에 로컬에서 확인하고 실제 백업·복구 수단도 점검합니다.

조회·생성·수정·삭제

값은 반드시 bind()로 바인딩합니다. SQL 문자열에 사용자 입력을 직접 이어 붙이지 마세요. 아래 코드는 서버 저장소 함수의 예이며, 공개 HTTP 경로에 연결할 때 별도의 관리자 권한 검사가 필요합니다. D1 Prepared statements

서버 DB 처리
// 권한 검사와 입력 검증이 끝난 서버 코드
const product = await env.DB.prepare('SELECT id,name,price FROM products WHERE id=?').bind(id).first();
await env.DB.prepare('INSERT INTO products(name,description,price,published) VALUES(?,?,?,0)').bind(name,description,price).run();
await env.DB.prepare('UPDATE products SET name=?,price=? WHERE id=?').bind(name,price,id).run();
// 상품 숨김은 공개 상태만 변경합니다. 실제 삭제는 참조하는 주문까지 검토하세요.
await env.DB.prepare('UPDATE products SET published=0 WHERE id=?').bind(id).run();
await env.DB.prepare('DELETE FROM products WHERE id=? AND published=0').bind(id).run();

주문 상태 변경과 발행 대기 기록처럼 함께 성공해야 하는 작업은 D1 batch 또는 DB 트리거 등 실제 트랜잭션 수단으로 묶습니다. 두 번의 독립적인 await를 하나의 트랜잭션으로 취급하지 않습니다.

커서 페이지네이션과 인덱스

목록 조회와 실행 계획
SELECT id,name,price FROM products
WHERE published=1 AND id>? ORDER BY id LIMIT 21;

EXPLAIN QUERY PLAN
SELECT id,name,price FROM products
WHERE published=1 AND id>0 ORDER BY id LIMIT 21;

20개를 표시하고 21번째 행의 존재로 다음 페이지를 판단합니다. 마지막으로 표시한 id를 다음 요청의 커서로 전달합니다. 예제의 idx_products_public(published,id)가 검색·정렬을 지원하는지 실행 계획에서 확인하세요. id는 정수 기본 키로 단건 조회에도 사용됩니다.

인덱스도 저장 공간과 쓰기 비용을 사용합니다. 실제 필터·정렬 경로에 맞게 추가하고 모든 컬럼에 무조건 만들지 마세요.

파일 업로드·목록·조회·삭제

아래 예제는 세션 미들웨어를 적용해 본인의 PNG 파일만 관리합니다. 2MiB는 예제 앱이 선택한 제한이며 플랫폼 최대 용량 설명이 아닙니다. Content-Type뿐 아니라 실제 파일 시그니처를 검사합니다.

src/routes/files.ts
import { Hono } from 'hono';
import { bodyLimit } from 'hono/body-limit';
import { requireSession } from '../middleware/session';
import type { AppEnv } from '../types';
export const files = new Hono<AppEnv>();
files.use('*', requireSession);
files.use('*', bodyLimit({ maxSize: 2 * 1024 * 1024 })); // 이 예제 앱이 선택한 제한

files.put('/:id', async (c) => {
  const id = c.req.param('id');
  if (!/^[a-zA-Z0-9_-]{1,64}$/.test(id)) return c.json({ error: 'invalid_id' }, 400);
  const bytes = new Uint8Array(await c.req.arrayBuffer());
  const png = [137,80,78,71,13,10,26,10];
  if (!png.every((v,i) => bytes[i] === v)) return c.json({ error: 'png_required' }, 415);
  const key = 'images/' + c.get('identity').user_id + '/' + id + '.png';
  await c.env.STORAGE.put(key, bytes, { httpMetadata: { contentType: 'image/png' } });
  return c.json({ id }); // 임의의 다른 사용자 키를 받지 않습니다.
});
files.get('/:id', async (c) => {
  const id = c.req.param('id');
  if (!/^[a-zA-Z0-9_-]{1,64}$/.test(id)) return c.json({ error: 'invalid_id' }, 400);
  const key = 'images/' + c.get('identity').user_id + '/' + id + '.png';
  const object = await c.env.STORAGE.get(key);
  if (!object) return c.json({ error: 'not_found' }, 404);
  return new Response(object.body, { headers: {
    'Content-Type': 'image/png', 'Cache-Control': 'private, no-store',
    'X-Content-Type-Options': 'nosniff', 'Content-Disposition': 'attachment; filename="image.png"',
  } });
});
files.delete('/:id', async (c) => {
  const id = c.req.param('id');
  if (!/^[a-zA-Z0-9_-]{1,64}$/.test(id)) return c.json({ error: 'invalid_id' }, 400);
  await c.env.STORAGE.delete('images/' + c.get('identity').user_id + '/' + id + '.png');
  return c.json({ ok: true });
});
files.get('/', async (c) => {
  const page = await c.env.STORAGE.list({
    prefix: 'images/' + c.get('identity').user_id + '/', limit: 50,
    ...(c.req.query('cursor') ? { cursor: c.req.query('cursor') } : {}),
  });
  return c.json({ files: page.objects.map(o => ({ id: o.key.split('/').pop()?.slice(0,-4), size: o.size })),
    cursor: page.truncated ? page.cursor : null });
});
src/index.ts에 추가
import { files } from './routes/files';
app.route('/api/files', files);

브라우저에서는 로그인 후 PUT /api/files/FILE_ID의 본문에 PNG 파일 바이트를 보내고, GET·DELETE도 같은 URL을 사용합니다. 목록은 GET /api/files?cursor=...입니다. 로컬에서 실행하려면 wrangler.jsonc에 r2_buckets: [{ binding: 'STORAGE', bucket_name: 'docs-local-files' }]를 추가하세요.

이 예제는 민감하지 않은 이미지 파일용입니다. 개인정보·증빙 파일은 별도 암호화와 보존·삭제 설계를 적용하세요. 업로드 파일을 임의 HTML로 제공하지 말고, MIME 검증·크기 제한·권한 검사와 필요한 악성 파일 검사를 적용합니다.