블로그로 돌아가기
SupabaseRealtimePostgreSQL디버깅

Supabase Realtime 삽질기

postgres_changes가 안 되는 이유를 찾다가 RLS, Publication 설정까지 - 실시간 킬피드 구현 과정

Softopia
2024년 11월 18일
4 min read

시작: "실시간 되겠지 뭐"

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은 "마법"이 아니다. 실제로 쓰려면:

  1. 테이블별 Publication 활성화 - 기본이 꺼져있음
  2. RLS 정책 확인 - SELECT 권한 없으면 구독도 안 됨
  3. 채널 cleanup 필수 - 특히 React에서
  4. 연결 상태 모니터링 - status 콜백 활용
  5. filter로 효율적 구독 - 클라이언트 필터링은 낭비

문서만 보면 5줄이지만, 프로덕션에서 쓰려면 설정과 예외 처리가 더 필요하다.


다음 편: 킬피드 실시간 연동 - Oxide 플러그인에서 Supabase까지