콘텐츠로 이동
Study NoteSupabase

8. Storage

경로 설계가 곧 권한 설계다

Storage API가 메타데이터는 Postgres에, 파일은 오브젝트 스토리지에 두고 CDN으로 내보내며 RLS로 접근을 판정한다

파일 바이트는 오브젝트 스토리지에, 메타데이터는 Postgres에 있다. 그래서 파일 권한도 RLS 정책으로 쓴다 — 새 권한 시스템을 배울 필요가 없다. CDN이 앞단에 붙어 전 세계에서 빠르게 서빙된다.

-- 공개 버킷: URL만 알면 누구나 접근
insert into storage.buckets (id, name, public)
values ('avatars', 'avatars', true);
-- 비공개 버킷: 서명된 URL 또는 인증된 요청만
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values ('documents', 'documents', false, 10485760, array['application/pdf','image/png']);
public 버킷private 버킷
읽기URL만 알면 누구나서명 URL 또는 인증 필요
CDN 캐싱매우 효율적제한적
용도아바타, 로고, 공개 이미지계약서, 개인 문서, 유료 콘텐츠
RLS쓰기에만 적용읽기·쓰기 모두 적용
// 기본 업로드
const { data, error } = await supabase.storage
.from('avatars')
.upload(`${userId}/profile.png`, file, {
cacheControl: '3600',
upsert: true, // 같은 경로가 있으면 덮어쓰기 (기본 false)
contentType: 'image/png',
})
// 서명된 업로드 URL — 서버가 발급, 클라이언트가 직접 업로드
const { data: signed } = await supabaseAdmin.storage
.from('documents')
.createSignedUploadUrl(`${userId}/${crypto.randomUUID()}.pdf`)
await supabase.storage
.from('documents')
.uploadToSignedUrl(signed.path, signed.token, file)
// 1) 공개 URL — public 버킷 전용. 만료 없음, CDN 캐시됨
const { data } = supabase.storage.from('avatars').getPublicUrl('user-1/profile.png')
// 2) 서명 URL — private 버킷. 지정 시간 동안만 유효
const { data: signed } = await supabase.storage
.from('documents')
.createSignedUrl('user-1/contract.pdf', 60) // 60초
// 여러 개 한 번에
await supabase.storage.from('documents').createSignedUrls(['a.pdf', 'b.pdf'], 60)
// 3) 직접 다운로드 — 인증된 요청, Blob으로 받음
const { data: blob } = await supabase.storage
.from('documents').download('user-1/contract.pdf')

서명 URL은 발급 시점에 권한이 검사된다. 발급 후에는 만료 전까지 누구나 쓸 수 있으므로 유효 시간을 짧게 잡고 필요할 때마다 새로 발급하는 게 안전하다.

-- 업로드: 자기 폴더에만
create policy "자기 폴더에 업로드"
on storage.objects for insert to authenticated
with check (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
-- 조회: 자기 폴더만 (update / delete 도 같은 형태로 만든다)
create policy "자기 폴더 조회"
on storage.objects for select to authenticated
using (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid())::text
);

storage.foldername(name)은 경로를 /로 쪼갠 배열을 준다 (1-indexed). storage.filename(name), storage.extension(name) 헬퍼도 있다.

삭제는 소유자 컬럼으로도 판정할 수 있다 — using ( bucket_id = 'documents' and owner_id = (select auth.uid())::text ).

  • 디렉터리documents/
    • 디렉터리8f3c1e2a-…/ 사용자 UUID — (storage.foldername(name))[1]
      • contract.pdf
      • invoice.pdf
    • 디렉터리team-abc/ 팀 단위로 나누는 것도 가능
      • shared.pdf
create policy "팀 문서 조회"
on storage.objects for select to authenticated
using (
bucket_id = 'documents'
and (storage.foldername(name))[1] in (
select team_id::text from public.team_members
where user_id = (select auth.uid())
)
);

DB 테이블 정책과 Storage 정책이 같은 헬퍼 함수를 공유하면 규칙이 어긋나지 않는다 (16장).

원본 하나만 올려두고 필요한 크기로 받아 쓴다.

