Skip to content

GDScript SDK

Ilhwan Seo edited this page Aug 29, 2026 · 3 revisions

GDScript SDK

Godot 4.3 앱용 addon. 현재 권장 버전은 0.6.7입니다.

0.6.5는 철회됐습니다. 0.6.4 이하에서 올릴 때는 0.6.7로 바로 갑니다.

가져오기

GDScript에는 패키지 매니저가 없습니다. Release tarball을 받아 sha256을 검증하고 addons/ 아래에 풉니다.

V=0.6.7
B=https://github.com/seorilabs/platform/releases/download/v$V
curl -fsSLO "$B/seorilabs-platform-gdscript-$V.tar.gz"
curl -fsSLO "$B/seorilabs-platform-gdscript-$V.tar.gz.sha256"
sha256sum -c "seorilabs-platform-gdscript-$V.tar.gz.sha256"    # macOS: shasum -a 256 -c
tar -xzf "seorilabs-platform-gdscript-$V.tar.gz" -C addons/

풀면 addons/seorilabs_platform/이 생깁니다.

addons/seorilabs_platform/
  platform_client.gd         진입점. class_name SeoriPlatformClient
  core/                      전송, envelope, 백오프, 정규화, presence
  adapters/                  Firebase identity, AdMob rewarded claim 표준 어댑터
  tools/                     SDK 자체 검증. 지워도 된다
  SOURCE  VERSION  CHECKSUM  출처 고정. addon과 같은 커밋에 넣는다

SOURCE는 다운로드한 Release asset URL을 가리킵니다. VERSION만 같고 CHECKSUM이 다르면 같은 배포본이 아닙니다 — 파일을 직접 고치지 말고, 올릴 때는 새 tarball로 통째로 바꿉니다.

symlink나 런타임 네트워크 의존으로 연결하지 않습니다. 모바일 export와 격리된 CI checkout에서도 같은 코드가 들어가야 합니다.

조립

var platform := SeoriPlatformClient.new()
platform.configure({
    "base_url": PLATFORM_API_URL,
    "iap_base_url": PLATFORM_IAP_URL,
    "ingest_base_url": PLATFORM_INGEST_URL,
    "ads_base_url": PLATFORM_ADS_URL,
    "app_id": "my-app",
    "event_context": func():
        return {
            "platform": "android",
            "appVersion": ProjectSettings.get_setting("application/config/version"),
            "locale": TranslationServer.get_locale(),
        },
    "presence_enabled": false,
})
add_child(platform)   # HTTPRequest가 동작하려면 트리에 있어야 한다

configure 키

키 기본 의미
base_url 필수 api 역할 URL
app_id 필수 레지스트리의 app_id
iap_base_url ingest_base_url ads_base_url base_url 역할별 URL. production은 전부 지정
auth_base_url base_url 세션 발급 host. AppsInToss처럼 mTLS 자격증명이 iap에 격리된 앱만 지정
event_context {} Dictionary 또는 Callable. Callable은 flush 시점에 평가 — 언어가 바뀌어도 다음 배치부터 반영
presence_enabled false RPI edge heartbeat. 명시적으로 켠 앱만
max_retries SDK 기본 전송 계층 재시도

event_context에는 OpenAPI에 선언된 platform, appVersion, locale, ga4ClientId만 들어갑니다. sdkVersion은 배포본 버전으로 SDK가 고정합니다.

콜백 envelope

모든 콜백은 Dictionary 하나를 받습니다.

키 뜻
ok 성공 여부
result 성공 결과
code 오류 코드. 분기는 이 값으로만 → Error Codes
message 사람이 읽는 메시지. 분기에 쓰지 않는다
local 로컬 판정인가. 서버에 닿지 못한 경우
http_status HTTP 상태

응답의 모르는 필드는 무시합니다. 키 개수를 세지 않습니다 — 서버가 필드 하나를 추가해도 마켓에 배포된 구버전이 깨지면 안 됩니다.

인증

platform.sign_in({"kind": "firebase-id-token", "value": id_token},
    func(res: Dictionary) -> void:
        if not res["ok"]:
            push_warning("로그인 실패: %s" % res["code"])
            return
        _on_signed_in(res["result"])
)
kind value 결제
firebase-id-token 앱 Firebase Auth의 ID 토큰 가능
ait-login AppsInToss appLogin() 결과 가능
anonymous getAnonymousKey() 해시 불가 — 사칭 가능

