
Stamply(가제)는 팝업스토어 현장에서 쓰는 서비스다.
참여자가 QR을 찍고 입장하거나, 미션을 완료하거나, 굿즈를 수령하면 관리자 대시보드의 숫자가 바뀐다.
특히 KPI 카드 4개는 운영자가 가장 먼저 보는 데이터다.

이 숫자들은 현장에서 실시간으로 진행도를 보여주기 위해 사용된다.
반대로 참여자 날짜별 차트, 시간대별 차트, 성별/연령대 분포, 미션별 완료 현황은 시각적으로 통계를 확인하는 데이터라 1분 단위 polling으로도 충분하다고 판단했다.
때문에 기준을 이렇게 잡았다.
KPI 카드 4개 -> Realtime Broadcast 수신 시 refetch
그 외 대시보드 데이터 -> 매 분 00초 기준 polling
처음엔 다 realtime으로 묶는 것을 고려했는데 이전 글의 대시보드 특성을 고려하여 이렇게 정했다.
또 화면 전체가 움직이면 보기 힘들고 어지러울 것 같기도 하고. 너무 근거 없는 느낌인가..?
Supabase Realtime을 쓰는 방법은 크게 두 가지를 고민했다.
결국 안쓰는 Postgres Changes부터 빠르게 알아보자.
Postgres Changes는 Supabase Realtime이 Postgres 테이블의 변경 이벤트를 직접 구독하게 해주는 방식이다.
테이블을 supabase_realtime publication에 등록하고, 클라이언트에서 postgres_changes 이벤트를 구독하면 INSERT, UPDATE, DELETE 같은 변경 payload를 받을 수 있다.
쉽게 말하면 "어떤 테이블에 어떤 row가 추가/수정/삭제됐다"는 변경 데이터를 브라우저가 직접 받는 방식이다.
예를 들어 참여자가 한 명 입장하면 participant_users에 새 row가 생기고, Postgres Changes는 그 row 변경 이벤트를 클라이언트에 보내는 식이다.
작은 테스트나 단순 변경 감지에는 접근이 쉬운 편이지만, 관리자 권한과 payload 노출 범위를 같이 봐야 하는 화면에서는 생각보다 검토할 게 많았다.
participant_users가 INSERT되면 받고, mission_completions가 INSERT되면 받고, 이런 식으로 테이블 변경을 바로 구독하면 된다.
그런데 우리 프로젝트에서는 걸리는 지점이 있었다.
먼저 현재 DB 기준으로 participant_users, mission_completions에는 명시적인 RLS 정책이 없다.
관리자 통계 API도 이 테이블을 브라우저에서 직접 조회하지 않고, Route Handler에서 행사 소유권을 확인한 뒤 service role 서버 클라이언트로 집계한다.
이 구조에서 브라우저가 원천 테이블 변경을 직접 구독하는 건 방향이 맞지 않았다.
또 supabase_realtime publication에 public 테이블이 등록되어 있지 않은 상태라, Postgres Changes를 쓰려면 publication 등록부터 RLS와 payload 노출 범위까지 다시 검토해야 했다.
그래서 최종적으로 Broadcast를 선택했다.
Broadcast는 테이블 row를 그대로 흘려보내는 방식이 아니라, 우리가 정한 topic으로 우리가 정한 payload만 보낼 수 있다.
쉽게 말하면 DB 변경 내용을 그대로 구독하는 것이 아니라, 특정 방에 "이런 일이 있었다"는 메시지를 보내는 방식에 가깝다.
Postgres Changes와 Broadcast의 차이란 다음과 같다.
Postgres Changes
-> 변경된 row 데이터를 직접 받는다
Broadcast
-> 우리가 정한 신호만 받는다
그래서 여러 테이블 변경을 하나의 의미 있는 이벤트로 묶기 좋다.
이번 대시보드에서는 participant_users, mission_completions, missions의 변경이 모두 결국 "KPI를 다시 계산해야 한다"는 의미로 이어진다.
이럴 때는 row payload를 각각 다루는 것보다 dashboard-kpis:{eventId} topic에 invalidate 이벤트 하나를 보내는 편이 더 단순했다.
Broadcast를 선택한 장점도 여기서 나온다.
첫 번째는 보안이다.
브라우저가 participant_users, mission_completions 같은 원천 테이블 row를 직접 받지 않는다.
그냥 dashboard-kpis:{eventId} 채널에서 invalidate 신호만 받는다.
그래서 payload에 참여자 정보, 미션 완료 row, 통계값을 담지 않아도 된다.
두 번째는 기존 조회 구조를 그대로 사용할 수 있다는 점이다.
우리 프로젝트는 이미 관리자 API Route Handler에서 로그인 확인, 행사 소유권 확인, service role 기반 통계 집계를 처리하고 있다.
Broadcast를 신호로만 쓰면 invalidate 신호를 받은 뒤 기존 /dashboard/kpis Route Handler를 다시 호출하면 된다.
즉, Realtime을 붙이기 위해 권한 확인이나 KPI 계산 로직을 프론트에 새로 만들 필요가 없었다.
이번 구현에서는 payload도 최소화했다.
{
"target": "dashboard-kpis"
}
브라우저는 이 신호를 받으면 기존 KPI API를 다시 호출하게 된다.
participant_users(참여 유저) / mission_completions(미션 완료) / missions(미션) 변경
-> DB trigger 실행
-> realtime.send(..., "invalidate", "dashboard-kpis:{eventId}", true)
-> 관리자 브라우저가 private channel에서 invalidate 수신
-> /dashboard/kpis API refetch
-> RPC get_admin_dashboard_kpis로 최신 KPI 계산
먼저 Realtime Channel은 eventId 단위로 나눴다.
dashboard-kpis:{eventId}
예를 들어 eventId가 12라면 topic은 이렇게 된다.
dashboard-kpis:12
이름에 dashboard-kpis를 넣어서 이 채널이 어떤 목적의 채널인지 드러냈고, 뒤에 eventId를 붙여 행사별로 구독 범위를 나눴다.
그리고 두 번째, 이벤트 이름은 invalidate 하나만 사용했다.
이벤트 종류를 participant_insert, mission_completed, reward_claimed처럼 쪼갤 수도 있었지만 KPI를 다시 가져오기만 하면 되기 때문에 하나만 설정했다.
create or replace function private.broadcast_admin_dashboard_kpi_update()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
declare
v_event_id integer;
begin
if tg_op = 'DELETE' then
v_event_id := old.events_id;
else
v_event_id := new.events_id;
end if;
if v_event_id is null then
return null;
end if;
perform realtime.send(
jsonb_build_object('target', 'dashboard-kpis'),
'invalidate',
'dashboard-kpis:' || v_event_id::text,
true
);
return null;
end;
$$;
여기서 마지막 인자인 true가 private Broadcast를 의미한다.
즉, 클라이언트도 private channel로 구독해야 한다.
그래야 Supabase가 realtime.messages RLS 정책으로 이 사용자가 해당 topic을 구독해도 되는지 검사하기 때문이다.
우리처럼 dashboard-kpis:{eventId} topic을 행사 소유 관리자에게만 열어야 하는 경우에는 이 권한 검사가 필요하다.
trigger는 KPI에 영향을 주는 변경에만 붙였다.
participant_users INSERT
-> 신규 참여자 입장
participant_users UPDATE OF is_reward_claimed
-> 굿즈 수령 상태 변경
mission_completions INSERT
-> 미션 완료
missions INSERT
-> 활성 미션 추가
missions UPDATE OF is_active
-> 활성 미션 수 변경
missions DELETE
-> 활성 미션 삭제
// hooks/useDashboardKpiBroadcast.ts
// 1. 브라우저 Supabase client를 만든다.
const supabase = createBrowserSupabaseClient();
const topic = `dashboard-kpis:${eventId}`;
// 2. 현재 세션의 access token을 Realtime에 설정한다.
const {
data: { session },
} = await supabase.auth.getSession(); // 세션을 받아서~
if (session?.access_token) { // 세션 안의 엑세스 토큰을~
await supabase.realtime.setAuth(session.access_token); // realtime에 설정(setAuth)한다!
} else {
await supabase.realtime.setAuth();
}
// 3. dashboard-kpis:{eventId} private channel을 구독한다.
channel = supabase
.channel(topic, { config: { private: true } })
.on("broadcast", { event: "invalidate" }, () => {
onInvalidate();
});
channel.subscribe();
그리고 cleanup에서는 channel을 제거한다.
if (channel) {
void supabase.removeChannel(channel);
}
Realtime은 연결이 계속 살아있는 구조라서 구독 해제를 빼먹으면 화면을 이동한 뒤에도 불필요한 구독이 남을 수 있다.
KPI query의 refetch만 Broadcast에 연결한다.
// components/admin/dashboard/DashboardClient.tsx
const { data: kpisData, refetch: refetchKpisQuery } = kpisQuery;
const refetchKpis = useCallback(() => {
if (isValidEventId(eventId)) {
void refetchKpisQuery();
}
}, [eventId, refetchKpisQuery]);
useDashboardKpiBroadcast({
eventId,
enabled: isEventIdValid,
onInvalidate: refetchKpis,
});
이 부분이 이번 구조의 핵심이다.
Broadcast를 받았다고 해서 화면 값을 직접 +1 하지 않는다. 아까 말했듯이 값이 오는게 아닌 신호가 오는거기 때문이다.
대신 /api/v1/admin/events/{eventId}/dashboard/kpis를 다시 요청한다.
이 뒤는 뭐.. 그냥 RPC 만들어서 요청하는 내용이다.
Realtime을 붙였지만 polling을 완전히 없애지는 않았다.
네트워크 문제, Realtime 연결 실패, 이벤트 누락은 언제든 생길 수 있기 때문에 fallback polling을 같이 뒀다.(좋은 조언이 있었다 ㅎ..)
DashboardClient에는 useAlignedMinuteRefetch가 있고, 이 훅은 매 분 00초에 전체 대시보드 데이터를 다시 가져온다.
12:34:50에 진입
-> 10초 뒤 12:35:00 첫 refetch
-> 이후 12:36:00, 12:37:00 ... 반복
그냥 setInterval(60000)을 바로 거는 방식이 아니라 분 단위 기준에 맞춘 이유는 운영 화면에서 요청 타이밍을 예측하기 쉽게 만들기 위해서다.
결과적으로 갱신 전략은 이렇게 됐다.
KPI
-> Broadcast 수신 시 즉시 refetch
-> 매 분 00초 fallback refetch
참여자 분석 / 달성자 통계 / 미션 현황
-> 매 분 00초 polling
사실 participant_users INSERT가 왔을 때 참여자 수를 프론트에서 바로 +1 할 수도 있다.
mission_completions INSERT가 오면 미션 완료 수도 올릴 수 있다.
그런데 그렇게 하지 않은 이유는 KPI 계산이 생각보다 단순하지 않기 때문이었다..
예를 들어 미션 진행 중은 단순히 미션 완료 row 수를 세는 게 아니다.
오늘 참여자 중에서 미션을 1개 이상 완료했지만 전체 미션 완료자는 아닌 사람을 계산해야 한다.
미션 전체 완료도 활성 미션 수를 기준으로 판단해야 한다.
굿즈 수령 완료는 전체 미션 완료자를 분모로 본다.
이 로직을 프론트에도 복사하면, DB RPC와 프론트 계산이 서로 어긋날 가능성이 생긴다.
그래서 Realtime payload는 최대한 작게 두고, 계산은 항상 서버 API를 통해 다시 가져오게 했다.
private Broadcast를 쓰려면 클라이언트에서 config: { private: true }만 넣는다고 끝이 아니다.
Supabase Realtime Settings와 realtime.messages RLS 정책도 같이 맞춰야 한다.
Supabase Dashboard에서 먼저 아래 설정을 바꿨다.
Realtime > Settings
Allow public access 비활성화
기본은 활성화 상태였지만, 관리자 대시보드에서는 비활성화로 전환했다.

