인앱 결제 없이 수익화하기 — 보상형 광고 기반 프리미엄 콘텐츠 설계 (4/5)
1. 들어가며: 수익화 전략을 고민하다
개인 개발자에게 수익화는 피할 수 없는 주제입니다. 아무리 좋은 앱을 만들어도, 지속적으로 유지보수하려면 최소한의 수익 구조가 필요합니다.
NeonBoard 1.0에서는 단순히 배너 광고만 넣어두었습니다. 수익은 미미했고, 솔직히 말하면 수익화에 대해 깊이 고민하지 않았습니다. 2.0을 설계하면서 본격적으로 두 가지 선택지를 놓고 고민했습니다. 인앱 결제(IAP)와 보상형 광고(Rewarded Ad).
인앱 결제는 안정적인 수익을 기대할 수 있지만, 결제 시스템 구현 복잡도, 환불 처리, 구독 관리, 그리고 무엇보다 **"유료 앱에 대한 사용자의 기대치"**가 부담이었습니다. 한 번이라도 결제한 사용자는 버그 하나에도 민감하게 반응합니다.
반면 보상형 광고는 "광고를 시청하면 프리미엄 기능을 해금"하는 방식으로, 사용자에게 선택권을 줍니다. 돈을 내지 않아도 모든 기능을 사용할 수 있되, 약간의 시간을 투자해야 합니다. 개인 개발자 입장에서도 결제 시스템을 관리할 필요가 없어 유지보수 부담이 훨씬 적습니다.
최종적으로 NeonBoard 2.0은 100% 광고 기반 수익화를 선택했습니다. 배너 광고로 기본 수익을 확보하고, 보상형 광고로 프리미엄 콘텐츠 해금과 디스플레이 모드 진입 게이팅을 처리합니다. 이번 글에서는 이 수익화 구조를 기술적으로 어떻게 설계하고 구현했는지를 다루겠습니다.
2. 프리미엄 콘텐츠 설계: 무엇을 잠그고, 무엇을 풀어줄 것인가
수익화에서 가장 중요한 결정은 **"무료와 유료의 경계선을 어디에 그을 것인가"**입니다. 너무 많이 잠그면 사용자가 떠나고, 너무 적게 잠그면 광고를 볼 동기가 없어집니다.
NeonBoard 2.0의 콘텐츠 구성과 잠금 비율은 다음과 같습니다.
| 카테고리 | 전체 | 무료 | 프리미엄(잠금) | 비율 | |---------|------|------|-------------|------| | 폰트 | 19종 | 9종 | 10종 | 약 53% 잠금 | | 애니메이션 | 6종 | 3종 | 3종 | 50% 잠금 | | 배경 패턴 | 4종 | 2종 | 2종 | 50% 잠금 |
핵심 원칙은 **"무료만으로도 완전한 전광판을 만들 수 있어야 한다"**는 것이었습니다. 무료 폰트 9종에는 한글과 영문 모두를 커버하는 대표적인 서체들이 포함되어 있고, 무료 애니메이션 3종(없음, 블링크, 페이드)만으로도 충분히 매력적인 전광판을 구현할 수 있습니다.
프리미엄 콘텐츠는 "있으면 더 좋은" 추가 옵션입니다. 개성 있는 손글씨 폰트, 타이핑/웨이브 같은 화려한 애니메이션, 별 패턴이나 움직이는 라인 같은 배경 효과 등이 여기에 해당합니다. 사용자가 기본 기능에 만족한 뒤, "더 멋지게 만들고 싶다"는 욕구가 생겼을 때 자연스럽게 광고 시청으로 연결되는 흐름을 의도했습니다.
3. 두 가지 광고 게이팅 메커니즘
NeonBoard 2.0에는 보상형 광고가 등장하는 지점이 두 곳 있습니다. 각각 다른 목적과 메커니즘을 가지고 있습니다.
메커니즘 1: 디스플레이 모드 진입 카운터
에디터에서 "시작" 버튼을 눌러 전광판을 풀스크린으로 실행할 때마다 내부 카운터가 증가합니다. 매 3회째 실행 시 보상형 광고를 시청해야 디스플레이 모드에 진입할 수 있습니다.
// 의사코드: 디스플레이 진입 게이팅
function onStartButtonPressed():
shouldShowAd = (displayLaunchCount % 3 == 2) // 먼저 체크
appSettings.displayLaunchCount += 1 // 그 다음 증가
saveCountToHive() // 영속화 (앱 재시작해도 카운터 유지)
if shouldShowAd:
showRewardAdDialog()
// 광고 시청 완료 후 → 디스플레이 모드 진입
else:
navigateToDisplay() // 바로 진입
여기서 중요한 디테일이 세 가지 있습니다.
첫째, 광고 표시 여부를 판단한 후에 카운터를 증가시킵니다. % 3 == 2 조건을 먼저 읽고, 그 다음에 카운터를 올립니다. 이 순서가 뒤바뀌면 광고 표시 시점이 한 칸씩 어긋나게 됩니다.
둘째, 카운터를 Hive에 영속화합니다. 앱을 껐다 켜도 카운터가 리셋되지 않습니다. 이렇게 하지 않으면 앱을 재시작하는 것만으로 광고를 회피할 수 있게 됩니다.
셋째, 카운터를 광고 표시 전에 증가시킵니다. 광고 표시 중 앱이 크래시하거나 사용자가 강제 종료해도, 카운터는 이미 올라간 상태입니다. 다음 실행에서는 정상적으로 광고 없이 진입할 수 있으므로, 사용자가 손해를 보지 않으면서도 카운터 우회가 방지됩니다.
메커니즘 2: 프리미엄 콘텐츠 개별 해금
에디터에서 잠긴 폰트, 애니메이션, 배경 패턴을 탭하면 해금 다이얼로그가 표시됩니다. 보상형 광고를 시청하면 해당 콘텐츠가 영구적으로 해금됩니다.
// 의사코드: 프리미엄 콘텐츠 해금 흐름
function onLockedItemTapped(contentId, contentType):
showUnlockDialog(
message: "광고를 시청하면 이 콘텐츠를 영구적으로 해금할 수 있습니다"
)
if userAccepts:
success = await showRewardAd()
if success:
appSettings.unlock(contentType, contentId)
saveToHive() // 해금 상태 영속화
// 잠금 아이콘 즉시 사라짐
"영구 해금"이라는 점이 핵심입니다. 한 번 광고를 보면 그 폰트나 애니메이션은 영원히 사용할 수 있습니다. 매번 광고를 보게 하면 사용자 경험이 극도로 나빠지기 때문입니다. 이 결정은 단기 수익을 포기하는 것이지만, 장기적으로 사용자의 신뢰와 리텐션을 유지하는 데 훨씬 유리하다고 판단했습니다.
4. 광고 Provider 아키텍처: 3개의 Provider 협업
광고 관련 로직은 3개의 Riverpod Provider가 역할을 나누어 처리합니다.
광고 시스템 아키텍처:
adProvider (광고 로딩 + 표시)
↑ 참조
unlockProvider (해금 요청 조율)
↑ 참조 ↑ 참조
appSettingsProvider (해금 상태 저장 + 카운터 관리)
adProvider: 광고의 생명주기 관리
adProvider는 보상형 광고의 프리로드(preload)와 표시를 담당합니다.
// 의사코드: 광고 생명주기
class AdNotifier:
rewardedAd: RewardedAd?
isLoading: boolean
function init():
preloadAd() // 생성 즉시 다음 광고 미리 로드
function preloadAd():
isLoading = true
RewardedAd.load(
adUnitId: AdConfig.rewardedAdUnitId,
onLoaded: (ad) => { rewardedAd = ad; isLoading = false },
onFailed: (error) => { isLoading = false }
)
function showRewardAd():
if rewardedAd == null: return false
rewardedAd.show(
onRewarded: () => { /* 보상 지급 */ },
onDismissed: () => { preloadAd() } // 보여준 직후 다음 광고 로드
)
return true
핵심은 프리로딩입니다. 사용자가 "시작" 버튼을 누르는 순간 광고를 로드하면, 네트워크 상태에 따라 수 초의 대기 시간이 발생합니다. 대신 에디터 페이지에 진입하는 시점에 미리 로드해 두면, 사용자가 버튼을 누를 때 즉시 광고를 표시할 수 있습니다.
광고가 표시된 후(onDismissed)에는 즉시 다음 광고를 로드합니다. 사용자가 에디터에서 여러 번 시작/편집을 반복할 수 있기 때문입니다.
unlockProvider: 해금 흐름 조율자
unlockProvider는 adProvider와 appSettingsProvider 사이의 조율자 역할을 합니다. UI에서 잠긴 콘텐츠를 탭하면 unlockProvider가 호출되고, 이것이 adProvider에게 광고 표시를 요청하고, 보상이 지급되면 appSettingsProvider에게 해금을 요청합니다.
이 조율자를 별도로 분리한 이유는 관심사의 분리 때문입니다. adProvider는 광고의 로드/표시만 알면 되고, appSettingsProvider는 데이터의 저장만 알면 됩니다. "광고를 보고 → 콘텐츠를 해금한다"는 비즈니스 로직은 unlockProvider가 전담합니다.
5. AdConfig: 플랫폼과 빌드 모드를 고려한 광고 설정
광고 SDK를 다루면서 실수하기 쉬운 부분이 광고 단위 ID(Ad Unit ID) 관리입니다. iOS와 Android의 ID가 다르고, 개발 중에는 테스트 ID를 사용해야 하며, 프로덕션에서는 실제 ID를 사용해야 합니다. 이 4가지 조합을 코드 곳곳에 하드코딩하면 실수가 불가피합니다.
// 의사코드: 플랫폼/빌드 모드 인식 광고 설정
abstract class AdConfig:
static function getBannerAdUnitId():
if isDebugMode:
return TEST_BANNER_ID // 플랫폼 무관 테스트 ID
if isIOS:
return IOS_PRODUCTION_BANNER_ID
return ANDROID_PRODUCTION_BANNER_ID
static function getRewardedAdUnitId():
if isDebugMode:
return TEST_REWARDED_ID
if isIOS:
return IOS_PRODUCTION_REWARDED_ID
return ANDROID_PRODUCTION_REWARDED_ID
abstract final class로 선언하여 인스턴스화를 방지하고, 모든 ID를 한 파일에서 관리합니다. 빌드 모드에 따라 자동으로 테스트/프로덕션 ID가 전환되므로, 개발 중에 실수로 실제 광고를 노출하거나, 배포 시 테스트 ID가 포함되는 사고를 방지할 수 있습니다.
6. 배너 광고와 ShellRoute: 구조적 광고 배치
이 부분은 시리즈 1편에서도 잠깐 다루었지만, 수익화 관점에서 좀 더 깊이 살펴보겠습니다.
배너 광고는 화면 하단에 항상 표시되지만, 디스플레이 모드에서만은 절대 표시되어서는 안 됩니다. 전광판 모드에서 하단에 광고가 보이면 본래 목적이 무너지기 때문입니다.
이 요구사항을 GoRouter의 ShellRoute로 구조적으로 해결했습니다.
// 의사코드: ShellRoute 기반 배너 광고 배치
GoRouter(
routes: [
// ShellRoute: 배너 광고가 포함된 레이아웃
ShellRoute(
builder: (context, state, child) =>
Column(
children: [
Expanded(child: child), // 실제 페이지 내용
BannerAdWidget(), // 하단 배너 광고
]
),
routes: [
home, // 배너 O
editor, // 배너 O
settings, // 배너 O
]
),
// ShellRoute 바깥: 배너 광고 없음
displayRoute, // 배너 X (풀스크린)
]
)
각 화면의 코드에는 광고 관련 로직이 단 한 줄도 없습니다. ShellRoute가 구조적으로 광고를 주입하고, ShellRoute 바깥의 라우트는 자동으로 광고가 제외됩니다. 이 구조 덕분에 새로운 화면을 추가할 때도, ShellRoute 안에 넣으면 광고가 자동으로 따라오고, 바깥에 넣으면 자동으로 제외됩니다.
BannerAdWidget의 자체 생명주기 관리
배너 광고 위젯은 자체적으로 광고 로딩 상태를 관리합니다. 외부 SizedBox가 배너의 표준 크기(320x50)를 항상 확보하고 있기 때문에, 광고 로드 전후로 레이아웃 높이가 변하지 않습니다. 내부 콘텐츠만 로드 완료 시 실제 광고로 교체되는 구조입니다. 이렇게 하면 광고가 로드되면서 갑자기 레이아웃이 밀리는 "점프" 현상을 방지할 수 있습니다.
광고 로드 실패 시에도 에러를 표시하지 않고 조용히 빈 공간을 유지합니다. 네트워크가 불안정한 환경에서도 사용자 경험이 깨지지 않도록 하기 위함입니다.
7. 개발 과정의 챌린지
보상형 광고 타이밍: 언제 다이얼로그를 닫을 것인가
보상형 광고의 흐름은 단순해 보이지만, 실제로는 여러 콜백의 타이밍을 세밀하게 제어해야 합니다.
사용자가 시작 버튼 탭
→ 광고 다이얼로그 표시 (배리어 닫기 불가)
→ 보상형 광고 표시
→ 사용자가 광고 시청
→ onRewardEarned 콜백 (보상 지급)
→ onAdDismissed 콜백 (광고 닫힘)
→ 다이얼로그 닫기
→ 디스플레이 모드 진입
초기에는 onRewardEarned에서 바로 다이얼로그를 닫고 디스플레이 모드로 전환했는데, onRewardEarned는 광고가 아직 화면에 표시된 상태에서 호출됩니다. 이때 화면 전환을 시도하면 광고 SDK와 네비게이션이 충돌할 수 있습니다.
해결책은 onAdDismissed 콜백에서 화면 전환을 처리하는 것이었습니다. 광고가 완전히 닫힌 후에야 안전하게 다음 화면으로 이동할 수 있습니다. 그리고 다이얼로그의 barrierDismissible을 false로 설정하여, 사용자가 다이얼로그 바깥을 탭해서 닫는 것을 방지했습니다.
광고 로드 실패 시의 폴백
네트워크가 불안정하거나 광고 인벤토리가 없는 상황에서 보상형 광고 로드가 실패할 수 있습니다. 이때 사용자가 아예 디스플레이 모드에 진입하지 못하면 앱 사용 자체가 불가능해집니다.
폴백 전략은 간단합니다. 광고 로드에 실패하면 광고 없이 디스플레이 모드에 진입하도록 허용합니다. 수익보다 사용자 경험이 우선입니다. 광고가 실패한 것은 사용자의 잘못이 아니므로, 그 대가를 사용자에게 전가하지 않는 것이 원칙입니다.
8. 폰트 라이선스 관리
프리미엄 콘텐츠로 19종의 폰트를 제공하면서, 라이선스 관리도 중요한 문제였습니다. 상업적 사용이 가능한 폰트만 포함해야 하기 때문입니다.
NeonBoard 2.0의 모든 폰트는 OFL(SIL Open Font License), MIT, 또는 Apache 2.0 라이선스만 허용합니다. 주로 Google Fonts에서 검증된 서체를 사용했으며, FontRegistry에 각 폰트의 메타데이터(이름, 한국어 지원 여부, 잠금 상태)를 중앙 관리합니다.
라이선스 의무를 이행하기 위해, 설정 화면에서 Flutter 내장 showLicensePage()를 호출하여 전체 라이선스 텍스트를 사용자에게 제공합니다.
9. 마치며
수익화 구조를 설계하면서 가장 많이 고민한 것은 **"사용자 경험과 수익 사이의 균형"**이었습니다.
보상형 광고는 인앱 결제보다 사용자 진입 장벽이 낮지만, 자칫하면 광고가 너무 자주 등장하여 사용자를 떠나게 만들 수 있습니다. "3회째마다 한 번"이라는 빈도, "영구 해금"이라는 보상 방식, "광고 실패 시 무조건 통과"라는 폴백 전략은 모두 이 균형을 맞추기 위한 결정이었습니다.
기술적으로는 3개의 Provider 분리(adProvider, unlockProvider, appSettingsProvider)와 GoRouter ShellRoute 기반의 구조적 광고 배치가 핵심이었습니다. 특히 ShellRoute는 "광고를 보여줄지 말지"를 조건문이 아닌 구조로 해결한 깔끔한 접근이었다고 생각합니다.
다음 글, 시리즈의 마지막인 5편에서는 다국어 지원, 테마 시스템, OLED 번인 방지 등 앱의 완성도를 높인 마무리 작업들을 다루겠습니다.