Supabase Realtime 삽질기
postgres_changes가 안 되는 이유를 찾다가 RLS, Publication 설정까지 - 실시간 킬피드 구현 과정
시작: "실시간 되겠지 뭐"
Supabase 문서를 보면 실시간 구독이 엄청 쉬워 보인다.
supabase
.channel('changes')
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'kill_logs' },
(payload) => console.log(payload)
)
.subscribe()
몇 줄이면 된다고? 바로 적용했다.
안 됐다.
삽질 1: 테이블별 Realtime 활성화
처음엔 아무것도 안 왔다. 콘솔에 찍히는 게 없음.
DB 직접 INSERT 해보면 데이터는 들어간다. 근데 subscribe 콜백이 안 불림.
Supabase 대시보드 → Database → Replication 가봤더니:
Tables enabled for realtime: (none)
테이블별로 Realtime을 켜줘야 한다. 기본값이 꺼져있음.
-- Realtime 활성화
ALTER PUBLICATION supabase_realtime ADD TABLE kill_logs;
ALTER PUBLICATION supabase_realtime ADD TABLE online_players;
ALTER PUBLICATION supabase_realtime ADD TABLE game_events;
대시보드에서 토글로 켜도 되지만, 스키마 파일에 명시해두는 게 좋다. 나중에 환경 재구축할 때 까먹으니까.
삽질 2: RLS가 Realtime도 막는다
Publication 설정했는데도 특정 테이블만 안 됨.
원인은 **RLS(Row Level Security)**였다. Supabase는 보안을 위해 기본적으로 RLS가 켜져 있다. 문제는 Realtime도 RLS 정책을 따른다는 것.
-- RLS 활성화
ALTER TABLE kill_logs ENABLE ROW LEVEL SECURITY;
-- 읽기 정책이 없으면 Realtime도 안 됨
CREATE POLICY "Public read kill_logs" ON kill_logs FOR SELECT USING (true);
SELECT 권한이 없으면 실시간 변경도 못 받는다. 당연한 건데 문서에서 놓치기 쉽다.
삽질 3: React에서 채널 cleanup
이제 되는 줄 알았다. 근데 페이지 왔다갔다 하면 가끔 안 됨.
로그 찍어보니까 채널이 계속 쌓이고 있었다.
// 이렇게 하면 채널이 계속 쌓임
useEffect(() => {
const channel = supabase
.channel(`kill_feed_${serverId}`)
.on('postgres_changes', { /* ... */ }, handleKill)
.subscribe()
// cleanup 안 하면 채널이 계속 남음
}, [serverId])
페이지 이동할 때마다 새 채널이 생기고, 이전 채널은 안 죽고 남아있었다.
// 해결: cleanup 필수
useEffect(() => {
const channel = supabase
.channel(`kill_feed_${serverId}`)
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'kill_logs',
filter: `server_id=eq.${serverId}`
}, (payload) => {
addKill(payload.new)
})
.subscribe((status) => {
setIsConnected(status === 'SUBSCRIBED')
})
return () => {
supabase.removeChannel(channel) // 이거 중요
}
}, [serverId])
React 쓰면서 cleanup 빼먹은 내 잘못.
삽질 4: 연결 상태 모니터링
가끔 실시간 업데이트가 안 오는데, 에러도 안 남.
알고 보니 연결이 끊겼는데 몰랐던 것. subscribe의 status 콜백을 활용해야 한다.
.subscribe((status) => {
console.log('[KillFeed] Subscription status:', status)
if (status === 'SUBSCRIBED') {
setIsConnected(true)
} else if (status === 'CHANNEL_ERROR') {
setIsConnected(false)
// 재연결 로직 추가 가능
}
})
UI에 연결 상태를 표시해두면 유저도 알 수 있고, 디버깅도 편하다.
삽질 5: 서버별 필터링
킬피드를 서버별로 분리해야 했다. Newbie 서버 킬이 Softopia 페이지에 뜨면 안 되니까.
처음엔 클라이언트에서 필터링했다:
// 비효율적: 모든 킬을 받고 클라이언트에서 필터
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'kill_logs' },
(payload) => {
if (payload.new.server_id === serverId) {
addKill(payload.new)
}
}
)
근데 이러면 다른 서버 킬도 다 받아야 한다. 트래픽 낭비.
해결: filter 옵션 사용
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'kill_logs',
filter: `server_id=eq.${serverId}` // DB에서 필터링
}, handleKill)
filter로 조건을 걸면 DB 레벨에서 필터링해서 보내준다. 필요한 데이터만 받을 수 있다.
최종 구조
// useKillFeed.ts 핵심 로직
useEffect(() => {
fetchInitialKills(serverId) // 초기 데이터 로드
const channel = supabase
.channel(`kill_feed_${serverId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'kill_logs',
filter: `server_id=eq.${serverId}`
},
(payload) => {
addKill(payload.new as KillLog)
}
)
.subscribe((status) => {
setIsConnected(status === 'SUBSCRIBED')
})
return () => {
supabase.removeChannel(channel)
}
}, [serverId])
교훈
Supabase Realtime은 "마법"이 아니다. 실제로 쓰려면:
- 테이블별 Publication 활성화 - 기본이 꺼져있음
- RLS 정책 확인 - SELECT 권한 없으면 구독도 안 됨
- 채널 cleanup 필수 - 특히 React에서
- 연결 상태 모니터링 - status 콜백 활용
- filter로 효율적 구독 - 클라이언트 필터링은 낭비
문서만 보면 5줄이지만, 프로덕션에서 쓰려면 설정과 예외 처리가 더 필요하다.
다음 편: 킬피드 실시간 연동 - Oxide 플러그인에서 Supabase까지