고양이 타로 앱에 서버를 안 두기로 했다 — Edge Function 도입기
React Native 타로 앱을 만들고 있습니다. iOS App Store 출시를 준비하면서 결제 검증과 일일 무료 사용
제한 같은 백엔드 로직이 필요해졌습니다. 그런데 "서버를 띄우자"가 첫 번째 답은 아니었습니다.
월 사용자가 100명일지 1,000명일지 아직 모르는 MVP 단계에서 EC2나 Vercel에 Express 서버를 24시간 켜두는 건 과한
결정이었습니다. 결국 Supabase의 Edge Functions를 도입했고, 이 글은 그 과정에서 배운 것의 정리입니다.
1. 왜 클라이언트만으로는 안 되는가
처음엔 단순하게 생각했습니다. "결제는 Apple이 처리하니까 앱에서 영수증 받아서 직접 처리하면 되지 않나?" 하지만 두
가지 문제가 있었습니다.
문제 1: 신뢰할 수 없는 환경
앱은 사용자의 손에서 돌아갑니다. 즉, 사용자가 마음만 먹으면 코드를 수정할 수 있다는 뜻입니다. 예를 들어 이런
코드가 앱에 있다고 합시다.
if (purchase.success) {
await supabase.from('profiles').update({ pass_expires_at: '2099-12-31' });
}
이걸 그대로 두면 결제 안 하고도 pass_expires_at을 미래로 늘리는 방법이 100가지는 있습니다. 권한이 큰 작업은 신뢰
가능한 환경에서 해야 합니다.
문제 2: 비밀 키를 앱에 못 넣음
Supabase에는 두 종류 키가 있습니다.
- anon key: 공개 가능. RLS(Row Level Security) 정책 안에서만 동작.
- service_role key: DB 전체를 마음대로 조작 가능. 앱에 절대 못 넣음.
우리는 purchases 테이블에 거래 기록을 적고, profiles.pass_expires_at을 갱신해야 합니다. 이건 RLS로 막혀 있어서
anon key로는 못 합니다. service_role 키가 필요한데, 그건 클라이언트에 둘 수 없습니다.
→ 결론: 신뢰 가능한 서버 환경에서 service_role 키로 동작하는 코드가 필요합니다.
2. Edge Function이 뭔가
전통적인 백엔드를 떠올리면 보통 이런 그림입니다.
[사용자] → [내가 빌린 EC2 한 대 (24/7 켜져 있음)] → [DB]
월 $5~$50 정도 깔리고, 사용자가 0명이어도 청구서는 같습니다.
Edge Function은 다릅니다.
[사용자] → [필요할 때만 깨어나는 함수] → [DB]
↑
평소엔 잠자고 있음
- Edge = 전 세계 여러 데이터센터에 동시에 배포. 도쿄 사용자는 도쿄 근처 서버가 응답.
- Function = 호출되면 1초 안에 깨어나서 일 처리 후 다시 잠.
- 요금 = 호출 횟수 × 단가. 안 쓰면 0원.
비유하자면 24시간 자리 잡고 앉아 있는 직원이 아니라, 벨 누르면 잠깐 나와서 일하고 다시 들어가는 직원입니다.
---
3. 우리 케이스의 두 함수
타로 앱의 백엔드 책임을 두 함수로 쪼갰습니다.
verify-purchase — 결제 검증
언제 호출: 사용자가 패스를 구매했을 때.
받는 것: Apple StoreKit 2가 발급한 JWS(JSON Web Signature) 토큰. 거래 사실을 Apple이 ECDSA 서명한 문자열입니다.
하는 일:
1. JWS의 헤더에서 인증서 체인(x5c)을 꺼냅니다.
2. 체인의 root가 "Apple Root CA - G3" 인지 확인 — 위조 차단의 핵심.
3. leaf 인증서로 JWS 서명을 ECDSA 검증.
4. 페이로드의 bundleId가 com.miracle.nekotarot인지, productId가 우리 상품인지, transactionId가 요청과 일치하는지
대조.
5. DB의 purchases 테이블에 INSERT. order_id에 UNIQUE 제약이 걸려 있어 중복 호출 자동 차단.
6. profiles.pass_expires_at = greatest(현재값, now()) + 기간으로 누적 연장.
7. 새 만료시각을 앱에 반환.
핵심은 5번. 같은 거래가 두 번 들어와도 두 번 늘어나지 않습니다. 멱등성(idempotency)을 DB 제약으로 강제했습니다.
consume-reading — 사용량 게이트
언제 호출: 사용자가 질문 카드를 탭해서 타로 한 번 보기 직전.
하는 일:
1. profiles.pass_expires_at이 지금보다 미래면 → { allowed: true, reason: 'pass' }.
2. 아니면 daily_usage에 오늘 날짜로 INSERT 시도.
- 성공 → 오늘 첫 무료 사용. { allowed: true, reason: 'free' }.
- 23505(unique violation) → 이미 오늘 1회 썼음. { allowed: false, reason: 'paywall' }.
daily_usage는 (user_id, used_date)가 복합 PK입니다. 같은 사용자가 같은 날 두 번 insert하면 무조건 23505 에러가
떨어집니다. "1일 1회 무료"를 DB 제약으로 표현한 것입니다.
흐름을 그림으로 보면:
앱 시작
└─ Supabase Auth(익명 로그인) → JWT 받음
질문 카드 탭
└─ consume-reading 호출
├─ 패스 유효 → 통과
├─ 오늘 첫 무료 → 통과 (count +1)
└─ 오늘 2회+ → BuyPass 화면으로
패스 구매
└─ Apple 결제 → JWS 토큰
└─ verify-purchase 호출
└─ 검증 → purchases INSERT → pass_expires_at 연장 → 알림
---
4. 전통 백엔드 vs Edge Function
┌─────────────────┬─────────────────────────┬───────────────────────────────────────────┐
│ │ Express on VM │ Edge Function │
├─────────────────┼─────────────────────────┼───────────────────────────────────────────┤
│ 상시 켜져 있나 │ 24/7 켜둠 │ 호출될 때만 │
├─────────────────┼─────────────────────────┼───────────────────────────────────────────┤
│ 사용자 0명 비용 │ 월 $5~$50 │ $0 │
├─────────────────┼─────────────────────────┼───────────────────────────────────────────┤
│ 확장 │ 수동으로 인스턴스 키움 │ 자동 (1명 → 10,000명까지 같은 코드) │
├─────────────────┼─────────────────────────┼───────────────────────────────────────────┤
│ 상태 유지 │ 메모리에 변수 살림 가능 │ 매 호출마다 새 인스턴스, 모든 상태는 DB에 │
├─────────────────┼─────────────────────────┼───────────────────────────────────────────┤
│ 런타임 │ 보통 Node.js │ Supabase는 Deno (Vercel은 Node) │
├─────────────────┼─────────────────────────┼───────────────────────────────────────────┤
│ 콜드 스타트 │ 없음 │ ~100-500ms 첫 호출 │
└─────────────────┴─────────────────────────┴───────────────────────────────────────────┘
마지막 두 줄이 함정입니다. Edge Function은 메모리에 아무것도 못 살립니다. "조금 전에 저 사용자가 뭘 했는지"를
함수 자체는 기억 못 합니다. 그래서 영속 상태는 무조건 DB에 적어야 합니다. 우리 purchases, daily_usage,
profiles.pass_expires_at이 그래서 존재합니다.
---
5. 비용 추정
Supabase 무료 한도는 월 500,000 호출입니다. 우리 케이스로 환산하면:
- 사용자 1명 ≒ 하루 3~5 호출 (앱 켜기 0, 타로 1~2회, 가끔 결제 1, 검증 1)
- 사용자 1,000명 ≒ 하루 5,000 호출 ≒ 월 150,000 호출
→ MAU 3,000명까지 무료. 그 위는 Pro($25/월)로 가면 월 200만 호출까지 커버.
EC2 t3.micro($8~/월) 24시간 굴리는 것보다, 100명짜리 앱이라면 Edge Function이 그냥 0원입니다.
---
6. 한계와 주의점
장점만 있는 건 아닙니다. 직접 부딪쳐서 알게 된 것들:
콜드 스타트: 함수가 한동안 호출 없으면 잠들어버려서, 다음 첫 호출에 100~500ms 지연이 생깁니다. 사용자가 처음
타로를 보는 순간에 0.5초 멈춰 보일 수 있습니다. UI에 로딩 인디케이터를 명시적으로 띄워서 "의도적인 동작처럼"
보이게 처리했습니다.
상태 없음: 같은 사용자가 1초 안에 두 번 호출해도 두 인스턴스가 동시에 깨어날 수 있습니다. 메모리에 락이나 캐시 못
둡니다. 모든 동시성 제어는 DB 제약(UNIQUE, PK)으로 처리해야 합니다.
디버깅이 다름: Express는 로컬에서 nodemon으로 띄우고 console.log 찍으면 끝이었는데, Edge Function은 supabase
functions serve로 로컬 에뮬레이션 돌리거나 Dashboard의 로그 탭을 봐야 합니다. 처음엔 조금 어색합니다.
런타임 제약: Deno라서 일부 Node 전용 npm 패키지가 그대로 안 돌아갑니다. 이번엔 jose 라이브러리를 ESM
빌드(https://esm.sh/jose@5.9.6)로 가져와서 JWS 검증에 썼는데, 다행히 호환 OK.
---
7. 마치며
서버리스가 만능은 아닙니다. 하지만 "지금 당장 사용자가 몇 명일지 모르는 작은 앱의 백엔드"라는 좁은 범위에서는
정말 잘 맞습니다.
- 신뢰 경계 = 서버에 있어야 할 코드가 서버에 있고
- 비용 = 사용자 수만큼만 지불하고
- 운영 = 인스턴스 관리, 스케일링, 헬스체크 모두 위임