stack-analysis
CHAPTER 67 / 90
읽기 약 2분
FUNCTION
파일 관리: 업로드/미리보기/버전
핵심 개념
S3 업로드·이미지/PDF 미리보기·버전 관리·복원 — 파일 시스템 SaaS.
본문
데이터 모델
CREATE TABLE files (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
parent_folder_id UUID REFERENCES files(id), -- nested folders
type VARCHAR NOT NULL, -- 'file', 'folder'
name VARCHAR NOT NULL,
mime_type VARCHAR,
size_bytes BIGINT,
s3_key VARCHAR,
hash VARCHAR, -- 중복 검출
current_version_id UUID,
deleted_at TIMESTAMP, -- 휴지통
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE file_versions (
id UUID PRIMARY KEY,
file_id UUID NOT NULL REFERENCES files(id),
version_number INT NOT NULL,
s3_key VARCHAR NOT NULL,
size_bytes BIGINT,
hash VARCHAR,
uploaded_by UUID NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(file_id, version_number)
);업로드 + 중복 검출
async function uploadFile(tenantId: string, userId: string, file: File, parentId?: string) {
// 1. 파일 hash (SHA-256)
const buffer = await file.arrayBuffer();
const hash = await crypto.subtle.digest('SHA-256', buffer)
.then(b => Array.from(new Uint8Array(b)).map(b => b.toString(16).padStart(2, '0')).join(''));
// 2. 같은 hash 파일 있는가? (중복 제거)
const existing = await db.fileVersion.findFirst({ where: { hash } });
let s3Key: string;
if (existing) {
// 기존 S3 객체 재사용
s3Key = existing.s3Key;
} else {
// 새 업로드
s3Key = `tenants/${tenantId}/${crypto.randomUUID()}-${file.name}`;
await s3.send(new PutObjectCommand({
Bucket: BUCKET, Key: s3Key, Body: Buffer.from(buffer),
ContentType: file.type,
}));
}
// 3. 파일 + 버전 생성
const newFile = await db.file.create({
data: {
tenantId, userId, parentFolderId: parentId,
type: 'file', name: file.name,
mimeType: file.type, sizeBytes: file.size,
s3Key, hash,
versions: {
create: {
versionNumber: 1, s3Key, sizeBytes: file.size, hash,
uploadedBy: userId,
},
},
},
});
return newFile;
}새 버전 업로드
async function uploadNewVersion(fileId: string, file: File, userId: string) {
const existing = await db.file.findUnique({
where: { id: fileId },
include: { versions: { orderBy: { versionNumber: 'desc' }, take: 1 } },
});
const nextVersion = existing!.versions[0].versionNumber + 1;
const buffer = await file.arrayBuffer();
const hash = await sha256(buffer);
// 같은 hash면 버전 추가만 (실제 업로드 X)
let s3Key = existing!.versions[0].s3Key;
if (hash !== existing!.versions[0].hash) {
s3Key = `${existing!.s3Key.split('-').slice(0, -1).join('-')}-v${nextVersion}-${file.name}`;
await s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: s3Key, Body: Buffer.from(buffer) }));
}
return db.fileVersion.create({
data: {
fileId, versionNumber: nextVersion, s3Key,
sizeBytes: file.size, hash, uploadedBy: userId,
},
});
}
async function restoreVersion(fileId: string, versionId: string) {
const version = await db.fileVersion.findUnique({ where: { id: versionId } });
return db.file.update({
where: { id: fileId },
data: { currentVersionId: version!.id, s3Key: version!.s3Key },
});
}이미지 미리보기 + 썸네일
import sharp from 'sharp';
const imageWorker = new Worker('image-thumb', async (job) => {
const { fileId } = job.data;
const file = await db.file.findUnique({ where: { id: fileId } });
if (!file?.mimeType?.startsWith('image/')) return;
const original = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: file.s3Key }));
const buffer = Buffer.from(await original.Body!.transformToByteArray());
// 200x200 썸네일
const thumb = await sharp(buffer)
.resize(200, 200, { fit: 'cover' })
.webp({ quality: 80 })
.toBuffer();
const thumbKey = file.s3Key + '_thumb_200.webp';
await s3.send(new PutObjectCommand({
Bucket: BUCKET, Key: thumbKey, Body: thumb, ContentType: 'image/webp',
}));
await db.file.update({
where: { id: fileId },
data: { thumbnailKey: thumbKey },
});
});PDF 미리보기
'use client';
import { Document, Page, pdfjs } from 'react-pdf';
pdfjs.GlobalWorkerOptions.workerSrc = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.js`;
function PDFPreview({ url }: { url: string }) {
const [numPages, setNumPages] = useState<number>();
const [pageNumber, setPageNumber] = useState(1);
return (
<div>
<Document file={url} onLoadSuccess={({ numPages }) => setNumPages(numPages)}>
<Page pageNumber={pageNumber} />
</Document>
<div>
<button onClick={() => setPageNumber(p => Math.max(p - 1, 1))}>이전</button>
<span>{pageNumber} / {numPages}</span>
<button onClick={() => setPageNumber(p => Math.min(p + 1, numPages!))}>다음</button>
</div>
</div>
);
}휴지통 + 자동 삭제
// 소프트 삭제
async function trashFile(fileId: string) {
return db.file.update({
where: { id: fileId },
data: { deletedAt: new Date() },
});
}
async function restoreFile(fileId: string) {
return db.file.update({
where: { id: fileId },
data: { deletedAt: null },
});
}
// Cron — 30일 후 영구 삭제
const cleanupWorker = new Worker('cleanup', async () => {
const expired = await db.file.findMany({
where: { deletedAt: { lt: new Date(Date.now() - 30 * 86400 * 1000) } },
});
for (const file of expired) {
// 다른 파일이 같은 hash 사용 중인가?
const others = await db.file.count({
where: { hash: file.hash, id: { not: file.id }, deletedAt: null },
});
if (others === 0) {
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: file.s3Key }));
}
await db.file.delete({ where: { id: file.id } });
}
});
scheduler.upsertJobScheduler('daily-cleanup', { pattern: '0 3 * * *' }, { name: 'cleanup' });다음 챕터
CH.68 "감사 로그: 누가 뭘 했는지 추적".
AI 프롬프트
🤖 AI에게 잘 물어보는 법 — 모델·전략별 프롬프트
Claude
무료: Sonnet 4.6 / Pro $20/mo: Opus 4.6
내 프로젝트의 파일 관리 부분을 분석해서 실전 분석 + 개선 우선순위를 알려줘.
ChatGPT
무료: GPT-5.5 / Plus $20/mo: GPT-5.5 Pro
파일 관리 관련 실제 서비스 5개를 비교 분석해서 패턴 추출를 알려줘.
Gemini
무료: 2.5 Flash / Pro $19.99/mo: 3.1 Pro
내 코드베이스에서 파일 관리 최적화 가능 위치를 보고해줘.
Grok
무료: Grok 4.1 / SuperGrok $30/mo
2026년 한국 풀스택 시장의 파일 관리 트렌드를 솔직히 알려줘.
⭐ 이것만 기억하세요
파일 관리: 업로드/미리보기/버전은 이 3가지만 확실히 잡으세요
1.hash 기반 중복 제거 = S3 비용 절감 — 같은 파일 1번만 저장
2.버전 관리는 file_versions 테이블 + 복원 API
3.휴지통 = soft delete + 30일 후 자동 영구 삭제
공유하기
진행도 67 / 90