sign_in은 자격증명을 보관해 refresh가 실패했을 때 다시 로그인합니다. Godot에서 매번 Firebase 토큰을 다시 받아오게 하면 호출 앞에 왕복이 두 번씩 붙기 때문입니다.

조회
current_session() 세션 Dictionary. expiresAt은 epoch ms
is_signed_in() is_anonymous()
session_changed 시그널 세션이 바뀔 때. 로그아웃이면 빈 Dictionary
with_token(cb) 유효한 토큰으로 콜백. 만료 60초 전부터 선제 refresh

세션 발급과 갱신

sequenceDiagram
  autonumber
  participant App as 앱
  participant FB as Firebase Auth
  participant SDK as Platform SDK
  participant API as platform-api
  participant FS as Firestore
  App->>FB: 로그인
  FB-->>App: ID 토큰
  App->>SDK: sign_in kind firebase-id-token
  SDK->>API: POST /v1/auth/session — X-Seori-App 헤더
  API->>API: 레지스트리에서 앱 조회 · paused면 403
  API->>API: ID 토큰 서명 · aud · iss · exp 검증
  API->>FS: 차단 목록 확인
  API->>FS: app_id + uid → platform_user_id 매핑 · 없으면 생성
  API-->>SDK: platformToken · refreshToken · supportCode · config
  SDK->>SDK: 세션과 자격증명 보관 · session_changed 발신
  SDK-->>App: 콜백 — ok · result
Loading
sequenceDiagram
  participant App as 앱
  participant SDK as Platform SDK
  participant API as platform-api
  App->>SDK: with_token 또는 인증이 필요한 호출
  SDK->>SDK: expiresAt까지 60초 미만인가
  alt 아직 유효
    SDK-->>App: 저장된 platformToken으로 진행
  else 만료 임박 — 동시 호출도 refresh는 한 번
    SDK->>API: POST /v1/auth/refresh — refreshToken
    alt 200
      API-->>SDK: 새 세션
      SDK->>SDK: 저장 후 진행
    else 401 또는 403
      SDK->>API: 보관한 자격증명으로 POST /v1/auth/session 재로그인
      Note over SDK: IAP 경로는 첫 401 session_expired에만<br/>refresh 후 한 번 replay하고 재로그인하지 않는다
    end
  end
Loading

Firebase 계정이 아직 없을 때 — custom token bridge

platform.create_firebase_custom_token("", app_check_token, func(bridge):
    if not bridge["ok"]:
        return
    # bridge.result.firebaseCustomToken → Firebase signInWithCustomToken에 한 번 쓰고 저장하지 않는다
    # 그 뒤 ID 토큰으로 sign_in
)

첫 인자에 기존 Firebase ID 토큰을 넣으면 uid를 보존합니다. 신규 발급은 자동 재시도하지 않습니다 — 응답 유실 뒤 재시도하면 다른 uid가 생길 수 있습니다.

이 흐름 전체를 adapters/firebase_identity_adapter.gd가 표준으로 구현합니다. 직접 Firebase 익명 가입으로 우회하지 않고, 기존 UID 이전이 실패하면 fail-closed합니다. UID와 refresh token만 원자적으로 저장하고 Custom Token과 ID token은 메모리에만 둡니다.

const FirebaseIdentity := preload("res://addons/seorilabs_platform/adapters/firebase_identity_adapter.gd")

var identity := FirebaseIdentity.new()
add_child(identity)
identity.configure({
    "firebase_api_key": firebase_api_key,
    "platform_client": platform,
    "state_path": "user://firebase_auth.json",
})
var who: Dictionary = await identity.ensure_identity()

어댑터가 하는 일 전체입니다. 저장된 신원이 있으면 Firebase refresh token으로 바로 가고, 처음이면 플랫폼 custom token을 거칩니다.

