feat: 파일 갤러리 CRUD PoC (DB + MinIO 동작 검증)

DB(메타데이터)와 MinIO(바이트)를 함께 실제로 쓰는 PoC. 신규 런타임 의존성 없음.
- src/files.js: files 테이블 + saveFile/list/get/rename/delete + getObjectStream
- src/server.js: POST/GET/PATCH/DELETE /files, 정적 UI, 부팅 시 테이블 보장
  · 업로드는 express.raw(멀티파트 파서 불필요), 다운로드는 S3 스트림 파이프
  · 텍스트 charset=utf-8 + RFC5987 파일명으로 한글 깨짐 방지
- public/index.html: 드래그앤드롭 업로드 + 목록/열기/이름변경/삭제 UI
- scripts/check-files.js(files:check): DB+S3 라운드트립 자가 검증

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 08:02:00 +00:00
parent 3252dfaa66
commit b85202ad1b
5 changed files with 555 additions and 4 deletions

297
public/index.html Normal file
View File

@@ -0,0 +1,297 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>파일 갤러리 — DB + MinIO PoC</title>
<style>
:root {
--bg: #0f172a;
--panel: #ffffff;
--ink: #0f172a;
--muted: #64748b;
--line: #e2e8f0;
--brand: #6366f1;
--brand-600: #4f46e5;
--ok: #10b981;
--err: #ef4444;
--radius: 14px;
--shadow: 0 10px 30px -12px rgba(2, 6, 23, 0.25);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: -apple-system, system-ui, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
color: var(--ink);
background:
radial-gradient(1200px 600px at 100% -10%, #312e81 0%, transparent 55%),
radial-gradient(1000px 500px at -10% 110%, #0e7490 0%, transparent 50%),
var(--bg);
}
.wrap { max-width: 760px; margin: 0 auto; padding: 3rem 1.25rem 4rem; }
header { color: #e2e8f0; margin-bottom: 1.75rem; }
.badge {
display: inline-flex; align-items: center; gap: .4rem;
font-size: .72rem; font-weight: 600; letter-spacing: .02em;
color: #c7d2fe; background: rgba(99,102,241,.18);
border: 1px solid rgba(99,102,241,.35);
padding: .3rem .6rem; border-radius: 999px;
}
.badge::before {
content: ""; width: 7px; height: 7px; border-radius: 50%;
background: var(--ok); box-shadow: 0 0 0 3px rgba(16,185,129,.25);
}
h1 { font-size: 1.6rem; margin: .8rem 0 .3rem; letter-spacing: -.01em; }
header p { margin: 0; color: #94a3b8; font-size: .92rem; }
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.25rem;
}
/* 드롭존 */
.drop {
display: flex; flex-direction: column; align-items: center; gap: .75rem;
padding: 2rem 1rem; text-align: center;
border: 2px dashed var(--line); border-radius: var(--radius);
background: #f8fafc; cursor: pointer; transition: .15s ease;
}
.drop:hover, .drop.over { border-color: var(--brand); background: #eef2ff; }
.drop svg { width: 40px; height: 40px; color: var(--brand); }
.drop strong { color: var(--brand-600); }
.drop small { color: var(--muted); }
#file { display: none; }
.actionbar {
display: flex; align-items: center; gap: .75rem; margin-top: 1rem;
}
.filename {
flex: 1; min-width: 0; font-size: .9rem; color: var(--muted);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
button {
font: inherit; font-weight: 600; cursor: pointer;
color: #fff; background: var(--brand-600); border: 0;
padding: .6rem 1.1rem; border-radius: 10px; transition: .15s ease;
}
button:hover { background: var(--brand); transform: translateY(-1px); }
button:disabled { opacity: .5; cursor: default; transform: none; }
#status { margin-top: .85rem; font-size: .88rem; min-height: 1.2em; }
#status.ok { color: var(--ok); }
#status.err { color: var(--err); }
#status.busy { color: var(--muted); }
.list-head {
display: flex; align-items: baseline; justify-content: space-between;
margin: 2rem .25rem .75rem; color: #cbd5e1;
}
.list-head h2 { font-size: 1rem; margin: 0; color: #e2e8f0; }
.count { font-size: .8rem; color: #94a3b8; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: .6rem; }
li {
display: flex; align-items: center; gap: .85rem;
background: var(--panel); border: 1px solid var(--line);
border-radius: 12px; padding: .8rem .95rem;
transition: .15s ease;
}
li:hover { border-color: #c7d2fe; box-shadow: var(--shadow); }
.ico {
flex: none; width: 38px; height: 38px; border-radius: 9px;
display: grid; place-items: center; font-weight: 700; font-size: .7rem;
color: var(--brand-600); background: #eef2ff;
text-transform: uppercase;
}
.meta { flex: 1; min-width: 0; }
.meta a {
font-weight: 600; color: var(--ink); text-decoration: none;
display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.meta a:hover { color: var(--brand-600); text-decoration: underline; }
.meta small { color: var(--muted); font-size: .78rem; }
.actions { flex: none; display: flex; gap: .4rem; }
.btn-sm {
color: var(--muted); background: #fff; text-decoration: none;
font: inherit; font-size: .82rem; font-weight: 600; cursor: pointer;
padding: .35rem .6rem; border: 1px solid var(--line); border-radius: 8px;
transition: .15s ease;
}
.btn-sm:hover { color: var(--brand-600); border-color: #c7d2fe; transform: none; }
.btn-sm.danger:hover { color: var(--err); border-color: #fecaca; background: #fef2f2; }
.empty { color: #94a3b8; font-size: .9rem; text-align: center; padding: 1.5rem; }
</style>
</head>
<body>
<div class="wrap">
<header>
<span class="badge">DB + MinIO 연결됨</span>
<h1>파일 갤러리</h1>
<p>업로드하면 MinIO(S3)에 저장되고 메타데이터는 Postgres에 기록됩니다.</p>
</header>
<div class="card">
<label class="drop" id="drop" for="file">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<div><strong>클릭</strong>하거나 파일을 여기로 끌어다 놓으세요</div>
<small>최대 25MB</small>
</label>
<input type="file" id="file" />
<div class="actionbar">
<span class="filename" id="filename">선택된 파일 없음</span>
<button id="upload" disabled>업로드</button>
</div>
<div id="status"></div>
</div>
<div class="list-head">
<h2>파일 목록</h2>
<span class="count" id="count"></span>
</div>
<ul id="list"></ul>
</div>
<script>
const $ = (s) => document.querySelector(s);
const fileInput = $("#file");
const drop = $("#drop");
const statusEl = $("#status");
const uploadBtn = $("#upload");
function setStatus(msg, kind) {
statusEl.textContent = msg || "";
statusEl.className = kind || "";
}
function fmtSize(n) {
n = Number(n);
if (n < 1024) return n + " B";
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
return (n / 1024 / 1024).toFixed(1) + " MB";
}
function ext(name) {
const m = /\.([a-z0-9]+)$/i.exec(name || "");
return m ? m[1].slice(0, 4) : "file";
}
function pick(file) {
fileInput._picked = file || null;
$("#filename").textContent = file ? file.name : "선택된 파일 없음";
uploadBtn.disabled = !file;
setStatus("");
}
fileInput.onchange = () => pick(fileInput.files[0]);
["dragenter", "dragover"].forEach((e) =>
drop.addEventListener(e, (ev) => { ev.preventDefault(); drop.classList.add("over"); })
);
["dragleave", "drop"].forEach((e) =>
drop.addEventListener(e, (ev) => { ev.preventDefault(); drop.classList.remove("over"); })
);
drop.addEventListener("drop", (ev) => pick(ev.dataTransfer.files[0]));
async function refresh() {
try {
const r = await fetch("/files");
const { files } = await r.json();
$("#count").textContent = (files || []).length + "개";
if (!files || !files.length) {
$("#list").innerHTML = '<div class="empty">아직 업로드된 파일이 없습니다.</div>';
return;
}
$("#list").innerHTML = files
.map(
(f) => `
<li data-id="${f.id}">
<div class="ico">${ext(f.name)}</div>
<div class="meta">
<a href="/files/${f.id}" target="_blank">${f.name}</a>
<small>${fmtSize(f.size)} · ${new Date(f.created_at).toLocaleString("ko-KR")}</small>
</div>
<div class="actions">
<a class="btn-sm" href="/files/${f.id}" target="_blank">열기</a>
<button class="btn-sm" data-act="rename">이름변경</button>
<button class="btn-sm danger" data-act="delete">삭제</button>
</div>
</li>`
)
.join("");
} catch (e) {
$("#list").innerHTML = '<div class="empty">목록을 불러오지 못했습니다.</div>';
}
}
// 목록의 이름변경/삭제 (이벤트 위임)
$("#list").addEventListener("click", async (ev) => {
const btn = ev.target.closest("button[data-act]");
if (!btn) return;
const li = btn.closest("li");
const id = li.dataset.id;
const cur = li.querySelector(".meta a").textContent;
if (btn.dataset.act === "delete") {
if (!confirm(`"${cur}" 을(를) 삭제할까요? (MinIO 객체도 함께 삭제됩니다)`)) return;
try {
const r = await fetch("/files/" + id, { method: "DELETE" });
const j = await r.json();
if (!r.ok) throw new Error(j.error || r.status);
setStatus("삭제됨 — #" + id, "ok");
refresh();
} catch (e) {
setStatus("삭제 실패: " + e.message, "err");
}
} else if (btn.dataset.act === "rename") {
const name = prompt("새 이름", cur);
if (!name || name.trim() === cur) return;
try {
const r = await fetch("/files/" + id, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim() }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error || r.status);
setStatus("이름 변경됨 — #" + id, "ok");
refresh();
} catch (e) {
setStatus("이름 변경 실패: " + e.message, "err");
}
}
});
uploadBtn.onclick = async () => {
const file = fileInput._picked;
if (!file) return;
uploadBtn.disabled = true;
setStatus("업로드 중…", "busy");
try {
const r = await fetch("/files?name=" + encodeURIComponent(file.name), {
method: "POST",
headers: { "Content-Type": file.type || "application/octet-stream" },
body: file,
});
const j = await r.json();
if (!r.ok) throw new Error(j.error || r.status);
setStatus("완료 — #" + j.id + " 저장됨", "ok");
fileInput.value = "";
pick(null);
refresh();
} catch (e) {
setStatus("실패: " + e.message, "err");
uploadBtn.disabled = false;
}
};
refresh();
</script>
</body>
</html>