본문 바로가기
Tech Notes

고양이 타로 앱에 서버를 안 두기로 했다 — Edge Function 도입기

by miracle-tech 2026. 5. 11.
728x90
반응형

  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. 마치며

  서버리스가 만능은 아닙니다. 하지만 "지금 당장 사용자가 몇 명일지 모르는 작은 앱의 백엔드"라는 좁은 범위에서는
  정말 잘 맞습니다.

  - 신뢰 경계 = 서버에 있어야 할 코드가 서버에 있고
  - 비용 = 사용자 수만큼만 지불하고
  - 운영 = 인스턴스 관리, 스케일링, 헬스체크 모두 위임

728x90