시리즈 목차 1편 – Supabase란 무엇인가? Firebase와 제대로 비교해보기 2편 – 데이터베이스 & 자동 API (REST/GraphQL) 3편 – 인증(Auth) — 소셜 로그인, JWT, MFA, SSO 4편 – RLS 보안 — Row Level Security 완벽 가이드 5편 – 실시간 기능(Realtime) — Broadcast, Presence, Postgres Changes 6편 👉 스토리지(Storage) — 파일 업로드, 이미지 최적화, CDN (현재 글) 7편 – Edge Functions — 서버리스 함수 활용법 …
들어가며
파일 업로드는 거의 모든 서비스에서 필요한 기능입니다. 프로필 사진, 게시글 첨부 파일, 상품 이미지 — 이런 파일들을 어디에, 어떻게 저장하고 서빙하느냐는 서비스 성능과 보안 모두에 영향을 미칩니다.
Supabase Storage는 S3 호환 오브젝트 스토리지로, 다음 특징을 갖고 있습니다.
- PostgreSQL의 RLS와 통합된 세밀한 접근 제어
- 이미지 리사이징, WebP 변환 등 온디맨드 이미지 변환
- 전 세계 빠른 서빙을 위한 Smart CDN 내장
- 공개/비공개 버킷 구분
- 대용량 파일 Resumable Upload 지원
이번 편에서 다룰 내용:
- 버킷(Bucket) 개념과 생성
- 파일 업로드 (단순 업로드 / Resumable Upload)
- 파일 조회 (공개 URL / Signed URL)
- 파일 삭제 및 이동
- 이미지 변환 (리사이징, WebP, 크롭)
- Next.js Image 컴포넌트와 Supabase 커스텀 로더 연동
- Smart CDN 캐싱 전략
- Storage RLS 설정
- 실전 패턴 (프로필 사진, 게시글 첨부 파일, 드래그 앤 드롭)
스토리지 구조
버킷(Bucket)
Storage의 최상위 단위는 버킷입니다. 버킷은 파일을 담는 컨테이너로, 공개(Public)와 비공개(Private) 두 종류가 있습니다.
| 종류 | 특징 | 활용 사례 |
|---|---|---|
| Public | URL만 알면 누구나 접근 가능 | 상품 이미지, 공개 에셋 |
| Private | RLS 정책 또는 Signed URL로만 접근 | 개인 파일, 첨부 문서 |
버킷은 대시보드 → Storage → Create bucket에서 생성하거나 SQL로 만들 수 있습니다.
-- SQL로 버킷 생성
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'avatars', -- 버킷 ID
'avatars', -- 버킷 이름
true, -- 공개 여부
5242880, -- 파일 크기 제한: 5MB (bytes)
ARRAY['image/jpeg', 'image/png', 'image/webp', 'image/gif'] -- 허용 MIME 타입
);
INSERT INTO storage.buckets (id, name, public, file_size_limit)
VALUES (
'documents',
'documents',
false, -- 비공개
52428800 -- 50MB
);
파일 경로 설계
버킷 내 파일 경로는 폴더/파일명 형태로 자유롭게 구성합니다. 사용자별 파일 분리를 위한 권장 패턴:
avatars/
{user_id}/avatar.jpg → 프로필 사진 (덮어쓰기)
{user_id}/avatar_v2.jpg → 버전 관리
posts/
{user_id}/{post_id}/thumb.jpg → 게시글 썸네일
{user_id}/{post_id}/image1.jpg
documents/
{user_id}/{timestamp}-{random}.pdf → 고유 파일명
파일 업로드
기본 업로드 (Client Component)
// components/FileUpload.tsx
'use client'
import { useState, useRef } from 'react'
import { createClient } from '@/lib/supabase/client'
interface Props {
bucket: string
path: string
onUpload: (url: string) => void
accept?: string
maxSizeMB?: number
}
export default function FileUpload({
bucket,
path,
onUpload,
accept = 'image/*',
maxSizeMB = 5,
}: Props) {
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const supabase = createClient()
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// 파일 크기 검증
if (file.size > maxSizeMB * 1024 * 1024) {
setError(`파일 크기는 ${maxSizeMB}MB 이하여야 합니다.`)
return
}
setUploading(true)
setError(null)
try {
// 고유 파일명 생성 (충돌 방지)
const ext = file.name.split('.').pop()
const fileName = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`
const filePath = `${path}/${fileName}`
const { error: uploadError } = await supabase.storage
.from(bucket)
.upload(filePath, file, {
cacheControl: '3600', // 브라우저 캐시 1시간
upsert: false, // 중복 파일 덮어쓰기 방지
})
if (uploadError) throw uploadError
// 공개 URL 생성
const { data: { publicUrl } } = supabase.storage
.from(bucket)
.getPublicUrl(filePath)
onUpload(publicUrl)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : '업로드 실패')
} finally {
setUploading(false)
if (inputRef.current) inputRef.current.value = ''
}
}
return (
<div>
<input
ref={inputRef}
type="file"
accept={accept}
onChange={handleUpload}
disabled={uploading}
className="hidden"
id="file-upload"
/>
<label
htmlFor="file-upload"
className={`cursor-pointer inline-flex items-center gap-2 px-4 py-2 border rounded-lg
${uploading ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50'}`}
>
{uploading ? '업로드 중...' : '파일 선택'}
</label>
{error && <p className="text-red-500 text-sm mt-1">{error}</p>}
</div>
)
}
프로필 사진 업로드 (덮어쓰기 패턴)
프로필 사진은 항상 같은 경로에 덮어쓰는 것이 일반적입니다.
// app/settings/profile/actions.ts
'use server'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
export async function uploadAvatar(formData: FormData) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('로그인이 필요합니다.')
const file = formData.get('avatar') as File
if (!file || file.size === 0) throw new Error('파일을 선택해주세요.')
// 허용 타입 검증
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']
if (!allowedTypes.includes(file.type)) {
throw new Error('JPG, PNG, WebP 형식만 허용됩니다.')
}
// 사용자 ID 기반 고정 경로 (덮어쓰기)
const ext = file.type.split('/')[1]
const filePath = `${user.id}/avatar.${ext}`
const { error } = await supabase.storage
.from('avatars')
.upload(filePath, file, {
upsert: true, // 덮어쓰기 허용
cacheControl: '3600',
})
if (error) throw new Error(error.message)
// profiles 테이블에 URL 저장
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(filePath)
await supabase
.from('profiles')
.update({ avatar_url: publicUrl })
.eq('id', user.id)
revalidatePath('/settings/profile')
return { url: publicUrl }
}
대용량 파일 — Resumable Upload
5MB 이상의 대용량 파일은 Resumable Upload를 사용합니다. 네트워크가 끊겨도 이어서 업로드할 수 있습니다.
// 대용량 파일 Resumable Upload
import { createClient } from '@/lib/supabase/client'
async function uploadLargeFile(file: File, filePath: string) {
const supabase = createClient()
const { data, error } = await supabase.storage
.from('documents')
.uploadToSignedUrl(filePath, '', file, {
// 업로드 진행률 추적
onUploadProgress: (progress) => {
const percent = (progress.loaded / progress.total) * 100
console.log(`업로드 진행률: ${percent.toFixed(1)}%`)
},
})
return { data, error }
}
드래그 앤 드롭 업로드
// components/DropZone.tsx
'use client'
import { useState, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
interface Props {
bucket: string
userId: string
onUpload: (urls: string[]) => void
}
export default function DropZone({ bucket, userId, onUpload }: Props) {
const [isDragging, setIsDragging] = useState(false)
const [uploading, setUploading] = useState(false)
const supabase = createClient()
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const files = Array.from(e.dataTransfer.files).filter((f) =>
f.type.startsWith('image/')
)
if (!files.length) return
setUploading(true)
const uploadedUrls = await Promise.all(
files.map(async (file) => {
const ext = file.name.split('.').pop()
const filePath = `${userId}/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`
await supabase.storage.from(bucket).upload(filePath, file)
const { data: { publicUrl } } = supabase.storage
.from(bucket)
.getPublicUrl(filePath)
return publicUrl
})
)
onUpload(uploadedUrls)
setUploading(false)
},
[bucket, userId, onUpload]
) return ( <div onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }} onDragLeave={() => setIsDragging(false)} onDrop={handleDrop} className={`border-2 border-dashed rounded-xl p-12 text-center transition-colors ${isDragging ? ‘border-blue-500 bg-blue-50’ : ‘border-gray-300’} ${uploading ? ‘opacity-50 pointer-events-none’ : ”}`} > {uploading ? ( <p className=”text-gray-500″>업로드 중…</p> ) : ( <p className=”text-gray-500″> 이미지를 드래그하거나 <span className=”text-blue-500 underline cursor-pointer”>클릭</span>하여 업로드 </p> )} </div> ) }
파일 조회
공개 URL (Public Bucket)
// 공개 버킷의 파일 URL 생성
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl('user-id/avatar.jpg')
// publicUrl: https://[project].supabase.co/storage/v1/object/public/avatars/user-id/avatar.jpg
Signed URL (Private Bucket)
비공개 파일은 서명된 임시 URL을 생성해서 제공합니다.
// Server Action / API Route에서 Signed URL 생성
export async function getSignedUrl(filePath: string) {
const supabase = await createClient()
const { data, error } = await supabase.storage
.from('documents')
.createSignedUrl(
filePath,
60 * 60, // 유효 시간: 1시간 (초 단위)
)
if (error) throw error
return data.signedUrl
}
// 여러 파일의 Signed URL 한 번에 생성
export async function getMultipleSignedUrls(paths: string[]) {
const supabase = await createClient()
const { data, error } = await supabase.storage
.from('documents')
.createSignedUrls(paths, 3600)
if (error) throw error
return data
}
파일 목록 조회
// 특정 폴더의 파일 목록
const { data: files, error } = await supabase.storage
.from('documents')
.list(`${userId}/`, {
limit: 20,
offset: 0,
sortBy: { column: 'created_at', order: 'desc' },
})
파일 삭제 및 이동
// 단일 파일 삭제
const { error } = await supabase.storage
.from('avatars')
.remove([`${userId}/avatar.jpg`])
// 여러 파일 한 번에 삭제
const { error } = await supabase.storage
.from('documents')
.remove([
`${userId}/file1.pdf`,
`${userId}/file2.pdf`,
])
// 파일 이동 (복사 후 삭제)
const { error } = await supabase.storage
.from('documents')
.move(
`${userId}/old-path/file.pdf`, // 원본 경로
`${userId}/new-path/file.pdf` // 대상 경로
)
// 파일 복사
const { error } = await supabase.storage
.from('documents')
.copy(
`${userId}/original.pdf`,
`${userId}/copy.pdf`
)
이미지 변환 (Image Transformations)
Supabase Storage는 이미지를 온디맨드로 변환해 서빙합니다. Pro 플랜 이상에서 사용 가능합니다.
변환 옵션
| 파라미터 | 설명 | 값 |
|---|---|---|
width | 너비 (px) | 정수 |
height | 높이 (px) | 정수 |
quality | 품질 | 20 ~ 100 (기본값: 80) |
resize | 리사이즈 모드 | cover, contain, fill |
format | 출력 포맷 | origin, avif (자동 WebP 변환) |
getPublicUrl로 변환된 URL 생성
// lib/storage.ts
import { createClient } from '@/lib/supabase/client'
const supabase = createClient()
// 썸네일 URL 생성 (200x200, WebP)
export function getThumbnailUrl(bucket: string, path: string) {
const { data: { publicUrl } } = supabase.storage
.from(bucket)
.getPublicUrl(path, {
transform: {
width: 200,
height: 200,
resize: 'cover',
quality: 80,
},
})
return publicUrl
}
// 다양한 크기별 URL 생성
export function getResponsiveUrls(bucket: string, path: string) {
const sizes = [
{ name: 'thumbnail', width: 150, height: 150 },
{ name: 'small', width: 400, height: 300 },
{ name: 'medium', width: 800, height: 600 },
{ name: 'large', width: 1200, height: 900 },
]
return sizes.reduce((acc, size) => {
const { data: { publicUrl } } = supabase.storage
.from(bucket)
.getPublicUrl(path, {
transform: {
width: size.width,
height: size.height,
resize: 'cover',
quality: 80,
},
})
acc[size.name] = publicUrl
return acc
}, {} as Record<string, string>)
}
Signed URL로 변환된 비공개 이미지 서빙
// 비공개 이미지도 변환 가능
const { data } = await supabase.storage
.from('private-images')
.createSignedUrl('user-id/photo.jpg', 3600, {
transform: {
width: 400,
height: 400,
resize: 'cover',
},
})
Next.js Image 컴포넌트 연동
커스텀 로더 설정
Supabase Storage의 이미지 변환 기능을 Next.js <Image> 컴포넌트와 연동하면 반응형 이미지 최적화가 자동으로 됩니다.
// supabase-image-loader.js (프로젝트 루트)
const projectId = process.env.NEXT_PUBLIC_SUPABASE_PROJECT_ID
export default function supabaseLoader({ src, width, quality }) {
return `https://${projectId}.supabase.co/storage/v1/render/image/public/${src}?width=${width}&quality=${quality ?? 75}`
}
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
loader: 'custom',
loaderFile: './supabase-image-loader.js',
},
}
module.exports = nextConfig
// 사용 예시 — Next.js Image 컴포넌트
import Image from 'next/image'
interface Props {
avatarPath: string // 예: 'avatars/user-id/avatar.jpg'
name: string
}
export function Avatar({ avatarPath, name }: Props) {
return (
<Image
src={avatarPath} // 버킷명/경로 형태
alt={name}
width={64}
height={64}
className="rounded-full object-cover"
// Next.js가 srcset으로 자동 크기별 URL 요청 →
// Supabase가 해당 크기로 자동 리사이징
/>
)
}
로더 없이 직접 변환 URL 사용
// 커스텀 로더 없이 변환 URL 직접 사용할 때
import Image from 'next/image'
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL
function PostImage({ bucket, path }: { bucket: string; path: string }) {
const imageUrl = `${SUPABASE_URL}/storage/v1/render/image/public/${bucket}/${path}`
return (
<Image
src={imageUrl}
alt="게시글 이미지"
width={800}
height={600}
sizes="(max-width: 768px) 100vw, 800px"
/>
)
}
Smart CDN 캐싱 전략
Supabase Storage는 Smart CDN을 통해 파일을 전 세계 엣지 서버에 캐싱합니다.
업로드 시 캐시 제어
// 업로드 시 Cache-Control 헤더 설정
await supabase.storage.from('avatars').upload(filePath, file, {
cacheControl: '31536000', // 1년 캐싱 (자주 바뀌지 않는 에셋)
upsert: true,
})
// 자주 갱신되는 파일
await supabase.storage.from('documents').upload(filePath, file, {
cacheControl: '3600', // 1시간
})
캐시 무효화 패턴
파일을 업데이트할 때 CDN 캐시는 최대 60초 후 자동 무효화됩니다. 즉각적인 갱신이 필요하다면 파일 경로에 버전을 포함하는 패턴을 사용합니다.
// ❌ 같은 경로에 덮어쓰면 CDN 캐시가 60초간 유지될 수 있음
await supabase.storage.from('images').upload('user/profile.jpg', file, { upsert: true })
// ✅ 버전을 경로에 포함해 즉각적인 갱신
const version = Date.now()
const filePath = `user/profile_v${version}.jpg`
await supabase.storage.from('images').upload(filePath, file)
// DB에 새 경로 저장
await supabase.from('profiles').update({ avatar_path: filePath }).eq('id', userId)
// 이전 파일 삭제
await supabase.storage.from('images').remove([oldPath])
Storage RLS 설정
Storage의 보안은 storage.objects 테이블에 RLS 정책을 적용해 제어합니다. (4편 RLS 가이드 참고)
-- 1. 공개 버킷(avatars): 누구나 읽기, 본인만 쓰기
CREATE POLICY "아바타 공개 읽기"
ON storage.objects FOR SELECT
TO public
USING (bucket_id = 'avatars');
CREATE POLICY "본인 아바타 업로드"
ON storage.objects FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = (select auth.uid())::TEXT
);
CREATE POLICY "본인 아바타 수정"
ON storage.objects FOR UPDATE
TO authenticated
USING (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = (select auth.uid())::TEXT
);
CREATE POLICY "본인 아바타 삭제"
ON storage.objects FOR DELETE
TO authenticated
USING (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = (select auth.uid())::TEXT
);
-- 2. 비공개 버킷(documents): 본인 파일만 접근
CREATE POLICY "본인 문서 읽기"
ON storage.objects FOR SELECT
TO authenticated
USING (
bucket_id = 'documents'
AND (storage.foldername(name))[1] = (select auth.uid())::TEXT
);
CREATE POLICY "본인 문서 업로드"
ON storage.objects FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'documents'
AND (storage.foldername(name))[1] = (select auth.uid())::TEXT
);
CREATE POLICY "본인 문서 삭제"
ON storage.objects FOR DELETE
TO authenticated
USING (
bucket_id = 'documents'
AND (storage.foldername(name))[1] = (select auth.uid())::TEXT
);
storage.foldername(name)[1]은 파일 경로의 첫 번째 폴더명을 반환합니다. 경로를 {user_id}/파일명 구조로 설계하면 이 패턴이 깔끔하게 동작합니다.
실전: 게시글 이미지 업로드 완성 예시
// app/posts/new/ImageUploader.tsx
'use client'
import { useState } from 'react'
import Image from 'next/image'
import { createClient } from '@/lib/supabase/client'
interface Props {
userId: string
postId: string
onImagesChange: (paths: string[]) => void
}
export default function ImageUploader({ userId, postId, onImagesChange }: Props) {
const [images, setImages] = useState<{ path: string; url: string }[]>([])
const [uploading, setUploading] = useState(false)
const supabase = createClient()
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? [])
if (!files.length) return
// 최대 5장 제한
if (images.length + files.length > 5) {
alert('이미지는 최대 5장까지 업로드할 수 있습니다.')
return
}
setUploading(true)
const uploaded = await Promise.all(
files.map(async (file) => {
const ext = file.name.split('.').pop()
const path = `${userId}/${postId}/${Date.now()}.${ext}`
await supabase.storage.from('posts').upload(path, file, {
cacheControl: '86400', // 1일
})
const { data: { publicUrl } } = supabase.storage
.from('posts')
.getPublicUrl(path, {
transform: { width: 800, quality: 85 },
})
return { path, url: publicUrl }
})
)
const newImages = [...images, ...uploaded]
setImages(newImages)
onImagesChange(newImages.map((img) => img.path))
setUploading(false)
}
const removeImage = async (index: number) => {
const { path } = images[index]
await supabase.storage.from('posts').remove([path])
const newImages = images.filter((_, i) => i !== index)
setImages(newImages)
onImagesChange(newImages.map((img) => img.path))
}
return (
<div className="space-y-4">
{/* 이미지 미리보기 */}
<div className="grid grid-cols-3 gap-2">
{images.map((img, index) => (
<div key={img.path} className="relative aspect-square">
<Image
src={img.url}
alt={`업로드 이미지 ${index + 1}`}
fill
className="object-cover rounded-lg"
/>
<button
onClick={() => removeImage(index)}
className="absolute top-1 right-1 bg-black/50 text-white rounded-full w-6 h-6 text-sm"
>
×
</button>
</div>
))}
{/* 업로드 버튼 */}
{images.length < 5 && (
<label className="aspect-square border-2 border-dashed rounded-lg flex items-center justify-center cursor-pointer hover:bg-gray-50">
<input
type="file"
accept="image/*"
multiple
onChange={handleFileChange}
disabled={uploading}
className="hidden"
/>
<span className="text-3xl text-gray-400">
{uploading ? '⏳' : '+'}
</span>
</label>
)}
</div>
<p className="text-xs text-gray-500">
{images.length}/5장 · JPG, PNG, WebP · 최대 5MB
</p>
</div>
)
}
자주 하는 실수
1. 공개 버킷에 민감한 파일 저장
❌ 공개 버킷에 개인정보, 계약서, 내부 문서 저장
✅ 민감한 파일은 반드시 비공개 버킷 + Signed URL 사용
2. 파일명에 한글/특수문자 사용
// ❌ 원본 파일명 그대로 사용 → 한글, 공백, 특수문자 문제
await supabase.storage.from('bucket').upload(file.name, file)
// ✅ 안전한 파일명 생성
const safeFileName = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`
3. RLS 없이 버킷 운영
버킷을 Public으로 설정해도 RLS로 업로드/삭제 권한은 반드시 제어해야 합니다. RLS 없이는 누구든 파일을 업로드하거나 삭제할 수 있습니다.
4. 파일 삭제 없이 DB 레코드만 삭제
// ❌ DB에서만 삭제 → Storage에 고아 파일 남음
await supabase.from('posts').delete().eq('id', postId)
// ✅ Storage 파일도 함께 삭제
const { data: post } = await supabase.from('posts').select('image_paths').eq('id', postId).single()
if (post?.image_paths?.length) {
await supabase.storage.from('posts').remove(post.image_paths)
}
await supabase.from('posts').delete().eq('id', postId)
마치며
Supabase Storage는 단순한 파일 저장소를 넘어, 이미지 변환, Smart CDN, RLS 기반 접근 제어까지 갖춘 프로덕션급 스토리지 솔루션입니다. 특히 Next.js <Image> 컴포넌트와 커스텀 로더를 결합하면, 별도의 이미지 최적화 서비스 없이도 반응형 이미지를 손쉽게 제공할 수 있습니다.
다음 편에서는 Edge Functions 를 다룹니다. Deno 기반의 서버리스 함수로 커스텀 API, 웹훅, 이메일 발송, 결제 처리 등 백엔드 로직을 Next.js App Router와 함께 어떻게 구현하는지 살펴봅니다.