Supabase Storage — 파일 업로드와 권한
Supabase Storage — 파일 업로드와 권한
Supabase 노트에서 Storage 는 한 문단으로만 다뤘습니다. 이 노트는 그 자리만 깊게 봅니다 — 버킷 설계 · 업로드/삭제 코드 · 파일 단위 권한(RLS) · S3 호환 API · 서명 URL. 이미지를 어디에 두고, 누구에게 보이게 할지의 실무.
1. 오브젝트 스토리지 개념
이미지 · PDF · 영상 같은 파일을 데이터베이스 BLOB 컬럼에 직접 넣는 길도 있지만, 규모가 커지면 거의 항상 후회합니다.
- DB 비대화 — 바이너리가 들어간 테이블은 백업 · 복제 · 인덱스 전부 무거워집니다.
- 캐시 · CDN 곤란 — DB 행은 CDN 이 직접 서빙하지 못합니다. 파일은 URL 로 노출돼야 엣지 캐시가 붙습니다.
- 스트리밍 부재 — 큰 파일을 부분 전송(Range) 하려면 오브젝트 스토리지의 기본기가 필요합니다.
그래서 일반적인 분리는 이렇습니다. 파일 바이트는 오브젝트 스토리지, 메타데이터(파일 URL · 크기 · mime 타입 · 업로더 ID)만 DB 에 둡니다. DB 의 images 테이블 한 행은 실제 바이트가 아니라 "이 파일이 스토리지 어디에 있고 얼마나 크다" 는 포인터입니다.
오브젝트 스토리지의 사실상 표준 API 는 Amazon S3 입니다. R2 · MinIO · GCS · Supabase Storage 모두 S3 호환 인터페이스를 제공하거나 흡수했습니다. "S3 API 로 말한다" 가 곧 이식성입니다.
2. Supabase Storage 버킷과 경로
Supabase Storage 의 최상위 단위는 버킷(bucket) 입니다. 버킷은 두 종류.
- public 버킷 — 객체 URL 을 알면 누구나 GET 가능. 공개 이미지 · 썸네일에 적합.
- private 버킷 — URL 만으로는 접근 불가. RLS 정책 또는 서명 URL 을 거쳐야 함. 사용자 업로드 원본 · 영수증 같은 민감 파일에 적합.
버킷 안에서 파일은 객체 키(경로 문자열) 로 식별됩니다. 경로 설계가 운영을 좌우합니다. 권장 패턴은 prefix 로 소유자 · 도메인을 앞세우는 것.
{userId}/{domain}/{uuid}.{ext}
예) 9f3c.../avatars/2a7b-...png
9f3c.../receipts/8e10-...pdf
이렇게 두면:
- 사용자 탈퇴 시
{userId}/prefix 를 통째로 나열·삭제하면 그 사람의 모든 파일이 한 번에 정리됩니다. - RLS 정책이 경로 첫 세그먼트(
(storage.foldername(name))[1])를 사용자 ID 와 비교하기 쉬워집니다. - 파일명에 UUID 를 쓰면 같은 이름 업로드 충돌과 경로 추측 공격을 동시에 줄입니다.
객체의 메타데이터(버킷 · 키 · 크기 · mime · 소유자)는 PostgreSQL 의 storage.objects 테이블에 한 행으로 저장됩니다. 즉 Storage 도 결국 DB 테이블이고, 이게 다음 절의 권한 이야기로 이어집니다.
3. supabase-js 로 업로드·삭제·URL
supabase-js 의 storage 네임스페이스로 파일을 다룹니다. 버킷을 고른 뒤 upload · remove · getPublicUrl 을 호출합니다.
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(URL, ANON_KEY);
const bucket = 'avatars';
// 업로드 — File / Blob / ArrayBuffer 를 받음
const path = `${userId}/avatars/${crypto.randomUUID()}.png`;
const { data, error } = await supabase.storage
.from(bucket)
.upload(path, file, {
contentType: 'image/png',
cacheControl: '3600',
upsert: false, // 같은 경로가 이미 있으면 실패
});
// 같은 경로 덮어쓰기 — upsert 를 명시
await supabase.storage
.from(bucket)
.upload(path, file, { upsert: true });
// public 버킷의 영구 URL — 네트워크 호출 없이 문자열만 조립
const { data: pub } = supabase.storage.from(bucket).getPublicUrl(path);
// pub.publicUrl 을 DB 의 메타데이터 행에 저장
// 삭제 — 키 배열을 받음
await supabase.storage.from(bucket).remove([path]);
기억할 점.
upload의 기본은upsert: false입니다. 같은 경로 재업로드를 의도한다면upsert: true를 반드시 명시해야 합니다. 누락하면 "Duplicate" 에러가 납니다.getPublicUrl은 public 버킷 전용입니다. private 버킷에 쓰면 URL 은 만들어지지만 열면 거부됩니다 — 그쪽은 6 절의 서명 URL 을 씁니다.upload가 반환하는 것은 키 정보일 뿐, 파일 바이트가 아닙니다. 업로드 직후publicUrl·path·size같은 메타데이터를 앱 DB 테이블에 따로 INSERT 하는 흐름이 일반적입니다.
4. 파일 접근 권한 — RLS
Storage 객체의 권한은 별도 시스템이 아닙니다. storage.objects 테이블의 Row Level Security 정책을 그대로 따릅니다. 2 절에서 메타데이터가 DB 테이블 행이라고 한 이유가 여기서 드러납니다.
- public 버킷 — 보통
SELECT를 누구에게나(anon포함) 허용하는 정책을 둡니다. 쓰기(INSERT/UPDATE/DELETE)는 여전히 제한. - private 버킷 —
SELECT부터 인증 사용자로 제한하고, 보통 "본인 파일만" 으로 좁힙니다.
본인 파일만 접근하게 하는 정책의 핵심은 객체 키의 첫 경로 세그먼트를 auth.uid() 와 비교하는 것입니다. 2 절의 {userId}/... 경로 설계가 여기에 맞물립니다.
-- 본인 prefix 의 파일만 읽기
CREATE POLICY "read own files" ON storage.objects
FOR SELECT
USING (
bucket_id = 'user-uploads'
AND (storage.foldername(name))[1] = auth.uid()::text
);
-- 본인 prefix 에만 업로드
CREATE POLICY "upload to own prefix" ON storage.objects
FOR INSERT
WITH CHECK (
bucket_id = 'user-uploads'
AND (storage.foldername(name))[1] = auth.uid()::text
);
-- 본인 파일만 삭제
CREATE POLICY "delete own files" ON storage.objects
FOR DELETE
USING (
bucket_id = 'user-uploads'
AND (storage.foldername(name))[1] = auth.uid()::text
);
storage.foldername(name)은 객체 키를/로 쪼갠 배열을 돌려줍니다.[1]이 첫 세그먼트.bucket_id조건을 넣어 정책이 의도한 버킷에만 적용되게 합니다 — 버킷이 늘면 정책이 섞입니다.service_role키는 RLS 를 우회합니다. 서버 라우트에서 관리 작업(전체 정리 등)을 할 때만 쓰고, 클라이언트에는 절대 노출하지 않습니다.
5. S3 호환 API
Supabase Storage 는 자체 API 외에 S3 호환 엔드포인트를 제공합니다. 그래서 @aws-sdk/client-s3 같은 표준 S3 SDK 로도 같은 버킷을 다룰 수 있습니다.
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
const s3 = new S3Client({
forcePathStyle: true,
region: '<project-region>', // 프로젝트 리전
endpoint: 'https://<project>.supabase.co/storage/v1/s3',
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY!, // 서버 전용
secretAccessKey: process.env.S3_SECRET_KEY!,
},
});
await s3.send(new PutObjectCommand({
Bucket: 'my-bucket',
Key: 'reports/quarterly.pdf',
Body: fileBuffer,
ContentType: 'application/pdf',
}));
S3 호환을 챙기는 이유.
- 멀티파트 업로드 — 큰 파일을 청크로 나눠 병렬·재개 가능하게 올리는 표준 흐름을 S3 SDK 가 그대로 지원합니다.
- 이식성 — 나중에 Cloudflare R2 · MinIO · AWS S3 본가로 옮길 때,
endpoint와 자격 증명만 바꾸면 코드가 거의 그대로 동작합니다. 특정 벤더에 묶이지 않습니다. - 기존 도구 재사용 —
aws s3 cp·rclone· 백업 스크립트 등 S3 를 아는 도구가 그대로 붙습니다.
구성 요소는 셋. 엔드포인트(스토리지 S3 경로), 리전(프로젝트 리전 문자열), 액세스 키 / 시크릿 키. S3 자격 증명은 RLS 를 거치지 않는 강한 권한이므로 service_role 키와 똑같이 서버에만 둡니다.
6. Presigned URL (서명 URL)
private 버킷의 파일은 URL 만으로 못 엽니다. 그렇다고 모든 접근을 로그인 세션으로만 묶으면, 외부 공유 · 이메일 첨부 · 임시 다운로드 링크가 곤란해집니다. 그 사이를 메우는 것이 서명 URL(presigned / signed URL) 입니다.
서명 URL 은 "이 객체를, 이 시각까지만, 누구든" 접근을 허용하는 한시적 토큰이 박힌 URL 입니다.
// 60초 동안만 유효한 다운로드 URL
const { data, error } = await supabase.storage
.from('user-uploads')
.createSignedUrl(`${userId}/receipts/8e10.pdf`, 60);
// data.signedUrl 을 응답으로 내려보냄
// 여러 객체를 한 번에
const { data: many } = await supabase.storage
.from('user-uploads')
.createSignedUrls([keyA, keyB], 60);
만료 시간(초)을 짧게 잡는 게 핵심입니다. 링크가 유출돼도 창이 곧 닫힙니다. 다운로드 즉시 쓸 거면 30~60초, 이메일에 담을 거면 수 시간 — 용도에 맞게.
자주 쓰는 정책 하나. 원본은 private, 변환본만 public. 사용자가 올린 고해상도 원본은 private 버킷에 두고 서명 URL 로만 열고, 리사이즈·워터마크를 거친 변환본만 public 버킷에 올려 CDN 으로 빠르게 서빙합니다. 원본 유출 위험과 서빙 속도를 동시에 챙기는 절충입니다.
자주 걸리는 자리
새 버킷 · 새 테이블 RLS 미설정 — storage.objects 에 정책을 안 깔면 의도와 다른 노출이 생깁니다. private 버킷을 만들었어도 RLS 정책 자체가 없으면 "아무도 못 본다" 가 되거나, 반대로 너무 넓은 정책 하나로 전부 열립니다. 버킷을 만들면 정책 작성을 같은 작업으로 묶습니다.
service_role 키의 클라이언트 번들 노출 — service_role 과 S3 시크릿 키는 RLS 를 우회합니다. 빌드된 프론트 번들에 들어가면 모든 파일이 열립니다. anon 키만 클라이언트에, 강한 키는 서버 환경 변수에 — CI 에서 번들 검사를 권장합니다.
DB 에 파일 바이트 저장 — 파일을 base64 로 인코딩해 DB 컬럼에 넣는 실수. DB 가 비대해지고 CDN 도 못 붙입니다. DB 에는 메타데이터(URL · 크기 · mime)만, 바이트는 Storage 에.
public 버킷 CDN 캐시 — public 객체는 CDN 이 캐시합니다. 같은 경로에 변환본을 새로 올려도 옛 캐시가 한동안 남습니다. 캐시 무효화를 호출하거나, 경로(또는 쿼리스트링)에 버전 토큰을 넣어 새 URL 로 만듭니다.
같은 경로 재업로드 시 upsert 누락 — upload 의 기본은 upsert: false. 프로필 사진 교체처럼 같은 키에 덮어쓸 의도면 upsert: true 를 명시해야 합니다. 빠뜨리면 중복 에러로 실패합니다.
클라이언트가 스토리지에 직접 업로드 — 클라에서 곧장 upload 하면 빠르지만, 파일 크기 · mime · 매수 검증이 클라 코드에만 의존합니다. 민감한 경로는 서버 라우트를 거쳐 검증한 뒤 업로드하거나, 서명 업로드 URL 을 서버가 발급하는 흐름이 안전합니다.
하고픈 말
Storage 의 어려움은 "파일을 어디 두느냐" 가 아니라 "누구에게 보이느냐" 입니다. 버킷의 public/private, 경로의 prefix 설계, storage.objects 의 RLS — 이 셋이 맞물려야 권한이 일관됩니다. 시작은 단순하게: public 버킷 하나로 공개 이미지를 서빙하고, 사용자별 파일이 생기는 시점에 private 버킷 + prefix 정책을 더하는 순서가 자연스럽습니다.
Next
- supabase
- image-pipeline
Supabase Storage 문서 · Storage S3 호환 API · Storage 접근 제어 (RLS) · Amazon S3 API 참조 를 참고합니다.