codingstairs
노트에듀라이프연락
⌕검색⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

연락하기

로그인 없이도 보낼 수 있어요. 답변이 필요하면 이메일을 함께 적어 주세요.

  • 익명 폼으로 의견 남기기 →
  • ✉ warragon112@gmail.com
  • 카카오톡 오픈채팅 ↗

© 2026 codingstairs

  • 노트
  • 에듀
  • 검색
  • 라이프
  • 연락
  • 약관
  • RSS
  • GitHub
에듀›Docker · Caddy · 클라우드 10단계 배포 옵션›10단계

10단계

10단계 — 오브젝트 스토리지와 파일 권한

0회 조회

10단계 — 오브젝트 스토리지와 파일 권한

배포까지 익혔으면 다음은 파일 입니다. 사용자 프로필 사진 · 첨부 · 영수증 — 이 바이트들을 어디 두고 누구에게 보일지. 이번 단계는 Supabase Storage 로 버킷을 만들고, 올리고, 본인 파일만 보이게 막고, 서명 URL 로 임시 공개하는 것까지 손으로 합니다.

왜 DB 가 아니라 오브젝트 스토리지인가

파일을 DB 컬럼(base64 BLOB)에 넣고 싶은 유혹이 있지만, 규모가 커지면 후회합니다.

  • DB 백업 · 복제 · 인덱스가 바이너리 무게만큼 무거워져요.
  • DB 행은 CDN 이 직접 서빙하지 못해요. 파일은 URL 로 나와야 엣지 캐시가 붙습니다.

그래서 정석은 바이트는 오브젝트 스토리지, 메타데이터(URL · 크기 · mime)만 DB. DB 의 파일 테이블 한 행은 "이 파일이 스토리지 어디 있다" 는 포인터입니다.

(1) 버킷 만들기 — public vs private

Storage 의 최상위 단위는 버킷 입니다. 두 개를 만들어 둡니다.

  • avatars — public. URL 만 알면 누구나 GET. 공개 프로필 이미지용.
  • user-uploads — private. URL 만으로는 못 엶. RLS 정책 또는 서명 URL 필요. 영수증 같은 민감 파일용.

대시보드의 Storage 메뉴, 또는 SQL 로:

insert into storage.buckets (id, name, public)
values ('avatars', 'avatars', true),
       ('user-uploads', 'user-uploads', false);

경로 설계가 중요합니다. 객체 키 앞에 소유자 ID 를 prefix 로 둡니다.

{userId}/{종류}/{uuid}.{확장자}
예) 9f3c.../avatars/2a7b.png

이렇게 두면 (a) 사용자 탈퇴 시 {userId}/ prefix 통째 삭제로 정리되고, (b) 다음 단계의 RLS 정책이 "경로 첫 칸 == 본인 ID" 로 단순해집니다.

(2) supabase-js 로 업로드 · URL · 삭제

supabase-js 의 storage 네임스페이스로 다룹니다.

import { createClient } from '@supabase/supabase-js';
const supabase = createClient(URL, ANON_KEY);

// public 버킷에 아바타 업로드
const path = `${userId}/avatars/${crypto.randomUUID()}.png`;
const { error } = await supabase.storage
  .from('avatars')
  .upload(path, file, { contentType: 'image/png', upsert: false });

// public URL — 네트워크 호출 없이 문자열만 조립
const { data } = supabase.storage.from('avatars').getPublicUrl(path);
// data.publicUrl 을 앱 DB 의 메타데이터 행에 INSERT

// 삭제 — 키 배열
await supabase.storage.from('avatars').remove([path]);

자주 막히는 곳 — upload 의 기본은 upsert: false. 프로필 사진 교체 처럼 같은 경로에 덮어쓸 거면 upsert: true 를 명시해야 합니다. 빠뜨리면 "Duplicate" 에러.

// 같은 경로 덮어쓰기 — upsert 명시
await supabase.storage.from('avatars').upload(path, file, { upsert: true });

getPublicUrl 은 public 버킷 전용 입니다. private 버킷엔 (4) 의 서명 URL 을 씁니다.

(3) 파일 RLS — 본인 파일만 보이게

Storage 객체의 권한은 별도 시스템이 아닙니다. storage.objects 테이블의 RLS 정책 을 그대로 따릅니다. (1) 에서 메타데이터가 DB 테이블 행이라고 한 이유가 여기서 드러나요.

