앱 20여 개가 Supabase 프로젝트 하나를 스키마로 나눠 씁니다. plotta.users, zone2.users, sage.users, moodbite.profiles 같은 식입니다. 앱별로 "28일 신규 가입자 몇 명, 그중 실제로 쓴 사람 몇 명"을 세서 어느 앱에 시간을 쓸지 판단해 왔습니다.
2026-08-10에 앱별 활동을 한 표로 뽑다가 숫자가 이상했습니다.
pulsCalm.profiles 28일 신규 264 7일 76
petpass.profiles 28일 신규 264 7일 76
sage.users 28일 신규 264 7일 76
bloatless.profiles 28일 신규 264 7일 76
moodbite.profiles 28일 신규 264 7일 76
plotta.users 28일 신규 264 7일 76
innra.profiles 28일 신규 264 7일 76
public.profiles 28일 신규 264 7일 76
zone2.users 28일 신규 264 7일 76
paxio.profiles 28일 신규 264 7일 76전부 264입니다. 그리고 auth.users의 28일 신규도 264입니다.
여기서 한번 여쭙겠습니다. 당신의 앱별 지표는 앱별로 다른 값이 나온다는 것을 누가 보증합니까? 저는 그걸 확인해 본 적이 없었습니다.
트리거 하나가 모든 앱에 프로필을 만들고 있었다
auth.users에 트리거가 걸려 있었습니다.
CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION handle_new_user_multiapp();이름 그대로입니다. 어느 앱에서 누가 가입하든 모든 앱 스키마에 프로필 행이 하나씩 생깁니다. 앱 A의 신규 유저가 앱 B~T의 "신규 유저"로도 동시에 집계됩니다. 앱별 사용자 수를 이 테이블로 세면 언제나 플릿 전체 가입자를 세는 것입니다.
반전은 숫자가 틀렸다는 것 자체가 아닙니다. 그 숫자로 이미 판단을 내려놨다는 것입니다.
엿새 전에 이렇게 기록해 뒀습니다. "지도 앱의 28일 신규 248명 중 걷기를 한 번이라도 한 사람은 29명(11.7%) — 신규의 88%가 한 번도 걷지 않고 사라진다." 그리고 이 88%를 근거로 "지불 의사 앞 항에서 88%가 새므로 수익화를 붙이는 건 무의미하다"는 결론까지 냈습니다. 결론의 방향은 살아남았지만 분모가 그 앱의 가입자가 아니라 플릿 전체 가입자였습니다. 이탈률의 크기는 재산정해야 합니다. 같은 종류의 재산정을 한 번 더 겪은 적이 있습니다 → 3배 부풀려진 숫자로 두 달을 판단했다.
행위 쪽 숫자는 이렇게 나옵니다.
걷기를 한 번이라도 한 사람 누적 54명
최근 28일 걷기 사용자 40명
최근 7일 18명54명이라는 숫자가 나오고 나서야 이 앱이 어떤 규모인지가 정확해졌습니다.
왜 몇 달을 못 알아챘나
총합은 스키마마다 다릅니다(618 / 596 / 550 / 530…). 트리거가 도중에 추가됐거나 일부 행이 지워진 흔적입니다. 그래서 총합만 보면 스키마별로 값이 달라서 팬아웃이 안 보입니다. 최근 창으로 잘라야 전부 같은 값으로 드러납니다.
당신이라면 어느 쪽을 보시겠습니까? 누적 총합입니까, 아니면 최근 28일입니까? 저는 몇 달 동안 총합만 보고 있었고, 그게 팬아웃을 가려주고 있었습니다.
의심하는 방법
총합이 아니라 최근 창으로 자릅니다.
select t.table_schema||'.'||t.table_name,
(xpath('/row/c/text()', query_to_xml(
format('select count(*) c from %I.%I where created_at > now() - interval ''28 days''',
t.table_schema, t.table_name), false, true, '')))[1]::text::bigint
from information_schema.tables t
where t.table_name ~ 'user|profile' and t.table_type='BASE TABLE';같은 값이 줄줄이 나오면 트리거를 봅니다.
select tgname, pg_get_triggerdef(t.oid)
from pg_trigger t join pg_class c on c.oid=t.tgrelid
join pg_namespace n on n.oid=c.relnamespace
where n.nspname='auth' and c.relname='users' and not t.tgisinternal;대신 쓸 기준 — 프로필이 아니라 행위
행위자 컬럼과 시각 컬럼을 동시에 가진 테이블만 자동으로 찾아 스키마별 distinct 행위자를 셉니다. 프로필/유저 테이블, ai_cache, *_counters, cron 산출물, 그리고 서버가 사용자 대신 만들어주는 insight/report/summary 류는 이름으로 제외합니다. 서버가 사용자 몫으로 쓴 행은 그 사람이 그 시각에 앱을 열었다는 증거가 아닙니다.
결과(28일 distinct 행위자):
미출시 앱 115 (전부 QA/시드) · 지도 앱 43 · 가사 분석 14 · 심박 앱 8
증상 기록 5 · 장 건강 4 · PCOS 4 · 피부 4
그리고 세 개 앱은 90일 내내 0세 개는 90일 동안 서버에 흔적을 남긴 사람이 아무도 없습니다.
한 가지 더 — 이 표에 아예 없는 앱들이 있습니다. 서버 스키마가 없는 로컬 전용 앱입니다. 그건 0이 아니라 "모른다"입니다. 도구가 그 문장을 직접 출력하게 만들었습니다. 0과 미측정을 같은 칸에 두면 다음 사람이 또 틀립니다.
지금 바로 점검할 3가지
- 앱별(혹은 테넌트별) 사용자 수를 최근 28일로 잘라 나란히 찍어 보세요. 같은 값이 두 줄 이상이면 거기서 멈추십시오.
auth.users(또는 그에 상응하는 공용 계정 테이블)에 걸린 트리거를 전부 나열해 보세요. 이름에multiapp·all·fanout이 들어 있으면 지표 쪽을 의심하세요.- 지금 "활성 사용자"로 세는 테이블에 서버가 쓴 행이 섞여 있습니까? 사람이 아니라 cron이 만든 행이면 그건 활성이 아닙니다.
솔직한 부분
앱별 지표를 이 테이블로 센 게 몇 달입니다. 그 사이 "이 앱은 유저가 좀 있다"는 감각 자체가 오염돼 있었습니다. 잘못된 판단이 하나 있었다는 건 확인했지만, 다른 판단들도 같은 분모를 썼는지는 전수 확인하지 않았습니다.
그리고 팬아웃 자체는 잘못이 아닙니다. 계정이 공용이니 어느 앱에 들어와도 프로필이 준비돼 있는 게 맞습니다. 잘못은 그걸 사용자 지표로 읽은 쪽입니다. 그래서 고친 것도 도구뿐이고, 트리거는 그대로 뒀습니다.
이 분모 오염을 걷어낸 뒤 앱별로 다시 센 매출·설치·평점은 113일 42개 앱 원장에 있습니다.
당신의 대시보드에서 앱별로 나란히 놓인 숫자 하나를 골라 보세요. 그 숫자들이 서로 다르다는 것을 마지막으로 확인한 게 언제입니까?