| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- React
- electron
- php
- 소셜로그인
- 인증
- next.js
- javascript
- 도메인설계
- 배포
- 온디바이스AI
- sqlite
- contextBridge
- 상태관리
- JWT
- Vite
- 프론트엔드
- Android
- 크롤링
- 아키텍처
- github actions
- python
- 메일자동화
- Kotlin
- typescript
- playwright
- 백엔드
- REST API
- FastAPI
- 마이그레이션
- 보안
- Today
- Total
HM소프트
모바일 축구 커리어 게임 개발기 (4) — AdMob 보상형 광고와 게임 재화 연동 본문

AdMob 보상형 광고를 안드로이드 게임에 붙이며 콜백 지옥을 가드 네 겹으로 길들인 기록. 광고 로직을 AdManager 한 곳에 모으고 준비 상태를 StateFlow로 노출해 버튼을 제어하며, 보상 지급은 원자적 SQL UPDATE로, 광고 빈도 정책은 상수로 모았다.
마지막 편, 그리고 가장 미뤄둔 편
시리즈 마지막이다. 1편에서 Compose + Hilt + Room + MVVM 아키텍처를 세웠고, 2편에서 게임 시스템과 재화 정합성을, 3편에서 Room 마이그레이션과 테스트를 다뤘다. 이번엔 수익화 — AdMob 보상형 광고를 붙이고 게임 재화와 연동한 이야기다.
솔직히 광고는 개인 개발 게임에서 가장 미뤄두기 쉬운 작업이다. 재미와 직접 관련이 없고, 잘해야 본전이며, 잘못 붙이면 사용자 경험만 망가진다. 그런데 막상 붙여 보니 광고는 "SDK 하나 끼우고 끝"이 아니었다. 콜백·생명주기·스레드·정합성이 한꺼번에 얽히는, 의외로 아키텍처적인 작업이었다. 이 글은 그 얽힘을 어떻게 한 겹씩 풀었는지에 대한 기록이다.
핵심 결정은 세 가지였다. 첫째, 광고 로직을 단 하나의 클래스(AdManager)로 모은다. 둘째, 화면(Compose)은 광고의 내부 사정을 전혀 모르게 한다. 셋째, 보상 지급은 2편에서 세운 원자적 잔액 변경 원칙을 그대로 따른다. 이 세 결정이 어떻게 코드로 떨어졌는지부터 보자.
왜 보상형 광고였나 — 결제 대신 "보고 받기"
이 게임에는 2편에서 만든 재화 시스템이 있다. 선수 능력치를 올리는 데 쓰는 SP(시즌 포인트), 그리고 상위 등급 스킬을 해금하는 데 쓰는 성장 결정체다. 결정체는 SS·SSS 등급 스킬에만 쓰이는 귀한 재화라, 평소엔 잘 안 쌓인다.
여기에 결제(IAP)를 붙일 수도 있었지만, 1인 개발 캐주얼 게임에서 결제는 진입 장벽이 높다. 결제 모듈 검수, 환불 처리, 세금 같은 부수 업무가 게임 본체만큼 무겁다. 반면 보상형 광고는 "광고를 끝까지 보면 재화를 준다"는 단순한 계약이다. 사용자는 돈 대신 시간을 내고 보상을 받고, 게임은 그 시청으로 수익을 낸다. 캐주얼 커리어 게임의 호흡과 잘 맞았다.
광고 진입점은 사용자가 "재화가 더 필요하다"고 느끼는 바로 그 순간에 두기로 했다. 결정체 팝업(스킬 해금 직전), 그리고 훈련 화면(능력치를 더 올리고 싶을 때)이다. 훈련 화면에는 두 종류를 뒀다 — 광고를 보면 그 훈련의 SP 획득량이 1.5배가 되는 SP 부스트, 그리고 떨어진 컨디션을 회복하는 컨디션 광고. 즉 한 게임 안에 성격이 다른 세 개의 보상형 광고 지점이 생겼고, 이것들이 전부 같은 AdManager 하나를 공유한다.

화면은 "광고 보여줘, 보상받으면 알려줘"만 요청하고, 보상이 확정되면 ViewModel이 지갑 DAO를 통해 재화를 원자적으로 올린다. 광고 SDK의 존재는 AdManager 안쪽에 완전히 갇힌다. 이 경계선이 이번 작업의 척추다.
AdManager — 광고 로직을 한 곳에, 상태는 Flow로
먼저 AdManager다. Hilt로 @Singleton 주입되며, 앱 전체에서 단 하나의 인스턴스가 광고 한 개를 들고 다닌다. 화면이 여러 개여도 광고 객체는 공유하므로, 결정체 팝업에서 미리 로드한 광고를 훈련 화면에서 그대로 쓸 수 있다.