sequenceDiagram
  autonumber
  participant App as 게임
  participant AD as firebase_identity_adapter
  participant SDK as SeoriPlatformClient
  participant API as platform-api
  participant FB as Firebase Auth REST
  App->>AD: ensure_identity
  AD->>AD: user 저장소에서 uid · refresh token 로드
  alt 저장된 신원이 있다
    AD->>FB: refresh token으로 ID 토큰 갱신
  else 처음이다
    AD->>SDK: create_firebase_custom_token — App Check 토큰
    SDK->>API: POST /v1/auth/firebase-custom-token — no_retry
    API-->>SDK: firebaseCustomToken · appUserId
    SDK-->>AD: 콜백
    AD->>FB: signInWithCustomToken
    FB-->>AD: ID 토큰 · refresh token
    AD->>AD: uid와 refresh token만 원자적으로 저장
  end
  AD->>SDK: sign_in kind firebase-id-token
  SDK->>API: POST /v1/auth/session
  API-->>SDK: 세션
  AD-->>App: identity — 실패하면 fail-closed
Loading

AppsInToss 로그인

Firebase ID 토큰이 없습니다. appLogin() 결과를 ait-login으로 넘기면 서버가 Toss Login API와 mTLS로 교환합니다. 이 자격증명은 iap·ads 역할에만 있으므로 auth_base_url을 그쪽으로 지정합니다.

sequenceDiagram
  autonumber
  participant App as 앱 — AppsInToss
  participant AIT as AppsInToss SDK
  participant SDK as Platform SDK
  participant IAP as platform-iap 또는 platform-ads
  participant TOSS as Toss Login API
  App->>AIT: appLogin
  AIT-->>App: authorization code · referrer
  App->>SDK: sign_in kind ait-login · referrer
  SDK->>IAP: POST /v1/auth/session
  IAP->>TOSS: mTLS로 code 교환
  TOSS-->>IAP: 사용자 식별자
  IAP->>IAP: 앱 범위 platform_user 매핑
  IAP-->>SDK: 세션 — isLinkedAccount true
  SDK-->>App: 콜백 — ok · result
  Note over SDK,IAP: mTLS 자격증명이 iap · ads 역할에만 있어<br/>세션 발급 host를 그쪽으로 지정한다
Loading

이벤트

platform.track("level_complete", {"level": 3, "is_new": true})
platform.flush_events()   # 백그라운드 진입, 종료 전
  • 즉시 보내지 않고 모았다가 배치로 보냅니다
  • 실패한 배치는 outbox에 남아 다시 시도합니다
  • 던지지 않습니다. 계측이 게임을 멈추면 안 됩니다
  • pending_event_count()로 남은 수를 봅니다

앱이 이미 canonical envelope(eventId, 발생 시각)를 갖고 있으면 track_event(event)로 그대로 보존합니다.

파라미터 정규화는 TypeScript SDK와 같은 출력을 냅니다 — spec/conformance/param-normalization.json이 정본입니다. boolean은 1/0, 객체·배열은 버리고, PII 키는 버립니다.

GA4를 대체하지 않고 병행합니다. GA4는 마케팅·잔존율, 플랫폼은 운영·CS·결제 근거입니다. 이벤트명을 한쪽만 바꾸지 않습니다.

sequenceDiagram
  participant App as 앱
  participant SDK as Platform SDK — Events
  participant OB as Outbox
  participant ING as platform-ingest
  App->>SDK: track name · params
  SDK->>SDK: allowlist 확인 · 파라미터 정규화 · eventId 부여
  SDK->>SDK: 버퍼에 추가 — 던지지 않는다
  alt 배치 도달 · 주기 · flush_events 호출
    SDK->>OB: 이전 실패분 drain
    SDK->>ING: POST /v1/events — 최대 20건 · 세션 토큰은 있으면
    alt 성공
      ING-->>SDK: ok
    else 실패
      SDK->>OB: 배치 보관 — 최대 500건
      Note over OB: 다음 flush에 다시 시도
    end
  end
Loading

결제 (IAP)

platform.verify_purchase(
    {"platform": "google_play", "product_id": "gecko_galaxy", "token": purchase_token},
    func(res: Dictionary) -> void:
        if not res["ok"]:
            _show_purchase_error(res["code"])
            return
        var result: Dictionary = res["result"]
        _apply_entitlements(result["entitlements"])
        var completion: Dictionary = result.get("completion", {})
        if String(completion.get("action", "none")) == "apps_in_toss_complete_product_grant":
            _ait_complete_product_grant(String(completion.get("orderId", "")))
)
마켓 token
Google Play purchaseToken
App Store transactionId
AppsInToss orderId