관리자 대시보드 KPI는 dashboard-kpis:{eventId} 형태의 Broadcast 채널로 갱신 신호를 받는다.
예를 들어 eventId가 12라면 topic은 dashboard-kpis:12가 된다.
이 채널은 해당 행사의 소유 관리자만 구독할 수 있어야 한다.
Allow public access를 비활성화하고 private channel을 사용하면, Supabase가 realtime.messages의 RLS 정책을 통해 채널 접근 권한을 검사한다.
즉, dashboard-kpis:12 채널은 eventId = 12 행사를 소유한 관리자만 구독할 수 있게 제한할 수 있다.
반대로 이 설정을 열어두면 다른 사용자가 topic 이름을 추측해 특정 행사 채널의 갱신 신호를 받을 가능성이 생긴다.
물론 우리는 Broadcast payload에 row 데이터나 통계값을 담지 않기는 하지만 특정 행사의 운영 활동이 발생하고 있다는 사실 자체도 관리자용 정보에 가깝다.
그래서 public access는 끄고, private channel과 realtime.messages RLS 정책으로 접근을 제한하는 쪽을 선택했다.
클라이언트도 이 설정에 맞춰 private channel로 붙어야 한다.
const channel = supabase.channel(`dashboard-kpis:${eventId}`, {
config: { private: true },
});
공식 문서 기준으로도 private channel은 Realtime Authorization, 즉 realtime.messages RLS 정책으로 접근을 제어한다.
우리 프로젝트에서는 브라우저가 Broadcast를 직접 보내지 않는다.
클라이언트는 받기만 하고, 발행은 DB trigger에서 한다.
그래서 우선 필요한 정책은 SELECT 정책이다.
SQL Editor에서 아래 정책을 추가하면 된다.
create policy "admin can receive own dashboard kpi broadcasts"
on realtime.messages
for select
to authenticated
using (
realtime.messages.extension = 'broadcast'
and split_part((select realtime.topic()), ':', 1) = 'dashboard-kpis'
and exists (
select 1
from public.events e
where e.id = case
when split_part((select realtime.topic()), ':', 2) ~ '^[0-9]+$'
then split_part((select realtime.topic()), ':', 2)::integer
else null
end
and e.user_id = (select auth.uid())
)
);
이 정책의 의미는 이렇다.
dashboard-kpis:{eventId} 형태일 때만 허용한다.완료 쿼리 결과는 다음과 같다.

INSERT 정책을 추가하지 않았는데, 브라우저가 Broadcast를 보내지 않게 하는 편이 더 안전하기 때문이다.
Broadcast는 서버/API 또는 DB trigger에서 private broadcast로 보내는 방식이 좋다고 봤다.
payload는 최소화한다.
예를 들면 이런 정도면 충분하다.
{
"eventId": 12,
"reason": "kpi_changed"
}
그리고 실제 구현에서는 이보다 더 줄여서 target만 보냈다.
perform realtime.send(
jsonb_build_object('target', 'dashboard-kpis'),
'invalidate',
'dashboard-kpis:' || v_event_id::text,
true
);
row 데이터는 보내지 않고, 프론트는 이 신호를 받으면 /dashboard/kpis를 refetch한다.
이 흐름에 맞춰 수동 검증 항목도 남겨뒀다.
participant_users, mission_completions, missions 변경 시 KPI만 refetch되는지 확인이번 프로젝트에서 Supabase Realtime Channel은 "실시간 데이터 운반"보다 "실시간 invalidation 신호"에 가깝게 사용했다.
장점은 대략 다음과 같다.
돌아는 가는데 어우 어려워