// 공개 URL에 변환 옵션
const { data } = supabase.storage
.from('avatars')
.getPublicUrl('user-1/profile.png', {
transform: { width: 200, height: 200, resize: 'cover', quality: 80 },
})
// 서명 URL에도 적용 가능
await supabase.storage.from('documents').createSignedUrl('a.png', 60, {
transform: { width: 400 },
})
  • resize는 cover / contain / fill
  • 변환 결과는 CDN에 캐시된다 — 같은 옵션의 두 번째 요청부터는 빠르다
  • 유료 플랜 기능이며 변환된 원본 이미지 수 기준으로 과금된다
import * as tus from 'tus-js-client'
const upload = new tus.Upload(file, {
// 대용량 업로드는 API 게이트웨이 대신 Storage 직결 호스트를 권장한다
endpoint: `https://${PROJECT_REF}.storage.supabase.co/storage/v1/upload/resumable`,
retryDelays: [0, 3000, 5000, 10000, 20000],
headers: {
authorization: `Bearer ${session.access_token}`,
'x-upsert': 'true',
},
metadata: {
bucketName: 'videos',
objectName: `${userId}/${file.name}`,
contentType: file.type,
},
chunkSize: 6 * 1024 * 1024, // 6MB — Supabase 권장값
onProgress: (sent, total) => console.log(`${((sent / total) * 100).toFixed(1)}%`),
onSuccess: () => console.log('완료'),
})
upload.start()

tus는 재개 가능(resumable) 업로드의 오픈 표준 프로토콜이다 — 네트워크가 끊겨도 이어서 올릴 수 있다. 일반 업로드는 파일 크기 상한이 있으므로 큰 파일은 이쪽을 쓴다. 진행률 표시가 필요한 UI에도 적합하다.

파일 정보를 앱 테이블에 따로 저장하면 도메인 정보를 담을 자리가 생긴다.

create table public.attachments (
id uuid primary key default gen_random_uuid(),
post_id bigint not null references public.posts (id) on delete cascade,
bucket_id text not null,
path text not null,
size_bytes bigint,
mime_type text,
uploaded_by uuid not null default auth.uid() references auth.users (id),
created_at timestamptz not null default now(),
unique (bucket_id, path)
);
  • 앱 도메인 정보(어느 글의 첨부인지, 정렬 순서, 캡션)를 담을 수 있다
  • 조인이 쉬워진다 — posts와 함께 한 번에 조회 가능
  • 고아 파일 문제: 행은 지웠는데 파일이 남는다 → 삭제 트리거나 정기 배치(pg_cron)로 정리한다
서명된 업로드 URL 흐름 — 서버가 검증 후 URL을 주고 클라이언트가 Storage에 직접 올린 뒤 서버가 행을 만든다
  1. 버킷을 public으로 만들고 잊기 — 추측 가능한 경로면 전부 노출된다
  2. storage.objects에 RLS 정책을 안 만들기 — 업로드가 통째로 막히거나 열린다
  3. 경로에 사용자 입력을 그대로 사용 — ../ 같은 문자, 한글 파일명 인코딩 문제
  4. MIME 타입을 클라이언트 값만 믿기 — 버킷의 allowed_mime_types로 서버 측 제한을 건다
  5. 파일 크기 제한 미설정 — 버킷의 file_size_limit와 서버 검증 둘 다
  6. 고아 파일 방치 — 스토리지 비용이 조용히 늘어난다
  7. 서명 URL 유효 시간을 너무 길게 — 유출되면 그 기간 내내 열려 있다
  8. 이미지 변환을 매 요청마다 다른 파라미터로 — 캐시가 안 먹고 비용만 는다
  • Storage = 오브젝트 스토리지 + Postgres 메타데이터 + CDN
  • 권한은 storage.objects에 대한 RLS 정책으로 쓴다
  • 경로 설계가 곧 권한 설계 — <userId>/파일 또는 <teamId>/파일
  • public 버킷은 공개 자산에만, 나머지는 private + 짧은 서명 URL
  • 큰 파일은 TUS(재개 가능) 또는 S3 멀티파트
  • 서명 업로드 URL을 쓰면 파일이 앱 서버를 통과하지 않는다