여기서 가장 신경 쓴 건 광고 준비 상태를 StateFlow로 밖에 흘려보내는 설계다. 광고는 네트워크와 광고 재고 사정에 따라 "지금 보여줄 수 있는지"가 시시각각 바뀐다. 이 사실을 화면이 알아야 한다. 그래서 adReady라는 StateFlow<Boolean>을 공개하고, Compose 버튼은 이 값을 구독해 준비됐을 때만 활성화된다. 광고가 아직 안 불러와진 상태에서 버튼이 눌릴 일 자체를 UI 단에서 막는 것이다.
광고 단위 ID는 코드에 직접 박지 않고 R.string.admob_rewarded_ad_unit_id로 빼뒀다. 빌드 변형(디버그/릴리스)에 따라 다른 ID를 쓸 수 있고, 무엇보다 ID 같은 운영 값이 소스에 흩어지는 걸 막는다. (테스트용 ID와 실 ID를 섞어 쓰는 사고를 이 한 겹이 줄여 준다.)
광고 SDK는 콜백 지옥 — 가드를 한 겹씩
광고를 처음 붙였을 때 마주친 현실은 "이게 왜 이렇게 미묘하지"였다. 보상형 광고의 표시(show)는 단순한 함수 호출이 아니라, 여러 콜백이 시간차로 날아오는 비동기 흐름이다. 광고가 닫혔는지, 표시에 실패했는지, 보상 조건을 충족했는지가 각각 다른 콜백으로 들어온다. 이걸 정리한 게 show()다.

이 짧은 함수에 가드가 네 겹 들어 있다. 하나씩 풀어 보면 각각이 실제로 겪은 사고에 대한 대응이다.
1) 중복 로드 가드 (isLoading). 광고가 로드 중인데 화면 전환·재진입으로 preload()가 또 불리면 네트워크 요청이 중복된다. 두 요청이 거의 동시에 성공하면 광고 객체를 덮어쓰며 메모리·과금 측면에서 낭비가 난다. isLoading 플래그 하나로 인플라이트 요청이 하나뿐임을 보장했다. 이 플래그는 메인 스레드에서만 만지므로 동기화가 따로 필요 없다 — 이게 @MainThread를 단 실용적 이유다.
2) 광고 1회 소비 (rewardedAd = null). RewardedAd 객체는 일회용이다. 한 번 show한 광고를 다시 보여주면 SDK가 거부하거나 오동작한다. 그래서 show 진입 즉시 참조를 비워(rewardedAd = null) 같은 광고가 재사용될 여지를 없앴다. 그리고 광고가 닫히는 순간(onAdDismissed) 바로 preload()를 호출해 다음 광고를 미리 채운다. 사용자가 연속으로 광고를 보려 할 때 "로딩 중"으로 기다리는 시간을 줄이는 효과도 있다.
3) 보상 여부 추적 (rewarded 플래그). 보상형 광고에서 가장 헷갈리는 부분이다. 사용자가 광고를 끝까지 본 경우에만 보상 콜백(ad.show의 람다)이 불린다. 중간에 닫으면 보상 없이 onAdDismissed만 온다. 문제는 두 콜백의 순서가 보장되지 않는다는 점. 그래서 rewarded라는 로컬 플래그를 둬서, 닫힘 콜백에서 "보상을 줬는가"를 확인하고 안 줬으면 onDismissed(취소)로 분기한다. 이렇게 해야 "광고를 봤는데 보상이 안 왔다" 혹은 "중간에 껐는데 취소 처리가 안 됐다" 같은 어긋남이 안 생긴다.
4) 표시 실패 분리 (onAdFailedToShowFullScreenContent). 광고가 로드는 됐는데 정작 표시 단계에서 실패하는 경우가 있다(다른 풀스크린이 떠 있거나, SDK 내부 사정). 이걸 로드 실패와 섞지 않고 onFailed로 따로 통보해, 화면이 "광고를 불러올 수 없습니다"를 사용자에게 띄울 수 있게 했다.