verify_purchase, list_entitlements, account_references는 전송 계층의 일반 재시도를 모두 끕니다. 첫 응답이 정확히 401 session_expired일 때만 refresh를 한 번 요청하고 새 토큰으로 원 요청을 한 번 replay합니다. refresh 실패, 두 번째 401, 403, 5xx, timeout은 그대로 한 번 반환합니다.

익명 세션은 SDK가 서버에 가기 전에 anonymous_not_allowed로 막습니다.

구매 전 계정 참조:

platform.account_references(func(res):
    # res.result.googlePlayObfuscatedAccountId / appStoreAppAccountToken
    # 마켓 결제 화면에 넣어 다른 사용자의 구매 가로채기를 막는다
)

구매 전 계정 참조부터 완료 처리까지의 전체 흐름입니다.

sequenceDiagram
  autonumber
  participant App as 앱
  participant SDK as Platform SDK
  participant MK as 마켓 SDK
  participant IAP as platform-iap
  participant MAPI as 마켓 API
  participant FS as 원장 Firestore
  App->>SDK: account_references
  SDK->>IAP: GET /v1/iap/account-references
  IAP-->>SDK: HMAC 계정 참조
  App->>MK: 구매 요청 — obfuscatedAccountId · appAccountToken
  MK-->>App: purchaseToken · transactionId · orderId
  App->>SDK: verify_purchase platform · product_id · token
  SDK->>IAP: POST /v1/iap/verify — 일반 재시도 없음
  IAP->>IAP: 마켓 검증기 선택 · SKU를 entitlement로 변환
  IAP->>MAPI: 구매 확인
  MAPI-->>IAP: 거래 상태
  IAP->>IAP: 계정 바인딩 검증 — 불일치는 신호로 기록하고 지급은 막지 않는다
  IAP->>FS: 트랜잭션 — 지급 · 보류 · 회수
  IAP->>MAPI: 완료 처리 — Play consume 등 · 실패해도 지급 유지
  IAP-->>SDK: status · entitlements · granted 또는 alreadyGranted · completion
  SDK-->>App: 콜백 — ok · result
  opt completion.action이 none이 아니면
    App->>MK: 마켓 쪽 마무리 — AppsInToss 지급 완료 등
  end
Loading

환불은 마켓 웹훅으로 서버에 먼저 도착합니다. 앱은 다음 시작 때 list_entitlements로 서버가 아는 최종 상태를 받아 반영합니다.

sequenceDiagram
  autonumber
  participant MAPI as App Store 또는 Google Play
  participant IAP as platform-iap
  participant FS as 원장 Firestore
  participant App as 앱
  participant SDK as Platform SDK
  MAPI->>IAP: 환불 알림 — Apple JWS 또는 Play RTDN
  IAP->>IAP: 서명 · OIDC 검증
  IAP->>FS: 기존 소유자의 entitlement 회수 — 알림만으로 신규 지급은 없다
  Note over App,SDK: 다음 앱 시작
  App->>SDK: list_entitlements
  SDK->>IAP: GET /v1/iap/entitlements
  IAP-->>SDK: 서버가 아는 최종 상태
  SDK-->>App: entitlements — 환불분 제외
Loading

Remote Config

platform.fetch_config({"app_version": "1.2.3", "platform": "android", "locale": "ko-KR"},
    func(res):
        var config: Dictionary = res["result"]
        if config.get("maintenance", {}).get("active", false):
            _show_maintenance()
)
platform.current_config()        # 마지막 값. 네트워크를 타지 않는다
platform.is_under_maintenance()
platform.is_sdk_blocked()

fetch_config는 **항상 ok: true**입니다. 실패하면 마지막 캐시, 그것도 없으면 열린 기본값을 돌려줍니다. 설정 조회 실패가 앱 시작을 막으면 서버 장애가 전체 중단으로 번집니다. config_changed 시그널로 갱신을 받을 수 있습니다.

