앱스토어 밖에서, 다나를 사는 법
모바일엔 인앱 결제가 있지만, 웹과 데스크톱엔 상점이 없습니다. 그래서 결제의 다른 문을 열었습니다 — 결제는 브라우저에서 끝나고, 다나 적립은 Polar가 보내오는 웹훅으로 마무리됩니다.
앱스토어 밖에서, 다나를 사는 법
다나는 Fecit 안에서 쓰는 작은 화폐입니다. 모바일에선 App Store와 Play의 인앱 결제로 충전합니다. 영수증을 서버로 보내면, 서버가 스토어에 직접 조회·검증한 뒤 다나를 적립하죠.
그런데 Fecit은 웹과 데스크톱에서도 돕니다. 거기엔 상점이 없습니다. 데스크톱 다나 화면은 잔액과 내역만 보여 줄 수 있었고, 루멘이 떨어졌을 때 “충전하세요”라고 말하면서도 보낼 곳이 없었습니다. 막다른 골목이었죠.
그래서 결제의 다른 문 하나를 열었습니다 — Polar.
흐름: 우리는 결제창을 만들고, 웹훅을 기다린다
데스크톱에서 다나 묶음을 고르면, 클라이언트가 우리 서버에 체크아웃을 요청합니다. 서버는 Polar에 결제 세션을 만들고, 거기에 누구의 결제인지(achiever id)를 metadata로 실은 뒤 결제 URL을 돌려줍니다. 그 URL을 브라우저로 엽니다.
결제가 끝나면, 우리에게 알려 주는 건 사용자의 화면이 아니라 Polar의 웹훅입니다. Polar가 서버로 order.paid 이벤트를 보내고, 서버는 그걸 받아 다나를 적립합니다.
핵심은, 이 적립이 인앱 결제와 같은 길을 탄다는 점입니다. 같은 원장(ledger), 같은 멱등성 — (platform, transaction_id) 유니크 인덱스로 같은 주문이 두 번 적립되지 않게 막는 그 장치를, platform만 polar로 바꿔 그대로 씁니다. 결제 수단이 둘이어도, 다나가 쌓이는 자리는 하나입니다.
클라이언트의 말은 믿지 않는다
체크아웃이든 웹훅이든, 충전량을 결정하는 건 클라이언트가 보낸 숫자가 아닙니다. 상품 id → 다나 양 매핑은 서버에만 있습니다. 들어온 product id를 그 표에서 찾아 양을 정하고, 표에 없으면 무시합니다. 인앱 결제에서 지키던 원칙 그대로 — 돈이 오가는 곳에서 클라이언트의 주장은 입력일 뿐, 권위가 아닙니다.
웹훅 자체의 진위는 서명으로 가립니다. Polar는 Standard Webhooks 규약으로 요청에 서명하고, 우리는 시크릿으로 그 서명을 확인합니다. 서명이 맞지 않으면 401, 누가 우리 엔드포인트를 흉내 내도 다나는 한 톨도 움직이지 않습니다.
비동기의 틈
여기 작은 틈이 있습니다. 사용자가 결제를 마치고 앱으로 돌아왔을 때, 웹훅은 아직 안 왔을 수도 있습니다. 적립은 Polar가 우리 서버로 통지하는 비동기 과정이니까요. 돌아온 화면의 다나가 아직 그대로일 수 있습니다.
그래서 복귀 시점에 잔액을 다시 따라잡게 했습니다. 다나가 바뀌면 서버가 실시간 이벤트를 쏘고, 그걸 놓쳤을 경우를 대비해 결제 직후엔 잠깐 폴링으로 메웁니다. 웹훅이 도착하는 순간, 화면의 숫자가 조용히 올라갑니다.
두 세계: sandbox와 production
Polar의 sandbox는 production과 완전히 분리된 별도 세계입니다. 계정도, 상품도, 토큰도 다릅니다. 같은 “다나 100”이라도 두 세계에서 상품 id가 다르죠.
그래서 상품 매핑을 환경별로 나눠 두고, 어떤 상품을 살 수 있는지는 서버가 현재 환경에 맞춰 내려 줍니다. 클라이언트는 id를 하드코딩하지 않습니다 — sandbox에서 테스트하다 실수로 진짜 상품을 부르는 일이 없도록.
문은 하나로 충분하지 않다
같은 다나를 사는 데 모바일은 스토어를, 웹은 Polar를 지납니다. 입구는 둘이지만 도착지는 같습니다 — 같은 잔액, 같은 내역.
상점이 없는 곳에도 결제의 문은 있어야 합니다. 막다른 골목이던 데스크톱 다나 화면이, 이제 다른 문으로 이어집니다.