Supabase Storage 실전: 파일 업로드, 권한 제어, CDN 가속

콘솔에 표시된 오류를 바라보고 있었습니다. 사용자 프로필 이미지 업로드 기능을 출시한 지 30분 만에 모든 사용자의 프로필 이미지가 한 사람의 이미지로 바뀌었다는 제보가 들어왔습니다.
조사해 보니 Storage의 RLS Policy를 아예 설정하지 않은 것이 문제였습니다. bucket은 공개 상태였고 업로드 경로에서 사용자를 격리하지 않아 누구나 다른 사람의 파일을 덮어쓸 수 있었습니다. 권한 설정 하나를 놓친 탓에 하마터면 운영 사고로 이어질 뻔했습니다.
Supabase Storage는 사용하기 쉽지만 제대로 활용하려면 권한 제어, CDN 가속, 이미지 변환 등 주의할 부분이 적지 않습니다. 이 글에서는 제가 겪은 문제와 그 과정에서 정리한 경험을 한 번에 설명합니다.
1. 빠르게 시작하기: 표준 파일 업로드
가장 기본적인 파일 업로드부터 살펴보겠습니다.
Bucket 생성
Supabase 콘솔을 열고 왼쪽 메뉴에서 Storage를 찾은 다음 New bucket을 클릭합니다. 프로필 이미지를 저장한다면 avatars, 게시물 이미지를 저장한다면 posts처럼 bucket 이름을 지정합니다. 이때 Make this bucket public?이라는 옵션이 나타나는데 바로 선택하지 마세요. 권한을 다루는 절에서 자세히 설명하겠습니다.
저는 민감한 파일은 비공개 bucket에, 정적 리소스는 공개 bucket에 저장합니다. 기본적으로는 먼저 비공개 bucket을 만든 뒤 필요에 따라 조정하는 편입니다.
SDK 업로드 코드
@supabase/supabase-js를 이미 설치했다고 가정하면 코드는 간단합니다.
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
'https://your-project.supabase.co',
'your-anon-key'
)
// 파일 업로드
async function uploadFile(file: File) {
const filePath = `uploads/${Date.now()}-${file.name}`
const { data, error } = await supabase.storage
.from('avatars') // bucket 이름
.upload(filePath, file, {
cacheControl: '3600', // 1시간 캐시
upsert: false // 파일이 이미 있으면 덮어쓰지 않고 오류 반환
})
if (error) {
console.error('업로드 실패:', error.message)
return null
}
return data.path // 파일 경로 반환
}
솔직히 이 코드는 열 번도 넘게 작성했습니다. 핵심은 filePath 설계입니다. 뒤에서 타임스탬프 접두사를 사용하는 이유와 사용자를 격리하는 방법을 설명하겠습니다.
파일 크기 제한
공식 문서에 따르면 표준 업로드는 최대 5GB 파일을 지원합니다. 하지만 실제로 사용해 보면 6MB 미만 파일은 표준 업로드가 가장 편리하고, 6MB를 넘으면 TUS 프로토콜을 이용한 재개 가능 업로드를 권장합니다.
TUS란 무엇일까요? 간단히 말해 대용량 파일을 업로드하다 중단되더라도 이어서 올릴 수 있게 해 주는 방식입니다. 네트워크가 끊겼다가 다시 연결되면 처음부터 다시 시작할 필요가 없습니다. 동영상이나 큰 이미지 같은 파일을 올릴 때 훨씬 편리합니다. 업로드가 90% 진행된 순간 네트워크가 끊긴다고 생각해 보세요. TUS가 없으면 처음부터 다시 올려야 합니다.
TUS 업로드를 활성화하려면 추가 설정이 필요합니다. 당장 필요하지 않다면 표준 업로드만으로도 대부분의 상황에 충분합니다.
// TUS 업로드 예시(대용량 파일에 권장)
const { data, error } = await supabase.storage
.from('videos')
.upload('large-video.mp4', file, {
duplex: 'half', // 스트리밍 업로드 활성화
// TUS가 재개 가능 업로드를 자동 처리
})
2. 보안 설정: RLS Policy 자세히 알아보기
다시 제가 새벽 세 시에 겪었던 문제로 돌아가 보겠습니다. 권한을 설정하지 않아 누구나 파일을 덮어쓸 수 있었습니다.
Supabase의 Storage는 데이터베이스와 마찬가지로 내부에서 PostgreSQL을 사용합니다. 따라서 권한 제어에도 RLS(Row Level Security)를 사용합니다. bucket은 테이블에, 각 파일은 하나의 레코드에 해당한다고 볼 수 있습니다.
공개 Bucket과 비공개 Bucket
bucket을 만들 때 Public bucket 또는 Private bucket을 선택할 수 있습니다.
Public bucket: 인증 없이 누구나 읽을 수 있습니다. 공개 프로필 이미지나 웹사이트 Logo 같은 정적 리소스를 저장하기에 적합합니다.
Private bucket: 인증해야 접근할 수 있습니다. 다만 주의할 점이 있습니다. 인증은 진입 조건일 뿐이며 누가 읽고 쓸 수 있는지는 RLS Policy로 따로 정해야 합니다.
파일을 완전히 공개해야 하는 경우가 아니라면 기본적으로 Private bucket을 만드는 것을 권합니다. 권한을 설정한 뒤 공개하는 편이 먼저 공개하고 나중에 수습하는 것보다 훨씬 안전합니다.
RLS Policy의 작업 유형
Storage의 Policy 페이지에는 다음 네 가지 작업이 표시됩니다.
- SELECT: 파일 읽기(다운로드, URL 가져오기)
- INSERT: 새 파일 업로드
- UPDATE: 기존 파일 업데이트 또는 덮어쓰기
- DELETE: 파일 삭제
각 작업마다 Policy를 따로 설정할 수 있습니다. 가장 자주 사용하는 설정 방식은 다음과 같습니다.
-- 사용자는 자신의 파일만 조작할 수 있음
CREATE POLICY "Users manage own files"
ON storage.objects FOR ALL
USING (auth.uid()::text = (storage.foldername(name))[1]);
SQL이 조금 복잡해 보이므로 나누어 살펴보겠습니다.
auth.uid()는 현재 로그인한 사용자의 ID를 가져옵니다.storage.foldername(name)은 파일 경로에서 첫 번째 디렉터리 이름을 추출합니다.- 예를 들어 파일 경로가
user123/avatar.jpg이면 첫 번째 디렉터리는user123입니다.
따라서 이 Policy의 전체 논리는 파일 경로의 첫 번째 디렉터리가 사용자 ID와 같을 때만 사용자가 해당 파일을 조작할 수 있다는 것입니다. 이것이 사용자 격리의 핵심입니다.
사용자 격리 구현
구체적으로 어떻게 구현할까요? 업로드할 때 경로의 첫 번째 단계에 사용자 ID를 넣습니다.
async function uploadAvatar(userId: string, file: File) {
// 경로 설계: 사용자ID/파일명
const filePath = `${userId}/avatar-${Date.now()}.jpg`
const { data, error } = await supabase.storage
.from('avatars')
.upload(filePath, file)
return data?.path
}
그러면 각 사용자의 파일이 자신의 폴더 아래에 저장됩니다. RLS Policy는 자신의 ID로 시작하는 경로만 조작하도록 허용하므로 다른 사용자의 파일에는 손댈 수 없습니다.
서명된 접근 URL 생성
Private bucket의 파일에 직접 접근하면 404 오류가 발생합니다. 따라서 서명된 URL을 만들어야 합니다.
// 임시 접근 링크 생성(유효 기간 1시간)
const { data, error } = await supabase.storage
.from('avatars')
.createSignedUrl('user123/avatar.jpg', 3600)
console.log(data?.signedUrl) // 서명된 전체 URL
서명의 유효 기간은 직접 정할 수 있습니다. 너무 길면 안전하지 않고 너무 짧으면 사용성이 떨어지므로 일반적으로 1~4시간이 적당합니다.
bucket 설정을 바꾸지 않으면서 파일을 완전히 공개하려면 getPublicUrl을 사용할 수 있습니다.
const { data } = supabase.storage
.from('public-assets')
.getPublicUrl('logo.png')
// 이 URL은 서명이 필요 없으며 누구나 접근할 수 있음
Policy 설정에서 자주 겪는 문제
제가 겪었던 문제를 몇 가지 정리하면 다음과 같습니다.
-
INSERT Policy 누락: 사용자가 로그인은 할 수 있지만 파일을 업로드하지 못합니다. 오류 메시지는
new row violates row-level security policy입니다. -
지나치게 느슨한 Policy: 예를 들어
USING (true)를 사용하면 누구나 모든 파일을 조작할 수 있습니다. RLS를 설정하지 않은 것과 다르지 않습니다. -
부적절한 경로 설계: 사용자 ID가 경로의 첫 번째 단계에 없으면 RLS의
foldername추출이 제대로 작동하지 않습니다. 예전에 경로를uploads/user123/file.jpg로 작성한 적이 있는데, 추출되는 값은uploads여서 Policy 판단이 잘못되었습니다.
Policy를 설정할 때는 먼저 콘솔의 SQL 편집기에서 테스트해 논리가 올바른지 확인한 다음 운영 환경에 적용하세요.
3. 성능 개선: Smart CDN과 이미지 변환
파일을 업로드하고 권한도 설정했습니다. 이제 파일을 더 빠르게 불러오는 방법을 생각해 볼 차례입니다.
Smart CDN의 원리
Supabase의 Smart CDN은 일반적인 CDN과 다릅니다. 파일 접근 빈도에 따라 캐시 전략을 자동으로 결정합니다. 자주 찾는 파일은 오래 캐시하고, 접근 빈도가 낮은 파일은 짧게 캐시합니다.
공식 문서에 따르면 캐시 무효화는 전 세계에 최대 60초 안에 동기화됩니다. 도쿄에서 파일을 업데이트하면 뉴욕의 사용자도 60초 안에 최신 버전을 볼 수 있다는 뜻입니다. 몇 분에서 길게는 몇 시간이 걸리는 기존 CDN보다 훨씬 빠릅니다.
다만 Smart CDN은 유료 기능이며 월 $25인 Pro Plan이 필요합니다. Free Plan에서도 파일에는 접근할 수 있지만 CDN 가속 없이 Supabase 서버에서 직접 불러옵니다.
이미지 변환 매개변수
제가 특히 좋아하는 기능입니다. 이미지 크기 조절과 자르기를 직접 처리할 필요 없이 Supabase URL에 매개변수만 추가하면 됩니다.
기본 매개변수는 다음과 같습니다.
?width=300&height=200 // 크기 지정
?resize=contain // 비율을 유지하고 자르지 않음
?resize=cover // 지정 크기를 채우고 넘치는 부분은 자름
?quality=80 // 이미지 품질(1~100)
?format=webp // 용량이 더 작은 WebP 형식으로 변환
매개변수를 조합해 사용할 수도 있습니다.
const baseUrl = supabase.storage
.from('avatars')
.getPublicUrl('user123/avatar.jpg').data.publicUrl
// 썸네일 생성
const thumbnailUrl = `${baseUrl}?width=100&height=100&resize=cover`
이미지 변환에는 다음 제한이 있습니다.
- 크기 범위: 1~2500픽셀
- 원본 파일 크기: 25MB 이하
- 호환 형식: JPEG, PNG, WebP, GIF, AVIF
제한을 넘으면 오류가 발생합니다. 한 번은 30MB 원본 이미지를 업로드한 뒤 크기를 줄이려고 했지만 곧바로 거부되었습니다.
요금: 프로젝트별 무료 한도
이미지 변환 요금은 저장 용량이 아니라 변환 횟수에 따라 계산됩니다.
각 프로젝트는 매월 이미지 100장까지 무료로 변환할 수 있습니다. 이 한도를 넘으면 1000장당 $5가 부과됩니다.
솔직히 개인 프로젝트나 소규모 팀이라면 100장으로도 충분합니다. 제 블로그 프로젝트도 매월 프로필 이미지와 게시물 이미지를 수십 장 정도 변환할 뿐입니다. Instagram과 같은 이미지 중심 소셜 앱을 만드는 경우가 아니라면 이 비용을 크게 걱정할 필요는 없습니다.
Next.js 연동: Image Loader
Next.js를 사용한다면 Supabase Image Loader를 설정해 next/image가 이미지 변환을 자동으로 처리하도록 할 수 있습니다.
// next.config.js
module.exports = {
images: {
loader: 'custom',
loaderFile: './supabase-image-loader.js',
}
}
그런 다음 loader 파일을 작성합니다.
// supabase-image-loader.js
export default function supabaseLoader({ src, width, quality }) {
const params = new URLSearchParams()
params.set('width', width.toString())
params.set('quality', (quality || 75).toString())
params.set('format', 'webp')
return `${src}?${params.toString()}`
}
이제 Next.js에서 <Image src="..." width={300} />를 사용하면 변환 매개변수가 자동으로 추가됩니다.
Pro Plan 사용 조건
앞서 설명한 Smart CDN과 이미지 변환은 모두 Pro Plan이 필요합니다. Free Plan 사용자는 기본 업로드와 다운로드 기능만 사용할 수 있습니다.
업그레이드해야 할까요? 프로젝트 요구 사항에 따라 결정하면 됩니다. 프로필 이미지 몇 장만 저장한다면 Free Plan으로 충분합니다. 하지만 많은 이미지를 처리하거나 성능을 최적화해야 한다면 Pro Plan의 CDN 가속과 이미지 변환으로 수고를 많이 덜 수 있습니다. 직접 CDN을 구축하거나 이미지 처리 서비스를 작성할 필요가 없기 때문입니다.
제 선택은 프로젝트 출시 전에는 Free Plan으로 테스트하고 트래픽이 안정되면 Pro로 업그레이드하는 것입니다. 월 $25도 적은 금액은 아니기 때문입니다.
4. 실전 사례: 블로그 프로젝트 전체 설정
이론만 길게 설명하는 것보다 전체 사례를 보는 편이 낫습니다. 다음은 제 블로그 프로젝트에서 Storage를 처음부터 사용할 수 있는 상태까지 설정한 과정입니다.
시나리오: 사용자 프로필 이미지 + 게시물 이미지
두 개의 bucket이 필요합니다.
avatars: 사용자 프로필 이미지용 비공개 bucket. 사용자는 자신의 프로필 이미지만 조작할 수 있습니다.post-images: 게시물 이미지용 비공개 bucket. 작성자는 업로드할 수 있고 누구나 읽을 수 있습니다(서명 URL 사용).
Step 1: Bucket 생성
콘솔에서 다음과 같이 작업합니다.
- Storage > New bucket으로 이동해 이름에
avatars를 입력하고 Private을 선택합니다. - 같은 방식으로
post-images를 생성합니다.
Step 2: RLS Policy 설정
avatars bucket의 Policy는 다음과 같습니다.
-- 모든 사용자의 프로필 이미지 읽기 허용(공개 읽기)
CREATE POLICY "Anyone can view avatars"
ON storage.objects FOR SELECT
USING (bucket_id = 'avatars');
-- 사용자는 자신의 프로필 이미지만 업로드하고 업데이트할 수 있음
CREATE POLICY "Users manage own avatar"
ON storage.objects FOR INSERT
WITH CHECK (bucket_id = 'avatars' AND auth.uid()::text = (storage.foldername(name))[1]);
-- 사용자는 자신의 프로필 이미지만 삭제할 수 있음
CREATE POLICY "Users delete own avatar"
ON storage.objects FOR DELETE
USING (bucket_id = 'avatars' AND auth.uid()::text = (storage.foldername(name))[1]);
post-images bucket의 Policy는 다음과 같습니다.
-- 작성자는 게시물 이미지를 업로드할 수 있음(작성자에게 author 역할이 있다고 가정)
CREATE POLICY "Authors can upload post images"
ON storage.objects FOR INSERT
WITH CHECK (
bucket_id = 'post-images'
AND auth.jwt() ->> 'role' = 'author'
);
-- 모든 사용자가 게시물 이미지를 읽을 수 있음
CREATE POLICY "Public read post images"
ON storage.objects FOR SELECT
USING (bucket_id = 'post-images');
Step 3: 프론트엔드 업로드 코드
프로필 이미지 업로드 컴포넌트는 다음과 같습니다.
async function handleAvatarUpload(file: File) {
const user = await supabase.auth.getUser()
if (!user.data.user) return alert('먼저 로그인해 주세요')
// 경로: 사용자ID/avatar.jpg(파일명을 고정해 업로드할 때마다 이전 프로필 이미지를 덮어씀)
const filePath = `${user.data.user.id}/avatar.jpg`
const { error } = await supabase.storage
.from('avatars')
.upload(filePath, file, { upsert: true })
if (!error) {
// 공개 URL 가져오기(SELECT Policy가 모든 사용자의 읽기를 허용하기 때문)
const url = supabase.storage.from('avatars').getPublicUrl(filePath)
setUserAvatar(url.data.publicUrl)
}
}
게시물 이미지 업로드 코드는 다음과 같습니다.
async function handlePostImageUpload(file: File) {
const filePath = `posts/${Date.now()}-${file.name}`
const { data, error } = await supabase.storage
.from('post-images')
.upload(filePath, file)
if (!error) {
// 유효 기간이 24시간인 서명 URL 생성
const { data: urlData } = await supabase.storage
.from('post-images')
.createSignedUrl(filePath, 86400)
insertImageToEditor(urlData?.signedUrl)
}
}
Step 4: 테스트 및 검증
출시 전에 다음 핵심 사항을 확인합니다.
- 로그인하지 않은 사용자가 게시물 이미지를 볼 수 있는가? (SELECT Policy가 허용하므로 볼 수 있어야 함)
- 일반 사용자가 게시물 이미지를 업로드할 수 있는가? (author 역할만 가능하므로 업로드할 수 없어야 함)
- 사용자 A가 사용자 B의 프로필 이미지를 덮어쓸 수 있는가? (경로가 격리되어 있으므로 불가능해야 함)
각 항목을 테스트해 Policy 설정이 올바른지 확인합니다. 새벽 세 시에 겪은 일을 다시 경험하고 싶지는 않습니다.
마무리
Supabase Storage의 핵심은 업로드, 권한, 가속이라는 세 가지입니다.
업로드는 코드 몇 줄이면 끝날 만큼 간단합니다. 하지만 권한 설정에는 더 신경 써야 합니다. RLS Policy는 한 번 설정하고 끝나는 것이 아니라 비즈니스 상황에 맞춰 반복해서 테스트해야 합니다. CDN과 이미지 변환은 있으면 좋은 기능이고 Pro Plan에서만 사용할 수 있지만 개발 시간을 크게 아껴 줍니다.
제 경험상 먼저 기본 업로드와 권한을 완성해 보안 사고가 발생하지 않도록 해야 합니다. 성능 요구가 생기면 CDN을 추가하고 이미지 처리가 필요해지면 변환 기능을 더하면 됩니다. 한 단계씩 진행하고 한꺼번에 너무 많은 것을 하려 하지 마세요.
Supabase Storage를 사용하고 있다면 겪었던 문제를 공유해 주세요. 새벽 세 시에 이런 일을 겪은 사람이 저뿐만은 아닐 겁니다.
Supabase Storage 전체 설정 절차
Bucket 생성부터 권한 설정과 CDN 가속까지 다루는 전체 실습 과정
⏱️ Estimated time: 30 min
- 1
Step 1: Bucket 생성
Supabase 콘솔에서 비공개 Bucket을 생성합니다.
• Storage > New bucket으로 이동
• 이름 입력(예: avatars)
• Private 선택(기본값으로 권장)
• Create bucket 클릭 - 2
Step 2: RLS Policy 설정
Bucket에 Row Level Security를 설정합니다.
• Storage > Bucket 선택 > Policies로 이동
• New Policy 클릭
• 작업 유형 선택(SELECT/INSERT/UPDATE/DELETE)
• Policy 규칙 작성(예: 사용자 격리)
• 테스트 후 운영 환경에 적용 - 3
Step 3: 파일 업로드
SDK로 파일을 업로드합니다.
• 경로 구조 설계(예: userId/filename)
• storage.from().upload() 호출
• cacheControl 및 upsert 매개변수 설정
• 업로드 오류와 반환 경로 처리 - 4
Step 4: CDN 및 이미지 변환 설정(선택 사항)
Pro Plan으로 업그레이드하면 고급 기능을 사용할 수 있습니다.
• Smart CDN이 인기 파일을 자동 캐시
• 이미지 변환 URL 매개변수(width/height/format)
• Next.js Image Loader 연동
• 무료 한도(월 100장) 모니터링
FAQ
Public bucket과 Private bucket은 무엇이 다른가요?
사용자를 격리하는 RLS Policy는 어떻게 설정하나요?
• 업로드 경로를 userId/filename으로 설계
• Policy에서 auth.uid()::text = (storage.foldername(name))[1] 사용
• 그러면 사용자는 자신의 ID로 시작하는 경로만 조작할 수 있음
Private bucket의 파일을 외부에 공유하려면 어떻게 하나요?
파일 업로드에는 어떤 제한이 있나요?
이미지 변환은 어떤 매개변수를 지원하며 제한은 무엇인가요?
• width/height: 크기(1~2500픽셀)
• resize: contain(비율 유지) 또는 cover(잘라서 채우기)
• quality: 품질(1~100)
• format: webp/jpeg/png/gif/avif
제한: 원본 파일은 25MB 이하여야 합니다.
Smart CDN과 이미지 변환은 유료인가요?
3분 읽기 · 게시일: 2026년 4월 9일 · 수정일: 2026년 9월 4일
Supabase 실전
검색으로 들어왔다면 같은 시리즈의 이전 글이나 다음 글로 이동하는 것이 가장 빠릅니다.
이전
Supabase Auth 실전 가이드: 이메일 인증, OAuth, 세션 관리
Supabase Auth 실전 가이드입니다. 이메일 인증 설정, OAuth 연동, JWT 세션 관리와 PKCE 흐름까지 한 번에 사용자 인증을 완성하는 방법을 알아봅니다.
8편 중 3편
다음
Supabase Realtime 실전: 세 가지 모드 비교와 협업 앱 개발
Supabase Realtime의 Postgres Changes, Presence, Broadcast 세 가지 실시간 모드를 비교하고, 완전한 협업 앱 코드 예제와 RLS 보안 설정을 설명합니다.
8편 중 5편



댓글
GitHub로 로그인하여 댓글을 남기세요