9월 21일 오후, 같이 하기 로비를 고치던 중에 Play 스토어 출시 준비를 시작했다. 그때까지 「오늘 누가 쏘냐?」는 WebGL로 브라우저에서만 돌려 봤다. 같이 하기(LAN)는 브라우저에서 소켓 서버를 열 수 없으니, 배포 대상은 처음부터 Android 하나로 정해 두었다.
이어서 Android 빌드 파이프라인, 서명 키, 아이콘, 개인정보처리방침, 스토어 등록 가이드를 만들고 첫 AAB를 Play Console에 올렸다. 그 뒤 9월 30일까지 버전 1.6(버전 코드 10)까지 올라갔다. 지금은 비공개 테스트 단계다. 정식 출시는 아직 아니다.
이 글에서는 그 과정에서 정한 규칙과 부딪힌 문제를 정리한다.
빌드 설정은 에디터 스크립트가 강제한다
Player Settings를 손으로 맞추면 누가 언제 무엇을 바꿨는지 남지 않는다. 그래서 빌드 함수가 시작할 때마다 필요한 설정을 코드로 덮어쓴다. 에디터 메뉴(「WhoPays > Android APK」, 「Android AAB」)로 빌드하든 배치 빌드로 하든 같은 함수를 거친다.
static BuildReport Build(bool aab, string output, string version)
{
// ---- 플레이어 설정 (Play 요구사항: 64비트 + AAB + targetSdk 최신) ----
var target = NamedBuildTarget.Android;
PlayerSettings.SetScriptingBackend(target, ScriptingImplementation.IL2CPP);
PlayerSettings.Android.targetArchitectures = AndroidArchitecture.ARM64;
PlayerSettings.Android.minSdkVersion = AndroidSdkVersions.AndroidApiLevel26;
PlayerSettings.Android.targetSdkVersion = AndroidSdkVersions.AndroidApiLevelAuto; // 설치된 최신 SDK(36)
PlayerSettings.defaultInterfaceOrientation = UIOrientation.Portrait;
PlayerSettings.SplashScreen.show = false; // Unity 6 부터 개인 요금제도 끌 수 있다
if (!string.IsNullOrEmpty(version)) PlayerSettings.bundleVersion = version;
ApplyIcons();
// ...
}
정리하면 IL2CPP, ARM64, minSdk 26, targetSdk는 설치된 최신(36), 세로 고정, Unity 스플래시 끔이다. 아이콘은 3편에서 다룬 gen_icon.py가 만든 PNG를 적응형(배경·전경)·라운드·레거시 아이콘으로 한꺼번에 넣는다. 패키지 이름은 kr.co.addsoft.whopays로 정했다.
같이 하기에 필요한 권한(INTERNET, ACCESS_NETWORK_STATE, ACCESS_WIFI_STATE, CHANGE_WIFI_MULTICAST_STATE)과 진동용 VIBRATE는 IPostGenerateGradleAndroidProject를 구현한 AndroidManifestPatcher가 gradle 프로젝트가 만들어진 직후 매니페스트에 넣는다. 모두 설치할 때 자동으로 주어지는 일반 권한이라 실행 중에 권한 요청 창은 뜨지 않는다.
첫 APK 빌드는 바로 실패했다. 같이 하기의 방 찾기는 UDP 브로드캐스트를 받으려고 Android MulticastLock을 AndroidJavaObject로 부르는데, 프로젝트에 Android JNI 내장 모듈이 빠져 있었다. Packages/manifest.json에 com.unity.modules.androidjni를 추가하고 나서 첫 APK가 나왔다.
서명 키는 저장소 밖, 값은 환경변수로만
AAB는 업로드 키로 서명해야 올라간다. 키스토어 파일은 저장소 밖 폴더에 두고, 빌드 스크립트는 환경변수 네 개로만 받는다.
string keystore = Environment.GetEnvironmentVariable("WHOPAYS_KEYSTORE");
bool hasKey = !string.IsNullOrEmpty(keystore) && File.Exists(keystore);
if (aab && !hasKey)
{
Debug.LogError("[AndroidBuild] AAB 는 서명 키가 필요합니다. 환경변수 WHOPAYS_KEYSTORE / WHOPAYS_KEYSTORE_PASS / WHOPAYS_KEY_ALIAS / WHOPAYS_KEY_PASS 를 설정하세요.");
return null;
}
PlayerSettings.Android.useCustomKeystore = hasKey;
if (hasKey)
{
PlayerSettings.Android.keystoreName = keystore;
PlayerSettings.Android.keystorePass = Environment.GetEnvironmentVariable("WHOPAYS_KEYSTORE_PASS") ?? "";
// ... 별칭, 키 비밀번호도 같은 방식
}
APK는 키가 없으면 Unity 디버그 키로 서명해 폰에 설치만 할 수 있게 했고, AAB는 키가 없으면 빌드를 멈춘다. ProjectSettings에는 키스토어 경로와 별칭만 저장되고 비밀번호는 저장되지 않는다는 것도 첫 빌드 후 커밋에서 확인했다. 비밀번호는 파일에도 커밋에도 적지 않는다는 원칙은 이때 정했다.
처음 쓴 가이드의 환경변수 예시에는 경로와 별칭 두 개만 있어서, 비밀번호를 어떻게 넘기는지가 빠져 있었다. 비밀번호 두 개(WHOPAYS_KEYSTORE_PASS, WHOPAYS_KEY_PASS)도 같은 방법으로 설정하도록 가이드를 고쳤다. Java 17의 PKCS12 키스토어는 키 비밀번호가 키스토어 비밀번호와 같으므로 두 변수에 같은 값을 넣는다.
keytool의 한글 안내가 깨졌다
키를 만드는 keytool -genkeypair를 PowerShell에서 실행하자 안내 문구가 전부 깨진 글자로 나왔다. 어느 질문이 비밀번호이고 어느 것이 이름인지 화면만으로는 구분할 수 없었다.
두 가지로 해결했다.
-J-Duser.language=en으로 keytool이 영어 안내를 쓰게 한다.- 이름·조직 질문은
-dname으로 미리 넣어, 비밀번호만 묻게 한다.
그런데 이 옵션을 그대로 넣었더니 이번에는 "잘못된 옵션: .language=en"이 나왔다. PowerShell이 -J-Duser.language=en을 점에서 잘라 버린 것이다. 네이티브 명령에 인자를 가공 없이 넘기는 --%를 앞에 붙여서 끝났다.
& "...\AndroidPlayer\OpenJDK\bin\keytool.exe" --% -J-Duser.language=en -genkeypair -v -keystore <저장소 밖 경로>\app.keystore -alias <별칭> -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Addsoft, O=Addsoft, C=KR"
묻는 것은 Enter keystore password:와 Re-enter new password: 두 번뿐이다. 가이드에는 첫 업로드 때 Play 앱 서명(Google이 최종 서명 키를 보관)을 선택하라고 적었다. 그러면 업로드 키를 잃어도 재발급 절차가 있다. 그래도 키스토어 파일은 따로 백업해 둔다.
배치 빌드와 -version 인자
Unity 에디터를 열지 않고 빌드하려고 Tools/build-android.ps1을 만들었다. Unity를 -batchmode -executeMethod AndroidBuild.BuildAndroid로 띄우고, 끝나면 로그에서 [WhoPays] 줄만 골라 보여 준다. 에디터에 프로젝트가 열려 있으면 실패하니, 빌드 전에 에디터를 닫는 일이 몇 번 반복됐다.
앱 버전을 넘기려고 처음에는 -version 1.1이라는 인자를 썼다. 그런데 빌드가 아예 실행되지 않았다. -version은 Unity 자체 명령줄 옵션과 이름이 겹쳤다. 인자 이름을 -appVersion으로 바꿨다. PowerShell 스크립트 쪽 매개변수는 여전히 -Version이고, Unity에 넘길 때만 이름을 바꾼다.
if ($Version) { $unityArgs += @("-appVersion", $Version) } # -version 은 Unity 자체 옵션과 겹친다
빌드 로그의 크기 표시도 틀려 있었다. 로그에 APK가 383MB로 찍혔는데, 실제 파일은 24MB였다. BuildSummary.totalSize는 gradle 중간 산출물까지 포함한 값이었다. 만들어진 파일 크기를 FileInfo로 직접 재도록 고쳤다.
한 번 쓴 버전 코드는 다시 못 쓴다
Play는 같은 앱에 대해 올라가는 버전 코드만 받는다. 그래서 AAB를 빌드할 때마다 bundleVersionCode를 1 올리고 ProjectSettings에 저장하게 했다. 빌드 후에는 이 파일을 함께 커밋한다. 처음 값이 1이었으므로 첫 AAB는 WhoPays-1.0-2.aab, 버전 코드 2가 됐다.
// ---- 버전 코드: 스토어 빌드마다 +1 (Play 는 올라가는 값만 받는다) ----
if (aab) PlayerSettings.Android.bundleVersionCode += 1;
첫 AAB를 올린 직후 문제가 생겼다. 먼저 내부 테스트에서 설치를 확인해야 했는데, 다른 트랙에 먼저 올려 버렸다. 같은 파일을 내부 테스트에 다시 올리자 이렇게 나왔다.

