Supabase 연동과 결제 시스템 구축

노영택·2026년 8월 12일

아틀리에316

목록 보기
2/28
post-thumbnail

Supabase 연동과 결제 붙이기

프론트엔드는 지난번에 다 만들어놨다. 화면 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 기반 그라디언트).


오늘 만난 문제들

여기부터가 진짜 오늘 한 일이다. 붙이는 것보다 터진 걸 고치는 데 시간이 훨씬 많이 갔다.

1. 계정을 지웠더니 앱에 갇혔다

테스트 계정을 서버에서 지우고 앱을 켰더니 프로필 입력 화면에서 못 빠져나왔다. 처음엔 예외 처리를 안 해서 그런 줄 알았는데 아니었다.

계정이 지워지면 users 행도 같이 사라지는데, 로컬에 저장된 토큰은 아직 유효하다. 그래서 쿼리는 성공하고 결과만 비어서 온다. 예외가 안 난다. 그러면 닉네임이 기본값으로 남고, 앱은 "아직 닉네임 안 정한 신규 가입자"로 판단해서 프로필 화면으로 계속 돌려보낸다.

예외 경로만 막았으면 이 버그는 그대로 남았을 거다. 프로필 행이 아예 없으면 강제 로그아웃하도록 고쳤다.

고치면서 보니 로그아웃할 때 즐겨찾기랑 읽은 알림을 안 지우고 있었다. A가 로그아웃하고 B가 로그인하면 A의 즐겨찾기가 그대로 보이는 상태였다. 같이 고쳤다.

2. StoreKit이 계속 크래시났다

유료 결제를 붙이는데 이게 제일 오래 걸렸다.

먼저 .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

3. 조용히 돈만 받고 물건을 안 주는 버그

결제가 드디어 성공해서 서버 기록을 확인했는데, 거래 식별자가 "0" 이었다.

{"price_paid": 1200, "iap_transaction_id": "0"}

StoreKit1도 로컬 테스트에서는 0을 준다. 내 코드는 "비어있지 않으면 그대로 쓴다"라서 통과시켰는데, "0"은 고유하지 않다. 서버는 같은 거래 식별자가 이미 있으면 "이미 처리한 거래"로 판단하고 넘어간다. 즉 세 번째 작품을 사면 결제는 되는데 소장 목록에는 안 들어간다.

지금 두 건이 무사히 들어간 건 첫 건이 우연히 다른 형식이었기 때문이다. 운이 좋았다.

클라이언트는 쓸 수 없는 값이면 영수증 해시를 식별자로 쓰게 하고, 서버도 "0"이나 빈 값은 400으로 거부하게 했다. 양쪽에서 막았다.

서버 검증은 7가지 경우를 다 테스트했다.

시나리오결과
유료 작품을 무료 획득 경로로403 차단
1000원 티어로 1500원 작품 받기400 차단
타인의 영수증 도용409 차단
인증 없이 호출401 차단
정상 결제200

4. UGC인데 상품을 미리 등록해야 하는 문제

이건 버그는 아니고 설계 문제였다. 작가가 계속 새 작품을 올리는데 인앱결제 상품은 스토어에 미리 등록해야 한다. 작품마다 상품을 만들 수가 없다.

가격 티어별로 상품을 하나씩만 만들고(atelier316.wallpaper.1000/1200/1500), 어떤 작품을 샀는지는 서버가 기록하는 방식으로 풀었다.


업로드 파이프라인

작가가 올린 사진에 말씀을 얹어서 게시하는 부분. 캔버스를 RepaintBoundary로 캡처해서 4장을 만든다. 말씀 O/X × 원본/미리보기.

순서가 중요했다. 스토리지 권한 정책이 products 행을 보고 판단해서, 행을 먼저 만들고(hidden) → 파일 업로드 → 공개(published) 순으로 해야 한다. 중간에 실패하면 행을 지워서 되돌린다.

원래 계획엔 미리보기를 서버(Edge Function)에서 만들기로 돼 있었는데 안 만들었다. 보안 목적인 "미구매자가 원본을 못 받게"는 버킷 권한으로 이미 달성되고, 미리보기를 누가 만드는지는 부차적이다. Deno에서 이미지 합성 돌리는 비용 대비 이득이 없어서 클라이언트에서 만든다.

접근 제어는 4가지를 다 확인했다.

누가원본미리보기
익명400 차단200
로그인했지만 미구매자400 차단200
구매자200200

화질이 뭉개져 보였다

게시하고 보니 미리보기 화질이 너무 떨어졌다. pixelRatio: 1로 구워서 캔버스 논리 크기(약 362×644) 그대로였는데, Retina에서 3배로 늘려 표시되니 뭉개진 거다.

배율만 올리면 PNG라 파일이 2MB 넘게 커진다. WebP로 하고 싶었는데 Dart image 패키지에는 WebP 인코더가 없다(디코더만 있다).

그런데 찾아보니 Supabase Storage가 서버에서 이미지 변환을 해준다. 테스트해봤다.

요청크기포맷
원본368 KBPNG
width=1200 + Accept: image/webp23 KBWebP

전송량 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건, 계좌를 바꾸니 다시 검증이 풀렸다.


오늘 한 것 정리

  • Supabase 프로젝트 세팅, 테이블 12개 + RLS
  • 이메일 로그인, 세션 유지
  • 피드/상세/작가 화면 실데이터 연동 (클린아키텍처로 재구성)
  • 무료 획득(광고), 즐겨찾기, 배경화면 적용 상태 서버 연동
  • 유료 인앱결제 + 영수증 서버 검증
  • 업로드 파이프라인 (캔버스 캡처 → 원본/미리보기 분리 저장)
  • 알림 자동 생성 트리거, 월간 정산 cron
  • 글로벌 정산 (거주국별 세율), 판매자 온보딩 화면
  • 마이그레이션 6개, Edge Function 2개, 테스트 16개

남은 것

출시 전에 꼭 해야 하는 것은 영수증 서명 검증이다. 지금은 가격이랑 티어, 중복만 확인하고 있어서 조작된 클라이언트가 가짜 영수증으로 유료 작품을 받아갈 수 있다. 실제 스토어 계정이 있어야 구현이랑 테스트가 가능해서 남겨뒀다.

그 외에 남은 것들.

  • 실제 송금 수단 연동 (PayPal Payouts / Payoneer 계약 필요)
  • 해외 송금은 외국환거래법 검토가 필요해서 세무·법무 확인
  • 신고·차단 기능 (스토어 심사 필수 요건인데 아직 화면이 없다)
  • 푸시 알림 (지금은 앱을 열어야 확인된다)
  • Android는 Play Console 등록 후 확인

오늘은 여기까지.

profile
https://github.com/NohYeongtaek

0개의 댓글