
실시간 채팅이 안 돼요 (Realtime 구독 먹통 해결법)
채팅 기능을 만들었는데 DB가 업데이트되어도 프론트엔드는 조용합니다. Supabase Realtime 구독이 먹통일 때 확인해야 할 'Replication' 설정과 RLS 필터링에 대해 정리해봤습니다.

채팅 기능을 만들었는데 DB가 업데이트되어도 프론트엔드는 조용합니다. Supabase Realtime 구독이 먹통일 때 확인해야 할 'Replication' 설정과 RLS 필터링에 대해 정리해봤습니다.
HTTP는 무전기(오버) 방식이지만, 웹소켓은 전화기(여보세요)입니다. 채팅과 주식 차트가 실시간으로 움직이는 기술적 비밀.

개발 중에 코드를 수정했는데 브라우저가 반응이 없나요? 새로고침을 백만 번 하다가 지쳐서 찾아낸 HMR(Hot Module Replacement)의 원리와 고장 원인, 그리고 해결 방법을 '노가다 개발자'의 시선으로 정리했습니다.

TypeScript/JavaScript에서 절대 경로 import 설정이 안 될 때의 원인을 '지도와 택시 기사' 비유로 설명합니다. CJS vs ESM 역사적 배경과 모노레포 설정, 팀 컨벤션까지 총정리.

개발 중 CORS 에러를 프록시로 해결하는 방법과 주의사항을 정리했습니다.

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)에는 소리가 안 나가는 거죠.
이건 코드로 해결하는 게 아니라 Supabase 대시보드에서 해결해야 합니다.
messages 테이블을 찾습니다.또는 SQL로 한 방에 켤 수도 있습니다:
-- messages 테이블의 변경 사항을 실시간으로 내보내라
alter table "messages" replica identity full; -- (선택사항)
alter publication supabase_realtime add table "messages";
이걸 켜고 나니, 거짓말처럼 콘솔에 로그가 찍히기 시작했습니다. "와, 마이크 켜는 걸 깜빡했네."
Supabase Realtime은 마법이 아니라 Postgres WAL (Write Ahead Log)를 읽어서 동작합니다. DB에 뭔가 쓰여지면 로그가 남는데, Realtime 서버가 그 로그를 낚아채서 웹소켓으로 쏴주는 겁니다.
여기서 또 하나의 함정이 있습니다. RLS(Row Level Security)입니다.
만약 messages 테이블에 RLS가 걸려 있고,
"철수는 영희의 메시지를 볼 수 없다"는 정책이 있다면?
Realtime 서버도 이 정책을 따릅니다. 영희가 메시지를 써도, 철수의 구독(Channel)에는 이벤트가 오지 않습니다. Supabase가 알아서 필터링해버립니다.
그래서 "로그에는 없는데 왜 안 와?" 싶으면 RLS 정책(SELECT)을 확인해보세요.
구독자가 볼 수 없는 데이터는 알림도 안 옵니다.
UPDATE나 DELETE 이벤트를 받았는데, old 데이터(변경 전 데이터)가 비어있는 경우가 있습니다.
Postgres는 성능을 위해 변경된 컬럼만 로그에 남깁니다.
만약 DELETE될 때 "삭제된 행의 ID" 말고 "삭제된 행의 전체 데이터"를 알고 싶다면?
테이블 설정을 바꿔야 합니다.
ALTER TABLE "messages" REPLICA IDENTITY FULL;
이렇게 하면 변경 시 모든 컬럼 데이터를 로그에 남겨줍니다. (대신 DB 성능은 조금 떨어집니다.)
실제로는 "연결됨/끊김" 상태를 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가 필수입니다.
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줄이면 짭니다.
Figma 같은 협업 툴을 만들 때 가장 큰 난관은 "다른 사람의 마우스 커서"를 보여주는 겁니다.
초당 60번씩 바뀌는 마우스 좌표를 DB에 INSERT 하면? DB가 1초 만에 폭발할 겁니다.
Supabase Realtime은 DB를 거치지 않고 사용자끼리만 데이터를 주고받는 Broadcast 모드를 지원합니다.
// 마우스가 움직일 때마다
channel.send({
type: 'broadcast',
event: 'cursor-pos',
payload: { x: 100, y: 200 }
});
이건 휘발성 데이터라 DB에 저장되지 않습니다. 빠르고 가볍습니다. 실시간 게임이나 화이트보드 앱은 이걸로 만듭니다. (Postgres Changes가 아닙니다!)
문제: 지하철에서 와이파이가 끊겼다가 다시 붙었습니다. 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);
});
이 패턴이 없으면 진정한 실시간 앱이 아닙니다.
Supabase Realtime의 뒤단에는 postgres_output 플러그인이 있습니다.
이것이 DB의 변경 로그(WAL)를 JSON으로 변환해줍니다.
우리가 대시보드에서 Replication을 켜는 행위는 실제로는 다음 SQL을 실행하는 것입니다.
CREATE PUBLICATION supabase_realtime FOR TABLE messages, users;
주의할 점:
REPLICA IDENTITY FULL을 켜면 변경 전 데이터를 다 기록하느라 DB 성능이 조금 떨어집니다. 꼭 필요한 경우(예: 삭제 전 이미지 URL을 알아야 파일도 지운다거나)에만 켜세요.Q: DB 트리거(Trigger)로 HTTP 요청을 보내는 거랑 뭐가 다나요? A:
pg_net) 가능하지만 느리고 트랜잭션을 잡고 있음.채팅 알림은 Realtime이 맞고, "회원가입 시 환영 이메일 발송"은 Trigger(Edge Functions 호출)가 맞습니다.