프론트엔드는 지난번에 다 만들어놨다. 화면 17개가 다 돌아가긴 하는데 전부 목데이터라 앱을 껐다 켜면 아무것도 안 남는다. 이제 백엔드를 붙일 차례라서 오늘은 종일 그것만 했다.
시작하기 전에 결정해야 할 게 몇 개 있어서 먼저 조사부터 했다.
결제는 인앱결제만 쓰기로 했다. 원래 프로토타입에는 토스페이 → 기기 지갑 → PayPal → 카드 순서로 결제 수단을 고르는 화면이 있었다. 한국은 2021년 인앱결제강제금지법 때문에 외부결제(아웃링크)가 합법이라 넣을 수 있긴 한데, 찾아보니 애플이랑 구글이 외부결제에도 26% 수수료를 매기고 있었다. 인앱결제가 15~30%인 걸 생각하면 절감 효과가 거의 없는데 PG 연동이랑 정산 인프라만 더 필요해진다. 1인 개발로는 안 맞아서 결제수단 선택 화면 자체를 없앴다.
성경은 개역한글판으로 정했다. 대한성서공회에 확인해보니 개역한글판은 저작재산권 보호기간 50년이 지나서 로열티 없이 쓸 수 있다. 개역개정판은 협의랑 매출 로열티가 필요하다. 문체가 좀 예스럽긴 한데 초기엔 이게 맞다고 봤다.
백엔드는 Supabase. Firebase는 다운로드마다 과금인데, 이 앱은 홈 피드에서 같은 이미지를 계속 불러오는 구조라 트래픽 비용이 저장 비용보다 커질 것 같았다. Supabase는 정액제고 Postgres라 "구매자만 원본 접근" 같은 규칙을 RLS로 짜기 편하다.
테이블 12개 만들었다. users, products, purchases, applied, favorites, follows, notifications, notices, payouts, reports, blocks, verse_blocks.
RLS(행 단위 보안)를 처음 써봤는데 이게 생각보다 강력하다. 예를 들어 차단 기능은 이렇게 짰다.
create policy "products read published not blocked" on public.products for select using (
status = 'published'
and not exists (
select 1 from public.blocks
where blocker_id = auth.uid() and blocked_id = seller_id
)
);
이러면 앱 코드에서 차단 필터를 안 짜도 DB가 알아서 걸러준다. 클라이언트가 뭘 하든 차단한 작가 작품은 안 나온다.
피드를 실데이터로 바꾸면서 구조를 정리했다. 원래 Hilt를 쓰려고 했는데 Hilt는 Android 전용이라 Flutter에선 못 쓴다. 이미 Riverpod을 쓰고 있었고 Riverpod 자체가 DI 컨테이너 역할을 해서 그대로 갔다.
domain/ — 엔티티랑 리포지토리 인터페이스. 어떤 DB도 모른다.data/ — Supabase 구현체screens/ — 화면이렇게 나눠놓으니까 테스트에서 가짜 리포지토리를 끼워넣을 수 있어서 Supabase 없이 화면 검증이 된다. 실제로 피드 테스트 3개를 그렇게 짰다.
홈 피드가 Supabase 실데이터로 뜬 화면
— 목데이터일 때랑 그림은 비슷한데 카드 색이 바뀐 게 실데이터 증거다(UUID 기반 그라디언트).
여기부터가 진짜 오늘 한 일이다. 붙이는 것보다 터진 걸 고치는 데 시간이 훨씬 많이 갔다.
테스트 계정을 서버에서 지우고 앱을 켰더니 프로필 입력 화면에서 못 빠져나왔다. 처음엔 예외 처리를 안 해서 그런 줄 알았는데 아니었다.
계정이 지워지면 users 행도 같이 사라지는데, 로컬에 저장된 토큰은 아직 유효하다. 그래서 쿼리는 성공하고 결과만 비어서 온다. 예외가 안 난다. 그러면 닉네임이 기본값으로 남고, 앱은 "아직 닉네임 안 정한 신규 가입자"로 판단해서 프로필 화면으로 계속 돌려보낸다.
예외 경로만 막았으면 이 버그는 그대로 남았을 거다. 프로필 행이 아예 없으면 강제 로그아웃하도록 고쳤다.
고치면서 보니 로그아웃할 때 즐겨찾기랑 읽은 알림을 안 지우고 있었다. A가 로그아웃하고 B가 로그인하면 A의 즐겨찾기가 그대로 보이는 상태였다. 같이 고쳤다.
유료 결제를 붙이는데 이게 제일 오래 걸렸다.
먼저 .storekit 설정 파일이 Xcode 프로젝트에 등록이 안 돼 있어서, 스킴에 경로만 적어놨더니 Xcode가 그걸 무시하고 실제 App Store에 상품을 조회하고 있었다. 로그에 countryCode: "USA"랑 Parsing 0 products가 찍혀서 알았다.
그걸 고쳤더니 이번엔 결제 후에 앱이 죽었다.
Unhandled Exception: Null check operator used on a null value
#0 InAppPurchaseStoreKitPlatform.completePurchase
패키지 소스를 열어보니 이랬다.
// 거래를 만들 때
purchaseID: id > 0 ? id.toString() : null,
// 거래를 닫을 때
return SK2Transaction.finish(int.parse(purchase.purchaseID!));
로컬 테스트 환경에서 transaction id를 0으로 주는데, 패키지가 그걸 null로 바꿔놓고 정작 닫을 때는 !로 강제 참조한다. 내 코드 문제가 아니라 패키지랑 로컬 테스트 조합의 버그였다. StoreKit1으로 바꿔서 우회했다. SK1은 transaction id를 제대로 채워준다.
거래가 안 닫히니까 다음 결제도 storekit_duplicate_product_object로 막혀서, 결제 시작 전에 미완료 거래를 정리하는 것도 넣었다.
드디어 뜬 StoreKit 결제 시트
— 상단에 "Xcode", 하단에 "For testing purposes only"가 찍힌 로컬 테스트 시트다. 상품도Wallpaper (tier 1500)으로 제대로 조회됐다.
screenshots/03-storekit-sheet.png
결제 성공 — "You're all set. [Environment: Xcode]"
screenshots/04-purchase-done.png
결제가 드디어 성공해서 서버 기록을 확인했는데, 거래 식별자가 "0" 이었다.
{"price_paid": 1200, "iap_transaction_id": "0"}
StoreKit1도 로컬 테스트에서는 0을 준다. 내 코드는 "비어있지 않으면 그대로 쓴다"라서 통과시켰는데, "0"은 고유하지 않다. 서버는 같은 거래 식별자가 이미 있으면 "이미 처리한 거래"로 판단하고 넘어간다. 즉 세 번째 작품을 사면 결제는 되는데 소장 목록에는 안 들어간다.
지금 두 건이 무사히 들어간 건 첫 건이 우연히 다른 형식이었기 때문이다. 운이 좋았다.
클라이언트는 쓸 수 없는 값이면 영수증 해시를 식별자로 쓰게 하고, 서버도 "0"이나 빈 값은 400으로 거부하게 했다. 양쪽에서 막았다.
서버 검증은 7가지 경우를 다 테스트했다.
| 시나리오 | 결과 |
|---|---|
| 유료 작품을 무료 획득 경로로 | 403 차단 |
| 1000원 티어로 1500원 작품 받기 | 400 차단 |
| 타인의 영수증 도용 | 409 차단 |
| 인증 없이 호출 | 401 차단 |
| 정상 결제 | 200 |
이건 버그는 아니고 설계 문제였다. 작가가 계속 새 작품을 올리는데 인앱결제 상품은 스토어에 미리 등록해야 한다. 작품마다 상품을 만들 수가 없다.
가격 티어별로 상품을 하나씩만 만들고(atelier316.wallpaper.1000/1200/1500), 어떤 작품을 샀는지는 서버가 기록하는 방식으로 풀었다.
작가가 올린 사진에 말씀을 얹어서 게시하는 부분. 캔버스를 RepaintBoundary로 캡처해서 4장을 만든다. 말씀 O/X × 원본/미리보기.
순서가 중요했다. 스토리지 권한 정책이 products 행을 보고 판단해서, 행을 먼저 만들고(hidden) → 파일 업로드 → 공개(published) 순으로 해야 한다. 중간에 실패하면 행을 지워서 되돌린다.
원래 계획엔 미리보기를 서버(Edge Function)에서 만들기로 돼 있었는데 안 만들었다. 보안 목적인 "미구매자가 원본을 못 받게"는 버킷 권한으로 이미 달성되고, 미리보기를 누가 만드는지는 부차적이다. Deno에서 이미지 합성 돌리는 비용 대비 이득이 없어서 클라이언트에서 만든다.
접근 제어는 4가지를 다 확인했다.
| 누가 | 원본 | 미리보기 |
|---|---|---|
| 익명 | 400 차단 | 200 |
| 로그인했지만 미구매자 | 400 차단 | 200 |
| 구매자 | 200 | 200 |
게시하고 보니 미리보기 화질이 너무 떨어졌다. pixelRatio: 1로 구워서 캔버스 논리 크기(약 362×644) 그대로였는데, Retina에서 3배로 늘려 표시되니 뭉개진 거다.
배율만 올리면 PNG라 파일이 2MB 넘게 커진다. WebP로 하고 싶었는데 Dart image 패키지에는 WebP 인코더가 없다(디코더만 있다).
그런데 찾아보니 Supabase Storage가 서버에서 이미지 변환을 해준다. 테스트해봤다.
| 요청 | 크기 | 포맷 |
|---|---|---|
| 원본 | 368 KB | PNG |
width=1200 + Accept: image/webp | 23 KB | WebP |
전송량 94% 감소인데 해상도는 오히려 더 높다. 화질 문제랑 용량 문제가 동시에 해결됐다.
구매자가 받는 원본은 고품질 JPEG(q95)로 바꿨다. PNG 4MB에서 1MB로 줄었는데 사진이라 육안으로는 차이가 거의 없다.
주의할 게 하나 있는데, Accept: image/webp 헤더가 빠지면 서버가 변환 없이 원본 포맷을 그대로 내려보낸다. 나중에 이미지 로딩 코드를 건드릴 때 이 헤더가 사라지면 조용히 전송량만 40배 늘어난다.
상세 화면 전체 — PREVIEW 워터마크가 반복해서 찍혀 있다
알림은 DB 트리거가 만들게 했다. 클라이언트가 만들면 남의 알림을 위조할 수 있으니까.
hidden → published로 바뀌는 순간에만 구독자에게. 게시 후 수정할 때 또 보내면 안 되니까.만들고 나서 앱에서 보니 문구가 이상했다.
여호수아 1:9 · 님이 새 배경화면을 게시했어요
작가 이름이 들어갈 자리에 구절명이 들어갔다. payload에 seller_id만 있고 닉네임이 없어서 앱이 이름을 못 채운 거였다. 알림 만들 때 닉네임도 같이 저장하게 고쳤다.
정산은 pg_cron으로 매월 10일에 자동 실행된다. 지난달 유료 구매를 판매자별로 합산하고 3.3%를 원천징수한다. 같은 기간을 두 번 돌려도 중복 정산이 안 되게 unique 인덱스를 걸었다.
정산을 다 만들고 나서 문득 이상했다. 이 앱은 15개 언어를 지원하는 글로벌 마켓플레이스인데, 나는 계속 한국 기준으로만 생각하고 있었다. 3.3% 원천징수, 국내 계좌이체, 통신판매업 신고... 판매자가 미국에 있으면 이게 다 성립을 안 한다.
다행히 인앱결제를 쓰기로 한 덕분에 많은 게 이미 해결돼 있었다. 전 세계 소비자 결제, 통화 환산, 각국 부가세는 애플이랑 구글이 처리한다. 진짜 남는 문제는 운영자 → 판매자 정산 한 곳뿐이었다.
거주국으로 갈라서 처리하게 바꿨다.
| 거주국 | 원천징수 | 지급 수단 |
|---|---|---|
| 한국 | 3.3% | 국내 계좌 |
| 그 외 | 없음 (전액 지급) | PayPal · Payoneer |
해외 거주자는 국내원천소득이 아니라 한국에서 원천징수할 의무가 없다. 현지 신고는 판매자가 직접 한다. 글로벌 플랫폼들이 대부분 이렇게 한다.
정산 정보 화면 — 거주국이 대한민국일 때
— 세금 안내가 "3.3% 원천징수", 지급 수단은 국내 계좌, 입력란은 은행·계좌번호·예금주
같은 화면 — 거주국을 미국으로 바꾸면
— 세금 안내가 "원천징수 없이 전액 지급"으로, 지급 수단은 PayPal·Payoneer로, 입력란도 PayPal 이메일로 바뀐다.
여기서 또 하나 걸린 게 있었다. 계좌 정보를 저장하려고 보니 users 테이블이 전체 공개 읽기였다. 그대로 두면 계좌번호가 누구에게나 노출된다. 본인만 읽게 바꿨더니 이번엔 피드의 작가명이 전부 null로 깨졌다. 공개 프로필용 뷰(public_profiles)를 따로 만들어서 해결했다.
판매자 온보딩 화면을 만들고 나서 생각해보니, 거주국을 판매자가 직접 고르는 거라 한국 거주자가 미국을 선택하면 3.3%를 피할 수 있다.
등록할 때 서류를 요구하면 진입 장벽만 커진다. 그런데 생각해보면 돈이 나갈 때만 막으면 된다. 정산 금액이 0원인 판매자의 거주국이 틀려도 아무 일도 안 일어난다.
그리고 지급할 때쯤이면 검증할 재료가 이미 있다. PayPal이나 Payoneer 계정은 개설 국가가 고정돼 있고 그쪽에서 KYC를 이미 끝냈다. 지급 API가 수취인 국가를 돌려주니까 자기신고랑 대조하면 된다. 우리가 신분증을 받을 필요가 없다.
그래서 안전망만 지금 만들어뒀다.
hold 상태로 잡힌다payouts_due 뷰에는 검증된 것만 나온다. 운영자는 이 목록만 보고 이체하면 된다실제로 테스트해보니 미검증 상태에선 지급 대상이 0건, 검증 후엔 1건, 계좌를 바꾸니 다시 검증이 풀렸다.
출시 전에 꼭 해야 하는 것은 영수증 서명 검증이다. 지금은 가격이랑 티어, 중복만 확인하고 있어서 조작된 클라이언트가 가짜 영수증으로 유료 작품을 받아갈 수 있다. 실제 스토어 계정이 있어야 구현이랑 테스트가 가능해서 남겨뒀다.
그 외에 남은 것들.
오늘은 여기까지.