한 구독자가 문의를 보냈습니다. 요약하면 한 문장입니다. "결제는 됐는데 앱에 아무것도 없어요."
이런 티켓을 받으면 손이 자동으로 결제 쪽으로 갑니다. 영수증 검증, 권한 동기화, 웹훅 유실. 저도 거기부터 뒤졌고, 전부 정상이었습니다. 청구도 됐고, 영수증도 유효하고, 구독 상태도 활성이었습니다. 결제는 처음부터 문제가 아니었습니다.
문제는 그 사람이 결제한 앱이 자기 데이터를 하나도 안 물어봤다는 것이었습니다. 온보딩이 통째로 건너뛰어져 있었습니다. 그러니 화면은 당연히 비어 있고, 유료 기능은 계산할 입력이 없었습니다.
먼저 하나 물어보겠습니다. 당신 앱은 "이 사용자가 온보딩을 마쳤다"를 무엇으로 판정합니까? 행이 존재하는지로 판정합니까, 아니면 그 행 안에 실제로 값이 들어 있는지로 판정합니까? 이 글은 그 차이가 유료 구독자 한 명을 어떻게 조용히 태워버렸는지에 대한 기록입니다.
1. 결제 경로는 결백했습니다
순서대로 확인한 것들입니다.
- 청구 성공 — 결제사 쪽 기록 정상
- 영수증 — 유효, 만료 아님
- 구독 권한(entitlement) — 활성
- 웹훅 — 도착해서 처리됨
여기서 멈추면 "고객 착각"으로 티켓을 닫게 됩니다. 저는 대신 그 계정의 데이터 행을 직접 읽었습니다. 그게 이 글의 유일한 방법론입니다. 결제 대시보드가 아니라 사용자 행을 보십시오.
행은 있었습니다. 그런데 비어 있었습니다.
2. 진짜 관측 — 한 명이 아니었습니다
여기서 이 사건의 성격이 바뀝니다. 저는 그 사람만 이상한 건지 확인하려고 최근 가입자들의 프로필 행을 훑었습니다.
가입자 전원의 selected_symptoms가 빈 배열이었습니다.
한 명의 결제 사고가 아니라, 가입한 사람 전부가 온보딩을 못 본 것이었습니다. 티켓을 보낸 사람은 유일한 피해자가 아니라, 유일하게 돈을 내서 그 사실을 알아차린 사람이었습니다.
이 글을 올린 뒤에 규모를 제대로 셌습니다. 숫자는 제 첫 추정보다 나빴습니다.
| 항목 | 값 |
|---|---|
| 총 가입 | 651 |
selected_symptoms가 빈 배열 |
646 (99.2%) |
| 증상 목록을 가진 사람 | 5 |
stage가 트리거 하드코딩 기본값(perimenopause) |
649 |
| 최근 30일 가입 | 303 |
| 기간 | 2026-04-21 ~ 2026-08-14 |
월별로 보면 4월 35명, 5월 91명, 6월 139명, 7월 230명이 가입했고 6월과 7월에 가입한 369명 중 증상 목록을 가진 사람은 0명입니다.
체크인 테이블은 더 조용했습니다. 4개월간 총 16건, 그중 12건은 symptoms = '{}' — 아무것도 기록되지 않은 체크인입니다. 온보딩이 스킵돼 추적할 증상이 없었으니 저장할 것도 없었던 겁니다. 그런데 그 12건에서 sleep_quality는 2·4·5로 바뀌어 있고 태그도 골라져 있습니다. 사람들은 화면을 열고 슬라이더를 움직이고 저장을 눌렀고, 증상은 한 줄도 남지 않았습니다.
이 지점을 강조하고 싶습니다. 지표판은 이 상황에서 아무 경고도 하지 않습니다. 가입 수는 늘고, 행은 생기고, 결제도 되니까요. 같은 종류의 착시를 지표가 0을 가리킨 게 버그였던 사건에서도 겪었습니다. 화면이 초록불이라는 건 아무 증거가 아닙니다.
3. 원인 — 두 조각이 각각 옳은데, 합치면 틀립니다
조각 A: 공유 가입 트리거
이 앱들은 하나의 Supabase 프로젝트에서 auth.users를 공유합니다. 그래서 가입 한 번에 앱별 스키마마다 프로필 행을 만들어주는 공유 트리거가 붙어 있습니다.
create or replace function public.handle_new_user_multiapp()
returns trigger language plpgsql security definer as $$
begin
-- ...
begin
insert into sage.users (id, stage)
values (new.id, 'perimenopause')
on conflict (id) do nothing;
exception when undefined_table or undefined_column or not_null_violation then null;
end;
-- ...
return new;
end;
$$;여기서 stage를 왜 넣었는지 보십시오. 테이블 정의가 이렇게 되어 있습니다.
CREATE TABLE IF NOT EXISTS sage.users (
id uuid PRIMARY KEY REFERENCES auth.users ON DELETE CASCADE,
stage text NOT NULL CHECK (stage IN ('perimenopause','menopause','postmenopause')),
selected_symptoms text[] NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);stage가 NOT NULL이고 CHECK 제약까지 있으니, 트리거가 값을 안 넣으면 insert가 실패합니다. 그래서 과거의 제가 기본값을 하나 골라서 박아 넣었습니다. 이게 함정의 절반입니다. 행은 이제 스키마상 완전히 유효합니다 — stage도 있고, selected_symptoms도 NOT NULL DEFAULT '{}'라 비어 있지 않은 척합니다. 누구도 입력한 적 없는 값들로 채워진, 문법적으로 완벽한 행.
조각 B: 앱의 판정 한 줄
hasProfile = (try? await SupabaseService.shared.fetchUserProfile(id: uid)) != nil} else if !hasProfile {
OnboardingView(onComplete: { hasProfile = true })
} else {
MainTabView()
}!= nil. 행이 있으면 온보딩 끝난 것으로 봅니다.
조각 A가 가입 순간에 행을 만들고, 조각 B가 그 행을 "완료"로 읽습니다. 그래서 온보딩 화면은 단 한 번도 뜨지 않았습니다. 앱은 사용자에게 아무것도 묻지 않고 바로 메인 탭으로 들어갔고, stage는 제가 트리거에 박아둔 값 그대로, selected_symptoms는 빈 배열 그대로 남았습니다.
두 코드 모두 리뷰를 통과했을 겁니다. 트리거는 "가입 시 프로필 보장"이라는 합리적인 일을 하고, 앱은 "프로필 있으면 온보딩 생략"이라는 합리적인 일을 합니다. 틀린 건 둘 사이의 계약입니다. 아무도 "행의 존재"와 "사용자의 의사표시"가 다른 것이라고 써두지 않았습니다.
4. 당신이라면 어디를 고치겠습니까?
해결책을 말하기 전에 던지고 싶습니다. 선택지는 셋입니다.
- 트리거에서 행 생성을 뺀다 — 앱마다 첫 로그인 시 직접 만들게. 대신 다른 앱들이 "행 있음"을 전제로 쓰던 코드가 전부 깨질 위험.
- 앱의 판정을 바꾼다 — 존재가 아니라 내용으로 판정.
selected_symptoms가 비어 있으면 온보딩 미완료. - 행에 명시적 플래그를 둔다 —
onboarding_completed_at timestamptz. 사용자가 실제로 끝냈을 때만 채워짐.
저는 2번을 즉시 적용했습니다. 앱 하나만 배포하면 되고, 이미 빈 상태로 묶여 있던 기존 사용자들까지 다음 실행에서 자동으로 온보딩으로 돌아오기 때문입니다. 3번이 더 정직한 모델이지만, 그건 신규 가입자만 구제하고 이미 갇힌 646명은 못 꺼냅니다 — 플래그가 없는 기존 행을 미완료로 볼지 완료로 볼지가 또 애매해지니까요. 값이 비었는지 보는 판정은 과거 행에도 그대로 소급 적용됩니다.
교훈을 한 줄로 줄이면 이렇습니다. 존재는 완료가 아닙니다. 같은 착각을 다른 층에서도 이미 한 번 했습니다 — 빌드가 끝난 것과 출시된 것은 다르다는 글이 그 얘기입니다.
5. 같은 파헤침에서 딸려 나온 것들
한 계정을 끝까지 추적하면 옆에 있던 것들이 같이 드러납니다.
RevenueCat logIn 누락. 결제 신원과 로그인 신원을 묶어주는 Purchases.logIn 호출이 빠진 앱이 많습니다. 방금 저장소 전체를 세어봤습니다.
Purchases.configure 를 호출하는 파일: 27
Purchases.logIn 을 호출하는 파일: 7configure만 하고 logIn을 안 하면 구독이 익명 앱 사용자 ID에 붙습니다. 기기를 바꾸거나 재설치하면 같은 계정으로 로그인해도 결제가 따라오지 않습니다. 아직 터지지 않은 티켓이 저기 스무 개쯤 깔려 있는 셈입니다.
복원(restore) 폴백. 권한이 안 보일 때 사용자에게 남은 마지막 수단인데, 그 경로가 조용히 실패해도 화면에 아무 말이 없었습니다.
로컬 Edge Function 소스가 배포본보다 낡아 있었습니다. 로컬 파일을 읽고 "코드는 이렇게 돼 있다"고 판단하면 틀립니다. 배포된 것이 진실입니다. 로컬과 원격이 갈라지는 이 함정은 테스트가 프로덕션에 쓰고 있던 사건과 뿌리가 같습니다 — 어느 환경을 보고 있는지 모르는 것.
6. 그리고 제출 자체가 막혔습니다
수정을 만들었으면 올려야 합니다. App Store Connect가 순서대로 막았습니다.
- EULA를 63개 로케일에 각각 넣어야 통과. 하나 빠지면 거절.
- 약관 본문에
.html이 들어간 링크 처리에서 걸림. appInfos를limit=1로 조회하면 엉뚱한 레코드 하나가 잡혀 오탐이 납니다. 목록을 다 받아서 골라야 합니다.- iPad 관련 갱신에서 409 충돌.
API가 200을 줬다고 반영된 게 아니라는 것도 여기서 또 확인했습니다. 저는 이제 쓰기 후 반드시 되읽어 비교합니다. 스토어가 "수정 불가"라고 거짓말한 사건에서 배운 습관입니다.
7. 솔직한 부분
수정하고, 확인하고, 답장을 보냈습니다. 그때 저는 이 건이 닫혔다고 생각했습니다.
틀렸습니다. 몇 시간 뒤에 답장이 왔고, 내용은 환불 요청이었습니다. 온보딩은 고쳐졌지만 그분이 원했던 사용 방식 — 하루 중 일어나는 대로 그때그때 기록하기 — 이 여전히 안 됐기 때문입니다. 그리고 저는 그 두 번째 문의에 **"그건 설계상 하루 한 건이라 바꿀 수 없습니다"**라고 답했습니다. 그것도 버그였습니다. 그 답장이 이 글보다 더 나쁜 실수였고, 별도 글로 썼습니다 → 제가 고객에게 "설계상 그렇습니다"라고 답했고, 그건 데이터 손실이었습니다.
즉 이 글은 "고객을 구했다"는 이야기가 아닙니다. 한 명뿐인 유료 구독자가 환불을 요청한 이야기입니다. 그 사람은 제 앱에서 유일하게 지갑을 연 사람이었고, 제가 처음 준 것은 빈 화면이었습니다. 고친 코드는 다음 사람을 위한 것이고, 이번 사람에게는 늦었습니다.
정정 (2026-08-14 밤). 이 글을 처음 올릴 때 저는 마지막 절에 "회신이 오지 않았고 계정이 삭제됐다"고 썼습니다. 사실이 아닙니다. 그분은 회신했고, 그 회신이 두 번째(더 심각한) 버그를 찾아냈습니다. 제가 받은 가장 중요한 피드백을 저는 "무응답"으로 기록해 두고 있었던 것입니다. 원문을 위와 같이 교체했습니다.
한 가지는 남았습니다. 그 티켓 하나가 646명분의 결함을 찾아냈습니다. 저는 이 교환을 이겼다고 생각하지 않습니다. 지표판은 이 결함을 석 달 넘게 보여주지 않았고, 사람 하나가 화를 내야 보였습니다.
자가진단 — 3개만 확인하십시오
지금 당신 프로젝트에 그대로 대볼 수 있는 것들입니다.
- 온보딩 판정 문장을 찾아 눈으로 읽으십시오.
!= nil,!= null,if row:로 판정하고 있습니까? 그러면 그 행을 당신이 아닌 무언가(트리거·백필·다른 앱)가 만들 수 있는지 확인하십시오. - 최근 가입자 20명의 "사용자가 입력했어야 하는 컬럼"을 조회하십시오. 전부 기본값이면 온보딩이 안 돌고 있는 것입니다. 한 줄 쿼리면 됩니다.
- 결제 SDK의 신원 연결 호출을 grep 하십시오.
configure개수와logIn개수가 다르면, 그 차이가 미래의 "구독이 사라졌어요" 티켓 수입니다.
결론
결제 티켓이 왔을 때 결제를 보는 건 자연스럽지만, 이번 원인은 결제에서 다섯 단계 떨어져 있었습니다. 사용자가 산 것은 기능이고, 기능이 계산할 입력이 없었습니다. 그 입력을 받아야 할 화면은 데이터베이스 트리거가 미리 만들어둔 행 때문에 한 번도 뜨지 않았습니다.
지금 딱 하나만 하십시오. 당신 DB에서 최근 가입자 20명의 온보딩 컬럼을 조회해 보십시오. 전부 {}이거나 전부 기본값이면, 당신도 저처럼 몇 달간 그걸 모르고 있었을 수 있습니다. 결과가 어떻게 나왔는지 알려주시면 좋겠습니다 — 특히 "우리는 괜찮았다"가 아니라 "우리도 비어 있었다"가 나온 경우에요.