콘텐츠로 이동

8. Storage

경로 설계가 곧 권한 설계다

flowchart LR
    C["클라이언트"] --> API["Storage API<br/>/storage/v1"]
    API --> META[("Postgres<br/>storage.buckets<br/>storage.objects")]
    API --> OBJ["오브젝트 스토리지<br/>S3 계열"]
    OBJ --> CDN["글로벌 CDN"]
    CDN --> C

    META -.->|"RLS 정책으로 접근 판정"| API

    classDef key  fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class META key
    class C,API,OBJ,CDN mute

파일 바이트는 오브젝트 스토리지에, 메타데이터는 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 },
})
  • resizecover / contain / fill
  • 변환 결과는 CDN에 캐시된다 — 같은 옵션의 두 번째 요청부터는 빠르다
  • 유료 플랜 기능이며 변환된 원본 이미지 수 기준으로 과금된다
import * as tus from 'tus-js-client'
const upload = new tus.Upload(file, {
endpoint: `${SUPABASE_URL}/storage/v1/upload/resumable`,
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()

네트워크가 끊겨도 이어서 올릴 수 있다. 일반 업로드는 파일 크기 상한이 있으므로 큰 파일은 이쪽을 쓴다. 진행률 표시가 필요한 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)로 정리한다
sequenceDiagram
    participant C as 클라이언트
    participant S as 서버 (Route Handler)
    participant ST as Storage
    participant DB as Postgres

    C->>S: 업로드 요청 (파일명, 크기, 타입)
    S->>S: 권한 · 용량 · 확장자 검증
    S->>ST: createSignedUploadUrl()
    ST-->>S: 서명된 URL + 토큰
    S-->>C: URL 전달
    C->>ST: 파일 직접 업로드 (서버를 거치지 않음)
    C->>S: 업로드 완료 알림
    S->>DB: attachments 행 생성
  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을 쓰면 파일이 앱 서버를 통과하지 않는다