광고를 넣기로 하고 가장 오래 고민한 건 "어떤 광고"가 아니라 "언제"였다.
이 글은 개발기와 정식 출시기에 이어, 「오늘 누가 쏘냐?」에 광고를 붙인 이야기다.
이 앱은 커피값 낼 사람을 미니 게임으로 정하는 파티게임이다. 폰 하나를 테이블 가운데 놓고 여럿이 들여다보거나, 같은 Wi-Fi 의 폰 여러 대로 같이 한다. 판이 끝나면 꼴등이 화면에 크게 뜨고, 다들 소리를 지른다. 이 게임에서 제일 재미있는 몇 초가 바로 거기다. 그 몇 초를 광고로 끊으면 앱이 망가진다.
넘어가는 순간 하나만
화면을 하나씩 짚어 가며 광고가 들어갈 수 없는 곳부터 지웠다.
게임 중은 당연히 안 된다. 연타 대결 10초 사이에 광고가 뜨면 판이 무효다. 결과가 나오는 순간도 안 된다. 앞에서 말한 그 몇 초다. 정산 화면도 뺐다. 계좌를 복사하고 카카오페이 링크를 여는 화면이라 돈 이야기를 하는 중에 광고가 끼어들면 신뢰가 깎인다. 기록 화면은 굳이 찾아 들어온 사람이 보는 곳이라 역시 뺐다.
남은 건 결과를 다 보고 결과 화면에서 「한 번 더!」나 「다른 게임」을 누르는 순간이다. 웃음이 가라앉고, 다음 판을 고르는 사이. 사람들이 이미 "넘어가자"고 마음먹은 자리라 화면 하나가 끼어도 흐름이 덜 끊긴다. 같은 결과 화면에 있어도 「정산하기」「기록 보기」「처음으로」「이미지 공유」에는 붙이지 않았다. 다음 판으로 가는 버튼이 아니기 때문이다.
형태는 전면 광고 하나로 정했다. 배너와 보상형은 넣지 않는다. 배너는 게임 중에도 화면 한쪽에 늘 떠 있으니 "게임 중에는 광고 없음"과 처음부터 맞지 않는다. 보상형은 줄 보상이 없다. 코인도 아이템도 없는 게임이다.
빈도는 숫자로 못 박았다.
- 세 판에 한 번 이하
- 3분에 한 번 이하
- 첫 판 뒤에는 띄우지 않는다
판 수와 마지막 광고 시각은 저장하지 않는다. 앱을 껐다 켜면 처음부터 센다. 오랜만에 앱을 켠 사람이 첫 판을 마치자마자 광고를 보는 일은 없어야 했다.
AdService 의 약속 하나
광고 코드는 Core/AdService 정적 클래스 하나에 모았다. MonoBehaviour 가 아니다. 이 앱의 화면은 전부 코드로 만들고 씬에는 부트스트랩 하나만 있어서, 화면이 광고를 쓰려면 어디서든 부를 수 있는 정적 진입점이 편하다.
바깥에 보이는 건 세 개뿐이다. 앱이 시작할 때 부트스트랩이 한 번 부르는 Init(), 판이 끝날 때마다 부르는 NoteGameFinished(), 그리고 화면이 쓰는 Show(닫힌 뒤 할 일).
Init() 의 순서는 이렇다. Google 의 동의 관리 라이브러리(UMP)로 동의 정보를 갱신하고, 동의 창이 필요한 지역이면 띄운다. 한국에서는 보통 창이 뜨지 않는다. 그다음 SDK 를 초기화하고, 곧바로 전면 광고 하나를 미리 불러온다. 버튼을 누른 뒤에 불러오기 시작하면 사람들은 몇 초 동안 멈춘 화면을 보게 된다. 광고는 늘 하나를 쥐고 있다가 띄우고, 닫히면 다음 것을 불러 둔다.
화면 쪽에서 가장 중요한 건 Show 의 약속이다. 닫힌 뒤 할 일은 어떤 경우에도 정확히 한 번 불린다. 광고가 없어도, 띄우다 실패해도, 에디터나 Windows, WebGL 이라 광고 SDK 가 아예 없어도. 결과 화면은 광고가 떴는지 신경 쓰지 않고 "다음 판으로 가는 일"을 넘기기만 하면 된다.
_showing = true;
bool done = false;
Action finish = () => NetPump.Post(() =>
{
if (done) return;
done = true;
_showing = false;
ad.Destroy();
AudioManager.HoldBgm(false);
Load(); // 다음 광고를 미리
if (onClosed != null) onClosed();
});
ad.OnAdFullScreenContentClosed += () => finish();
ad.OnAdFullScreenContentFailed += e => finish();
AudioManager.HoldBgm(true);
ad.Show();
done 이 있는 이유는 닫힘과 실패 이벤트가 둘 다 오는 경우를 막기 위해서다. 다음 판으로 두 번 넘어가면 게임 화면이 두 번 열린다.
NetPump.Post 는 같이 하기를 만들 때 소켓 스레드의 메시지를 메인 스레드로 넘기려고 만든 펌프다. 광고 SDK 의 콜백도 백그라운드 스레드에서 온다. 거기서 바로 화면을 바꾸면 Unity API 가 예외를 던지니, 콜백은 전부 이 펌프에 실어 다음 프레임에 메인 스레드에서 처리한다. 광고가 떠 있는 동안에는 배경 음악을 멈추고, 닫히면 다시 튼다.
불러 둔 광고가 오래돼 만료되는 경우도 있다. CanShowAd() 가 거짓이면 그 광고를 버리고 새로 불러오기를 걸어 둔 뒤, 이번에는 광고 없이 바로 넘어간다.
실패는 조용히 넘긴다
이 앱은 인터넷이 없는 곳에서도 쓴다. 회식 자리에서 방장 폰 핫스팟으로 같이 하기를 열면 광고를 불러올 길이 없다. 그래서 광고를 못 불러오는 건 오류가 아니라 평범한 일로 다룬다.
불러오기에 실패하면 30초 뒤에 다시 시도한다. 타이머는 따로 두지 않고 NetPump 의 프레임 콜백에서 시각만 본다.
static void Tick()
{
if (_retryAt > 0f && Time.realtimeSinceStartup >= _retryAt) Load();
}
넘어가는 순간의 판단은 ShowAtBreak 가 한다. 띄울 차례가 아니거나 준비된 광고가 없으면 바로 다음 판으로 간다. 기다리지 않는다. 여기서 한 가지를 신경 썼다. 광고가 없어서 못 띄웠을 때는 판 수를 초기화하지 않는다. 세 판을 채웠는데 광고가 아직 안 왔다면, 다음에 넘어갈 때 다시 본다. 반대로 초기화했다면 인터넷이 약한 곳에서는 광고가 거의 안 나오고, 판 수 규칙이 실제보다 훨씬 느슨해진다.
public static bool Due => _gamesSinceAd >= GamesPerAd
&& Time.realtimeSinceStartup - _lastAdAt >= MinInterval;
public static void ShowAtBreak(Action next)
{
if (!Due || !Ready) { if (next != null) next(); return; }
MarkShown(); // 판 수·시각을 처음부터
Show(next);
}
MinInterval 이 180초라서 마지막 광고 시각의 초깃값을 음의 무한대로 뒀다. 그래야 앱을 켜고 3분이 안 됐을 때도 세 판을 채우면 첫 광고가 나갈 수 있다.
결과 화면에는 버튼을 두 번 누르는 경우를 막는 깃발 하나를 넣었다. 「한 번 더!」를 누르고 광고가 뜨기 전 짧은 틈에 한 번 더 누르면, 광고와 화면 이동이 겹친다. 넘어가는 중이면 두 번째 누름은 무시하고, 화면이 다시 보일 때 깃발을 내린다.
실기기에서 테스트 광고로 확인했다. 첫 판과 두 번째 판은 그냥 넘어가고, 세 번째 판의 「한 번 더!」에서 전면 광고가 떴다. 닫으니 다음 판이 바로 시작됐다. 3분 안에 다시 세 판을 해도 광고는 나오지 않았다. 광고를 닫은 직후 다음 광고 불러오기가 No fill 로 한 번 실패했는데, 30초 뒤 다시 시도해 준비됐다는 로그가 찍혔다. 일부러 만든 실패는 아니었지만 재시도 경로를 실제로 한 번 지나간 셈이다.
빌드가 정하는 광고 단위와 등급
개발 중에 실제 광고를 띄워 놓고 실수로 누르면 무효 클릭이 되고, 심하면 AdMob 계정이 정지된다. 그래서 어떤 광고 단위를 쓸지는 사람이 고르지 않고 빌드가 정한다. 폰에 바로 까는 테스트용 APK 에는 스크립팅 심볼 하나를 넣고, 스토어에 올리는 AAB 에는 넣지 않는다.
// 테스트 설치용 APK 는 Google 테스트 광고 단위, 스토어 AAB 만 실제 광고 단위
extraScriptingDefines = aab ? null : new[] { "WHOPAYS_TEST_ADS" },
AdService 는 이 심볼이 있으면 Google 이 공개한 테스트 전면 광고 단위(ca-app-pub-3940256099942544/1033173712)를, 없으면 실제 단위를 쓴다. 빌드 완료 로그에도 "광고 테스트 단위" 또는 "실제 단위"를 찍어서 어떤 파일이 어느 쪽인지 바로 보이게 했다.
앱 ID 는 광고 단위와 다르다. 플러그인 설정 에셋에 들어가야 하고, 비어 있으면 앱이 실행되자마자 종료된다. 메뉴에서 손으로 넣을 수도 있지만 빌드할 때마다 확인하고 싶었다. 문제는 그 설정 클래스가 internal 이라 에디터 스크립트에서 바로 부를 수 없다는 점이다. 리플렉션으로 설정 인스턴스를 불러오고 SerializedObject 로 값을 썼다. 필드 이름이 플러그인 버전에 따라 바뀌면 조용히 넘어가지 않고 빌드를 멈춘다.
플러그인을 넣고 처음 만든 릴리스 빌드는 R8 단계에서 "Missing class" 로 멈췄다. 플러그인이 표준 SDK 연결 코드와 함께 차세대 SDK 연결 코드도 넣는데, 그 SDK 는 의존성에 없어서다. 쓰지 않는 쪽 경고만 끄고, 플러그인의 Java 연결 클래스는 C# 에서 이름으로 부르니 축소 대상에서 뺐다.
-dontwarn com.google.android.libraries.ads.mobile.sdk.**
-keep class com.google.unity.ads.** { *; }
광고 내용의 등급도 정했다. AdMob 은 앱마다 최대 광고 콘텐츠 등급을 고를 수 있다. 처음에는 전체이용가(G)로 하려고 했다. 그런데 AdMob 이 보여 주는 예상 수익 감소폭이 G 는 3669%, 청소년(T)은 1841% 였다. 이 앱의 대상 연령은 만 13세 이상이라 T 가 앱의 실제 이용자와도 맞는다. 계정 전체 설정은 그대로 두고 이 앱만 T 로 묶었고, 코드에서도 같은 값으로 요청한다.
MobileAds.SetRequestConfiguration(new RequestConfiguration { MaxAdContentRating = MaxAdContentRating.T });
아동 대상 태그는 넣지 않았다. 대상 연령에 만 13세 미만이 없기 때문이다.
로그에는 UMP 경고가 하나 남는다. AdMob 에 동의 메시지를 만들지 않았다는 경고다. 출시 국가가 대한민국뿐이라 광고 요청 자체는 막히지 않는다. 출시 국가를 유럽 쪽으로 넓히는 날에 GDPR 메시지를 만들면 된다. UMP 를 지금부터 넣어 둔 이유도 그날 코드를 다시 열지 않기 위해서다.
iOS 는 분기만 먼저
iOS 빌드를 준비하면서 AdService 의 플랫폼 조건을 넓혔다. UNITY_ANDROID 만 보던 곳을 (UNITY_ANDROID || UNITY_IOS) && !UNITY_EDITOR 로 바꿨다. 동의 → 초기화 → 미리 불러오기 → 띄우기 순서는 그대로다. 플랫폼별로 다른 건 광고 단위뿐이다.
#if WHOPAYS_TEST_ADS && UNITY_IOS
const string InterstitialId = "ca-app-pub-3940256099942544/4411468910"; // Google 테스트 전면 (iOS)
#elif WHOPAYS_TEST_ADS
const string InterstitialId = "ca-app-pub-3940256099942544/1033173712"; // Google 테스트 전면
#elif UNITY_IOS
const string InterstitialId = IosInterstitialId;
#else
const string InterstitialId = "ca-app-pub-0000000000000000/0000000000"; // 실제 Android 단위
#endif
iOS 의 실제 광고 단위는 아직 없다. AdMob 에 iOS 앱을 따로 만들어야 나오는 값이라, 상수를 빈 문자열로 두고 iOS 빌드 스크립트가 출시 빌드일 때 그 값이 비어 있으면 빌드를 막게 했다. 빈 단위로 광고를 요청하는 앱이 스토어에 나가는 것보다 빌드가 멈추는 편이 낫다. 이 상수만 조건부 컴파일 밖에 둔 것도 그 때문이다. 빌드 스크립트가 에디터에서 이 값을 읽어야 한다.
iOS 에는 추적 허용(ATT) 창이 하나 더 있다. 이건 코드로 띄우지 않고, AdMob 의 개인정보 보호 메시지에서 IDFA 안내를 켜면 UMP 가 동의 흐름 안에서 띄우도록 할 생각이다. 지금 코드는 테스트 단위로 도는 분기까지만이고, iOS 실기기에서 광고가 뜨는 것은 아직 확인하지 않았다.
정리하면 광고는 결과를 보고 넘어갈 때만, 세 판과 3분에 한 번 이하로 나온다. 화면은 "다음에 할 일"을 넘길 뿐이고, 광고가 있든 없든 그 일은 꼭 한 번 실행된다. 이 광고는 1.9 에 들어갈 예정이고, 아직 스토어에 나가지 않았다.
여기까지는 폰 한 대 이야기다. 같이 하기에서는 폰 여러 대가 같은 결과 화면을 보고 있다. 방장이 「한 번 더!」를 누르면 누구 폰에 광고가 떠야 할까. 다음 편은 모든 폰에 동시에 광고를 띄우고, 다 같이 닫을 때까지 기다리는 이야기다.