flowchart TD
  A["config 조회"] --> B{"캐시가 TTL 안인가"}
  B -->|예| C["캐시 반환"]
  B -->|아니오| D["GET /v1/config — appVersion · platform · locale"]
  D --> E{"성공"}
  E -->|예| F["캐시 갱신 후 반환"]
  E -->|아니오| G{"이전 캐시가 있는가"}
  G -->|예| H["마지막 캐시 반환"]
  G -->|아니오| I["열린 기본값 반환 — 차단 없음"]
  F --> J{"sdk.status blocked<br/>또는 maintenance.active"}
  H --> J
  I --> J
  J -->|예| K["앱이 차단 · 점검 화면 표시"]
  J -->|아니오| L["정상 진행"]
Loading

광고 — reward claim

저수준 API는 get_ads_policy, create_reward_claim, get_reward_claim, confirm_reward_claim, ack_reward_claim입니다. 게임은 이걸 직접 쓰지 않고 adapters/rewarded_claim_adapter.gd를 씁니다. Custom Token 교환과 claim 상태 전이를 다시 구현하지 않고, 네이티브 AdMob 표시 코드에는 custom_data와 user_id만 넘깁니다.

const RewardedClaims := preload("res://addons/seorilabs_platform/adapters/rewarded_claim_adapter.gd")

var rewards := RewardedClaims.new()
add_child(rewards)
rewards.configure({
    "platform_client": platform,
    "identity_adapter": identity,
    "client_platform": "android",
})

var policy: Dictionary = await rewards.policy()
if not policy.get("allowed", false):
    return
var created: Dictionary = await rewards.create_admob_claim({
    "request_id": local_claim_id,
    "placement": "hint_reward",
    "reward_key": "hint",
    "reward_amount": 3,
})
var ssv := rewards.ssv_options(local_claim_id)
# 네이티브 SDK의 ServerSideVerificationOptions에 ssv 값을 설정한다

# recover_admob_claim이 server_verified이면 로컬 exactly-once 보상을 정산하고,
# 정산이 끝난 뒤에만 acknowledge한다
await rewards.acknowledge(local_claim_id)

# 광고 미노출·미보상 종료, 또는 recover가 final failed로 끝난 경우에만
rewards.discard_unsettled_claim(local_claim_id)

정책 조회 실패를 광고 허용으로 바꾸지 않습니다. pending claim 참조와 ack 재시도는 로컬에 보존합니다. 이미 로컬 정산 후 ack 대기열에 든 claim은 폐기하지 않습니다. 광고 unit, placement, reward 범위는 앱 레지스트리가 원장입니다.

claim 상태

stateDiagram-v2
  [*] --> accepted: claim 생성
  accepted --> confirmed: AdMob SSV 콜백 — server_verified
  accepted --> confirmed: confirm — client_confirmed · AppsInToss만
  accepted --> expired: expiresAt 경과
  confirmed --> delivered: ack — 로컬 지급 완료 후
  delivered --> [*]
  expired --> [*]
Loading

AdMob — 어댑터가 소유하는 흐름

sequenceDiagram
  autonumber
  participant App as 게임
  participant AD as rewarded_claim_adapter
  participant SDK as SeoriPlatformClient
  participant ADS as platform-ads
  participant GMA as AdMob 네이티브
  participant SSV as AdMob SSV
  App->>AD: policy
  AD->>AD: ensure_session — identity adapter로 로그인 보장
  AD->>SDK: get_ads_policy
  SDK->>ADS: GET /v1/ads/policy
  ADS-->>AD: adsEnabled · disabledBy
  alt allowed false 또는 조회 실패
    AD-->>App: 광고 중단 — fail-closed
  end
  App->>AD: create_admob_claim request_id · placement · reward
  AD->>SDK: create_reward_claim
  SDK->>ADS: POST /v1/ads/reward-claims
  ADS-->>AD: claim — accepted · admobSsv
  AD->>AD: claim 참조를 로컬에 저장
  App->>AD: ssv_options request_id
  AD-->>App: custom_data · user_id
  App->>GMA: 보상형 광고 표시 — ServerSideVerificationOptions
  GMA->>SSV: 시청 완료
  SSV->>ADS: GET /v1/ads/admob/ssv/appId — 서명된 콜백
  ADS->>ADS: 서명 검증 · 한도 재판정 → confirmed · server_verified
  App->>AD: recover_admob_claim request
  AD->>SDK: get_reward_claim
  SDK->>ADS: GET /v1/ads/reward-claims/claimId
  ADS-->>AD: state · assurance
  alt confirmed 또는 delivered 이고 server_verified
    AD-->>App: verified
    App->>App: 로컬 exactly-once 보상 지급
    App->>AD: acknowledge request_id
    AD->>SDK: ack_reward_claim
    SDK->>ADS: POST /v1/ads/reward-claims/claimId/ack
    ADS-->>AD: delivered
  else expired
    AD-->>App: failed — discard_unsettled_claim으로 정리
  else 그 외
    AD-->>App: pending — 다음 기회에 recover 재호출
  end