버전 코드는 트랙별이 아니라 앱 전체에서 한 번만 쓸 수 있다. 다른 트랙에 올린 번들이라도 같은 코드는 다시 받지 않는다. 버전 코드를 올려 새로 빌드하는 수밖에 없었다. 그날 버전 1.0으로만 코드 2, 3, 4가 나왔다. 코드 4부터는 아래의 디버그 기호도 함께 만든다.
네이티브 디버그 기호
AAB를 올리면 Play Console이 "네이티브 디버그 기호를 업로드하라"는 경고를 띄운다. IL2CPP 빌드는 네이티브 코드이므로, 기호 파일이 없으면 비정상 종료 보고의 스택을 해석할 수 없다.

빌드 설정 두 줄로 해결된다. 빌드 옆에 *.symbols.zip이 생긴다.
UnityEditor.Android.UserBuildSettings.DebugSymbols.level = Unity.Android.Types.DebugSymbolLevel.SymbolTable;
UnityEditor.Android.UserBuildSettings.DebugSymbols.format = Unity.Android.Types.DebugSymbolFormat.Zip;
처음에는 출력 폴더의 *.symbols.zip을 전부 로그에 찍었더니, 지난 버전 기호 파일까지 여섯 개가 나와 어느 것을 올려야 할지 헷갈렸다. 빌드 시작 시각을 기억해 두고 그 뒤에 만들어진 파일만 찍도록 고쳤다.
광고 ID와 개인정보처리방침
Play Console의 앱 콘텐츠 설문에는 개인정보처리방침 URL이 필요하다. privacy.html을 WebGL 빌드 템플릿에 넣어 웹 배포와 함께 올라가게 했고, 운영 주소 https://whopays.addsoft.co.kr/privacy.html을 등록했다. 처음에는 개발용 서버 주소를 적었다가 운영 서버 주소로 바꿨다.
광고는 AdMob을 붙이기로 했지만 아직 넣지 않았다. 문제는 광고 ID 선언이었다. Play Console 안내의 요지는 이렇다. 광고 ID를 쓴다고 선언하면 매니페스트에 com.google.android.gms.permission.AD_ID 권한이 없는 버전은 차단된다. 지금 빌드에는 광고 SDK가 없으니 이 권한도 없다.
그래서 스토어 가이드에 이렇게 정리했다.
- 광고 ID 선언: 광고 SDK가 들어간 빌드부터 예(이유: 광고 또는 마케팅). 그 전까지는 「아니요」 또는 「출시 오류 사용 중지」 체크.
- 데이터 보안: 광고 SDK를 넣으면 「기기 또는 기타 ID」 수집, 광고 파트너와 공유. 광고 SDK가 없는 빌드만 있을 때는 「아니요」. 기기 안
PlayerPrefs저장과 로컬 네트워크 전송은 수집에 해당하지 않는다. - 광고를 넣을 때 함께 바꿀 것(앱 콘텐츠의 광고 항목, 콘텐츠 등급 설문 재제출 등)은 목록으로 따로 남겼다.
개인정보처리방침은 광고가 들어갈 것을 전제로 미리 고쳤다. AdMob이 수집할 수 있는 광고 ID·기기 정보·IP 기반 대략 위치, 광고 ID를 재설정·삭제하는 방법, 사용하는 권한 목록을 넣었다. 같이 하기로 참여한 사람의 이름이 방장 폰의 벌칙 기록에 남을 수 있다는 점도 이때 정정했다. 앱은 회원 가입이 없고, 입력한 정보는 기기 안에만 저장된다.
Firebase 설정 파일 google-services.json은 .gitignore에 넣어 저장소에 올리지 않는다.
비공개 테스트 12명, 14일
개인 개발자 계정은 바로 프로덕션에 낼 수 없다. 비공개 테스트를 테스터 12명 이상, 14일 연속으로 거쳐야 「프로덕션 액세스 신청」이 열린다. 테스터는 Gmail 주소로 초대하고, 옵트인 링크를 열어 설치해야 집계된다.
그래서 순서를 이렇게 잡았다.
- 내부 테스트(심사 없음)에 올려 내 폰에서 스토어 설치를 확인한다.
- 비공개 테스트 트랙에 테스터 12명 이상을 넣고 14일을 유지한다. 그동안 고친 버전은 버전 코드를 올려 같은 트랙에 다시 올린다.
- 14일 뒤 프로덕션 액세스를 신청한다.
비공개 테스트 동안 버전이 자주 올라가니 출시 노트 규칙도 정했다. 이력은 Tools/store/release-notes.md에 버전별로 남긴다.
- 언어당 500자 제한. 목록 4~5줄과 안내 한 줄이면 충분하다.
- 상표명 금지. 「베스킨라빈스」 같은 남의 상표는 쓰지 않고 앱 안 이름(31 게임)과 똑같이 쓴다.
- 같이 하기 규약 버전이 바뀐 버전에는 「같이 하기는 모든 폰이 최신 버전이어야 함께할 수 있어요」를 반드시 넣는다. 규약이 다르면 방에 못 들어가기 때문이다.
- 테스터가 어느 버전에서 올라오는지 모르므로 A(직전 버전에서)와 B(그 전 버전도 건너뛴 경우) 두 안을 둔다.
결과 이미지 공유: 권한 없이 MediaStore로
결과 화면의 「이미지 공유」는 화면을 캡처해 카톡 등으로 보낸다. 다른 앱에 이미지를 넘기려면 content:// 주소가 필요한데, 보통은 FileProvider를 설정한다. 외부 패키지와 매니페스트 수정을 늘리고 싶지 않아서 다른 길을 택했다.
Android 10(API 29) 이상에서는 MediaStore에 이미지를 넣으면 저장소 권한 없이 content:// 주소를 바로 얻는다. Pictures/WhoPays에 저장하고 그 주소로 ACTION_SEND를 보낸다.
values.Call("put", "_display_name", fileName);
values.Call("put", "mime_type", "image/png");
values.Call("put", "relative_path", "Pictures/WhoPays");
var uri = resolver.Call<AndroidJavaObject>("insert", collection, values);
// ... openOutputStream 으로 PNG 쓰기
intent.Call<AndroidJavaObject>("setType", "image/png");
intent.Call<AndroidJavaObject>("putExtra", "android.intent.extra.STREAM", uri);
intent.Call<AndroidJavaObject>("addFlags", 1); // FLAG_GRANT_READ_URI_PERMISSION
minSdk가 26이므로 Android 8~9 기기도 있다. 거기서는 이미지 공유를 건너뛰고 결과 문장을 텍스트로 공유한다. 갤러리에도 이미지가 남으니 공유를 취소해도 결과 사진은 남는다. 진동(Haptics)도 같은 방식으로 외부 패키지 없이 AndroidJavaObject로 Vibrator를 직접 부른다.
버전 이력
| 버전 | 코드 | 날짜 | 주요 내용 |
|---|---|---|---|
| 1.0 | 2 | 9/21 | 첫 업로드용 AAB (사다리·눈치 게임) |
| 1.0 | 3 | 9/21 | 같이 하기 2단계(눈치 게임 각자 폰으로) |
| 1.0 | 4 | 9/21 | 네이티브 디버그 기호 생성 추가 |
| 1.1 | 5 | 9/21 | 같이 하기 전 단계, -appVersion 인자 |
| 1.2 | 6 | 9/21 | 폭탄 돌리기·룰렛·초성 퀴즈, 결과 이미지 공유, 배경음악·진동, 폴더블 비율 대응 |
| 1.3 | 7 | 9/23 | 369·바니바니, 게임 선택 2열 |
| 1.4 | 8 | 9/24 | 순서 외우기·딱 멈춰!·제일 작은 숫자·제비뽑기 |
| 1.5 | 9 | 9/26 | 31 게임·연타 대결·업다운, 게임 선택 3열 |
| 1.6 | 10 | 9/30 | 끝까지 버텨!, 게임 15개 |
AAB 크기는 1.3과 1.6 모두 24MB였다. 1.3 이후로는 버전마다 같이 하기 규약 번호도 올라갔다(출시 노트 기준 1.3은 규약 4, 1.6은 규약 12). 1.3과 1.6 빌드 커밋에는 "테스터 전원이 새 버전으로 올려야 한다"는 메모를 함께 남겼다.
정리
- 빌드 설정(IL2CPP, ARM64, minSdk 26, targetSdk 최신, 세로 고정, 스플래시 끔, 아이콘, 권한)은 에디터 스크립트가 빌드 때마다 강제한다.
- 서명 키는 저장소 밖에 두고 환경변수로만 넘긴다. keytool 한글이 깨지면
-J-Duser.language=en과-dname, PowerShell에서는--%. - 배치 빌드 인자에
-version을 쓰지 않는다. Unity 옵션과 겹친다. - AAB마다 버전 코드를 올린다. 한 번 올린 코드는 다른 트랙에서도 다시 쓸 수 없다.
- IL2CPP 빌드는
symbols.zip을 같이 만들어 올린다. - 광고는 SDK를 넣는 빌드에 맞춰 광고 ID 선언·데이터 보안을 바꾼다. 개인정보처리방침은 미리 준비해 둘 수 있다.
- 개인 계정은 비공개 테스트 12명·14일이 먼저다. 일정에 넣어야 한다.
연재를 마치며
이 글로 「WhoPays 개발기」 연재를 마친다. 전체 목록은 다음과 같다.
- Unity로 파티게임 15개를 만들었다
- 씬 편집기를 한 번도 열지 않았다 — UI를 전부 코드로
- 캐릭터·효과음·배경음악·폰트를 Python으로 생성하기
- Unity WebGL을 IIS에 올리기 — 한글 빈칸, LTE에서 40MB wasm 실패
- 등수별 커피값 분담 공식
- 서버 없이 여러 폰으로 — TCP/UDP 비콘, 서브넷 스캔, 방 번호
- 방장이 판정한다 — 호스트 권위, 재접속, 늦은 입력
- 비밀은 방장 폰만 안다 — 15개 게임의 규칙 다듬기
- Play 스토어 비공개 테스트까지 (이 글)
사다리 하나로 시작해 열다섯 번째 게임과 버전 1.6까지 왔다. 돌아보면 기능을 만드는 일보다 실제 폰에 깔아 보고 나서 고친 일이 더 많았다. 한글 빈칸, LTE에서 안 열리는 wasm, 방화벽, 폴더블 화면, 이미 사용된 버전 코드는 모두 직접 해 보기 전에는 보이지 않았다. 레드마인 이슈와 커밋 메시지를 꼬박꼬박 남긴 덕분에 이 연재도 그 기록을 다시 읽으며 쓸 수 있었다.
앱은 아직 비공개 테스트 중이다. 14일을 채우고 프로덕션을 신청하는 일, 광고를 붙이는 일이 남아 있다. 여기까지 읽어 주셔서 감사하다.