AIStudio에서 Admob을 추가시켜서 작업한 뒤, 안드로이드 스튜디오로 테스트를 할 때 빌드된 앱이 켜지지 않는 버그가 있을 수 있다.
이런 경우 대부분 AdMob App ID가 AndroidManifest에 없는 경우라던지, Vite에서 base 설정이 없는 경우다.
파일 폴더를 열어서 수정해주자.
1) AndroidManifest에 AdMob App ID 추가 (앱 즉시 종료 방지)
>> AdMob을 쓰면 JS 코드만으로는 부족하다. Android 네이티브 설정이 필수다.
📌 파일:
android/app/src/main/AndroidManifest.xml
📌 위치:
<application ...> 태그 내부
<application
...>
<!-- AdMob App ID 추가 (TEST) -->
<meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="ca-app-pub-...~..." /> // 테스트용 App ID 추가 :"ca-app-pub-3940256099942544~3347511713"
<activity ...>
...
</activity>
...
</application>
>> 이게 없으면 앱 실행 직후 크래시가 난다, 배너가 아예 안 뜨는 문제도 발생할 수 있음. 반드시 확인해 보기.
2) ) Vite 설정: base 경로 수정 (흰 화면 방지)
📌 파일: vite.config.ts
return {
base: './', //이거 추가.
server: { port: 3000, host: '0.0.0.0' },
plugins: [react()],
...
}
3) 빌드 산출물 반영
PowerShell에서 아래 복붙 후 엔터.
npm install
npm run build
npx cap sync android
4) 안드로이드 스튜디오 실행
npx cap open android
이제 다시 AdMob 배너를 앱 전체 공통으로 하단에 고정하고, adsRemoved 상태로 광고 표시/제거를 테스트해보자.
AIStudio에서 AdMob 배너를 붙였는데 App.tsx가 너무 무거워진 것 같아 나눠서 정리했다.
< 작업할 것 >
- 배너 광고는 앱 전체 공통 하단 고정
- adsRemoved 상태에 따라 false → 광고 표시 true → 광고 숨김
- 광고 로직은 App.tsx에서 분리하여 유지보수 가능한 구조로 만들기
- Android / Web 플랫폼 차이로 인한 크래시 방지
>> 광고는 네이티브(오버레이)로 뜨고, React(UI)는 그 광고를 제어만 하는 구조로 만들기.
폴더 정리
: React 코드 위치 vs Android 네이티브 코드 위치
Capacitor 앱에서:
- android/ 폴더 → Android 네이티브 코드(Gradle/Manifest/Kotlin)
- src/ 폴더 → React/Vite 웹 앱 코드(JS/TS/TSX)
React 코드는 절대 android/app/src 에 두지 않는다. Capacitor 구조에서 React는 항상 웹 앱이란다.
그래서 프로젝트 폴더에 아래처럼 src폴더를 추가해 줄 것이다. 그리고 나머지도 추가해 주자.
프로젝트 폴더/
├─ android/ # 네이티브(Android)
├─ src/ # React/Vite 코드
│ ├─ components/
│ │ └─ GlobalAdBanner.tsx # 전 앱 공통 배너 컨트롤러
│ ├─ services/
│ │ └─ ads/
│ │ └─ admob.ts # AdMob 제어 로직
│ └─ ...
├─ App.tsx # 루트 App (UI 중심)
├─ capacitor.config.ts
├─ vite.config.ts
└─ package.json
이제 GlobalAdBanner.tsx와 admob.ts를 만들어 주자.
GlobalAdBanner란?
: GlobalAdBanner.tsx 는 화면에 버튼/텍스트/레이아웃을 그리는 컴포넌트 X
▶ 광고 표시/숨김을 제어하는 “컨트롤러 컴포넌트” 다. 아래 코드를 복사 붙여 넣기 해서 만들어주자.
>> 역할 : 앱이 실행되면 자동으로 AdMob을 초기화하고, adsRemoved 값에 따라 show/hide를 실행하는 역할.
// src/components/GlobalAdBanner.tsx
import { useEffect } from "react";
import {
bindBannerListenersOnce,
initAdMobOnce,
showGlobalBanner,
hideGlobalBanner,
} from "../services/ads/admob";
/**
* ✅ 컨트롤러 컴포넌트
* - UI 렌더링 X (return null)
* - adsRemoved 상태로 배너 show/hide만 담당
*/
export default function GlobalAdBanner({ adsRemoved }: { adsRemoved: boolean }) {
// 1) 앱 시작 시 딱 1회: 리스너 등록 + AdMob 초기화
useEffect(() => {
bindBannerListenersOnce();
initAdMobOnce();
}, []);
// 2) adsRemoved 값이 바뀔 때마다: show/hide 실행
useEffect(() => {
const run = async () => {
if (adsRemoved) {
await hideGlobalBanner();
} else {
await showGlobalBanner();
}
};
run();
// (3) 핫리로드/언마운트 안정장치
return () => {
hideGlobalBanner();
};
}, [adsRemoved]);
// 배너는 네이티브 오버레이로 뜨므로 React UI는 렌더하지 않음
return null;
}
위의 return null을 통해
- React DOM에 아무것도 렌더 하지 않고
- useEffect()만 실행해서
- 네이티브 AdMob 배너를 띄우거나/숨긴다.
▶ 광고는 React가 그리는 게 아니라, Android 네이티브 레이어(WebView 위)에 오버레이로 표시되기 때문에 이 방식이 정석이다.
★ 왜 return null이 중요한가.
* 배너는 네이티브 오버레이로 뜸 → React DOM에 뭘 추가할 필요가 없음.
* return으로 <div> 같은 걸 렌더 하면 오히려 “배너가 웹 레이아웃 일부인 것처럼” 혼동만 생김.
* GlobalAdBanner는 “앱 실행 시 자동으로 동작하는 기능 모듈”로 보는 게 맞음.
AdMob 제어 로직은 services로 분리 (파일 위치 + 역할)
: 여기는 “실제로 AdMob 플러그인을 호출하는 코드”를 모아둔다.
- initialize를 중복 호출하지 않게 방지.
- 배너 이벤트 리스너를 중복 등록하지 않게 방지.
- show/hide 토글 시 Android에서 다시 안 뜨는 문제 해결을 위해 hide + remove / show 전 remove 패턴 적용.
// src/services/ads/admob.ts
import { Capacitor } from "@capacitor/core";
import {
AdMob,
BannerAdPluginEvents,
BannerAdOptions,
BannerAdPosition,
BannerAdSize,
} from "@capacitor-community/admob";
/**
* < Google TEST Ad Unit ID (Banner) >
* - App ID는 AndroidManifest.xml <application> 아래 meta-data로 넣어야 함
* - 여기서는 배너 ad unit id만 사용
*/
export const TEST_BANNER_ID = "ca-app-pub-3940256099942544/6300978111";
/** 내부 상태: 중복 init / 중복 listener 등록 방지 */
let didInit = false;
let didBind = false;
const isNative = () => Capacitor.getPlatform() !== "web";
/**
* < 리스너 1회만 등록 >
* - 리렌더링마다 addListener 붙으면 로그/이벤트가 중복되는 버그가 생김
*/
export function bindBannerListenersOnce() {
if (!isNative() || didBind) return;
AdMob.addListener(BannerAdPluginEvents.Loaded, () => {
console.log("admob: banner_loaded_success");
});
AdMob.addListener(BannerAdPluginEvents.FailedToLoad, (info) => {
console.error("admob: banner_load_failed", info);
});
didBind = true;
}
/**
* < AdMob 초기화 1회만 >
*/
export async function initAdMobOnce() {
if (!isNative() || didInit) return;
console.log("admob: admob_init_start");
try {
await AdMob.initialize();
didInit = true;
console.log("admob: admob_init_complete");
} catch (e) {
console.error("admob: admob_init_failed", e);
}
}
/**
* < 전 앱 공통 하단 배너 표시 >
* - Adaptive 배너 사용 (기기 폭에 맞게)
* - isTesting: true (테스트 배너)
*/
export async function showGlobalBanner(adId: string = TEST_BANNER_ID) {
if (!isNative()) return;
console.log("admob: showBanner_called");
const options: BannerAdOptions = {
adId,
adSize: BannerAdSize.ADAPTIVE_BANNER,
position: BannerAdPosition.BOTTOM_CENTER,
margin: 0,
isTesting: true,
};
try {
await AdMob.showBanner(options);
console.log("admob: showBanner_success");
} catch (e) {
console.error("admob: showBanner_exception", e);
}
}
/**
* < 배너 숨김 >
* - removeBanner는 show/hide 반복 시 꼬이는 케이스가 있어서 테스트 단계에서는 hide만 권장
*/
export async function hideGlobalBanner() {
if (!isNative()) return;
console.log("admob: hideBanner_called");
try {
await AdMob.hideBanner();
console.log("admob: hideBanner_success");
} catch (e) {
console.error("admob: hideBanner_exception", e);
}
}
▶ 광고에서 가장 중요한 건 중복 호출 방지와 플랫폼 가드.
const isNative = () => Capacitor.getPlatform() !== "web";
▶ 초기화/ 리스너는 1회만 하기.
let didInit = false;
let didBind = false;
- 리렌더링마다 initialize() / addListener() 호출하면 버그 발생.
- 반드시 내부 플래그로 막아야 한다.
**** 만약 위의 admob 코드 사용할 예정이라면 아래쪽의 admob 코드 사용하자. 추가됐다. ****
App.tsx에서는 “한 줄로 전역 배너 연결”만 한다 (삽입 위치)
App.tsx에서 광고를 직접 제어하지 않게 해야 한다. 전역 배너 컨트롤러만 호출하자. AdMob에 관련된 건 삭제해 주고 새로 만든 GlobalAdBanner를 import 해주고 한 줄만 추가해주면 된다.
import GlobalAdBanner from "./src/components/GlobalAdBanner";<GlobalAdBanner adsRemoved={settings.adsRemoved} /> // App 컴포넌트 return 최상단에 한줄 추가
// < 추가 위치 > div 사이에 껴 넣어 주자.
return (
<div className="...">
<GlobalAdBanner adsRemoved={settings.adsRemoved} />
{/* 기존 UI는 전부 그대로 */}
...
</div>
);
- React 컴포넌트 트리 상 App 최상단에서 한 번만 마운트되면 되는 역할이므로 루트 div 바로 아래에 두는 게 가장 안전하다.
테스트 광고 토글 문제
: hide 하고 다시 show 할 때 안 뜨는 오류.
내 경우 발생했던 오류였다. adsRemoved=true로 hide는 되는데, 다시 false로 show 했는데 배너가 안 뜨는 현상.
▶ 해결 방법 :
우선 admob.ts 를 켜주자.
Android에서는 hide만 하면 내부 상태가 꼬일 수 있어서 hideBanner 후 removeBanner까지 호출하는 패턴으로 가야 함.
- hide 시: hideBanner() → removeBanner()
- show 시: removeBanner()(안전용) → showBanner()
// src/services/ads/admob.ts
import { Capacitor } from "@capacitor/core";
import {
AdMob,
BannerAdPluginEvents,
BannerAdOptions,
BannerAdPosition,
BannerAdSize,
} from "@capacitor-community/admob";
/**
* < Google TEST Ad Unit ID (Banner) >
* - App ID는 AndroidManifest.xml <application> 아래 meta-data로 넣어야 함
* - 여기서는 배너 ad unit id만 사용
*/
export const TEST_BANNER_ID = "ca-app-pub-3940256099942544/6300978111";
/** 내부 상태: 중복 init / 중복 listener 등록 방지 */
let didInit = false;
let didBind = false;
const isNative = () => Capacitor.getPlatform() !== "web";
/**
* < 리스너 1회만 등록 >
* - 리렌더링마다 addListener 붙으면 로그/이벤트가 중복되는 버그가 생김
*/
export function bindBannerListenersOnce() {
if (!isNative() || didBind) return;
AdMob.addListener(BannerAdPluginEvents.Loaded, () => {
console.log("admob: banner_loaded_success");
});
AdMob.addListener(BannerAdPluginEvents.FailedToLoad, (info) => {
console.error("admob: banner_load_failed", info);
});
didBind = true;
}
/**
* < AdMob 초기화 1회만 >
*/
export async function initAdMobOnce() {
if (!isNative() || didInit) return;
console.log("admob: admob_init_start");
try {
await AdMob.initialize();
didInit = true;
console.log("admob: admob_init_complete");
} catch (e) {
console.error("admob: admob_init_failed", e);
}
}
/**
* < 전 앱 공통 하단 배너 표시 >
* - Adaptive 배너 사용 (기기 폭에 맞게)
* - isTesting: true (테스트 배너)
*/
export async function showGlobalBanner(adId: string = TEST_BANNER_ID) {
if (!isNative()) return;
console.log("admob: showBanner_called");
const options: BannerAdOptions = {
adId,
adSize: BannerAdSize.ADAPTIVE_BANNER,
position: BannerAdPosition.BOTTOM_CENTER,
margin: 0,
isTesting: true,
};
try {
// 혹시 남아있는 배너 인스턴스가 있으면 제거 시도 (에러 나도 무시)
await AdMob.removeBanner().catch(() => {});
await AdMob.showBanner(options);
console.log("admob: showBanner_success");
} catch (e) {
console.error("admob: showBanner_exception", e);
}
}
/**
* < 배너 숨김 >
* - removeBanner는 show/hide 반복 시 꼬이는 케이스가 있어서 테스트 단계에서는 hide만 권장
*/
export async function hideGlobalBanner() {
if (!isNative()) return;
console.log("admob: hideBanner_called");
try {
// 1) 일단 숨김
await AdMob.hideBanner();
// 2) 그리고 완전히 제거 (토글 복구 안정화)
await AdMob.removeBanner();
console.log("admob: hide/remove_banner_success");
} catch (e) {
console.error("admob: hide/remove_banner_exception", e);
}
}
이 패턴 적용 후 정상 복구 확인.
마지막으로 powerShell에서 실제 반영 명령 하기
npm run build
npx cap sync android
그리고 다시 안드로이드 스튜디오 열어주고 실행시켜 보면 잘 되는 것을 확인할 수 있다.
npx cap open android
'게임' 카테고리의 다른 글
| Android Studio ) 키스토어 설정, AAB 파일 생성하기. (feat. alias 잊었을 때 확인하는 방법) (0) | 2026.01.19 |
|---|---|
| Capacitor + React(Vite) Android ) Google Play Console 인앱 결제 연결. (0) | 2026.01.16 |
| AIStudio ) PowerShell로 Android( Capacitor ) 세팅 끝내기 (1) | 2026.01.16 |
| Capacitor ) AdMob 광고 추가 + 광고 제거(영구) 인앱결제 구현하기 (초보자용) (0) | 2026.01.12 |
| AIStudio ) 업데이트 시킬 때 설정 파일. (0) | 2026.01.12 |
