OPEN HYPER STEP
← 목록으로 (stack-analysis)
STACK-ANALYSIS · 67 / 90
stack-analysis
CHAPTER 67 / 90
읽기 약 2
FUNCTION

파일 관리: 업로드/미리보기/버전


핵심 개념

S3 업로드·이미지/PDF 미리보기·버전 관리·복원 — 파일 시스템 SaaS.

본문

데이터 모델

SQL📋 코드 (28줄)
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)
);

업로드 + 중복 검출

TYPESCRIPT📋 코드 (40줄)
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;
}

새 버전 업로드

TYPESCRIPT📋 코드 (33줄)
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 },
  });
}

이미지 미리보기 + 썸네일

TYPESCRIPT📋 코드 (26줄)
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 미리보기

TSX📋 코드 (22줄)
'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>
  );
}

휴지통 + 자동 삭제

TYPESCRIPT📋 코드 (35줄)
// 소프트 삭제
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