Context에서 Activity를 거슬러 찾기
보상형 광고의 show()에는 Activity가 필요하다. 그런데 Compose 세계에서 손에 잡히는 건 보통 LocalContext로 얻은 Context뿐이다. 이 Context는 사실 Activity를 여러 겹의 ContextWrapper로 감싼 것일 수도, 아닐 수도 있다. 그래서 래퍼를 거슬러 올라가며 Activity를 찾는 확장 함수를 만들었다.

별것 아닌 8줄이지만, 이게 없으면 화면마다 (LocalContext.current as Activity) 같은 위험한 캐스팅을 흩뿌리게 된다. 그 캐스팅은 컴포즈 프리뷰나 일부 호스팅 환경에서 ClassCastException으로 앱을 죽인다. 확장 함수 하나로 "찾으면 Activity, 못 찾으면 null"이라는 안전한 계약을 만들고, 호출부는 context.findActivity()?.let { ... }로 받는다. 못 찾으면 그냥 아무 일도 안 일어난다 — 크래시보다 백 배 낫다.
@MainThread로 의도를 코드에 박다
AdManager의 preload()와 show()에는 @MainThread를 달았다. 광고 SDK의 로드·표시·콜백은 메인 스레드에서 일어나야 하고, 앞서 말한 isLoading 플래그도 메인 스레드 단일 접근을 전제로 동기화를 생략했다.
@MainThread는 런타임 강제가 아니라 정적 분석용 어노테이션이다. 하지만 효과가 분명하다. 백그라운드 코루틴에서 실수로 adManager.show(...)를 부르면 Lint가 경고로 잡아 준다. "이 함수는 메인 스레드에서만 불러라"는 약속을 주석이 아니라 코드가 검증할 수 있는 형태로 박아 둔 것이다. 멀티스레드 버그는 재현이 어렵고 디버깅이 고통스러우니, 이런 사전 차단이 값지다.
보상은 원자적으로 — 2편 정합성 원칙의 연장
광고를 끝까지 보면 onRewarded가 불리고, 여기서 재화를 지급한다. 결정체 팝업의 경우를 보자.
fun showAd(activity: Activity) {
if (playerId == 0) return
// onRewarded는 광고 오버레이가 살아 있는 동안 호출되므로 viewModelScope가 유효하다.
adManager.show(
activity = activity,
onRewarded = {
viewModelScope.launch {
walletDao.addCrystal(playerId, AppConstants.CRYSTAL_PER_TRAINING_AD)
_ui.update { it.copy(message = "결정체 +${AppConstants.CRYSTAL_PER_TRAINING_AD} 획득!") }
}
},
onDismissed = {},
onFailed = { _ui.update { it.copy(message = "광고를 불러올 수 없습니다") } }
)
}
여기서 addCrystal은 단순 대입이 아니라 DB 단의 원자적 증감이다. 2편에서 재화는 절대 "현재값 읽기 → 더하기 → 쓰기"의 3단계로 다루지 않기로 했다. 그 사이에 다른 코루틴이 끼면 갱신이 유실되기 때문이다. 대신 SQL UPDATE ... SET x = x + :amount 한 방으로 끝낸다.

restoreCondition을 보면 MIN(100, ...)으로 상한을 SQL 안에서 강제한다. 광고로 컨디션을 회복하더라도 100을 넘지 않는다. 이런 클램핑을 애플리케이션 코드가 아니라 쿼리에 넣은 이유는, 호출 경로가 여러 개(휴식·아이템·광고)여도 상한 규칙이 한 곳에서만 산다는 점 때문이다. 광고 보상 경로가 추가됐다고 상한 로직을 또 짜야 한다면 그게 곧 버그의 씨앗이다.
훈련 화면의 SP 부스트는 결이 조금 다르다. 보상으로 재화를 직접 주는 게 아니라, 다음 훈련 1회의 SP 배율을 켜는 플래그를 세운다.
fun showSpBoostAd(activity: Activity) {
adManager.show(
activity = activity,
onRewarded = { _uiState.update { it.copy(adBoostActive = true) } }, // 다음 훈련에 적용될 플래그
onFailed = { _uiState.update { it.copy(error = "광고를 불러올 수 없습니다") } }
)
}
fun showConditionAd(activity: Activity) {
val id = _uiState.value.playerId
if (id == 0) return
adManager.show(
activity = activity,
onRewarded = { viewModelScope.launch { walletDao.restoreCondition(id, AppConstants.CONDITION_AD_RECOVERY) } },
onFailed = { _uiState.update { it.copy(error = "광고를 불러올 수 없습니다") } }
)
}
세 진입점(결정체·부스트·컨디션)이 전부 같은 adManager.show(...)를 호출하지만, onRewarded 람다 안에서 무엇을 줄지만 다르다. 광고를 어떻게 띄우고 어떤 콜백을 처리하는지는 전부 AdManager에 한 번만 짜여 있고, 화면별로는 "보상으로 무엇을 줄 것인가"라는 게임 로직만 남는다. 이 분리 덕에 새 광고 지점을 추가하는 비용이 거의 0에 수렴했다.
광고 빈도는 정책이다 — 상수로 한 곳에 모으기
광고는 "넣으면 끝"이 아니라 얼마나 자주 보여줄지가 사용자 경험을 좌우한다. 너무 인색하면 수익이 안 나고, 너무 잦으면 사용자가 떠난다. 특히 막 시작한 신규 사용자에게 광고를 들이밀면 이탈로 직결된다. 그래서 빈도 정책을 코드 곳곳에 흩지 않고, 의미 있는 상수로 한 파일에 모았다.

