Flutter 앱 17개의 AdMob 빈도를 자체 서버에서 조절하기
Firebase Remote Config를 141줄짜리 PHP 엔드포인트로 바꾼 이유, 앱 쪽 대비책, 그리고 다시 빌드하지 않고 광고 간격을 바꿨을 때 CTR이 어떻게 움직였는지.
모바일 앱의 광고 설정은 대개 상수입니다. 전면 광고 사이 간격, 세션당 최대 횟수, 앱 오픈 광고의 대기 시간. 모두 Dart 코드 안에 있고, 바꿀 때마다 빌드하고, 스토어 심사를 받고, 사용자가 업데이트하기를 기다려야 합니다. 앱이 17개면 결국 한 번도 바꾸지 않게 됩니다.
저희는 이 숫자들을 자체 서버로 옮겼습니다. 이 글에서는 약속(계약), 그것을 안전하게 만드는 앱 쪽 대비책, 관리 쪽 안전장치, 그리고 실제로 써 보니 무슨 일이 있었는지를 정리합니다.
Remote Config 대신 자체 엔드포인트를 만든 이유
Firebase Remote Config는 "출시 없이 값 바꾸기"를 해결해 줍니다. 그래도 바꾼 이유는 같은 요청에 일을 두 가지 더 시킬 수 있었기 때문입니다.
- 시간대×요일 트래픽. AdMob 보고서 API에는 시간 축이 없습니다. 모든 앱이 실행할 때마다 설정 엔드포인트를 한 번 부르니, 그 횟수를 세면 분석 SDK 없이도 앱별 7×24 실행 히트맵이 나옵니다.
- 저장 전 정책 검사. 위험한 광고 패턴이 되는 값은 "권장하지 않음"이 아니라 아예 "저장할 수 없음"이길 원했습니다.
전환 후 firebase_remote_config는 앱에서 지웠습니다.
약속
앱을 실행할 때마다 인증 없는 GET을 한 번 보냅니다.
GET /tlabo-ad/api/config.php?app=takarakuji&tz=540
| 파라미터 | 처리 |
|---|---|
app |
소문자로 바꾸고 [a-z0-9_]만 남김. 비면 400 {"error":"app parameter required"} |
tz |
기기의 UTC 오프셋(분). −720~840으로 제한. 없으면 UTC로 집계하고 no_tz_hits로 따로 셈 |
push_id, ping=1 |
푸시 열람 추적용. ping=1이면 실행으로 세지 않음 |
응답은 언제나 같은 모양의 정수 객체입니다.
{
"ad_interstitial_max_per_session": 3,
"ad_interstitial_interval_sec": 240,
"ad_interstitial_first_delay_sec": 60,
"ad_app_open_cooldown_min": 2,
"ad_app_open_gap_sec": 2,
"ad_inactivity_trigger_min": 3,
"ad_new_user_grace_days": 3,
"ad_interstitial_tap_delay_ms": 700
}
헤더는 Cache-Control: no-store라서 CDN이나 프록시가 오래된 정책을 내보낼 일이 없습니다.
구버전 앱을 지키는 규칙은 세 가지입니다.
- 모양을 바꾸지 않는다. 키는 추가만 하고, 지우거나 타입을 바꾸지 않습니다. 8월에 빌드한 앱은 11월에 추가된 키를 그냥 무시합니다.
- 실패하면 기본값을 돌려준다. 등록되지 않은 앱, 비활성화된 앱, DB 예외 모두 상태 200과 기본값으로 응답합니다. 앱이 해석해야 하는 오류 본문은 돌려주지 않습니다.
- 부수 효과가 응답을 망치지 않는다. 트래픽 집계와 열람 카운터는 try/catch로 감쌌습니다. 집계 쓰기가 실패해도 설정은 나갑니다.
값은 정해진 순서로 합칩니다. 코드 기본값, 앱별로 저장한 값, 그 앱에 시간대 일정이 켜져 있다면 그 프리셋 순입니다. 일정 규칙은 요일 비트마스크와 자정을 넘어도 되는 시간 범위를 갖고, 우선순위대로 처음 맞는 것이 적용됩니다.
앱 쪽: 기다리지 않고, 예외를 던지지 않는다
Flutter 쪽 값은 세 층입니다. 코드의 기본값, SharedPreferences에 캐시한 지난 값, 그리고 서버.
Future<void> init() async {
await _loadCache(); // 빠름, 로컬
unawaited(_refreshFromServer()); // 5초 제한, 오류는 삼킴
}
int get interstitialIntervalSec =>
_values['ad_interstitial_interval_sec'] ?? _defaults['ad_interstitial_interval_sec']!;
코드보다 중요한 결정이 두 가지 있습니다.
- 새 값은 다음 실행부터 쓴다. 가져온 값은 캐시에 적기만 하고 그 자리에서 바꾸지 않습니다. 세션 도중에 광고 동작이 바뀌지 않고, 느린 회선에서 스플래시 화면이 아직 필요도 없는 정책을 최대 5초 기다리는 일도 없어졌습니다.
- 0도 올바른 값이다. 초기 버전은
v > 0인 값만 받아서, 서버에서 기능을 끌 수가 없었습니다.v >= 0으로 고쳤습니다.
원격 설정 덕분에 드러난 버그가 하나 더 있습니다. 앱 오픈 광고의 쿨다운을 메모리에만 두고 있어서, OS가 백그라운드에서 앱을 종료하면 쿨다운이 초기화되고, 다음에 돌아왔을 때 예상치 못한 광고가 나왔습니다. 지금은 타임스탬프를 SharedPreferences에 저장합니다.
아무것도 바꾸지 않고 도입하기
앱마다 하드코딩된 숫자가 제각각이었습니다. 어떤 앱은 쿨다운 20분, 어떤 앱은 240분과 1800초. 첫 앱을 원격 값으로 바꾸기 전에, 서버에는 그 앱의 기존 상수를 넣어 두었습니다. 그래서 첫 출시에서 바뀐 건 숫자의 출처뿐이고 동작은 그대로였습니다. 조정은 그 뒤에 한 번에 하나씩, 의도를 갖고 했습니다.
허용 범위는 서버에서 잘라냅니다. 상한은 첫날에만 세 번 올렸습니다. 실제 앱들이 이미 예상보다 큰 값(간격 1800초, 쿨다운 360분, 간격 120초)을 쓰고 있었기 때문입니다. 하한은 저희 정책 기준선보다 위에 뒀습니다. AdMob은 숫자 한도를 공개하지 않으므로 이건 보수적인 자체 규칙입니다. 예를 들어 전면 광고 간격이 60초 미만이면 거부하고, 120초 미만이면 주의를 띄웁니다.
관리 쪽 안전장치
- 모든 쓰기는 커밋하기 전에 정책 검사를 거칩니다. 위반이면 롤백하고 409를 돌려줍니다(명시적인
override_policy플래그가 없는 한). - 모든 변경은 전후 스냅샷과 함께 기록되고, 클릭 한 번으로 되돌릴 수 있습니다.
- 시간대 일정은 트래픽 데이터가 7일 또는 실행 1,000회가 안 되면 켤 수 없습니다. 주말 이틀치 데이터로 만든 히트맵은 잡음이기 때문입니다.
- 앱을 지우려면 그 앱의 ID를 직접 입력해야 합니다.
서버에서 값을 바꿨을 때 실제로 일어난 일
아래는 모두 앱을 다시 빌드하지 않고 서버에서 한 변경입니다(첫 줄만 앱 업데이트 포함).
| 변경 | 결과 |
|---|---|
| 앱 오픈 광고 오클릭 대책(앱 업데이트로 배포), 2주 뒤 측정 | 확인한 앱×OS 조합 4개 모두 CTR 하락: 23.14%→17.29%, 17.39%→14.98%(p=0.0074), 7.07%→4.06%(p=0.0098), 5.82%→4.55%(p=0.0034). 실수로 누르는 일이 줄었다. 전면 광고 CTR은 개선되지 않음 |
| 전면 광고 닫기 전 탭 지연: 0 / 700 / 2500ms | 측정 가능한 효과 없음. 700ms로 되돌림 |
| 앱 8개에서 간격 240→900초, 세션당 상한 3→2 | 출시 없이 당일 적용. 전면 광고가 수익의 약 11%라 예상 감소폭이 작아 받아들임 |
| 한 앱에서 간격 900→1800초, 이어서 첫 대기 시간 60→300초 | 기간당 노출 94→66→49, CTR 57.4%→53.0%→28.6%. 효과는 주로 첫 대기 시간에서 나옴 |
마지막 줄에는 한계가 있습니다. 300초가 허용 범위의 최대라서, 그 앱에서는 이 레버를 다 썼습니다. 다음 개선에는 서버 값이 아니라 앱 변경이 필요합니다.
만들 만한 경우
| 상황 | 선택 |
|---|---|
| 앱이 한두 개, 값을 거의 안 바꿈 | 코드 안의 상수로 충분 |
| 원격으로 값만 바꾸면 됨 | Firebase Remote Config |
| 앱이 많고, 자주 조정하고, 실행 시각 데이터와 정책 검사도 원함 | 이런 작은 엔드포인트 |
엔드포인트 자체는 PHP 141줄이고, 관리 화면과 API가 약 1,100줄 더해집니다. 가치는 대부분 지루한 규칙에서 나왔습니다. 응답 모양을 바꾸지 않을 것, 실패하면 기본값을 돌려줄 것, 값은 캐시해 두고 다음 실행부터 쓸 것.
자주 묻는 질문
Firebase Remote Config로는 안 됐나요?
동작은 합니다. 다만 같은 요청에 두 가지 일을 더 시키고 싶었습니다. 시간대×요일 트래픽 히트맵(AdMob 보고서 API에는 시간 축이 없음)과, 값을 저장하기 전의 서버 쪽 정책 검사입니다. 자체 서버의 작은 엔드포인트로 둘 다 해결했고, firebase_remote_config는 앱에서 뺐습니다.
설정 서버가 죽으면 어떻게 되나요?
눈에 보이는 변화는 없습니다. 앱은 캐시한 값(없으면 코드 기본값)으로 시작하고, 뒤에서 5초 제한으로 가져오며, 예외를 던지지 않고, 새 값은 다음 실행부터 씁니다. 서버 오류 때도 같은 모양의 JSON으로 기본값을 돌려줍니다.
서버에서 설정을 바꿨더니 실제로 결과가 달라졌나요?
숫자로 달라졌습니다. 한 앱에서 서버 쪽 변경 두 단계(간격 늘리기, 이어서 첫 대기 시간 늘리기)로 기간당 전면 광고 노출이 94→66→49, CTR이 57.4%→53.0%→28.6%가 됐습니다. 탭 지연 실험(0·700·2500ms)은 효과가 없어 되돌렸습니다.