Series·STEP 05 / 06Supabase 실전 가이드

Supabase Realtime은 구독됐는데 왜 이벤트가 오지 않나

SUBSCRIBED인데 이벤트가 오지 않는 이유를 postgres_changes·broadcast·presence의 출발점과 권한 검사 차이로 풀고, 채팅방 멤버 정책에서 강퇴가 두 방식에 어떻게 다르게 먹는지 보여 줍니다.

동딩
2026.10.12·5분 읽기·

Supabase Realtime은 WebSocket 연결로 서버가 먼저 변경을 보내 주는 서비스입니다. 없으면 몇 초마다 목록을 다시 불러 오는 폴링을 짜야 합니다. 클라이언트는 채널(이름 하나로 묶인 구독 방)에 들어가 세 종류의 메시지를 받습니다. DB 행 변경을 받는 postgres_changes, 앱끼리 주고받는 broadcast, 누가 접속해 있는지 맞추는 presence입니다.

아래 예는 채팅 앱입니다. 메시지는 messages(room_id, user_id, body)에, 방 멤버는 room_members(room_id, user_id)에 두고, 채널 이름은 방마다 room:1처럼 짓습니다.

구독은 됐는데 이벤트가 오지 않습니다

테이블을 만들고 바로 1번 방 메시지를 postgres_changes로 구독하면, 상태 콜백은 SUBSCRIBED를 찍는데 insert해도 아무것도 오지 않습니다. 단서는 system 이벤트로 옵니다.

node realtime.mjs
supabase.channel('room:1')
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: 'room_id=eq.1',
}, (p) => console.log(
p.eventType, p.new.body))
.on('system', {}, (m) =>
console.log(m.message))
.subscribe((s) =>
console.log(s))
실행 결과 · publication에 없음
SUBSCRIBED
Unable to subscribe to changes
with given parameters. …
-- insert 해도 아무것도 오지 않음
실행 결과 · publication에 넣은 뒤
SUBSCRIBED
Subscribed to PostgreSQL
INSERT A가 쓴 글
테이블을 publication에 넣기 전과 뒤의 구독

publication은 Postgres가 변경을 바깥으로 내보낼 테이블 목록입니다. supabase_realtime publication에 테이블을 넣어야 Realtime이 그 테이블의 변경을 읽습니다. 기본은 바뀐 뒤 값(payload.new)만 오므로, 수정·삭제 때 바뀌기 전 값(payload.old)까지 받으려면 replica identity를 full로 바꿉니다.

sql
alter publication supabase_realtime add table public.messages;
alter table public.messages replica identity full;

세 갈래는 출발점이 다릅니다

postgres_changes는 DB에서, broadcast와 presence는 앱에서 출발합니다.

세 갈래 메시지가 Realtime을 지나는 순서
  1. 1앱 A→Postgresinsert into messages
  2. 2Postgres→Realtimepublication에 든 테이블의 변경
  3. 3Realtime→Realtime받을 사람마다 select 정책 확인
  4. 4Realtime⇢앱 BINSERT 이벤트
  5. 5앱 A→Realtimebroadcast · typing
  6. 6Realtime⇢앱 Btyping
  7. 7앱 B→Realtimepresence · track
  8. 8Realtime⇢앱 Async · A, B
앱 A → Postgres

postgres_changes는 DB에 쓴 행에서 출발합니다.

Postgres → Realtime

Realtime은 publication에 든 테이블의 변경만 읽습니다.

Realtime

받을 사람마다 RLS를 돌려, 그 사람이 읽을 수 있는 행만 보냅니다.

앱 A → Realtime

broadcast는 DB를 거치지 않고 채널로 바로 갑니다. 어디에도 저장되지 않습니다.

앱 B → Realtime

presence는 각자 올린 상태를 Realtime이 한 목록으로 모아 모두에게 나눠 줍니다.

권한은 갈래마다 다른 곳에서 봅니다

postgres_changes는 2편의 테이블 정책을 그대로 씁니다. 채팅이라면 '내가 멤버인 방의 메시지만 읽는다'는 select 정책입니다.

sql
create policy "members read messages" on public.messages
for select to authenticated using (
exists (select 1 from public.room_members m
where m.room_id = messages.room_id and m.user_id = auth.uid())
);

Realtime은 이 정책을 이벤트마다 구독자마다 돌립니다. 그래서 B를 room_members에서 지우면(강퇴) 다음 메시지부터 B에게 INSERT 이벤트가 가지 않습니다. 정책에 걸린 행은 에러 없이 그 사람에게만 빠집니다.