Loading

AppsInToss — 클라이언트 확인

AppsInToss 광고는 SSV가 없어 앱이 confirm_reward_claim으로 transaction_id를 보고합니다.

sequenceDiagram
  autonumber
  participant App as 앱 — AppsInToss
  participant SDK as Platform SDK
  participant ADS as platform-ads
  participant AIT as AppsInToss 광고 SDK
  App->>SDK: create_reward_claim provider apps_in_toss
  SDK->>ADS: POST /v1/ads/reward-claims
  ADS-->>SDK: claim — state accepted
  App->>AIT: 보상형 광고 표시
  AIT-->>App: 보상 완료 · transactionId
  App->>SDK: confirm_reward_claim claim_id · transaction_id
  SDK->>ADS: POST /v1/ads/reward-claims/claimId/confirm
  ADS->>ADS: provider가 apps_in_toss인지 확인 · transactionId 해시 · 한도 재판정
  ADS-->>SDK: confirmed · client_confirmed
  App->>App: 로컬 exactly-once 보상 지급
  App->>SDK: ack_reward_claim claim_id
  SDK->>ADS: POST /v1/ads/reward-claims/claimId/ack
  ADS-->>SDK: delivered
Loading

Presence (opt-in)

presence_enabled: true이고 레지스트리에 features.presence가 켜진 앱만 heartbeat를 보냅니다. 제품 요청과 별도 HTTPRequest를 쓰고, edge timeout 2초·token timeout 5초 안에서 끝나며 실패를 인증·결제 흐름에 전달하지 않습니다. Godot focus-in은 자동 감지하고, host가 별도 foreground 이벤트를 주면 resume_presence()를 부릅니다.

sequenceDiagram
  participant SDK as Platform SDK — Presence
  participant ING as platform-ingest
  participant EDGE as presence edge — RPI
  SDK->>ING: POST /v1/presence/token — 익명 세션 ID
  alt enabled false 또는 발급기 미준비
    ING-->>SDK: enabled false — 5분 뒤 다시 bootstrap
  else
    ING-->>SDK: 단기 토큰 · edgeUrl
    loop 60초 주기 · 실패 시 최대 5분 backoff
      SDK->>EDGE: POST /v1/presence/heartbeat — sequence · platform · appVersion
      EDGE-->>SDK: 200 또는 Retry-After
    end
  end
  Note over SDK: 실패를 던지지도 outbox에 넣지도 않는다
Loading

제약

  • HTTP 요청은 직렬로 흐릅니다. Godot HTTPRequest가 한 번에 하나만 처리합니다. 플랫폼 호출은 빈도가 낮아 충분합니다
  • Retry-After는 초 단위 숫자만 읽습니다. Godot에 HTTP-date 파서가 없습니다
  • 세션 만료 기준은 monotonic tick이 아니라 expiresAt(epoch ms)입니다. 기기 sleep 중 멈추는 tick은 쓰지 않습니다

검증

sha256sum -c seorilabs-platform-gdscript-0.6.7.tar.gz.sha256   # 가져올 때
godot --headless --script addons/seorilabs_platform/tools/conformance_probe.gd
godot --headless --script addons/seorilabs_platform/tools/smoke_probe.gd

exit code만 보면 안 됩니다. Godot은 SCRIPT ERROR가 나도 실행을 계속하므로 로그에서 SCRIPT ERROR와 ERROR:를 함께 찾습니다.

레퍼런스 배선은 examples/reference-godot에 있습니다.

Clone this wiki locally