master-project
CHAPTER 19 / 50
읽기 약 2분
FUNCTION
파일 업로드 UI: 드래그앤드롭 + 미리보기
핵심 개념
Supabase Storage + 드래그앤드롭 + 진행률 + 미리보기 + 보안 — 이미지·문서 업로드 표준.
본문
Supabase Storage 버킷 설정
-- supabase/migrations/20260429000003_storage.sql
insert into storage.buckets (id, name, public)
values ('uploads', 'uploads', false);
-- RLS: 본인 폴더만 접근
create policy "users upload own"
on storage.objects for insert
with check (bucket_id = 'uploads' and (storage.foldername(name))[1] = auth.uid()::text);
create policy "users read own"
on storage.objects for select
using (bucket_id = 'uploads' and (storage.foldername(name))[1] = auth.uid()::text);드래그앤드롭 컴포넌트
// src/components/upload-zone.tsx
'use client'
import { useState, useCallback } from 'react'
import { Upload, X } from 'lucide-react'
import { createClient } from '@/lib/supabase/client'
export function UploadZone({ onUpload }: { onUpload: (path: string) => void }) {
const [dragOver, setDragOver] = useState(false)
const [progress, setProgress] = useState<number | null>(null)
const [preview, setPreview] = useState<string | null>(null)
const supabase = createClient()
const handleFile = useCallback(async (file: File) => {
if (file.size > 10 * 1024 * 1024) return alert('10MB 이하만 가능')
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) return alert('이미지만 가능')
setPreview(URL.createObjectURL(file))
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
const path = `${user.id}/${Date.now()}-${file.name}`
setProgress(0)
const { error } = await supabase.storage.from('uploads').upload(path, file, {
cacheControl: '3600',
upsert: false,
})
setProgress(100)
if (error) return alert(error.message)
onUpload(path)
}, [supabase, onUpload])
return (
<div
onDragOver={e => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={e => {
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer.files[0]
if (file) handleFile(file)
}}
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
dragOver ? 'border-primary bg-primary/5' : 'border-border'
}`}
>
{preview ? (
<div className="relative inline-block">
<img src={preview} alt="" className="max-h-40 rounded" />
<button onClick={() => setPreview(null)} className="absolute -top-2 -right-2 bg-red-500 rounded-full p-1">
<X className="h-3 w-3 text-white" />
</button>
</div>
) : (
<>
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-sm">파일을 드래그하거나 클릭하세요</p>
<input
type="file"
accept="image/*"
onChange={e => e.target.files?.[0] && handleFile(e.target.files[0])}
className="hidden"
id="upload-input"
/>
<label htmlFor="upload-input" className="text-primary text-sm cursor-pointer underline mt-2 inline-block">
파일 선택
</label>
</>
)}
{progress !== null && (
<div className="mt-3 h-1 bg-muted rounded">
<div className="h-full bg-primary rounded transition-all" style={{ width: `${progress}%` }} />
</div>
)}
</div>
)
}Signed URL (Private 파일)
// 1시간 유효 URL
const { data } = await supabase.storage
.from('uploads')
.createSignedUrl(path, 3600)
// data.signedUrl → <img src={...} />보안 체크리스트
✓ 파일 크기 제한 (10MB 이하)
✓ MIME 타입 화이트리스트
✓ 확장자 검증 (.exe 차단)
✓ 사용자별 폴더 (RLS)
✓ Public bucket 사용 금지 (필요한 것만)
✓ Signed URL TTL 짧게 (1시간)
✓ 바이러스 스캔 (큰 서비스 ClamAV)
✓ Rate limit (1분당 10개)이미지 최적화 (Next.js Image)
import Image from 'next/image'
<Image
src={publicUrl}
alt=""
width={400}
height={300}
className="rounded"
loading="lazy"
/>다음 챕터
CH.20 "실시간 알림: 토스트 + 벨 아이콘".
AI 프롬프트
🤖 AI에게 잘 물어보는 법 — 모델·전략별 프롬프트
Claude
무료: Sonnet 4.6 / Pro $20/mo: Opus 4.6
내 마스터 프로젝트의 파일 업로드 부분을 분석해서 실전 적용 + 개선 우선순위 3가지를 알려줘.
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년 한국 1인 개발자 시장의 파일 업로드 트렌드와 차별화 포인트를 정리해줘.
⭐ 이것만 기억하세요
파일 업로드 UI: 드래그앤드롭 + 미리보기는 이 3가지만 확실히 잡으세요
1.Supabase Storage = S3 호환 + RLS 통합
2.드래그앤드롭 + 진행률 + 미리보기 = UX 핵심
3.다음 챕터에서 실시간 알림
공유하기
진행도 19 / 50