broadcast와 presence에는 테이블이 없어서 채널 단위로 막습니다. { config: { private: true } }로 만든 private 채널은 realtime.messages 테이블의 정책을 통과해야 들어갑니다. select 정책은 받기, insert 정책은 보내기를 허락하고, 정책 식의 realtime.topic()이 들어오려는 채널 이름을 돌려줍니다. 방 멤버만 자기 방 채널에 들이는 정책은 이렇습니다.

sql
create policy "members join room" on realtime.messages
for select to authenticated using (
exists (select 1 from public.room_members m
where m.user_id = auth.uid()
and realtime.topic() = 'room:' || m.room_id)
);

정책을 통과하지 못하면 CHANNEL_ERROR Unauthorized: You do not have permissions to read from this Channel topic이 옵니다.

postgres_changesbroadcastpresence
출발점DB에 쓴 행앱이 보낸 메시지앱이 올린 상태
저장지원미지원미지원
권한받을 사람마다 테이블 RLS채널 정책(private)채널 정책(private)
검사 시점이벤트마다채널에 들어올 때채널에 들어올 때
쓰는 곳채팅 기록입력 중 표시·커서접속자 목록
세 갈래가 다른 점

구독자가 많아지면 DB에서 broadcast로 보냅니다

postgres_changes는 변경 하나를 받을 사람 수만큼 RLS로 검사하고, 순서를 지키려고 한 줄로 처리합니다. 같은 변경을 3,000명 넘게 구독한다면 broadcast로 내보내는 편이 낫습니다. 트리거가 행이 바뀔 때마다 realtime.broadcast_changes()로 채널에 한 번 보내고, Realtime이 그 채널 참여자에게 나눠 줍니다.

sql
create function public.messages_changes() returns trigger
security definer set search_path = '' language plpgsql as $$
begin
perform realtime.broadcast_changes('room:' || NEW.room_id,
TG_OP, TG_OP, TG_TABLE_NAME, TG_TABLE_SCHEMA, NEW, OLD);
return null;
end $$;
create trigger messages_broadcast after insert on public.messages
for each row execute function public.messages_changes();

받는 쪽은 private 채널에서 broadcast 이벤트를 듣습니다. 이벤트 이름은 트리거가 넘긴 TG_OP이고, 행은 payload.record에 들어 있습니다. React에서는 effect 정리 함수에서 removeChannel을 불러야 합니다. 같은 이름의 채널은 하나만 만들어져서, 정리 없이 effect가 다시 돌면 그 채널에 핸들러가 하나 더 붙고 메시지 하나가 두 번 그려집니다(postgres_changes면 cannot add postgres_changes callbacks … after subscribe() 에러). 개발 모드의 StrictMode가 effect를 두 번 돌리니 바로 드러납니다.

js
await supabase.realtime.setAuth() // 구독 전에 로그인 토큰을 Realtime에 넘깁니다
useEffect(() => {
const channel = supabase
.channel(`room:${roomId}`, { config: { private: true } })
.on('broadcast', { event: 'INSERT' }, (p) =>
setMessages((list) => [...list, p.payload.record]))
.subscribe()
return () => { supabase.removeChannel(channel) }
}, [roomId])

이때 권한 검사가 달라집니다. broadcast는 행마다 messages 테이블 정책을 다시 돌리지 않으니, 채널 정책이 테이블 정책과 같은 규칙(방 멤버)을 말해야 합니다. 게다가 채널 정책은 들어올 때 한 번 보고 연결 동안 캐시합니다. B를 room_members에서 지워도, 이미 room:1에 들어와 있던 B는 JWT가 만료되거나 새 토큰이 갈 때까지 메시지를 계속 받습니다. postgres_changes였다면 다음 메시지부터 끊겼을 강퇴입니다.

이벤트가 오지 않으면 상태 콜백 말고 system 메시지를 먼저 찍어 보고, publication과 정책 순서로 봅니다. 구독자가 수천 명이 되기 전까지는 설정이 적고 강퇴도 바로 먹는 postgres_changes로 충분합니다. 그보다 커져 DB broadcast로 옮길 때는 채널 정책을 테이블 정책과 같은 규칙으로 맞추고, 강퇴가 늦게 먹는 만큼 JWT 만료를 짧게 둡니다.

Comments

댓글

powered by giscus ↗
시간순 글 탐색