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

View File

@@ -8,7 +8,8 @@
"dev": "node --watch src/server.js", "dev": "node --watch src/server.js",
"start": "node src/server.js", "start": "node src/server.js",
"db:check": "node scripts/check-db.js", "db:check": "node scripts/check-db.js",
"minio:check": "node scripts/check-minio.js" "minio:check": "node scripts/check-minio.js",
"files:check": "node scripts/check-files.js"
}, },
"engines": { "engines": {
"node": ">=22" "node": ">=22"

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>

49
scripts/check-files.js Normal file
View File

@@ -0,0 +1,49 @@
// DB+S3 라운드트립 한 방 검증: `npm run files:check`
// ensureFilesTable → saveFile → listFiles(포함 확인) → getObjectStream(바이트 일치) → 성공/실패.
import { pool } from "../src/db.js";
import {
ensureFilesTable,
saveFile,
listFiles,
getObjectStream,
} from "../src/files.js";
async function readStream(stream) {
const chunks = [];
for await (const c of stream) chunks.push(c);
return Buffer.concat(chunks);
}
try {
await ensureFilesTable();
const payload = Buffer.from(`poc check ${new Date().toISOString()}`);
const row = await saveFile({
name: "poc-check.txt",
contentType: "text/plain",
body: payload,
});
console.log("saveFile OK:", { id: row.id, key: row.key, size: row.size });
const files = await listFiles();
if (!files.some((f) => f.key === row.key)) {
throw new Error("listFiles 에 방금 업로드한 key 가 없음");
}
console.log("listFiles OK:", files.length, "rows");
const obj = await getObjectStream(row.key);
const got = await readStream(obj.body);
if (!got.equals(payload)) {
throw new Error(
`다운로드 바이트 불일치: expected ${payload.length}, got ${got.length}`
);
}
console.log("getObject OK: 바이트 일치", got.length, "bytes");
console.log("FILES CHECK OK ✅ (DB insert/select + MinIO put/get 라운드트립 성공)");
} catch (e) {
console.error("FILES CHECK FAIL:", e.message);
process.exitCode = 1;
} finally {
await pool.end();
}

92
src/files.js Normal file
View File

@@ -0,0 +1,92 @@
// 파일 갤러리 도메인 로직 — DB(메타데이터) + MinIO(바이트)를 함께 검증한다.
// 자격증명/접속정보는 src/config.js 한 곳을 거친 pool/s3 를 그대로 재사용한다(코드에 비밀값 없음).
import crypto from "node:crypto";
import {
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
} from "@aws-sdk/client-s3";
import { pool } from "./db.js";
import { s3 } from "./s3.js";
import { config } from "./config.js";
const BUCKET = config.s3.bucket;
// 직원 전용 schema가 search_path에 고정돼 있으므로 이 테이블은 본인 schema에 생성된다.
export async function ensureFilesTable() {
await pool.query(`
create table if not exists files (
id bigserial primary key,
key text unique not null,
name text not null,
size bigint not null,
content_type text,
created_at timestamptz not null default now()
)
`);
}
// 파일명에서 경로/위험문자 제거(S3 key 안전).
function safeName(name) {
return String(name).replace(/[^\w.\-]+/g, "_").slice(0, 200) || "file";
}
// MinIO 에 업로드 + 메타데이터 insert. 생성된 row 반환.
export async function saveFile({ name, contentType, body }) {
const key = `uploads/${Date.now()}-${crypto.randomUUID()}-${safeName(name)}`;
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: body,
ContentType: contentType || "application/octet-stream",
})
);
const { rows } = await pool.query(
`insert into files (key, name, size, content_type)
values ($1, $2, $3, $4)
returning *`,
[key, name, body.length, contentType || null]
);
return rows[0];
}
export async function listFiles() {
const { rows } = await pool.query(
"select * from files order by created_at desc limit 100"
);
return rows;
}
export async function getFile(id) {
const { rows } = await pool.query("select * from files where id = $1", [id]);
return rows[0] || null;
}
// 이름 변경(메타데이터만 수정 — S3 객체/key 는 그대로). 없으면 null.
export async function renameFile(id, name) {
const { rows } = await pool.query(
"update files set name = $2 where id = $1 returning *",
[id, name]
);
return rows[0] || null;
}
// 삭제 — MinIO 객체와 DB row 를 함께 제거. 삭제된 row 반환(없으면 null).
export async function deleteFile(id) {
const row = await getFile(id);
if (!row) return null;
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: row.key }));
await pool.query("delete from files where id = $1", [id]);
return row;
}
// MinIO 에서 객체를 스트림으로 가져온다(다운로드 시 res 로 파이프).
export async function getObjectStream(key) {
const out = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: key }));
return {
body: out.Body, // Node Readable
contentType: out.ContentType,
contentLength: out.ContentLength,
};
}

