"채팅 앱 만들었는데, 새로고침 해야 메시지가 보여요"
Supabase의 꽃, Realtime 기능을 써서 채팅 앱을 만들고 있었습니다.
분명 매뉴얼대로 supabase.channel('room-1').on(...).subscribe()를 했습니다.
const channel = supabase
.channel('room1')
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages' }, (payload) => {
console.log('New message!', payload);
})
.subscribe();
그리고 다른 창에서 메시지를 보냈습니다. DB에는 메시지가 잘 들어갔습니다. 그런데... 제 콘솔은 조용합니다. 아무 일도 안 일어납니다. 새로고침(F5)을 눌러야 메시지가 뜹니다. 이건 실시간이 아니잖아요!
처음엔 뭐가 이해가 안 갔나? (연결은 됐는데?)
네트워크 탭을 확인해보니 Websocket 연결은 성공(101 Switching Protocols)했습니다.
Supabase 대시보드에서 Realtime Inspector를 켜봐도 메시지가 안 잡혔습니다.
"서버가 죽었나? 아니야, 연결은 됐어. 코드가 문제인가?" 오타를 수십 번 확인했지만 틀린 게 없었습니다.
어떤 포인트에서 이해가 됐나? (방송 송출 버튼)
문제는 코드가 아니라 DB 설정에 있었습니다. Supabase(정확히는 Postgres)는 기본적으로 "이 테이블의 변경 사항을 방송(Broadcast)하지 않음"으로 설정되어 있습니다.
왜냐고요? 모든 테이블의 변경 사항을 다 실시간으로 쏘면 서버 부하가 엄청나니까요. 그래서 "이 테이블은 방송에 내보내도 돼"라고 명시적으로 켜줘야 합니다.
비유하자면, 방송국(Supabase)에 카메라(Realtime Server)는 설치되어 있지만, 정작 뉴스 앵커(Table) 마이크가 꺼져(Replication Off) 있었던 겁니다. 앵커가 아무리 떠들어도(INSERT), 방송(Subscriber)에는 소리가 안 나가는 거죠.
해결 과정 - Replication 켜기
이건 코드로 해결하는 게 아니라 Supabase 대시보드에서 해결해야 합니다.
- Supabase Dashboard -> Database -> Replication으로 이동합니다.
messages테이블을 찾습니다.- Source 토글을 켭니다. (Enable Replication)
또는 SQL로 한 방에 켤 수도 있습니다:
-- messages 테이블의 변경 사항을 실시간으로 내보내라
alter table "messages" replica identity full; -- (선택사항)
alter publication supabase_realtime add table "messages";
이걸 켜고 나니, 거짓말처럼 콘솔에 로그가 찍히기 시작했습니다. "와, 마이크 켜는 걸 깜빡했네."
깊이 파고들기 - Postgres WAL와 RLS의 관계
Supabase Realtime은 마법이 아니라 Postgres WAL (Write Ahead Log)를 읽어서 동작합니다. DB에 뭔가 쓰여지면 로그가 남는데, Realtime 서버가 그 로그를 낚아채서 웹소켓으로 쏴주는 겁니다.
1. RLS가 켜져 있다면?
여기서 또 하나의 함정이 있습니다. RLS(Row Level Security)입니다.
만약 messages 테이블에 RLS가 걸려 있고,
"철수는 영희의 메시지를 볼 수 없다"는 정책이 있다면?
Realtime 서버도 이 정책을 따릅니다. 영희가 메시지를 써도, 철수의 구독(Channel)에는 이벤트가 오지 않습니다. Supabase가 알아서 필터링해버립니다.
그래서 "로그에는 없는데 왜 안 와?" 싶으면 RLS 정책(SELECT)을 확인해보세요.
구독자가 볼 수 없는 데이터는 알림도 안 옵니다.
2. Payload가 비어있다면? (Replica Identity)
UPDATE나 DELETE 이벤트를 받았는데, old 데이터(변경 전 데이터)가 비어있는 경우가 있습니다.
Postgres는 성능을 위해 변경된 컬럼만 로그에 남깁니다.
만약 DELETE될 때 "삭제된 행의 ID" 말고 "삭제된 행의 전체 데이터"를 알고 싶다면?
테이블 설정을 바꿔야 합니다.
ALTER TABLE "messages" REPLICA IDENTITY FULL;
이렇게 하면 변경 시 모든 컬럼 데이터를 로그에 남겨줍니다. (대신 DB 성능은 조금 떨어집니다.)
Application: 연결 상태 관리
실제로는 "연결됨/끊김" 상태를 UI에 표시해주는 게 좋습니다.
const channel = supabase.channel('room1')
.on('postgres_changes', ...)
.subscribe((status) => {
if (status === 'SUBSCRIBED') {
console.log('🟢 실시간 연결됨');
setIsConnected(true);
}
if (status === 'CHANNEL_ERROR') {
console.log('🔴 연결 실패');
setIsConnected(false);
}
if (status === 'TIMED_OUT') {
console.log('🟡 시간 초과 (재연결 시도 중)');
}
});
특히 모바일(Flutter/React Native)에서는 앱이 백그라운드로 갔다가 오면 연결이 끊길 수 있으니, 이 상태를 감지해서 사용자에게 "재연결 중..."이라고 알려주는 UX가 필수입니다.
"누가 접속해 있나요?" (Presence) 뜯어보기
Realtime의 또 다른 기능인 Presence를 이용하면 "현재 접속 중인 사용자" 명단을 만들 수 있습니다. DBMS와는 상관없이, 웹소켓 채널에 붙어있는 클라이언트들의 상태를 공유하는 메모리 기반 기능입니다.
const channel = supabase.channel('room-1');
channel
.on('presence', { event: 'sync' }, () => {
const newState = channel.presenceState();
console.log('접속자 명단:', newState);
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
// 나 접속했다고 알림!
await channel.track({ user_id: 'user_123', status: 'online' });
}
});
이걸 응용하면 "○○님이 입력 중입니다..." 같은 기능도 10줄이면 짭니다.
8. Case Study: 마우스를 따라다니는 유령들 (Collaborative Cursors)
Figma 같은 협업 툴을 만들 때 가장 큰 난관은 "다른 사람의 마우스 커서"를 보여주는 겁니다.
초당 60번씩 바뀌는 마우스 좌표를 DB에 INSERT 하면? DB가 1초 만에 폭발할 겁니다.
해결책 - Presence의 Broadcast
Supabase Realtime은 DB를 거치지 않고 사용자끼리만 데이터를 주고받는 Broadcast 모드를 지원합니다.
// 마우스가 움직일 때마다
channel.send({
type: 'broadcast',
event: 'cursor-pos',
payload: { x: 100, y: 200 }
});
이건 휘발성 데이터라 DB에 저장되지 않습니다. 빠르고 가볍습니다. 실시간 게임이나 화이트보드 앱은 이걸로 만듭니다. (Postgres Changes가 아닙니다!)
9. Refactoring Challenge: 끊기면 죽는 앱 (Graceful Reconnection)
문제: 지하철에서 와이파이가 끊겼다가 다시 붙었습니다. Supabase SDK는 자동으로 재연결을 시도하지만, 그 "끊겨있던 시간 동안의 메시지"는 유실됩니다. 사용자는 채팅이 끊긴 줄도 모르고 있다가 대화 맥락을 놓칩니다.
도전:
on(SUBSCRIBED) 이벤트가 발생할 때마다, "마지막으로 받은 메시지 ID 이후의 데이터"를 다시 긁어오세요 (Fetch).
/* 의사 코드 (Pseudo Code) */
let lastReceivedId = 0;
channel.subscribe((status) => {
if (status === 'SUBSCRIBED') {
// 1. 끊기기 전 마지막 데이터 확인
if (lastReceivedId > 0) {
// 2. 그 사이 유실된 데이터 Fetch
const missedMessages = await fetchMessages({ gt: lastReceivedId });
mergeMessages(missedMessages);
}
}
});
channel.on(..., (payload) => {
lastReceivedId = payload.new.id;
updateUI(payload.new);
});
이 패턴이 없으면 진정한 실시간 앱이 아닙니다.
Postgres Publication Model 한 걸음 더
Supabase Realtime의 뒤단에는 postgres_output 플러그인이 있습니다.
이것이 DB의 변경 로그(WAL)를 JSON으로 변환해줍니다.
우리가 대시보드에서 Replication을 켜는 행위는 실제로는 다음 SQL을 실행하는 것입니다.
CREATE PUBLICATION supabase_realtime FOR TABLE messages, users;
주의할 점:
- UPDATE/DELETE 성능:
REPLICA IDENTITY FULL을 켜면 변경 전 데이터를 다 기록하느라 DB 성능이 조금 떨어집니다. 꼭 필요한 경우(예: 삭제 전 이미지 URL을 알아야 파일도 지운다거나)에만 켜세요. - Toast Columns: 아주 긴 텍스트(Text)나 바이너리(Bytea) 데이터는 WAL에 포함되지 않을 수 있습니다. 중요한 데이터는 별도 테이블로 분리하는 게 좋습니다.
11. FAQ: Trigger vs Realtime
Q: DB 트리거(Trigger)로 HTTP 요청을 보내는 거랑 뭐가 다나요? A:
- Trigger: DB 내부에서 동작. 데이터 무결성 보장. 외부 API 호출(
pg_net) 가능하지만 느리고 트랜잭션을 잡고 있음. - Realtime: DB 로그(WAL)를 비동기로 읽어서 쏘는 것. DB 성능에 영향 거의 없음. 클라이언트에게 "알려주는" 용도.
채팅 알림은 Realtime이 맞고, "회원가입 시 환영 이메일 발송"은 Trigger(Edge Functions 호출)가 맞습니다.
한 줄 요약
Supabase Realtime이 안 되면 99%는 'Replication'을 안 켰기 때문이다. 대시보드에서 해당 테이블의 '방송 송출 버튼'을 켜라. 그래도 안 되면 RLS 정책을 의심하라.