연결됐는데 안 온다
SUBSCRIBED.
상태가 그렇게 찍혀 있었다. 구독은 됐다고 한다. 근데 메시지가 없다.
이상한 상황
API로 메시지를 보냈다. DB에는 들어갔다. Supabase 대시보드에서 직접 확인했다. 데이터는 있다.
그런데 앱에 아무것도 안 왔다.
구독 상태는
SUBSCRIBED. 에러도 없다. 그냥 조용하다.
비개발자한테 이게 얼마나 무서운 상황인지 설명하자면 — 에러가 나면 원인이라도 있다. 이건 에러가 없다. 근데 안 된다. 뭐가 문제인지 모르는 채로 Claude Code한테 계속 이것저것 시키다가 다른 것까지 망가지는 시나리오가 머릿속에 그려졌다.
원인은 두 개였다. 그리고 둘 다 예상 밖이었다.
원인 1 — RLS가 자기 자신을 물고 있었다
RLS(Row Level Security)는 Supabase에서 "누가 어떤 데이터를 볼 수 있는지"를 제어하는 정책이다.
users 테이블에 이런 정책이 있었다.
-- 같은 family_id를 가진 사람만 볼 수 있다 USING (family_id = ( SELECT family_id FROM users WHERE id = auth.uid() ))
얼핏 보면 맞는 것 같다. 근데 이게
users 테이블 자신의 정책이다. users를 조회하려면 users를 먼저 봐야 하는데, users를 보려면 users를 조회해야 한다. 무한루프.
API는 service role(RLS 우회)을 쓰니까 멀쩡했다. Realtime은 user JWT로 RLS를 평가하니까 여기서 터진 것이다. 에러 메시지도 없이 조용하게.
해결책은
SECURITY DEFINER 함수를 만드는 것이었다.
CREATE OR REPLACE FUNCTION get_my_family_id() RETURNS uuid LANGUAGE sql SECURITY DEFINER -- RLS를 우회해서 실행 AS $$ SELECT family_id FROM public.users WHERE id = auth.uid(); $$; -- 이제 자기 참조 없음 CREATE POLICY "family_isolation_users" ON users FOR SELECT USING (family_id = get_my_family_id());
get_my_family_id()는 RLS 밖에서 실행되니까 재귀가 끊긴다. 이 함수 하나가 막혀 있던 것들을 전부 뚫었다.
원인 2 — JWT가 늦게 도착했다
두 번째 문제는 타이밍이었다.
Supabase 클라이언트가 처음 연결될 때 WebSocket은 anon key로 먼저 열린다. React Native에서 세션 복구가 비동기라 user JWT가 늦게 온다. 그 사이에 구독이 먼저 열리면 — JWT 없이 구독된 상태가 된다.
SUBSCRIBED는 맞는데 RLS를 통과 못 하는 상태.
해결책은 한 줄이었다.
const session = await supabase.auth.getSession() supabase.realtime.setAuth(session.data.session?.access_token) // 이 다음에 subscribe()
구독 직전에 JWT를 명시적으로 주입한다. 당연히 돼야 할 것 같은데, 안 하면 타이밍 문제로 터진다.
단일 채널 패턴
이 과정에서 또 하나 발견한 것이 있다. 채널 여러 개를 따로
subscribe하면 레이스 컨디션이 생긴다는 것.
같은 이름의 채널을 두 번 열면 Supabase가 기존 채널을 반환한다. 이미
subscribe 된 채널에 .on()을 추가하면 에러. 화면을 나갔다가 다시 들어오면 채널이 꼬인다.
해결책은 하나의 채널에 모든 구독을 체이닝하는 것이다.
const channel = supabase .channel(`room:${roomId}:${Date.now()}`) // 타임스탬프로 이름 충돌 방지 .on('postgres_changes', { event: 'INSERT', table: 'chat_messages' }, onInsert) .on('postgres_changes', { event: 'UPDATE', table: 'chat_messages' }, onUpdate) .on('postgres_changes', { event: 'INSERT', table: 'message_reactions' }, onReaction) .subscribe() // 마지막에 딱 한 번
.subscribe()는 체이닝이 끝난 후 한 번만 호출한다. cleanup도 supabase.removeChannel(channel) 한 번으로 끝.
SUBSCRIBED인데 안 온다는 느낌. 이제 그 이유를 안다.
데이터베이스 정책이 자기 자신을 물고 있었고, JWT가 0.몇 초 늦게 도착하고 있었다. 둘 다 에러 메시지가 없었다. 로그를 읽을 줄 모르는 나는, Claude Code가 진단 쿼리를 짜줄 때까지 원인을 몰랐다.
로그가 없으면 블랙박스다. EP 04에서
pepper_logs를 만든 이유가 다시 실감났다.