Supabase Storage 와 Realtime — 파일과 변경을 한 벌의 권한 모델로
객체 스토리지를 RLS 와 같은 정책으로 잠그고, WebSocket 으로 DB 변경·메시지·프레즌스를 구독하는 세 가지 방식을 정리합니다.
들어가며
3편까지 우리는 데이터·권한·인증을 한 벌로 묶었다. 남은 두 영역이 비정형 데이터(파일) 와 변경의 흐름(realtime) 이다. Supabase 는 이 둘 모두를 RLS 와 같은 정책 모델 로 묶어 두려 한다 — 즉 인증·권한 토큰 한 벌이 DB·파일·구독에 모두 흐른다.
이번 편은 그 두 가지의 동작 방식과 실전 패턴.
Storage — 모델부터
Supabase Storage 는 S3 호환 객체 스토리지를 추상화한 서비스다. 모델은 단순하다.
- Bucket — 최상위 컨테이너. 한 프로젝트에 여러 버킷.
- Object — 버킷 안의 파일. 경로(
folder/sub/file.png)와 메타데이터를 가짐. - Policy — 어떤 사용자가 어떤 객체에 SELECT/INSERT/UPDATE/DELETE 할 수 있는지를 결정.
핵심 한 가지: Storage 의 정책은 Postgres 의 storage.objects 테이블 위에 그냥 RLS 로 정의된다. 별도 DSL 이 아니라 같은 SQL 모델 — 우리가 지난 편에서 본 auth.uid() · auth.jwt() 가 그대로 동작한다.
첫 버킷 만들기
대시보드 Storage → New bucket 또는 SQL 한 줄.
insert into storage.buckets (id, name, public)values ('avatars', 'avatars', false);public = false 로 만들었으니, 모든 동작에 정책이 필요하다. 자주 쓰는 셋:
-- 1) 누구나 읽을 수 있다 (대신 public 토글 대용)create policy "anyone can view avatars"on storage.objects for selectusing ( bucket_id = 'avatars' );-- 2) 로그인 사용자가 자기 폴더에만 업로드할 수 있다create policy "users can upload to their own folder"on storage.objects for insertwith check ( bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text);-- 3) 본인 객체만 수정·삭제create policy "users can manage their own objects"on storage.objects for update using ( bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text);create policy "users can delete their own objects"on storage.objects for delete using ( bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text);storage.foldername(name) 은 객체 path 를 / 로 쪼개 배열로 반환하는 헬퍼 함수. 위에선 최상위 폴더 이름 을 사용자 UUID 로 간주해 비교한다 — 사용자별 격리의 가장 단순한 패턴.
업로드 · 다운로드 · 서명 URL
const { data, error } = await supabase.storage .from('avatars') .upload(`${userId}/profile.png`, file, { cacheControl: '3600', upsert: true, contentType: 'image/png', });업로드 결과의 data.path 가 객체 키. 다운로드는 두 가지 흐름.
Public 버킷 — 그냥 URL
const { data } = supabase.storage .from('avatars') .getPublicUrl(`${userId}/profile.png`);// data.publicUrl: https://<project>.supabase.co/storage/v1/object/public/avatars/...Private 버킷 — 서명 URL
const { data, error } = await supabase.storage .from('avatars') .createSignedUrl(`${userId}/profile.png`, 60); // 60초 유효// data.signedUrl: https://...?token=...서명 URL 은 짧은 시간만 유효한 임시 다운로드 링크 다. 이메일에 붙이거나 SSR 응답에 박을 때 유용.
이미지 변환
Supabase Storage 는 imgproxy 를 백엔드로 URL 파라미터 기반 변환 을 제공한다 — 별도 CDN 설정 없이 즉시.
const { data } = supabase.storage .from('avatars') .getPublicUrl('user-1/profile.png', { transform: { width: 200, height: 200, resize: 'cover', quality: 80 }, });생성되는 URL 은 /storage/v1/render/image/public/...?width=200&height=200&... 형태. 같은 원본을 다양한 크기로 배포할 때 유용.
Realtime — 세 가지 모드
Supabase Realtime 은 한 가지가 아니라 세 가지 다른 채널 유형 을 한 SDK 로 묶어 제공한다. 헷갈리기 쉬운 부분이라 한 번 정리.
postgres_changes— DB 의INSERT/UPDATE/DELETE를 실시간 구독.broadcast— 가벼운 메시지(사용자가 보낸 임의 페이로드)를 채널 참여자들에게 푸시.presence— 채널에 누가 온라인인지·어떤 상태인지 동기화.
세 모드는 한 채널 안에서 섞어 쓸 수도 있다. 채팅방을 예로 들면 postgres_changes 로 메시지 영구 저장, broadcast 로 "ㅇㅇ가 입력 중" 같은 휘발성 신호, presence 로 접속자 목록을 동시에 다룬다.
postgres_changes — DB 변경 구독
const channel = supabase .channel('comments-stream') .on( 'postgres_changes', { event: '*', schema: 'public', table: 'comments', filter: `post_id=eq.${postId}` }, (payload) => { console.log(payload.eventType, payload.new, payload.old); }, ) .subscribe();내부 동작: Postgres 의 논리 복제(logical replication) 가 만들어내는 WAL 을 wal2json 으로 흘려, Realtime 서버가 받아 채널 가입자에게 WebSocket 으로 푸시한다. 즉 DB 가 truth 이고 우리는 그 변경을 듣는다 — 메시지 발송 코드를 명시적으로 짤 필요가 없다.
-- 어떤 테이블의 변경을 Realtime 으로 흘릴지 명시 (publication 에 추가)alter publication supabase_realtime add table comments;대시보드에서 Database → Replication 탭으로도 토글 가능.
broadcast — 가벼운 메시지
DB 를 거치지 않고 채널 참여자들에게 그 자리에서 메시지를 푸시한다.
// 보내는 쪽const channel = supabase.channel('typing-room', { config: { broadcast: { self: false } } });await channel.subscribe();await channel.send({ type: 'broadcast', event: 'typing', payload: { userId: 'kim', at: Date.now() },});// 받는 쪽channel.on('broadcast', { event: 'typing' }, (payload) => { console.log('typing:', payload);});영속성이 없다. 메시지는 그 순간 채널에 있는 사람만 받고 사라진다. "입력 중", "마우스 커서 위치" 같은 휘발성 신호 에 적합.
presence — 누가 온라인인가
각 클라이언트가 자기 상태 를 채널에 등록(track) 하면, 채널의 다른 참여자들이 그 상태 맵을 동기화 받는다.
const channel = supabase.channel('room-1', { config: { presence: { key: userId } },});channel .on('presence', { event: 'sync' }, () => { const state = channel.presenceState(); console.log('online users:', Object.keys(state)); }) .on('presence', { event: 'join' }, ({ key }) => console.log(key, 'joined')) .on('presence', { event: 'leave' }, ({ key }) => console.log(key, 'left')) .subscribe(async (status) => { if (status === 'SUBSCRIBED') { await channel.track({ status: 'online', cursor: null }); } });연결이 끊기면 자동으로 leave 가 발생한다. 협업 도구의 접속자 아바타 띠 가 전형적인 사용처.
채팅 미니 예제 — 셋을 합치면
const channel = supabase.channel(`room:${roomId}`, { config: { presence: { key: userId } },});// (1) DB 에 저장된 메시지가 추가되면 화면에 추가channel.on( 'postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages', filter: `room_id=eq.${roomId}` }, (payload) => appendMessage(payload.new),);// (2) 입력 중 신호는 broadcastchannel.on('broadcast', { event: 'typing' }, ({ payload }) => showTyping(payload.userId));// (3) 접속자 목록은 presencechannel.on('presence', { event: 'sync' }, () => { setOnlineUsers(Object.keys(channel.presenceState()));});await channel.subscribe(async (status) => { if (status === 'SUBSCRIBED') await channel.track({ joined_at: Date.now() });});// 메시지 보내기 — DB 에 INSERT 만 하면, postgres_changes 가 모두에게 알려준다await supabase.from('messages').insert({ room_id: roomId, author_id: userId, body: text,});// 입력 중 알림 — DB 거치지 않고 broadcastawait channel.send({ type: 'broadcast', event: 'typing', payload: { userId } });세 가지 모드의 역할 분담이 그대로 드러난다 — 영속성 있는 데이터는 DB·RLS 에, 휘발성 신호는 broadcast 에, 상태 맵은 presence 에.
정리
Storage 와 Realtime 은 별도의 권한 시스템을 만들지 않는다 는 점이 일관된 디자인이다. 인증 토큰 → JWT → RLS → DB·Storage·Realtime 이 한 줄로 흐른다.
- Storage 정책은
storage.objects위 RLS 와 동일. - Realtime 의
postgres_changes는 RLS 를 그대로 적용해 필터링. - 휘발성 신호는 broadcast, 접속자 동기화는 presence.