user-uploads (private) 버킷을 "본인 파일만" 으로 막아 봅니다. 핵심은 객체 키 첫 세그먼트를 auth.uid() 와 비교 — (1) 의 {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 조건으로 정책이 의도한 버킷에만 적용되게 합니다.
  • 테스트 — 사용자 A 로 로그인해 B 의 파일 경로를 읽어 보세요. RLS 가 빈 결과로 막아야 정상입니다.

(4) Presigned URL — private 파일 임시 공개

private 버킷 파일은 URL 만으론 못 엽니다. 그런데 외부 공유 · 이메일 첨부 · 임시 다운로드 링크는 필요하죠. 그 사이를 메우는 게 서명 URL — "이 객체를, 이 시각까지만, 누구든" 접근을 허용하는 한시적 토큰이 박힌 URL.

// 60초 동안만 유효한 다운로드 URL
const { data } = await supabase.storage
  .from('user-uploads')
  .createSignedUrl(`${userId}/receipts/8e10.pdf`, 60);
// data.signedUrl 을 응답으로 내려보냄

만료 시간(초)은 짧게. 링크가 유출돼도 창이 곧 닫힙니다. 즉시 다운로드면 30~60초, 이메일에 담으면 수 시간 — 용도에 맞게.

실무 패턴 하나: 원본은 private, 변환본만 public. 사용자가 올린 고해상도 원본은 private 버킷 + 서명 URL 로만 열고, 리사이즈·워터마크를 거친 변환본만 public 버킷에 올려 CDN 으로 빠르게 서빙합니다.

S3 호환 — 한 발 더

Supabase Storage 는 S3 호환 엔드포인트 도 제공합니다. @aws-sdk/client-s3 같은 표준 S3 SDK 로 같은 버킷을 다룰 수 있어요. 큰 파일의 멀티파트 업로드를 쓰거나, 나중에 Cloudflare R2 · MinIO 로 옮길 때 endpoint 와 자격 증명만 바꾸면 코드가 거의 그대로입니다 — 벤더 락인을 줄이는 안전판. S3 자격 증명은 RLS 를 우회하므로 서버 환경 변수에만 둡니다.

자주 걸리는 자리

  • 버킷 만들고 RLS 미설정 — private 버킷이어도 정책이 없으면 의도와 다르게 막히거나 열립니다. 버킷 생성과 정책 작성을 한 작업으로.
  • service_role · S3 시크릿 키를 클라 번들에 노출 — 이 키들은 RLS 를 우회합니다. 프론트 번들에 들어가면 모든 파일이 열려요. anon 키만 클라이언트에.
  • DB 에 파일 바이트 저장 — base64 로 DB 컬럼에 넣지 마세요. 메타데이터만 DB, 바이트는 Storage.
  • public 버킷 CDN 캐시 — 같은 경로에 변환본을 새로 올려도 옛 캐시가 남습니다. 캐시 무효화 호출, 또는 경로·쿼리스트링에 버전 토큰.
  • 클라이언트 직접 업로드 — 크기·mime·매수 검증이 클라 코드에만 의존하면 우회 쉬움. 민감한 경로는 서버 라우트를 거쳐 검증 후 업로드.

더 깊이

  • Supabase Storage 노트
  • Supabase 노트

강좌 마무리

여기까지 완주하면 — Docker · Caddy · AWS · Fly.io · Replit · GitHub Pages 6 가지 운영·배포 옵션에 더해, 오브젝트 스토리지로 사용자 파일을 안전하게 다루는 법까지 손에 쥔 셈입니다. 코드를 띄우는 것에서 그치지 않고, 그 위에 쌓이는 파일 — 버킷 · RLS · 서명 URL — 을 어디 두고 누구에게 보일지가 운영의 마지막 한 칸입니다. 사이드 프로젝트는 GitHub Pages / Replit, 본격 운영은 Fly.io 또는 단일 서버 + Caddy 에 Supabase Storage 를 얹는 구성이 일반적입니다.

← 9단계

9단계 — GitHub Pages 무료 호스팅

🎉 Docker · Caddy · 클라우드 10단계 배포 옵션 완주를 축하해요

이어서 어떤 걸 배워 볼까요?

다음: 중앙 관리자 플랫폼 — 여러 도메인을 한 허브에서 →전체 강좌 둘러보기