View File

@@ -1,11 +1,23 @@
// 최소 Express 서버 — /healthz, /db, /s3 연결 확인. // 최소 Express 서버 — /healthz, /db, /s3 연결 확인 + /files 갤러리(DB+S3 실사용 검증).
import express from "express"; import express from "express";
import { config } from "./config.js"; import { config } from "./config.js";
import { pingDb } from "./db.js"; import { pingDb } from "./db.js";
import { pingS3 } from "./s3.js"; import { pingS3 } from "./s3.js";
import {
ensureFilesTable,
saveFile,
listFiles,
getFile,
renameFile,
deleteFile,
getObjectStream,
} from "./files.js";
const app = express(); const app = express();
// 최소 업로드 UI(public/index.html).
app.use(express.static("public"));
app.get("/healthz", (_req, res) => res.json({ ok: true })); app.get("/healthz", (_req, res) => res.json({ ok: true }));
app.get("/db", async (_req, res) => { app.get("/db", async (_req, res) => {
@@ -24,10 +36,110 @@ app.get("/s3", async (_req, res) => {
} }
}); });
app.get("/", (_req, res) => // 업로드: 원시 바이트 본문(멀티파트 파서 불필요). 파일명은 ?name= 또는 X-Filename 헤더.
res.json({ name: "sample", endpoints: ["/healthz", "/db", "/s3"] }) // curl -X POST "localhost:3000/files?name=a.txt" -H "Content-Type: text/plain" --data-binary "hi"
// 브라우저: fetch('/files?name='+file.name, { method:'POST', body:file })
app.post(
"/files",
express.raw({ type: "*/*", limit: "25mb" }),
async (req, res) => {
try {
const name = req.query.name || req.headers["x-filename"];
if (!name) {
return res
.status(400)
.json({ ok: false, error: "파일명이 필요합니다(?name= 또는 X-Filename)" });
}
if (!req.body || !req.body.length) {
return res.status(400).json({ ok: false, error: "빈 본문입니다" });
}
const row = await saveFile({
name: String(name),
contentType: req.headers["content-type"],
body: req.body,
});
res.status(201).json({ ok: true, ...row });
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) });
}
}
); );
app.get("/files", async (_req, res) => {
try {
res.json({ ok: true, files: await listFiles() });
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) });
}
});
// 텍스트류인데 charset 이 없으면 UTF-8 명시(한글 본문 깨짐 방지).
function withCharset(ct) {
const type = ct || "application/octet-stream";
const isText =
/^text\//i.test(type) || /(json|xml|javascript|csv|svg)/i.test(type);
return isText && !/charset=/i.test(type) ? `${type}; charset=utf-8` : type;
}
// 한글 등 비ASCII 파일명을 RFC 5987 방식으로 내려준다(ASCII fallback + filename*).
function contentDisposition(name) {
const ascii = name.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
return `inline; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;
}
app.get("/files/:id", async (req, res) => {
try {
const row = await getFile(req.params.id);
if (!row) return res.status(404).json({ ok: false, error: "not found" });
const obj = await getObjectStream(row.key);
res.setHeader("Content-Type", withCharset(obj.contentType));
res.setHeader("Content-Disposition", contentDisposition(row.name));
if (obj.contentLength != null)
res.setHeader("Content-Length", obj.contentLength);
obj.body.on("error", (e) => res.destroy(e));
obj.body.pipe(res);
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) });
}
});
// 이름 변경(U): PATCH /files/:id { name }
app.patch("/files/:id", express.json(), async (req, res) => {
try {
const name = req.body && req.body.name;
if (!name || !String(name).trim()) {
return res.status(400).json({ ok: false, error: "name 이 필요합니다" });
}
const row = await renameFile(req.params.id, String(name).trim());
if (!row) return res.status(404).json({ ok: false, error: "not found" });
res.json({ ok: true, ...row });
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) });
}
});
// 삭제(D): DELETE /files/:id — MinIO 객체 + DB row 함께 제거.
app.delete("/files/:id", async (req, res) => {
try {
const row = await deleteFile(req.params.id);
if (!row) return res.status(404).json({ ok: false, error: "not found" });
res.json({ ok: true, deleted: { id: row.id, name: row.name } });
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) });
}
});
app.get("/", (_req, res) =>
res.json({ name: "sample", endpoints: ["/healthz", "/db", "/s3", "/files"] })
);
// 부팅 시 테이블 보장(실패해도 서버는 뜨게 두고 로그만 남김 — /db 로 원인 확인).
try {
await ensureFilesTable();
} catch (e) {
console.error("ensureFilesTable FAIL:", e.message);
}
app.listen(config.port, "0.0.0.0", () => { app.listen(config.port, "0.0.0.0", () => {
console.log(`sample listening on :${config.port}`); console.log(`sample listening on :${config.port}`);
}); });