이 상수들이 말해 주는 건 단순한 숫자가 아니라 밸런스 설계다. 신규 사용자에게는 보상형 광고 한도를 8회에서 3회로 줄이고, 전면 광고에는 3분 쿨다운을 둔다. 결정체는 광고로 무한정 받을 수 없고 하루 5회로 묶여 있다 — 광고 시청만으로 게임을 부수지 못하게 하는 상한이다. 이런 값을 한 파일에 모아 두면, 운영하며 "광고가 너무 잦다"는 피드백이 왔을 때 숫자 하나만 바꿔 대응할 수 있다.
광고 시청 로그 — 한도 집행과 사후 분석을 위해
위의 "하루 N회" 한도를 실제로 집행하려면, 사용자가 오늘 광고를 몇 번 봤는지 세야 한다. 이걸 위해 광고 시청을 Room에 기록한다. 로그는 한도 집행뿐 아니라, 나중에 "어느 지점의 광고가 실제로 많이 소비되는지"를 분석하는 데이터도 된다.

핵심은 (player_id, date_key) 복합 인덱스다. "오늘 이 사용자가 이 타입 광고를 몇 번 봤나"라는 질의가 한도 검사 때마다 불리는데, 이 인덱스가 그 카운트 쿼리를 빠르게 만든다.
@Query("SELECT COUNT(*) FROM ad_logs WHERE player_id = :playerId AND ad_type = :adType AND date_key = :dateKey")
suspend fun getDailyCount(playerId: Int, adType: String, dateKey: String): Int
date_key를 타임스탬프가 아니라 "2026-06-11" 같은 날짜 문자열로 둔 게 의도적이다. "오늘"의 경계를 자정 기준으로 깔끔하게 끊을 수 있고, BETWEEN으로 시간 범위를 계산할 필요 없이 문자열 일치만으로 하루치를 집계한다. 자정 넘어가면 date_key가 자동으로 바뀌니 한도도 자연히 리셋된다.
광고 동의와 SDK 초기화 — 첫 화면의 책임
광고를 붙이면 따라오는 게 개인정보·광고 동의다. 이 게임은 첫 실행 시 동의 화면을 거치게 하고, 동의 사실을 SharedPreferences에 남긴다.
fun onConsent() {
context.getSharedPreferences("autosoccer_prefs", Context.MODE_PRIVATE)
.edit().putBoolean("consent_given", true).apply()
viewModelScope.launch { _navToCreate.emit(Unit) } // 동의 후 캐릭터 생성으로
}
그리고 광고 SDK 초기화는 Application의 onCreate에서 단 한 번 한다. 무거운 초기화를 화면 진입마다 반복하지 않기 위함이다.
@HiltAndroidApp
class AutoSoccerApplication : Application() {
@Inject lateinit var skillRepository: SkillRepository
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate() {
super.onCreate()
MobileAds.initialize(this) // 광고 SDK 1회 초기화
appScope.launch { skillRepository.initIfNeeded() } // 스킬 데이터 시드(백그라운드)
}
}
MobileAds.initialize는 동기로 보이지만 내부적으로 네트워크 작업을 한다. 그래서 첫 광고 로드는 초기화가 끝난 뒤 화면이 preload()를 부르는 시점에 자연스럽게 이뤄진다. 스킬 데이터 시드는 별도 IO 코루틴으로 돌려 메인 스레드의 앱 시작을 막지 않는다 — 광고든 데이터든 앱 시작 경로를 무겁게 만들지 않는다는 원칙은 동일하다.
UI는 광고를 모른다 — 버튼은 상태만 본다
마지막으로 화면 쪽이다. 결정체 팝업의 버튼은 광고의 내부 사정을 전혀 모른다. 그저 ViewModel이 흘려주는 adReady 상태만 보고 활성/비활성을 정한다.
Button(
onClick = { context.findActivity()?.let { viewModel.showAd(it) } },
enabled = s.adReady, // 광고 준비됐을 때만 누를 수 있음
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = Purple300)
) {
Text(
if (s.adReady) "광고 보기 → +3 결정체" else "광고 로딩 중...",
color = SurfaceDark, fontWeight = FontWeight.Bold
)
}
버튼 라벨조차 상태에 따라 바뀐다 — 준비됐으면 "광고 보기 → +3 결정체", 아니면 "광고 로딩 중...". 사용자는 버튼만 보고도 지금 광고를 볼 수 있는지 안다. "버튼을 눌렀는데 아무 일도 안 일어난다"는 가장 흔한 광고 UX 사고를, 버튼을 누를 수 없게 만드는 것으로 원천 차단했다. 이게 가능한 이유는 AdManager가 준비 상태를 StateFlow로 정직하게 노출하기 때문이다. 처음의 설계 결정 하나가 마지막 화면까지 일관되게 떨어진다.
정리하며
- 광고 로직은
AdManager한 곳에. 화면은 "보여줘 / 보상받음 / 실패"만 안다. 광고 SDK의 존재는 이 클래스 안에 갇힌다. - SDK의 콜백 지옥은 가드로 길들인다. 중복 로드(
isLoading), 광고 1회 소비(rewardedAd = null), 보상 여부 추적(rewarded), 로드/표시 실패 분리 — 네 겹의 가드가 실제 사고들에 대한 대응이었다. - 준비 상태를
StateFlow로 노출해, 광고가 안 됐을 때 버튼을 아예 못 누르게 했다. "눌렀는데 반응 없음"이라는 사고를 UI 단에서 막았다. - 보상 지급은 원자적 UPDATE. 2편의 재화 정합성 원칙을 그대로 적용하고, 상한(
MIN(100, ...))은 SQL 안에 두어 경로가 늘어도 규칙은 한 곳에 산다. - 광고 빈도는 정책이다. 신규 유저 완화·일일 한도·쿨다운을
AppConstants에 모으고, 시청 로그를(player_id, date_key)인덱스로 집행 가능하게 만들었다.
시리즈를 마치며
네 편에 걸쳐 모바일 축구 커리어 게임의 안드로이드 클라이언트를 정리했다.
- 1편: Compose + Hilt + Room + MVVM 아키텍처
- 2편: 게임 시스템과 원자적 SQL(재화 정합성)
- 3편: Room 마이그레이션과 테스트
- 4편: AdMob 보상형 광고와 재화 연동
게임은 "상태가 많고 빠르게 바뀌는" 앱이다. 그래서 시리즈를 관통한 교훈은 하나로 모인다 — 정합성은 DB와 원자 연산으로 보장하고, 외부 SDK는 한 겹의 경계 뒤에 가두며, 정책 값은 한 곳에 모아 운영으로 조절한다. 광고처럼 가장 미뤄두고 싶었던 작업조차, 결국 이 세 원칙의 응용이었다. 다음은 이 게임의 백엔드 — 매치 통계를 모으고 밸런스를 시뮬레이션하는 도구들의 이야기로 이어진다.
'외주 개발일지' 카테고리의 다른 글
| 엔지니어링 기업 홈페이지 개발기 (1) — Next.js 정적 사이트와 이중 언어 지원 (0) | 2026.06.23 |
|---|---|
| 엔지니어링 기업 홈페이지 개발기 (1) — Next.js 정적 사이트와 이중 언어 지원 (0) | 2026.06.23 |
| 브랜드 웹사이트 개발기 (2) — 기획 문서와 콘텐츠를 정확히 맞추기 (0) | 2026.06.23 |
| 브랜드 웹사이트 개발기 (1) — 서버 설정 없는 PHP + SQLite와 콘텐츠 관리 (0) | 2026.06.23 |
| 급여 계산 자동화 도구 개발기 (1) — 4대보험·세금 계산 로직과 엣지케이스 (1) | 2026.06.23 |