apps.sskplay.com 안드로이드 사이드로드 배포 패턴
Play 스토어를 거치지 않고 안드로이드 앱(APK)을 직접 배포하고, 앱이 스스로 업데이트하게 만드는 패턴 정리. 다른 AI 에이전트에게 “이 앱을 apps.sskplay.com 에 등록/게시하고 앱내 업데이트까지 붙여라”라고 시킬 때의 참조 문서다. 현재 Monogame Studio가 이 패턴을 쓴다.
구성 요소는 4개다:
- 카탈로그 사이트 —
apps.sskplay.com(GitHub Pages,ssk-play/apps.sskplay.com레포의docs/서빙) - 서명된 APK — 공유 keystore 로 서명, 안정적인 파일명(
<app>.apk)으로 게시 latest.json— 버전 메타데이터. 사이트와 앱이 둘 다 이걸 읽는다(릴리스마다 페이지 코드 수정 불필요)- 앱내 자동 업데이트 — 설치된 versionCode 와
latest.json을 비교해 새 버전이면 받아서 설치
큰 그림
[로컬 빌드 / deploy 스크립트] [GitHub Pages: apps.sskplay.com] [사용자 기기]
├─ bump version.properties docs/<app>/ ┌ 최초 설치
├─ assembleSideloadRelease ──APK──▶ ├─ index.html (latest.json fetch) │ 카탈로그 → 다운로드 → APK
├─ write latest.json ──meta─▶ ├─ <app>.apk └ 이후
└─ git commit + push ───────▶ └─ latest.json {version, code, sha…} 앱이 latest.json 폴링
│ Pages 자동 빌드 → 새 code면 배너 → 자가설치
▼
https://apps.sskplay.com/<app>/
핵심: 사이트는 latest.json 을 읽어 버전/용량/다운로드를 동적으로 보여준다. 그래서 새 빌드 게시는 “APK + latest.json 교체 + push”가 전부이고, 페이지 HTML 은 한 번만 만들면 된다.
저장소 구조
apps.sskplay.com 레포 기준:
docs/
├─ index.html # 전체 앱 카탈로그(목록)
└─ <app>/ # 앱 하나당 디렉터리 (예: monogame-studio/)
├─ index.html # 다운로드 페이지 (latest.json 을 fetch)
├─ <app>.apk # 안정적 파일명 — 항상 최신본으로 덮어씀
├─ latest.json # 버전 메타데이터
└─ assets/ # 아이콘/스크린샷 등
- 레포는 GitHub Pages 로 서빙되고
Access-Control-Allow-Origin: *라, 앱(다른 오리진)에서latest.json을 fetch 할 수 있다. - APK 는 버전명을 안 붙인 고정 파일명(
<app>.apk)으로 둔다. 버전은latest.json이 source of truth. (구버전 versioned APK 가 남아 있으면 deploy 시git rm으로 청소.)
1. 앱 등록 (카탈로그 페이지)
docs/<app>/index.html 을 한 번 만든다. 정적이지만 버전/용량은 latest.json 에서 동적으로 채운다:
<a id="dl" href="<app>.apk" download>다운로드</a>
<span id="meta"></span>
<script>
fetch("latest.json", { cache: "no-store" })
.then(r => r.json())
.then(m => {
document.getElementById("dl").href = m.apk; // <app>.apk
document.getElementById("meta").textContent =
`v${m.version} · ${m.sizeLabel}`;
});
</script>
그리고 docs/index.html(전체 카탈로그)에 이 앱 링크를 한 줄 추가한다. 이후 릴리스에서는 이 페이지를 건드리지 않는다.
2. APK 빌드 + 서명
서명 (공유 keystore)
android/key.properties (gitignore 필수 — 절대 커밋 금지):
storeFile=/path/to/shared-release.keystore
storePassword=…
keyAlias=…
keyPassword=…
app/build.gradle.kts:
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = java.util.Properties().apply {
if (keystorePropertiesFile.exists()) load(java.io.FileInputStream(keystorePropertiesFile))
}
android {
signingConfigs {
create("release") {
if (keystorePropertiesFile.exists()) {
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
}
}
}
buildTypes { getByName("release") { signingConfig = signingConfigs.getByName("release") } }
}
플레이버 분리 — 자가설치 권한 격리
사이드로드 빌드만 자기 자신을 설치할 수 있어야 하고, (혹시 올릴) Play 스토어 빌드엔 그 권한이 들어가면 안 된다. product flavor 로 가른다:
flavorDimensions += "dist"
productFlavors {
create("sideload") { dimension = "dist" } // 자가설치 O
create("play") { dimension = "dist" } // 자가설치 X
}
REQUEST_INSTALL_PACKAGES 권한과 설치용 FileProvider/플러그인은 src/sideload/AndroidManifest.xml 에만 선언한다. src/play/ 엔 매니페스트를 두지 않으면 main 매니페스트(권한 없음)를 그대로 쓴다. 빌드는 assembleSideloadRelease.
3. latest.json 스펙
{
"version": "1.0.10", // 사용자 노출 versionName (x.y.z)
"versionCode": 11, // 정수. 앱 업데이트 비교의 기준
"apk": "monogame-studio.apk", // 다운로드 파일명
"size": 4882202, // 바이트
"sizeLabel": "4.7 MB",
"sha256": "…", // 무결성
"updated": "2026-06-19"
}
versionCode 가 업데이트 판단의 단일 기준이다. 버전은 한 곳에서만 관리한다 — android/version.properties 를 source of truth 로 두고 build 스크립트가 읽게 하면, deploy 스크립트는 이 평문 파일만 bump 하면 된다(Kotlin/Gradle 파일을 정규식으로 패칭하지 말 것 — 깨지기 쉽다):
# android/version.properties
versionCode=11
versionName=1.0.10
// app/build.gradle.kts
val v = java.util.Properties().apply {
rootProject.file("version.properties").inputStream().use { load(it) }
}
defaultConfig {
versionCode = v.getProperty("versionCode").trim().toInt()
versionName = v.getProperty("versionName").trim()
}
4. 게시 (deploy 스크립트)
한 방에 끝나는 deploy.sh 패턴. 스크립트 위치에서 studio 디렉터리를 자동 도출하면 어디서 실행하든 동작한다:
set -euo pipefail
STUDIO_DIR="${STUDIO_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"
CATALOG_DIR="${CATALOG_DIR:-/Users/ssk/work/apps.sskplay.com}"
VERSION_FILE="$STUDIO_DIR/android/version.properties"
PUB="$CATALOG_DIR/docs/<app>"
export JAVA_HOME="…/openjdk@21/…" # AGP 9 / Gradle 9 는 JDK 21
# ① version.properties 패치 bump (평문이라 sed 한 줄)
code=$(grep -oE 'versionCode=[0-9]+' "$VERSION_FILE" | grep -oE '[0-9]+')
name=$(grep -oE 'versionName=[0-9.]+' "$VERSION_FILE" | grep -oE '[0-9.]+')
IFS=. read -r maj min pat <<<"$name"
sed -i '' -E "s/versionCode=[0-9]+/versionCode=$((code+1))/" "$VERSION_FILE"
sed -i '' -E "s/versionName=[0-9.]+/versionName=$maj.$min.$((pat+1))/" "$VERSION_FILE"
# ② 빌드 + 서명 검증
( cd "$STUDIO_DIR" && npx cap sync android >/dev/null )
( cd "$STUDIO_DIR/android" && ./gradlew --quiet assembleSideloadRelease )
APK="$STUDIO_DIR/android/app/build/outputs/apk/sideload/release/app-sideload-release.apk"
apksigner=$(ls -t "$ANDROID_HOME"/build-tools/*/apksigner | head -1)
"$apksigner" verify "$APK" >/dev/null
# ③ APK 복사 + latest.json 작성
cp "$APK" "$PUB/<app>.apk"
size=$(stat -f%z "$PUB/<app>.apk"); sha=$(shasum -a 256 "$PUB/<app>.apk" | cut -d' ' -f1)
cat > "$PUB/latest.json" <<JSON
{ "version":"…","versionCode":…,"apk":"<app>.apk","size":$size,"sha256":"$sha","updated":"$(date -u +%F)" }
JSON
# ④ 카탈로그 레포에 커밋 + push → Pages 자동 배포
cd "$CATALOG_DIR"
git add docs/<app>/ && git commit -q -m "release v…" && git push origin main
5. 앱내 자동 업데이트
앱(Capacitor)이 켜질 때 latest.json 을 폴링해, 설치본보다 versionCode 가 크면 배너를 띄우고 한 번에 받아서 설치한다.
// 설치된 build = App.getInfo().build (= versionCode)
const info = await App.getInfo();
const meta = await (await fetch(LATEST_URL, { cache: "no-store" })).json();
if (parseInt(meta.versionCode) > parseInt(info.build)) showUpdateBanner(meta);
설치는 네이티브 플러그인(사이드로드 플레이버 전용) 으로:
latest.json의 APK 를 앱cacheDir에 다운로드FileProvider.getUriForFile(ctx, "$packageName.fileprovider", apk)ACTION_VIEW인텐트 (FLAG_GRANT_READ_URI_PERMISSION) → 시스템 PackageInstaller → “알 수 없는 출처” 확인 → 설치
Intent i = new Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, "application/vnd.android.package-archive")
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK);
ctx.startActivity(i);
Play 플레이버엔 이 플러그인/권한이 dex 에 없어야 한다.
MainActivity에서Class.forName(...)으로 리플렉션 등록하면, 클래스가 없는 play 빌드에선 조용히 건너뛴다.
버전 라벨을 탭하면 즉시 재확인하게 해두면 디버깅이 편하다.
함정 / 메모
- Capacitor
server.url이 원격이면 웹/JS 는 원격에서 로드된다. 즉 JS 버그 픽스는 APK 재배포 없이 웹만 배포하면 기기에 반영된다. 네이티브 변경(권한·플러그인·라이브러리)만 새 APK 가 필요. (Monogame Studio 는 에디터를monogame.cc/dev에서 원격 로드한다.) - 네이티브 구글 로그인은 클래식 GMS 피커(
useCredentialManager:false)를 쓴다. 일부 삼성/One UI 기기에서 시스템 Credential Manager UI 가 init 중 크래시 →signInWithGoogle()이 에러 없이 무한 대기한다. 클래식 피커는 그 시스템 UI 를 안 거친다. - AGP 9 + Capacitor 8: Capacitor 가 생성하는 네이티브 플러그인 모듈은 자체 buildscript 에 AGP 8.13 을 박아둔다. 루트에서
resolutionStrategy로 전 모듈 AGP 를 통일(force)하면 AGP 9.2 빌드가 통과한다. Kotlin 은 AGP 9 빌트인이라kotlin.android플러그인을 따로 적용하면 에러. - 시크릿:
key.properties/local.properties는 절대 커밋 금지(.gitignore). keystore 비밀번호를 로그/PR 에 찍지 말 것.google-services.json은 클라이언트 식별자라 private 레포면 커밋해도 무방. - 권한: 자가설치엔
REQUEST_INSTALL_PACKAGES+FileProvider가 사이드로드 매니페스트에만 있어야 한다. 인터넷 권한도 확인.